Files
proxi-ai/tools/check_melodies.py
T
christian 3905647b7f Proxi AI — Spielzeug-Roboter mit natürlicher Sprache steuern
Firmware, Bluetooth-Brücke und Agent-Skill für den KOSMOS-Proxi: einmal
flashen, danach hört der Roboter über Bluetooth LE auf Textbefehle. Was die
Befehle schickt, ist ihm egal — ein Skript, ein curl, oder ein KI-Agent, der
aus "mach mal was Lustiges" eine Choreografie baut.

  firmware/   MakeCode-Projekt (TypeScript) und fertige .hex für V1 und V2
  bridge/     BLE-Brücke und HTTP-API, einzige Abhängigkeit ist bleak
  agent/      Skill-Definition: Alltagssprache -> Befehle
  tools/      Quelltext aus .hex extrahieren, Musik-Tabellen prüfen
  docs/       Protokoll, Einrichtung, Hardware, Lizenzen

Zwei Dinge, die sonst nirgends stehen und in docs/HARDWARE.md dokumentiert
sind: die Pin-Belegung des Roboters ist nicht öffentlich, sie wurde aus den
mitgelieferten .hex-Dateien rekonstruiert (tools/hex_source.py holt sie
heraus). Und der Ton braucht zwingend eine ganzzahlige Frequenz sowie Pin P0
— sonst kracht der Lautsprecher, der Aufruf hängt und die Bluetooth-
Verbindung stirbt.

Am echten Roboter durchgetestet: Verbindung, Sensoren, Display, 41 Sekunden
Musik am Stück, alle Bewegungsrichtungen, Musik und Fahren gleichzeitig,
Tanzfiguren, Not-Stopp mitten in der Bewegung, Autostart nach Neustart.

MIT-Lizenz. Die Hardware-Extension stammt von kaku111 (TobbieII) und die
Notendaten aus pxt-microbit, beide ebenfalls MIT — siehe THIRD-PARTY-NOTICES.
2026-08-22 11:41:00 +00:00

128 lines
4.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""
check_melodies.py — prüft die Musik-Tabellen der Firmware, ohne zu flashen.
python3 tools/check_melodies.py
Hintergrund: die Frequenzen wurden ursprünglich mit Math.pow() gerechnet. Das
Ergebnis war rechnerisch korrekt, kam aber als Kommazahl in music.playTone()
an — der micro:bit machte daraus eine unsinnige Schwingungsdauer, krachte laut,
blieb hängen und riss dabei die Bluetooth-Verbindung ab (Connection Timeout
0x08). Zu finden war das nur am Gerät.
Damit so etwas künftig schon beim Bauen auffällt, rechnet dieses Skript die
Ganzzahl-Tabelle aus main.ts gegen die exakte Formel nach und prüft die
Notendaten auf Werte, die den Tonausgang durcheinanderbringen.
Rückgabewert 0 = alles in Ordnung.
"""
import math
import os
import re
import sys
MAIN_TS = os.path.join(os.path.dirname(__file__), "..", "firmware", "main.ts")
#: Ab hier wird es hörbar. 100 Cent sind ein Halbton.
MAX_CENTS = 8.0
def strip_comments(src):
"""Kommentare entfernen — sonst schlägt die Suche nach Math.pow auf dem
Kommentar an, der genau davor warnt."""
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
src = re.sub(r"//[^\n]*", "", src)
return src
def parse_main(path):
src = open(path, encoding="utf-8").read()
m = re.search(r"const NOTE_HZ16 = \[([0-9,\s]+)\]", src)
if not m:
sys.exit("NOTE_HZ16 nicht in main.ts gefunden")
table = [int(x) for x in m.group(1).replace("\n", "").split(",") if x.strip()]
melodies = re.findall(r'name == "([A-Z]+)"\) return hex`([0-9a-f]+)`', src)
return src, table, melodies
def midi_to_freq(table, midi):
"""Muss sich genauso verhalten wie midiToFreq() in main.ts."""
hz16 = table[midi % 12]
octave = midi // 12 - 5
while octave > 0:
hz16 *= 2
octave -= 1
while octave < 0:
hz16 //= 2
octave += 1
return (hz16 + 8) // 16
def main():
src, table, melodies = parse_main(MAIN_TS)
code = strip_comments(src)
problems = []
if len(table) != 12:
problems.append(f"NOTE_HZ16 hat {len(table)} Einträge, erwartet 12")
# Kein Fließkomma im Tonpfad — das war die Ursache des Fehlers.
for bad in ("Math.pow", "Math.round", "parseFloat"):
if bad in code:
problems.append(f"{bad} steht wieder in main.ts — Frequenzen müssen ganzzahlig bleiben")
if not melodies:
problems.append("keine Melodien gefunden")
notes = set()
for name, hexdata in melodies:
data = bytes.fromhex(hexdata)
if len(data) % 2:
problems.append(f"{name}: ungerade Byte-Zahl, Noten sind Paare aus MIDI und Dauer")
continue
for i in range(0, len(data) - 1, 2):
midi, dur = data[i], data[i + 1]
if dur == 0:
problems.append(f"{name}: Note {i//2} hat Dauer 0 — der Ton liefe endlos")
if midi > 127:
problems.append(f"{name}: Note {i//2} hat MIDI {midi} (>127)")
if midi:
notes.add(midi)
worst = 0.0
worst_note = None
for midi in sorted(notes):
got = midi_to_freq(table, midi)
if got <= 0:
problems.append(f"MIDI {midi} ergibt {got} Hz — playTone braucht einen positiven Wert")
continue
exact = 440.0 * 2 ** ((midi - 69) / 12)
cents = abs(1200 * math.log2(got / exact))
if cents > worst:
worst, worst_note = cents, (midi, got, round(exact))
if cents > MAX_CENTS:
problems.append(f"MIDI {midi}: {got} Hz statt {exact:.1f} Hz ({cents:.1f} Cent daneben)")
print(f"{len(melodies)} Melodien, {len(notes)} verschiedene Noten "
f"(MIDI {min(notes)}-{max(notes)})")
if worst_note:
midi, got, exact = worst_note
print(f"größte Abweichung: {worst:.2f} Cent bei MIDI {midi} "
f"({got} Hz statt {exact} Hz), erlaubt sind {MAX_CENTS}")
if problems:
print("\nFEHLER:", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 1
print("alles in Ordnung")
return 0
if __name__ == "__main__":
sys.exit(main())