Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Through-air calibration for the Morse Code app (laptop speaker + mic). | |
| Plays the app's impulse wire-code as percussive clicks out the laptop speaker | |
| while recording the laptop mic, then runs the SAME transient-onset detector the | |
| browser app uses (ported from marionette/tests/audio_analysis.py) and decodes | |
| the result. Reports detection accuracy and recommends a `unitMs` so the app's | |
| defaults can be tuned to this room/hardware — no robot required. | |
| This MAKES NOISE. Turn the volume up and run it only when ready. | |
| Run with the marionette venv (has sounddevice/scipy): | |
| /Users/remi/reachy_mini_apps/marionette/.venv/bin/python \ | |
| tools/calibrate_audio.py --phrase "SOS HELLO" --sweep | |
| Then set the winning unit in the app: lib/timing.js SPEED_PRESETS / Settings. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import sounddevice as sd | |
| # Reuse the exact detector the JS app was ported from. | |
| MARIONETTE_TESTS = Path("/Users/remi/reachy_mini_apps/marionette/tests") | |
| sys.path.insert(0, str(MARIONETTE_TESTS)) | |
| from audio_analysis import detect_transient_onsets # noqa: E402 | |
| SR = 48000 | |
| # ── Morse + wire code (mirror of lib/morse.js, lib/wire.js, lib/timing.js) ── | |
| CHAR_TO_MORSE = { | |
| "A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.", | |
| "G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..", | |
| "M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.", | |
| "S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-", | |
| "Y": "-.--", "Z": "--..", | |
| "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-", | |
| "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", | |
| } | |
| MORSE_TO_CHAR = {v: k for k, v in CHAR_TO_MORSE.items()} | |
| def timing(unit_ms: int) -> dict: | |
| u = max(40, int(unit_ms)) | |
| dah, elem, letter, word = 2 * u, 4 * u, 8 * u, 14 * u | |
| return { | |
| "unit": u, "dah": dah, "elem": elem, "letter": letter, "word": word, | |
| "click": min(60, int(u * 0.5)), | |
| "d_elem": round((dah * elem) ** 0.5), | |
| "d_letter": round((elem * letter) ** 0.5), | |
| "d_word": round((letter * word) ** 0.5), | |
| } | |
| def text_to_onsets(text: str, t: dict) -> list[float]: | |
| onsets, cursor = [], 0.0 | |
| words = [w for w in text.upper().split() if w] | |
| for wi, word in enumerate(words): | |
| if wi > 0: | |
| cursor += t["word"] | |
| letters = [CHAR_TO_MORSE[c] for c in word if c in CHAR_TO_MORSE] | |
| for li, code in enumerate(letters): | |
| if li > 0: | |
| cursor += t["letter"] | |
| for si, sym in enumerate(code): | |
| if si > 0: | |
| cursor += t["elem"] | |
| onsets.append(cursor) | |
| if sym == "-": | |
| cursor += t["dah"] | |
| onsets.append(cursor) | |
| return onsets | |
| def onsets_to_text(onsets_ms: list[float], t: dict) -> str: | |
| onsets = sorted(onsets_ms) | |
| if not onsets: | |
| return "" | |
| clusters, size, prev_last = [], 1, None | |
| i = 1 | |
| while i <= len(onsets): | |
| ioi = onsets[i] - onsets[i - 1] if i < len(onsets) else float("inf") | |
| if i < len(onsets) and ioi < t["d_elem"]: | |
| size += 1 | |
| i += 1 | |
| continue | |
| gap = None if prev_last is None else onsets[i - size] - prev_last | |
| clusters.append((size, gap)) | |
| prev_last = onsets[i - 1] | |
| size = 1 | |
| i += 1 | |
| morse_words, cur = [], "" | |
| for idx, (sz, gap) in enumerate(clusters): | |
| if idx > 0: | |
| if gap >= t["d_word"]: | |
| morse_words.append(cur) | |
| morse_words.append("/") | |
| cur = "" | |
| elif gap >= t["d_letter"]: | |
| cur += " " | |
| cur += "-" if sz >= 2 else "." | |
| morse_words.append(cur) | |
| out_words, letters = [], [] | |
| for tok in " ".join(morse_words).split(): | |
| if tok == "/": | |
| out_words.append("".join(letters)) | |
| letters = [] | |
| else: | |
| letters.append(MORSE_TO_CHAR.get(tok, "?")) | |
| out_words.append("".join(letters)) | |
| return " ".join(w for w in out_words if w) | |
| def render_clicks(onsets_ms: list[float], lead_ms=300, tail_ms=600, freq=2200.0) -> np.ndarray: | |
| dur_ms = lead_ms + (onsets_ms[-1] if onsets_ms else 0) + tail_ms | |
| n = int(SR * dur_ms / 1000) | |
| buf = np.zeros(n, dtype=np.float32) | |
| click_len = int(0.05 * SR) | |
| tt = np.arange(click_len) / SR | |
| env = np.exp(-tt / 0.012) | |
| blip = (0.7 * env * np.sin(2 * np.pi * freq * tt)).astype(np.float32) | |
| for t0 in onsets_ms: | |
| start = int((t0 + lead_ms) / 1000 * SR) | |
| end = min(start + click_len, n) | |
| buf[start:end] += blip[: end - start] | |
| return buf | |
| def run_once(text: str, unit_ms: int, hp: float, ratio: float) -> dict: | |
| t = timing(unit_ms) | |
| onsets = text_to_onsets(text, t) | |
| audio = render_clicks(onsets, freq=2200.0) | |
| rec = sd.playrec(audio, samplerate=SR, channels=1, dtype="float32") | |
| sd.wait() | |
| rec = rec.flatten() | |
| detected = detect_transient_onsets(rec, SR, highpass_freq=hp, threshold_db=20 * np.log10(ratio)) | |
| detected_ms = [d * 1000.0 for d in detected] | |
| decoded = onsets_to_text(detected_ms, t) | |
| return { | |
| "unit": unit_ms, "expected": len(onsets), "detected": len(detected_ms), | |
| "sent": text.upper(), "decoded": decoded, "ok": decoded == text.upper(), | |
| } | |
| def main() -> int: | |
| ap = argparse.ArgumentParser(description="Morse audio through-air calibration") | |
| ap.add_argument("--phrase", default="SOS HELLO") | |
| ap.add_argument("--unit", type=int, default=120, help="base unit (ms)") | |
| ap.add_argument("--highpass", type=float, default=2000.0) | |
| ap.add_argument("--ratio", type=float, default=0.1, help="flux threshold ratio") | |
| ap.add_argument("--sweep", action="store_true", help="try several units, recommend one") | |
| args = ap.parse_args() | |
| print(f"Devices:\n{sd.query_devices()}\n") | |
| print(f"Phrase: {args.phrase!r} (turn the volume up — this plays sound)\n") | |
| time.sleep(0.5) | |
| units = [160, 120, 90, 70] if args.sweep else [args.unit] | |
| results = [] | |
| for u in units: | |
| print(f"— unit={u}ms …", flush=True) | |
| r = run_once(args.phrase, u, args.highpass, args.ratio) | |
| flag = "OK " if r["ok"] else "FAIL" | |
| print(f" [{flag}] expected {r['expected']} hits, detected {r['detected']} | " | |
| f"decoded: {r['decoded']!r}") | |
| results.append(r) | |
| good = [r for r in results if r["ok"]] | |
| print("\n" + "=" * 60) | |
| if good: | |
| best = min(good, key=lambda r: r["unit"]) # fastest that still decodes | |
| print(f"RECOMMEND unitMs = {best['unit']} (fastest reliable in this room)") | |
| print(f" → set SPEED_PRESETS.normal / Settings unit to {best['unit']}") | |
| else: | |
| print("No unit decoded cleanly. Try: louder volume, slower (--unit 200),") | |
| print("lower --highpass (e.g. 1200), or higher --ratio (e.g. 0.15).") | |
| print("=" * 60) | |
| return 0 if good else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |