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.
This commit is contained in:
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user