Spaces:
Running
Running
RemiFabre
commited on
Commit
·
e3fb530
0
Parent(s):
First commit, wip
Browse files- .gitignore +170 -0
- README.md +25 -0
- Theremini/__init__.py +3 -0
- Theremini/main.py +163 -0
- index.html +95 -0
- pyproject.toml +20 -0
- style.css +175 -0
.gitignore
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# based on https://github.com/github/gitignore/blob/main/Python.gitignore
|
| 2 |
+
|
| 3 |
+
.vscode/
|
| 4 |
+
|
| 5 |
+
# Byte-compiled / optimized / DLL files
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.py[cod]
|
| 8 |
+
*$py.class
|
| 9 |
+
|
| 10 |
+
# C extensions
|
| 11 |
+
*.so
|
| 12 |
+
|
| 13 |
+
# Distribution / packaging
|
| 14 |
+
.Python
|
| 15 |
+
build/
|
| 16 |
+
develop-eggs/
|
| 17 |
+
dist/
|
| 18 |
+
downloads/
|
| 19 |
+
eggs/
|
| 20 |
+
.eggs/
|
| 21 |
+
lib/
|
| 22 |
+
lib64/
|
| 23 |
+
parts/
|
| 24 |
+
sdist/
|
| 25 |
+
var/
|
| 26 |
+
wheels/
|
| 27 |
+
share/python-wheels/
|
| 28 |
+
*.egg-info/
|
| 29 |
+
.installed.cfg
|
| 30 |
+
*.egg
|
| 31 |
+
MANIFEST
|
| 32 |
+
*.zip
|
| 33 |
+
|
| 34 |
+
# PyInstaller
|
| 35 |
+
# Usually these files are written by a python script from a template
|
| 36 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 37 |
+
*.manifest
|
| 38 |
+
*.spec
|
| 39 |
+
|
| 40 |
+
# Installer logs
|
| 41 |
+
pip-log.txt
|
| 42 |
+
pip-delete-this-directory.txt
|
| 43 |
+
|
| 44 |
+
# Unit test / coverage reports
|
| 45 |
+
htmlcov/
|
| 46 |
+
.tox/
|
| 47 |
+
.nox/
|
| 48 |
+
.coverage
|
| 49 |
+
.coverage.*
|
| 50 |
+
.cache
|
| 51 |
+
nosetests.xml
|
| 52 |
+
coverage.xml
|
| 53 |
+
*.cover
|
| 54 |
+
*.py,cover
|
| 55 |
+
.hypothesis/
|
| 56 |
+
.pytest_cache/
|
| 57 |
+
cover/
|
| 58 |
+
|
| 59 |
+
# Translations
|
| 60 |
+
*.mo
|
| 61 |
+
*.pot
|
| 62 |
+
|
| 63 |
+
# Django stuff:
|
| 64 |
+
*.log
|
| 65 |
+
local_settings.py
|
| 66 |
+
db.sqlite3
|
| 67 |
+
db.sqlite3-journal
|
| 68 |
+
|
| 69 |
+
# Flask stuff:
|
| 70 |
+
instance/
|
| 71 |
+
.webassets-cache
|
| 72 |
+
|
| 73 |
+
# Scrapy stuff:
|
| 74 |
+
.scrapy
|
| 75 |
+
|
| 76 |
+
# Sphinx documentation
|
| 77 |
+
docs/_build/
|
| 78 |
+
|
| 79 |
+
# PyBuilder
|
| 80 |
+
.pybuilder/
|
| 81 |
+
target/
|
| 82 |
+
|
| 83 |
+
# Jupyter Notebook
|
| 84 |
+
.ipynb_checkpoints
|
| 85 |
+
|
| 86 |
+
# IPython
|
| 87 |
+
profile_default/
|
| 88 |
+
ipython_config.py
|
| 89 |
+
|
| 90 |
+
# pyenv
|
| 91 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 92 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 93 |
+
# .python-version
|
| 94 |
+
|
| 95 |
+
# pipenv
|
| 96 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 97 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 98 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 99 |
+
# install all needed dependencies.
|
| 100 |
+
#Pipfile.lock
|
| 101 |
+
|
| 102 |
+
# poetry
|
| 103 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 104 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 105 |
+
# commonly ignored for libraries.
|
| 106 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 107 |
+
#poetry.lock
|
| 108 |
+
|
| 109 |
+
# pdm
|
| 110 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 111 |
+
#pdm.lock
|
| 112 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 113 |
+
# in version control.
|
| 114 |
+
# https://pdm.fming.dev/#use-with-ide
|
| 115 |
+
.pdm.toml
|
| 116 |
+
|
| 117 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 118 |
+
__pypackages__/
|
| 119 |
+
|
| 120 |
+
# Celery stuff
|
| 121 |
+
celerybeat-schedule
|
| 122 |
+
celerybeat.pid
|
| 123 |
+
|
| 124 |
+
# SageMath parsed files
|
| 125 |
+
*.sage.py
|
| 126 |
+
|
| 127 |
+
# Environments
|
| 128 |
+
.env
|
| 129 |
+
.venv
|
| 130 |
+
env/
|
| 131 |
+
venv/
|
| 132 |
+
ENV/
|
| 133 |
+
env.bak/
|
| 134 |
+
venv.bak/
|
| 135 |
+
|
| 136 |
+
# Spyder project settings
|
| 137 |
+
.spyderproject
|
| 138 |
+
.spyproject
|
| 139 |
+
|
| 140 |
+
# Rope project settings
|
| 141 |
+
.ropeproject
|
| 142 |
+
|
| 143 |
+
# mkdocs documentation
|
| 144 |
+
/site
|
| 145 |
+
|
| 146 |
+
# mypy
|
| 147 |
+
.mypy_cache/
|
| 148 |
+
.dmypy.json
|
| 149 |
+
dmypy.json
|
| 150 |
+
|
| 151 |
+
# Pyre type checker
|
| 152 |
+
.pyre/
|
| 153 |
+
|
| 154 |
+
# pytype static type analyzer
|
| 155 |
+
.pytype/
|
| 156 |
+
|
| 157 |
+
# Cython debug symbols
|
| 158 |
+
cython_debug/
|
| 159 |
+
|
| 160 |
+
# PyCharm
|
| 161 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 162 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 163 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 164 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 165 |
+
#.idea/
|
| 166 |
+
|
| 167 |
+
# generate documentation
|
| 168 |
+
docs/*.html
|
| 169 |
+
docs/*.js
|
| 170 |
+
docs/example/
|
README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Theremini
|
| 3 |
+
emoji: 🎛️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Play Reachy Mini like a theremin?!
|
| 9 |
+
tags:
|
| 10 |
+
- reachy_mini
|
| 11 |
+
- music
|
| 12 |
+
- theremin
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Overview
|
| 16 |
+
|
| 17 |
+
Theremini turns Reachy Mini into a music instrument. The robot's head roll sets the pitch, head vertical translation maps to the amplitude, and the right antenna selects the current instrument (FluidSynth GM programs).
|
| 18 |
+
|
| 19 |
+
## Controls
|
| 20 |
+
|
| 21 |
+
| Control | Range | Target |
|
| 22 |
+
|--------------------|---------------------------|--------------------|
|
| 23 |
+
| Head roll | -60° … +60° | MIDI notes 48–84 |
|
| 24 |
+
| Head Z translation | -30 mm … +5 mm | Amplitude 0–100 % |
|
| 25 |
+
| Right antenna | -π/3 … +π/3 radians | Instrument program |
|
Theremini/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .main import Theremini
|
| 2 |
+
|
| 3 |
+
__all__ = ["Theremini"]
|
Theremini/main.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import contextlib
|
| 4 |
+
import io
|
| 5 |
+
import math
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 12 |
+
from scamp import Session
|
| 13 |
+
from scipy.spatial.transform import Rotation as R
|
| 14 |
+
|
| 15 |
+
# ────────────────── mapping constants ───────────────────────────────
|
| 16 |
+
ROLL_DEG_RANGE = (-60, 60) # head roll span
|
| 17 |
+
NOTE_MIDI_RANGE = (48, 84) # C3–C6
|
| 18 |
+
Z_MM_RANGE = (-30, 5) # farther → quieter
|
| 19 |
+
AMP_RANGE = (0.0, 1.0) # mute…full
|
| 20 |
+
RIGHT_ANT_RANGE = (-math.pi / 3, math.pi / 3) # raw radians → program index
|
| 21 |
+
|
| 22 |
+
# ────────────────── available instruments (subset of GM bank) ──────
|
| 23 |
+
AVAILABLE_PARTS = [
|
| 24 |
+
"choir_aahs",
|
| 25 |
+
"orchestra_hit",
|
| 26 |
+
"taiko_drum",
|
| 27 |
+
"trumpet",
|
| 28 |
+
"french_horn",
|
| 29 |
+
"soundtrack",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
NOTE_NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def midi_to_name(midi_note: int) -> str:
|
| 36 |
+
return NOTE_NAMES[midi_note % 12] + str(midi_note // 12 - 1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _aff(x: float, a: float, b: float, y0: float, y1: float) -> float:
|
| 40 |
+
if x <= a:
|
| 41 |
+
return y0
|
| 42 |
+
if x >= b:
|
| 43 |
+
return y1
|
| 44 |
+
return (x - a) * (y1 - y0) / (b - a) + y0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _send_cc(instr: Any, ctrl: int, val: int) -> None:
|
| 48 |
+
if hasattr(instr, "cc"):
|
| 49 |
+
instr.cc(ctrl, val)
|
| 50 |
+
elif hasattr(instr, "send_midi_cc"):
|
| 51 |
+
instr.send_midi_cc(ctrl, val / 127)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _dash_bar(val: float, lo: float, hi: float, width: int = 28) -> str:
|
| 55 |
+
ratio = max(0.0, min((val - lo) / (hi - lo), 1.0))
|
| 56 |
+
filled = int(ratio * width)
|
| 57 |
+
colour = "32" if ratio < 0.5 else "33" if ratio < 0.8 else "31"
|
| 58 |
+
bar = f"\x1b[{colour}m{'█' * filled}{'-' * (width - filled)}\x1b[0m"
|
| 59 |
+
return f"{val:8.2f} |{bar}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Theremini(ReachyMiniApp):
|
| 63 |
+
"""Theremin-like Reachy Mini app driven by head pose and right antenna."""
|
| 64 |
+
|
| 65 |
+
custom_app_url: str | None = None
|
| 66 |
+
|
| 67 |
+
def __init__(self) -> None:
|
| 68 |
+
super().__init__()
|
| 69 |
+
self._session = Session(max_threads=1024)
|
| 70 |
+
self._parts_cache: dict[int, Any] = {}
|
| 71 |
+
self._theremin = self._get_part_for_prog(0)
|
| 72 |
+
self._note_handle: Any | None = None
|
| 73 |
+
self._current_pitch: int | None = None
|
| 74 |
+
self._current_prog: int | None = None
|
| 75 |
+
|
| 76 |
+
def _get_part_for_prog(self, prog: int):
|
| 77 |
+
if prog not in self._parts_cache:
|
| 78 |
+
buf = io.StringIO()
|
| 79 |
+
with contextlib.redirect_stdout(buf):
|
| 80 |
+
name = AVAILABLE_PARTS[prog]
|
| 81 |
+
self._parts_cache[prog] = self._session.new_part(name)
|
| 82 |
+
return self._parts_cache[prog]
|
| 83 |
+
|
| 84 |
+
def _stop_note(self) -> None:
|
| 85 |
+
if self._note_handle:
|
| 86 |
+
self._note_handle.end()
|
| 87 |
+
self._note_handle = None
|
| 88 |
+
self._current_pitch = None
|
| 89 |
+
|
| 90 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
|
| 91 |
+
print("Roll → Pitch\nZ → Volume\nRight antenna → Instrument\n")
|
| 92 |
+
reachy_mini.disable_motors()
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
while not stop_event.is_set():
|
| 96 |
+
_, antennas = reachy_mini.get_current_joint_positions()
|
| 97 |
+
pose = reachy_mini.get_current_head_pose()
|
| 98 |
+
|
| 99 |
+
trans_mm = pose[:3, 3] * 1_000
|
| 100 |
+
roll_deg = math.degrees(R.from_matrix(pose[:3, :3]).as_euler("xyz")[0])
|
| 101 |
+
|
| 102 |
+
target_pitch = int(round(_aff(roll_deg, *ROLL_DEG_RANGE, *NOTE_MIDI_RANGE)))
|
| 103 |
+
amp = max(0.0, min(_aff(trans_mm[2], *Z_MM_RANGE, *AMP_RANGE), 1.0))
|
| 104 |
+
prog = int(
|
| 105 |
+
_aff(antennas[1], *RIGHT_ANT_RANGE, 0, len(AVAILABLE_PARTS) - 0.0001)
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if prog != self._current_prog:
|
| 109 |
+
self._stop_note()
|
| 110 |
+
self._theremin = self._get_part_for_prog(prog)
|
| 111 |
+
self._current_prog = prog
|
| 112 |
+
|
| 113 |
+
if amp == 0.0:
|
| 114 |
+
self._stop_note()
|
| 115 |
+
else:
|
| 116 |
+
if self._note_handle is None:
|
| 117 |
+
self._note_handle = self._theremin.start_note(
|
| 118 |
+
target_pitch, int(amp * 100)
|
| 119 |
+
)
|
| 120 |
+
self._current_pitch = target_pitch
|
| 121 |
+
elif target_pitch != self._current_pitch:
|
| 122 |
+
self._stop_note()
|
| 123 |
+
self._note_handle = self._theremin.start_note(
|
| 124 |
+
target_pitch, int(amp * 100)
|
| 125 |
+
)
|
| 126 |
+
self._current_pitch = target_pitch
|
| 127 |
+
else:
|
| 128 |
+
_send_cc(self._theremin, 11, int(amp * 127))
|
| 129 |
+
|
| 130 |
+
inst_name = (
|
| 131 |
+
AVAILABLE_PARTS[self._current_prog]
|
| 132 |
+
if self._current_prog is not None
|
| 133 |
+
else "--"
|
| 134 |
+
)
|
| 135 |
+
lines = [
|
| 136 |
+
f"Instrument : {self._current_prog if self._current_prog is not None else '--':>3} {inst_name}",
|
| 137 |
+
f"Note : {midi_to_name(target_pitch):>3} ({target_pitch}) Playing: {bool(self._note_handle)}",
|
| 138 |
+
f"Head roll (°) : {_dash_bar(roll_deg, *ROLL_DEG_RANGE)}",
|
| 139 |
+
f"Head Z (mm): {_dash_bar(trans_mm[2], *Z_MM_RANGE)}",
|
| 140 |
+
f"Amplitude (0-1): {_dash_bar(amp, 0, 1)}",
|
| 141 |
+
f"Right ant (rad): {_dash_bar(antennas[1], *RIGHT_ANT_RANGE)}",
|
| 142 |
+
]
|
| 143 |
+
sys.stdout.write("\r" + "\n".join(lines) + "\033[F" * (len(lines) - 1))
|
| 144 |
+
sys.stdout.flush()
|
| 145 |
+
|
| 146 |
+
time.sleep(0.02)
|
| 147 |
+
finally:
|
| 148 |
+
self._stop_note()
|
| 149 |
+
print("\nTheremin stopped.")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
with ReachyMini() as mini:
|
| 154 |
+
app = Theremini()
|
| 155 |
+
stop = threading.Event()
|
| 156 |
+
try:
|
| 157 |
+
print("Running 'Theremini' a ReachyMiniApp...")
|
| 158 |
+
print("Press Ctrl+C to stop the app.")
|
| 159 |
+
app.run(mini, stop)
|
| 160 |
+
print("App has stopped.")
|
| 161 |
+
except KeyboardInterrupt:
|
| 162 |
+
print("Stopping the app...")
|
| 163 |
+
stop.set()
|
index.html
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>Theremini • Reachy Mini App</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css" />
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<main class="page">
|
| 13 |
+
<header class="hero">
|
| 14 |
+
<p class="eyebrow">Reachy Mini App</p>
|
| 15 |
+
<h1>Theremini</h1>
|
| 16 |
+
<p class="lede">Head roll drives pitch, head height controls the volume and the right antenna switches instruments. Exactly the same mapping as the original <code>simple_theremini.py</code>, now packaged as an app.</p>
|
| 17 |
+
</header>
|
| 18 |
+
|
| 19 |
+
<section class="card">
|
| 20 |
+
<h2>Mapping</h2>
|
| 21 |
+
<table class="config-table">
|
| 22 |
+
<thead>
|
| 23 |
+
<tr>
|
| 24 |
+
<th>Input</th>
|
| 25 |
+
<th>Range</th>
|
| 26 |
+
<th>Output</th>
|
| 27 |
+
</tr>
|
| 28 |
+
</thead>
|
| 29 |
+
<tbody>
|
| 30 |
+
<tr>
|
| 31 |
+
<td>Head roll</td>
|
| 32 |
+
<td>-60° ⇢ +60°</td>
|
| 33 |
+
<td>MIDI notes 48 (C3) ⇢ 84 (C6)</td>
|
| 34 |
+
</tr>
|
| 35 |
+
<tr>
|
| 36 |
+
<td>Head Z</td>
|
| 37 |
+
<td>-30 mm ⇢ +5 mm</td>
|
| 38 |
+
<td>Amplitude 0 ⇢ 100 %</td>
|
| 39 |
+
</tr>
|
| 40 |
+
<tr>
|
| 41 |
+
<td>Right antenna</td>
|
| 42 |
+
<td>-π/3 ⇢ +π/3 rad</td>
|
| 43 |
+
<td>Instrument index 0 ⇢ 5</td>
|
| 44 |
+
</tr>
|
| 45 |
+
</tbody>
|
| 46 |
+
</table>
|
| 47 |
+
</section>
|
| 48 |
+
|
| 49 |
+
<section class="card">
|
| 50 |
+
<h2>Instrument Programs</h2>
|
| 51 |
+
<p class="caption">Theremini cycles through a curated subset of FluidSynth GM presets. The robot crossfades by reusing CC 11 just like the original script.</p>
|
| 52 |
+
<ul class="instrument-list">
|
| 53 |
+
<li><span class="label">0</span>choir_aahs</li>
|
| 54 |
+
<li><span class="label">1</span>orchestra_hit</li>
|
| 55 |
+
<li><span class="label">2</span>taiko_drum</li>
|
| 56 |
+
<li><span class="label">3</span>trumpet</li>
|
| 57 |
+
<li><span class="label">4</span>french_horn</li>
|
| 58 |
+
<li><span class="label">5</span>soundtrack</li>
|
| 59 |
+
</ul>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<section class="card">
|
| 63 |
+
<h2>Runtime Defaults</h2>
|
| 64 |
+
<dl class="kv">
|
| 65 |
+
<div>
|
| 66 |
+
<dt>Loop frequency</dt>
|
| 67 |
+
<dd>50 Hz (sleep 20 ms)</dd>
|
| 68 |
+
</div>
|
| 69 |
+
<div>
|
| 70 |
+
<dt>Session threads</dt>
|
| 71 |
+
<dd>SCAMP Session(max_threads=1024)</dd>
|
| 72 |
+
</div>
|
| 73 |
+
<div>
|
| 74 |
+
<dt>MIDI expression</dt>
|
| 75 |
+
<dd>CC 11 scaled 0 ⇢ 127</dd>
|
| 76 |
+
</div>
|
| 77 |
+
<div>
|
| 78 |
+
<dt>Volume clamp</dt>
|
| 79 |
+
<dd>Amplitude 0.0 ⇢ 1.0</dd>
|
| 80 |
+
</div>
|
| 81 |
+
</dl>
|
| 82 |
+
</section>
|
| 83 |
+
|
| 84 |
+
<section class="card future">
|
| 85 |
+
<h2>Next Iterations</h2>
|
| 86 |
+
<ul>
|
| 87 |
+
<li>Expose the mapping ranges and instrument list as editable controls.</li>
|
| 88 |
+
<li>Plot head pose, antenna and amplitude as tiny charts for quick debugging.</li>
|
| 89 |
+
<li>Preview GM programs with audio snippets or links.</li>
|
| 90 |
+
</ul>
|
| 91 |
+
</section>
|
| 92 |
+
</main>
|
| 93 |
+
</body>
|
| 94 |
+
|
| 95 |
+
</html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
[project]
|
| 7 |
+
name = "Theremini"
|
| 8 |
+
version = "0.1.0"
|
| 9 |
+
description = "Add your description here"
|
| 10 |
+
readme = "README.md"
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"reachy-mini",
|
| 14 |
+
"scamp",
|
| 15 |
+
"scipy"
|
| 16 |
+
]
|
| 17 |
+
keywords = ["reachy-mini-app"]
|
| 18 |
+
|
| 19 |
+
[project.entry-points."reachy_mini_apps"]
|
| 20 |
+
Theremini = "Theremini.main:Theremini"
|
style.css
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light dark;
|
| 3 |
+
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
| 4 |
+
line-height: 1.5;
|
| 5 |
+
font-size: 16px;
|
| 6 |
+
--bg: #0f172a;
|
| 7 |
+
--surface: rgba(15, 23, 42, 0.75);
|
| 8 |
+
--border: rgba(148, 163, 184, 0.35);
|
| 9 |
+
--text: #e2e8f0;
|
| 10 |
+
--accent: #a5b4fc;
|
| 11 |
+
--muted: #94a3b8;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
margin: 0;
|
| 20 |
+
background: radial-gradient(circle at top, #312e81 0%, #0f172a 45%, #020617 100%);
|
| 21 |
+
color: var(--text);
|
| 22 |
+
min-height: 100vh;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.page {
|
| 26 |
+
max-width: 960px;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
padding: 3rem 1.5rem 4rem;
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
gap: 1.5rem;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.hero {
|
| 35 |
+
text-align: center;
|
| 36 |
+
margin-bottom: 1rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.hero h1 {
|
| 40 |
+
margin: 0.3rem 0 0.8rem;
|
| 41 |
+
font-size: clamp(2.2rem, 5vw, 3rem);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.hero .eyebrow {
|
| 45 |
+
letter-spacing: 0.2em;
|
| 46 |
+
text-transform: uppercase;
|
| 47 |
+
font-size: 0.78rem;
|
| 48 |
+
color: var(--muted);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.hero .lede {
|
| 52 |
+
max-width: 720px;
|
| 53 |
+
margin: 0 auto;
|
| 54 |
+
color: rgba(226, 232, 240, 0.85);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.card {
|
| 58 |
+
background: var(--surface);
|
| 59 |
+
border: 1px solid var(--border);
|
| 60 |
+
border-radius: 18px;
|
| 61 |
+
padding: 1.75rem;
|
| 62 |
+
box-shadow: 0 25px 40px rgba(2, 6, 23, 0.6);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.card h2 {
|
| 66 |
+
margin-top: 0;
|
| 67 |
+
margin-bottom: 1rem;
|
| 68 |
+
font-size: 1.35rem;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.config-table {
|
| 72 |
+
width: 100%;
|
| 73 |
+
border-collapse: collapse;
|
| 74 |
+
font-size: 0.95rem;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.config-table th,
|
| 78 |
+
.config-table td {
|
| 79 |
+
padding: 0.8rem;
|
| 80 |
+
border-bottom: 1px solid rgba(148, 163, 184, 0.3);
|
| 81 |
+
text-align: left;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.config-table th {
|
| 85 |
+
text-transform: uppercase;
|
| 86 |
+
font-size: 0.75rem;
|
| 87 |
+
letter-spacing: 0.08em;
|
| 88 |
+
color: var(--muted);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.caption {
|
| 92 |
+
color: var(--muted);
|
| 93 |
+
margin-top: -0.3rem;
|
| 94 |
+
margin-bottom: 1rem;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.instrument-list {
|
| 98 |
+
list-style: none;
|
| 99 |
+
padding: 0;
|
| 100 |
+
margin: 0;
|
| 101 |
+
display: grid;
|
| 102 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 103 |
+
gap: 0.75rem;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.instrument-list li {
|
| 107 |
+
background: rgba(49, 46, 129, 0.4);
|
| 108 |
+
border: 1px solid rgba(129, 140, 248, 0.4);
|
| 109 |
+
border-radius: 12px;
|
| 110 |
+
padding: 0.85rem 1rem;
|
| 111 |
+
display: flex;
|
| 112 |
+
align-items: center;
|
| 113 |
+
gap: 0.6rem;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.instrument-list .label {
|
| 117 |
+
font-variant-numeric: tabular-nums;
|
| 118 |
+
font-weight: 600;
|
| 119 |
+
color: var(--accent);
|
| 120 |
+
width: 2ch;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.kv {
|
| 124 |
+
display: grid;
|
| 125 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 126 |
+
gap: 0.9rem 1.2rem;
|
| 127 |
+
margin: 0;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.kv div {
|
| 131 |
+
background: rgba(15, 23, 42, 0.55);
|
| 132 |
+
border-radius: 10px;
|
| 133 |
+
padding: 0.9rem 1rem;
|
| 134 |
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.kv dt {
|
| 138 |
+
font-size: 0.8rem;
|
| 139 |
+
text-transform: uppercase;
|
| 140 |
+
letter-spacing: 0.08em;
|
| 141 |
+
color: var(--muted);
|
| 142 |
+
margin-bottom: 0.4rem;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.kv dd {
|
| 146 |
+
margin: 0;
|
| 147 |
+
font-weight: 600;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.future ul {
|
| 151 |
+
margin: 0;
|
| 152 |
+
padding-left: 1rem;
|
| 153 |
+
color: rgba(226, 232, 240, 0.9);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.future li + li {
|
| 157 |
+
margin-top: 0.5rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
code {
|
| 161 |
+
background: rgba(15, 23, 42, 0.6);
|
| 162 |
+
padding: 0.1rem 0.3rem;
|
| 163 |
+
border-radius: 4px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
@media (max-width: 600px) {
|
| 167 |
+
.card {
|
| 168 |
+
padding: 1.25rem;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.config-table th,
|
| 172 |
+
.config-table td {
|
| 173 |
+
padding: 0.6rem;
|
| 174 |
+
}
|
| 175 |
+
}
|