RemiFabre commited on
Commit
e3fb530
·
0 Parent(s):

First commit, wip

Browse files
Files changed (7) hide show
  1. .gitignore +170 -0
  2. README.md +25 -0
  3. Theremini/__init__.py +3 -0
  4. Theremini/main.py +163 -0
  5. index.html +95 -0
  6. pyproject.toml +20 -0
  7. 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
+ }