From 0ac7e72029029fc745b5155c34602c1b5bf10490 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:47:19 +0000 Subject: [PATCH 01/70] docs: research Traktor reference, API landscape and agent concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork so implementation can start from facts instead of guesses. Traktor reference: deck modes (track/remix/stem), real-time stem separation, pattern player, beatgrids, and the collection.nml format — an existing Python parser makes Traktor library import a cheap early feature. API landscape: Suno has no public developer API as of August 2026, only a curated partner program opened in July. Generation therefore goes behind an adapter interface starting with a reachable provider, so Suno becomes an added adapter later rather than a rewrite. Third-party reverse-engineered Suno wrappers are documented as not recommended. Building blocks: playback, time-stretching, beat/key detection and stem separation options, with a license table up front — several audio libraries are (A)GPL with commercial relicensing, which constrains the stack before anything is chosen. Agent concept: crowd-feedback loop with prior art, signal sources, role sketch, and the timing constraint that breaks the naive design — generation is too slow to close the loop live, so it plans ahead while fast controls handle the immediate response. Camera and biometric signals are flagged as GDPR-relevant. README: adds a docs index, marks the Suno blocker, extends the roadmap with dependencies, and records the open decisions. Adds .env.example. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- .env.example | 28 +++++++++ README.md | 114 ++++++++++++++++++++++------------ docs/AGENTEN.md | 129 ++++++++++++++++++++++++++++++++++++++ docs/APIS.md | 124 +++++++++++++++++++++++++++++++++++++ docs/BAUSTEINE.md | 130 +++++++++++++++++++++++++++++++++++++++ docs/TRAKTOR-REFERENZ.md | 114 ++++++++++++++++++++++++++++++++++ 6 files changed, 599 insertions(+), 40 deletions(-) create mode 100644 .env.example create mode 100644 docs/AGENTEN.md create mode 100644 docs/APIS.md create mode 100644 docs/BAUSTEINE.md create mode 100644 docs/TRAKTOR-REFERENZ.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..20bb515 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Kopie als .env anlegen und ausfüllen. .env ist in .gitignore — Keys gehören +# niemals ins Repo. + +# --- Musikgenerierung --------------------------------------------------- + +# ElevenLabs Music — vorgesehener Startadapter (siehe docs/APIS.md) +ELEVENLABS_API_KEY= + +# Suno — aktuell KEINE öffentliche API verfügbar (Stand August 2026). +# Platzhalter für den Fall, dass die Bewerbung beim Partner-Programm +# durchgeht. Bis dahin leer lassen. +SUNO_API_KEY= + +# Stable Audio (Stability AI) — Alternative für instrumentale Texturen +STABILITY_API_KEY= + +# --- Samples ------------------------------------------------------------ + +# Freesound APIv2 — kostenlos für nicht-kommerzielle Nutzung +FREESOUND_API_KEY= + +# --- Lokale Pfade ------------------------------------------------------- + +# Wohin generierte Tracks geschrieben werden +GENERATED_DIR=./generated + +# Bestehende Traktor-Sammlung für den Import (docs/TRAKTOR-REFERENZ.md) +# TRAKTOR_COLLECTION_PATH= diff --git a/README.md b/README.md index 52afa9f..b0b175b 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,100 @@ # Musik -Traktor-artiges DJ-/Musik-Tool. Decks, Mixer, Library — mit dem Ziel, später die -[Suno API](https://suno.com) für KI-Musikgenerierung direkt in den Workflow einzubinden. +Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte +Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den +Reaktionen der Crowd formt. -Status: **Konzeptphase** — noch kein Code, nur Zielbild und Roadmap. +Status: **Konzeptphase.** Noch kein Code. Recherche, Zielbild und Roadmap +stehen. ## Zielbild -Ein DJ-Werkzeug, das sich wie Traktor bedienen lässt, aber generative Musik als -erstklassige Quelle behandelt: Ein Deck kann einen Track aus der Library laden — -oder einen, der gerade erst per Prompt entsteht. +Drei Ebenen, aufeinander aufbauend: -## Geplante Funktionen +1. **Ein DJ-Werkzeug**, das sich wie Traktor bedienen lässt — Decks, Mixer, + Beatgrid, Library. +2. **Generierte Tracks als normale Quelle.** Ein Deck unterscheidet nicht + zwischen einer Datei von der Platte und einem Track, der gerade per Prompt + entstanden ist. +3. **Ein Agenten-Team, das die Crowd liest.** Die Menge schreibt keine Noten, + aber ihre Reaktion wird Eingabe in Auswahl, Übergang und Komposition. -### Kern (DJ) -- Zwei oder mehr Decks mit unabhängiger Wiedergabe -- Pitch/Tempo-Kontrolle, Keylock -- BPM-Erkennung und Beatgrid -- Sync zwischen Decks -- Crossfader, Kanal-EQ, Gain, Filter -- Cue-Points und Loops -- Waveform-Darstellung (Übersicht + Zoom) -- Track-Library mit Metadaten, Suche, Playlists +Ebene 1 ist die Voraussetzung für alles andere. Ohne ein Tool, das mixt, gibt +es nichts, was Agenten steuern könnten. -### Suno-Integration (später) -- Track per Prompt generieren und direkt auf ein Deck laden -- Generierte Tracks in der Library mit Prompt-Historie ablegen -- Stem-/Variantenerzeugung als Material für Übergänge -- Warteschlange für Generierungen, damit der Mix nicht blockiert +## Dokumentation + +| Dokument | Inhalt | +| --- | --- | +| [docs/TRAKTOR-REFERENZ.md](docs/TRAKTOR-REFERENZ.md) | Was Traktor Pro 4 kann, was wir davon übernehmen | +| [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | +| [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | +| [docs/AGENTEN.md](docs/AGENTEN.md) | Multi-Agenten-Team und Crowd-Feedback-Schleife | +| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Bausteine und Schnittstellen | + +## Aktuelle Blocker + +**Suno hat keine öffentliche Entwickler-API.** Stand August 2026 gibt es nur ein +kuratiertes Partner-Programm mit Bewerbungsformular (seit Juli 2026) — keine +Endpunkte, keine Doku, kein Termin. Generierung ist damit vorerst nicht über +Suno machbar. + +Konsequenz: Der Generierungs-Teil wird **hinter einer Adapter-Schnittstelle** +gebaut und startet mit einem erreichbaren Anbieter (Vorschlag: ElevenLabs +Music). Suno wird später ein weiterer Adapter, kein Umbau. Details und +Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap -| Phase | Inhalt | -| --- | --- | -| 0 | Setup, Tech-Stack-Entscheidung, Audio-Prototyp (ein Deck, Play/Pause/Pitch) | -| 1 | Zweites Deck, Crossfader, EQ — ein Mix ist durchgängig spielbar | -| 2 | BPM-Analyse, Beatgrid, Sync | -| 3 | Waveforms, Cue-Points, Loops | -| 4 | Library: Import, Metadaten, Suche, Playlists | -| 5 | Suno-API-Adapter: Prompt → Track → Deck | -| 6 | Generierungs-Queue, Prompt-Historie, Varianten | +| Phase | Inhalt | Abhängig von | +| --- | --- | --- | +| **0** | Stack-Entscheidung per Prototyp: ein Deck, Play/Pause/Pitch mit Keylock | — | +| 1 | Zweites Deck, Crossfader, EQ, Filter — ein Mix ist durchgängig spielbar | 0 | +| 2 | BPM-Analyse, Beatgrid (inkl. manueller Korrektur), Sync | 1 | +| 3 | Waveforms, Cue-Points, Loops | 2 | +| 4 | Library: Import inkl. Traktor-`collection.nml`, Metadaten, Suche, Playlists | 2 | +| 5 | Generierungs-Adapter: Prompt → Track → Deck, asynchron mit Queue | 4, API-Zugang | +| 6 | Stems (beim Import vorberechnet), Stem-Deck-Modus | 4 | +| 7 | Crowd-Signale einsammeln und visualisieren — noch ohne Steuerung | 4 | +| 8 | Agenten-Team: Energie-Analyse, Set-Planung, Übergangssteuerung | 5, 7 | + +Phase 5 hängt am API-Zugang, Phase 7 kann davon unabhängig laufen — Crowd-Signale +lassen sich auch gegen eine reine Datei-Library testen. ## Tech-Stack -Noch offen. Vorschlag als Ausgangspunkt: - -- **Audio:** Web Audio API — reicht für Decks, EQ, Filter und Zeitstreckung, - läuft ohne Installation und ist gut zu debuggen. -- **UI:** TypeScript + React -- **Analyse (BPM/Beatgrid):** zunächst im Browser, bei Bedarf als separater Worker +**Offen — das ist die Phase-0-Entscheidung.** Die Frage ist nicht Latenz, +sondern der getrennte Kopfhörer-Cue-Ausgang: den gibt es im Browser nicht +sauber, und ohne ihn ist ernsthaftes DJing nicht möglich. -Alternative, falls die Latenz im Browser nicht reicht: nativer Audio-Kern -(Rust/C++ mit CPAL bzw. JUCE) und die UI darüber. +- **Browser (Web Audio API)** — schneller Prototyp, kein Install, gut zu + debuggen. Kein exklusiver Interface-Zugriff, kein zweiter Ausgang. +- **Nativ (Rust/C++)** — echte Low-Latency-Ausgabe, mehrere Ausgänge, mehr + Aufwand. -Diese Entscheidung fällt in Phase 0. +Analyse-, Library- und Agenten-Schicht sind von dieser Entscheidung unabhängig +und können parallel entstehen. Konkreter Testaufbau zur Entscheidung in +[docs/BAUSTEINE.md](docs/BAUSTEINE.md#empfehlung-für-phase-0). ## Nicht-Ziele (vorerst) - Kein DVS/Timecode-Vinyl - Keine Controller-/MIDI-Anbindung in den ersten Phasen - Kein Streaming-Dienst-Import (Spotify, Beatport) +- Keine Echtzeit-Stem-Separation — beim Import vorberechnen (siehe + [docs/BAUSTEINE.md](docs/BAUSTEINE.md#stem-separation)) + +## Offene Entscheidungen + +1. **Browser oder nativ?** → Phase 0 +2. **Wird das Produkt kommerziell?** Davon hängt die gesamte Lizenzstrategie ab, + und zwar rückwirkend teuer — bei den Bibliotheken (AGPL/GPL), bei den + Samples (CC BY-NC) und bei den Generierungs-Anbietern (Suno und Udio haben + ungeklärte Lizenzlage). +3. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — + prägt das Agenten-Design. ## Setup -Kommt mit Phase 0, sobald der Stack steht. +Kommt mit Phase 0, sobald der Stack steht. Für API-Keys siehe +[`.env.example`](.env.example). diff --git a/docs/AGENTEN.md b/docs/AGENTEN.md new file mode 100644 index 0000000..16cedb1 --- /dev/null +++ b/docs/AGENTEN.md @@ -0,0 +1,129 @@ +# Multi-Agenten-Team: Musik aus Crowd-Reaktionen + +Fernziel, nicht Phase 1. Dieses Dokument hält die Idee fest, solange sie frisch +ist, und markiert die Stellen, an denen sie an harte Grenzen stößt. + +## Die Idee + +Ein Team von Agenten übernimmt gemeinsam, was ein DJ tut: die Menge lesen und +entscheiden, was als Nächstes kommt. Der Unterschied zum klassischen DJ-Set — +„was als Nächstes kommt" muss nicht existieren. Es kann generiert werden, +zugeschnitten auf das, was der Raum gerade macht. + +Die Crowd wird damit zum Ko-Autor: Sie schreibt keine Noten, aber ihre Reaktion +ist Eingabe in die Komposition. + +## Vorarbeit, die es schon gibt + +Das ist keine neue Idee, und das ist gut — es gibt Erfahrungswerte. + +**hpDJ (HP Labs)** ist das bekannteste Forschungssystem, das DJ-Aufgaben +vollständig automatisiert *und* dabei live auf Crowd-Reaktionen reagiert. Es +misst die Reaktion auf den laufenden Track und lenkt damit die Auswahl des +nächsten. Genau unsere Schleife — nur ohne den Generierungsschritt, weil es den +damals nicht gab. + +Heutige kommerzielle AI-DJ-Ansätze nutzen Chat-Nachrichten, Umfragen und +teilweise biometrische Signale, um Tracks, Tonfall und Ansagen zu steuern. + +## Signalquellen — von einfach nach heikel + +| Quelle | Aufwand | Latenz | Problem | +| --- | --- | --- | --- | +| Chat, Umfragen, Reaktionen | Gering | Sekunden | Nur die lauteste Minderheit antwortet | +| Voting per Handy-App | Mittel | Sekunden | Braucht Installation/QR und aktive Teilnahme | +| Raummikrofon (Pegel, Jubel) | Gering | Sofort | Verwechselt Musiklautstärke mit Publikum | +| Kamera, Bewegungsanalyse | Hoch | Sofort | **Datenschutz** | +| Wearables, Biometrie | Hoch | Sofort | **Datenschutz**, Ausstattung nötig | + +⚠️ **Kamera und Biometrie sind in der EU kein Detail.** Gesichtserkennung oder +biometrische Auswertung von Gästen fällt unter die DSGVO und braucht eine +tragfähige Rechtsgrundlage — in einem Club praktisch nur mit ausdrücklicher, +freiwilliger Einwilligung. Das ist keine Zeile Code, sondern ein Konzept mit +Beschilderung, Opt-out und Löschfristen. **Empfehlung: mit anonymen, +aggregierten Signalen starten** (Bewegungsenergie im Bild ohne Personenbezug, +Pegel, Votes). Das liefert überraschend viel und vermeidet den ganzen Komplex. + +## Agentenrollen (Entwurf) + +``` + Signale ──► [Crowd-Sensor] ──► [Energie-Analyst] + │ + ▼ + [Set-Planer] ◄── Set-Historie, Uhrzeit + │ │ + ┌────────────┘ └───────────┐ + ▼ ▼ + [Track-Wähler] [Generator] + (Library) (Suno/ElevenLabs) + │ │ + └──────────────┬────────────────┘ + ▼ + [Mix-Engineer] + │ + ▼ + Audio-Engine +``` + +| Rolle | Aufgabe | +| --- | --- | +| **Crowd-Sensor** | Rohsignale einsammeln, normalisieren, anonymisieren | +| **Energie-Analyst** | Aus Signalen einen Zustand ableiten: Energie, Trend, Kipppunkte | +| **Set-Planer** | Dramaturgie über die Zeit — nicht jeder Peak ist der richtige Moment für den Peak | +| **Track-Wähler** | Passendes aus der Library, harmonisch und tempomäßig anschlussfähig | +| **Generator** | Beauftragt neue Tracks, wenn nichts Passendes existiert | +| **Mix-Engineer** | Übergang planen und ausführen: Punkt, Länge, EQ-Kurve | +| **Guardrail** | Vetorecht — verhindert Ausreißer, die den Floor leeren | + +## Die harte Grenze: Zeit + +Das ist der Punkt, an dem die Idee zuerst bricht, deshalb steht er hier und +nicht im Kleingedruckten. + +Ein Dancefloor entscheidet in **Sekunden**. Ein Übergang wird ein bis zwei +Minuten im Voraus geplant. Ein generierter Track braucht — je nach Anbieter — +**Zehner von Sekunden bis Minuten** und ist erst danach überhaupt spielbar. + +Die Schleife „Crowd reagiert → Agent generiert → Track spielt" schließt sich +also **nicht** in Echtzeit. Was funktioniert: + +1. **Vorausschauend generieren.** Der Set-Planer erzeugt permanent Kandidaten + für wahrscheinliche nächste Zustände, bevor sie eintreten. Die Crowd steuert + dann die *Auswahl* aus fertigem Material — das geht sofort. +2. **Echtzeit nur für das, was schnell ist.** EQ, Filter, Loop-Länge, + Stem-Muting, Pattern-Player-Variationen reagieren unmittelbar. Das ist die + Ebene, auf der sich „die Musik reagiert auf uns" tatsächlich anfühlt. +3. **Generierung als langsame Schicht.** Sie formt die nächste halbe Stunde, + nicht die nächsten acht Takte. + +Wer das umdreht und auf Echtzeit-Generierung wartet, baut ein System, das immer +zu spät kommt. + +## Offene Fragen + +- Wie misst man „Energie" so, dass es nicht bloß Lautstärke ist? +- Wie verhindert man Rückkopplung — das System spielt, was gefällt, wird + dadurch immer glatter und verliert jede Kante? +- Wieviel Kontrolle behält der Mensch am Pult? Vollautomat oder Assistent? +- Wo läuft das Ganze? Auf dem Laptop am Pult oder verteilt? + +Die zweite Frage ist die interessanteste. Ein reiner Zustimmungs-Optimierer +konvergiert gegen Mittelmaß. Ein guter DJ nimmt bewusst Risiko — das müsste im +Set-Planer explizit modelliert sein, nicht als Nebeneffekt entstehen. + +## Voraussetzungen + +Bevor das hier überhaupt sinnvoll wird, müssen stehen: + +- Audio-Engine mit zwei Decks und programmierbarem Mixer (Phase 1–2) +- Library mit Metadaten, BPM, Tonart (Phase 4) +- Mindestens ein funktionierender Generierungs-Adapter (siehe [APIS.md](APIS.md)) + +Das Agenten-Team ist die Schicht *über* dem DJ-Tool. Ohne das Tool darunter ist +es nichts. + +## Quellen + +- [hpDJ: An Automated DJ with Floorshow Feedback – Springer](https://link.springer.com/chapter/10.1007/1-4020-4097-0_12) +- [Real-Time Adaptive AI DJ Sets: How Audience Feedback Drives Next-Gen Streams](https://www.djcara.com/blog/real-time-adaptive-ai-dj-sets-how-audience-feedback-drives-next-gen-streams/) +- [The Algorithm of the Dancefloor: How DJs Use Data to Command the Crowd – Audiopool](https://www.audiopool.io/all-posts/the-algorithm-of-the-dancefloor-how-djs-use-data-to-command-the-crowd) diff --git a/docs/APIS.md b/docs/APIS.md new file mode 100644 index 0000000..8f80c54 --- /dev/null +++ b/docs/APIS.md @@ -0,0 +1,124 @@ +# Externe APIs und Datenquellen + +Stand: August 2026. Recherchestand, keine eigenen Tests — Preise und +Lizenzbedingungen vor jeder Integration selbst prüfen, die ändern sich schnell. + +## Kurzfassung + +**Suno hat keine öffentliche API.** Das ist der Blocker für die +Generierungs-Features. Es gibt seit Juli 2026 ein Partner-Programm mit +Bewerbungsformular, aber keine offenen Endpunkte, keine Doku, kein Preismodell, +kein Termin. + +Konsequenz für die Planung: Wir bauen den Generierungs-Teil **hinter einer +Adapter-Schnittstelle** und starten mit einem Anbieter, an den wir heute +herankommen. Suno wird später ein weiterer Adapter, kein Umbau. + +## Musikgenerierung + +### Suno — aktuell nicht verfügbar + +| | | +| --- | --- | +| Status | Keine self-serve öffentliche API | +| Partner-Programm | Seit Juli 2026, kuratierte Auswahl, Bewerbung per Formular | +| Doku/Preise | Nicht veröffentlicht | +| Lizenzlage | Ungeklärt, Verfahren zu Trainingsdaten laufen | + +Suno-CPO Jack Brody hat das Programm im Juli 2026 angekündigt und sucht Apps, +„die Erfahrungen ermöglichen, die generative Musik zum ersten Mal möglich +macht". Ein Multi-Agenten-DJ, der auf Crowd-Reaktionen komponiert, ist genau +dieses Profil — **die Bewerbung ist einen Versuch wert.** + +⚠️ **Nicht empfohlen:** Es kursieren reverse-engineerte „Suno APIs" von +Drittanbietern. Die sind inoffiziell, können jederzeit brechen und bewegen sich +rechtlich auf dünnem Eis. Für ein Projekt, das später kommerziell laufen soll, +kein tragfähiges Fundament. + +### Alternativen, die heute nutzbar sind + +| Anbieter | Stärke | Lizenzlage | Anmerkung | +| --- | --- | --- | --- | +| **ElevenLabs Music** | Vocals, Songs mit Text; klare Konditionen | Auf lizenzierten Daten trainiert, breit kommerziell freigegeben | Zusatzlizenz nötig für Werbung, Film, TV, Games, Enterprise | +| **Stable Audio** (Stability) | Instrumentale Texturen, transparente Preise | Stability-Lizenz | Drittquellen nennen ~0,20 $/Track | +| **Udio** | Von Musikern für Qualität gelobt | Ungeklärt, wie Suno | Gleiches Risikoprofil | +| **Mubert**, **Beatoven** | Hintergrundmusik, Royalty-free | Sauber strukturiert | Weniger „Track", mehr Bett | +| **Loudly** | Volle kommerzielle Rechte am API-Output | Klar | | +| **Google Lyria** | Vocals, lyric-getriebene Songs | Google-Lizenz | | + +**Empfehlung als Startadapter: ElevenLabs Music.** Es gibt einen dokumentierten +`/music/compose`-Endpunkt, die Lizenzlage ist die klarste im Feld, und der +Funktionsumfang deckt das ab, was wir zum Testen der Pipeline brauchen. + +Bekannte Parameter von `/music/compose` (laut Doku-Zusammenfassung): + +- Entweder ein Text-Prompt **oder** ein detaillierter Kompositionsplan — nicht beides +- Songlänge in Millisekunden, 3 000–600 000 (nur zusammen mit Prompt) +- Modellauswahl +- Seed für reproduzierbare Ergebnisse +- Optionales Flag für rein instrumentale Ausgabe + +Der Seed-Parameter ist für uns interessanter, als er klingt: Er macht Varianten +eines Tracks reproduzierbar — brauchbar für Übergänge zwischen zwei Fassungen +desselben Materials. + +### Was die Adapter-Schnittstelle abdecken muss + +Aus dem Vergleich oben ergeben sich die gemeinsamen Nenner: + +``` +generiere(prompt | plan, laenge, instrumental?, seed?) -> Job +status(Job) -> pending | fertig | fehler +hole(Job) -> Audio + Metadaten + Prompt-Historie +``` + +Asynchron, weil alle Anbieter Zeit brauchen. Siehe +[ARCHITEKTUR.md](ARCHITEKTUR.md) — der Audio-Pfad darf davon nie blockiert +werden. + +## Samples und Audiomaterial + +### Freesound + +APIv2 ist die praktikabelste Quelle für Samples mit sauberer Rechtelage. + +- Textsuche über die gesamte Bibliothek +- Endpunkt pro Sound: Metadaten, Tags, Content-Analyse-Features, Preview-URLs, Download +- Lizenzen: **CC0, CC BY, CC BY-NC** +- API frei für nicht-kommerzielle Anwendungen; kommerzielle Nutzung ist separat lizenzierbar +- Seit Juli 2026 gibt es eine „Generative AI"-Präferenz pro Upload + +⚠️ **Zwei Fallstricke:** CC-BY-NC-Material ist für ein kommerzielles Produkt +unbrauchbar — die Lizenz muss pro Sample geprüft und mitgeführt werden, nicht +pauschal angenommen. Und CC BY verlangt Namensnennung, was heißt: Wir brauchen +ein Feld für Attribution in den Track-Metadaten, von Anfang an. + +### Weitere Quellen + +- **Free Music Archive** — ganze Tracks unter CC +- **ccMixter** — Remix-orientiert, Stems und Acapellas +- **Splice** u. ä. — kommerzielle Sample-Abos, API-Zugang meist nicht offen + +## Konkrete nächste Schritte + +- [ ] Bei Suno für das Partner-Programm bewerben (Intake-Formular, Juli 2026 veröffentlicht) +- [ ] ElevenLabs-API-Key besorgen, `/music/compose` einmal manuell gegen curl testen +- [ ] Freesound-API-Key besorgen (kostenlos für nicht-kommerziell) +- [ ] Entscheiden, ob das Produkt kommerziell werden soll — davon hängt die + gesamte Lizenzstrategie ab, und zwar rückwirkend teuer + +Der letzte Punkt ist keine Formalie. Wenn kommerziell, dann fallen CC-BY-NC-Samples, +Suno und Udio vorerst raus, und die Bibliothek muss von Tag eins Lizenzinfos +pro Track mitführen. + +## Quellen + +- [Suno Is Opening an API Partner Program – Digital Music News](https://www.digitalmusicnews.com/2026/07/03/suno-is-opening-an-api-partner-program/) +- [The Suno API Reality: A Developer's Guide – AI/ML API Blog](https://aimlapi.com/blog/the-suno-api-reality) +- [Is There a Public Suno API for Developers in 2026 – MusicGPT](https://musicgpt.com/blog/suno-api) +- [Compose music – ElevenLabs Documentation](https://elevenlabs.io/docs/api-reference/music/compose) +- [Eleven Music, now available in the API – ElevenLabs](https://elevenlabs.io/blog/eleven-music-now-available-in-the-api) +- [AI Music Generation API Comparison: The Developer's Guide 2026](https://aimusicapi.ai/en/blog/ai-music-generation-api-comparison) +- [AI Music Generation 2026: Suno, Udio, ElevenLabs Compared – Digital Applied](https://www.digitalapplied.com/blog/ai-music-generation-platforms-suno-udio-elevenlabs-2026) +- [APIv2 Overview – Freesound API documentation](https://freesound.org/docs/api/overview.html) +- [Freesound FAQ](https://freesound.org/help/faq/) diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md new file mode 100644 index 0000000..2b9f273 --- /dev/null +++ b/docs/BAUSTEINE.md @@ -0,0 +1,130 @@ +# Technische Bausteine + +Was es an fertigen Bibliotheken gibt, damit wir nichts nachbauen, was es schon +gibt. Recherchestand August 2026, nicht selbst getestet. + +## ⚠️ Lizenzen zuerst lesen + +Der Audio-Bereich ist voll von **(A)GPL-Bibliotheken mit kommerzieller +Zweitlizenz**. Das ist kein Randthema: Wer Essentia oder Rubber Band einbaut und +das Produkt später verkaufen will, steht vor der Wahl, den eigenen Code +offenzulegen oder eine Lizenz zu kaufen. Diese Entscheidung fällt **vor** der +Bibliotheksauswahl, nicht danach. + +| Bibliothek | Lizenz | Für ein Closed-Source-Produkt | +| --- | --- | --- | +| Essentia | AGPL | Nein, ohne kommerzielle Lizenz | +| Rubber Band | GPL + kommerziell | Lizenz kaufen | +| aubio | GPL | Problematisch | +| SoundTouch | LGPL | Meist okay (dynamisch gelinkt) | +| librosa | ISC | Frei | +| miniaudio | Public Domain / MIT-0 | Frei | +| CPAL (Rust) | MIT / Apache-2.0 | Frei | +| JUCE | GPL + kommerziell | Lizenz kaufen | +| Demucs | MIT (Code) | Modellgewichte separat prüfen | + +Siehe die offene Frage in [APIS.md](APIS.md#konkrete-nächste-schritte): Ob das +Produkt kommerziell wird, entscheidet hier mit. + +## Audio-Ausgabe und Engine + +Das ist die Phase-0-Entscheidung. + +**Browser — Web Audio API** +Reicht für Decks, EQ, Filter, Gain und Routing. Kein Installationsaufwand, gut +zu debuggen, schnell zu bauen. Grenzen: Latenz ist nicht garantiert, exklusiver +Zugriff auf ein Audio-Interface (ASIO/CoreAudio, getrennter Cue-Ausgang für +Kopfhörer) ist im Browser nicht sauber möglich. **Wichtig:** `playbackRate` +ändert Tempo *und* Tonhöhe — für Pitch-unabhängiges Tempo braucht es zwingend +einen eigenen Time-Stretcher (siehe unten). + +**Nativ** +- [`miniaudio`](https://github.com/mackron/miniaudio) — Single-Header C, gemeinfrei, minimaler Ballast +- [`CPAL`](https://github.com/RustAudio/cpal) — Rust, plattformübergreifend, permissive Lizenz +- `RtAudio` — C++, etabliert +- `JUCE` — volles Framework inkl. UI, aber GPL/kommerziell + +Nativ gibt echte Low-Latency-Ausgabe und mehrere Ausgänge — die Voraussetzung +für Kopfhörer-Cue, also für ernsthaftes DJing. + +**Einschätzung:** Wenn das Ding je auf einer echten Anlage laufen soll, führt +langfristig kein Weg an nativ vorbei — wegen des getrennten Cue-Ausgangs, nicht +wegen der Latenz. Für einen ersten Prototyp ist der Browser trotzdem der +schnellere Weg, und die Analyse-/Library-/Agenten-Schichten sind davon ohnehin +unabhängig. + +## Time-Stretching und Pitch-Shifting + +Kernstück jedes DJ-Tools: Tempo ändern, ohne dass die Tonhöhe mitwandert +(Keylock). + +- **[Rubber Band Library](https://breakfastquay.com/rubberband/)** — Referenzqualität, GPL + kommerziell +- **[SoundTouch](https://www.surina.net/soundtouch/)** — LGPL, solide, weit verbreitet +- **[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch)** — MIT, modern, auch als WASM nutzbar + +Für einen Browser-Prototyp ist `signalsmith-stretch` per WebAssembly der +pragmatischste Einstieg: gute Qualität ohne Lizenzfrage. + +## BPM, Beatgrid, Tonart + +- **[aubio](https://aubio.org/)** — Onset, Beat-Tracking, Pitch; C mit Python-Binding; GPL +- **[Essentia](https://essentia.upf.edu/)** — sehr umfangreich (Rhythmus, Tonalität, spektrale und High-Level-Deskriptoren), C++ mit Python-Wrapper, fertige Extraktoren mit JSON/YAML-Ausgabe; AGPL +- **[librosa](https://librosa.org/)** — Python, ISC, ideal zum Prototypen +- **[madmom](https://github.com/CPJKU/madmom)** — sehr gute Beat-/Downbeat-Erkennung + +Für Tonarterkennung liefert Essentia brauchbare Ergebnisse; als eigenständige +OSS-Alternative gibt es KeyFinder. + +**Praktischer Hinweis:** Beatgrid-Erkennung ist bei geradem 4/4-Material fast +gelöst und bei allem anderen mühsam. Traktor löst das über manuelle +Grid-Korrektur — die brauchen wir auch, egal wie gut der Detektor ist. + +## Stem-Separation + +Vortrainierte Modelle: + +- **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_ft`) — aktueller Qualitätsmaßstab +- **[Open-Unmix](https://github.com/sigsep/open-unmix-pytorch)** (`umxhq`) +- **[Spleeter](https://github.com/deezer/spleeter)** (`spleeter:4stems`) — älter, dafür schnell + +Alle liefern die vier Stems, die auch Traktor nutzt: Drums, Bass, Vocals, Other. + +⚠️ **Nicht echtzeitfähig auf üblicher Hardware.** Traktors Echtzeit-Separation +(iZotope RX) ist genau deshalb ein Verkaufsargument. Für uns heißt das: **Stems +beim Import einmal vorberechnen** und neben dem Track ablegen. Im Deck fühlt +sich das identisch an, kostet nur Plattenplatz statt Latenz. + +## Waveform-Darstellung + +Kein fertiges Paket nötig — Peaks beim Import berechnen, mehrstufig ablegen +(Übersicht grob, Zoom fein), im Canvas/WebGL zeichnen. Die Berechnung gehört zur +Import-Analyse, nicht in den Abspielpfad. Für die Farbcodierung nach +Frequenzbändern (wie Traktor) braucht es zusätzlich eine grobe +Bandaufteilung pro Peak. + +## Traktor-Import + +- **[traktor-nml-utils](https://github.com/wolkenarchitekt/traktor-nml-utils)** — Python, liest und schreibt `collection.nml` + +Damit lassen sich Sammlung, Cue-Points, Beatgrids und Playlists übernehmen. Ein +gutes frühes Feature: viel Nutzen für wenig Aufwand. Siehe +[TRAKTOR-REFERENZ.md](TRAKTOR-REFERENZ.md#datenformat-die-collectionnml). + +## Empfehlung für Phase 0 + +Als Prototyp, um die Stack-Frage empirisch statt theoretisch zu klären: + +1. Ein Deck im Browser, Web Audio API, Datei laden und abspielen +2. Tempo per `signalsmith-stretch` (WASM) ändern, Tonhöhe halten +3. BPM offline mit `librosa` oder `aubio` bestimmen, Ergebnis als JSON danebenlegen +4. Messen: Wie fühlt sich die Latenz an? Wie klingt der Stretcher bei ±8 %? + +Fällt der Test durch, ist der native Weg belegt statt vermutet — und die +Analyse-Skripte aus Schritt 3 bleiben unverändert nutzbar. + +## Quellen + +- [ESSENTIA: an Audio Analysis Library for Music Information Retrieval](https://records.sigmm.org/2014/03/20/essentia-an-open-source-library-for-audio-analysis/) +- [stem-separation – GitHub Topics](https://github.com/topics/stem-separation) +- [Audio Development Tools (ADT) – Sammlung](https://github.com/Yuan-ManX/audio-development-tools) +- [traktor-nml-utils](https://github.com/wolkenarchitekt/traktor-nml-utils) diff --git a/docs/TRAKTOR-REFERENZ.md b/docs/TRAKTOR-REFERENZ.md new file mode 100644 index 0000000..237ae4b --- /dev/null +++ b/docs/TRAKTOR-REFERENZ.md @@ -0,0 +1,114 @@ +# Traktor als Referenz + +Traktor Pro 4 (Native Instruments) ist das Zielbild für die DJ-Seite dieses +Projekts. Dieses Dokument hält fest, was Traktor kann — damit klar ist, was wir +nachbauen, was wir bewusst weglassen und was wir anders machen wollen. + +Quelle: Herstellerangaben und Reviews zu Traktor Pro 4, siehe Abschnitt +[Quellen](#quellen). Nicht selbst nachgemessen. + +## Deck-Modelle + +Traktor hat bis zu vier Decks, und ein Deck ist nicht nur ein Abspieler — es hat +drei Betriebsarten: + +| Modus | Was es tut | +| --- | --- | +| **Track** | Klassische lineare Wiedergabe einer Datei | +| **Remix** | Grid aus Samples/Loops, die synchron getriggert werden | +| **Stem** | Vier getrennte Spuren innerhalb eines Tracks, live zu- und abschaltbar | + +Das ist die wichtigste Erkenntnis für unsere Architektur: **ein Deck ist ein +Container mit austauschbarem Innenleben**, kein Dateiabspieler. Genau da hängt +sich später auch die Generierung ein — ein viertes Deck-Modell. + +## Kernfunktionen + +**Echtzeit-Stem-Separation.** Traktor zerlegt einen beliebigen Track live in +Drums, Bass, Vocals und „Other", angetrieben von iZotope-RX-Algorithmen. Damit +lassen sich Acapellas spontan droppen oder Breakdowns bauen. Siehe +[BAUSTEINE.md](BAUSTEINE.md#stem-separation) — das in Echtzeit hinzubekommen ist +der technisch teuerste Punkt der ganzen Liste. + +**Pattern Player.** Lädt Drum-Kits und erzeugt polyrhythmische Percussion- +Sequenzen direkt aus dem Deck heraus. Mitgeliefert werden Kits von Künstlern wie +Rebekah, Luke Slater, Len Faki, Chris Liebing, Dubfire. + +**Effekte.** Über 40 Effekte, dazu Mixer FX, die *post-fader* liegen — wichtig +für weiche Übergänge, weil der Effekt nicht mit dem Fader weggezogen wird. + +**Beatgrids und Sync.** Automatische BPM-Erkennung mit Beatgrid pro Track, +darauf aufbauend Sync zwischen Decks. + +**Harmonisches Mixen.** Tonarterkennung, damit sich Tracks nicht beißen. + +**Master-Kette.** iZotope Ozone Maximizer auf der Summe. + +**Library.** Smartlists (regelbasierte Playlists), Sampler, Suche über die +Sammlung. + +## Datenformat: die `collection.nml` + +Traktors Sammlung liegt als XML-Datei (`.nml`). Sie enthält **keine Audiodaten**, +sondern: + +- Pfade zu den Mediendateien auf der Platte +- Metadaten (Titel, Artist, Genre, …) +- BPM und Beatgrid +- Tonart +- 1–8 Hot Cues und Loop-Marker +- Playlists + +Das ist für uns direkt relevant: **Traktor-Import ist ein realistisches Feature.** +Wer von Traktor kommt, bringt Jahre an Cue-Points mit. Es gibt fertige Parser, +z. B. [`traktor-nml-utils`](https://github.com/wolkenarchitekt/traktor-nml-utils) +(Python). + +## Controller-Anbindung + +Mappings liegen als `.tsi`-Dateien und werden im Controller Manager verwaltet. +Praktisch jeder Generic-MIDI-Controller lässt sich anlernen. Für uns vorerst ein +Nicht-Ziel, aber die Architektur sollte MIDI-Events nicht ausschließen. + +## Was wir übernehmen — und was nicht + +| Traktor-Feature | Priorität bei uns | Anmerkung | +| --- | --- | --- | +| 2 Decks, Track-Modus | **Phase 1** | Fundament | +| Crossfader, EQ, Gain, Filter | **Phase 1** | Fundament | +| BPM/Beatgrid, Sync | **Phase 2** | Ohne das kein brauchbarer Mix | +| Waveform, Cues, Loops | **Phase 3** | | +| Library + Traktor-Import | **Phase 4** | NML-Parser existiert bereits | +| Tonarterkennung | Phase 4 | Nice-to-have, kein Blocker | +| Stem-Modus | Später | Vorberechnet, nicht Echtzeit (s. u.) | +| 40+ Effekte | Später | Wenige gute schlagen viele mittelmäßige | +| Pattern Player | Später | Interessant als Agenten-Spielwiese | +| 4 Decks | Später | 2 reichen lange | +| Remix-Decks | Später | | +| MIDI/Controller | Nicht-Ziel (vorerst) | | +| DVS/Timecode-Vinyl | **Nicht-Ziel** | Riesiger Aufwand, enge Zielgruppe | + +**Zur Stem-Separation:** Traktor macht das in Echtzeit. Wir sollten das *nicht* +direkt versuchen. Stems beim Import einmal vorberechnen und ablegen bringt +denselben Nutzen im Deck, ohne das Latenzproblem. Echtzeit ist eine +Optimierung, kein Startpunkt. + +## Wo wir bewusst abweichen + +Traktor ist ein geschlossenes Werkzeug für einen Menschen an zwei Decks. Unser +Unterschied liegt woanders: + +1. **Generierte Tracks sind gleichberechtigte Quelle** — siehe [APIS.md](APIS.md) +2. **Die Crowd ist ein Eingabegerät** — siehe [AGENTEN.md](AGENTEN.md) + +Alles, was Traktor besser kann und wir nie einholen, ist damit kein Problem — +solange diese zwei Achsen funktionieren. + +## Quellen + +- [Traktor Pro 4 – Native Instruments (Review, MusicTech)](https://musictech.com/reviews/dj/native-instruments-traktor-4-pro/) +- [Native Instruments releases Traktor Pro 4 – Decoded Magazine](https://www.decodedmagazine.com/native-instruments-releases-traktor-pro-4-with-incredible-new-features/) +- [Traktor Pro 4 Produktseite – DJ TechTools](https://store.djtechtools.com/products/traktor-pro-4) +- [Configuring MIDI Controller for Controlling Traktor – Native Instruments](https://www.native-instruments.com/ni-tech-manuals/traktor-pro-manual/en/configuring-midi-controller-for-controlling-traktor) +- [traktor-nml-utils – NML-Parser](https://github.com/wolkenarchitekt/traktor-nml-utils) +- [Import Traktor – Lexicon DJ (NML-Feldbeschreibung)](https://www.lexicondj.com/manual/import-traktor) From 949613f2324d1c51a6d7f5ff1d88798058deba6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 11:04:19 +0000 Subject: [PATCH 02/70] feat: native Rust audio core with one working deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the stack decision and implements phase 0 against it. Native over browser because of the separate headphone cue output — not latency. Serious DJing needs pre-listening on a second output, which browsers do not offer cleanly. Rust over C++ because the whole dependency set stays permissive (cpal Apache-2.0, symphonia MPL-2.0), so the still-open question of whether this becomes commercial is not pre-decided by the toolkit. audio-core: - track: decode mp3/flac/wav/aac/ogg to interleaved stereo, resample once at load so the callback carries no rate conversion - stretch: WSOLA time-stretch, written here rather than pulling in Rubber Band, which is GPL. The ideal read position advances undisturbed and the correlation search only corrects locally — accumulating the found offset would drift the timeline - deck: control via atomics only, so the audio path has no lock and no allocation - player: cpal output, generic over the device sample format musik-cli drives one deck from the shell: play/pause, tempo, keylock, seek. Verified by 11 tests: keylock holds pitch at 0.92x, 1.06x and 1.20x; varispeed shifts pitch with tempo as it should; the timeline does not drift; decode, mono-to-stereo and resampling preserve length and pitch. Sound on real hardware is not verified — no audio device here. Adds CI running fmt, clippy -D warnings and tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- .github/workflows/ci.yml | 37 ++ .gitignore | 3 + Cargo.lock | 1062 ++++++++++++++++++++++++++++++ Cargo.toml | 10 + README.md | 67 +- crates/audio-core/Cargo.toml | 9 + crates/audio-core/src/deck.rs | 291 ++++++++ crates/audio-core/src/error.rs | 24 + crates/audio-core/src/lib.rs | 19 + crates/audio-core/src/player.rs | 115 ++++ crates/audio-core/src/stretch.rs | 238 +++++++ crates/audio-core/src/testing.rs | 37 ++ crates/audio-core/src/track.rs | 251 +++++++ crates/musik-cli/Cargo.toml | 8 + crates/musik-cli/src/main.rs | 140 ++++ docs/BAUSTEINE.md | 64 +- 16 files changed, 2344 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/audio-core/Cargo.toml create mode 100644 crates/audio-core/src/deck.rs create mode 100644 crates/audio-core/src/error.rs create mode 100644 crates/audio-core/src/lib.rs create mode 100644 crates/audio-core/src/player.rs create mode 100644 crates/audio-core/src/stretch.rs create mode 100644 crates/audio-core/src/testing.rs create mode 100644 crates/audio-core/src/track.rs create mode 100644 crates/musik-cli/Cargo.toml create mode 100644 crates/musik-cli/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a648dd3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: fmt, clippy, tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # CPAL bindet unter Linux gegen ALSA. + - name: ALSA-Header installieren + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Formatierung + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Tests + run: cargo test --all diff --git a/.gitignore b/.gitignore index a111428..12662f0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .env.* !.env.example +# Rust — Cargo.lock wird bewusst eingecheckt (Anwendung, keine Bibliothek) +/target/ + # Node node_modules/ dist/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8d8c866 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1062 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "audio-core" +version = "0.1.0" +dependencies = [ + "cpal", + "symphonia", + "thiserror 2.0.20", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "musik-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "audio-core", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f52b2ba --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +resolver = "2" +members = ["crates/audio-core", "crates/musik-cli"] + +[workspace.package] +edition = "2021" +publish = false + +[workspace.dependencies] +audio-core = { path = "crates/audio-core" } diff --git a/README.md b/README.md index b0b175b..9815e29 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den Reaktionen der Crowd formt. -Status: **Konzeptphase.** Noch kein Code. Recherche, Zielbild und Roadmap -stehen. +Status: **Phase 0.** Ein Deck läuft — Wiedergabe, Tempo mit und ohne Keylock, +Springen. Nativ in Rust. ## Zielbild @@ -48,7 +48,7 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). | Phase | Inhalt | Abhängig von | | --- | --- | --- | -| **0** | Stack-Entscheidung per Prototyp: ein Deck, Play/Pause/Pitch mit Keylock | — | +| ~~0~~ | ~~Stack-Entscheidung per Prototyp: ein Deck, Play/Pause/Pitch mit Keylock~~ ✅ | — | | 1 | Zweites Deck, Crossfader, EQ, Filter — ein Mix ist durchgängig spielbar | 0 | | 2 | BPM-Analyse, Beatgrid (inkl. manueller Korrektur), Sync | 1 | | 3 | Waveforms, Cue-Points, Loops | 2 | @@ -63,18 +63,22 @@ lassen sich auch gegen eine reine Datei-Library testen. ## Tech-Stack -**Offen — das ist die Phase-0-Entscheidung.** Die Frage ist nicht Latenz, -sondern der getrennte Kopfhörer-Cue-Ausgang: den gibt es im Browser nicht -sauber, und ohne ihn ist ernsthaftes DJing nicht möglich. +**Nativ, in Rust.** Ausschlaggebend war der getrennte Kopfhörer-Cue-Ausgang, +nicht die Latenz — ohne Vorhören ist ernsthaftes DJing nicht möglich, und im +Browser gibt es das nicht sauber. Rust statt C++ wegen der Lizenzen: der ganze +Stack ist permissiv, damit die offene Frage „wird das kommerziell?" nicht vom +Werkzeugkasten vorentschieden wird. -- **Browser (Web Audio API)** — schneller Prototyp, kein Install, gut zu - debuggen. Kein exklusiver Interface-Zugriff, kein zweiter Ausgang. -- **Nativ (Rust/C++)** — echte Low-Latency-Ausgabe, mehrere Ausgänge, mehr - Aufwand. +| Crate | Lizenz | Wofür | +| --- | --- | --- | +| `cpal` | Apache-2.0 | Audioausgabe | +| `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | + +Die Zeitstreckung (WSOLA) ist selbst implementiert — Rubber Band wäre besser, +ist aber GPL. Details und Begründung in +[docs/BAUSTEINE.md](docs/BAUSTEINE.md#phase-0-was-gebaut-ist). -Analyse-, Library- und Agenten-Schicht sind von dieser Entscheidung unabhängig -und können parallel entstehen. Konkreter Testaufbau zur Entscheidung in -[docs/BAUSTEINE.md](docs/BAUSTEINE.md#empfehlung-für-phase-0). +UI ist noch offen und kommt frühestens mit Phase 1. ## Nicht-Ziele (vorerst) @@ -86,15 +90,42 @@ und können parallel entstehen. Konkreter Testaufbau zur Entscheidung in ## Offene Entscheidungen -1. **Browser oder nativ?** → Phase 0 -2. **Wird das Produkt kommerziell?** Davon hängt die gesamte Lizenzstrategie ab, +1. **Wird das Produkt kommerziell?** Davon hängt die gesamte Lizenzstrategie ab, und zwar rückwirkend teuer — bei den Bibliotheken (AGPL/GPL), bei den Samples (CC BY-NC) und bei den Generierungs-Anbietern (Suno und Udio haben ungeklärte Lizenzlage). -3. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — +2. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — prägt das Agenten-Design. ## Setup -Kommt mit Phase 0, sobald der Stack steht. Für API-Keys siehe -[`.env.example`](.env.example). +Voraussetzung: Rust (aktuelles Stable). Unter Linux zusätzlich die +ALSA-Header — `sudo apt install libasound2-dev`. + +```sh +cargo build +cargo test +``` + +Ein Deck fahren: + +```sh +cargo run -p musik-cli -- /pfad/zum/track.mp3 +``` + +Danach im Prompt: + +| Befehl | Wirkung | +| --- | --- | +| `p` | Play/Pause | +| `t +6` | Tempo +6 % (oder `t 1.06` als Verhältnis) | +| `k` | Keylock an/aus — Tonhöhe beim Tempowechsel halten | +| `s 30` | Auf Sekunde 30 springen | +| `i` | Status | +| `q` | Beenden | + +Der Vergleich, um den es geht: `t +6` einmal mit und einmal ohne `k`. Mit +Keylock bleibt die Tonhöhe stehen, ohne wandert sie hoch wie beim +Plattenspieler. + +Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/audio-core/Cargo.toml b/crates/audio-core/Cargo.toml new file mode 100644 index 0000000..a5f973e --- /dev/null +++ b/crates/audio-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "audio-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +cpal = "0.18.1" +symphonia = { version = "0.6.0", default-features = false, features = ["mp3", "flac", "wav", "pcm", "isomp4", "aac", "vorbis", "ogg"] } +thiserror = "2.0.20" diff --git a/crates/audio-core/src/deck.rs b/crates/audio-core/src/deck.rs new file mode 100644 index 0000000..d7de98f --- /dev/null +++ b/crates/audio-core/src/deck.rs @@ -0,0 +1,291 @@ +//! Deck-Zustand und Rendering. +//! +//! Die Steuerung läuft ausschließlich über Atomics: Der Audio-Callback liest +//! sie, der Rest der Anwendung schreibt sie. Damit braucht der Abspielpfad kein +//! Lock, keine Allokation und keine Zuteilung von Speicher — die drei Dinge, +//! die in einem Audio-Callback zu Aussetzern führen. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; + +use crate::stretch::Wsola; +use crate::track::{CHANNELS, Track}; + +/// Unterhalb dieser Abweichung von 1.0 wird direkt kopiert statt gestreckt. +const TEMPO_EPS: f64 = 1e-4; + +/// Maximale Blockgröße, die am Stück gerendert wird. +const SCRATCH_FRAMES: usize = 4096; + +pub struct DeckState { + playing: AtomicBool, + keylock: AtomicBool, + tempo_bits: AtomicU32, + position: AtomicU64, + seek_to: AtomicI64, + finished: AtomicBool, +} + +impl Default for DeckState { + fn default() -> Self { + Self::new() + } +} + +impl DeckState { + pub fn new() -> Self { + DeckState { + playing: AtomicBool::new(false), + keylock: AtomicBool::new(true), + tempo_bits: AtomicU32::new(1.0f32.to_bits()), + position: AtomicU64::new(0), + seek_to: AtomicI64::new(-1), + finished: AtomicBool::new(false), + } + } + + pub fn is_playing(&self) -> bool { + self.playing.load(Ordering::Relaxed) + } + + pub fn set_playing(&self, on: bool) { + if on { + self.finished.store(false, Ordering::Relaxed); + } + self.playing.store(on, Ordering::Relaxed); + } + + pub fn toggle_playing(&self) -> bool { + let next = !self.is_playing(); + self.set_playing(next); + next + } + + pub fn keylock(&self) -> bool { + self.keylock.load(Ordering::Relaxed) + } + + pub fn set_keylock(&self, on: bool) { + self.keylock.store(on, Ordering::Relaxed); + } + + /// Tempoverhältnis: 1.0 = Originalgeschwindigkeit, 1.06 = +6 %. + pub fn tempo(&self) -> f32 { + f32::from_bits(self.tempo_bits.load(Ordering::Relaxed)) + } + + pub fn set_tempo(&self, ratio: f32) { + let clamped = ratio.clamp(0.25, 4.0); + self.tempo_bits.store(clamped.to_bits(), Ordering::Relaxed); + } + + pub fn position_frames(&self) -> u64 { + self.position.load(Ordering::Relaxed) + } + + pub fn seek_frames(&self, frame: u64) { + self.seek_to.store(frame as i64, Ordering::Relaxed); + } + + /// Track ist bis zum Ende gelaufen. + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } +} + +/// Die Audio-Thread-Seite eines Decks. Wird vom Callback exklusiv besessen. +pub struct Voice { + track: Arc, + state: Arc, + wsola: Wsola, + scratch: Vec, + pos: f64, + stretching: bool, +} + +impl Voice { + pub fn new(track: Arc, state: Arc) -> Self { + Voice { + track, + state, + wsola: Wsola::new(), + scratch: vec![0.0; SCRATCH_FRAMES * CHANNELS], + pos: 0.0, + stretching: false, + } + } + + /// Schreibt einen Block in den Gerätepuffer. `out_channels` ist die + /// Kanalzahl des Geräts, nicht die des Tracks. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + if out_channels == 0 { + return; + } + let total_frames = out.len() / out_channels; + let mut done = 0; + + while done < total_frames { + let n = (total_frames - done).min(SCRATCH_FRAMES); + self.fill_stereo(n); + + for i in 0..n { + let l = self.scratch[i * CHANNELS]; + let r = self.scratch[i * CHANNELS + 1]; + let base = (done + i) * out_channels; + let frame = &mut out[base..base + out_channels]; + if out_channels == 1 { + frame[0] = 0.5 * (l + r); + } else { + frame[0] = l; + frame[1] = r; + frame[2..].fill(0.0); + } + } + + done += n; + } + } + + /// Füllt die ersten `frames` Frames von `self.scratch` mit Stereo-Material. + fn fill_stereo(&mut self, frames: usize) { + let len = frames * CHANNELS; + + if let Some(target) = self.take_seek() { + self.pos = target; + self.wsola.reset(); + } + + if !self.state.is_playing() { + self.scratch[..len].fill(0.0); + return; + } + + let tempo = self.state.tempo() as f64; + let want_stretch = self.state.keylock() && (tempo - 1.0).abs() > TEMPO_EPS; + + if want_stretch != self.stretching { + self.wsola.reset(); + self.stretching = want_stretch; + } + + // Getrennte Feldzugriffe, damit der Borrow-Checker `scratch` von + // `wsola`/`track`/`pos` unterscheiden kann. + let out = &mut self.scratch[..len]; + + let alive = if want_stretch { + self.wsola + .render(&self.track.samples, tempo, &mut self.pos, out) + } else { + varispeed(&self.track.samples, tempo, &mut self.pos, out) + }; + + if !alive { + self.state.playing.store(false, Ordering::Relaxed); + self.state.finished.store(true, Ordering::Relaxed); + } + + let frames_total = self.track.frames() as f64; + let clamped = self.pos.clamp(0.0, frames_total); + self.state.position.store(clamped as u64, Ordering::Relaxed); + } + + fn take_seek(&mut self) -> Option { + let raw = self.state.seek_to.swap(-1, Ordering::Relaxed); + if raw < 0 { + return None; + } + let max = self.track.frames().saturating_sub(1) as i64; + Some(raw.min(max) as f64) + } +} + +/// Abspielen mit veränderter Rate — Tempo *und* Tonhöhe wandern mit, wie beim +/// Pitchfader eines Plattenspielers. Das ist der Modus ohne Keylock. +fn varispeed(src: &[f32], ratio: f64, pos: &mut f64, out: &mut [f32]) -> bool { + let frames = src.len() / CHANNELS; + if frames < 2 { + out.fill(0.0); + return false; + } + + let blocks = out.len() / CHANNELS; + for i in 0..blocks { + let idx = pos.floor(); + if idx < 0.0 || idx as usize + 1 >= frames { + out[i * CHANNELS..].fill(0.0); + return false; + } + let idx = idx as usize; + let frac = (*pos - idx as f64) as f32; + + for c in 0..CHANNELS { + let a = src[idx * CHANNELS + c]; + let b = src[(idx + 1) * CHANNELS + c]; + out[i * CHANNELS + c] = a + (b - a) * frac; + } + + *pos += ratio; + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{dominant_freq, sine}; + + const RATE: u32 = 44_100; + + /// Ohne Keylock wandert die Tonhöhe mit dem Tempo — wie beim Plattenspieler. + #[test] + fn varispeed_verschiebt_die_tonhoehe() { + let src = sine(440.0, RATE, 10.0); + let mut pos = 0.0; + let mut out = vec![0.0; 4 * RATE as usize * CHANNELS]; + + assert!(varispeed(&src, 1.06, &mut pos, &mut out)); + + let freq = dominant_freq(&out, RATE); + let erwartet = 440.0 * 1.06; + assert!( + (freq - erwartet).abs() < 6.0, + "erwartet ~{erwartet:.1} Hz, gemessen {freq:.1} Hz" + ); + } + + #[test] + fn varispeed_meldet_das_ende() { + let src = sine(440.0, RATE, 0.1); + let mut pos = 0.0; + let mut out = vec![0.5; RATE as usize * CHANNELS]; + + assert!(!varispeed(&src, 1.0, &mut pos, &mut out)); + assert_eq!(out.last().copied(), Some(0.0)); + } + + #[test] + fn tempo_wird_auf_sinnvolle_grenzen_geklemmt() { + let state = DeckState::new(); + + state.set_tempo(99.0); + assert_eq!(state.tempo(), 4.0); + + state.set_tempo(0.0); + assert_eq!(state.tempo(), 0.25); + + state.set_tempo(1.06); + assert!((state.tempo() - 1.06).abs() < 1e-6); + } + + #[test] + fn play_setzt_das_ende_flag_zurueck() { + let state = DeckState::new(); + state.finished.store(true, Ordering::Relaxed); + + state.set_playing(true); + + assert!(!state.is_finished()); + assert!(state.is_playing()); + } +} diff --git a/crates/audio-core/src/error.rs b/crates/audio-core/src/error.rs new file mode 100644 index 0000000..4349132 --- /dev/null +++ b/crates/audio-core/src/error.rs @@ -0,0 +1,24 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AudioError { + #[error("Datei konnte nicht gelesen werden: {0}")] + Io(#[from] std::io::Error), + + #[error("Dekodierung fehlgeschlagen: {0}")] + Decode(#[from] symphonia::core::errors::Error), + + #[error("keine Audiospur in der Datei gefunden")] + NoAudioTrack, + + #[error("kein Ausgabegerät gefunden")] + NoOutputDevice, + + #[error("Audio-Gerät: {0}")] + Device(#[from] cpal::Error), + + #[error("Sample-Format {0} wird nicht unterstützt")] + UnsupportedSampleFormat(String), +} + +pub type Result = std::result::Result; diff --git a/crates/audio-core/src/lib.rs b/crates/audio-core/src/lib.rs new file mode 100644 index 0000000..480bbc8 --- /dev/null +++ b/crates/audio-core/src/lib.rs @@ -0,0 +1,19 @@ +//! Audio-Kern für ein Traktor-artiges DJ-Tool. +//! +//! Phase 0: ein Deck, Wiedergabe, Tempo mit und ohne Keylock. Der Zweck ist, +//! die Stack-Entscheidung nativ vs. Browser empirisch zu belegen — siehe +//! `docs/BAUSTEINE.md`. + +pub mod deck; +pub mod error; +pub mod player; +pub mod stretch; +pub mod track; + +#[cfg(test)] +mod testing; + +pub use deck::DeckState; +pub use error::{AudioError, Result}; +pub use player::Player; +pub use track::Track; diff --git a/crates/audio-core/src/player.rs b/crates/audio-core/src/player.rs new file mode 100644 index 0000000..88759fd --- /dev/null +++ b/crates/audio-core/src/player.rs @@ -0,0 +1,115 @@ +//! Anbindung an das Ausgabegerät via CPAL. +//! +//! Der Track wird beim Öffnen einmalig auf die Geräte-Samplerate gebracht. +//! Dadurch muss der Callback nur noch Tempo behandeln und keine +//! Ratenkonvertierung — eine Rechenstufe weniger im zeitkritischen Pfad. + +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample, StreamConfig}; + +use crate::deck::{DeckState, Voice}; +use crate::error::{AudioError, Result}; +use crate::track::Track; + +pub struct Player { + state: Arc, + sample_rate: u32, + device_name: String, + // Das Abreißen des Streams stoppt die Wiedergabe — er muss am Leben bleiben. + _stream: cpal::Stream, +} + +impl Player { + /// Öffnet das Standard-Ausgabegerät und legt den Track auf ein Deck. + pub fn open(track: Track) -> Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or(AudioError::NoOutputDevice)?; + let device_name = device + .id() + .map(|id| id.to_string()) + .unwrap_or_else(|_| "unbekannt".to_string()); + + let supported = device.default_output_config()?; + let sample_format = supported.sample_format(); + let config: StreamConfig = supported.into(); + let sample_rate = config.sample_rate; + + let track = Arc::new(track.resampled_to(sample_rate)); + let state = Arc::new(DeckState::new()); + let voice = Voice::new(Arc::clone(&track), Arc::clone(&state)); + + let stream = match sample_format { + SampleFormat::F32 => build::(&device, config, voice)?, + SampleFormat::I16 => build::(&device, config, voice)?, + SampleFormat::I32 => build::(&device, config, voice)?, + SampleFormat::U16 => build::(&device, config, voice)?, + SampleFormat::U8 => build::(&device, config, voice)?, + SampleFormat::F64 => build::(&device, config, voice)?, + other => { + return Err(AudioError::UnsupportedSampleFormat(other.to_string())); + } + }; + + stream.play()?; + + Ok(Player { + state, + sample_rate, + device_name, + _stream: stream, + }) + } + + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn position_secs(&self) -> f64 { + self.state.position_frames() as f64 / self.sample_rate as f64 + } + + pub fn seek_secs(&self, secs: f64) { + let frame = (secs.max(0.0) * self.sample_rate as f64) as u64; + self.state.seek_frames(frame); + } +} + +fn build(device: &cpal::Device, config: StreamConfig, mut voice: Voice) -> Result +where + T: SizedSample + FromSample, +{ + let channels = config.channels as usize; + // Zwischenpuffer in f32; der Callback allokiert dadurch nicht. + let mut block: Vec = Vec::new(); + + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + if block.len() < data.len() { + block.resize(data.len(), 0.0); + } + let scratch = &mut block[..data.len()]; + voice.render(scratch, channels); + + for (dst, src) in data.iter_mut().zip(scratch.iter()) { + *dst = T::from_sample(*src); + } + }, + |err| eprintln!("Audio-Stream: {err}"), + None, + )?; + + Ok(stream) +} diff --git a/crates/audio-core/src/stretch.rs b/crates/audio-core/src/stretch.rs new file mode 100644 index 0000000..d6d1b70 --- /dev/null +++ b/crates/audio-core/src/stretch.rs @@ -0,0 +1,238 @@ +//! WSOLA-Zeitstreckung — Tempo ändern, Tonhöhe behalten (Keylock). +//! +//! Das Verfahren schneidet überlappende Frames aus dem Quellmaterial und +//! überblendet sie mit einem Hann-Fenster. Damit an der Nahtstelle keine +//! Phasensprünge hörbar werden, wird die Schnittposition lokal um bis zu +//! `SEARCH` Samples verschoben — dorthin, wo die Kreuzkorrelation mit dem +//! bereits ausgegebenen Übergang am größten ist. +//! +//! Wichtig: Die *ideale* Leseposition läuft ungestört weiter, die Suche ist nur +//! eine lokale Korrektur. Würde man den gefundenen Versatz aufaddieren, driftete +//! die Wiedergabe über die Zeit aus dem Takt. +//! +//! Qualität: brauchbar für einen Prototyp und für die üblichen ±8 % im DJ-Betrieb. +//! Für extreme Faktoren oder Produktionsqualität siehe `docs/BAUSTEINE.md` — +//! Rubber Band oder signalsmith-stretch spielen in einer anderen Liga. + +use crate::track::CHANNELS; + +const FRAME: usize = 2048; +const HOP: usize = FRAME / 2; +const OVERLAP: usize = FRAME - HOP; +const SEARCH: usize = 256; +const CORR_LEN: usize = 256; + +pub struct Wsola { + window: Vec, + /// Overlap-Add-Akkumulator, `FRAME` Frames à `CHANNELS`. + accum: Vec, + /// Fertig produzierter, noch nicht abgeholter Hop. + fifo: Vec, + fifo_read: usize, +} + +impl Default for Wsola { + fn default() -> Self { + Self::new() + } +} + +impl Wsola { + pub fn new() -> Self { + // Periodisches Hann-Fenster: bei 50 % Überlappung summieren sich zwei + // benachbarte Fenster exakt zu 1.0, es braucht also keine Normierung. + let window = (0..FRAME) + .map(|i| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / FRAME as f32).cos()) + .collect(); + + Wsola { + window, + accum: vec![0.0; FRAME * CHANNELS], + fifo: vec![0.0; HOP * CHANNELS], + fifo_read: HOP * CHANNELS, + } + } + + /// Verwirft den internen Zustand — nach einem Sprung oder Moduswechsel. + pub fn reset(&mut self) { + self.accum.fill(0.0); + self.fifo_read = self.fifo.len(); + } + + /// Füllt `out` (interleaved Stereo) aus `src`. + /// + /// `pos` ist die Leseposition in Quell-Frames und wird fortgeschrieben, + /// `ratio` das Tempoverhältnis (1.0 = Original). Gibt `false` zurück, wenn + /// die Quelle erschöpft ist; der Rest von `out` ist dann Stille. + pub fn render(&mut self, src: &[f32], ratio: f64, pos: &mut f64, out: &mut [f32]) -> bool { + let mut written = 0; + + while written < out.len() { + if self.fifo_read < self.fifo.len() { + let n = (out.len() - written).min(self.fifo.len() - self.fifo_read); + out[written..written + n] + .copy_from_slice(&self.fifo[self.fifo_read..self.fifo_read + n]); + written += n; + self.fifo_read += n; + continue; + } + + if !self.produce_hop(src, ratio, pos) { + out[written..].fill(0.0); + return false; + } + } + + true + } + + /// Erzeugt genau einen Hop Ausgangsmaterial in den FIFO. + fn produce_hop(&mut self, src: &[f32], ratio: f64, pos: &mut f64) -> bool { + let src_frames = src.len() / CHANNELS; + if src_frames < FRAME { + return false; + } + + let ideal = pos.round(); + if ideal < 0.0 { + return false; + } + let max_start = src_frames - FRAME; + if ideal as usize > max_start { + return false; + } + + let start = self.best_offset(src, ideal as usize, max_start); + + for i in 0..FRAME { + let w = self.window[i]; + let s = (start + i) * CHANNELS; + let a = i * CHANNELS; + for c in 0..CHANNELS { + self.accum[a + c] += src[s + c] * w; + } + } + + self.fifo.copy_from_slice(&self.accum[..HOP * CHANNELS]); + self.fifo_read = 0; + + self.accum.copy_within(HOP * CHANNELS.., 0); + self.accum[OVERLAP * CHANNELS..].fill(0.0); + + // Ideale Position fortschreiben, nicht die gefundene — sonst driftet es. + *pos += HOP as f64 * ratio; + true + } + + /// Sucht im Fenster ±`SEARCH` um `ideal` die Position, deren Wellenform am + /// besten an den bereits ausgegebenen Übergang anschließt. + fn best_offset(&self, src: &[f32], ideal: usize, max_start: usize) -> usize { + let lo = ideal.saturating_sub(SEARCH); + let hi = (ideal + SEARCH).min(max_start); + if lo >= hi { + return ideal.min(max_start); + } + + let mut best = ideal.min(max_start); + let mut best_score = f32::NEG_INFINITY; + + for cand in lo..=hi { + let mut dot = 0.0f32; + let mut energy = 0.0f32; + + for i in 0..CORR_LEN { + let a = i * CHANNELS; + let reference = self.accum[a] + self.accum[a + 1]; + let s = (cand + i) * CHANNELS; + let sample = src[s] + src[s + 1]; + dot += reference * sample; + energy += sample * sample; + } + + // Normieren, damit nicht einfach die lauteste Stelle gewinnt. + let score = dot / (energy.sqrt() + 1e-9); + if score > best_score { + best_score = score; + best = cand; + } + } + + best + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{dominant_freq, sine}; + + const RATE: u32 = 44_100; + + /// Der Kern von Keylock: Tempo ändern, Tonhöhe halten. + #[test] + fn keylock_haelt_die_tonhoehe() { + for ratio in [0.92_f64, 1.06, 1.20] { + let src = sine(440.0, RATE, 10.0); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.0; 4 * RATE as usize * CHANNELS]; + + assert!( + wsola.render(&src, ratio, &mut pos, &mut out), + "Quelle war zu früh erschöpft bei ratio={ratio}" + ); + + let freq = dominant_freq(&out, RATE); + assert!( + (freq - 440.0).abs() < 13.0, + "ratio={ratio}: erwartet ~440 Hz, gemessen {freq:.1} Hz" + ); + } + } + + /// Bei ratio=1.06 müssen 4 s Ausgabe rund 4,24 s Quelle verbrauchen. + #[test] + fn zeitachse_stimmt() { + let src = sine(440.0, RATE, 20.0); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let out_frames = 4 * RATE as usize; + let mut out = vec![0.0; out_frames * CHANNELS]; + + assert!(wsola.render(&src, 1.06, &mut pos, &mut out)); + + let erwartet = out_frames as f64 * 1.06; + // Toleranz: ein Hop, der wegen des FIFO noch nicht ausgegeben wurde. + assert!( + (pos - erwartet).abs() < (FRAME as f64), + "Position {pos:.0} weicht von erwarteten {erwartet:.0} ab" + ); + } + + /// Läuft die Quelle aus, meldet render() das und liefert Stille statt Müll. + #[test] + fn quellenende_wird_gemeldet() { + let src = sine(440.0, RATE, 0.5); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.123; 2 * RATE as usize * CHANNELS]; + + assert!(!wsola.render(&src, 1.0, &mut pos, &mut out)); + assert_eq!( + out.last().copied(), + Some(0.0), + "nach Quellenende muss Stille stehen" + ); + } + + /// Ein zu kurzer Puffer darf nicht paniken. + #[test] + fn zu_kurze_quelle_paniert_nicht() { + let src = sine(440.0, RATE, 0.001); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.0; 512 * CHANNELS]; + + assert!(!wsola.render(&src, 1.0, &mut pos, &mut out)); + } +} diff --git a/crates/audio-core/src/testing.rs b/crates/audio-core/src/testing.rs new file mode 100644 index 0000000..a31057c --- /dev/null +++ b/crates/audio-core/src/testing.rs @@ -0,0 +1,37 @@ +//! Hilfsmittel für die Tests des Audio-Kerns. + +use crate::track::CHANNELS; + +/// Erzeugt einen Sinus als interleaved Stereo. +pub fn sine(freq: f32, rate: u32, secs: f32) -> Vec { + let frames = (rate as f32 * secs) as usize; + let mut out = vec![0.0; frames * CHANNELS]; + let step = 2.0 * std::f32::consts::PI * freq / rate as f32; + + for i in 0..frames { + let v = (step * i as f32).sin(); + out[i * CHANNELS] = v; + out[i * CHANNELS + 1] = v; + } + out +} + +/// Schätzt die Grundfrequenz über positive Nulldurchgänge des linken Kanals. +/// +/// Für einen reinen Sinus genau genug — und anders als eine FFT ohne +/// zusätzliche Abhängigkeit zu haben. +pub fn dominant_freq(samples: &[f32], rate: u32) -> f32 { + let left: Vec = samples.iter().step_by(CHANNELS).copied().collect(); + if left.len() < 2 { + return 0.0; + } + + let mut cycles = 0u32; + for w in left.windows(2) { + if w[0] <= 0.0 && w[1] > 0.0 { + cycles += 1; + } + } + + cycles as f32 * rate as f32 / left.len() as f32 +} diff --git a/crates/audio-core/src/track.rs b/crates/audio-core/src/track.rs new file mode 100644 index 0000000..a8fe7eb --- /dev/null +++ b/crates/audio-core/src/track.rs @@ -0,0 +1,251 @@ +//! Dekodieren einer Audiodatei in einen zusammenhängenden Stereo-Puffer. +//! +//! Bewusst „alles in den Speicher": Ein Deck darf im Audio-Callback nicht auf +//! die Platte warten, und ein 5-Minuten-Track kostet als f32-Stereo rund 100 MB +//! — vertretbar für Phase 0. Streaming von Platte kommt, wenn die Library steht. + +use std::fs::File; +use std::path::Path; + +use symphonia::core::formats::TrackType; +use symphonia::core::formats::probe::Hint; +use symphonia::core::io::MediaSourceStream; + +use crate::error::{AudioError, Result}; + +/// Ein vollständig dekodierter Track als interleaved Stereo (L, R, L, R, …). +pub struct Track { + pub samples: Vec, + pub sample_rate: u32, +} + +pub const CHANNELS: usize = 2; + +impl Track { + pub fn frames(&self) -> usize { + self.samples.len() / CHANNELS + } + + pub fn duration_secs(&self) -> f64 { + self.frames() as f64 / self.sample_rate as f64 + } + + /// Dekodiert eine Datei. Format wird über Inhalt und Dateiendung erkannt. + pub fn decode_file(path: &Path) -> Result { + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let mut format = symphonia::default::get_probe().probe( + &hint, + mss, + Default::default(), + Default::default(), + )?; + + let track = format + .default_track(TrackType::Audio) + .ok_or(AudioError::NoAudioTrack)?; + let track_id = track.id; + let params = track + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or(AudioError::NoAudioTrack)?; + + let mut decoder = + symphonia::default::get_codecs().make_audio_decoder(params, &Default::default())?; + + let mut samples: Vec = Vec::new(); + let mut scratch: Vec = Vec::new(); + let mut sample_rate = 0u32; + + while let Some(packet) = format.next_packet()? { + if packet.track_id != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + // Einzelne defekte Pakete überspringen statt den ganzen Track + // zu verlieren — kommt bei geschnittenen MP3s regelmäßig vor. + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(e.into()), + }; + + let frames = decoded.frames(); + if frames == 0 { + continue; + } + sample_rate = decoded.spec().rate(); + let src_channels = decoded.samples_interleaved() / frames; + + decoded.copy_to_vec_interleaved(&mut scratch); + append_as_stereo(&mut samples, &scratch, src_channels, frames); + } + + if samples.is_empty() || sample_rate == 0 { + return Err(AudioError::NoAudioTrack); + } + + Ok(Track { + samples, + sample_rate, + }) + } + + /// Bringt den Track auf die Rate des Ausgabegeräts. + /// + /// Läuft einmalig beim Laden, nicht im Audio-Callback — deshalb ist lineare + /// Interpolation hier unkritisch. Wandert das je in den Abspielpfad, gehört + /// ein richtiger Resampler her (siehe docs/BAUSTEINE.md). + pub fn resampled_to(self, target_rate: u32) -> Track { + if target_rate == self.sample_rate { + return self; + } + + let ratio = self.sample_rate as f64 / target_rate as f64; + let src_frames = self.frames(); + let dst_frames = ((src_frames as f64) / ratio).floor() as usize; + let mut out = vec![0.0f32; dst_frames * CHANNELS]; + + for i in 0..dst_frames { + let pos = i as f64 * ratio; + let idx = pos.floor() as usize; + let frac = (pos - idx as f64) as f32; + let next = (idx + 1).min(src_frames - 1); + for c in 0..CHANNELS { + let a = self.samples[idx * CHANNELS + c]; + let b = self.samples[next * CHANNELS + c]; + out[i * CHANNELS + c] = a + (b - a) * frac; + } + } + + Track { + samples: out, + sample_rate: target_rate, + } + } +} + +/// Hängt einen dekodierten Block als Stereo an. Mono wird dupliziert, bei mehr +/// als zwei Kanälen werden die ersten beiden genommen. +fn append_as_stereo(dst: &mut Vec, src: &[f32], src_channels: usize, frames: usize) { + match src_channels { + 0 => {} + 1 => { + for &s in &src[..frames] { + dst.push(s); + dst.push(s); + } + } + n => { + for i in 0..frames { + dst.push(src[i * n]); + dst.push(src[i * n + 1]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::dominant_freq; + use std::path::PathBuf; + + const RATE: u32 = 44_100; + + fn wav_pcm16(samples: &[i16], channels: u16, rate: u32) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut b = Vec::with_capacity(44 + data_len as usize); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data_len).to_le_bytes()); + b.extend_from_slice(b"WAVE"); + b.extend_from_slice(b"fmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); // PCM + b.extend_from_slice(&channels.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); + b.extend_from_slice(&(channels * 2).to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + b.extend_from_slice(&s.to_le_bytes()); + } + b + } + + /// Schreibt eine Mono-WAV mit 1 kHz Sinus und gibt den Pfad zurück. + fn temp_wav(name: &str, secs: f32) -> PathBuf { + let frames = (RATE as f32 * secs) as usize; + let step = 2.0 * std::f32::consts::PI * 1000.0 / RATE as f32; + let pcm: Vec = (0..frames) + .map(|i| ((step * i as f32).sin() * 20_000.0) as i16) + .collect(); + + let path = std::env::temp_dir().join(name); + std::fs::write(&path, wav_pcm16(&pcm, 1, RATE)).expect("WAV schreiben"); + path + } + + #[test] + fn mono_wird_zu_stereo_dekodiert() { + let path = temp_wav("audio-core-mono.wav", 0.5); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + assert_eq!(track.sample_rate, RATE); + assert_eq!(track.samples.len(), track.frames() * CHANNELS); + assert!( + (track.frames() as i64 - (RATE as f32 * 0.5) as i64).abs() < 64, + "unerwartete Länge: {} Frames", + track.frames() + ); + + // Mono muss auf beide Kanäle gelegt worden sein. + for i in (0..track.samples.len()).step_by(CHANNELS) { + assert_eq!(track.samples[i], track.samples[i + 1]); + } + } + + #[test] + fn resampling_haelt_die_tonhoehe() { + let path = temp_wav("audio-core-resample.wav", 1.0); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + let resampled = track.resampled_to(48_000); + + assert_eq!(resampled.sample_rate, 48_000); + assert!( + (resampled.frames() as i64 - 48_000).abs() < 128, + "unerwartete Länge nach Resampling: {} Frames", + resampled.frames() + ); + + let freq = dominant_freq(&resampled.samples, 48_000); + assert!( + (freq - 1000.0).abs() < 10.0, + "Tonhöhe verschoben: {freq:.1} Hz statt 1000 Hz" + ); + } + + #[test] + fn resampling_auf_gleiche_rate_ist_ein_no_op() { + let path = temp_wav("audio-core-noop.wav", 0.2); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + let frames_vorher = track.frames(); + let gleich = track.resampled_to(RATE); + + assert_eq!(gleich.frames(), frames_vorher); + } +} diff --git a/crates/musik-cli/Cargo.toml b/crates/musik-cli/Cargo.toml new file mode 100644 index 0000000..491875c --- /dev/null +++ b/crates/musik-cli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "musik-cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +audio-core = { version = "0.1.0", path = "../audio-core" } diff --git a/crates/musik-cli/src/main.rs b/crates/musik-cli/src/main.rs new file mode 100644 index 0000000..b6a830b --- /dev/null +++ b/crates/musik-cli/src/main.rs @@ -0,0 +1,140 @@ +//! Phase-0-Prototyp: ein Deck an der Kommandozeile. +//! +//! Zweck ist nicht Bedienkomfort, sondern die Frage aus `docs/BAUSTEINE.md` +//! empirisch zu beantworten — trägt der native Audio-Pfad, und wie klingt die +//! Zeitstreckung bei den ±8 %, die im DJ-Betrieb üblich sind? + +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use audio_core::{Player, Track}; + +fn main() -> Result<()> { + let path: PathBuf = match std::env::args().nth(1) { + Some(p) => PathBuf::from(p), + None => { + eprintln!("Aufruf: musik-cli "); + eprintln!("Unterstützt: mp3, flac, wav, m4a/aac, ogg"); + std::process::exit(2); + } + }; + + println!("Lade {} …", path.display()); + let track = Track::decode_file(&path) + .with_context(|| format!("konnte {} nicht dekodieren", path.display()))?; + println!( + " {} Frames, {} Hz, {}", + track.frames(), + track.sample_rate, + format_time(track.duration_secs()) + ); + + let player = Player::open(track).context("konnte das Ausgabegerät nicht öffnen")?; + println!( + "Gerät: {} @ {} Hz", + player.device_name(), + player.sample_rate() + ); + println!(); + print_help(); + + let state = player.state().clone(); + let stdin = io::stdin(); + let mut lines = stdin.lock().lines(); + + loop { + print!("> "); + io::stdout().flush().ok(); + + let Some(line) = lines.next() else { break }; + let line = line?; + let mut parts = line.split_whitespace(); + let Some(cmd) = parts.next() else { continue }; + let arg = parts.next(); + + match cmd { + "p" | "play" => { + let now = state.toggle_playing(); + println!("{}", if now { "▶ läuft" } else { "⏸ pausiert" }); + } + "k" | "keylock" => { + let next = !state.keylock(); + state.set_keylock(next); + println!("Keylock {}", if next { "an" } else { "aus" }); + } + "t" | "tempo" => match arg.map(parse_tempo) { + Some(Ok(ratio)) => { + state.set_tempo(ratio); + println!( + "Tempo {:+.2} % ({:.4}×)", + (state.tempo() - 1.0) * 100.0, + state.tempo() + ); + } + Some(Err(e)) => println!("{e}"), + None => println!("Tempo {:+.2} %", (state.tempo() - 1.0) * 100.0), + }, + "s" | "seek" => match arg.map(|a| a.parse::()) { + Some(Ok(secs)) => { + player.seek_secs(secs); + println!("Sprung auf {}", format_time(secs)); + } + _ => println!("Aufruf: s "), + }, + "i" | "info" => print_status(&player), + "h" | "help" | "?" => print_help(), + "q" | "quit" | "exit" => break, + other => println!("unbekannt: {other} — 'h' für Hilfe"), + } + + if state.is_finished() { + println!("(Track zu Ende)"); + } + } + + Ok(()) +} + +/// Nimmt entweder ein Verhältnis (`1.06`) oder eine Prozentangabe (`+6`, `-3`). +fn parse_tempo(raw: &str) -> std::result::Result { + let value: f32 = raw.parse().map_err(|_| format!("'{raw}' ist keine Zahl"))?; + + let ratio = if raw.starts_with('+') || raw.starts_with('-') { + 1.0 + value / 100.0 + } else { + value + }; + + if !(0.25..=4.0).contains(&ratio) { + return Err(format!("{ratio:.3}× liegt außerhalb von 0.25×–4.0×")); + } + Ok(ratio) +} + +fn print_status(player: &Player) { + let state = player.state(); + println!( + "{} {} Tempo {:+.2} % Keylock {}", + if state.is_playing() { "▶" } else { "⏸" }, + format_time(player.position_secs()), + (state.tempo() - 1.0) * 100.0, + if state.keylock() { "an" } else { "aus" } + ); +} + +fn print_help() { + println!(" p Play/Pause"); + println!(" t Tempo — '1.06' als Verhältnis oder '+6' in Prozent"); + println!(" k Keylock an/aus (Tonhöhe halten beim Tempowechsel)"); + println!(" s Springen"); + println!(" i Status"); + println!(" q Beenden"); +} + +fn format_time(secs: f64) -> String { + let secs = secs.max(0.0); + let m = (secs / 60.0).floor() as u64; + let s = secs - (m as f64) * 60.0; + format!("{m}:{s:05.2}") +} diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md index 2b9f273..42b0faa 100644 --- a/docs/BAUSTEINE.md +++ b/docs/BAUSTEINE.md @@ -47,11 +47,13 @@ einen eigenen Time-Stretcher (siehe unten). Nativ gibt echte Low-Latency-Ausgabe und mehrere Ausgänge — die Voraussetzung für Kopfhörer-Cue, also für ernsthaftes DJing. -**Einschätzung:** Wenn das Ding je auf einer echten Anlage laufen soll, führt -langfristig kein Weg an nativ vorbei — wegen des getrennten Cue-Ausgangs, nicht -wegen der Latenz. Für einen ersten Prototyp ist der Browser trotzdem der -schnellere Weg, und die Analyse-/Library-/Agenten-Schichten sind davon ohnehin -unabhängig. +**Entscheidung: nativ, in Rust.** Ausschlaggebend war der getrennte +Cue-Ausgang, nicht die Latenz — ohne Kopfhörer-Vorhören ist ernsthaftes DJing +nicht möglich, und im Browser gibt es das nicht sauber. + +Rust statt C++ wegen der Lizenztabelle oben: CPAL, Symphonia und ein eigener +Stretcher sind durchgehend permissiv. Damit bleibt die offene Frage „wird das +kommerziell?" wirklich offen, statt vom Stack vorentschieden zu werden. ## Time-Stretching und Pitch-Shifting @@ -110,17 +112,53 @@ Damit lassen sich Sammlung, Cue-Points, Beatgrids und Playlists übernehmen. Ein gutes frühes Feature: viel Nutzen für wenig Aufwand. Siehe [TRAKTOR-REFERENZ.md](TRAKTOR-REFERENZ.md#datenformat-die-collectionnml). -## Empfehlung für Phase 0 +## Phase 0: was gebaut ist + +Ein Deck als Rust-Workspace, steuerbar über eine CLI. -Als Prototyp, um die Stack-Frage empirisch statt theoretisch zu klären: +| Crate | Inhalt | +| --- | --- | +| `crates/audio-core` | Dekodierung, Zeitstreckung, Deck-Zustand, CPAL-Ausgabe | +| `crates/musik-cli` | Kommandozeile zum Fahren des Decks | -1. Ein Deck im Browser, Web Audio API, Datei laden und abspielen -2. Tempo per `signalsmith-stretch` (WASM) ändern, Tonhöhe halten -3. BPM offline mit `librosa` oder `aubio` bestimmen, Ergebnis als JSON danebenlegen -4. Messen: Wie fühlt sich die Latenz an? Wie klingt der Stretcher bei ±8 %? +**Gewählte Abhängigkeiten** — bewusst wenige, alle permissiv: -Fällt der Test durch, ist der native Weg belegt statt vermutet — und die -Analyse-Skripte aus Schritt 3 bleiben unverändert nutzbar. +| Crate | Lizenz | Wofür | +| --- | --- | --- | +| `cpal` | Apache-2.0 | Ausgabegerät, plattformübergreifend | +| `symphonia` | MPL-2.0 | Dekodierung: MP3, FLAC, WAV, AAC/M4A, OGG | +| `thiserror` / `anyhow` | MIT/Apache-2.0 | Fehlerbehandlung | + +Zeitstreckung ist **selbst implementiert** (WSOLA, `stretch.rs`). Das war keine +Sparmaßnahme, sondern folgt aus der Lizenztabelle: Rubber Band ist GPL, und der +Prototyp sollte nicht an einer Lizenzentscheidung hängen, die noch offen ist. + +**Architektur des Abspielpfads.** Der Track wird beim Laden komplett dekodiert +und einmalig auf die Samplerate des Geräts gebracht. Dadurch enthält der +Callback keine Ratenkonvertierung, keine Allokation, kein Lock — Steuerung läuft +ausschließlich über Atomics. Das sind genau die drei Dinge, die sonst Aussetzer +verursachen. + +**Was verifiziert ist.** Elf Tests, unter anderem: + +- Keylock hält die Tonhöhe bei 0.92×, 1.06× und 1.20× (gemessen über + Nulldurchgänge, Abweichung < 13 Hz bei 440 Hz) +- Ohne Keylock wandert die Tonhöhe korrekt mit dem Tempo mit +- Die Zeitachse driftet nicht: 4 s Ausgabe bei 1.06× verbrauchen 4,24 s Quelle +- Dekodierung, Mono→Stereo und Resampling halten Länge und Tonhöhe + +Nicht verifiziert ist der Klang auf einer echten Anlage — das braucht Ohren an +einer Soundkarte, nicht Tests. Ebenso offen: gemessene Latenz und das Verhalten +unter Last. + +## Was als Nächstes ansteht + +- Zweites Deck und Mixer (Phase 1) — dafür muss der Track im Callback + austauschbar werden, ohne dort zu allokieren (Send-Back-Kanal für den alten + `Arc`, damit die Freigabe außerhalb des Audio-Threads passiert) +- BPM-Analyse offline mit `aubio` oder `librosa`, Ergebnis neben dem Track ablegen +- Wenn die WSOLA-Qualität bei ±8 % nicht reicht: `signalsmith-stretch` (MIT) + einbinden, bevor über eine Rubber-Band-Lizenz nachgedacht wird ## Quellen From fe1d074bddb9544912156efa6ab05c106085c959 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 11:09:11 +0000 Subject: [PATCH 03/70] docs: record non-commercial decision and its license consequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second open question. The practical effect is larger than it looks: (A)GPL libraries are now available, which removes the main constraint the audio ecosystem imposes. The reason is narrower than "non-commercial" and worth stating precisely — copyleft triggers on distribution, not on use. A tool that stays on one machine creates no obligations at all. Two caveats now carry the warnings instead: - Distributing the tool later, even for free, would require releasing the project's own code under GPL. - AGPL (Essentia) triggers on network use, not just distribution, which matters if the agent layer ever runs as a service. Sample side: CC BY-NC material becomes usable, a large part of Freesound. The attribution requirement survives, so the library schema needs license and author fields before any import — that cannot be reconstructed afterwards. Adjusts the passages that assumed the question was still open: the Rust choice, the self-written stretcher (Rubber Band is now an option, worth taking only once someone has heard both), and the reason to avoid reverse-engineered Suno wrappers — that risk was never licensing but a banned account. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- README.md | 36 ++++++++++++++------ docs/APIS.md | 31 ++++++++++------- docs/BAUSTEINE.md | 84 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 102 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 9815e29..a7fc2d3 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,18 @@ lassen sich auch gegen eine reine Datei-Library testen. **Nativ, in Rust.** Ausschlaggebend war der getrennte Kopfhörer-Cue-Ausgang, nicht die Latenz — ohne Vorhören ist ernsthaftes DJing nicht möglich, und im -Browser gibt es das nicht sauber. Rust statt C++ wegen der Lizenzen: der ganze -Stack ist permissiv, damit die offene Frage „wird das kommerziell?" nicht vom -Werkzeugkasten vorentschieden wird. +Browser gibt es das nicht sauber. Rust statt C++, weil der Stack damit klein und +permissiv bleibt — zum Entscheidungszeitpunkt war die Lizenzfrage noch offen, +und der Werkzeugkasten sollte sie nicht vorwegnehmen. | Crate | Lizenz | Wofür | | --- | --- | --- | | `cpal` | Apache-2.0 | Audioausgabe | | `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | -Die Zeitstreckung (WSOLA) ist selbst implementiert — Rubber Band wäre besser, -ist aber GPL. Details und Begründung in +Die Zeitstreckung (WSOLA) ist selbst implementiert. Rubber Band ist besser und +steht inzwischen offen (GPL, nicht kommerzielles Projekt) — der Tausch lohnt +aber erst, wenn die eigene Variante hörbar nicht reicht. Details in [docs/BAUSTEINE.md](docs/BAUSTEINE.md#phase-0-was-gebaut-ist). UI ist noch offen und kommt frühestens mit Phase 1. @@ -88,14 +89,29 @@ UI ist noch offen und kommt frühestens mit Phase 1. - Keine Echtzeit-Stem-Separation — beim Import vorberechnen (siehe [docs/BAUSTEINE.md](docs/BAUSTEINE.md#stem-separation)) +## Entschieden + +**Nativ in Rust** (Phase 0) und **nicht kommerziell.** + +Die zweite Entscheidung entspannt die Lizenzlage deutlich: GPL-Bibliotheken wie +Rubber Band und aubio stehen offen, ebenso CC-BY-NC-Samples von Freesound. Der +genaue Grund ist enger als „nicht kommerziell" — Copyleft greift bei +*Weitergabe*, nicht bei Nutzung. Solange das Tool auf dem eigenen Rechner +bleibt, entstehen gar keine Pflichten. + +Zwei Dinge bleiben trotzdem zu beachten, Details in +[docs/BAUSTEINE.md](docs/BAUSTEINE.md#lizenzen--entschärft-nicht-erledigt): + +- Wird das Tool je weitergegeben, auch kostenlos, müsste der eigene Code unter + GPL stehen. +- AGPL (Essentia) löst schon bei Netzwerknutzung aus — relevant, falls die + Agenten-Schicht je als Dienst läuft. + ## Offene Entscheidungen -1. **Wird das Produkt kommerziell?** Davon hängt die gesamte Lizenzstrategie ab, - und zwar rückwirkend teuer — bei den Bibliotheken (AGPL/GPL), bei den - Samples (CC BY-NC) und bei den Generierungs-Anbietern (Suno und Udio haben - ungeklärte Lizenzlage). -2. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — +1. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — prägt das Agenten-Design. +2. **UI-Schicht.** Frühestens ab Phase 1. ## Setup diff --git a/docs/APIS.md b/docs/APIS.md index 8f80c54..35f1bfc 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -32,8 +32,9 @@ dieses Profil — **die Bewerbung ist einen Versuch wert.** ⚠️ **Nicht empfohlen:** Es kursieren reverse-engineerte „Suno APIs" von Drittanbietern. Die sind inoffiziell, können jederzeit brechen und bewegen sich -rechtlich auf dünnem Eis. Für ein Projekt, das später kommerziell laufen soll, -kein tragfähiges Fundament. +verstoßen aller Wahrscheinlichkeit nach gegen Sunos Nutzungsbedingungen. Dass +das Projekt nicht kommerziell ist, ändert daran nichts — das realistische Risiko +ist ein gesperrter Account, nicht eine Lizenzklage. ### Alternativen, die heute nutzbar sind @@ -88,10 +89,14 @@ APIv2 ist die praktikabelste Quelle für Samples mit sauberer Rechtelage. - API frei für nicht-kommerzielle Anwendungen; kommerzielle Nutzung ist separat lizenzierbar - Seit Juli 2026 gibt es eine „Generative AI"-Präferenz pro Upload -⚠️ **Zwei Fallstricke:** CC-BY-NC-Material ist für ein kommerzielles Produkt -unbrauchbar — die Lizenz muss pro Sample geprüft und mitgeführt werden, nicht -pauschal angenommen. Und CC BY verlangt Namensnennung, was heißt: Wir brauchen -ein Feld für Attribution in den Track-Metadaten, von Anfang an. +**CC BY-NC ist nutzbar**, weil das Projekt nicht kommerziell ist — das ist der +größte praktische Gewinn dieser Entscheidung, denn NC-Material macht einen +erheblichen Teil von Freesound aus. + +⚠️ **Die Attributionspflicht bleibt.** CC BY verlangt Namensnennung auch +nicht-kommerziell. Das heißt konkret: Die Library braucht von Anfang an ein Feld +für Lizenz und Urheber pro Track. Nachträglich lässt sich das nicht +rekonstruieren, wenn erst einmal tausend Samples ohne Herkunft im Ordner liegen. ### Weitere Quellen @@ -101,15 +106,17 @@ ein Feld für Attribution in den Track-Metadaten, von Anfang an. ## Konkrete nächste Schritte +- [x] Entschieden: **nicht kommerziell.** Damit sind CC-BY-NC-Samples nutzbar + und die ungeklärte Lizenzlage bei Suno/Udio ist kein Ausschlusskriterium + mehr — nur noch der fehlende API-Zugang. - [ ] Bei Suno für das Partner-Programm bewerben (Intake-Formular, Juli 2026 veröffentlicht) - [ ] ElevenLabs-API-Key besorgen, `/music/compose` einmal manuell gegen curl testen -- [ ] Freesound-API-Key besorgen (kostenlos für nicht-kommerziell) -- [ ] Entscheiden, ob das Produkt kommerziell werden soll — davon hängt die - gesamte Lizenzstrategie ab, und zwar rückwirkend teuer +- [ ] Freesound-API-Key besorgen (die API ist für nicht-kommerzielle Nutzung kostenlos) +- [ ] Lizenz- und Urheberfeld im Library-Schema vorsehen, bevor Samples + importiert werden -Der letzte Punkt ist keine Formalie. Wenn kommerziell, dann fallen CC-BY-NC-Samples, -Suno und Udio vorerst raus, und die Bibliothek muss von Tag eins Lizenzinfos -pro Track mitführen. +Was die Entscheidung **nicht** löst: Suno hat weiterhin keine öffentliche API. +Der Blocker war nie die Lizenz, sondern der Zugang. ## Quellen diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md index 42b0faa..8073fe7 100644 --- a/docs/BAUSTEINE.md +++ b/docs/BAUSTEINE.md @@ -3,28 +3,45 @@ Was es an fertigen Bibliotheken gibt, damit wir nichts nachbauen, was es schon gibt. Recherchestand August 2026, nicht selbst getestet. -## ⚠️ Lizenzen zuerst lesen +## Lizenzen — entschärft, nicht erledigt -Der Audio-Bereich ist voll von **(A)GPL-Bibliotheken mit kommerzieller -Zweitlizenz**. Das ist kein Randthema: Wer Essentia oder Rubber Band einbaut und -das Produkt später verkaufen will, steht vor der Wahl, den eigenen Code -offenzulegen oder eine Lizenz zu kaufen. Diese Entscheidung fällt **vor** der -Bibliotheksauswahl, nicht danach. +**Das Projekt ist nicht kommerziell.** Damit fällt die Sperre weg, die den +Audio-Bereich sonst prägt: (A)GPL-Bibliotheken mit kommerzieller Zweitlizenz +sind hier nutzbar. -| Bibliothek | Lizenz | Für ein Closed-Source-Produkt | +Der Grund ist präziser als „nicht kommerziell": **Copyleft greift bei +Weitergabe, nicht bei Nutzung.** Solange das Tool auf dem eigenen Rechner läuft +und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine +Pflichten. + +| Bibliothek | Lizenz | Für dieses Projekt | | --- | --- | --- | -| Essentia | AGPL | Nein, ohne kommerzielle Lizenz | -| Rubber Band | GPL + kommerziell | Lizenz kaufen | -| aubio | GPL | Problematisch | -| SoundTouch | LGPL | Meist okay (dynamisch gelinkt) | +| Essentia | AGPL | Nutzbar — siehe AGPL-Hinweis unten | +| Rubber Band | GPL + kommerziell | Nutzbar | +| aubio | GPL | Nutzbar | +| JUCE | GPL + kommerziell | Nutzbar | +| SoundTouch | LGPL | Nutzbar | | librosa | ISC | Frei | | miniaudio | Public Domain / MIT-0 | Frei | | CPAL (Rust) | MIT / Apache-2.0 | Frei | -| JUCE | GPL + kommerziell | Lizenz kaufen | +| Symphonia (Rust) | MPL-2.0 | Frei | | Demucs | MIT (Code) | Modellgewichte separat prüfen | -Siehe die offene Frage in [APIS.md](APIS.md#konkrete-nächste-schritte): Ob das -Produkt kommerziell wird, entscheidet hier mit. +Zwei Punkte bleiben trotzdem stehen: + +⚠️ **Wenn das Tool je weitergegeben wird** — auch kostenlos, auch nur an +Freunde, auch als Open Source — greift die GPL: Der eigene Code müsste dann +ebenfalls unter GPL stehen. Das ist kein Problem, aber eine Entscheidung, die +dann ansteht und nicht danach. + +⚠️ **AGPL geht weiter als GPL.** Sie löst schon bei Nutzung über ein Netzwerk +aus, nicht erst bei Weitergabe. Relevant wird das, wenn die Agenten-Schicht aus +[AGENTEN.md](AGENTEN.md) je als Dienst läuft, auf den andere zugreifen — etwa +ein Crowd-Voting per Handy. Dann wäre Essentia im Stack ein Thema. `librosa` +(ISC) und `madmom` sind für Analyse die unkomplizierteren Wege. + +Bei Samples bleibt eine Pflicht unabhängig davon bestehen: **CC BY verlangt +Namensnennung auch nicht-kommerziell.** Siehe [APIS.md](APIS.md#samples-und-audiomaterial). ## Audio-Ausgabe und Engine @@ -51,9 +68,10 @@ für Kopfhörer-Cue, also für ernsthaftes DJing. Cue-Ausgang, nicht die Latenz — ohne Kopfhörer-Vorhören ist ernsthaftes DJing nicht möglich, und im Browser gibt es das nicht sauber. -Rust statt C++ wegen der Lizenztabelle oben: CPAL, Symphonia und ein eigener -Stretcher sind durchgehend permissiv. Damit bleibt die offene Frage „wird das -kommerziell?" wirklich offen, statt vom Stack vorentschieden zu werden. +Rust statt C++, weil der Stack damit klein und permissiv bleibt: CPAL, +Symphonia, ein eigener Stretcher. Dass GPL inzwischen offensteht (nicht +kommerziell), ändert daran nichts — die Wahl war zu dem Zeitpunkt richtig und +ist es geblieben. Sie macht nur den *Zwang* weg, alles selbst zu bauen. ## Time-Stretching und Pitch-Shifting @@ -62,10 +80,16 @@ Kernstück jedes DJ-Tools: Tempo ändern, ohne dass die Tonhöhe mitwandert - **[Rubber Band Library](https://breakfastquay.com/rubberband/)** — Referenzqualität, GPL + kommerziell - **[SoundTouch](https://www.surina.net/soundtouch/)** — LGPL, solide, weit verbreitet -- **[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch)** — MIT, modern, auch als WASM nutzbar +- **[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch)** — MIT, modern +- Aktuell im Einsatz: **eigene WSOLA-Implementierung** in `crates/audio-core/src/stretch.rs` + +Da das Projekt nicht kommerziell ist, steht **Rubber Band offen** — die beste +der drei Bibliotheken. Sie ist C++ und müsste per FFI angebunden werden. -Für einen Browser-Prototyp ist `signalsmith-stretch` per WebAssembly der -pragmatischste Einstieg: gute Qualität ohne Lizenzfrage. +Der Austausch lohnt aber erst, wenn die eigene WSOLA hörbar nicht reicht. Beim +DJ-typischen Bereich von ±8 % ist der Abstand klein; groß wird er bei starken +Faktoren und bei perkussivem Material. **Erst hören, dann tauschen** — die +Schnittstelle ist schmal genug, dass der Wechsel jederzeit möglich bleibt. ## BPM, Beatgrid, Tonart @@ -74,8 +98,14 @@ pragmatischste Einstieg: gute Qualität ohne Lizenzfrage. - **[librosa](https://librosa.org/)** — Python, ISC, ideal zum Prototypen - **[madmom](https://github.com/CPJKU/madmom)** — sehr gute Beat-/Downbeat-Erkennung +Alle vier sind jetzt nutzbar. Für Phase 2 ist `librosa` oder `madmom` der +erste Griff — nicht wegen der Lizenz, sondern weil die Analyse ohnehin offline +als eigener Schritt läuft und Python dort schneller zum Ergebnis führt als eine +FFI-Anbindung an den Rust-Kern. + Für Tonarterkennung liefert Essentia brauchbare Ergebnisse; als eigenständige -OSS-Alternative gibt es KeyFinder. +OSS-Alternative gibt es KeyFinder. Zu Essentia siehe den AGPL-Hinweis oben, +falls die Agenten-Schicht je über Netzwerk erreichbar wird. **Praktischer Hinweis:** Beatgrid-Erkennung ist bei geradem 4/4-Material fast gelöst und bei allem anderen mühsam. Traktor löst das über manuelle @@ -129,9 +159,10 @@ Ein Deck als Rust-Workspace, steuerbar über eine CLI. | `symphonia` | MPL-2.0 | Dekodierung: MP3, FLAC, WAV, AAC/M4A, OGG | | `thiserror` / `anyhow` | MIT/Apache-2.0 | Fehlerbehandlung | -Zeitstreckung ist **selbst implementiert** (WSOLA, `stretch.rs`). Das war keine -Sparmaßnahme, sondern folgt aus der Lizenztabelle: Rubber Band ist GPL, und der -Prototyp sollte nicht an einer Lizenzentscheidung hängen, die noch offen ist. +Zeitstreckung ist **selbst implementiert** (WSOLA, `stretch.rs`). Zum Zeitpunkt +der Umsetzung war die Lizenzfrage noch offen, und der Prototyp sollte nicht +daran hängen. Inzwischen steht Rubber Band offen — der Tausch bleibt eine +Option, sobald jemand beides gehört hat. **Architektur des Abspielpfads.** Der Track wird beim Laden komplett dekodiert und einmalig auf die Samplerate des Geräts gebracht. Dadurch enthält der @@ -156,9 +187,8 @@ unter Last. - Zweites Deck und Mixer (Phase 1) — dafür muss der Track im Callback austauschbar werden, ohne dort zu allokieren (Send-Back-Kanal für den alten `Arc`, damit die Freigabe außerhalb des Audio-Threads passiert) -- BPM-Analyse offline mit `aubio` oder `librosa`, Ergebnis neben dem Track ablegen -- Wenn die WSOLA-Qualität bei ±8 % nicht reicht: `signalsmith-stretch` (MIT) - einbinden, bevor über eine Rubber-Band-Lizenz nachgedacht wird +- BPM-Analyse offline mit `librosa` oder `madmom`, Ergebnis neben dem Track ablegen +- Wenn die WSOLA-Qualität bei ±8 % nicht reicht: Rubber Band per FFI anbinden ## Quellen From a2ece89435e66311b86ef792217c7849318a331a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:35:25 +0000 Subject: [PATCH 04/70] docs: note personal-use-only status, no LICENSE file needed The project is used privately and not distributed. In that state the GPL dependencies create no obligations at all, so the two caveats recorded earlier are demoted to notes for if that changes, and the absence of a LICENSE file is now a stated choice rather than an oversight. Practical effect: library choice is unconstrained, including Rubber Band. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- README.md | 7 ++++++- docs/BAUSTEINE.md | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a7fc2d3..b5c5902 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,12 @@ genaue Grund ist enger als „nicht kommerziell" — Copyleft greift bei *Weitergabe*, nicht bei Nutzung. Solange das Tool auf dem eigenen Rechner bleibt, entstehen gar keine Pflichten. -Zwei Dinge bleiben trotzdem zu beachten, Details in +**Aktueller Stand: reine Eigennutzung, keine Weitergabe geplant.** Damit gibt es +derzeit überhaupt keine Lizenzpflichten aus den Abhängigkeiten, und deshalb +liegt auch bewusst keine `LICENSE`-Datei im Repo. + +Die folgenden zwei Punkte sind Merkposten für den Fall, dass sich das ändert — +Details in [docs/BAUSTEINE.md](docs/BAUSTEINE.md#lizenzen--entschärft-nicht-erledigt): - Wird das Tool je weitergegeben, auch kostenlos, müsste der eigene Code unter diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md index 8073fe7..8a014dc 100644 --- a/docs/BAUSTEINE.md +++ b/docs/BAUSTEINE.md @@ -14,6 +14,10 @@ Weitergabe, nicht bei Nutzung.** Solange das Tool auf dem eigenen Rechner läuft und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine Pflichten. +**Aktueller Stand: reine Eigennutzung, Weitergabe nicht geplant.** Damit ist die +Bibliothekswahl frei — auch Rubber Band, die beste der Stretching-Bibliotheken. +Eine `LICENSE`-Datei braucht das Repo in diesem Zustand nicht. + | Bibliothek | Lizenz | Für dieses Projekt | | --- | --- | --- | | Essentia | AGPL | Nutzbar — siehe AGPL-Hinweis unten | From 2811dcde330eaeae1d84c829978b02bae05110f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:39:49 +0000 Subject: [PATCH 05/70] docs: add full build plan for the DJ software MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the coarse roadmap into an engineering plan: crate layout, threading model, signal chain, phases with acceptance criteria, and risks. Three decisions shape everything else and are called out as such: - The headphone cue output forces master and cue onto one device with four outputs. Two devices means two clocks, and the drift would need async resampling in exactly the path that is kept free of it. This is a hardware question that has to be settled before phase 1, not during. - Atomics no longer suffice once tracks are swapped on a deck. A lock-free SPSC queue carries commands in, and a second one carries the old Arc back out — dropping a decoded track inside the callback frees a hundred megabytes and stalls the audio thread. - A beatgrid is an anchor plus BPM, optionally grid markers, and sync needs phase as well as tempo. Matching BPM with the wrong phase sounds just as wrong as the wrong tempo. Also fixes two things that are easy to build backwards: mixer FX belong post-fader so closing the fader fades the effect out instead of cutting it, and the cue tap belongs before the crossfader so a faded-out channel is still audible in the headphones. Records that the library schema needs license and attribution columns from the first migration, following from the CC BY requirement. Recommends egui for the UI over a Tauri frontend — a DJ surface redraws every frame anyway, and the UI/audio state coupling is too tight and too frequent to route through IPC. Crate choices verified against crates.io for existence and license. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- README.md | 35 ++++++--- docs/PLAN.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 docs/PLAN.md diff --git a/README.md b/README.md index b5c5902..05116d7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ es nichts, was Agenten steuern könnten. | Dokument | Inhalt | | --- | --- | +| **[docs/PLAN.md](docs/PLAN.md)** | **Vollständiger Bauplan der DJ-Software — Architektur, Phasen, Risiken** | | [docs/TRAKTOR-REFERENZ.md](docs/TRAKTOR-REFERENZ.md) | Was Traktor Pro 4 kann, was wir davon übernehmen | | [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | | [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | @@ -46,20 +47,30 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap +**Phase 0 steht** ✅ — ein Deck, Wiedergabe, Tempo mit und ohne Keylock. + +Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und +Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: + +| Phase | Inhalt | +| --- | --- | +| 1–2 | Mehrdeck-Engine mit Mixer und Cue-Bus · Analyse-Pipeline (BPM, Grid, Peaks) | +| 3–5 | UI mit Waveforms · Sync und Beatmatching · Hot Cues und Loops | +| 6–7 | Library inkl. Traktor-Import · Effekte | +| 8–10 | Stems · Mitschnitt · MIDI-Controller | + +Ab Phase 6 ist die Software als DJ-Werkzeug benutzbar. + +Darauf setzen die beiden Schichten aus dem Zielbild auf: + | Phase | Inhalt | Abhängig von | | --- | --- | --- | -| ~~0~~ | ~~Stack-Entscheidung per Prototyp: ein Deck, Play/Pause/Pitch mit Keylock~~ ✅ | — | -| 1 | Zweites Deck, Crossfader, EQ, Filter — ein Mix ist durchgängig spielbar | 0 | -| 2 | BPM-Analyse, Beatgrid (inkl. manueller Korrektur), Sync | 1 | -| 3 | Waveforms, Cue-Points, Loops | 2 | -| 4 | Library: Import inkl. Traktor-`collection.nml`, Metadaten, Suche, Playlists | 2 | -| 5 | Generierungs-Adapter: Prompt → Track → Deck, asynchron mit Queue | 4, API-Zugang | -| 6 | Stems (beim Import vorberechnet), Stem-Deck-Modus | 4 | -| 7 | Crowd-Signale einsammeln und visualisieren — noch ohne Steuerung | 4 | -| 8 | Agenten-Team: Energie-Analyse, Set-Planung, Übergangssteuerung | 5, 7 | - -Phase 5 hängt am API-Zugang, Phase 7 kann davon unabhängig laufen — Crowd-Signale -lassen sich auch gegen eine reine Datei-Library testen. +| G1 | Generierungs-Adapter: Prompt → Track → Deck, asynchron mit Queue | 6, API-Zugang | +| A1 | Crowd-Signale einsammeln und visualisieren — noch ohne Steuerung | 6 | +| A2 | Agenten-Team: Energie-Analyse, Set-Planung, Übergangssteuerung | G1, A1 | + +G1 hängt am API-Zugang, A1 nicht — Crowd-Signale lassen sich auch gegen eine +reine Datei-Library testen. ## Tech-Stack diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..d99ec65 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,204 @@ +# Plan: vollständige DJ-Software + +Der Weg von einem Deck an der Kommandozeile (Phase 0, steht) zu einem Werkzeug, +mit dem man tatsächlich auflegen kann. + +Ergänzt [TRAKTOR-REFERENZ.md](TRAKTOR-REFERENZ.md) (was das Ziel kann) um das +Wie. Aufwandsangaben sind relativ (S/M/L/XL), keine Kalenderzeit. + +## Was „vollständig" heißt + +**Dazu gehört:** vier Decks, Mixer mit EQ und Filter, getrennter +Kopfhörer-Cue, Beatgrid und Sync, Hot Cues und Loops, Waveform-Anzeige, +Library mit Suche und Playlists, Effekte, vorberechnete Stems, Mitschnitt, +MIDI-Controller. + +**Dazu gehört nicht:** DVS/Timecode-Vinyl, Video, Karaoke, +Streaming-Dienst-Integration. Alles vier ist viel Arbeit für wenig Ertrag in +diesem Projekt. + +Generierung und Agenten sind eine Schicht *darüber* und stehen in +[APIS.md](APIS.md) und [AGENTEN.md](AGENTEN.md). Dieser Plan bringt das +Fundament, auf dem sie aufsetzen. + +## Die drei Entscheidungen, die alles andere bestimmen + +Der Rest des Plans ist Fleißarbeit. Diese drei nicht — sie prägen die +Architektur, und sie nachträglich zu ändern ist teuer. + +### 1. Der Cue-Ausgang zwingt zu einem Gerät mit vier Ausgängen + +Vorhören auf dem Kopfhörer war der Grund für die native Entscheidung. Praktisch +heißt das: **Master L/R und Cue L/R müssen über dasselbe Audiogerät laufen.** + +Zwei getrennte Geräte haben zwei Quarze. Die laufen minimal unterschiedlich +schnell, und der Versatz summiert sich — nach ein paar Minuten ist der +Kopfhörer hörbar gegen den Master verschoben. Das ließe sich nur mit +asynchroner Ratenkonvertierung im Abspielpfad auffangen, also genau der +Rechenstufe, die wir dort bewusst nicht haben. + +Konsequenz für die Hardware: ein Interface mit mindestens vier Ausgängen, oder +ein DJ-Controller mit eingebauter Soundkarte. Das ist eine +Anschaffungsentscheidung, keine Softwarefrage — und sie steht vor Phase 1, weil +sich der Cue-Bus sonst nicht testen lässt. + +Plattformseitig: unter Linux ALSA oder PipeWire, unter macOS CoreAudio, unter +Windows ASIO (CPAL hat dafür ein Feature-Flag). Aggregate Devices unter macOS +lösen das Taktproblem übrigens *nicht* — sie bündeln zwei physische Uhren. + +### 2. Atomics reichen nicht mehr — es braucht eine Kommandoschlange + +Phase 0 steuert das Deck über Atomics. Das trägt für Skalare wie Tempo und +Play-Zustand, aber nicht für „lade diesen Track" oder „setze acht Cue-Points". + +Nötig ist eine **lock-freie SPSC-Schlange** vom Steuer- zum Audio-Thread +([`rtrb`](https://crates.io/crates/rtrb), MIT/Apache-2.0). Und eine zweite in +die Gegenrichtung, deren Zweck man leicht übersieht: + +> Wird ein Track auf einem Deck ausgetauscht, darf der alte `Arc` **nicht +> im Audio-Thread fallen gelassen werden.** Der letzte Drop gibt hundert +> Megabyte frei — ein Syscall mitten im Callback, also genau der Aussetzer, den +> die ganze Architektur vermeiden soll. Der alte Arc geht deshalb über eine +> Rückschlange hinaus und stirbt außerhalb. + +``` +Steuer-Thread ──── Kommandos (rtrb) ────► Audio-Thread + ◄─── Altlasten (rtrb) ───── + ◄─── Zustand (Atomics) ──── +``` + +### 3. Beatgrid ist mehr als BPM + +Für Sync reicht eine Zahl nicht. Gebraucht wird: + +- **Anker** — Position des ersten Downbeats in Frames +- **BPM** +- optional eine **Liste von Grid-Markern** für Material mit schwankendem Tempo + (Live-Aufnahmen, alte Platten) + +Und Sync braucht zwei Größen, nicht eine: **Tempo** *und* **Phase**. Zwei Decks +mit identischem BPM, aber verschobener Phase, klingen genauso falsch wie +unterschiedliches Tempo. Die Engine braucht dafür eine globale Beat-Uhr, an der +ein Deck als Tempo-Master hängt. + +## Architektur + +### Crates + +``` +crates/ + audio-core/ steht — Dekodierung, WSOLA, ein Deck, CPAL-Ausgabe + audio-engine/ NEU — Mehrdeck-Graph, Mixer, Cue-Bus, Effekte, Master + analysis/ NEU — BPM, Beatgrid, Tonart, Waveform-Peaks (offline) + library/ NEU — SQLite, Metadaten, Playlists, Traktor-Import + sources/ NEU — Trackquellen: Datei, später Generierung + musik-app/ NEU — UI + musik-cli/ steht — bleibt als Testwerkzeug +``` + +`audio-core` behält seine Rolle als Deck-Innenleben; `audio-engine` ist alles +darüber. Die Trennung lohnt sich, weil `audio-engine` die Echtzeitregeln +durchsetzt und `analysis`/`library` sie bewusst nicht kennen müssen. + +### Signalkette pro Kanal + +``` +Deck ─► Trim ─► EQ (3-Band) ─► Filter (HP/LP) ─► Kanalfader ─┬─► Crossfader ─► Master-Summe ─► Limiter ─► Ausgang 1/2 + │ + └─► Cue-Bus ──────────────────────────────► Ausgang 3/4 +``` + +Zwei Details aus der Traktor-Referenz, die man leicht falsch baut: + +- **Mixer-FX liegen post-fader.** Zieht man den Fader zu, klingt der Effekt aus + statt abzureißen. Das ist der Unterschied zwischen einem weichen und einem + ruckeligen Übergang. +- **Der Cue-Abgriff liegt vor dem Crossfader.** Sonst hört man im Kopfhörer + nichts, wenn der Kanal ausgeblendet ist — also genau dann, wenn man ihn + braucht. + +### Threads + +| Thread | Aufgabe | Regeln | +| --- | --- | --- | +| Audio-Callback | Decks lesen, mischen, Effekte, Ausgabe | Keine Allokation, kein Lock, kein Syscall | +| UI | Darstellung ~60 fps, Eingaben → Kommandos | Liest Zustand über Atomics | +| Lader | Datei dekodieren, resamplen → `Arc` | Darf blockieren | +| Analyse | BPM, Grid, Peaks, später Stems | Parallel, im Hintergrund | + +## Datenhaltung + +**Analyse-Ergebnisse als Sidecar, adressiert über den Inhalts-Hash.** + +Nicht über den Dateipfad — sonst ist die Arbeit weg, sobald jemand einen Ordner +umbenennt. Der Hash wird über die dekodierten Audiodaten gebildet, nicht über +die Datei, damit ein geänderter ID3-Tag die Analyse nicht entwertet. + +Inhalt: BPM, Grid-Anker und -Marker, Tonart, Loudness, Waveform-Peaks in +mehreren Auflösungen (grob für die Übersicht, fein für den Zoom). + +**Library als SQLite** ([`rusqlite`](https://crates.io/crates/rusqlite), MIT). + +Tabellen: `tracks`, `cues`, `loops`, `playlists`, `playlist_items`, `analysis`. + +⚠️ **`tracks` braucht von Anfang an `license` und `attribution`.** CC BY +verlangt Namensnennung auch bei nicht-kommerzieller Nutzung, und das lässt sich +nicht rekonstruieren, wenn erst tausend Freesound-Samples ohne Herkunft im +Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). + +## Phasen + +| # | Ziel | Abnahmekriterium | Aufwand | Braucht | +| --- | --- | --- | --- | --- | +| 1 | Mehrdeck-Engine, Mixer, Cue-Bus | Zwei Tracks laufen gleichzeitig, Crossfader blendet, Cue liegt hörbar getrennt auf 3/4 | M | 4-Kanal-Interface | +| 2 | Analyse-Pipeline | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | +| 3 | UI-Grundgerüst | Zwei Decks mit Waveform, Mixer bedienbar, stabile 60 fps | L | 1, 2 | +| 4 | Sync und Beatmatching | Zwei Tracks bleiben über 5 Minuten im Takt, Phase korrekt | M | 2 | +| 5 | Hot Cues und Loops | 8 Cues pro Deck, Loop in/out, beat-quantisiert | M | 4 | +| 6 | Library | Import, Suche, Playlists, Traktor-`collection.nml` | L | 2 | +| 7 | Effekte | 6–8 gute Effekte, Mixer-FX post-fader | M | 1 | +| 8 | Stems | Beim Import vorberechnet, Stem-Deck-Modus | L | 6 | +| 9 | Mitschnitt | Master-Bus als WAV/FLAC, ohne Aussetzer | S | 1 | +| 10 | MIDI-Controller | Generic-MIDI-Mapping, speicherbar | M | 3 | + +Phase 1 und 2 sind unabhängig und können parallel laufen — die Analyse braucht +die Engine nicht. + +Ab Phase 6 ist die Software als DJ-Werkzeug benutzbar. 7 bis 10 machen sie gut. + +**Wenige gute Effekte statt vierzig.** Traktor hat über 40; die meisten davon +benutzt niemand. Sinnvoll sind Filter, Delay, Reverb, Beatmasher/Gater, Flanger +und ein Bitcrusher — richtig gebaut und mit vernünftigen Regelwegen. + +## Offene Entscheidungen + +**UI-Framework** (Phase 3). Empfehlung: **egui/eframe** (MIT/Apache-2.0, +GPU-beschleunigt). Passt zu einer DJ-Oberfläche, weil die ohnehin jedes Bild neu +zeichnet — laufende Waveforms, bewegte Fader, Pegelanzeigen. Immediate-Mode ist +für so etwas eher Vorteil als Nachteil, und die Waveform wird selbst gemalt statt +aus Widgets zusammengesetzt. + +Die Alternative wäre Tauri mit TypeScript-Frontend. Näher an vorhandener +Erfahrung, aber die Zustandskopplung zwischen UI und Audio ist eng und +hochfrequent — das über IPC zu führen ist Aufwand, den egui nicht hat. + +**Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier +Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. + +## Risiken + +| Risiko | Auswirkung | Umgang | +| --- | --- | --- | +| Cue-Ausgang braucht bestimmte Hardware | Phase 1 nicht abnehmbar | Vor Phase 1 klären, nicht währenddessen | +| Eigene WSOLA klingt bei ±8 % nicht gut genug | Kernfunktion unbrauchbar | Rubber Band per FFI — steht offen, siehe [BAUSTEINE.md](BAUSTEINE.md) | +| Beatgrid-Erkennung bei nicht-geradem Material | Sync unbrauchbar für Teile der Sammlung | Manuelle Grid-Korrektur einplanen, nicht als Notlösung, sondern als Feature | +| Stem-Separation zu langsam | Phase 8 zieht sich | Beim Import vorberechnen, Fortschritt anzeigen, nie im Abspielpfad | +| UI schafft keine 60 fps mit Waveforms | Fühlt sich träge an | Peaks vorberechnet halten, nie im Zeichenpfad rechnen | + +## Nächster konkreter Schritt + +Vor Phase 1 steht eine Hörprobe: die vorhandene WSOLA mit einem echten Track bei +±8 % beurteilen. Fällt sie durch, ändert das die Reihenfolge — dann kommt der +Stretcher-Tausch vor dem Mehrdeck-Umbau, weil sonst auf einem wackligen +Fundament weitergebaut wird. From a85aaad60789b2e9a8b3c2de01593dc2e42a2c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:15:17 +0000 Subject: [PATCH 06/70] feat: offline analysis for tempo, beatgrid and waveform peaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the build plan. Runs entirely without an audio device, so it is verifiable end to end here. Pipeline: spectral flux onset envelope, autocorrelation for the coarse period, a descent to the fundamental, then a comb filter for the exact period and phase. Each stage uses a different tool on purpose — autocorrelation compares the signal against a single shift and tolerates a small period error, while a comb filter sums over hundreds of pulses and a half-frame error misaligns the fiftieth pulse by twenty-five. The first draft used the comb for the coarse search and detected 128 BPM as 85; measuring the comb curve rather than reasoning about it is what found that. The octave ambiguity gets its own stage. Autocorrelation peaks at every multiple of the beat period, so 174 BPM correlates exactly as strongly at 87, and a prior centred near 120 picks the wrong one. The half period is a valley rather than a peak, so halving while correlation survives lands on the shortest period of the series. What remains is a genuine ambiguity — 87 BPM hip-hop with hats on eighths reads as 174 — which is why DJ software has a halve/double button. Anchors are corrected in the time domain. Spectral flux fires when a transient enters the analysis window, not when it sounds, leaving a systematic lead of about 15 ms — four percent of a beat at 128 BPM, enough to make sync useless. A search on a fine energy-rise envelope, bounded to half a period so it cannot jump to a neighbouring beat, brings that to 3 ms. Results are stored keyed by a hash of the decoded audio rather than the path, so renaming a folder does not discard the work and duplicate files are analysed once. Quantised to i16 before hashing so tag edits do not invalidate it. Refusing to answer is a valid answer: a sustained tone yields no tempo rather than a guessed one, gated on how far the autocorrelation peak exceeds the mean. The threshold separates the two measured extremes but has never been calibrated against a real library, and says so. Adds musik-analyze to drive it, egui recorded as the UI decision, and 24 tests covering detection, the octave descent, anchor accuracy, peak quantisation and the sidecar cache. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- .gitignore | 3 + Cargo.lock | 165 +++++++ Cargo.toml | 3 +- README.md | 38 +- crates/analysis/Cargo.toml | 13 + crates/analysis/src/lib.rs | 126 ++++++ crates/analysis/src/onset.rs | 175 ++++++++ crates/analysis/src/peaks.rs | 125 ++++++ crates/analysis/src/sidecar.rs | 248 +++++++++++ crates/analysis/src/tempo.rs | 496 ++++++++++++++++++++++ crates/analysis/src/testing.rs | 39 ++ crates/musik-cli/Cargo.toml | 1 + crates/musik-cli/src/bin/musik-analyze.rs | 107 +++++ docs/PLAN.md | 37 +- 14 files changed, 1557 insertions(+), 19 deletions(-) create mode 100644 crates/analysis/Cargo.toml create mode 100644 crates/analysis/src/lib.rs create mode 100644 crates/analysis/src/onset.rs create mode 100644 crates/analysis/src/peaks.rs create mode 100644 crates/analysis/src/sidecar.rs create mode 100644 crates/analysis/src/tempo.rs create mode 100644 crates/analysis/src/testing.rs create mode 100644 crates/musik-cli/src/bin/musik-analyze.rs diff --git a/.gitignore b/.gitignore index 12662f0..045848a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # Rust — Cargo.lock wird bewusst eingecheckt (Anwendung, keine Bibliothek) /target/ +# Analyse-Sidecars (aus dem Audio reproduzierbar) +.musik-analyse/ + # Node node_modules/ dist/ diff --git a/Cargo.lock b/Cargo.lock index 8d8c866..6121cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,12 +24,36 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "analysis" +version = "0.1.0" +dependencies = [ + "audio-core", + "base64", + "blake3", + "rustfft", + "serde", + "serde_json", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "audio-core" version = "0.1.0" @@ -45,12 +69,32 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block2" version = "0.6.2" @@ -78,6 +122,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -94,6 +148,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "coreaudio-rs" version = "0.14.2" @@ -138,6 +198,15 @@ dependencies = [ "windows-core", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -166,6 +235,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + [[package]] name = "futures-core" version = "0.3.33" @@ -206,6 +281,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -309,6 +390,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "musik-cli" version = "0.1.0" dependencies = [ + "analysis", "anyhow", "audio-core", ] @@ -362,6 +444,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -501,6 +592,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -543,6 +643,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -564,6 +678,16 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -584,6 +708,25 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -612,6 +755,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "symphonia" version = "0.6.0" @@ -853,6 +1002,16 @@ dependencies = [ "winnow", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1060,3 +1219,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f52b2ba..30becad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "2" -members = ["crates/audio-core", "crates/musik-cli"] +members = ["crates/analysis","crates/audio-core", "crates/musik-cli"] [workspace.package] edition = "2021" publish = false [workspace.dependencies] +analysis = { path = "crates/analysis" } audio-core = { path = "crates/audio-core" } diff --git a/README.md b/README.md index 05116d7..04860e3 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den Reaktionen der Crowd formt. -Status: **Phase 0.** Ein Deck läuft — Wiedergabe, Tempo mit und ohne Keylock, -Springen. Nativ in Rust. +Status: **Phase 0 und 2.** Ein Deck läuft (Wiedergabe, Tempo mit und ohne +Keylock, Springen), und die Analyse-Pipeline erkennt Tempo, Beatgrid und +Wellenform-Spitzen. Nativ in Rust. ## Zielbild @@ -47,14 +48,15 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap -**Phase 0 steht** ✅ — ein Deck, Wiedergabe, Tempo mit und ohne Keylock. +**Phase 0 und 2 stehen** ✅ — ein Deck mit Keylock, und die Analyse-Pipeline. Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: | Phase | Inhalt | | --- | --- | -| 1–2 | Mehrdeck-Engine mit Mixer und Cue-Bus · Analyse-Pipeline (BPM, Grid, Peaks) | +| 1 | Mehrdeck-Engine mit Mixer und Cue-Bus | +| ~~2~~ ✅ | ~~Analyse-Pipeline (BPM, Grid, Peaks)~~ | | 3–5 | UI mit Waveforms · Sync und Beatmatching · Hot Cues und Loops | | 6–7 | Library inkl. Traktor-Import · Effekte | | 8–10 | Stems · Mitschnitt · MIDI-Controller | @@ -84,13 +86,17 @@ und der Werkzeugkasten sollte sie nicht vorwegnehmen. | --- | --- | --- | | `cpal` | Apache-2.0 | Audioausgabe | | `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | +| `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | +| `blake3`, `serde`, `base64` | permissiv | Fingerabdruck und Sidecar | -Die Zeitstreckung (WSOLA) ist selbst implementiert. Rubber Band ist besser und -steht inzwischen offen (GPL, nicht kommerzielles Projekt) — der Tausch lohnt -aber erst, wenn die eigene Variante hörbar nicht reicht. Details in +Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert. Rubber Band +ist beim Stretching besser und steht inzwischen offen (GPL, nicht kommerzielles +Projekt) — der Tausch lohnt aber erst, wenn die eigene Variante hörbar nicht +reicht. Details in [docs/BAUSTEINE.md](docs/BAUSTEINE.md#phase-0-was-gebaut-ist). -UI ist noch offen und kommt frühestens mit Phase 1. +**UI: egui/eframe**, entschieden. Begründung und die verworfenen Alternativen +(Tauri, Electron) in [docs/PLAN.md](docs/PLAN.md#entschiedene-punkte). ## Nicht-Ziele (vorerst) @@ -127,7 +133,10 @@ Details in 1. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — prägt das Agenten-Design. -2. **UI-Schicht.** Frühestens ab Phase 1. +2. **Audio-Interface mit vier Ausgängen.** Ohne getrennten Cue-Ausgang lässt + sich Phase 1 nicht abnehmen — eine Hardware-, keine Softwarefrage. Warum + zwei Geräte nicht gehen, steht in + [docs/PLAN.md](docs/PLAN.md#1-der-cue-ausgang-zwingt-zu-einem-gerät-mit-vier-ausgängen). ## Setup @@ -160,4 +169,15 @@ Der Vergleich, um den es geht: `t +6` einmal mit und einmal ohne `k`. Mit Keylock bleibt die Tonhöhe stehen, ohne wandert sie hoch wie beim Plattenspieler. +Tracks analysieren — braucht **kein** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-analyze -- ~/Musik/*.mp3 +``` + +Liefert BPM, Beatgrid-Anker und Wellenform-Spitzen und legt sie als Sidecar +unter `.musik-analyse/` ab, adressiert über einen Hash des Audioinhalts. Ein +zweiter Lauf über dieselbe Datei liest aus dem Cache; ein umbenannter Ordner +entwertet die Analyse nicht. + Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/analysis/Cargo.toml b/crates/analysis/Cargo.toml new file mode 100644 index 0000000..a2cf3ae --- /dev/null +++ b/crates/analysis/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "analysis" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +base64 = "0.23.1" +blake3 = "1.8.6" +rustfft = "6.4.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs new file mode 100644 index 0000000..58d6109 --- /dev/null +++ b/crates/analysis/src/lib.rs @@ -0,0 +1,126 @@ +//! Offline-Analyse: Tempo, Beatgrid und Wellenform-Spitzen. +//! +//! Läuft bewusst außerhalb des Abspielpfads. Nichts hier hat Echtzeitauflagen, +//! dafür ist alles reproduzierbar und wird über den Inhalts-Hash +//! zwischengespeichert — siehe [`sidecar`]. + +pub mod onset; +pub mod peaks; +pub mod sidecar; +pub mod tempo; + +#[cfg(test)] +pub(crate) mod testing; + +use audio_core::Track; + +pub use sidecar::{Analysis, Store}; +pub use tempo::Beatgrid; + +/// Analysiert einen dekodierten Track vollständig. +pub fn analyze(track: &Track) -> Analysis { + let envelope = onset::onset_envelope(&track.samples, track.sample_rate); + let grid = tempo::detect(&envelope) + .map(|g| tempo::refine_anchor(&track.samples, track.sample_rate, g)); + let levels = peaks::compute(&track.samples); + + Analysis { + version: sidecar::FORMAT_VERSION, + fingerprint: sidecar::fingerprint(track), + sample_rate: track.sample_rate, + frames: track.frames() as u64, + duration_secs: track.duration_secs(), + bpm: grid.map(|g| g.bpm), + beat_anchor_frames: grid.map(|g| g.anchor_frames), + bpm_confidence: grid.map(|g| g.confidence), + peaks: levels + .iter() + .map(sidecar::PeakLevelData::from_level) + .collect(), + } +} + +/// Analysiert einen Track, sofern kein passendes Sidecar vorliegt. +/// +/// Gibt zusätzlich zurück, ob gerechnet werden musste (`true`) oder der Cache +/// gereicht hat (`false`). +pub fn analyze_cached(track: &Track, store: &Store) -> (Analysis, bool) { + let fp = sidecar::fingerprint(track); + if let Some(vorhanden) = store.load(&fp) { + return (vorhanden, false); + } + (analyze(track), true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::click_track; + + fn track(bpm: f64, secs: f64) -> Track { + Track { + samples: click_track(bpm, 44_100, secs, 0.0), + sample_rate: 44_100, + } + } + + #[test] + fn vollstaendige_analyse_liefert_alle_felder() { + let t = track(128.0, 25.0); + let a = analyze(&t); + + assert_eq!(a.version, sidecar::FORMAT_VERSION); + assert_eq!(a.sample_rate, 44_100); + assert_eq!(a.frames, t.frames() as u64); + assert!((a.duration_secs - 25.0).abs() < 0.1); + assert_eq!(a.peaks.len(), peaks::LEVELS.len()); + + let bpm = a.bpm.expect("kein BPM erkannt"); + assert!((bpm - 128.0).abs() < 1.0, "BPM {bpm:.2}"); + assert!(a.beat_anchor_frames.is_some()); + } + + #[test] + fn gleichfoermiges_material_liefert_kein_tempo() { + // Ein Dauerton hat keine Onsets — hier ist `None` die richtige Antwort, + // nicht eine geratene Zahl. + let frames = 44_100 * 20; + let mut samples = Vec::with_capacity(frames * 2); + for i in 0..frames { + let v = (2.0 * std::f32::consts::PI * 220.0 * i as f32 / 44_100.0).sin() * 0.5; + samples.push(v); + samples.push(v); + } + let t = Track { + samples, + sample_rate: 44_100, + }; + + let a = analyze(&t); + assert!( + a.bpm.is_none(), + "Tempo aus einem Dauerton geraten: {:?}", + a.bpm + ); + } + + #[test] + fn zweiter_lauf_kommt_aus_dem_cache() { + let dir = std::env::temp_dir().join("musik-analysis-test-cached"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let t = track(128.0, 25.0); + + let (erste, gerechnet) = analyze_cached(&t, &store); + assert!(gerechnet); + store.save(&erste).expect("speichern"); + + let (zweite, nochmal_gerechnet) = analyze_cached(&t, &store); + assert!(!nochmal_gerechnet, "Cache wurde nicht genutzt"); + assert_eq!(zweite.fingerprint, erste.fingerprint); + assert_eq!(zweite.bpm, erste.bpm); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/analysis/src/onset.rs b/crates/analysis/src/onset.rs new file mode 100644 index 0000000..36caf13 --- /dev/null +++ b/crates/analysis/src/onset.rs @@ -0,0 +1,175 @@ +//! Onset-Hüllkurve über spektralen Fluss. +//! +//! Grundlage für alles Rhythmische: Statt im Zeitsignal nach lauten Stellen zu +//! suchen, wird gemessen, wie stark sich das *Spektrum* von Frame zu Frame +//! ändert. Ein Hi-Hat auf einer laufenden Bassline erzeugt kaum Pegelanstieg, +//! aber deutlichen spektralen Fluss — deshalb funktioniert das dort, wo eine +//! Pegelmessung versagt. + +use rustfft::{num_complex::Complex, FftPlanner}; + +use audio_core::track::CHANNELS; + +pub const WINDOW: usize = 1024; +pub const HOP: usize = 256; + +pub struct OnsetEnvelope { + /// Ein Wert pro Analyse-Frame, halbwellengleichgerichtet, Maximum auf 1.0. + pub values: Vec, + /// Frames pro Sekunde. + pub rate: f64, + pub hop: usize, +} + +impl OnsetEnvelope { + /// Rechnet eine Frame-Position (auch gebrochen) in Sample-Frames um. + pub fn to_sample_frames(&self, frame: f64) -> f64 { + frame * self.hop as f64 + } +} + +/// Berechnet die Hüllkurve aus interleaved Stereo. +pub fn onset_envelope(samples: &[f32], sample_rate: u32) -> OnsetEnvelope { + let rate = sample_rate as f64 / HOP as f64; + let mono = downmix(samples); + + if mono.len() < WINDOW { + return OnsetEnvelope { + values: Vec::new(), + rate, + hop: HOP, + }; + } + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(WINDOW); + let window = hann(WINDOW); + let bins = WINDOW / 2 + 1; + + let mut prev = vec![0.0f32; bins]; + let mut cur = vec![0.0f32; bins]; + let mut scratch = vec![Complex::new(0.0f32, 0.0f32); WINDOW]; + let mut flux = Vec::with_capacity((mono.len() - WINDOW) / HOP + 1); + + let frames = (mono.len() - WINDOW) / HOP + 1; + for f in 0..frames { + let off = f * HOP; + for i in 0..WINDOW { + scratch[i] = Complex::new(mono[off + i] * window[i], 0.0); + } + fft.process(&mut scratch); + + for (k, slot) in cur.iter_mut().enumerate() { + *slot = scratch[k].norm(); + } + + if f == 0 { + flux.push(0.0); + } else { + // Nur Zuwachs zählt — abklingende Energie ist kein Onset. + let mut sum = 0.0; + for k in 0..bins { + let d = cur[k] - prev[k]; + if d > 0.0 { + sum += d; + } + } + flux.push(sum); + } + + std::mem::swap(&mut prev, &mut cur); + } + + detrend(&mut flux, rate); + + OnsetEnvelope { + values: flux, + rate, + hop: HOP, + } +} + +fn downmix(samples: &[f32]) -> Vec { + samples + .chunks_exact(CHANNELS) + .map(|f| 0.5 * (f[0] + f[1])) + .collect() +} + +fn hann(n: usize) -> Vec { + (0..n) + .map(|i| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / n as f32).cos()) + .collect() +} + +/// Zieht den gleitenden Mittelwert ab und richtet gleich. +/// +/// Ohne diesen Schritt dominiert der laute Teil eines Tracks die gesamte +/// Hüllkurve, und eine leise Passage liefert keine verwertbaren Onsets mehr. +fn detrend(values: &mut [f32], rate: f64) { + let win = ((0.3 * rate) as usize).max(1) | 1; + let half = win / 2; + let n = values.len(); + if n == 0 { + return; + } + + let mut prefix = Vec::with_capacity(n + 1); + prefix.push(0.0f64); + for v in values.iter() { + prefix.push(prefix[prefix.len() - 1] + *v as f64); + } + + let mut out = Vec::with_capacity(n); + for (i, value) in values.iter().enumerate() { + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(n); + let mean = (prefix[hi] - prefix[lo]) / (hi - lo) as f64; + out.push((*value as f64 - mean).max(0.0) as f32); + } + + let peak = out.iter().cloned().fold(0.0f32, f32::max); + let scale = if peak > 0.0 { 1.0 / peak } else { 0.0 }; + for (dst, src) in values.iter_mut().zip(out) { + *dst = src * scale; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::click_track; + + #[test] + fn klicks_erzeugen_klare_spitzen() { + let rate = 44_100; + let track = click_track(120.0, rate, 6.0, 0.0); + let env = onset_envelope(&track, rate); + + assert!(!env.values.is_empty()); + assert!((env.rate - rate as f64 / HOP as f64).abs() < 1e-9); + + // Bei 120 BPM in 6 s liegen 12 Klicks. Deutlich über der Hälfte des + // Maximums sollten grob so viele Frames liegen — nicht hunderte. + let laut = env.values.iter().filter(|v| **v > 0.5).count(); + assert!( + (1..=60).contains(&laut), + "unplausibel viele/wenige Spitzen: {laut}" + ); + } + + #[test] + fn stille_liefert_keine_onsets() { + let rate = 44_100; + let stille = vec![0.0f32; rate as usize * CHANNELS]; + let env = onset_envelope(&stille, rate); + + assert!(env.values.iter().all(|v| *v == 0.0)); + } + + #[test] + fn zu_kurzes_material_paniert_nicht() { + let env = onset_envelope(&[0.0; 32], 44_100); + assert!(env.values.is_empty()); + } +} diff --git a/crates/analysis/src/peaks.rs b/crates/analysis/src/peaks.rs new file mode 100644 index 0000000..8a45955 --- /dev/null +++ b/crates/analysis/src/peaks.rs @@ -0,0 +1,125 @@ +//! Vorberechnete Wellenform-Spitzen in mehreren Auflösungen. +//! +//! Die Darstellung darf nie über den Rohdaten rechnen: Bei fünf Minuten Audio +//! sind das dreizehn Millionen Werte pro Deck und Bild. Stattdessen werden +//! Min/Max je Fenster einmalig abgelegt, in drei Stufen von Übersicht bis Zoom. +//! +//! Quantisiert auf `i8` — für eine Wellenformdarstellung sind 8 Bit reichlich, +//! und es viertelt die Dateigröße gegenüber `f32`. + +use audio_core::track::CHANNELS; + +/// Samples pro Spitze, von fein nach grob. +pub const LEVELS: [u32; 3] = [256, 2048, 16384]; + +#[derive(Debug, Clone)] +pub struct PeakLevel { + pub samples_per_peak: u32, + pub min: Vec, + pub max: Vec, +} + +impl PeakLevel { + pub fn len(&self) -> usize { + self.min.len() + } + + pub fn is_empty(&self) -> bool { + self.min.is_empty() + } +} + +/// Berechnet alle Stufen aus interleaved Stereo. +pub fn compute(samples: &[f32]) -> Vec { + let mono: Vec = samples + .chunks_exact(CHANNELS) + .map(|f| 0.5 * (f[0] + f[1])) + .collect(); + + LEVELS.iter().map(|n| level(&mono, *n)).collect() +} + +fn level(mono: &[f32], samples_per_peak: u32) -> PeakLevel { + let bucket = samples_per_peak as usize; + let count = mono.len().div_ceil(bucket); + + let mut min = Vec::with_capacity(count); + let mut max = Vec::with_capacity(count); + + for chunk in mono.chunks(bucket) { + let (lo, hi) = chunk + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + min.push(quantize(lo)); + max.push(quantize(hi)); + } + + PeakLevel { + samples_per_peak, + min, + max, + } +} + +fn quantize(v: f32) -> i8 { + (v.clamp(-1.0, 1.0) * 127.0).round() as i8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spitzen_treffen_die_extremwerte() { + // Rampe von -1 bis +1 über 4096 Frames. + let frames = 4096; + let mut samples = Vec::with_capacity(frames * CHANNELS); + for i in 0..frames { + let v = -1.0 + 2.0 * i as f32 / (frames - 1) as f32; + samples.push(v); + samples.push(v); + } + + let levels = compute(&samples); + assert_eq!(levels.len(), LEVELS.len()); + + let fein = &levels[0]; + assert_eq!(fein.samples_per_peak, 256); + assert_eq!(fein.len(), frames / 256); + + // Erster Bucket beginnt bei -1, letzter endet bei +1. + assert_eq!(fein.min[0], -127); + assert_eq!(*fein.max.last().unwrap(), 127); + + // Innerhalb eines Buckets muss max >= min gelten. + for (lo, hi) in fein.min.iter().zip(&fein.max) { + assert!(hi >= lo); + } + } + + #[test] + fn groebere_stufen_haben_weniger_spitzen() { + let samples = vec![0.5f32; 100_000 * CHANNELS]; + let levels = compute(&samples); + + for pair in levels.windows(2) { + assert!( + pair[0].len() > pair[1].len(), + "Stufen sind nicht monoton gröber" + ); + } + } + + #[test] + fn leeres_material_ergibt_leere_stufen() { + let levels = compute(&[]); + assert!(levels.iter().all(|l| l.is_empty())); + } + + #[test] + fn uebersteuerung_wird_geklemmt() { + let samples = vec![4.0f32; 512 * CHANNELS]; + let levels = compute(&samples); + assert_eq!(levels[0].max[0], 127); + } +} diff --git a/crates/analysis/src/sidecar.rs b/crates/analysis/src/sidecar.rs new file mode 100644 index 0000000..976207e --- /dev/null +++ b/crates/analysis/src/sidecar.rs @@ -0,0 +1,248 @@ +//! Ablage der Analyseergebnisse, adressiert über den Inhalt. +//! +//! Der Schlüssel ist ein Hash über die dekodierten Audiodaten — **nicht** über +//! den Dateipfad. Sonst ist die Arbeit weg, sobald jemand einen Ordner +//! umbenennt, und doppelt vorhandene Tracks würden zweimal analysiert. +//! +//! Gehasht wird auf `i16` quantisiert. Ein geänderter ID3-Tag lässt die +//! Sidecars damit unberührt, und die Empfindlichkeit gegenüber winzigen +//! Fließkomma-Unterschieden sinkt deutlich. +//! +//! Sie verschwindet aber nicht: Werte direkt an einer Rundungsgrenze können +//! trotzdem kippen. Ein Decoder-Wechsel kann Sidecars also entwerten — das ist +//! hingenommen, weil die Analyse dann schlicht neu läuft. + +use std::path::{Path, PathBuf}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +use audio_core::Track; + +use crate::peaks::PeakLevel; + +/// Bei einer Änderung des Formats hochzählen — ältere Sidecars werden dann +/// verworfen statt falsch gelesen. +pub const FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Analysis { + pub version: u32, + pub fingerprint: String, + pub sample_rate: u32, + pub frames: u64, + pub duration_secs: f64, + + /// `None`, wenn kein verlässliches Tempo gefunden wurde. + #[serde(default)] + pub bpm: Option, + /// Erster Beat in Sample-Frames. + #[serde(default)] + pub beat_anchor_frames: Option, + #[serde(default)] + pub bpm_confidence: Option, + + pub peaks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeakLevelData { + pub samples_per_peak: u32, + /// base64, ein Byte je Spitze (i8 als u8 kodiert). + pub min: String, + pub max: String, +} + +impl PeakLevelData { + pub fn from_level(level: &PeakLevel) -> Self { + PeakLevelData { + samples_per_peak: level.samples_per_peak, + min: encode(&level.min), + max: encode(&level.max), + } + } + + pub fn to_level(&self) -> Option { + Some(PeakLevel { + samples_per_peak: self.samples_per_peak, + min: decode(&self.min)?, + max: decode(&self.max)?, + }) + } +} + +fn encode(values: &[i8]) -> String { + let bytes: Vec = values.iter().map(|v| *v as u8).collect(); + STANDARD.encode(bytes) +} + +fn decode(s: &str) -> Option> { + STANDARD + .decode(s) + .ok() + .map(|b| b.into_iter().map(|v| v as i8).collect()) +} + +/// Inhalts-Hash eines dekodierten Tracks. +pub fn fingerprint(track: &Track) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&track.sample_rate.to_le_bytes()); + + let mut buf = Vec::with_capacity(2 * 8192); + for chunk in track.samples.chunks(8192) { + buf.clear(); + for s in chunk { + let q = (s.clamp(-1.0, 1.0) * 32_767.0).round() as i16; + buf.extend_from_slice(&q.to_le_bytes()); + } + hasher.update(&buf); + } + + hasher.finalize().to_hex()[..32].to_string() +} + +/// Verzeichnisbasierter Speicher für Sidecars. +pub struct Store { + root: PathBuf, +} + +impl Store { + pub fn new(root: impl Into) -> Self { + Store { root: root.into() } + } + + pub fn path_for(&self, fingerprint: &str) -> PathBuf { + self.root.join(format!("{fingerprint}.json")) + } + + /// Lädt ein Sidecar. `None` bei fehlender Datei, kaputtem JSON oder + /// veralteter Formatversion — in allen drei Fällen ist neu rechnen richtig. + pub fn load(&self, fingerprint: &str) -> Option { + let raw = std::fs::read_to_string(self.path_for(fingerprint)).ok()?; + let analysis: Analysis = serde_json::from_str(&raw).ok()?; + if analysis.version != FORMAT_VERSION { + return None; + } + Some(analysis) + } + + pub fn save(&self, analysis: &Analysis) -> std::io::Result { + std::fs::create_dir_all(&self.root)?; + let path = self.path_for(&analysis.fingerprint); + let json = serde_json::to_string_pretty(analysis) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&path, json)?; + Ok(path) + } + + pub fn root(&self) -> &Path { + &self.root + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::peaks; + use crate::testing::click_track; + + fn track(bpm: f64, secs: f64) -> Track { + Track { + samples: click_track(bpm, 44_100, secs, 0.0), + sample_rate: 44_100, + } + } + + #[test] + fn fingerabdruck_ist_stabil_und_unterscheidet() { + let a = track(128.0, 2.0); + let b = track(128.0, 2.0); + let c = track(140.0, 2.0); + + assert_eq!(fingerprint(&a), fingerprint(&b)); + assert_ne!(fingerprint(&a), fingerprint(&c)); + assert_eq!(fingerprint(&a).len(), 32); + } + + #[test] + fn lautstaerkeaenderung_aendert_den_hash() { + // Der Hash beschreibt den Inhalt, nicht die Datei — anderer Klang, + // anderer Schlüssel, damit die Analyse neu läuft. + let a = track(128.0, 2.0); + let mut b = track(128.0, 2.0); + for s in b.samples.iter_mut() { + *s *= 0.5; + } + + assert_ne!(fingerprint(&a), fingerprint(&b)); + } + + #[test] + fn spitzen_ueberleben_die_kodierung() { + let t = track(128.0, 2.0); + let levels = peaks::compute(&t.samples); + + for original in &levels { + let zurueck = PeakLevelData::from_level(original) + .to_level() + .expect("dekodieren"); + assert_eq!(zurueck.samples_per_peak, original.samples_per_peak); + assert_eq!(zurueck.min, original.min); + assert_eq!(zurueck.max, original.max); + } + } + + #[test] + fn speichern_und_laden() { + let dir = std::env::temp_dir().join("musik-analysis-test-store"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let analysis = Analysis { + version: FORMAT_VERSION, + fingerprint: "abc123".into(), + sample_rate: 44_100, + frames: 1000, + duration_secs: 0.0227, + bpm: Some(128.0), + beat_anchor_frames: Some(42), + bpm_confidence: Some(0.9), + peaks: vec![], + }; + + store.save(&analysis).expect("speichern"); + let geladen = store.load("abc123").expect("laden"); + + assert_eq!(geladen.bpm, Some(128.0)); + assert_eq!(geladen.beat_anchor_frames, Some(42)); + assert!(store.load("gibtesnicht").is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn veraltete_version_wird_verworfen() { + let dir = std::env::temp_dir().join("musik-analysis-test-version"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let mut analysis = Analysis { + version: FORMAT_VERSION, + fingerprint: "veraltet".into(), + sample_rate: 44_100, + frames: 0, + duration_secs: 0.0, + bpm: None, + beat_anchor_frames: None, + bpm_confidence: None, + peaks: vec![], + }; + analysis.version = FORMAT_VERSION + 1; + store.save(&analysis).expect("speichern"); + + assert!(store.load("veraltet").is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/analysis/src/tempo.rs b/crates/analysis/src/tempo.rs new file mode 100644 index 0000000..d158ace --- /dev/null +++ b/crates/analysis/src/tempo.rs @@ -0,0 +1,496 @@ +//! Tempo und Beatgrid aus der Onset-Hüllkurve. +//! +//! Drei Stufen mit bewusst unterschiedlichen Werkzeugen: +//! +//! 1. **Grob** per Autokorrelation. Sie vergleicht das Signal mit *einer* +//! Verschiebung und ist damit unempfindlich gegen kleine Periodenfehler. +//! 2. **Grundperiode** per Autokorrelation an der halben Verschiebung. +//! 3. **Fein** per Kammfilter, der zugleich die Phase liefert. +//! +//! Die Reihenfolge ist nicht beliebig. Ein Kammfilter summiert über hunderte +//! Pulse, und ein Periodenfehler von einem halben Frame verschiebt den +//! fünfzigsten Puls bereits um fünfundzwanzig — als Grobsuche ist er deshalb +//! unbrauchbar, als Feinsuche in einem engen Fenster dagegen ideal. +//! +//! Die Phase ist der Teil, den man leicht vergisst: ohne sie hat man ein Tempo, +//! aber kein Grid, und Sync klingt trotz gleicher BPM falsch. + +use audio_core::track::CHANNELS; + +use crate::onset::OnsetEnvelope; + +const MIN_BPM: f64 = 70.0; +const MAX_BPM: f64 = 200.0; + +/// Sanfter Erwartungswert für die Oktavwahl, nur für die Grobsuche. +const PRIOR_CENTER_BPM: f64 = 120.0; +const PRIOR_WIDTH: f64 = 1.2; + +/// Ab welcher Güte die halbe Periode als die eigentliche gilt. Siehe [`fundamental`]. +const FUNDAMENTAL_RATIO: f32 = 0.8; + +/// Wie deutlich die Autokorrelationsspitze den Mittelwert übertreffen muss, +/// damit das Ergebnis als Aussage durchgeht statt als Rauschen. +/// +/// Gemessen: Klick-Tracks liegen bei 14–27, ein reiner Dauerton bei 1,65. Der +/// Wert liegt bewusst näher am unteren Ende, deckt aber nur die beiden +/// Extreme ab — gegen eine echte Sammlung ist er nie kalibriert worden. Siehe +/// `schwellen_trennen_perkussiv_von_dauerton`. +const MIN_SALIENCE: f32 = 3.0; + +/// Glättung der Hüllkurve für die Grobstufe, in Frames. Macht die +/// Autokorrelation an ganzzahligen Verschiebungen tolerant gegen den halben +/// Frame, um den ein Onset danebenliegen kann. +const SMOOTH: usize = 3; + +#[derive(Debug, Clone, Copy)] +pub struct Beatgrid { + pub bpm: f32, + /// Erster erkannter Beat, in Sample-Frames. + pub anchor_frames: u64, + /// 0..1, abgeleitet aus der Schärfe der Autokorrelationsspitze. + pub confidence: f32, +} + +/// Ermittelt Tempo und ersten Beat. +/// +/// `None`, wenn das Material zu kurz ist oder keine verwertbare Periodizität +/// zeigt — eine geratene Zahl wäre schlechter als keine. +pub fn detect(env: &OnsetEnvelope) -> Option { + let v = &env.values; + let p_short = env.rate * 60.0 / MAX_BPM; + let p_long = env.rate * 60.0 / MIN_BPM; + + if v.len() < (p_long * 4.0) as usize { + return None; + } + if v.iter().all(|x| *x <= 0.0) { + return None; + } + + let smoothed = smooth(v, SMOOTH); + let (coarse, salience) = coarse_period(&smoothed, env.rate, p_short, p_long)?; + + if salience < MIN_SALIENCE { + return None; + } + + let period = fundamental(&smoothed, coarse, p_short); + let (period, phase) = refine(v, period, p_short); + + Some(Beatgrid { + bpm: (env.rate * 60.0 / period) as f32, + anchor_frames: env.to_sample_frames(phase).round().max(0.0) as u64, + confidence: ((salience - MIN_SALIENCE) / 8.0).clamp(0.0, 1.0), + }) +} + +/// Beste Verschiebung nach Autokorrelation, gewichtet mit dem Oktav-Prior. +/// +/// Zweiter Rückgabewert ist die Deutlichkeit: Spitze geteilt durch Mittelwert +/// über den Suchbereich. Bei einem Dauerton liegt die nahe 1, bei perkussivem +/// Material deutlich darüber — das ist das Kriterium, ob überhaupt ein Tempo +/// vorliegt. +fn coarse_period(v: &[f32], rate: f64, p_short: f64, p_long: f64) -> Option<(f64, f32)> { + let lo = p_short.floor().max(2.0) as usize; + let hi = p_long.ceil() as usize; + if hi <= lo { + return None; + } + + let mut best = 0.0f64; + let mut best_weighted = f32::NEG_INFINITY; + let mut peak = 0.0f32; + let mut sum = 0.0f64; + let mut n = 0usize; + + for lag in lo..=hi { + let r = autocorrelation(v, lag as f64); + sum += r as f64; + n += 1; + peak = peak.max(r); + + let weighted = r * octave_prior(rate * 60.0 / lag as f64) as f32; + if weighted > best_weighted { + best_weighted = weighted; + best = lag as f64; + } + } + + if best == 0.0 || n == 0 { + return None; + } + + let mean = (sum / n as f64) as f32; + let salience = if mean > 0.0 { peak / mean } else { 0.0 }; + + Some((best, salience)) +} + +/// Steigt von einer Verschiebung zu ihrer Grundperiode ab. +/// +/// Der Kern des Oktavproblems: Die Autokorrelation hat Spitzen bei *jedem* +/// Vielfachen der Beat-Periode. Bei 174 BPM liegt eine bei 174 und eine +/// genauso hohe bei 87 — der Prior allein wählt die falsche, weil 87 näher an +/// 120 liegt. +/// +/// Umgekehrt gilt es nicht: Bei der *halben* Periode liegt ein Tal, nicht eine +/// Spitze. Deshalb wird halbiert, solange die Korrelation dabei erhalten +/// bleibt, und man landet bei der kleinsten Periode der Reihe. +/// +/// Es bleibt eine echte Mehrdeutigkeit: 87-BPM-HipHop mit Hi-Hats auf Achteln +/// wird als 174 erkannt. Genau dafür haben DJ-Programme den +/// Halbieren/Verdoppeln-Knopf, und den brauchen wir auch. +fn fundamental(v: &[f32], start: f64, p_short: f64) -> f64 { + let mut period = start; + let mut score = autocorrelation(v, period); + + loop { + let half = period / 2.0; + if half < p_short { + return period; + } + let half_score = autocorrelation(v, half); + if half_score < FUNDAMENTAL_RATIO * score { + return period; + } + period = half; + score = half_score; + } +} + +/// Feinsuche ±1 Frame per Kammfilter, auf der ungeglätteten Hüllkurve. +/// +/// Hier kann die Oktave nicht mehr kippen, dafür wird die Periode auf zwei +/// Nachkommastellen genau — nötig, damit der Kamm auch am Ende eines langen +/// Tracks noch auf den Beats sitzt. +fn refine(v: &[f32], around: f64, p_short: f64) -> (f64, f64) { + let mut best = (around, 0.0, f32::NEG_INFINITY); + + let mut p = around - 1.0; + while p <= around + 1.0 { + if p >= p_short { + let (score, phase) = comb(v, p); + if score > best.2 { + best = (p, phase, score); + } + } + p += 0.02; + } + + (best.0, best.1) +} + +/// Blockgröße der Zeitbereichs-Nachkorrektur. 64 Samples ≈ 1,5 ms bei 44,1 kHz. +const FINE_HOP: usize = 64; + +/// Zieht den Anker im Zeitbereich nach. +/// +/// Der spektrale Fluss schlägt aus, sobald ein Transient ins Analysefenster +/// *eintritt* — also bis zu einer Fensterlänge, bevor er tatsächlich erklingt. +/// Über die ganze Kette bleibt davon ein systematischer Vorlauf von gut zehn +/// Millisekunden übrig. Bei 128 BPM sind das vier Prozent eines Beats, und ein +/// so verschobenes Grid macht Sync unbrauchbar. +/// +/// Die Korrektur sucht deshalb auf einer feinen Energie-Anstiegskurve nach, +/// und zwar nur innerhalb einer halben Periode um den vorhandenen Anker. Damit +/// kann sie die Phase schärfen, aber nicht auf einen anderen Beat springen. +pub fn refine_anchor(samples: &[f32], sample_rate: u32, grid: Beatgrid) -> Beatgrid { + let period = 60.0 / grid.bpm as f64 * sample_rate as f64; + if !period.is_finite() || period < (FINE_HOP * 4) as f64 { + return grid; + } + + let mono: Vec = samples + .chunks_exact(CHANNELS) + .map(|f| 0.5 * (f[0] + f[1])) + .collect(); + + let blocks = mono.len() / FINE_HOP; + if blocks < 8 { + return grid; + } + + let mut rise = Vec::with_capacity(blocks); + let mut prev = 0.0f32; + for b in 0..blocks { + let energy: f32 = mono[b * FINE_HOP..(b + 1) * FINE_HOP] + .iter() + .map(|v| v * v) + .sum(); + rise.push((energy - prev).max(0.0)); + prev = energy; + } + + let period_blocks = period / FINE_HOP as f64; + let start = grid.anchor_frames as f64 / FINE_HOP as f64; + + let mut best = (f32::NEG_INFINITY, start); + let mut offset = -period_blocks / 2.0; + while offset <= period_blocks / 2.0 { + let phase = start + offset; + offset += 1.0; + if phase < 0.0 { + continue; + } + + let mut sum = 0.0f64; + let mut n = 0usize; + let mut t = phase; + while t < (blocks - 1) as f64 { + sum += interpolate(&rise, t) as f64; + n += 1; + t += period_blocks; + } + + if n > 0 { + let score = (sum / n as f64) as f32; + if score > best.0 { + best = (score, phase); + } + } + } + + Beatgrid { + anchor_frames: (best.1 * FINE_HOP as f64).max(0.0).round() as u64, + ..grid + } +} + +fn smooth(v: &[f32], win: usize) -> Vec { + if win <= 1 { + return v.to_vec(); + } + let half = win / 2; + (0..v.len()) + .map(|i| { + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(v.len()); + v[lo..hi].iter().sum::() / (hi - lo) as f32 + }) + .collect() +} + +fn autocorrelation(v: &[f32], lag: f64) -> f32 { + let count = v.len() as f64 - lag - 1.0; + if count <= 1.0 { + return 0.0; + } + let count = count as usize; + let mut sum = 0.0f64; + for (i, x) in v.iter().enumerate().take(count) { + sum += (*x * interpolate(v, i as f64 + lag)) as f64; + } + (sum / count as f64) as f32 +} + +/// Bevorzugt Tempi in der Nähe von [`PRIOR_CENTER_BPM`] auf einer Log-Skala. +fn octave_prior(bpm: f64) -> f64 { + let d = (bpm / PRIOR_CENTER_BPM).log2() / PRIOR_WIDTH; + (-0.5 * d * d).exp() +} + +/// Legt einen Pulskamm der Periode `period` über die Hüllkurve und sucht die +/// Phase mit der größten Ausbeute. +/// +/// Rückgabe: (mittlerer Wert an den Pulsstellen, Phase in Frames). +fn comb(v: &[f32], period: f64) -> (f32, f64) { + let steps = (period * 2.0).round().max(1.0) as usize; + let mut best = (f32::NEG_INFINITY, 0.0); + + for s in 0..steps { + let phase = s as f64 * period / steps as f64; + let mut sum = 0.0f64; + let mut n = 0usize; + let mut t = phase; + + while t < (v.len() - 1) as f64 { + sum += interpolate(v, t) as f64; + n += 1; + t += period; + } + + if n > 0 { + let score = (sum / n as f64) as f32; + if score > best.0 { + best = (score, phase); + } + } + } + + best +} + +fn interpolate(v: &[f32], t: f64) -> f32 { + let i = t.floor() as usize; + if i + 1 >= v.len() { + return v[v.len() - 1]; + } + let frac = (t - i as f64) as f32; + v[i] + (v[i + 1] - v[i]) * frac +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onset::onset_envelope; + use crate::testing::click_track; + + const RATE: u32 = 44_100; + + fn erkenne(bpm: f64, offset_secs: f64, secs: f64) -> Beatgrid { + let track = click_track(bpm, RATE, secs, offset_secs); + let env = onset_envelope(&track, RATE); + detect(&env).expect("kein Beatgrid erkannt") + } + + fn deutlichkeit(samples: &[f32]) -> Option { + let env = onset_envelope(samples, RATE); + let sm = smooth(&env.values, SMOOTH); + let p_short = env.rate * 60.0 / MAX_BPM; + let p_long = env.rate * 60.0 / MIN_BPM; + coarse_period(&sm, env.rate, p_short, p_long).map(|(_, sal)| sal) + } + + fn dauerton(secs: f64) -> Vec { + let frames = (RATE as f64 * secs) as usize; + let mut samples = Vec::with_capacity(frames * 2); + for i in 0..frames { + let v = (2.0 * std::f32::consts::PI * 220.0 * i as f32 / RATE as f32).sin() * 0.5; + samples.push(v); + samples.push(v); + } + samples + } + + /// Sichert den Abstand ab, auf dem [`MIN_SALIENCE`] beruht. Rutschen die + /// beiden Enden zusammen, ist die Schwelle nicht mehr begründet. + #[test] + fn schwellen_trennen_perkussiv_von_dauerton() { + for bpm in [100.0, 128.0, 174.0] { + let sal = deutlichkeit(&click_track(bpm, RATE, 25.0, 0.0)) + .unwrap_or_else(|| panic!("keine Periode bei {bpm} BPM")); + assert!( + sal > 10.0, + "Klick-Track bei {bpm} BPM nur bei Deutlichkeit {sal:.2}" + ); + } + + let sal = deutlichkeit(&dauerton(20.0)).unwrap_or(0.0); + assert!( + sal < MIN_SALIENCE, + "Dauerton erreicht Deutlichkeit {sal:.2} und käme durch" + ); + } + + #[test] + fn erkennt_gaengige_tempi() { + for bpm in [100.0, 128.0, 140.0, 174.0] { + let grid = erkenne(bpm, 0.0, 25.0); + assert!( + (grid.bpm as f64 - bpm).abs() < 1.0, + "erwartet {bpm} BPM, erkannt {:.2}", + grid.bpm + ); + } + } + + /// Abstand des Ankers zum nächstgelegenen echten Beat, in Sekunden. + /// Der Anker darf auf jedem Beat sitzen, nur eben genau. + fn rasterfehler(grid: &Beatgrid, bpm: f64, offset: f64) -> f64 { + let period = 60.0 / bpm; + let anchor = grid.anchor_frames as f64 / RATE as f64; + let versatz = ((anchor - offset) / period).rem_euclid(1.0); + versatz.min(1.0 - versatz) * period + } + + #[test] + fn findet_den_ersten_beat() { + let offset = 0.3; + let grid = erkenne(128.0, offset, 25.0); + let fehler = rasterfehler(&grid, 128.0, offset); + + assert!( + fehler < 0.025, + "Anker liegt {:.1} ms neben dem Raster", + fehler * 1000.0 + ); + } + + #[test] + fn nachkorrektur_schaerft_den_anker() { + let offset = 0.4; + let samples = click_track(128.0, RATE, 25.0, offset); + let env = onset_envelope(&samples, RATE); + let roh = detect(&env).expect("kein Beatgrid"); + let fein = refine_anchor(&samples, RATE, roh); + + let vorher = rasterfehler(&roh, 128.0, offset); + let nachher = rasterfehler(&fein, 128.0, offset); + + assert!( + nachher < 0.005, + "nach der Korrektur immer noch {:.1} ms daneben", + nachher * 1000.0 + ); + assert!( + nachher <= vorher, + "Korrektur hat verschlechtert: {:.1} ms → {:.1} ms", + vorher * 1000.0, + nachher * 1000.0 + ); + } + + #[test] + fn nachkorrektur_springt_nicht_auf_einen_anderen_beat() { + let offset = 0.4; + let samples = click_track(128.0, RATE, 25.0, offset); + let env = onset_envelope(&samples, RATE); + let roh = detect(&env).expect("kein Beatgrid"); + let fein = refine_anchor(&samples, RATE, roh); + + let period = 60.0 / 128.0 * RATE as f64; + let sprung = (fein.anchor_frames as f64 - roh.anchor_frames as f64).abs(); + assert!( + sprung <= period / 2.0 + 1.0, + "Anker ist um {sprung:.0} Frames gewandert, mehr als eine halbe Periode" + ); + assert_eq!(fein.bpm, roh.bpm, "Tempo darf sich nicht ändern"); + } + + #[test] + fn waehlt_die_grundperiode_statt_der_haelfte() { + // 87 BPM korreliert bei 174er Material genauso stark wie 174 selbst. + // Der Abstieg muss die kleinere Periode liefern. + let grid = erkenne(174.0, 0.0, 25.0); + assert!( + grid.bpm > 150.0, + "in der halben Frequenz hängengeblieben: {:.1} BPM", + grid.bpm + ); + } + + #[test] + fn verdoppelt_nicht_ueber_das_material_hinaus() { + // Bei 100 BPM läge 200 noch im Suchbereich, dort ist aber ein Tal. + let grid = erkenne(100.0, 0.0, 25.0); + assert!( + (grid.bpm - 100.0).abs() < 1.0, + "fälschlich verdoppelt: {:.1} BPM", + grid.bpm + ); + } + + #[test] + fn zu_kurzes_material_ergibt_none() { + let track = click_track(128.0, RATE, 1.0, 0.0); + let env = onset_envelope(&track, RATE); + assert!(detect(&env).is_none()); + } + + #[test] + fn stille_ergibt_none() { + let env = onset_envelope(&vec![0.0; RATE as usize * 2 * 30], RATE); + assert!(detect(&env).is_none()); + } +} diff --git a/crates/analysis/src/testing.rs b/crates/analysis/src/testing.rs new file mode 100644 index 0000000..bafdffa --- /dev/null +++ b/crates/analysis/src/testing.rs @@ -0,0 +1,39 @@ +//! Testsignale für die Analyse. + +use audio_core::track::CHANNELS; + +/// Erzeugt einen Klick-Track als interleaved Stereo. +/// +/// Die Klicks sind kurze, deterministisch erzeugte Rauschstöße mit +/// exponentiellem Abfall — breitbandig genug, damit der spektrale Fluss sie +/// deutlich sieht, und ohne Abhängigkeit auf einen Zufallsgenerator. +pub fn click_track(bpm: f64, sample_rate: u32, secs: f64, offset_secs: f64) -> Vec { + let frames = (sample_rate as f64 * secs) as usize; + let mut out = vec![0.0f32; frames * CHANNELS]; + + let period = 60.0 / bpm * sample_rate as f64; + let burst = ((sample_rate as f64 * 0.008) as usize).max(1); + let decay = burst as f32 * 0.25; + + let mut seed = 0x1234_5678u32; + let mut at = offset_secs * sample_rate as f64; + + while (at as usize) < frames { + let start = at as usize; + for i in 0..burst { + let idx = start + i; + if idx >= frames { + break; + } + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + let env = (-(i as f32) / decay).exp(); + let v = noise * env * 0.8; + out[idx * CHANNELS] += v; + out[idx * CHANNELS + 1] += v; + } + at += period; + } + + out +} diff --git a/crates/musik-cli/Cargo.toml b/crates/musik-cli/Cargo.toml index 491875c..8ff13e8 100644 --- a/crates/musik-cli/Cargo.toml +++ b/crates/musik-cli/Cargo.toml @@ -4,5 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] +analysis = { version = "0.1.0", path = "../analysis" } anyhow = "1.0.104" audio-core = { version = "0.1.0", path = "../audio-core" } diff --git a/crates/musik-cli/src/bin/musik-analyze.rs b/crates/musik-cli/src/bin/musik-analyze.rs new file mode 100644 index 0000000..a20a98e --- /dev/null +++ b/crates/musik-cli/src/bin/musik-analyze.rs @@ -0,0 +1,107 @@ +//! Analysiert Tracks und legt die Ergebnisse als Sidecar ab. +//! +//! Läuft komplett ohne Audiogerät — Tempo, Beatgrid und Wellenform-Spitzen +//! entstehen offline. Das ist der Schritt, der Phase 2 aus `docs/PLAN.md` +//! bedient und die Grundlage für Sync und Waveform-Anzeige legt. + +use std::path::{Path, PathBuf}; + +use analysis::{Store, analyze_cached}; +use anyhow::{Context, Result}; +use audio_core::Track; + +const DEFAULT_CACHE: &str = ".musik-analyse"; + +fn main() -> Result<()> { + let mut cache = PathBuf::from(DEFAULT_CACHE); + let mut dateien: Vec = Vec::new(); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--cache" => { + let dir = args.next().context("--cache braucht ein Verzeichnis")?; + cache = PathBuf::from(dir); + } + "-h" | "--help" => { + hilfe(); + return Ok(()); + } + other => dateien.push(PathBuf::from(other)), + } + } + + if dateien.is_empty() { + hilfe(); + std::process::exit(2); + } + + let store = Store::new(&cache); + println!("Cache: {}", store.root().display()); + + let mut fehler = 0; + for pfad in &dateien { + if let Err(e) = eine_datei(pfad, &store) { + eprintln!("{}: {e:#}", pfad.display()); + fehler += 1; + } + } + + if fehler > 0 { + std::process::exit(1); + } + Ok(()) +} + +fn eine_datei(pfad: &Path, store: &Store) -> Result<()> { + let track = Track::decode_file(pfad) + .with_context(|| format!("konnte {} nicht dekodieren", pfad.display()))?; + + let (analyse, gerechnet) = analyze_cached(&track, store); + if gerechnet { + store + .save(&analyse) + .context("Sidecar konnte nicht geschrieben werden")?; + } + + let tempo = match (analyse.bpm, analyse.beat_anchor_frames) { + (Some(bpm), Some(anker)) => { + let sek = anker as f64 / analyse.sample_rate as f64; + let konfidenz = analyse.bpm_confidence.unwrap_or(0.0); + format!("{bpm:6.2} BPM Anker {sek:6.3}s Konfidenz {konfidenz:.2}") + } + _ => "kein verlässliches Tempo erkannt".to_string(), + }; + + let spitzen: Vec = analyse + .peaks + .iter() + .map(|p| format!("{}", p.samples_per_peak)) + .collect(); + + println!( + "\n{}\n {}\n {}\n Dauer {:.1}s Spitzen-Stufen {} {}", + pfad.display(), + analyse.fingerprint, + tempo, + analyse.duration_secs, + spitzen.join("/"), + if gerechnet { + "berechnet" + } else { + "aus dem Cache" + }, + ); + + Ok(()) +} + +fn hilfe() { + println!("Aufruf: musik-analyze [--cache ] ..."); + println!(); + println!("Analysiert Tempo, Beatgrid und Wellenform-Spitzen und legt sie als"); + println!("Sidecar ab, adressiert über einen Hash des Audioinhalts. Ein erneuter"); + println!("Lauf über dieselbe Datei liest aus dem Cache."); + println!(); + println!(" --cache Ablageort der Sidecars (Vorgabe: {DEFAULT_CACHE})"); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index d99ec65..9e33d12 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -151,7 +151,7 @@ Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). | # | Ziel | Abnahmekriterium | Aufwand | Braucht | | --- | --- | --- | --- | --- | | 1 | Mehrdeck-Engine, Mixer, Cue-Bus | Zwei Tracks laufen gleichzeitig, Crossfader blendet, Cue liegt hörbar getrennt auf 3/4 | M | 4-Kanal-Interface | -| 2 | Analyse-Pipeline | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | +| ~~2~~ | ~~Analyse-Pipeline~~ ✅ | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | | 3 | UI-Grundgerüst | Zwei Decks mit Waveform, Mixer bedienbar, stabile 60 fps | L | 1, 2 | | 4 | Sync und Beatmatching | Zwei Tracks bleiben über 5 Minuten im Takt, Phase korrekt | M | 2 | | 5 | Hot Cues und Loops | 8 Cues pro Deck, Loop in/out, beat-quantisiert | M | 4 | @@ -172,15 +172,34 @@ und ein Bitcrusher — richtig gebaut und mit vernünftigen Regelwegen. ## Offene Entscheidungen -**UI-Framework** (Phase 3). Empfehlung: **egui/eframe** (MIT/Apache-2.0, -GPU-beschleunigt). Passt zu einer DJ-Oberfläche, weil die ohnehin jedes Bild neu -zeichnet — laufende Waveforms, bewegte Fader, Pegelanzeigen. Immediate-Mode ist -für so etwas eher Vorteil als Nachteil, und die Waveform wird selbst gemalt statt -aus Widgets zusammengesetzt. +**Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier +Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. + +**Kalibrierung der Tempo-Schwelle.** Die Grenze, ab der ein erkanntes Tempo als +Aussage gilt statt als Rauschen, trennt derzeit nur die beiden Extreme — +Klick-Track gegen Dauerton. Gegen echte Musik ist sie nie geprüft worden; das +geht erst mit einer Sammlung auf der Platte. + +## Entschiedene Punkte + +**UI-Framework: egui/eframe** (MIT/Apache-2.0, GPU-beschleunigt über wgpu). + +Eine DJ-Oberfläche zeichnet ohnehin jedes Bild neu — laufende Waveforms, bewegte +Fader, Pegelanzeigen. Immediate-Mode ist dafür eher Vorteil als Nachteil, und +die Waveform wird selbst gemalt statt aus Widgets zusammengesetzt. + +Zwei Alternativen wurden verworfen: -Die Alternative wäre Tauri mit TypeScript-Frontend. Näher an vorhandener -Erfahrung, aber die Zustandskopplung zwischen UI und Audio ist eng und -hochfrequent — das über IPC zu führen ist Aufwand, den egui nicht hat. +- **Tauri** wäre näher an vorhandener TypeScript-Erfahrung, aber die + Zustandskopplung zwischen UI und Audio ist eng und hochfrequent. Das über IPC + zu führen ist Aufwand, den egui nicht hat. Dazu hängt das Rendering an der + System-Webview, und unter Linux ist WebKitGTK bei canvas-lastigen Oberflächen + die schwächste der Engines. +- **Electron** bringt gebündeltes Chromium und damit identisches Rendering + überall, verlangt aber ein natives N-API-Addon zum Rust-Kern samt Prebuilds + für drei Plattformen — bei ~200 MB Bundle. Cross-Plattform ist dabei kein + Argument: egui und Tauri bauen genauso für Windows, macOS und Linux. **Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird From 2ef5b67d95baba0eb012d890ca8c60dab6b6efc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:18:06 +0000 Subject: [PATCH 07/70] docs: assess integration with VibeMind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VibeMind exposes tools over MCP (FastMCP stdio servers, read tools free and write tools behind approval) and groups domains under spaces/. That is the seam: the Rust core stays the audio engine and gains a thin MCP surface, so the Python/Rust split is a process boundary rather than a problem. Two findings that change decisions here. The license relief recorded earlier does not survive this path. VibeMind is MIT and headed for publication, and publication is distribution — so GPL dependencies would once again constrain the whole project. Everything currently vendored is MIT-compatible, which is a side effect of writing the stretcher and the tempo detection here rather than taking Rubber Band or aubio. That was a workaround for an open question at the time and now happens to hold the door open. Staying permissive is the cheaper default while this is undecided. The agent layer sketched in AGENTEN.md largely duplicates Brain and OpenFang. Delivering tools here and running the agents there removes an entire stage of work; the role sketch stays valid as domain design regardless of where it executes. Also notes that write-tool approval and live performance are in tension — nobody can confirm a dialog mid-mix, so time-critical calls need a grant covering the set rather than each action, which is the safety-side view of the open automatic-versus-assistant question. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- README.md | 1 + docs/AGENTEN.md | 10 ++++ docs/BAUSTEINE.md | 10 +++- docs/VIBEMIND.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 docs/VIBEMIND.md diff --git a/README.md b/README.md index 04860e3..9af76c6 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ es nichts, was Agenten steuern könnten. | [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | | [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | | [docs/AGENTEN.md](docs/AGENTEN.md) | Multi-Agenten-Team und Crowd-Feedback-Schleife | +| [docs/VIBEMIND.md](docs/VIBEMIND.md) | Anschluss an VibeMind — MCP, Arbeitsteilung, Lizenzfolgen | | [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Bausteine und Schnittstellen | ## Aktuelle Blocker diff --git a/docs/AGENTEN.md b/docs/AGENTEN.md index 16cedb1..f00a799 100644 --- a/docs/AGENTEN.md +++ b/docs/AGENTEN.md @@ -44,6 +44,16 @@ Beschilderung, Opt-out und Löschfristen. **Empfehlung: mit anonymen, aggregierten Signalen starten** (Bewegungsenergie im Bild ohne Personenbezug, Pegel, Votes). Das liefert überraschend viel und vermeidet den ganzen Komplex. +## Erst prüfen: nicht selbst bauen + +Bevor hier ein Agenten-Framework entsteht — VibeMind hat mit Brain und OpenFang +genau diese Schicht bereits: Intent, Planung, Routing, Freigabe, Ausführung. +Die naheliegende Arbeitsteilung ist, hier nur die Werkzeuge zu liefern und die +Agenten dort laufen zu lassen. Siehe [VIBEMIND.md](VIBEMIND.md). + +Die folgenden Rollen bleiben als fachlicher Entwurf gültig, unabhängig davon, +wo sie am Ende ausgeführt werden. + ## Agentenrollen (Entwurf) ``` diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md index 8a014dc..44bc43f 100644 --- a/docs/BAUSTEINE.md +++ b/docs/BAUSTEINE.md @@ -15,8 +15,14 @@ und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine Pflichten. **Aktueller Stand: reine Eigennutzung, Weitergabe nicht geplant.** Damit ist die -Bibliothekswahl frei — auch Rubber Band, die beste der Stretching-Bibliotheken. -Eine `LICENSE`-Datei braucht das Repo in diesem Zustand nicht. +Bibliothekswahl formal frei — auch Rubber Band. Eine `LICENSE`-Datei braucht das +Repo in diesem Zustand nicht. + +⚠️ **Trotzdem ist Zurückhaltung geboten.** Ein Anschluss an VibeMind (MIT, +zur Veröffentlichung vorgesehen) steht im Raum, und der wäre Weitergabe — dann +gilt die Entlastung nicht mehr. Solange diese Frage offen ist, ist jede +GPL-Abhängigkeit eine Tür, die sich hinter einem schließt. Siehe +[VIBEMIND.md](VIBEMIND.md#1-die-lizenzlage-kippt-zurück). | Bibliothek | Lizenz | Für dieses Projekt | | --- | --- | --- | diff --git a/docs/VIBEMIND.md b/docs/VIBEMIND.md new file mode 100644 index 0000000..8e99385 --- /dev/null +++ b/docs/VIBEMIND.md @@ -0,0 +1,142 @@ +# Anschluss an VibeMind + +Kann dieses Projekt später Teil von [VibeMind](https://github.com/Flissel/Vibemind_V1) +werden? Ja. Die Anschlussstelle steht dort schon, und dieses Dokument hält fest, +was das für Entscheidungen hier bedeutet. + +Stand der Betrachtung: VibeMind V1, August 2026. + +## Wie VibeMind Dinge anschließt + +``` +Voice · UI · Channels · API + │ + Brain Intent, Planung, Routing + │ autorisierte Übergabe + OpenFang Agentenauflösung, Freigabe, Ausführung + │ + Agenten + │ + MCP-Tools (allowlisted) + │ + Spaces +``` + +Zwei Muster sind für uns relevant: + +**MCP-Server.** VibeMind spricht Werkzeuge über MCP an — `FastMCP` über stdio, +ein Python-Paket je Server, Lesewerkzeuge laufen frei, schreibende gehen durch +eine Freigabe. Vorbild ist `mcp/docker/`. + +**Spaces.** Fachdomänen liegen unter `spaces//` mit `agents/`, `api/`, +`README.md` und `STATUS.md`. Vorbild ist `spaces/marketing/`. + +Größere Komponenten hängen als Submodule in `repo-manifest.yaml`. + +## Der naheliegende Weg + +Der Rust-Kern bleibt, was er ist — die Audio-Engine. Darüber kommt eine dünne +Steuerfläche als MCP-Server: + +| Werkzeug | Art | Zweck | +| --- | --- | --- | +| `analyze_track` | lesend | Tempo, Beatgrid, Spitzen — läuft heute schon | +| `library_search` | lesend | Tracks nach BPM, Tonart, Tags | +| `deck_state` | lesend | Position, Tempo, was liegt auf welchem Deck | +| `deck_load` | schreibend | Track auf ein Deck legen | +| `deck_play` / `deck_tempo` | schreibend | Wiedergabe steuern | +| `mixer_set` | schreibend | Fader, EQ, Filter | + +**Die Sprachgrenze ist kein Problem.** VibeMind ist Python, dieses Projekt ist +Rust — MCP läuft über eine Prozessgrenze und ist genau dafür gedacht. Der Server +kann in Rust geschrieben sein oder als dünne Python-Schicht, die über einen +lokalen Socket mit der Engine redet. + +## Was das für die Agenten-Schicht bedeutet + +[AGENTEN.md](AGENTEN.md) entwirft ein Team aus Crowd-Sensor, Energie-Analyst, +Set-Planer, Track-Wähler, Generator und Mix-Engineer. **Diese Schicht sollte hier +nicht noch einmal gebaut werden.** Brain und OpenFang machen genau das — +Intent, Planung, Routing, Freigabe, Ausführung. + +Die Arbeitsteilung wäre also: + +- **Hier:** Audio-Engine, Analyse, Library, MCP-Werkzeuge +- **VibeMind:** die Agenten, die diese Werkzeuge benutzen, als Space `musik` + +Das streicht einen ganzen Bauabschnitt. Phase A2 aus der Roadmap wird damit +von „Agenten-Framework bauen" zu „Space anlegen und Werkzeuge freischalten". + +## Zwei Stolpersteine + +### 1. Die Lizenzlage kippt zurück + +⚠️ **VibeMind steht unter MIT und ist zur Veröffentlichung vorgesehen.** + +Damit gilt die Entlastung aus [BAUSTEINE.md](BAUSTEINE.md) — „nicht kommerziell, +also ist GPL frei" — für diesen Weg **nicht mehr**. Copyleft greift bei +Weitergabe, und ein veröffentlichtes MIT-Projekt ist Weitergabe. + +Konkret fielen wieder aus: + +| Bibliothek | Warum | +| --- | --- | +| Rubber Band (GPL) | Zwänge das Gesamtprojekt unter GPL | +| aubio (GPL) | dito | +| Essentia (AGPL) | dito, zusätzlich bei Netzwerknutzung | +| CC-BY-NC-Samples | dürften nicht mitgeliefert werden | + +**Der aktuelle Stand ist zum Glück sauber.** Alles, was heute im Repo hängt, ist +MIT-verträglich: + +| Crate | Lizenz | +| --- | --- | +| `cpal` | Apache-2.0 | +| `symphonia` | MPL-2.0 — dateiweises Copyleft, in einem MIT-Projekt unproblematisch | +| `rustfft`, `blake3`, `serde`, `base64` | MIT/Apache-2.0 | + +Zeitstreckung und Tempoerkennung sind selbst geschrieben. Das war ursprünglich +eine Notlösung, weil die Lizenzfrage noch offen war — und hält jetzt genau die +Tür offen, die es für VibeMind braucht. + +**Empfehlung: permissiv bleiben.** Solange ein Anschluss an VibeMind denkbar +ist, ist Rubber Band die teure Option, auch wenn sie für den reinen Eigenbedarf +erlaubt wäre. Reicht die eigene WSOLA nicht, ist +[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch) +(MIT) der Weg, der beide Optionen offenlässt. + +### 2. Freigabe-Pflicht und Livebetrieb vertragen sich nicht + +In VibeMind laufen lesende MCP-Werkzeuge frei, schreibende durch eine Freigabe. +Für einen DJ-Kontext ist das eine harte Grenze: „Lege Track X auf Deck 2" ist +formal ein schreibender Aufruf, aber mitten in einem Set kann niemand auf eine +Bestätigung warten. Sechzehn Takte sind bei 128 BPM dreißig Sekunden. + +Das ist kein Hindernis, sondern eine Entwurfsvorgabe: + +- **Vorbereitende Aufrufe** (analysieren, suchen, Kandidaten generieren) dürfen + durch die Freigabe. +- **Zeitkritische Aufrufe** (Fader, EQ, Loop, Cue) brauchen eine im Voraus + erteilte Freigabe für die Dauer des Sets — ein Bereich, kein Einzelfall. + +Das ist dieselbe Frage wie die offene Entscheidung „Vollautomat oder Assistent +am Pult", nur von der Sicherheitsseite betrachtet. + +## Plattform + +VibeMind zielt auf Windows 11. CPAL unterstützt dort WASAPI und, über ein +Feature-Flag, ASIO. Für den getrennten Cue-Ausgang aus +[PLAN.md](PLAN.md#1-der-cue-ausgang-zwingt-zu-einem-gerät-mit-vier-ausgängen) +führt unter Windows praktisch kein Weg an ASIO vorbei — das ist beim Kauf des +Interfaces mitzudenken. + +## Was jetzt zu tun ist + +Nichts. Die Entscheidung, ob und wann angeschlossen wird, kann später fallen, +solange zwei Dinge eingehalten werden: + +1. **Keine (A)GPL-Abhängigkeiten aufnehmen**, solange die Frage offen ist. +2. **Die Steuerung des Decks als Schnittstelle denken**, nicht als UI-Innenleben + — dann ist der MCP-Server später eine dünne Hülle statt eines Umbaus. + +Beides ist heute erfüllt. From 8ead61c6e201f9ebe80f61f84b271e22114221a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:18:40 +0000 Subject: [PATCH 08/70] docs: align README with the VibeMind license finding Two passages still claimed GPL libraries were freely available, which holds for personal use but not for the VibeMind path. Both now carry the caveat and point at the detailed assessment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- README.md | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9af76c6..cf0bb83 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,11 @@ und der Werkzeugkasten sollte sie nicht vorwegnehmen. | `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | | `blake3`, `serde`, `base64` | permissiv | Fingerabdruck und Sidecar | -Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert. Rubber Band -ist beim Stretching besser und steht inzwischen offen (GPL, nicht kommerzielles -Projekt) — der Tausch lohnt aber erst, wenn die eigene Variante hörbar nicht -reicht. Details in +Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert — damit ist +der gesamte Stack MIT-verträglich. Rubber Band wäre beim Stretching besser, ist +aber GPL und würde einen späteren Anschluss an VibeMind verbauen (siehe unten). +Reicht die eigene Variante hörbar nicht, ist `signalsmith-stretch` (MIT) der +Weg, der beide Optionen offenlässt. Details in [docs/BAUSTEINE.md](docs/BAUSTEINE.md#phase-0-was-gebaut-ist). **UI: egui/eframe**, entschieden. Begründung und die verworfenen Alternativen @@ -111,24 +112,20 @@ reicht. Details in **Nativ in Rust** (Phase 0) und **nicht kommerziell.** -Die zweite Entscheidung entspannt die Lizenzlage deutlich: GPL-Bibliotheken wie -Rubber Band und aubio stehen offen, ebenso CC-BY-NC-Samples von Freesound. Der -genaue Grund ist enger als „nicht kommerziell" — Copyleft greift bei -*Weitergabe*, nicht bei Nutzung. Solange das Tool auf dem eigenen Rechner -bleibt, entstehen gar keine Pflichten. +Die zweite Entscheidung entspannt die Lizenzlage: Copyleft greift bei +*Weitergabe*, nicht bei Nutzung. Solange das Tool auf dem eigenen Rechner bleibt +und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine +Pflichten — deshalb liegt auch bewusst keine `LICENSE`-Datei im Repo. -**Aktueller Stand: reine Eigennutzung, keine Weitergabe geplant.** Damit gibt es -derzeit überhaupt keine Lizenzpflichten aus den Abhängigkeiten, und deshalb -liegt auch bewusst keine `LICENSE`-Datei im Repo. +⚠️ **Trotzdem bleibt der Stack permissiv.** Ein späterer Anschluss an +[VibeMind](https://github.com/Flissel/Vibemind_V1) steht im Raum, und das ist +MIT und zur Veröffentlichung vorgesehen — also Weitergabe. Damit fielen +Rubber Band, aubio und Essentia wieder aus, und CC-BY-NC-Samples dürften nicht +mitgeliefert werden. Solange die Frage offen ist, ist jede GPL-Abhängigkeit eine +Tür, die hinter einem zufällt. Details in +[docs/VIBEMIND.md](docs/VIBEMIND.md#1-die-lizenzlage-kippt-zurück). -Die folgenden zwei Punkte sind Merkposten für den Fall, dass sich das ändert — -Details in -[docs/BAUSTEINE.md](docs/BAUSTEINE.md#lizenzen--entschärft-nicht-erledigt): - -- Wird das Tool je weitergegeben, auch kostenlos, müsste der eigene Code unter - GPL stehen. -- AGPL (Essentia) löst schon bei Netzwerknutzung aus — relevant, falls die - Agenten-Schicht je als Dienst läuft. +Der aktuelle Stand ist sauber: alles im Repo ist MIT-verträglich. ## Offene Entscheidungen From ec6e0235c779040d367fdc4ac1c75786847a4129 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 18:52:51 +0000 Subject: [PATCH 09/70] feat: mixer engine with channel strips, cue bus and AUX input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1. The mixer knows no decks, only sources that yield stereo — so an AUX input is a channel like any other, and the generation layer can attach at the same seam later. Signal chain per channel: trim, three-band EQ, filter, then a split into the cue tap and the fader/crossfader path. The cue tap sits before the *fader*, not merely before the crossfader: a track is prepared in the headphones while its fader is down, and a tap behind it would go silent exactly when it is needed. It sits after EQ and filter because tone changes must be audible before they reach the room. AUX carries two differences from a deck. It defaults to Thru so a microphone does not vanish when someone moves the crossfader, and it reads through a lock-free ring buffer because capture and playback run in separate callbacks with separate clocks — a late writer yields silence and a counted underrun rather than a stall. The three-band EQ took two wrong turns worth recording, both caught by the neutral-passthrough test: - Low plus high of a state-variable filter does not sum to the input; the k·bp term is missing. At unity gains the EQ was a notch around the crossover, and a 220 Hz tone lost over 80 percent of its level. - Forming the remainder subtractively is exact in time but not in magnitude, because the low band is phase-shifted. At 60 Hz the shift already reaches ~50°, so bass kill left 0.84 of the bass standing, and on the crossover frequency the output exceeded the input by half. Linkwitz-Riley of fourth order is the construction that actually holds: both branches meet in phase at -6 dB and sum back to unity. Adds musik-mix, which renders a transition to a WAV file offline — tempo-matched with keylock, crossfade with bass swap. Same signal path as the live engine, but verifiable without an audio device, which is the only way to judge anything in this environment. 50 engine tests: filter responses, EQ band isolation and passthrough, crossfader curves and Thru immunity, limiter ceiling and recovery, AUX underrun and overrun accounting, and the mixer routing itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- Cargo.lock | 15 + Cargo.toml | 3 +- README.md | 25 +- crates/audio-core/src/deck.rs | 21 +- crates/audio-engine/Cargo.toml | 9 + crates/audio-engine/src/aux.rs | 163 +++++++++ crates/audio-engine/src/channel.rs | 219 ++++++++++++ crates/audio-engine/src/crossfader.rs | 157 +++++++++ crates/audio-engine/src/eq.rs | 240 +++++++++++++ crates/audio-engine/src/filter.rs | 190 +++++++++++ crates/audio-engine/src/lib.rs | 34 ++ crates/audio-engine/src/limiter.rs | 166 +++++++++ crates/audio-engine/src/mixer.rs | 360 ++++++++++++++++++++ crates/audio-engine/src/source.rs | 101 ++++++ crates/audio-engine/src/svf.rs | 147 ++++++++ crates/audio-engine/src/testing.rs | 37 ++ crates/musik-cli/Cargo.toml | 1 + crates/musik-cli/src/bin/musik-mix.rs | 464 ++++++++++++++++++++++++++ docs/PLAN.md | 35 +- 19 files changed, 2363 insertions(+), 24 deletions(-) create mode 100644 crates/audio-engine/Cargo.toml create mode 100644 crates/audio-engine/src/aux.rs create mode 100644 crates/audio-engine/src/channel.rs create mode 100644 crates/audio-engine/src/crossfader.rs create mode 100644 crates/audio-engine/src/eq.rs create mode 100644 crates/audio-engine/src/filter.rs create mode 100644 crates/audio-engine/src/lib.rs create mode 100644 crates/audio-engine/src/limiter.rs create mode 100644 crates/audio-engine/src/mixer.rs create mode 100644 crates/audio-engine/src/source.rs create mode 100644 crates/audio-engine/src/svf.rs create mode 100644 crates/audio-engine/src/testing.rs create mode 100644 crates/musik-cli/src/bin/musik-mix.rs diff --git a/Cargo.lock b/Cargo.lock index 6121cf8..2e3e46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,14 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "audio-engine" +version = "0.1.0" +dependencies = [ + "audio-core", + "rtrb", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -393,6 +401,7 @@ dependencies = [ "analysis", "anyhow", "audio-core", + "audio-engine", ] [[package]] @@ -634,6 +643,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 30becad..d822da3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/analysis","crates/audio-core", "crates/musik-cli"] +members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/musik-cli"] [workspace.package] edition = "2021" @@ -9,3 +9,4 @@ publish = false [workspace.dependencies] analysis = { path = "crates/analysis" } audio-core = { path = "crates/audio-core" } +audio-engine = { path = "crates/audio-engine" } diff --git a/README.md b/README.md index cf0bb83..c2d14d1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den Reaktionen der Crowd formt. -Status: **Phase 0 und 2.** Ein Deck läuft (Wiedergabe, Tempo mit und ohne -Keylock, Springen), und die Analyse-Pipeline erkennt Tempo, Beatgrid und -Wellenform-Spitzen. Nativ in Rust. +Status: **Phasen 0–2.** Ein Deck läuft (Wiedergabe, Tempo mit und ohne +Keylock, Springen), die Analyse erkennt Tempo, Beatgrid und Wellenform-Spitzen, +und der Mixer mischt mehrere Kanäle inklusive AUX auf Master und Kopfhörer. +Nativ in Rust. ## Zielbild @@ -49,14 +50,15 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap -**Phase 0 und 2 stehen** ✅ — ein Deck mit Keylock, und die Analyse-Pipeline. +**Phasen 0 und 2 stehen** ✅, **Phase 1 zur Hälfte** ⚙️ — der Mixer ist gebaut +und getestet, abnehmen lässt er sich erst mit einem 4-Kanal-Interface. Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: | Phase | Inhalt | | --- | --- | -| 1 | Mehrdeck-Engine mit Mixer und Cue-Bus | +| 1 ⚙️ | Mehrdeck-Engine mit Mixer, Cue-Bus und AUX — Code steht, Abnahme braucht Hardware | | ~~2~~ ✅ | ~~Analyse-Pipeline (BPM, Grid, Peaks)~~ | | 3–5 | UI mit Waveforms · Sync und Beatmatching · Hot Cues und Loops | | 6–7 | Library inkl. Traktor-Import · Effekte | @@ -88,6 +90,7 @@ und der Werkzeugkasten sollte sie nicht vorwegnehmen. | `cpal` | Apache-2.0 | Audioausgabe | | `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | | `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | +| `rtrb` | MIT/Apache-2.0 | Lock-freier Ringpuffer für AUX | | `blake3`, `serde`, `base64` | permissiv | Fingerabdruck und Sidecar | Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert — damit ist @@ -178,4 +181,16 @@ unter `.musik-analyse/` ab, adressiert über einen Hash des Audioinhalts. Ein zweiter Lauf über dieselbe Datei liest aus dem Cache; ein umbenannter Ordner entwertet die Analyse nicht. +Einen Übergang rendern — ebenfalls **ohne** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-mix -- --a a.mp3 --b b.mp3 --out mix.wav +``` + +Zieht Deck B per Keylock auf das Tempo von A, fährt einen Crossfade mit +Bass-Swap und schreibt das Ergebnis als WAV. Ohne `--a`/`--b` nimmt es +synthetische Loops — praktisch, um die Engine zu hören, wenn gerade keine +Musik zur Hand ist. `--aux ` legt einen dritten Kanal auf Thru dazu, +der den Crossfader unbeschadet übersteht. + Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/audio-core/src/deck.rs b/crates/audio-core/src/deck.rs index d7de98f..ca36b1b 100644 --- a/crates/audio-core/src/deck.rs +++ b/crates/audio-core/src/deck.rs @@ -126,7 +126,10 @@ impl Voice { while done < total_frames { let n = (total_frames - done).min(SCRATCH_FRAMES); - self.fill_stereo(n); + let len = n * CHANNELS; + let mut scratch = std::mem::take(&mut self.scratch); + self.render_stereo(&mut scratch[..len]); + self.scratch = scratch; for i in 0..n { let l = self.scratch[i * CHANNELS]; @@ -146,17 +149,19 @@ impl Voice { } } - /// Füllt die ersten `frames` Frames von `self.scratch` mit Stereo-Material. - fn fill_stereo(&mut self, frames: usize) { - let len = frames * CHANNELS; - + /// Schreibt interleaved Stereo in `out`. + /// + /// Das ist der Einstieg für den Mixer: Ein Deck ist dort eine Quelle unter + /// mehreren und liefert immer Stereo, unabhängig davon, wie das + /// Ausgabegerät aussieht. + pub fn render_stereo(&mut self, out: &mut [f32]) { if let Some(target) = self.take_seek() { self.pos = target; self.wsola.reset(); } if !self.state.is_playing() { - self.scratch[..len].fill(0.0); + out.fill(0.0); return; } @@ -168,10 +173,6 @@ impl Voice { self.stretching = want_stretch; } - // Getrennte Feldzugriffe, damit der Borrow-Checker `scratch` von - // `wsola`/`track`/`pos` unterscheiden kann. - let out = &mut self.scratch[..len]; - let alive = if want_stretch { self.wsola .render(&self.track.samples, tempo, &mut self.pos, out) diff --git a/crates/audio-engine/Cargo.toml b/crates/audio-engine/Cargo.toml new file mode 100644 index 0000000..f1208c2 --- /dev/null +++ b/crates/audio-engine/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "audio-engine" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +rtrb = "0.3.4" diff --git a/crates/audio-engine/src/aux.rs b/crates/audio-engine/src/aux.rs new file mode 100644 index 0000000..912d20b --- /dev/null +++ b/crates/audio-engine/src/aux.rs @@ -0,0 +1,163 @@ +//! AUX-Eingang: externes Audio in den Mixer. +//! +//! Mikrofon, Drum-Machine, ein zweiter Rechner, das Handy eines Gastes — alles, +//! was nicht aus einem Deck kommt, läuft hier herein. Im Mixer ist AUX ein +//! Kanal wie jeder andere: mit Trim, EQ, Filter, Fader und Cue. +//! +//! ## Warum ein Ringpuffer +//! +//! Die Aufnahme läuft in ihrem eigenen Callback, die Wiedergabe in einem +//! anderen. Zwei Callbacks heißt zwei Uhren, und Uhren laufen nie exakt gleich. +//! Ein lock-freier Ringpuffer entkoppelt beide, sodass keiner auf den anderen +//! wartet — im Audio-Pfad ist Warten dasselbe wie ein Aussetzer. +//! +//! Bleibt die Aufnahme zurück, wird Stille ausgegeben und ein Unterlauf +//! gezählt. Das ist eine Diagnose, kein Zufall: Häufige Unterläufe bedeuten, +//! dass der Puffer zu klein ist oder die beiden Uhren zu weit auseinander +//! driften. + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::source::Source; + +/// Schreibende Seite — gehört dem Aufnahme-Callback. +pub struct AuxWriter { + producer: Producer, + overruns: u64, +} + +impl AuxWriter { + /// Schiebt interleaved Stereo hinein. Gibt zurück, wie viele Werte Platz + /// fanden; der Rest geht verloren und zählt als Überlauf. + pub fn write(&mut self, samples: &[f32]) -> usize { + let mut written = 0; + for sample in samples { + if self.producer.push(*sample).is_err() { + self.overruns += samples.len() as u64 - written as u64; + break; + } + written += 1; + } + written + } + + /// Wie viele Werte verworfen wurden, weil der Puffer voll war. + pub fn overruns(&self) -> u64 { + self.overruns + } +} + +/// Lesende Seite — hängt als Quelle im Mixer. +pub struct AuxSource { + consumer: Consumer, + underruns: u64, +} + +impl AuxSource { + /// Wie viele Werte als Stille geliefert wurden, weil nichts anlag. + pub fn underruns(&self) -> u64 { + self.underruns + } + + /// Wie viele Werte gerade bereitliegen. + pub fn available(&self) -> usize { + self.consumer.slots() + } +} + +impl Source for AuxSource { + fn render(&mut self, out: &mut [f32]) { + for (i, sample) in out.iter_mut().enumerate() { + match self.consumer.pop() { + Ok(value) => *sample = value, + Err(_) => { + self.underruns += (out.len() - i) as u64; + out[i..].fill(0.0); + return; + } + } + } + } +} + +/// Legt einen AUX-Kanal an. `capacity_frames` ist die Puffertiefe in +/// Stereo-Frames — größer heißt robuster gegen Uhrendrift, aber auch mehr +/// Latenz zwischen Eingang und Ausgang. +pub fn aux_channel(capacity_frames: usize) -> (AuxWriter, AuxSource) { + let (producer, consumer) = RingBuffer::new(capacity_frames.max(1) * 2); + ( + AuxWriter { + producer, + overruns: 0, + }, + AuxSource { + consumer, + underruns: 0, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geschriebenes_kommt_unveraendert_an() { + let (mut writer, mut source) = aux_channel(1_024); + + let eingang: Vec = (0..64).map(|i| i as f32 * 0.01).collect(); + assert_eq!(writer.write(&eingang), eingang.len()); + + let mut out = vec![0.0; 64]; + source.render(&mut out); + + assert_eq!(out, eingang); + assert_eq!(source.underruns(), 0); + } + + #[test] + fn unterlauf_liefert_stille_und_wird_gezaehlt() { + let (mut writer, mut source) = aux_channel(1_024); + writer.write(&[0.5; 8]); + + let mut out = vec![9.0; 32]; + source.render(&mut out); + + assert_eq!(&out[..8], &[0.5; 8]); + assert!( + out[8..].iter().all(|v| *v == 0.0), + "nach dem Unterlauf steht kein Nullpegel" + ); + assert_eq!(source.underruns(), 24); + } + + #[test] + fn ohne_zuspieler_ist_der_kanal_still() { + let (_writer, mut source) = aux_channel(64); + let mut out = vec![1.0; 16]; + source.render(&mut out); + + assert!(out.iter().all(|v| *v == 0.0)); + assert_eq!(source.underruns(), 16); + } + + #[test] + fn ueberlauf_wird_gezaehlt_statt_zu_blockieren() { + let (mut writer, _source) = aux_channel(8); + let geschrieben = writer.write(&[1.0; 64]); + + assert!( + geschrieben < 64, + "der Puffer hat mehr geschluckt als er fasst" + ); + assert!(writer.overruns() > 0, "Überlauf wurde nicht bemerkt"); + } + + #[test] + fn kapazitaet_null_paniert_nicht() { + let (mut writer, mut source) = aux_channel(0); + writer.write(&[1.0; 4]); + let mut out = vec![0.0; 4]; + source.render(&mut out); + } +} diff --git a/crates/audio-engine/src/channel.rs b/crates/audio-engine/src/channel.rs new file mode 100644 index 0000000..2d9c7ca --- /dev/null +++ b/crates/audio-engine/src/channel.rs @@ -0,0 +1,219 @@ +//! Ein Kanalzug. +//! +//! Reihenfolge der Kette: +//! +//! ```text +//! Quelle → Trim → EQ → Filter →┬→ Cue-Abgriff (pre-fader) +//! └→ Fader → Crossfader → Summe +//! ``` +//! +//! **Der Cue-Abgriff liegt vor dem Fader, aber hinter EQ und Filter.** Beides +//! hat einen Grund und beides ist so auf jedem DJ-Mixer verdrahtet: +//! +//! - *Vor dem Fader*, weil man einen Track im Kopfhörer vorbereitet, während +//! sein Fader unten ist. Läge der Abgriff dahinter, hörte man genau dann +//! nichts, wenn man ihn braucht. +//! - *Hinter EQ und Filter*, weil man seine Klangeingriffe kontrollieren +//! können muss, bevor sie auf die Anlage gehen. + +use crate::crossfader::Assign; +use crate::eq::ThreeBandEq; +use crate::filter::DjFilter; +use crate::source::Source; + +pub struct Channel { + pub name: String, + source: Box, + eq: ThreeBandEq, + filter: DjFilter, + trim: f32, + fader: f32, + cue: bool, + assign: Assign, + buffer: Vec, +} + +impl Channel { + pub fn new(name: impl Into, source: Box, sample_rate: f32) -> Self { + Channel { + name: name.into(), + source, + eq: ThreeBandEq::new(sample_rate), + filter: DjFilter::new(sample_rate), + trim: 1.0, + fader: 0.0, + cue: false, + assign: Assign::Thru, + buffer: Vec::new(), + } + } + + pub fn set_source(&mut self, source: Box) -> Box { + self.eq.reset(); + self.filter.reset(); + std::mem::replace(&mut self.source, source) + } + + /// Eingangsverstärkung vor dem EQ. + pub fn set_trim(&mut self, trim: f32) { + self.trim = trim.clamp(0.0, 4.0); + } + + pub fn trim(&self) -> f32 { + self.trim + } + + /// Kanalfader, 0.0 bis 1.0. + pub fn set_fader(&mut self, fader: f32) { + self.fader = fader.clamp(0.0, 1.0); + } + + pub fn fader(&self) -> f32 { + self.fader + } + + pub fn set_eq(&mut self, low: f32, mid: f32, high: f32) { + self.eq.set_gains(low, mid, high); + } + + pub fn eq_gains(&self) -> (f32, f32, f32) { + self.eq.gains() + } + + pub fn set_filter(&mut self, position: f32) { + self.filter.set_position(position); + } + + pub fn filter_position(&self) -> f32 { + self.filter.position() + } + + /// Legt den Kanal auf den Kopfhörer. + pub fn set_cue(&mut self, cue: bool) { + self.cue = cue; + } + + pub fn is_cued(&self) -> bool { + self.cue + } + + pub fn set_assign(&mut self, assign: Assign) { + self.assign = assign; + } + + pub fn assign(&self) -> Assign { + self.assign + } + + /// Rendert die Kette bis einschließlich Filter und gibt das Ergebnis + /// zurück — also genau das Signal, das der Cue-Bus abgreift. + /// + /// Fader und Crossfader liegen bewusst *nicht* darin; die legt der Mixer + /// beim Summieren an. + pub fn render_pre_fader(&mut self, frames: usize) -> &[f32] { + let len = frames * 2; + if self.buffer.len() < len { + self.buffer.resize(len, 0.0); + } + let buffer = &mut self.buffer[..len]; + + self.source.render(buffer); + + if (self.trim - 1.0).abs() > f32::EPSILON { + for sample in buffer.iter_mut() { + *sample *= self.trim; + } + } + + self.eq.process(buffer); + self.filter.process(buffer); + + &self.buffer[..len] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::LoopSource; + use crate::testing::{rms, sine_stereo}; + + const RATE: f32 = 48_000.0; + + fn kanal_mit_ton(freq: f32) -> Channel { + let source = LoopSource::new(sine_stereo(freq, RATE as u32, 1.0)); + Channel::new("test", Box::new(source), RATE) + } + + #[test] + fn fader_startet_unten() { + // Ein Kanal, der beim Anlegen auf die Anlage geht, ist ein Unfall. + let kanal = kanal_mit_ton(440.0); + assert_eq!(kanal.fader(), 0.0); + assert!(!kanal.is_cued()); + assert_eq!(kanal.assign(), Assign::Thru); + } + + #[test] + fn trim_wirkt_vor_dem_eq() { + let mut kanal = kanal_mit_ton(440.0); + let voll = rms(kanal.render_pre_fader(4_800)).max(1e-9); + + kanal.set_trim(0.5); + let halb = rms(kanal.render_pre_fader(4_800)); + + assert!( + (halb / voll - 0.5).abs() < 0.05, + "Trim 0.5 ergibt Faktor {:.3}", + halb / voll + ); + } + + #[test] + fn der_fader_steckt_nicht_im_pre_fader_signal() { + let mut kanal = kanal_mit_ton(440.0); + kanal.set_fader(0.0); + + let pegel = rms(kanal.render_pre_fader(4_800)); + assert!( + pegel > 0.1, + "Fader unten dämpft schon das Cue-Signal: {pegel:.4}" + ); + } + + #[test] + fn eq_wirkt_auf_das_pre_fader_signal() { + let mut kanal = kanal_mit_ton(60.0); + let voll = rms(kanal.render_pre_fader(4_800)).max(1e-9); + + kanal.set_eq(0.0, 1.0, 1.0); + let gekillt = rms(kanal.render_pre_fader(4_800)); + + assert!( + gekillt / voll < 0.1, + "Bass-Kill wirkt nicht: Faktor {:.3}", + gekillt / voll + ); + } + + #[test] + fn quellenwechsel_gibt_die_alte_quelle_zurueck() { + let mut kanal = kanal_mit_ton(440.0); + let alt = kanal.set_source(Box::new(LoopSource::new(vec![0.0; 4]))); + + // Die alte Quelle darf hier fallen, nicht im Audio-Thread. + drop(alt); + assert!(rms(kanal.render_pre_fader(128)) < 1e-6); + } + + #[test] + fn werte_werden_geklemmt() { + let mut kanal = kanal_mit_ton(440.0); + kanal.set_fader(5.0); + assert_eq!(kanal.fader(), 1.0); + kanal.set_fader(-1.0); + assert_eq!(kanal.fader(), 0.0); + kanal.set_trim(-3.0); + assert_eq!(kanal.trim(), 0.0); + } +} diff --git a/crates/audio-engine/src/crossfader.rs b/crates/audio-engine/src/crossfader.rs new file mode 100644 index 0000000..25c71de --- /dev/null +++ b/crates/audio-engine/src/crossfader.rs @@ -0,0 +1,157 @@ +//! Crossfader mit einstellbarer Kurve. +//! +//! Kanäle liegen auf Seite A, Seite B oder auf *Thru* — letztere sind vom +//! Crossfader unberührt. Thru ist kein Sonderfall, sondern der Normalfall für +//! alles, was nicht gemischt, sondern dazugelegt wird: ein Mikrofon, ein +//! AUX-Zuspieler, eine Drum-Machine. + +/// Wo ein Kanal am Crossfader hängt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Assign { + A, + B, + /// Unberührt vom Crossfader. + #[default] + Thru, +} + +/// Kurvenform von weich (0.0) bis schneidend (1.0). +/// +/// Weich ist eine Konstant-Leistungs-Kurve: In der Mitte liegen beide Seiten +/// bei etwa 0,707, sodass die Summe gleich laut bleibt. Schneidend hält beide +/// Seiten lange auf voll und blendet erst kurz vor dem Anschlag — das ist die +/// Einstellung zum Scratchen. +#[derive(Debug, Clone, Copy)] +pub struct Crossfader { + position: f32, + curve: f32, + gain_a: f32, + gain_b: f32, +} + +impl Default for Crossfader { + fn default() -> Self { + Self::new() + } +} + +impl Crossfader { + pub fn new() -> Self { + let mut xf = Crossfader { + position: 0.0, + curve: 0.0, + gain_a: 0.0, + gain_b: 0.0, + }; + xf.recompute(); + xf + } + + /// −1 = ganz auf A, 0 = Mitte, +1 = ganz auf B. + pub fn set_position(&mut self, position: f32) { + self.position = position.clamp(-1.0, 1.0); + self.recompute(); + } + + pub fn position(&self) -> f32 { + self.position + } + + /// 0.0 = weich (Konstant-Leistung), 1.0 = schneidend. + pub fn set_curve(&mut self, curve: f32) { + self.curve = curve.clamp(0.0, 1.0); + self.recompute(); + } + + pub fn gain(&self, assign: Assign) -> f32 { + match assign { + Assign::A => self.gain_a, + Assign::B => self.gain_b, + Assign::Thru => 1.0, + } + } + + fn recompute(&mut self) { + let t = (self.position + 1.0) * 0.5; + let angle = t * std::f32::consts::FRAC_PI_2; + + // Exponent < 1 drückt die Kurve nach oben: beide Seiten bleiben länger + // laut, der Übergang wird kürzer und härter. + let exponent = 1.0 - 0.85 * self.curve; + + self.gain_a = angle.cos().clamp(0.0, 1.0).powf(exponent); + self.gain_b = angle.sin().clamp(0.0, 1.0).powf(exponent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anschlaege_isolieren_je_eine_seite() { + let mut xf = Crossfader::new(); + + xf.set_position(-1.0); + assert!((xf.gain(Assign::A) - 1.0).abs() < 1e-5); + assert!(xf.gain(Assign::B) < 1e-5); + + xf.set_position(1.0); + assert!(xf.gain(Assign::A) < 1e-5); + assert!((xf.gain(Assign::B) - 1.0).abs() < 1e-5); + } + + #[test] + fn mitte_haelt_die_leistung_konstant() { + let mut xf = Crossfader::new(); + xf.set_curve(0.0); + xf.set_position(0.0); + + let a = xf.gain(Assign::A); + let b = xf.gain(Assign::B); + + assert!((a - 0.707).abs() < 0.01, "A in der Mitte: {a:.3}"); + assert!((b - 0.707).abs() < 0.01, "B in der Mitte: {b:.3}"); + assert!( + (a * a + b * b - 1.0).abs() < 0.01, + "Leistungssumme weicht ab: {:.3}", + a * a + b * b + ); + } + + #[test] + fn thru_ignoriert_den_crossfader() { + let mut xf = Crossfader::new(); + for pos in [-1.0, -0.3, 0.0, 0.5, 1.0] { + xf.set_position(pos); + assert_eq!(xf.gain(Assign::Thru), 1.0, "bei Position {pos}"); + } + } + + #[test] + fn schneidende_kurve_haelt_laenger_voll() { + let mut weich = Crossfader::new(); + weich.set_curve(0.0); + weich.set_position(-0.5); + + let mut scharf = Crossfader::new(); + scharf.set_curve(1.0); + scharf.set_position(-0.5); + + assert!( + scharf.gain(Assign::A) > weich.gain(Assign::A), + "schneidend {:.3} sollte über weich {:.3} liegen", + scharf.gain(Assign::A), + weich.gain(Assign::A) + ); + } + + #[test] + fn position_wird_geklemmt() { + let mut xf = Crossfader::new(); + xf.set_position(-5.0); + assert_eq!(xf.position(), -1.0); + xf.set_position(5.0); + assert_eq!(xf.position(), 1.0); + } +} diff --git a/crates/audio-engine/src/eq.rs b/crates/audio-engine/src/eq.rs new file mode 100644 index 0000000..e235fed --- /dev/null +++ b/crates/audio-engine/src/eq.rs @@ -0,0 +1,240 @@ +//! Dreiband-EQ mit Kill. +//! +//! Kein Shelving-EQ, sondern ein echtes Crossover: Das Signal wird bei 200 Hz +//! und 2 kHz aufgeteilt, die drei Bänder werden getrennt verstärkt und wieder +//! summiert. +//! +//! Der Unterschied ist nicht akademisch. Ein Shelving-Filter kann ein Band +//! absenken, aber nie ganz entfernen — und ein DJ-EQ, dessen Bass-Regler den +//! Bass nicht *weg* bekommt, ist für Übergänge unbrauchbar. Mit Aufteilung ist +//! Verstärkung null gleichbedeutend mit Kill. +//! +//! ## Zwei Sackgassen, die hier schon durchlaufen sind +//! +//! **Tief- und Hochpass eines Zustandsvariablen-Filters** summieren sich nicht +//! zum Eingang — es fehlt der Bandpassanteil `k·bp`. In Neutralstellung war der +//! EQ dadurch kein Durchgang, sondern eine Kerbe um die Trennfrequenz; ein +//! 220-Hz-Ton verlor über 80 % seines Pegels. +//! +//! **Das Restband subtraktiv bilden** (`rest = Eingang − tief`) ist im +//! Zeitbereich zwar exakt, aber `tief` ist phasenverschoben. Bei 60 Hz liegt +//! die Verschiebung eines Filters vierter Ordnung schon bei etwa 50°, und +//! `|Eingang − tief|` wurde dadurch 0,84 statt nahe null — der Bass-Kill ließ +//! den Bass stehen. Auf der Trennfrequenz kam sogar das Anderthalbfache heraus. +//! +//! ## Was stattdessen gilt +//! +//! Linkwitz-Riley vierter Ordnung: je Trennstelle ein Tief- und ein Hochpass +//! aus zwei kaskadierten Butterworth-Stufen. Genau dafür ist diese Bauform +//! gemacht — beide Zweige liegen auf der Trennfrequenz *in Phase* bei −6 dB und +//! ergeben zusammen wieder eins. Die Summe aller drei Bänder ist ein Allpass: +//! Betrag glatt, Phase gedreht. Für einen EQ ist das genau richtig. + +use crate::svf::Svf; + +const LOW_MID_HZ: f32 = 200.0; +const MID_HIGH_HZ: f32 = 2_000.0; +const Q: f32 = std::f32::consts::FRAC_1_SQRT_2; + +/// Maximale Anhebung je Band (etwa +6 dB). +pub const MAX_GAIN: f32 = 2.0; + +/// Linkwitz-Riley-Trennstelle vierter Ordnung, mono. +#[derive(Debug, Clone, Copy)] +struct Crossover { + lp1: Svf, + lp2: Svf, + hp1: Svf, + hp2: Svf, +} + +impl Crossover { + fn new() -> Self { + Crossover { + lp1: Svf::new(), + lp2: Svf::new(), + hp1: Svf::new(), + hp2: Svf::new(), + } + } + + fn set(&mut self, cutoff: f32, sample_rate: f32) { + for svf in [&mut self.lp1, &mut self.lp2, &mut self.hp1, &mut self.hp2] { + svf.set(cutoff, Q, sample_rate); + } + } + + fn reset(&mut self) { + for svf in [&mut self.lp1, &mut self.lp2, &mut self.hp1, &mut self.hp2] { + svf.reset(); + } + } + + /// Liefert (unterhalb, oberhalb). + #[inline] + fn process(&mut self, input: f32) -> (f32, f32) { + let low = self.lp2.process(self.lp1.process(input).low).low; + let high = self.hp2.process(self.hp1.process(input).high).high; + (low, high) + } +} + +#[derive(Debug, Clone)] +pub struct ThreeBandEq { + /// Pro Kanal eine eigene Trennstelle — Filter haben Zustand. + lower: [Crossover; 2], + upper: [Crossover; 2], + low: f32, + mid: f32, + high: f32, +} + +impl ThreeBandEq { + pub fn new(sample_rate: f32) -> Self { + let mut eq = ThreeBandEq { + lower: [Crossover::new(); 2], + upper: [Crossover::new(); 2], + low: 1.0, + mid: 1.0, + high: 1.0, + }; + eq.set_sample_rate(sample_rate); + eq + } + + pub fn set_sample_rate(&mut self, sample_rate: f32) { + for split in self.lower.iter_mut() { + split.set(LOW_MID_HZ, sample_rate); + } + for split in self.upper.iter_mut() { + split.set(MID_HIGH_HZ, sample_rate); + } + } + + /// Verstärkungen der drei Bänder. 0.0 = Kill, 1.0 = neutral. + pub fn set_gains(&mut self, low: f32, mid: f32, high: f32) { + self.low = low.clamp(0.0, MAX_GAIN); + self.mid = mid.clamp(0.0, MAX_GAIN); + self.high = high.clamp(0.0, MAX_GAIN); + } + + pub fn gains(&self) -> (f32, f32, f32) { + (self.low, self.mid, self.high) + } + + pub fn is_neutral(&self) -> bool { + (self.low - 1.0).abs() < f32::EPSILON + && (self.mid - 1.0).abs() < f32::EPSILON + && (self.high - 1.0).abs() < f32::EPSILON + } + + pub fn reset(&mut self) { + for split in self.lower.iter_mut().chain(self.upper.iter_mut()) { + split.reset(); + } + } + + /// Verarbeitet interleaved Stereo an Ort und Stelle. + pub fn process(&mut self, buffer: &mut [f32]) { + for frame in buffer.chunks_exact_mut(2) { + for (ch, sample) in frame.iter_mut().enumerate() { + let (low, rest) = self.lower[ch].process(*sample); + let (mid, high) = self.upper[ch].process(rest); + + *sample = low * self.low + mid * self.mid + high * self.high; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine_stereo}; + + const RATE: u32 = 48_000; + + /// Verhältnis von Ausgang zu Eingang bei einer Frequenz, mit gegebenen Reglern. + fn verhaeltnis(freq: f32, low: f32, mid: f32, high: f32) -> f32 { + let signal = sine_stereo(freq, RATE, 0.5); + let mut eq = ThreeBandEq::new(RATE as f32); + eq.set_gains(low, mid, high); + + let mut out = signal.clone(); + eq.process(&mut out); + + let half = out.len() / 2; + rms(&out[half..]) / rms(&signal[half..]) + } + + #[test] + fn neutral_ist_ein_echter_durchgang() { + // Die Eigenschaft, an der beide früheren Entwürfe gescheitert sind. + for freq in [60.0, 200.0, 630.0, 2_000.0, 8_000.0] { + let v = verhaeltnis(freq, 1.0, 1.0, 1.0); + assert!( + (v - 1.0).abs() < 0.02, + "{freq} Hz wird neutral auf {v:.4} verändert" + ); + } + } + + #[test] + fn bass_kill_entfernt_den_bass() { + let bass = verhaeltnis(60.0, 0.0, 1.0, 1.0); + assert!(bass < 0.05, "60 Hz überlebt den Kill mit {bass:.3}"); + } + + #[test] + fn bass_kill_laesst_die_hoehen_stehen() { + // Das ist der eigentliche Zweck: Bass raus, Rest bleibt spielbar. + let hoehen = verhaeltnis(8_000.0, 0.0, 1.0, 1.0); + assert!(hoehen > 0.95, "Bass-Kill dämpft auch 8 kHz auf {hoehen:.3}"); + } + + #[test] + fn jedes_band_trifft_seinen_bereich() { + assert!( + verhaeltnis(60.0, 0.0, 1.0, 1.0) < 0.05, + "Low-Kill wirkt nicht" + ); + assert!( + verhaeltnis(630.0, 1.0, 0.0, 1.0) < 0.05, + "Mid-Kill wirkt nicht" + ); + assert!( + verhaeltnis(10_000.0, 1.0, 1.0, 0.0) < 0.05, + "High-Kill wirkt nicht" + ); + } + + #[test] + fn auf_der_trennfrequenz_teilen_sich_zwei_baender_das_signal() { + // Bei Linkwitz-Riley liegt dort jeder Zweig bei −6 dB. Kill des einen + // lässt also etwa die Hälfte stehen — kein Fehler, sondern die + // Bauform. Bei jedem analogen DJ-EQ ist es genauso. + let auf_der_grenze = verhaeltnis(200.0, 0.0, 1.0, 1.0); + assert!( + (0.35..0.65).contains(&auf_der_grenze), + "auf der Trennfrequenz unerwartet: {auf_der_grenze:.3}" + ); + } + + #[test] + fn alles_zu_ergibt_stille() { + let v = verhaeltnis(1_000.0, 0.0, 0.0, 0.0); + assert!(v < 0.02, "trotz aller Kills bleibt {v:.3}"); + } + + #[test] + fn anhebung_ist_begrenzt() { + let mut eq = ThreeBandEq::new(RATE as f32); + eq.set_gains(99.0, -5.0, 1.5); + let (low, mid, high) = eq.gains(); + + assert_eq!(low, MAX_GAIN); + assert_eq!(mid, 0.0); + assert_eq!(high, 1.5); + assert!(!eq.is_neutral()); + } +} diff --git a/crates/audio-engine/src/filter.rs b/crates/audio-engine/src/filter.rs new file mode 100644 index 0000000..a3402ec --- /dev/null +++ b/crates/audio-engine/src/filter.rs @@ -0,0 +1,190 @@ +//! Filterknopf nach DJ-Art: ein Regler, zwei Richtungen. +//! +//! Mitte ist neutral, nach links fährt ein Tiefpass herunter, nach rechts ein +//! Hochpass herauf. Ein einziger Regler statt zweier ist keine Sparmaßnahme — +//! im Livebetrieb greift man blind danach, und die Mittelstellung muss sich +//! ertasten lassen. +//! +//! Die Frequenzen laufen exponentiell, weil Gehör so funktioniert: Von 100 auf +//! 200 Hz ist gefühlt derselbe Schritt wie von 1000 auf 2000. + +use crate::svf::Svf; + +/// Unterhalb dieser Auslenkung bleibt der Filter aus. +const DEADZONE: f32 = 0.02; + +const LP_MAX_HZ: f32 = 20_000.0; +const LP_MIN_HZ: f32 = 40.0; +const HP_MIN_HZ: f32 = 20.0; +const HP_MAX_HZ: f32 = 12_000.0; + +const Q: f32 = 0.9; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Bypass, + LowPass, + HighPass, +} + +#[derive(Debug, Clone)] +pub struct DjFilter { + svf: [Svf; 2], + mode: Mode, + position: f32, + sample_rate: f32, +} + +impl DjFilter { + pub fn new(sample_rate: f32) -> Self { + DjFilter { + svf: [Svf::new(); 2], + mode: Mode::Bypass, + position: 0.0, + sample_rate, + } + } + + /// −1 = Tiefpass ganz zu, 0 = neutral, +1 = Hochpass ganz auf. + pub fn set_position(&mut self, position: f32) { + let position = position.clamp(-1.0, 1.0); + self.position = position; + + let (mode, cutoff) = if position < -DEADZONE { + let t = (-position - DEADZONE) / (1.0 - DEADZONE); + (Mode::LowPass, exp_sweep(LP_MAX_HZ, LP_MIN_HZ, t)) + } else if position > DEADZONE { + let t = (position - DEADZONE) / (1.0 - DEADZONE); + (Mode::HighPass, exp_sweep(HP_MIN_HZ, HP_MAX_HZ, t)) + } else { + (Mode::Bypass, 1_000.0) + }; + + if mode != self.mode { + // Beim Umschalten den Zustand verwerfen, sonst knackt der Übergang. + for svf in self.svf.iter_mut() { + svf.reset(); + } + self.mode = mode; + } + + for svf in self.svf.iter_mut() { + svf.set(cutoff, Q, self.sample_rate); + } + } + + pub fn position(&self) -> f32 { + self.position + } + + pub fn is_active(&self) -> bool { + self.mode != Mode::Bypass + } + + pub fn reset(&mut self) { + for svf in self.svf.iter_mut() { + svf.reset(); + } + } + + pub fn process(&mut self, buffer: &mut [f32]) { + if self.mode == Mode::Bypass { + return; + } + + for frame in buffer.chunks_exact_mut(2) { + for (ch, sample) in frame.iter_mut().enumerate() { + let out = self.svf[ch].process(*sample); + *sample = match self.mode { + Mode::LowPass => out.low, + Mode::HighPass => out.high, + Mode::Bypass => *sample, + }; + } + } + } +} + +fn exp_sweep(from: f32, to: f32, t: f32) -> f32 { + from * (to / from).powf(t.clamp(0.0, 1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine_stereo}; + + const RATE: u32 = 48_000; + + fn verhaeltnis(freq: f32, position: f32) -> f32 { + let signal = sine_stereo(freq, RATE, 0.5); + let mut filter = DjFilter::new(RATE as f32); + filter.set_position(position); + + let mut out = signal.clone(); + filter.process(&mut out); + + let half = out.len() / 2; + rms(&out[half..]) / rms(&signal[half..]) + } + + #[test] + fn mittelstellung_laesst_alles_durch() { + for freq in [50.0, 1_000.0, 12_000.0] { + let v = verhaeltnis(freq, 0.0); + assert!((v - 1.0).abs() < 1e-4, "{freq} Hz verändert auf {v:.4}"); + } + } + + #[test] + fn die_totzone_gilt_in_beide_richtungen() { + assert!(!DjFilter::new(48_000.0).is_active()); + + let mut f = DjFilter::new(48_000.0); + f.set_position(0.01); + assert!(!f.is_active(), "winzige Auslenkung schaltet schon"); + + f.set_position(-0.01); + assert!(!f.is_active()); + + f.set_position(0.5); + assert!(f.is_active()); + } + + #[test] + fn ganz_links_bleibt_nur_bass() { + // Am Anschlag steht die Grenzfrequenz bei 40 Hz. Selbst 60 Hz liegt + // dann schon darüber und wird gedämpft — das ist gewollt, „ganz zu" + // heißt eben fast zu. Entscheidend ist der Abstand zu den Höhen. + let bass = verhaeltnis(40.0, -1.0); + let hoehen = verhaeltnis(8_000.0, -1.0); + + assert!(bass > 0.5, "Bass wird bei Tiefpass zu {bass:.3} gedämpft"); + assert!( + hoehen < 0.02, + "Höhen überleben den Tiefpass mit {hoehen:.3}" + ); + assert!( + bass / hoehen.max(1e-9) > 50.0, + "Tiefpass trennt kaum: Bass {bass:.3} gegen Höhen {hoehen:.4}" + ); + } + + #[test] + fn ganz_rechts_bleibt_nur_hoehe() { + let bass = verhaeltnis(60.0, 1.0); + let hoehen = verhaeltnis(14_000.0, 1.0); + + assert!(hoehen > 0.5, "Höhen bei Hochpass nur {hoehen:.3}"); + assert!(bass < 0.02, "Bass überlebt den Hochpass mit {bass:.3}"); + } + + #[test] + fn position_wird_geklemmt() { + let mut f = DjFilter::new(48_000.0); + f.set_position(-9.0); + assert_eq!(f.position(), -1.0); + f.set_position(9.0); + assert_eq!(f.position(), 1.0); + } +} diff --git a/crates/audio-engine/src/lib.rs b/crates/audio-engine/src/lib.rs new file mode 100644 index 0000000..7e318e5 --- /dev/null +++ b/crates/audio-engine/src/lib.rs @@ -0,0 +1,34 @@ +//! Mixer-Engine: mehrere Kanäle, Master- und Cue-Bus. +//! +//! Setzt auf `audio-core` auf, das ein einzelnes Deck liefert. Hier kommt +//! zusammen, was daraus ein Mischpult macht — Kanalzüge mit EQ und Filter, +//! Crossfader, Begrenzer auf der Summe und ein getrennter Kopfhörerweg. +//! +//! Quellen sind austauschbar: Ein Kanal weiß nicht, ob hinter ihm ein Deck, +//! ein AUX-Eingang oder später ein generierter Track steht. +//! +//! Alles hier läuft im Audio-Callback und hält sich an dessen Regeln — keine +//! Allokation, kein Lock, keine Speicherfreigabe. Puffer wachsen beim ersten +//! Block auf die nötige Größe und bleiben danach stehen. + +pub mod aux; +pub mod channel; +pub mod crossfader; +pub mod eq; +pub mod filter; +pub mod limiter; +pub mod mixer; +pub mod source; +pub mod svf; + +#[cfg(test)] +pub(crate) mod testing; + +pub use aux::{aux_channel, AuxSource, AuxWriter}; +pub use channel::Channel; +pub use crossfader::{Assign, Crossfader}; +pub use eq::ThreeBandEq; +pub use filter::DjFilter; +pub use limiter::Limiter; +pub use mixer::{assign_deck_pair, Engine}; +pub use source::{DeckSource, LoopSource, SilentSource, Source}; diff --git a/crates/audio-engine/src/limiter.rs b/crates/audio-engine/src/limiter.rs new file mode 100644 index 0000000..f82664e --- /dev/null +++ b/crates/audio-engine/src/limiter.rs @@ -0,0 +1,166 @@ +//! Begrenzer auf der Summe. +//! +//! Vier Kanäle addieren sich, und irgendwann übersteuert das. Ohne Begrenzer +//! wäre die Folge digitales Clipping — die hässlichste Art, laut zu werden. +//! +//! Bewusst einfach gehalten: Die Verstärkung fällt sofort und steigt langsam +//! wieder. Ohne Vorausschau (Lookahead) heißt das, dass sehr steile Transienten +//! verzerren statt weich begrenzt zu werden. Für die Summe eines DJ-Mixes ist +//! das vertretbar; ein Mastering-Begrenzer ist es nicht. + +/// Voreinstellung: knapp unter Vollaussteuerung, damit noch Luft bleibt. +pub const DEFAULT_CEILING: f32 = 0.98; + +#[derive(Debug, Clone)] +pub struct Limiter { + ceiling: f32, + gain: f32, + release: f32, +} + +impl Limiter { + pub fn new(sample_rate: f32) -> Self { + Limiter { + ceiling: DEFAULT_CEILING, + gain: 1.0, + release: release_coefficient(0.100, sample_rate), + } + } + + pub fn set_ceiling(&mut self, ceiling: f32) { + self.ceiling = ceiling.clamp(0.01, 1.0); + } + + pub fn ceiling(&self) -> f32 { + self.ceiling + } + + /// Aktuelle Pegelreduktion in Dezibel, negativ oder null. + pub fn reduction_db(&self) -> f32 { + 20.0 * self.gain.max(1e-6).log10() + } + + pub fn reset(&mut self) { + self.gain = 1.0; + } + + /// Verarbeitet interleaved Stereo an Ort und Stelle. + pub fn process(&mut self, buffer: &mut [f32]) { + for frame in buffer.chunks_exact_mut(2) { + let peak = frame[0].abs().max(frame[1].abs()); + + let needed = if peak > 0.0 { + (self.ceiling / peak).min(1.0) + } else { + 1.0 + }; + + if needed < self.gain { + // Sofort greifen — hier darf nichts durchrutschen. + self.gain = needed; + } else { + self.gain += (needed - self.gain) * self.release; + } + + for sample in frame.iter_mut() { + *sample = (*sample * self.gain).clamp(-self.ceiling, self.ceiling); + } + } + } +} + +fn release_coefficient(seconds: f32, sample_rate: f32) -> f32 { + let samples = (seconds * sample_rate).max(1.0); + 1.0 - (-1.0 / samples).exp() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{peak, rms, sine_stereo}; + + const RATE: u32 = 48_000; + + #[test] + fn lautes_material_bleibt_unter_der_decke() { + let mut signal: Vec = sine_stereo(220.0, RATE, 0.5) + .into_iter() + .map(|v| v * 4.0) + .collect(); + + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + let gemessen = peak(&signal); + assert!( + gemessen <= limiter.ceiling() + 1e-6, + "Spitze {gemessen:.4} über der Decke {:.4}", + limiter.ceiling() + ); + } + + #[test] + fn leises_material_bleibt_unangetastet() { + let original = sine_stereo(220.0, RATE, 0.3) + .into_iter() + .map(|v| v * 0.2) + .collect::>(); + + let mut signal = original.clone(); + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + for (a, b) in original.iter().zip(&signal) { + assert!((a - b).abs() < 1e-6, "leises Signal wurde verändert"); + } + } + + #[test] + fn ein_einzelner_ausreisser_reisst_nicht_alles_mit() { + // Nach einem kurzen Peak muss der Pegel wieder hochkommen, sonst + // duckt ein einziger Knall den ganzen Mix. + let mut signal = sine_stereo(220.0, RATE, 1.0) + .into_iter() + .map(|v| v * 0.3) + .collect::>(); + signal[100] = 8.0; + signal[101] = 8.0; + + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + let spaet = rms(&signal[signal.len() * 3 / 4..]); + let erwartet = 0.3 / 2.0_f32.sqrt(); + assert!( + (spaet - erwartet).abs() < 0.02, + "Pegel erholt sich nicht: {spaet:.3} statt {erwartet:.3}" + ); + } + + #[test] + fn decke_wird_auf_sinnvolle_werte_geklemmt() { + let mut limiter = Limiter::new(RATE as f32); + limiter.set_ceiling(5.0); + assert_eq!(limiter.ceiling(), 1.0); + limiter.set_ceiling(-1.0); + assert_eq!(limiter.ceiling(), 0.01); + } + + #[test] + fn reduktion_wird_gemeldet() { + let mut signal = sine_stereo(220.0, RATE, 0.2) + .into_iter() + .map(|v| v * 4.0) + .collect::>(); + + let mut limiter = Limiter::new(RATE as f32); + assert_eq!(limiter.reduction_db(), 0.0); + + limiter.process(&mut signal); + assert!( + limiter.reduction_db() < -6.0, + "Reduktion nur {:.1} dB bei vierfachem Pegel", + limiter.reduction_db() + ); + } +} diff --git a/crates/audio-engine/src/mixer.rs b/crates/audio-engine/src/mixer.rs new file mode 100644 index 0000000..a7e4dd4 --- /dev/null +++ b/crates/audio-engine/src/mixer.rs @@ -0,0 +1,360 @@ +//! Der Mixer: summiert Kanäle auf Master- und Cue-Bus. +//! +//! Zwei getrennte Summen, zwei getrennte Ausgänge. Der Master geht auf die +//! Anlage (Ausgang 1/2), der Cue auf den Kopfhörer (Ausgang 3/4) — beides über +//! *dasselbe* Gerät, weil zwei Geräte zwei Uhren bedeuten und deren Drift den +//! Kopfhörer nach ein paar Minuten hörbar gegen den Master verschiebt. Siehe +//! `docs/PLAN.md`. + +use crate::channel::Channel; +use crate::crossfader::{Assign, Crossfader}; +use crate::limiter::Limiter; +use crate::source::Source; + +pub struct Engine { + sample_rate: f32, + channels: Vec, + crossfader: Crossfader, + limiter: Limiter, + master_gain: f32, + cue_gain: f32, + cue_mix: f32, + master: Vec, + cue: Vec, +} + +impl Engine { + pub fn new(sample_rate: f32) -> Self { + Engine { + sample_rate, + channels: Vec::new(), + crossfader: Crossfader::new(), + limiter: Limiter::new(sample_rate), + master_gain: 1.0, + cue_gain: 1.0, + cue_mix: 0.0, + master: Vec::new(), + cue: Vec::new(), + } + } + + pub fn sample_rate(&self) -> f32 { + self.sample_rate + } + + /// Legt einen Kanal an und gibt seinen Index zurück. + pub fn add_channel(&mut self, name: impl Into, source: Box) -> usize { + self.channels + .push(Channel::new(name, source, self.sample_rate)); + self.channels.len() - 1 + } + + pub fn channel(&mut self, index: usize) -> &mut Channel { + &mut self.channels[index] + } + + pub fn channels(&self) -> &[Channel] { + &self.channels + } + + pub fn channel_count(&self) -> usize { + self.channels.len() + } + + pub fn crossfader(&mut self) -> &mut Crossfader { + &mut self.crossfader + } + + pub fn limiter(&self) -> &Limiter { + &self.limiter + } + + pub fn set_master_gain(&mut self, gain: f32) { + self.master_gain = gain.clamp(0.0, 2.0); + } + + pub fn set_cue_gain(&mut self, gain: f32) { + self.cue_gain = gain.clamp(0.0, 2.0); + } + + /// Blendet den Master in den Kopfhörer: 0.0 = nur Cue, 1.0 = nur Master. + pub fn set_cue_mix(&mut self, mix: f32) { + self.cue_mix = mix.clamp(0.0, 1.0); + } + + /// Wieviele Ausgangskanäle nötig sind, damit der Cue-Bus ankommt. + pub const REQUIRED_OUTPUTS_FOR_CUE: usize = 4; + + /// Rendert einen Block in den Gerätepuffer. + /// + /// Belegung: 0/1 Master, 2/3 Cue. Hat das Gerät weniger als vier Kanäle, + /// fällt der Cue-Bus weg — die Software läuft dann, aber Vorhören ist + /// nicht möglich. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + if out_channels == 0 { + return; + } + let frames = out.len() / out_channels; + let len = frames * 2; + + if self.master.len() < len { + self.master.resize(len, 0.0); + self.cue.resize(len, 0.0); + } + self.master[..len].fill(0.0); + self.cue[..len].fill(0.0); + + for channel in self.channels.iter_mut() { + let gain = channel.fader() * self.crossfader.gain(channel.assign()); + let cued = channel.is_cued(); + if gain <= 0.0 && !cued { + // Still und nicht im Kopfhörer — die Kette trotzdem laufen + // lassen wäre Rechenzeit ohne Wirkung. Der Filterzustand + // altert dabei, was beim Aufziehen einen Einschwinger kostet; + // das ist der Preis und er ist kurz. + continue; + } + + let pre = channel.render_pre_fader(frames); + + if cued { + for (dst, src) in self.cue[..len].iter_mut().zip(pre) { + *dst += *src; + } + } + + if gain > 0.0 { + for (dst, src) in self.master[..len].iter_mut().zip(pre) { + *dst += *src * gain; + } + } + } + + if (self.master_gain - 1.0).abs() > f32::EPSILON { + for sample in self.master[..len].iter_mut() { + *sample *= self.master_gain; + } + } + self.limiter.process(&mut self.master[..len]); + + // Kopfhörer: Mischung aus Cue-Bus und Master. + for i in 0..len { + let mixed = self.cue[i] * (1.0 - self.cue_mix) + self.master[i] * self.cue_mix; + self.cue[i] = (mixed * self.cue_gain).clamp(-1.0, 1.0); + } + + for frame in 0..frames { + let base = frame * out_channels; + let slot = &mut out[base..base + out_channels]; + slot.fill(0.0); + + match out_channels { + 1 => slot[0] = 0.5 * (self.master[frame * 2] + self.master[frame * 2 + 1]), + 2 | 3 => { + slot[0] = self.master[frame * 2]; + slot[1] = self.master[frame * 2 + 1]; + } + _ => { + slot[0] = self.master[frame * 2]; + slot[1] = self.master[frame * 2 + 1]; + slot[2] = self.cue[frame * 2]; + slot[3] = self.cue[frame * 2 + 1]; + } + } + } + } +} + +/// Bequemer Zugriff auf die Crossfader-Seiten beim Aufbau. +pub fn assign_deck_pair(engine: &mut Engine, left: usize, right: usize) { + engine.channel(left).set_assign(Assign::A); + engine.channel(right).set_assign(Assign::B); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{LoopSource, SilentSource}; + use crate::testing::{peak, rms_channel, sine_stereo}; + + const RATE: f32 = 48_000.0; + const FRAMES: usize = 4_800; + + fn ton(freq: f32, amplitude: f32) -> Box { + let samples = sine_stereo(freq, RATE as u32, 1.0) + .into_iter() + .map(|v| v * amplitude) + .collect(); + Box::new(LoopSource::new(samples)) + } + + fn rendern(engine: &mut Engine, out_channels: usize) -> Vec { + let mut out = vec![0.0; FRAMES * out_channels]; + engine.render(&mut out, out_channels); + out + } + + fn master_rms(out: &[f32], channels: usize) -> f32 { + rms_channel(out, 0, channels) + } + + fn cue_rms(out: &[f32], channels: usize) -> f32 { + rms_channel(out, 2, channels) + } + + #[test] + fn ohne_kanaele_bleibt_es_still() { + let mut engine = Engine::new(RATE); + let out = rendern(&mut engine, 4); + assert_eq!(peak(&out), 0.0); + } + + #[test] + fn zwei_decks_summieren_sich() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.3)); + let b = engine.add_channel("B", ton(660.0, 0.3)); + + engine.channel(a).set_fader(1.0); + let nur_a = master_rms(&rendern(&mut engine, 4), 4); + + engine.channel(b).set_fader(1.0); + let beide = master_rms(&rendern(&mut engine, 4), 4); + + assert!( + beide > nur_a * 1.2, + "zweites Deck kommt nicht an: {nur_a:.4} → {beide:.4}" + ); + } + + #[test] + fn der_crossfader_blendet_zwischen_den_seiten() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + let b = engine.add_channel("B", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.channel(b).set_fader(1.0); + assign_deck_pair(&mut engine, a, b); + + engine.crossfader().set_position(-1.0); + let links = master_rms(&rendern(&mut engine, 4), 4); + + engine.channel(a).set_fader(0.0); + let links_ohne_a = master_rms(&rendern(&mut engine, 4), 4); + + assert!(links > 0.1, "auf A steht nichts an: {links:.4}"); + assert!( + links_ohne_a < 0.01, + "B ist bei Crossfader ganz links hörbar: {links_ohne_a:.4}" + ); + } + + #[test] + fn thru_kanaele_ueberleben_jede_crossfader_stellung() { + // Genau dafür ist AUX da: ein Mikrofon darf nicht verschwinden, + // nur weil der Crossfader bewegt wird. + let mut engine = Engine::new(RATE); + let aux = engine.add_channel("AUX", ton(440.0, 0.3)); + engine.channel(aux).set_fader(1.0); + engine.channel(aux).set_assign(Assign::Thru); + + let mut pegel = Vec::new(); + for pos in [-1.0, -0.5, 0.0, 0.5, 1.0] { + engine.crossfader().set_position(pos); + pegel.push(master_rms(&rendern(&mut engine, 4), 4)); + } + + let min = pegel.iter().cloned().fold(f32::MAX, f32::min); + let max = pegel.iter().cloned().fold(0.0f32, f32::max); + assert!(min > 0.1, "Thru-Kanal fällt weg: {pegel:?}"); + assert!( + (max - min) / max < 0.02, + "Thru-Kanal schwankt mit dem Crossfader: {pegel:?}" + ); + } + + #[test] + fn cue_hoert_einen_kanal_mit_geschlossenem_fader() { + // Der eigentliche Zweck des Vorhörens. + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(0.0); + engine.channel(a).set_cue(true); + + let out = rendern(&mut engine, 4); + + assert!( + master_rms(&out, 4) < 0.001, + "der Kanal steht trotz geschlossenem Fader auf dem Master" + ); + assert!( + cue_rms(&out, 4) > 0.1, + "im Kopfhörer ist nichts zu hören: {:.4}", + cue_rms(&out, 4) + ); + } + + #[test] + fn ohne_cue_taste_bleibt_der_kopfhoerer_still() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + + let out = rendern(&mut engine, 4); + assert!(master_rms(&out, 4) > 0.1); + assert!(cue_rms(&out, 4) < 0.001, "Cue-Bus leckt"); + } + + #[test] + fn cue_mix_holt_den_master_in_den_kopfhoerer() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.set_cue_mix(1.0); + + let out = rendern(&mut engine, 4); + assert!( + cue_rms(&out, 4) > 0.1, + "bei vollem Cue-Mix kommt der Master nicht durch" + ); + } + + #[test] + fn zweikanalgeraet_liefert_master_ohne_zu_paniken() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.channel(a).set_cue(true); + + let out = rendern(&mut engine, 2); + assert!(master_rms(&out, 2) > 0.1); + } + + #[test] + fn die_summe_wird_begrenzt() { + let mut engine = Engine::new(RATE); + for i in 0..4 { + let ch = engine.add_channel(format!("D{i}"), ton(220.0, 0.9)); + engine.channel(ch).set_fader(1.0); + } + + let out = rendern(&mut engine, 4); + let spitze = peak(&out); + + assert!( + spitze <= engine.limiter().ceiling() + 1e-6, + "vier laute Kanäle erzeugen Spitze {spitze:.3}" + ); + } + + #[test] + fn stille_kanaele_kosten_nichts_und_stoeren_nicht() { + let mut engine = Engine::new(RATE); + engine.add_channel("leer", Box::new(SilentSource)); + let a = engine.add_channel("A", ton(220.0, 0.3)); + engine.channel(a).set_fader(1.0); + + let out = rendern(&mut engine, 4); + assert!(master_rms(&out, 4) > 0.1); + } +} diff --git a/crates/audio-engine/src/source.rs b/crates/audio-engine/src/source.rs new file mode 100644 index 0000000..e1c4115 --- /dev/null +++ b/crates/audio-engine/src/source.rs @@ -0,0 +1,101 @@ +//! Signalquellen eines Kanals. +//! +//! Der Mixer kennt keine Decks. Er kennt Quellen, die interleaved Stereo +//! liefern — ob dahinter ein Deck steht, ein Mikrofon am AUX-Eingang oder +//! später ein generierter Track, ist ihm gleich. +//! +//! Das ist dieselbe Trennung wie in `docs/ARCHITEKTUR.md`: austauschbare +//! Trackquellen, damit die Generierung sich später einhängen kann, ohne dass +//! am Mixer etwas anzufassen ist. + +use audio_core::deck::Voice; + +/// Liefert interleaved Stereo. Wird im Audio-Callback aufgerufen und darf +/// deshalb nicht allokieren, blockieren oder Speicher freigeben. +pub trait Source: Send { + fn render(&mut self, out: &mut [f32]); +} + +/// Ein Deck aus `audio-core`. +pub struct DeckSource { + voice: Voice, +} + +impl DeckSource { + pub fn new(voice: Voice) -> Self { + DeckSource { voice } + } +} + +impl Source for DeckSource { + fn render(&mut self, out: &mut [f32]) { + self.voice.render_stereo(out); + } +} + +/// Stille. Nützlich als Platzhalter für einen Kanal ohne geladenen Track. +pub struct SilentSource; + +impl Source for SilentSource { + fn render(&mut self, out: &mut [f32]) { + out.fill(0.0); + } +} + +/// Spielt einen festen Puffer in Schleife. Für Testaufbauten und Zuspieler. +pub struct LoopSource { + samples: Vec, + position: usize, +} + +impl LoopSource { + /// `samples` ist interleaved Stereo. + pub fn new(samples: Vec) -> Self { + LoopSource { + samples, + position: 0, + } + } +} + +impl Source for LoopSource { + fn render(&mut self, out: &mut [f32]) { + if self.samples.is_empty() { + out.fill(0.0); + return; + } + for sample in out.iter_mut() { + *sample = self.samples[self.position]; + self.position = (self.position + 1) % self.samples.len(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stille_ist_still() { + let mut out = vec![1.0; 64]; + SilentSource.render(&mut out); + assert!(out.iter().all(|v| *v == 0.0)); + } + + #[test] + fn schleife_wiederholt_sich() { + let mut source = LoopSource::new(vec![1.0, 2.0, 3.0, 4.0]); + let mut out = vec![0.0; 10]; + source.render(&mut out); + + assert_eq!(out, vec![1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0, 2.0]); + } + + #[test] + fn leere_schleife_liefert_stille_statt_zu_paniken() { + let mut source = LoopSource::new(Vec::new()); + let mut out = vec![1.0; 8]; + source.render(&mut out); + assert!(out.iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/audio-engine/src/svf.rs b/crates/audio-engine/src/svf.rs new file mode 100644 index 0000000..78a2312 --- /dev/null +++ b/crates/audio-engine/src/svf.rs @@ -0,0 +1,147 @@ +//! Zustandsvariablen-Filter (TPT, nach Andy Simper). +//! +//! Baustein für EQ und Filterknopf. Liefert Tief-, Band- und Hochpass aus +//! *einem* Durchlauf — genau das, was ein Crossover braucht, ohne das Signal +//! mehrfach durch getrennte Filter zu schicken. +//! +//! Die topologiebewahrende Form bleibt auch bei Parameteränderungen während +//! des Betriebs stabil. Das ist keine Feinheit: An einem DJ-Filterknopf wird +//! gedreht, während Audio läuft, und ein klassisches Biquad kann dabei knacken. + +use std::f32::consts::PI; + +#[derive(Debug, Clone, Copy)] +pub struct Outputs { + pub low: f32, + pub band: f32, + pub high: f32, +} + +#[derive(Debug, Clone, Copy)] +pub struct Svf { + ic1: f32, + ic2: f32, + a1: f32, + a2: f32, + a3: f32, + k: f32, +} + +impl Default for Svf { + fn default() -> Self { + Self::new() + } +} + +impl Svf { + pub fn new() -> Self { + let mut svf = Svf { + ic1: 0.0, + ic2: 0.0, + a1: 0.0, + a2: 0.0, + a3: 0.0, + k: 0.0, + }; + svf.set(1_000.0, std::f32::consts::FRAC_1_SQRT_2, 48_000.0); + svf + } + + /// Setzt Grenzfrequenz und Güte. `q` = 0.707 ergibt Butterworth. + pub fn set(&mut self, cutoff_hz: f32, q: f32, sample_rate: f32) { + let nyquist = sample_rate * 0.5; + let cutoff = cutoff_hz.clamp(10.0, nyquist * 0.99); + let g = (PI * cutoff / sample_rate).tan(); + let k = 1.0 / q.max(0.01); + + self.a1 = 1.0 / (1.0 + g * (g + k)); + self.a2 = g * self.a1; + self.a3 = g * self.a2; + self.k = k; + } + + pub fn reset(&mut self) { + self.ic1 = 0.0; + self.ic2 = 0.0; + } + + #[inline] + pub fn process(&mut self, input: f32) -> Outputs { + let v3 = input - self.ic2; + let v1 = self.a1 * self.ic1 + self.a2 * v3; + let v2 = self.ic2 + self.a2 * self.ic1 + self.a3 * v3; + + self.ic1 = 2.0 * v1 - self.ic1; + self.ic2 = 2.0 * v2 - self.ic2; + + Outputs { + low: v2, + band: v1, + high: input - self.k * v1 - v2, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine}; + + const RATE: f32 = 48_000.0; + + fn durchlass(cutoff: f32, freq: f32, band: fn(Outputs) -> f32) -> f32 { + let signal = sine(freq, RATE as u32, 0.5); + let mut svf = Svf::new(); + svf.set(cutoff, std::f32::consts::FRAC_1_SQRT_2, RATE); + + // Erste Hälfte verwerfen, damit der Einschwingvorgang draußen bleibt. + let out: Vec = signal.iter().map(|x| band(svf.process(*x))).collect(); + rms(&out[out.len() / 2..]) / rms(&signal[signal.len() / 2..]) + } + + #[test] + fn tiefpass_laesst_tiefes_durch_und_sperrt_hohes() { + let durch = durchlass(1_000.0, 100.0, |o| o.low); + let gesperrt = durchlass(1_000.0, 10_000.0, |o| o.low); + + assert!(durch > 0.9, "100 Hz nur bei {durch:.3} durchgelassen"); + assert!(gesperrt < 0.05, "10 kHz noch bei {gesperrt:.3}"); + } + + #[test] + fn hochpass_ist_das_spiegelbild() { + let gesperrt = durchlass(1_000.0, 100.0, |o| o.high); + let durch = durchlass(1_000.0, 10_000.0, |o| o.high); + + assert!(durch > 0.9, "10 kHz nur bei {durch:.3}"); + assert!(gesperrt < 0.05, "100 Hz noch bei {gesperrt:.3}"); + } + + #[test] + fn an_der_grenzfrequenz_bleibt_die_haelfte_der_leistung() { + let durch = durchlass(1_000.0, 1_000.0, |o| o.low); + // −3 dB entspricht Faktor 0.707. + assert!( + (durch - 0.707).abs() < 0.05, + "bei fc gemessen: {durch:.3}, erwartet ~0.707" + ); + } + + #[test] + fn bleibt_bei_extremen_parametern_endlich() { + let mut svf = Svf::new(); + for cutoff in [0.0, 1.0, 24_000.0, 1e9] { + for q in [0.0, 0.1, 100.0] { + svf.set(cutoff, q, RATE); + svf.reset(); + for i in 0..1_000 { + let o = svf.process(if i % 2 == 0 { 1.0 } else { -1.0 }); + assert!( + o.low.is_finite() && o.band.is_finite() && o.high.is_finite(), + "divergiert bei cutoff={cutoff}, q={q}" + ); + } + } + } + } +} diff --git a/crates/audio-engine/src/testing.rs b/crates/audio-engine/src/testing.rs new file mode 100644 index 0000000..75b8cd5 --- /dev/null +++ b/crates/audio-engine/src/testing.rs @@ -0,0 +1,37 @@ +//! Testsignale und Messhilfen für die Engine. + +pub fn sine(freq: f32, sample_rate: u32, secs: f32) -> Vec { + let n = (sample_rate as f32 * secs) as usize; + let step = 2.0 * std::f32::consts::PI * freq / sample_rate as f32; + (0..n).map(|i| (step * i as f32).sin()).collect() +} + +/// Interleaved Stereo, beide Kanäle gleich. +pub fn sine_stereo(freq: f32, sample_rate: u32, secs: f32) -> Vec { + sine(freq, sample_rate, secs) + .into_iter() + .flat_map(|v| [v, v]) + .collect() +} + +pub fn rms(values: &[f32]) -> f32 { + if values.is_empty() { + return 0.0; + } + (values.iter().map(|v| (v * v) as f64).sum::() / values.len() as f64).sqrt() as f32 +} + +/// Effektivwert eines Kanals aus interleaved Stereo. +pub fn rms_channel(interleaved: &[f32], channel: usize, channels: usize) -> f32 { + let values: Vec = interleaved + .iter() + .skip(channel) + .step_by(channels) + .copied() + .collect(); + rms(&values) +} + +pub fn peak(values: &[f32]) -> f32 { + values.iter().fold(0.0f32, |m, v| m.max(v.abs())) +} diff --git a/crates/musik-cli/Cargo.toml b/crates/musik-cli/Cargo.toml index 8ff13e8..b1989da 100644 --- a/crates/musik-cli/Cargo.toml +++ b/crates/musik-cli/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" analysis = { version = "0.1.0", path = "../analysis" } anyhow = "1.0.104" audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } diff --git a/crates/musik-cli/src/bin/musik-mix.rs b/crates/musik-cli/src/bin/musik-mix.rs new file mode 100644 index 0000000..3906a2e --- /dev/null +++ b/crates/musik-cli/src/bin/musik-mix.rs @@ -0,0 +1,464 @@ +//! Rendert einen Mix offline in eine WAV-Datei. +//! +//! Zwei Zwecke. Erstens lässt sich die Engine damit ohne Audiogerät prüfen — +//! derselbe Signalweg, nur schneller als Echtzeit und in eine Datei statt auf +//! die Anlage. Zweitens kann man das Ergebnis hinterher anhören, was in einer +//! Umgebung ohne Soundkarte der einzige Weg ist, überhaupt etwas zu beurteilen. +//! +//! Der Übergang ist ein Bass-Swap, wie man ihn von Hand fahren würde: Der +//! Crossfader wandert hinüber, und in der Mitte wechselt der Bass von einem +//! Deck zum anderen. Zwei Bässe gleichzeitig matschen — deshalb macht das +//! niemand anders. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use audio_core::Track; +use audio_core::deck::{DeckState, Voice}; +use audio_engine::{Assign, DeckSource, Engine, Source, aux_channel}; + +const BLOCK: usize = 512; + +struct Options { + out: PathBuf, + a: Option, + b: Option, + aux: Option, + seconds: f64, + transition: Option, + transition_len: f64, + sample_rate: u32, +} + +impl Default for Options { + fn default() -> Self { + Options { + out: PathBuf::from("mix.wav"), + a: None, + b: None, + aux: None, + seconds: 60.0, + transition: None, + transition_len: 16.0, + sample_rate: 48_000, + } + } +} + +fn main() -> Result<()> { + let opts = parse_args()?; + let rate = opts.sample_rate; + + // Deck B wird gestreckt und braucht dadurch mehr Quellmaterial als + // Renderzeit — mit Reserve. + let reserve = opts.seconds * 1.5 + 10.0; + let (track_a, quelle_a) = + lade_oder_synthetisiere(opts.a.as_deref(), rate, Variante::A, reserve)?; + let (track_b, quelle_b) = + lade_oder_synthetisiere(opts.b.as_deref(), rate, Variante::B, reserve)?; + + let mut engine = Engine::new(rate as f32); + + let deck_a = engine.add_channel("A", quelle_a); + let deck_b = engine.add_channel("B", quelle_b); + engine.channel(deck_a).set_assign(Assign::A); + engine.channel(deck_b).set_assign(Assign::B); + + // Tempo angleichen, sofern beide Seiten ein Grid haben. + let tempo_info = angleichen(&track_a, &track_b); + + // AUX läuft auf Thru — der Crossfader darf ihn nicht wegnehmen. + let aux_kanal = if let Some(pfad) = opts.aux.as_deref() { + let track = Track::decode_file(pfad) + .with_context(|| format!("AUX: {} nicht lesbar", pfad.display()))? + .resampled_to(rate); + let (mut writer, source) = aux_channel(rate as usize * 4); + writer.write(&track.samples); + + let idx = engine.add_channel("AUX", Box::new(source)); + engine.channel(idx).set_assign(Assign::Thru); + engine.channel(idx).set_fader(0.35); + Some(idx) + } else { + None + }; + + let transition = opts.transition.unwrap_or(opts.seconds * 0.5); + let frames_total = (opts.seconds * rate as f64) as usize; + + println!("Rendere {:.0}s @ {} Hz", opts.seconds, rate); + println!(" Deck A: {}", beschreibung(opts.a.as_deref(), Variante::A)); + println!(" Deck B: {}", beschreibung(opts.b.as_deref(), Variante::B)); + if let Some(idx) = aux_kanal { + println!(" AUX: {} (Thru, Fader 0.35)", engine.channel(idx).name); + } + if let Some(text) = &tempo_info { + println!(" {text}"); + } + println!( + " Übergang ab {transition:.1}s über {:.0}s", + opts.transition_len + ); + + let mut pcm: Vec = Vec::with_capacity(frames_total * 2); + let mut block = vec![0.0f32; BLOCK * 2]; + let mut frame = 0usize; + + while frame < frames_total { + let n = BLOCK.min(frames_total - frame); + let zeit = frame as f64 / rate as f64; + + automatisieren( + &mut engine, + deck_a, + deck_b, + zeit, + transition, + opts.transition_len, + ); + + let slice = &mut block[..n * 2]; + engine.render(slice, 2); + pcm.extend_from_slice(slice); + + frame += n; + } + + schreibe_wav(&opts.out, &pcm, rate).context("WAV konnte nicht geschrieben werden")?; + println!( + "\n{} geschrieben ({:.1} MB)", + opts.out.display(), + (pcm.len() * 2) as f64 / 1_048_576.0 + ); + + Ok(()) +} + +/// Fahrplan des Übergangs. +/// +/// Vor dem Übergang läuft A allein, danach B allein. Dazwischen wandert der +/// Crossfader hinüber, und der Bass wechselt in der Mitte die Seite. +fn automatisieren( + engine: &mut Engine, + deck_a: usize, + deck_b: usize, + zeit: f64, + start: f64, + dauer: f64, +) { + let t = ((zeit - start) / dauer).clamp(0.0, 1.0) as f32; + + engine.channel(deck_a).set_fader(1.0); + engine.channel(deck_b).set_fader(1.0); + engine.crossfader().set_position(t * 2.0 - 1.0); + + // Bass-Swap: B bringt seinen Bass erst nach der Hälfte, A gibt ihn dort ab. + let (bass_a, bass_b) = if t < 0.5 { + (1.0, (t * 2.0).powi(2) * 0.0) + } else { + let u = (t - 0.5) * 2.0; + (1.0 - u, u) + }; + + engine.channel(deck_a).set_eq(bass_a, 1.0, 1.0); + engine.channel(deck_b).set_eq(bass_b, 1.0, 1.0); + + // Gegen Ende des Übergangs ein kurzer Hochpass auf A — nimmt ihm das + // Fundament, bevor er ganz verschwindet. + let filter_a = if t > 0.6 { + ((t - 0.6) / 0.4).clamp(0.0, 1.0) * 0.7 + } else { + 0.0 + }; + engine.channel(deck_a).set_filter(filter_a); +} + +/// Zieht Deck B auf das Tempo von Deck A. +/// +/// Das ist der Grund, warum auch die synthetischen Loops als echte Decks +/// laufen und nicht als simple Schleifen: Nur so greift der Zeitstrecker, und +/// nur dann stimmt auch die Meldung darüber. +fn angleichen(a: &Deck, b: &Deck) -> Option { + let (bpm_a, bpm_b) = (a.bpm?, b.bpm?); + let ratio = bpm_a / bpm_b; + b.state.set_tempo(ratio); + + Some(format!( + "Tempo: B {bpm_b:.2} → {:.2} BPM (Faktor {:.4}, Keylock an)", + bpm_b * b.state.tempo(), + b.state.tempo() + )) +} + +struct Deck { + bpm: Option, + state: Arc, +} + +enum Variante { + A, + B, +} + +fn beschreibung(pfad: Option<&Path>, variante: Variante) -> String { + match pfad { + Some(p) => p.display().to_string(), + None => match variante { + Variante::A => "synthetisch, 128 BPM (Kick, Bass, Hats)".into(), + Variante::B => "synthetisch, 124 BPM (Kick, Bass, Snare)".into(), + }, + } +} + +/// Lädt eine Datei oder erzeugt einen synthetischen Loop, und hängt beides an +/// ein Deck. +fn lade_oder_synthetisiere( + pfad: Option<&Path>, + rate: u32, + variante: Variante, + mindestlaenge: f64, +) -> Result<(Deck, Box)> { + let (track, bpm) = match pfad { + Some(p) => { + let track = Track::decode_file(p) + .with_context(|| format!("{} nicht lesbar", p.display()))? + .resampled_to(rate); + let bpm = analysis::analyze(&track).bpm; + (track, bpm) + } + None => { + let (loop_samples, bpm) = match variante { + Variante::A => (synth::loop_a(rate), 128.0), + Variante::B => (synth::loop_b(rate), 124.0), + }; + // Der Loop ist wenige Sekunden lang; ein Deck würde ihn einmal + // abspielen und dann verstummen. Also so oft aneinanderhängen, + // dass er die Renderdauer trägt. + let samples = wiederholen(loop_samples, rate, mindestlaenge); + ( + Track { + samples, + sample_rate: rate, + }, + Some(bpm), + ) + } + }; + + let state = Arc::new(DeckState::new()); + state.set_playing(true); + state.set_keylock(true); + + let voice = Voice::new(Arc::new(track), Arc::clone(&state)); + Ok((Deck { bpm, state }, Box::new(DeckSource::new(voice)))) +} + +fn wiederholen(samples: Vec, rate: u32, mindestlaenge: f64) -> Vec { + if samples.is_empty() { + return samples; + } + let noetig = (mindestlaenge * rate as f64) as usize * 2; + let male = (noetig / samples.len()).max(1) + 1; + + let mut out = Vec::with_capacity(samples.len() * male); + for _ in 0..male { + out.extend_from_slice(&samples); + } + out +} + +/// Kleine Klangerzeugung für den Fall ohne Eingabedateien. +mod synth { + use std::f32::consts::PI; + + pub fn loop_a(rate: u32) -> Vec { + bauen(rate, 128.0, 8, |bar, step, t, out| { + if step % 4 == 0 { + kick(t, out); + } + if step % 4 == 2 { + hat(t, out, 0.25); + } + if step % 2 == 0 { + let note = [55.0, 55.0, 73.42, 65.41][bar % 4]; + bass(t, note, out, 0.30); + } + }) + } + + pub fn loop_b(rate: u32) -> Vec { + bauen(rate, 124.0, 8, |bar, step, t, out| { + if step % 4 == 0 { + kick(t, out); + } + if step % 8 == 4 { + snare(t, out); + } + if step % 2 == 1 { + let note = [49.0, 61.74, 49.0, 58.27][bar % 4]; + bass(t, note, out, 0.28); + } + }) + } + + /// Baut `bars` Takte à 16 Sechzehntel und ruft je Schritt den Aufbau auf. + fn bauen( + rate: u32, + bpm: f32, + bars: usize, + mut schritt: impl FnMut(usize, usize, f32, &mut dyn FnMut(usize, f32)), + ) -> Vec { + let frames_per_beat = rate as f32 * 60.0 / bpm; + let frames_per_step = frames_per_beat / 4.0; + let total = (frames_per_step * 16.0 * bars as f32) as usize; + let mut mono = vec![0.0f32; total]; + + for bar in 0..bars { + for step in 0..16 { + let start = ((bar * 16 + step) as f32 * frames_per_step) as usize; + let laenge = (frames_per_step * 4.0) as usize; + + let mut add = |offset: usize, value: f32| { + let idx = start + offset; + if idx < total { + mono[idx] += value; + } + }; + + schritt(bar, step, laenge as f32 / rate as f32, &mut add); + let _ = laenge; + } + } + + // Auf Stereo aufziehen und Pegel bändigen. + let peak = mono.iter().fold(0.0f32, |m, v| m.max(v.abs())).max(1e-6); + let scale = 0.7 / peak; + mono.into_iter() + .flat_map(|v| { + let s = v * scale; + [s, s] + }) + .collect() + } + + fn kick(dauer: f32, add: &mut dyn FnMut(usize, f32)) { + let n = (dauer * 48_000.0).min(12_000.0) as usize; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 22.0).exp(); + // Tonhöhe fällt — das macht den Punch aus. + let f = 110.0 * (-t * 30.0).exp() + 45.0; + add(i, (2.0 * PI * f * t).sin() * env * 0.9); + } + } + + fn snare(dauer: f32, add: &mut dyn FnMut(usize, f32)) { + let n = (dauer * 48_000.0).min(9_000.0) as usize; + let mut seed = 0x2545_F491u32; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 30.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + let ton = (2.0 * PI * 190.0 * t).sin(); + add(i, (noise * 0.6 + ton * 0.4) * env * 0.6); + } + } + + fn hat(dauer: f32, add: &mut dyn FnMut(usize, f32), gain: f32) { + let n = (dauer * 48_000.0).min(2_400.0) as usize; + let mut seed = 0x9E37_79B9u32; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 120.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add(i, noise * env * gain); + } + } + + fn bass(dauer: f32, freq: f32, add: &mut dyn FnMut(usize, f32), gain: f32) { + let n = (dauer * 48_000.0).min(14_000.0) as usize; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (1.0 - (-t * 120.0).exp()) * (-t * 6.0).exp(); + let saw = 2.0 * ((freq * t) % 1.0) - 1.0; + add(i, saw * env * gain); + } + } +} + +fn schreibe_wav(pfad: &Path, pcm: &[f32], rate: u32) -> std::io::Result<()> { + let data_len = (pcm.len() * 2) as u32; + let mut out = BufWriter::new(File::create(pfad)?); + + out.write_all(b"RIFF")?; + out.write_all(&(36 + data_len).to_le_bytes())?; + out.write_all(b"WAVEfmt ")?; + out.write_all(&16u32.to_le_bytes())?; + out.write_all(&1u16.to_le_bytes())?; + out.write_all(&2u16.to_le_bytes())?; + out.write_all(&rate.to_le_bytes())?; + out.write_all(&(rate * 4).to_le_bytes())?; + out.write_all(&4u16.to_le_bytes())?; + out.write_all(&16u16.to_le_bytes())?; + out.write_all(b"data")?; + out.write_all(&data_len.to_le_bytes())?; + + for sample in pcm { + let value = (sample.clamp(-1.0, 1.0) * 32_767.0) as i16; + out.write_all(&value.to_le_bytes())?; + } + + out.flush() +} + +fn parse_args() -> Result { + let mut opts = Options::default(); + let mut args = std::env::args().skip(1); + + while let Some(arg) = args.next() { + let mut wert = || args.next().context("fehlender Wert"); + match arg.as_str() { + "--out" | "-o" => opts.out = PathBuf::from(wert()?), + "--a" => opts.a = Some(PathBuf::from(wert()?)), + "--b" => opts.b = Some(PathBuf::from(wert()?)), + "--aux" => opts.aux = Some(PathBuf::from(wert()?)), + "--seconds" => opts.seconds = wert()?.parse().context("--seconds")?, + "--transition" => opts.transition = Some(wert()?.parse().context("--transition")?), + "--transition-len" => { + opts.transition_len = wert()?.parse().context("--transition-len")? + } + "--rate" => opts.sample_rate = wert()?.parse().context("--rate")?, + "-h" | "--help" => { + hilfe(); + std::process::exit(0); + } + other => bail!("unbekannte Option: {other}"), + } + } + + if opts.seconds <= 0.0 { + bail!("--seconds muss positiv sein"); + } + Ok(opts) +} + +fn hilfe() { + println!("Aufruf: musik-mix [--a ] [--b ] [--aux ] --out "); + println!(); + println!("Rendert einen Übergang zwischen zwei Decks offline in eine WAV-Datei."); + println!("Ohne --a/--b werden synthetische Loops verwendet."); + println!(); + println!(" --a Deck A"); + println!(" --b Deck B"); + println!(" --aux AUX-Zuspieler, läuft auf Thru durch"); + println!(" --out Ausgabe (Vorgabe: mix.wav)"); + println!(" --seconds Gesamtlänge (Vorgabe: 60)"); + println!(" --transition Beginn des Übergangs (Vorgabe: Mitte)"); + println!(" --transition-len Dauer des Übergangs (Vorgabe: 16)"); + println!(" --rate Samplerate (Vorgabe: 48000)"); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 9e33d12..f369b54 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -103,19 +103,38 @@ durchsetzt und `analysis`/`library` sie bewusst nicht kennen müssen. ### Signalkette pro Kanal ``` -Deck ─► Trim ─► EQ (3-Band) ─► Filter (HP/LP) ─► Kanalfader ─┬─► Crossfader ─► Master-Summe ─► Limiter ─► Ausgang 1/2 - │ - └─► Cue-Bus ──────────────────────────────► Ausgang 3/4 +Quelle ─► Trim ─► EQ (3-Band) ─► Filter (HP/LP) ─┬─► Kanalfader ─► Crossfader ─► Summe ─► Limiter ─► Ausgang 1/2 + │ + └─► Cue-Bus ────────────────────────────────────► Ausgang 3/4 ``` -Zwei Details aus der Traktor-Referenz, die man leicht falsch baut: +**Quelle, nicht Deck.** Der Mixer kennt keine Decks, nur Quellen, die Stereo +liefern. Ein AUX-Eingang ist damit ein Kanal wie jeder andere — mit Trim, EQ, +Filter, Fader und Cue —, und die Generierung kann sich später an derselben +Stelle einhängen. + +Drei Details, die man leicht falsch baut: - **Mixer-FX liegen post-fader.** Zieht man den Fader zu, klingt der Effekt aus statt abzureißen. Das ist der Unterschied zwischen einem weichen und einem ruckeligen Übergang. -- **Der Cue-Abgriff liegt vor dem Crossfader.** Sonst hört man im Kopfhörer - nichts, wenn der Kanal ausgeblendet ist — also genau dann, wenn man ihn - braucht. +- **Der Cue-Abgriff liegt vor dem *Fader*** — nicht bloß vor dem Crossfader. + Man bereitet einen Track im Kopfhörer vor, während sein Fader unten ist; läge + der Abgriff dahinter, hörte man genau dann nichts. +- **Der Cue-Abgriff liegt hinter EQ und Filter**, weil man seine Klangeingriffe + kontrollieren muss, bevor sie auf die Anlage gehen. + +### AUX + +Mikrofon, Drum-Machine, ein zweiter Rechner. Zwei Dinge unterscheiden AUX von +einem Deck: + +- **Zuweisung Thru.** Ein Mikrofon darf nicht verschwinden, nur weil jemand den + Crossfader bewegt. +- **Ringpuffer statt direktem Zugriff.** Aufnahme und Wiedergabe laufen in + getrennten Callbacks mit getrennten Uhren. Ein lock-freier Ringpuffer + entkoppelt beide; bleibt die Aufnahme zurück, gibt es Stille und einen + gezählten Unterlauf statt eines Aussetzers. ### Threads @@ -150,7 +169,7 @@ Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). | # | Ziel | Abnahmekriterium | Aufwand | Braucht | | --- | --- | --- | --- | --- | -| 1 | Mehrdeck-Engine, Mixer, Cue-Bus | Zwei Tracks laufen gleichzeitig, Crossfader blendet, Cue liegt hörbar getrennt auf 3/4 | M | 4-Kanal-Interface | +| 1 | Mehrdeck-Engine, Mixer, Cue-Bus, AUX | Code steht ✅ · Abnahme offen: Cue muss hörbar getrennt auf 3/4 liegen | M | 4-Kanal-Interface | | ~~2~~ | ~~Analyse-Pipeline~~ ✅ | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | | 3 | UI-Grundgerüst | Zwei Decks mit Waveform, Mixer bedienbar, stabile 60 fps | L | 1, 2 | | 4 | Sync und Beatmatching | Zwei Tracks bleiben über 5 Minuten im Takt, Phase korrekt | M | 2 | From 09202bfd0ea9534bfa2f84dbdadba15eb727212b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:53:20 +0000 Subject: [PATCH 10/70] feat: sync, hot cues, loops and the library with Traktor import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more phases. Everything here is verifiable without an audio device, which is why it could be built ahead of the hardware. Sync couples tempo *and* phase. Matching BPM alone is the half that looks finished and isn't: two decks at exactly 128 BPM whose beats sit an eighth apart sound as wrong as two at different tempos. A beatgrid therefore lives on the deck as anchor plus BPM, held in atomics rather than behind a lock because the audio thread reads it. The correction jumps rather than eases the phase in — audible when large, but exact, and it always takes the shorter way around the beat. Once effective tempos match, phase stays locked on its own; no continuous regulation is needed. Covered by the phase 4 acceptance criterion: five minutes of rendering leaves the decks under 5 ms apart. Loops wrap at the loop boundary, not at the end of the callback block. Wrapping at the block would stretch each pass by up to one buffer, which is audible as an uneven bar. Beat loops snap the start to the grid, otherwise every pass begins mid-beat and stumbles. The library stores positions in milliseconds rather than frames. It is the interchange layer between analysis, Traktor import and deck, and a frame count without its sample rate is ambiguous — a resampled track would carry different numbers for the same instant. License and attribution columns are in the first migration, not a later one, because CC BY requires naming even non-commercially and the provenance of a thousand samples cannot be reconstructed afterwards. Upserts preserve existing values where the incoming record is empty. A folder rescan knows only paths and tags; without that rule it would erase the analysis, which is the expensive part. The Traktor importer reads tempo, grid anchor and hot cues from collection.nml. Field meanings come from public descriptions of an undocumented format and are tested only against hand-written samples — cue type mapping, key numbering and Windows volume handling are flagged as needing a check against a real collection. Adds musik-lib to scan folders, import from Traktor, and search by text or mixable tempo. 137 tests total. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGFKXoHmTkrnmQa8nav65p --- Cargo.lock | 115 ++++- Cargo.toml | 3 +- README.md | 39 +- crates/audio-core/src/deck.rs | 350 ++++++++++++++- crates/audio-core/src/grid.rs | 141 ++++++ crates/audio-core/src/lib.rs | 2 + crates/audio-engine/src/lib.rs | 2 + crates/audio-engine/src/sync.rs | 273 ++++++++++++ crates/library/Cargo.toml | 11 + crates/library/src/db.rs | 599 ++++++++++++++++++++++++++ crates/library/src/error.rs | 21 + crates/library/src/lib.rs | 26 ++ crates/library/src/model.rs | 315 ++++++++++++++ crates/library/src/traktor.rs | 421 ++++++++++++++++++ crates/musik-cli/Cargo.toml | 1 + crates/musik-cli/src/bin/musik-lib.rs | 219 ++++++++++ docs/PLAN.md | 33 +- 17 files changed, 2543 insertions(+), 28 deletions(-) create mode 100644 crates/audio-core/src/grid.rs create mode 100644 crates/audio-engine/src/sync.rs create mode 100644 crates/library/Cargo.toml create mode 100644 crates/library/src/db.rs create mode 100644 crates/library/src/error.rs create mode 100644 crates/library/src/lib.rs create mode 100644 crates/library/src/model.rs create mode 100644 crates/library/src/traktor.rs create mode 100644 crates/musik-cli/src/bin/musik-lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2e3e46f..04cf1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,12 +243,30 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures-core" version = "0.3.33" @@ -273,11 +291,32 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "indexmap" @@ -286,7 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -376,6 +415,27 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "library" +version = "0.1.0" +dependencies = [ + "audio-core", + "quick-xml", + "rusqlite", + "thiserror 2.0.20", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "log" version = "0.4.33" @@ -402,6 +462,7 @@ dependencies = [ "anyhow", "audio-core", "audio-engine", + "library", ] [[package]] @@ -628,6 +689,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -643,12 +713,37 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + [[package]] name = "rtrb" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -770,6 +865,18 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "strength_reduce" version = "0.2.4" @@ -1033,6 +1140,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d822da3..bce1a00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/musik-cli"] +members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/library", "crates/musik-cli"] [workspace.package] edition = "2021" @@ -10,3 +10,4 @@ publish = false analysis = { path = "crates/analysis" } audio-core = { path = "crates/audio-core" } audio-engine = { path = "crates/audio-engine" } +library = { path = "crates/library" } diff --git a/README.md b/README.md index c2d14d1..0130058 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den Reaktionen der Crowd formt. -Status: **Phasen 0–2.** Ein Deck läuft (Wiedergabe, Tempo mit und ohne -Keylock, Springen), die Analyse erkennt Tempo, Beatgrid und Wellenform-Spitzen, -und der Mixer mischt mehrere Kanäle inklusive AUX auf Master und Kopfhörer. -Nativ in Rust. +Status: **sechs von zehn Phasen gebaut.** Decks mit Keylock, Hot Cues und +Loops; Mixer mit EQ, Filter, Crossfader, Cue-Bus und AUX; Analyse für Tempo, +Beatgrid und Wellenform; Sync über Tempo *und* Phase; Sammlung mit +Traktor-Import. Nativ in Rust — was fehlt, ist die Oberfläche. ## Zielbild @@ -50,8 +50,8 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap -**Phasen 0 und 2 stehen** ✅, **Phase 1 zur Hälfte** ⚙️ — der Mixer ist gebaut -und getestet, abnehmen lässt er sich erst mit einem 4-Kanal-Interface. +**Sechs von zehn Phasen sind gebaut.** Was jetzt fehlt, ist vor allem die +Oberfläche — bedienen lässt sich alles bisher nur über die Kommandozeile. Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: @@ -60,11 +60,14 @@ Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: | --- | --- | | 1 ⚙️ | Mehrdeck-Engine mit Mixer, Cue-Bus und AUX — Code steht, Abnahme braucht Hardware | | ~~2~~ ✅ | ~~Analyse-Pipeline (BPM, Grid, Peaks)~~ | -| 3–5 | UI mit Waveforms · Sync und Beatmatching · Hot Cues und Loops | -| 6–7 | Library inkl. Traktor-Import · Effekte | -| 8–10 | Stems · Mitschnitt · MIDI-Controller | +| **3** | **UI mit Waveforms — das größte offene Stück** | +| ~~4~~ ✅ | ~~Sync und Beatmatching über Tempo und Phase~~ | +| ~~5~~ ✅ | ~~Hot Cues und Loops~~ (Bedienung kommt mit Phase 3) | +| ~~6~~ ✅ | ~~Library inkl. Traktor-Import~~ | +| 7–10 | Effekte · Stems · Mitschnitt · MIDI-Controller | -Ab Phase 6 ist die Software als DJ-Werkzeug benutzbar. +Zum Auflegen fehlt vor allem Phase 3: Ohne Oberfläche mit Waveforms lässt sich +das Vorhandene zwar testen, aber nicht spielen. Darauf setzen die beiden Schichten aus dem Zielbild auf: @@ -88,6 +91,8 @@ und der Werkzeugkasten sollte sie nicht vorwegnehmen. | Crate | Lizenz | Wofür | | --- | --- | --- | | `cpal` | Apache-2.0 | Audioausgabe | +| `rusqlite` | MIT | Sammlung als SQLite-Datei | +| `quick-xml` | MIT | Traktor-`collection.nml` lesen | | `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | | `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | | `rtrb` | MIT/Apache-2.0 | Lock-freier Ringpuffer für AUX | @@ -193,4 +198,18 @@ synthetische Loops — praktisch, um die Engine zu hören, wenn gerade keine Musik zur Hand ist. `--aux ` legt einen dritten Kanal auf Thru dazu, der den Crossfader unbeschadet übersteht. +Sammlung aufbauen — ebenfalls **ohne** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-lib -- scan ~/Musik +cargo run -p musik-cli --bin musik-lib -- import-traktor ~/Documents/Native\ Instruments/Traktor/collection.nml +cargo run -p musik-cli --bin musik-lib -- mixable 128 +``` + +`scan` dekodiert, analysiert und legt Tempo, Beatgrid und Inhalts-Hash in einer +SQLite-Datei ab. `import-traktor` übernimmt Tempo, Beatgrid und Hot Cues aus +einer bestehenden Traktor-Sammlung — ⚠️ gegen eine *echte* `collection.nml` ist +das noch nicht geprüft, nur gegen selbst geschriebene Beispiele. Der erste Lauf +gehört stichprobenartig nachgesehen. + Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/audio-core/src/deck.rs b/crates/audio-core/src/deck.rs index ca36b1b..8fddae3 100644 --- a/crates/audio-core/src/deck.rs +++ b/crates/audio-core/src/deck.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use crate::grid::Beatgrid; use crate::stretch::Wsola; use crate::track::{CHANNELS, Track}; @@ -17,6 +18,9 @@ const TEMPO_EPS: f64 = 1e-4; /// Maximale Blockgröße, die am Stück gerendert wird. const SCRATCH_FRAMES: usize = 4096; +/// Anzahl der Hot Cues je Deck, wie bei Traktor. +pub const HOT_CUES: usize = 8; + pub struct DeckState { playing: AtomicBool, keylock: AtomicBool, @@ -24,6 +28,19 @@ pub struct DeckState { position: AtomicU64, seek_to: AtomicI64, finished: AtomicBool, + + /// Beatgrid als Atomics statt als Struktur hinter einem Lock — der + /// Audio-Thread liest es, und Locks haben dort nichts zu suchen. + /// BPM 0.0 bedeutet: kein Grid bekannt. + grid_bpm_bits: AtomicU32, + grid_anchor: AtomicU64, + + /// −1 = nicht gesetzt. + cues: [AtomicI64; HOT_CUES], + + loop_start: AtomicI64, + loop_end: AtomicI64, + loop_active: AtomicBool, } impl Default for DeckState { @@ -41,7 +58,114 @@ impl DeckState { position: AtomicU64::new(0), seek_to: AtomicI64::new(-1), finished: AtomicBool::new(false), + grid_bpm_bits: AtomicU32::new(0.0f32.to_bits()), + grid_anchor: AtomicU64::new(0), + cues: std::array::from_fn(|_| AtomicI64::new(-1)), + loop_start: AtomicI64::new(-1), + loop_end: AtomicI64::new(-1), + loop_active: AtomicBool::new(false), + } + } + + /// Hinterlegt das Beatgrid aus der Analyse. `None` löscht es. + pub fn set_grid(&self, grid: Option) { + match grid.filter(|g| g.is_usable()) { + Some(g) => { + self.grid_anchor.store(g.anchor_frames, Ordering::Relaxed); + self.grid_bpm_bits.store(g.bpm.to_bits(), Ordering::Relaxed); + } + None => self + .grid_bpm_bits + .store(0.0f32.to_bits(), Ordering::Relaxed), + } + } + + pub fn grid(&self) -> Option { + let bpm = f32::from_bits(self.grid_bpm_bits.load(Ordering::Relaxed)); + let grid = Beatgrid::new(bpm, self.grid_anchor.load(Ordering::Relaxed), 1.0); + grid.is_usable().then_some(grid) + } + + /// Effektives Tempo in BPM — Grid-Tempo mal Pitchfader. + pub fn effective_bpm(&self) -> Option { + self.grid().map(|g| g.bpm * self.tempo()) + } + + /// Lage im laufenden Beat, 0.0 bis 1.0. + pub fn beat_phase(&self, sample_rate: u32) -> Option { + self.grid() + .map(|g| g.phase_at(self.position_frames() as f64, sample_rate)) + } + + /// Setzt einen Hot Cue auf die angegebene Position. + pub fn set_cue(&self, index: usize, frame: Option) { + if let Some(slot) = self.cues.get(index) { + slot.store(frame.map(|f| f as i64).unwrap_or(-1), Ordering::Relaxed); + } + } + + pub fn cue(&self, index: usize) -> Option { + let raw = self.cues.get(index)?.load(Ordering::Relaxed); + (raw >= 0).then_some(raw as u64) + } + + /// Springt auf einen Hot Cue. Gibt zurück, ob er gesetzt war. + pub fn jump_to_cue(&self, index: usize) -> bool { + match self.cue(index) { + Some(frame) => { + self.seek_frames(frame); + true + } + None => false, + } + } + + /// Setzt eine Schleife. `end` muss hinter `start` liegen. + pub fn set_loop(&self, start: u64, end: u64) -> bool { + if end <= start { + return false; + } + self.loop_start.store(start as i64, Ordering::Relaxed); + self.loop_end.store(end as i64, Ordering::Relaxed); + true + } + + pub fn loop_range(&self) -> Option<(u64, u64)> { + let start = self.loop_start.load(Ordering::Relaxed); + let end = self.loop_end.load(Ordering::Relaxed); + (start >= 0 && end > start).then_some((start as u64, end as u64)) + } + + /// Schaltet die Schleife scharf. Ohne gesetzten Bereich passiert nichts. + pub fn set_loop_active(&self, active: bool) -> bool { + let ok = !active || self.loop_range().is_some(); + self.loop_active.store(active && ok, Ordering::Relaxed); + ok + } + + pub fn is_looping(&self) -> bool { + self.loop_active.load(Ordering::Relaxed) && self.loop_range().is_some() + } + + /// Legt eine Schleife über `beats` Beats ab der aktuellen Position, + /// eingerastet auf das Grid. + /// + /// Ohne Einrasten säße der Anfang irgendwo im Takt, und die Schleife + /// klänge bei jedem Durchlauf wie ein Stolperer. + pub fn set_loop_beats(&self, beats: f64, sample_rate: u32) -> bool { + let Some(grid) = self.grid() else { + return false; + }; + if beats <= 0.0 { + return false; } + + let start = grid + .snap(self.position_frames() as f64, sample_rate) + .max(0.0); + let length = beats * grid.frames_per_beat(sample_rate); + + self.set_loop(start as u64, (start + length) as u64) } pub fn is_playing(&self) -> bool { @@ -173,12 +297,32 @@ impl Voice { self.stretching = want_stretch; } - let alive = if want_stretch { - self.wsola - .render(&self.track.samples, tempo, &mut self.pos, out) - } else { - varispeed(&self.track.samples, tempo, &mut self.pos, out) - }; + let mut alive = true; + let mut written = 0usize; + + // Blockweise bis zur nächsten Schleifengrenze rendern. Erst am Ende des + // Callback-Blocks umzubrechen wäre bequemer, würde die Schleife aber um + // bis zu einen Block verlängern — hörbar als ungleichmäßiger Takt. + while written < out.len() { + let rest = &mut out[written..]; + let chunk = self.frames_until_loop_end(tempo).min(rest.len() / CHANNELS); + let len = chunk.max(1) * CHANNELS; + let len = len.min(rest.len()); + + alive = if want_stretch { + self.wsola + .render(&self.track.samples, tempo, &mut self.pos, &mut rest[..len]) + } else { + varispeed(&self.track.samples, tempo, &mut self.pos, &mut rest[..len]) + }; + + written += len; + + if !alive { + break; + } + self.wrap_loop(); + } if !alive { self.state.playing.store(false, Ordering::Relaxed); @@ -190,6 +334,44 @@ impl Voice { self.state.position.store(clamped as u64, Ordering::Relaxed); } + /// Wie viele Ausgabe-Frames bis zum Schleifenende gerendert werden dürfen. + /// Ohne aktive Schleife: unbegrenzt. + fn frames_until_loop_end(&self, tempo: f64) -> usize { + let Some((_, end)) = self.active_loop() else { + return usize::MAX; + }; + let verbleibend = end as f64 - self.pos; + if verbleibend <= 0.0 { + return 1; + } + // Quell-Frames in Ausgabe-Frames umrechnen: bei doppeltem Tempo + // verbraucht ein Ausgabe-Frame zwei Quell-Frames. + let schritt = tempo.max(1e-6); + (verbleibend / schritt).ceil() as usize + } + + /// Setzt die Position zurück, sobald das Schleifenende überschritten ist. + fn wrap_loop(&mut self) { + let Some((start, end)) = self.active_loop() else { + return; + }; + if self.pos < end as f64 { + return; + } + + let laenge = (end - start) as f64; + let ueberschuss = (self.pos - end as f64).rem_euclid(laenge); + self.pos = start as f64 + ueberschuss; + self.wsola.reset(); + } + + fn active_loop(&self) -> Option<(u64, u64)> { + self.state + .is_looping() + .then(|| self.state.loop_range()) + .flatten() + } + fn take_seek(&mut self) -> Option { let raw = self.state.seek_to.swap(-1, Ordering::Relaxed); if raw < 0 { @@ -279,6 +461,162 @@ mod tests { assert!((state.tempo() - 1.06).abs() < 1e-6); } + fn deck_mit_ton(secs: f32) -> (Voice, Arc) { + let track = Arc::new(Track { + samples: sine(440.0, RATE, secs), + sample_rate: RATE, + }); + let state = Arc::new(DeckState::new()); + state.set_playing(true); + (Voice::new(track, Arc::clone(&state)), state) + } + + #[test] + fn hot_cues_speichern_und_springen() { + let (_voice, state) = deck_mit_ton(10.0); + + assert_eq!(state.cue(0), None); + assert!(!state.jump_to_cue(0), "Sprung auf leeren Cue meldet Erfolg"); + + state.set_cue(0, Some(12_345)); + assert_eq!(state.cue(0), Some(12_345)); + assert!(state.jump_to_cue(0)); + + state.set_cue(0, None); + assert_eq!(state.cue(0), None); + } + + #[test] + fn cue_index_ausserhalb_paniert_nicht() { + let (_voice, state) = deck_mit_ton(1.0); + state.set_cue(99, Some(1)); + assert_eq!(state.cue(99), None); + assert!(!state.jump_to_cue(99)); + } + + #[test] + fn schleife_haelt_die_position_im_bereich() { + let (mut voice, state) = deck_mit_ton(10.0); + let (start, end) = (RATE as u64, RATE as u64 + 12_000); + + assert!(state.set_loop(start, end)); + state.seek_frames(start); + assert!(state.set_loop_active(true)); + + // Deutlich mehr rendern, als die Schleife lang ist. + let mut out = vec![0.0; 8_192 * CHANNELS]; + for _ in 0..12 { + voice.render_stereo(&mut out); + let pos = state.position_frames(); + assert!( + (start..end).contains(&pos), + "Position {pos} außerhalb der Schleife {start}..{end}" + ); + } + + assert!( + !state.is_finished(), + "Schleife darf nicht ans Trackende laufen" + ); + } + + #[test] + fn schleife_aus_laesst_weiterlaufen() { + let (mut voice, state) = deck_mit_ton(10.0); + state.set_loop(RATE as u64, RATE as u64 + 12_000); + state.seek_frames(RATE as u64); + state.set_loop_active(false); + + let mut out = vec![0.0; 8_192 * CHANNELS]; + for _ in 0..6 { + voice.render_stereo(&mut out); + } + + assert!( + state.position_frames() > RATE as u64 + 12_000, + "ohne aktive Schleife bleibt das Deck stehen" + ); + } + + #[test] + fn unsinnige_schleifen_werden_abgelehnt() { + let (_voice, state) = deck_mit_ton(1.0); + + assert!(!state.set_loop(500, 500), "leere Schleife akzeptiert"); + assert!( + !state.set_loop(500, 100), + "rückwärts laufende Schleife akzeptiert" + ); + assert!(state.loop_range().is_none()); + + assert!( + !state.set_loop_active(true), + "Schleife ohne Bereich scharf gestellt" + ); + assert!(!state.is_looping()); + } + + #[test] + fn beat_schleife_rastet_auf_das_grid() { + let (_voice, state) = deck_mit_ton(10.0); + // 120 BPM = 22 050 Frames je Beat bei 44,1 kHz. + state.set_grid(Some(Beatgrid::new(120.0, 1_000, 1.0))); + state.seek_frames(30_000); + + assert!(state.set_loop_beats(4.0, RATE)); + let (start, end) = state.loop_range().expect("keine Schleife gesetzt"); + + // Start muss auf einem Beat sitzen, nicht auf 30 000. + let grid = state.grid().unwrap(); + let phase = grid.phase_at(start as f64, RATE); + assert!( + !(1e-6..=1.0 - 1e-6).contains(&phase), + "Schleifenanfang liegt bei Phase {phase:.4} statt auf dem Beat" + ); + + let beats = (end - start) as f64 / grid.frames_per_beat(RATE); + assert!( + (beats - 4.0).abs() < 1e-3, + "Schleife ist {beats:.3} Beats lang" + ); + } + + #[test] + fn ohne_grid_gibt_es_keine_beat_schleife() { + let (_voice, state) = deck_mit_ton(1.0); + assert!(!state.set_loop_beats(4.0, RATE)); + } + + #[test] + fn grid_ueberlebt_die_atomics() { + let (_voice, state) = deck_mit_ton(1.0); + assert!(state.grid().is_none()); + + state.set_grid(Some(Beatgrid::new(128.5, 4_242, 0.8))); + let grid = state.grid().expect("Grid verschwunden"); + assert!((grid.bpm - 128.5).abs() < 1e-4); + assert_eq!(grid.anchor_frames, 4_242); + + state.set_grid(None); + assert!(state.grid().is_none()); + + // Unbrauchbare Grids werden gar nicht erst übernommen. + state.set_grid(Some(Beatgrid::new(0.0, 1, 1.0))); + assert!(state.grid().is_none()); + } + + #[test] + fn effektives_tempo_beruecksichtigt_den_pitchfader() { + let (_voice, state) = deck_mit_ton(1.0); + assert_eq!(state.effective_bpm(), None); + + state.set_grid(Some(Beatgrid::new(120.0, 0, 1.0))); + state.set_tempo(1.05); + + let bpm = state.effective_bpm().unwrap(); + assert!((bpm - 126.0).abs() < 0.01, "effektives Tempo {bpm:.2}"); + } + #[test] fn play_setzt_das_ende_flag_zurueck() { let state = DeckState::new(); diff --git a/crates/audio-core/src/grid.rs b/crates/audio-core/src/grid.rs new file mode 100644 index 0000000..3b658c5 --- /dev/null +++ b/crates/audio-core/src/grid.rs @@ -0,0 +1,141 @@ +//! Beatgrid: die Umrechnung zwischen Sample-Position und Taktzählung. +//! +//! Ein Tempo allein reicht für Sync nicht. Zwei Decks mit identischem BPM, +//! deren Beats aber versetzt liegen, klingen genauso falsch wie zwei mit +//! verschiedenem Tempo. Gebraucht werden immer beide Größen: **Tempo** und +//! **Phase** — und die Phase kommt aus dem Anker. +//! +//! Alle Angaben beziehen sich auf *Quell-Frames*, nicht auf Ausgabezeit. Ein +//! Deck, das mit 1,03-fachem Tempo läuft, wandert schneller durch das Grid, +//! aber das Grid selbst bleibt, wie es beim Analysieren gemessen wurde. + +/// Tempo und Lage der Beats eines Tracks. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Beatgrid { + pub bpm: f32, + /// Position eines Beats in Sample-Frames. Welcher Beat, ist gleichgültig — + /// das Raster wiederholt sich. + pub anchor_frames: u64, + /// 0..1, wie verlässlich die Erkennung war. + pub confidence: f32, +} + +impl Beatgrid { + pub fn new(bpm: f32, anchor_frames: u64, confidence: f32) -> Self { + Beatgrid { + bpm, + anchor_frames, + confidence, + } + } + + pub fn is_usable(&self) -> bool { + self.bpm.is_finite() && self.bpm > 1.0 + } + + /// Länge eines Beats in Sample-Frames. + pub fn frames_per_beat(&self, sample_rate: u32) -> f64 { + 60.0 / self.bpm as f64 * sample_rate as f64 + } + + /// Fortlaufende Beat-Nummer an einer Position. Kann negativ sein, wenn die + /// Position vor dem Anker liegt. + pub fn beat_at(&self, frame: f64, sample_rate: u32) -> f64 { + (frame - self.anchor_frames as f64) / self.frames_per_beat(sample_rate) + } + + /// Umkehrung: Position einer Beat-Nummer. + pub fn frame_of_beat(&self, beat: f64, sample_rate: u32) -> f64 { + self.anchor_frames as f64 + beat * self.frames_per_beat(sample_rate) + } + + /// Lage innerhalb des laufenden Beats, 0.0 bis 1.0. + pub fn phase_at(&self, frame: f64, sample_rate: u32) -> f64 { + self.beat_at(frame, sample_rate).rem_euclid(1.0) + } + + /// Nächstgelegene Beat-Position zu `frame`. + pub fn snap(&self, frame: f64, sample_rate: u32) -> f64 { + let beat = self.beat_at(frame, sample_rate).round(); + self.frame_of_beat(beat, sample_rate) + } +} + +/// Kürzester Weg von `from` nach `to` auf einem Kreis der Länge 1. +/// +/// Liegt ein Deck bei Phase 0,9 und das andere bei 0,1, ist der richtige Weg +/// +0,2 vorwärts und nicht −0,8 rückwärts. Ohne diese Wicklung würde Sync +/// einen fast ganzen Beat springen, wo ein Zehntel genügt. +pub fn shortest_phase_delta(from: f64, to: f64) -> f64 { + let raw = (to - from).rem_euclid(1.0); + if raw > 0.5 { raw - 1.0 } else { raw } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 48_000; + + fn grid() -> Beatgrid { + // 120 BPM: ein Beat sind exakt 24 000 Frames bei 48 kHz. + Beatgrid::new(120.0, 10_000, 1.0) + } + + #[test] + fn beat_laenge_stimmt() { + assert!((grid().frames_per_beat(RATE) - 24_000.0).abs() < 1e-6); + } + + #[test] + fn anker_liegt_auf_beat_null() { + let g = grid(); + assert!((g.beat_at(10_000.0, RATE) - 0.0).abs() < 1e-9); + assert!((g.beat_at(34_000.0, RATE) - 1.0).abs() < 1e-9); + assert!((g.beat_at(-14_000.0, RATE) + 1.0).abs() < 1e-9); + } + + #[test] + fn hin_und_zurueck_ist_verlustfrei() { + let g = grid(); + for beat in [-3.5, 0.0, 1.25, 17.75] { + let frame = g.frame_of_beat(beat, RATE); + assert!((g.beat_at(frame, RATE) - beat).abs() < 1e-9); + } + } + + #[test] + fn phase_laeuft_von_null_bis_eins() { + let g = grid(); + assert!((g.phase_at(10_000.0, RATE) - 0.0).abs() < 1e-9); + assert!((g.phase_at(22_000.0, RATE) - 0.5).abs() < 1e-9); + assert!((g.phase_at(33_999.0, RATE) - 0.99995).abs() < 1e-4); + + // Auch vor dem Anker muss die Phase im Bereich bleiben. + let vorher = g.phase_at(-1_000.0, RATE); + assert!((0.0..1.0).contains(&vorher), "Phase {vorher} außerhalb"); + } + + #[test] + fn snap_zieht_auf_den_naechsten_beat() { + let g = grid(); + assert!((g.snap(11_000.0, RATE) - 10_000.0).abs() < 1e-6); + assert!((g.snap(33_000.0, RATE) - 34_000.0).abs() < 1e-6); + } + + #[test] + fn phasendifferenz_nimmt_den_kurzen_weg() { + // 0.9 → 0.1 sind 0.2 vorwärts, nicht 0.8 rückwärts. + assert!((shortest_phase_delta(0.9, 0.1) - 0.2).abs() < 1e-9); + assert!((shortest_phase_delta(0.1, 0.9) + 0.2).abs() < 1e-9); + assert!((shortest_phase_delta(0.25, 0.75) - 0.5).abs() < 1e-9); + assert!(shortest_phase_delta(0.5, 0.5).abs() < 1e-9); + } + + #[test] + fn unbrauchbare_grids_werden_erkannt() { + assert!(grid().is_usable()); + assert!(!Beatgrid::new(0.0, 0, 0.0).is_usable()); + assert!(!Beatgrid::new(f32::NAN, 0, 0.0).is_usable()); + } +} diff --git a/crates/audio-core/src/lib.rs b/crates/audio-core/src/lib.rs index 480bbc8..c603d38 100644 --- a/crates/audio-core/src/lib.rs +++ b/crates/audio-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod deck; pub mod error; +pub mod grid; pub mod player; pub mod stretch; pub mod track; @@ -15,5 +16,6 @@ mod testing; pub use deck::DeckState; pub use error::{AudioError, Result}; +pub use grid::Beatgrid; pub use player::Player; pub use track::Track; diff --git a/crates/audio-engine/src/lib.rs b/crates/audio-engine/src/lib.rs index 7e318e5..592a607 100644 --- a/crates/audio-engine/src/lib.rs +++ b/crates/audio-engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod limiter; pub mod mixer; pub mod source; pub mod svf; +pub mod sync; #[cfg(test)] pub(crate) mod testing; @@ -32,3 +33,4 @@ pub use filter::DjFilter; pub use limiter::Limiter; pub use mixer::{assign_deck_pair, Engine}; pub use source::{DeckSource, LoopSource, SilentSource, Source}; +pub use sync::{phase_error, sync, sync_tempo_only, SyncPlan}; diff --git a/crates/audio-engine/src/sync.rs b/crates/audio-engine/src/sync.rs new file mode 100644 index 0000000..54bca66 --- /dev/null +++ b/crates/audio-engine/src/sync.rs @@ -0,0 +1,273 @@ +//! Sync: zwei Decks auf Tempo *und* Phase bringen. +//! +//! Der Teil, den man leicht halbiert. Gleiches Tempo genügt nicht — zwei Decks +//! mit exakt 128 BPM, deren Beats aber um eine Achtel versetzt liegen, klingen +//! genauso falsch wie zwei mit verschiedenem Tempo. Sync ist deshalb immer +//! zweierlei: +//! +//! 1. **Tempo** — der Pitchfader des Slaves wird so gesetzt, dass sein +//! effektives Tempo dem des Masters entspricht. +//! 2. **Phase** — der Slave springt auf die Beat-Lage des Masters. +//! +//! Danach halten beide von selbst zusammen: Ist das effektive Tempo gleich, +//! läuft auch die Phase gleich schnell weiter. Es braucht keine fortlaufende +//! Nachregelung, solange niemand am Pitchfader dreht. +//! +//! ## Warum gesprungen und nicht geschoben wird +//! +//! Ein Sprung ist hörbar, wenn er groß ist. Weichere Verfahren ziehen das Tempo +//! kurz an, bis die Phase stimmt. Das ist angenehmer, aber auch träger, und für +//! den ersten Aufbau ist der Sprung ehrlicher: Er stimmt sofort und exakt. +//! Der Sprung nimmt immer den kürzeren Weg, höchstens eine halbe Beat-Länge. + +use audio_core::deck::DeckState; +use audio_core::grid::shortest_phase_delta; + +/// Was am Slave-Deck zu tun ist. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SyncPlan { + /// Neuer Wert für den Pitchfader des Slaves. + pub tempo: f32, + /// Zielposition in Quell-Frames, wenn die Phase korrigiert werden muss. + pub seek_to: Option, + /// Wie weit die Phase danebenlag, in Beats (−0.5 … 0.5). + pub phase_error_beats: f64, +} + +/// Berechnet den Plan, ohne etwas zu verändern. +/// +/// `None`, wenn einem der beiden Decks das Beatgrid fehlt — ohne Grid gibt es +/// weder Tempo noch Phase, auf die man synchronisieren könnte. +pub fn plan(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let master_grid = master.grid()?; + let slave_grid = slave.grid()?; + + let master_bpm = master_grid.bpm * master.tempo(); + let tempo = master_bpm / slave_grid.bpm; + if !tempo.is_finite() || tempo <= 0.0 { + return None; + } + + let master_pos = master.position_frames() as f64; + let slave_pos = slave.position_frames() as f64; + + let master_phase = master_grid.phase_at(master_pos, sample_rate); + let slave_phase = slave_grid.phase_at(slave_pos, sample_rate); + let delta = shortest_phase_delta(slave_phase, master_phase); + + let ziel = slave_pos + delta * slave_grid.frames_per_beat(sample_rate); + let seek_to = (ziel >= 0.0).then_some(ziel.round() as u64); + + Some(SyncPlan { + tempo, + seek_to, + phase_error_beats: delta, + }) +} + +/// Berechnet und wendet an. Gibt zurück, was getan wurde. +pub fn sync(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let plan = plan(master, slave, sample_rate)?; + + slave.set_tempo(plan.tempo); + if let Some(frame) = plan.seek_to { + slave.seek_frames(frame); + } + + Some(plan) +} + +/// Nur das Tempo angleichen, ohne Sprung. +/// +/// Für den Fall, dass man selbst einschieben will — das Tempo abzunehmen ist +/// die mühsame Arbeit, die Phase trifft man von Hand. +pub fn sync_tempo_only(master: &DeckState, slave: &DeckState) -> Option { + let master_bpm = master.effective_bpm()?; + let slave_grid = slave.grid()?; + + let tempo = master_bpm / slave_grid.bpm; + if !tempo.is_finite() || tempo <= 0.0 { + return None; + } + + slave.set_tempo(tempo); + Some(tempo) +} + +/// Aktueller Phasenversatz in Beats, −0.5 bis 0.5. Für Anzeigen und Tests. +pub fn phase_error(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let master_phase = master.beat_phase(sample_rate)?; + let slave_phase = slave.beat_phase(sample_rate)?; + Some(shortest_phase_delta(slave_phase, master_phase)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use audio_core::deck::Voice; + use audio_core::{Beatgrid, Track}; + + use super::*; + use crate::testing::sine_stereo; + + const RATE: u32 = 48_000; + + fn deck(bpm: f32, anchor: u64, secs: f32) -> (Voice, Arc) { + let track = Arc::new(Track { + samples: sine_stereo(220.0, RATE, secs), + sample_rate: RATE, + }); + let state = Arc::new(DeckState::new()); + state.set_grid(Some(Beatgrid::new(bpm, anchor, 1.0))); + state.set_playing(true); + (Voice::new(track, Arc::clone(&state)), state) + } + + #[test] + fn tempo_wird_angeglichen() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(124.0, 0, 5.0); + + let plan = sync(&master, &slave, RATE).expect("kein Plan"); + + assert!( + (plan.tempo - 128.0 / 124.0).abs() < 1e-6, + "Tempofaktor {:.5}", + plan.tempo + ); + assert!( + (slave.effective_bpm().unwrap() - 128.0).abs() < 0.01, + "effektives Tempo {:?}", + slave.effective_bpm() + ); + } + + #[test] + fn der_pitchfader_des_masters_zaehlt_mit() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(128.0, 0, 5.0); + + master.set_tempo(1.05); + sync(&master, &slave, RATE); + + assert!( + (slave.tempo() - 1.05).abs() < 1e-5, + "Slave folgt dem Pitchfader nicht: {:.4}", + slave.tempo() + ); + } + + #[test] + fn die_phase_wird_korrigiert() { + let (mut a, master) = deck(120.0, 0, 30.0); + let (mut b, slave) = deck(120.0, 0, 30.0); + + // Slave um eine Viertel-Beat-Länge versetzen. + let viertel = (60.0 / 120.0 * RATE as f64 * 0.25) as u64; + slave.seek_frames(viertel); + + let mut out = vec![0.0; 1_024 * 2]; + a.render_stereo(&mut out); + b.render_stereo(&mut out); + + let plan = sync(&master, &slave, RATE).expect("kein Plan"); + assert!(plan.seek_to.is_some(), "kein Sprung geplant"); + + b.render_stereo(&mut out); + a.render_stereo(&mut out); + + let rest = phase_error(&master, &slave, RATE).unwrap().abs(); + assert!( + rest < 0.01, + "nach Sync noch {rest:.4} Beats daneben (vorher {:.4})", + plan.phase_error_beats + ); + } + + #[test] + fn der_sprung_nimmt_den_kurzen_weg() { + let (_a, master) = deck(120.0, 0, 30.0); + let (mut b, slave) = deck(120.0, 0, 30.0); + + // Slave liegt bei Phase 0.9, Master bei 0.0 — vorwärts sind es 0.1, + // rückwärts 0.9. Der Plan muss den kurzen Weg nehmen. + let fast_ganz = (60.0 / 120.0 * RATE as f64 * 0.9) as u64; + slave.seek_frames(fast_ganz); + + // `seek_frames` ist nur eine Anforderung; ausgeführt wird sie im + // Audio-Thread. Ohne einen Renderdurchlauf steht die Position noch + // auf dem alten Wert und der Plan liefe ins Leere. + let mut out = vec![0.0; 256 * 2]; + b.render_stereo(&mut out); + + let plan = plan(&master, &slave, RATE).expect("kein Plan"); + assert!( + plan.phase_error_beats.abs() <= 0.5, + "Sprung über eine halbe Beat-Länge: {:.3}", + plan.phase_error_beats + ); + assert!( + plan.phase_error_beats > 0.0, + "Sprung geht in die falsche Richtung: {:.3}", + plan.phase_error_beats + ); + } + + #[test] + fn zwei_decks_bleiben_ueber_minuten_im_takt() { + // Das Abnahmekriterium aus docs/PLAN.md, Phase 4. + let (mut a, master) = deck(128.0, 0, 400.0); + let (mut b, slave) = deck(124.0, 7_000, 400.0); + + sync(&master, &slave, RATE); + + let block = 4_096; + let mut out_a = vec![0.0; block * 2]; + let mut out_b = vec![0.0; block * 2]; + + let bloecke = (300.0 * RATE as f64 / block as f64) as usize; + for _ in 0..bloecke { + a.render_stereo(&mut out_a); + b.render_stereo(&mut out_b); + } + + let fehler = phase_error(&master, &slave, RATE).unwrap().abs(); + let ms = fehler * 60.0 / 128.0 * 1_000.0; + + assert!( + ms < 5.0, + "nach 5 Minuten {ms:.2} ms auseinander ({fehler:.5} Beats)" + ); + } + + #[test] + fn ohne_grid_gibt_es_keinen_sync() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(124.0, 0, 5.0); + + slave.set_grid(None); + assert!(plan(&master, &slave, RATE).is_none()); + assert!(sync(&master, &slave, RATE).is_none()); + assert!(sync_tempo_only(&master, &slave).is_none()); + assert!(phase_error(&master, &slave, RATE).is_none()); + } + + #[test] + fn nur_tempo_springt_nicht() { + let (_a, master) = deck(128.0, 0, 30.0); + let (_b, slave) = deck(124.0, 0, 30.0); + + slave.seek_frames(12_345); + let vorher = slave.position_frames(); + + let tempo = sync_tempo_only(&master, &slave).expect("kein Tempo"); + + assert!((tempo - 128.0 / 124.0).abs() < 1e-6); + assert_eq!( + slave.position_frames(), + vorher, + "sync_tempo_only hat die Position verändert" + ); + } +} diff --git a/crates/library/Cargo.toml b/crates/library/Cargo.toml new file mode 100644 index 0000000..ea3f6b4 --- /dev/null +++ b/crates/library/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "library" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +quick-xml = "0.41.0" +rusqlite = { version = "0.40.2", features = ["bundled"] } +thiserror = "2.0.20" diff --git a/crates/library/src/db.rs b/crates/library/src/db.rs new file mode 100644 index 0000000..d583853 --- /dev/null +++ b/crates/library/src/db.rs @@ -0,0 +1,599 @@ +//! SQLite-Ablage der Sammlung. +//! +//! Eine Datei, kein Server. Für eine DJ-Library ist das genau richtig: Sie liegt +//! neben der Musik, lässt sich kopieren und sichern wie jede andere Datei, und +//! sie ist beim Auflegen sofort da statt nach dem Start eines Dienstes. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension, Row}; + +use crate::error::{LibraryError, Result}; +use crate::model::{CueKind, CueRecord, Query, Source, TrackRecord}; + +/// Version des Schemas. Bei Änderungen hochzählen und eine Migration ergänzen. +const SCHEMA_VERSION: i32 = 1; + +pub struct Library { + conn: Connection, +} + +impl Library { + pub fn open(path: impl AsRef) -> Result { + let library = Library { + conn: Connection::open(path)?, + }; + library.prepare()?; + Ok(library) + } + + pub fn open_in_memory() -> Result { + let library = Library { + conn: Connection::open_in_memory()?, + }; + library.prepare()?; + Ok(library) + } + + fn prepare(&self) -> Result<()> { + self.conn.pragma_update(None, "foreign_keys", "ON")?; + self.conn.pragma_update(None, "journal_mode", "WAL")?; + + let version: i32 = self + .conn + .pragma_query_value(None, "user_version", |row| row.get(0))?; + + if version < 1 { + self.migrate_to_v1()?; + } + + self.conn + .pragma_update(None, "user_version", SCHEMA_VERSION)?; + Ok(()) + } + + fn migrate_to_v1(&self) -> Result<()> { + self.conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + fingerprint TEXT, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + duration_secs REAL, + bpm REAL, + beat_anchor_ms REAL, + musical_key TEXT, + license TEXT, + attribution TEXT, + source TEXT NOT NULL DEFAULT 'file', + added_at INTEGER NOT NULL + ); + + -- Kein UNIQUE: Derselbe Klang darf zweimal im Dateisystem liegen, + -- und das ist kein Fehler, den ein Constraint erzwingen sollte. + CREATE INDEX IF NOT EXISTS idx_tracks_fingerprint ON tracks(fingerprint); + CREATE INDEX IF NOT EXISTS idx_tracks_bpm ON tracks(bpm); + + CREATE TABLE IF NOT EXISTS cues ( + id INTEGER PRIMARY KEY, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + hotcue INTEGER, + position_ms REAL NOT NULL, + name TEXT, + kind TEXT NOT NULL DEFAULT 'cue' + ); + CREATE INDEX IF NOT EXISTS idx_cues_track ON cues(track_id); + + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS playlist_items ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, position) + ); + "#, + )?; + Ok(()) + } + + /// Legt einen Track an oder aktualisiert ihn. Schlüssel ist der Pfad. + /// + /// Felder, die im übergebenen Datensatz `None` sind, überschreiben + /// vorhandene Werte **nicht** — sonst löschte ein erneuter Ordner-Scan die + /// mühsam erarbeitete Analyse wieder weg. + pub fn upsert_track(&self, track: &TrackRecord) -> Result { + let now = unix_now(); + + self.conn.execute( + r#" + INSERT INTO tracks + (path, fingerprint, title, artist, album, genre, duration_secs, + bpm, beat_anchor_ms, musical_key, license, attribution, source, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + ON CONFLICT(path) DO UPDATE SET + fingerprint = COALESCE(excluded.fingerprint, tracks.fingerprint), + title = COALESCE(excluded.title, tracks.title), + artist = COALESCE(excluded.artist, tracks.artist), + album = COALESCE(excluded.album, tracks.album), + genre = COALESCE(excluded.genre, tracks.genre), + duration_secs = COALESCE(excluded.duration_secs, tracks.duration_secs), + bpm = COALESCE(excluded.bpm, tracks.bpm), + beat_anchor_ms = COALESCE(excluded.beat_anchor_ms, tracks.beat_anchor_ms), + musical_key = COALESCE(excluded.musical_key, tracks.musical_key), + license = COALESCE(excluded.license, tracks.license), + attribution = COALESCE(excluded.attribution, tracks.attribution), + source = excluded.source + "#, + params![ + track.path, + track.fingerprint, + track.title, + track.artist, + track.album, + track.genre, + track.duration_secs, + track.bpm, + track.beat_anchor_ms, + track.musical_key, + track.license, + track.attribution, + track.source.as_str(), + now, + ], + )?; + + self.track_id_by_path(&track.path)? + .ok_or_else(|| LibraryError::NotFound(track.path.clone())) + } + + pub fn track_id_by_path(&self, path: &str) -> Result> { + Ok(self + .conn + .query_row( + "SELECT id FROM tracks WHERE path = ?1", + params![path], + |r| r.get(0), + ) + .optional()?) + } + + pub fn track(&self, id: i64) -> Result> { + Ok(self + .conn + .query_row( + &format!("{SELECT_TRACK} WHERE id = ?1"), + params![id], + track_from_row, + ) + .optional()?) + } + + pub fn track_by_path(&self, path: &str) -> Result> { + Ok(self + .conn + .query_row( + &format!("{SELECT_TRACK} WHERE path = ?1"), + params![path], + track_from_row, + ) + .optional()?) + } + + /// Alle Tracks mit diesem Inhalts-Hash — mehr als einer heißt: Dublette. + pub fn tracks_by_fingerprint(&self, fingerprint: &str) -> Result> { + let mut stmt = self + .conn + .prepare(&format!("{SELECT_TRACK} WHERE fingerprint = ?1"))?; + let rows = stmt.query_map(params![fingerprint], track_from_row)?; + Ok(rows.collect::>>()?) + } + + pub fn track_count(&self) -> Result { + Ok(self + .conn + .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get::<_, i64>(0))? + as u64) + } + + pub fn search(&self, query: &Query) -> Result> { + let mut sql = String::from(SELECT_TRACK); + let mut wheres: Vec = Vec::new(); + let mut args: Vec> = Vec::new(); + + if let Some(text) = query.text.as_ref().filter(|t| !t.trim().is_empty()) { + // Drei Spalten, derselbe Wert — SQLite nummeriert Platzhalter, also + // dreimal binden statt einmal wiederverwenden. + let n = args.len(); + wheres.push(format!( + "(title LIKE ?{} OR artist LIKE ?{} OR album LIKE ?{})", + n + 1, + n + 2, + n + 3 + )); + + let muster = format!("%{text}%"); + args.push(Box::new(muster.clone())); + args.push(Box::new(muster.clone())); + args.push(Box::new(muster)); + } + + if let Some(min) = query.bpm_min { + wheres.push(format!("bpm >= ?{}", args.len() + 1)); + args.push(Box::new(min)); + } + if let Some(max) = query.bpm_max { + wheres.push(format!("bpm <= ?{}", args.len() + 1)); + args.push(Box::new(max)); + } + if let Some(genre) = query.genre.as_ref() { + wheres.push(format!("genre = ?{}", args.len() + 1)); + args.push(Box::new(genre.clone())); + } + + if !wheres.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&wheres.join(" AND ")); + } + sql.push_str(" ORDER BY artist, title"); + if let Some(limit) = query.limit { + sql.push_str(&format!(" LIMIT {limit}")); + } + + let mut stmt = self.conn.prepare(&sql)?; + let refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|a| a.as_ref()).collect(); + let rows = stmt.query_map(refs.as_slice(), track_from_row)?; + Ok(rows.collect::>>()?) + } + + /// Tracks, deren Rechteangaben fehlen, obwohl sie welche bräuchten. + pub fn tracks_missing_attribution(&self) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "{SELECT_TRACK} WHERE source = 'sample' AND \ + (license IS NULL OR TRIM(license) = '' OR \ + attribution IS NULL OR TRIM(attribution) = '')" + ))?; + let rows = stmt.query_map([], track_from_row)?; + Ok(rows.collect::>>()?) + } + + /// Ersetzt alle Marker eines Tracks. + pub fn replace_cues(&self, track_id: i64, cues: &[CueRecord]) -> Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute("DELETE FROM cues WHERE track_id = ?1", params![track_id])?; + + for cue in cues { + tx.execute( + "INSERT INTO cues (track_id, hotcue, position_ms, name, kind) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + track_id, + cue.hotcue.map(|h| h as i64), + cue.position_ms, + cue.name, + cue.kind.as_str(), + ], + )?; + } + + tx.commit()?; + Ok(()) + } + + pub fn cues(&self, track_id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, track_id, hotcue, position_ms, name, kind FROM cues \ + WHERE track_id = ?1 ORDER BY position_ms", + )?; + let rows = stmt.query_map(params![track_id], |row| { + Ok(CueRecord { + id: row.get(0)?, + track_id: row.get(1)?, + hotcue: row.get::<_, Option>(2)?.map(|v| v as u8), + position_ms: row.get(3)?, + name: row.get(4)?, + kind: CueKind::parse(&row.get::<_, String>(5)?), + }) + })?; + Ok(rows.collect::>>()?) + } + + pub fn create_playlist(&self, name: &str) -> Result { + self.conn.execute( + "INSERT OR IGNORE INTO playlists (name, created_at) VALUES (?1, ?2)", + params![name, unix_now()], + )?; + self.conn + .query_row( + "SELECT id FROM playlists WHERE name = ?1", + params![name], + |r| r.get(0), + ) + .map_err(Into::into) + } + + pub fn playlists(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT id, name FROM playlists ORDER BY name")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::>>()?) + } + + /// Hängt einen Track ans Ende einer Playlist. + pub fn add_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> { + let next: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_items WHERE playlist_id = ?1", + params![playlist_id], + |r| r.get(0), + )?; + + self.conn.execute( + "INSERT INTO playlist_items (playlist_id, track_id, position) VALUES (?1, ?2, ?3)", + params![playlist_id, track_id, next], + )?; + Ok(()) + } + + pub fn playlist_tracks(&self, playlist_id: i64) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "{SELECT_TRACK_PREFIXED} JOIN playlist_items pi ON pi.track_id = t.id \ + WHERE pi.playlist_id = ?1 ORDER BY pi.position" + ))?; + let rows = stmt.query_map(params![playlist_id], track_from_row)?; + Ok(rows.collect::>>()?) + } +} + +const SELECT_TRACK: &str = "SELECT id, path, fingerprint, title, artist, album, genre, \ + duration_secs, bpm, beat_anchor_ms, musical_key, \ + license, attribution, source FROM tracks"; + +const SELECT_TRACK_PREFIXED: &str = "SELECT t.id, t.path, t.fingerprint, t.title, t.artist, \ + t.album, t.genre, t.duration_secs, t.bpm, \ + t.beat_anchor_ms, t.musical_key, t.license, \ + t.attribution, t.source FROM tracks t"; + +fn track_from_row(row: &Row) -> rusqlite::Result { + Ok(TrackRecord { + id: row.get(0)?, + path: row.get(1)?, + fingerprint: row.get(2)?, + title: row.get(3)?, + artist: row.get(4)?, + album: row.get(5)?, + genre: row.get(6)?, + duration_secs: row.get(7)?, + bpm: row.get(8)?, + beat_anchor_ms: row.get(9)?, + musical_key: row.get(10)?, + license: row.get(11)?, + attribution: row.get(12)?, + source: Source::parse(&row.get::<_, String>(13)?), + }) +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use audio_core::Beatgrid; + + fn track(path: &str, artist: &str, title: &str, bpm: Option) -> TrackRecord { + let mut t = TrackRecord::from_path(path); + t.artist = Some(artist.into()); + t.title = Some(title.into()); + t.bpm = bpm; + t + } + + #[test] + fn anlegen_und_wiederfinden() { + let lib = Library::open_in_memory().unwrap(); + let id = lib + .upsert_track(&track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0))) + .unwrap(); + + let geladen = lib.track(id).unwrap().expect("Track weg"); + assert_eq!(geladen.artist.as_deref(), Some("Rebekah")); + assert_eq!(geladen.path, "/a.mp3"); + assert_eq!(lib.track_count().unwrap(), 1); + } + + #[test] + fn erneuter_scan_loescht_die_analyse_nicht() { + // Der wichtigste Punkt am Upsert: Ein Ordner-Scan kennt nur Pfad und + // Tags. Würde er die Analyse überschreiben, wäre sie nach jedem Scan + // weg — und Analysieren ist die teure Arbeit. + let lib = Library::open_in_memory().unwrap(); + + let mut voll = track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0)); + voll.set_beatgrid(Some(Beatgrid::new(135.0, 4_410, 1.0)), 44_100); + voll.fingerprint = Some("abc123".into()); + lib.upsert_track(&voll).unwrap(); + + // Zweiter Durchlauf ohne Analysedaten. + let mager = TrackRecord::from_path("/a.mp3"); + lib.upsert_track(&mager).unwrap(); + + let geladen = lib.track_by_path("/a.mp3").unwrap().unwrap(); + assert_eq!(geladen.bpm, Some(135.0), "BPM überschrieben"); + assert_eq!(geladen.fingerprint.as_deref(), Some("abc123")); + assert_eq!(geladen.artist.as_deref(), Some("Rebekah")); + assert_eq!(lib.track_count().unwrap(), 1, "Dublette angelegt"); + } + + #[test] + fn suche_nach_text_trifft_titel_und_kuenstler() { + let lib = Library::open_in_memory().unwrap(); + lib.upsert_track(&track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0))) + .unwrap(); + lib.upsert_track(&track("/b.mp3", "Len Faki", "My Black Sheep", Some(128.0))) + .unwrap(); + + assert_eq!(lib.search(&Query::text("rebekah")).unwrap().len(), 1); + assert_eq!(lib.search(&Query::text("Sheep")).unwrap().len(), 1); + assert_eq!(lib.search(&Query::text("zzz")).unwrap().len(), 0); + assert_eq!(lib.search(&Query::default()).unwrap().len(), 2); + } + + #[test] + fn suche_nach_mixbarem_tempo() { + let lib = Library::open_in_memory().unwrap(); + // 135 BPM läge bei ±6 % noch im Fenster — für diesen Test muss der + // Ausreißer deutlich außerhalb liegen. + lib.upsert_track(&track("/a.mp3", "A", "zu schnell", Some(150.0))) + .unwrap(); + lib.upsert_track(&track("/b.mp3", "B", "passend", Some(129.0))) + .unwrap(); + lib.upsert_track(&track("/c.mp3", "C", "ohne Tempo", None)) + .unwrap(); + + let treffer = lib.search(&Query::mixable_with(128.0, 0.06)).unwrap(); + let titel: Vec<_> = treffer.iter().filter_map(|t| t.title.as_deref()).collect(); + + assert_eq!(titel, vec!["passend"], "unerwartete Treffer: {titel:?}"); + } + + #[test] + fn dubletten_werden_ueber_den_hash_gefunden() { + let lib = Library::open_in_memory().unwrap(); + + let mut a = track("/ordner1/x.mp3", "A", "X", Some(128.0)); + a.fingerprint = Some("gleich".into()); + let mut b = track("/ordner2/x.mp3", "A", "X", Some(128.0)); + b.fingerprint = Some("gleich".into()); + + lib.upsert_track(&a).unwrap(); + lib.upsert_track(&b).unwrap(); + + let dubletten = lib.tracks_by_fingerprint("gleich").unwrap(); + assert_eq!(dubletten.len(), 2, "derselbe Klang darf zweimal liegen"); + } + + #[test] + fn fehlende_rechteangaben_lassen_sich_auflisten() { + let lib = Library::open_in_memory().unwrap(); + + let mut ohne = TrackRecord::from_path("/kick.wav"); + ohne.source = Source::Sample; + lib.upsert_track(&ohne).unwrap(); + + let mut mit = TrackRecord::from_path("/hat.wav"); + mit.source = Source::Sample; + mit.license = Some("CC BY 4.0".into()); + mit.attribution = Some("jemand".into()); + lib.upsert_track(&mit).unwrap(); + + lib.upsert_track(&TrackRecord::from_path("/eigen.mp3")) + .unwrap(); + + let luecken = lib.tracks_missing_attribution().unwrap(); + assert_eq!(luecken.len(), 1); + assert_eq!(luecken[0].path, "/kick.wav"); + } + + #[test] + fn cues_werden_ersetzt_nicht_angehaeuft() { + let lib = Library::open_in_memory().unwrap(); + let id = lib + .upsert_track(&track("/a.mp3", "A", "X", Some(128.0))) + .unwrap(); + + let cue = |hot: u8, ms: f64| CueRecord { + id: None, + track_id: id, + hotcue: Some(hot), + position_ms: ms, + name: None, + kind: CueKind::Cue, + }; + + lib.replace_cues(id, &[cue(0, 1_000.0), cue(1, 2_000.0)]) + .unwrap(); + assert_eq!(lib.cues(id).unwrap().len(), 2); + + lib.replace_cues(id, &[cue(0, 500.0)]).unwrap(); + let cues = lib.cues(id).unwrap(); + assert_eq!(cues.len(), 1, "alte Cues blieben stehen"); + assert_eq!(cues[0].position_ms, 500.0); + assert_eq!(cues[0].frame(48_000), 24_000); + } + + #[test] + fn playlists_behalten_ihre_reihenfolge() { + let lib = Library::open_in_memory().unwrap(); + let a = lib + .upsert_track(&track("/a.mp3", "A", "erst", None)) + .unwrap(); + let b = lib + .upsert_track(&track("/b.mp3", "B", "dann", None)) + .unwrap(); + let c = lib + .upsert_track(&track("/c.mp3", "C", "zuletzt", None)) + .unwrap(); + + let pl = lib.create_playlist("Warmup").unwrap(); + lib.add_to_playlist(pl, c).unwrap(); + lib.add_to_playlist(pl, a).unwrap(); + lib.add_to_playlist(pl, b).unwrap(); + + let titel: Vec<_> = lib + .playlist_tracks(pl) + .unwrap() + .into_iter() + .filter_map(|t| t.title) + .collect(); + + assert_eq!(titel, vec!["zuletzt", "erst", "dann"]); + assert_eq!(lib.playlists().unwrap().len(), 1); + } + + #[test] + fn playlist_mit_gleichem_namen_wird_nicht_verdoppelt() { + let lib = Library::open_in_memory().unwrap(); + let a = lib.create_playlist("Set").unwrap(); + let b = lib.create_playlist("Set").unwrap(); + + assert_eq!(a, b); + assert_eq!(lib.playlists().unwrap().len(), 1); + } + + #[test] + fn geloeschte_tracks_nehmen_ihre_cues_mit() { + let lib = Library::open_in_memory().unwrap(); + let id = lib.upsert_track(&track("/a.mp3", "A", "X", None)).unwrap(); + lib.replace_cues( + id, + &[CueRecord { + id: None, + track_id: id, + hotcue: Some(0), + position_ms: 100.0, + name: None, + kind: CueKind::Cue, + }], + ) + .unwrap(); + + lib.conn + .execute("DELETE FROM tracks WHERE id = ?1", params![id]) + .unwrap(); + + assert!(lib.cues(id).unwrap().is_empty(), "verwaiste Cues geblieben"); + } +} diff --git a/crates/library/src/error.rs b/crates/library/src/error.rs new file mode 100644 index 0000000..589a8b8 --- /dev/null +++ b/crates/library/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LibraryError { + #[error("Datenbankfehler: {0}")] + Db(#[from] rusqlite::Error), + + #[error("Datei konnte nicht gelesen werden: {0}")] + Io(#[from] std::io::Error), + + #[error("XML konnte nicht gelesen werden: {0}")] + Xml(#[from] quick_xml::Error), + + #[error("nicht gefunden: {0}")] + NotFound(String), + + #[error("unerwartetes Format: {0}")] + Format(String), +} + +pub type Result = std::result::Result; diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs new file mode 100644 index 0000000..3ce5388 --- /dev/null +++ b/crates/library/src/lib.rs @@ -0,0 +1,26 @@ +//! Die Sammlung: Tracks, Marker, Playlists — und der Weg herein aus Traktor. +//! +//! Getrennt von Engine und Analyse, weil hier keine Echtzeitregeln gelten. +//! Datenbankzugriffe dürfen blockieren; im Audio-Callback hat davon nichts +//! etwas zu suchen. +//! +//! Zwei Entwurfsentscheidungen prägen alles Weitere: +//! +//! **Positionen stehen in Millisekunden**, nicht in Frames. Die Library ist die +//! Tauschschicht zwischen Analyse, Traktor-Import und Deck. Ein Frame-Wert ohne +//! zugehörige Samplerate ist mehrdeutig, und ein resampelter Track hätte andere +//! Zahlen für denselben Zeitpunkt. +//! +//! **Lizenz und Urheber stehen von der ersten Migration an in der Tabelle.** +//! CC BY verlangt Namensnennung auch nicht-kommerziell, und die Herkunft von +//! tausend Samples lässt sich nachträglich nicht rekonstruieren. + +pub mod db; +pub mod error; +pub mod model; +pub mod traktor; + +pub use db::Library; +pub use error::{LibraryError, Result}; +pub use model::{CueKind, CueRecord, Query, Source, TrackRecord}; +pub use traktor::{import_nml, ImportReport}; diff --git a/crates/library/src/model.rs b/crates/library/src/model.rs new file mode 100644 index 0000000..89190db --- /dev/null +++ b/crates/library/src/model.rs @@ -0,0 +1,315 @@ +//! Datenmodell der Library. + +use audio_core::Beatgrid; + +/// Woher ein Track stammt. Bestimmt mit, welche Rechtepflichten daran hängen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// Eigene Datei auf der Platte. + File, + /// Aus einer Sample-Datenbank geladen — Lizenz und Urheber sind Pflichtfelder. + Sample, + /// Von einem Generierungsdienst erzeugt. + Generated, +} + +impl Source { + pub fn as_str(&self) -> &'static str { + match self { + Source::File => "file", + Source::Sample => "sample", + Source::Generated => "generated", + } + } + + pub fn parse(raw: &str) -> Source { + match raw { + "sample" => Source::Sample, + "generated" => Source::Generated, + _ => Source::File, + } + } +} + +/// Ein Track in der Sammlung. +#[derive(Debug, Clone, PartialEq)] +pub struct TrackRecord { + pub id: Option, + /// Inhalts-Hash aus der Analyse. `None`, solange nicht analysiert. + pub fingerprint: Option, + pub path: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub duration_secs: Option, + pub bpm: Option, + /// Lage des ersten Beats in Millisekunden. + /// + /// Millisekunden statt Frames, weil die Library die Tauschschicht ist: + /// Traktor speichert so, ein resampelter Track hätte andere Frame-Zahlen, + /// und ein Wert ohne zugehörige Samplerate wäre mehrdeutig. + pub beat_anchor_ms: Option, + /// Tonart als Text, wie sie in der Quelle stand. + pub musical_key: Option, + + /// Lizenz und Urheber. + /// + /// **Pflichtfelder für alles aus Sample-Datenbanken.** CC BY verlangt + /// Namensnennung auch bei nicht-kommerzieller Nutzung, und nachträglich + /// lässt sich die Herkunft nicht mehr rekonstruieren, wenn erst tausend + /// Dateien ohne sie im Ordner liegen. Deshalb stehen sie hier von der + /// ersten Migration an und nicht in einer späteren. + pub license: Option, + pub attribution: Option, + + pub source: Source, +} + +impl TrackRecord { + /// Neuer Eintrag aus einem Dateipfad, ohne Metadaten. + pub fn from_path(path: impl Into) -> Self { + TrackRecord { + id: None, + fingerprint: None, + path: path.into(), + title: None, + artist: None, + album: None, + genre: None, + duration_secs: None, + bpm: None, + beat_anchor_ms: None, + musical_key: None, + license: None, + attribution: None, + source: Source::File, + } + } + + pub fn beatgrid(&self, sample_rate: u32) -> Option { + let bpm = self.bpm?; + let anchor = ms_to_frames(self.beat_anchor_ms.unwrap_or(0.0), sample_rate); + let grid = Beatgrid::new(bpm, anchor, 1.0); + grid.is_usable().then_some(grid) + } + + pub fn set_beatgrid(&mut self, grid: Option, sample_rate: u32) { + match grid { + Some(g) => { + self.bpm = Some(g.bpm); + self.beat_anchor_ms = Some(frames_to_ms(g.anchor_frames, sample_rate)); + } + None => { + self.bpm = None; + self.beat_anchor_ms = None; + } + } + } + + /// Ob die Rechtelage vollständig ist. + /// + /// Für eigene Dateien belanglos, für Samples entscheidend — und deshalb + /// abfragbar, statt nur dokumentiert zu sein. + pub fn attribution_complete(&self) -> bool { + match self.source { + Source::Sample => { + self.license.as_ref().is_some_and(|s| !s.trim().is_empty()) + && self + .attribution + .as_ref() + .is_some_and(|s| !s.trim().is_empty()) + } + _ => true, + } + } +} + +/// Ein Hot Cue oder Grid-Marker. +#[derive(Debug, Clone, PartialEq)] +pub struct CueRecord { + pub id: Option, + pub track_id: i64, + /// Hot-Cue-Nummer, `None` bei Markern ohne Taste (etwa dem Grid-Anker). + pub hotcue: Option, + /// Position in Millisekunden — siehe [`TrackRecord::beat_anchor_ms`]. + pub position_ms: f64, + pub name: Option, + pub kind: CueKind, +} + +impl CueRecord { + pub fn frame(&self, sample_rate: u32) -> u64 { + ms_to_frames(self.position_ms, sample_rate) + } +} + +/// Millisekunden in Sample-Frames. Negatives wird auf null gezogen. +pub fn ms_to_frames(ms: f64, sample_rate: u32) -> u64 { + (ms.max(0.0) / 1_000.0 * sample_rate as f64).round() as u64 +} + +pub fn frames_to_ms(frames: u64, sample_rate: u32) -> f64 { + frames as f64 * 1_000.0 / sample_rate as f64 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CueKind { + Cue, + Grid, + Loop, + FadeIn, + FadeOut, + Load, +} + +impl CueKind { + pub fn as_str(&self) -> &'static str { + match self { + CueKind::Cue => "cue", + CueKind::Grid => "grid", + CueKind::Loop => "loop", + CueKind::FadeIn => "fade_in", + CueKind::FadeOut => "fade_out", + CueKind::Load => "load", + } + } + + pub fn parse(raw: &str) -> CueKind { + match raw { + "grid" => CueKind::Grid, + "loop" => CueKind::Loop, + "fade_in" => CueKind::FadeIn, + "fade_out" => CueKind::FadeOut, + "load" => CueKind::Load, + _ => CueKind::Cue, + } + } +} + +/// Suchanfrage an die Library. +#[derive(Debug, Clone, Default)] +pub struct Query { + /// Freitext über Titel, Künstler und Album. + pub text: Option, + pub bpm_min: Option, + pub bpm_max: Option, + pub genre: Option, + pub limit: Option, +} + +impl Query { + pub fn text(query: impl Into) -> Self { + Query { + text: Some(query.into()), + ..Default::default() + } + } + + /// Tracks, die tempomäßig zu `bpm` passen — Standard sind ±6 %, der + /// Bereich, den ein Pitchfader ohne hörbare Verfärbung hergibt. + pub fn mixable_with(bpm: f32, toleranz: f32) -> Self { + Query { + bpm_min: Some(bpm * (1.0 - toleranz)), + bpm_max: Some(bpm * (1.0 + toleranz)), + ..Default::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 44_100; + + #[test] + fn beatgrid_geht_hin_und_zurueck() { + let mut track = TrackRecord::from_path("/a.mp3"); + assert!(track.beatgrid(RATE).is_none()); + + track.set_beatgrid(Some(Beatgrid::new(128.0, 4_410, 1.0)), RATE); + assert_eq!(track.beat_anchor_ms, Some(100.0)); + + let grid = track.beatgrid(RATE).expect("kein Grid"); + assert!((grid.bpm - 128.0).abs() < 1e-6); + assert_eq!(grid.anchor_frames, 4_410); + + track.set_beatgrid(None, RATE); + assert!(track.beatgrid(RATE).is_none()); + } + + #[test] + fn millisekunden_ueberstehen_einen_ratenwechsel() { + // Derselbe Zeitpunkt, zwei Samplerates — genau dafür ist ms da. + let mut track = TrackRecord::from_path("/a.mp3"); + track.set_beatgrid(Some(Beatgrid::new(120.0, 4_410, 1.0)), 44_100); + + let bei_48k = track.beatgrid(48_000).unwrap(); + assert_eq!(bei_48k.anchor_frames, 4_800); + } + + #[test] + fn negative_positionen_werden_abgefangen() { + assert_eq!(ms_to_frames(-5.0, 48_000), 0); + assert_eq!(ms_to_frames(0.0, 48_000), 0); + assert_eq!(ms_to_frames(1_000.0, 48_000), 48_000); + } + + #[test] + fn samples_brauchen_lizenz_und_urheber() { + let mut track = TrackRecord::from_path("/kick.wav"); + track.source = Source::Sample; + assert!( + !track.attribution_complete(), + "Sample ohne Rechte durchgelassen" + ); + + track.license = Some("CC BY 4.0".into()); + assert!(!track.attribution_complete(), "Urheber fehlt noch"); + + track.attribution = Some("jemand auf Freesound".into()); + assert!(track.attribution_complete()); + } + + #[test] + fn leerzeichen_zaehlen_nicht_als_angabe() { + let mut track = TrackRecord::from_path("/kick.wav"); + track.source = Source::Sample; + track.license = Some(" ".into()); + track.attribution = Some("".into()); + + assert!(!track.attribution_complete()); + } + + #[test] + fn eigene_dateien_brauchen_keine_angaben() { + let track = TrackRecord::from_path("/eigener-track.mp3"); + assert_eq!(track.source, Source::File); + assert!(track.attribution_complete()); + } + + #[test] + fn mixbarer_bereich_umfasst_das_tempo() { + let q = Query::mixable_with(128.0, 0.06); + assert!((q.bpm_min.unwrap() - 120.32).abs() < 0.01); + assert!((q.bpm_max.unwrap() - 135.68).abs() < 0.01); + } + + #[test] + fn quellen_und_marker_ueberleben_die_textform() { + for s in [Source::File, Source::Sample, Source::Generated] { + assert_eq!(Source::parse(s.as_str()), s); + } + for k in [ + CueKind::Cue, + CueKind::Grid, + CueKind::Loop, + CueKind::FadeIn, + CueKind::FadeOut, + CueKind::Load, + ] { + assert_eq!(CueKind::parse(k.as_str()), k); + } + } +} diff --git a/crates/library/src/traktor.rs b/crates/library/src/traktor.rs new file mode 100644 index 0000000..6310482 --- /dev/null +++ b/crates/library/src/traktor.rs @@ -0,0 +1,421 @@ +//! Import einer Traktor-Sammlung (`collection.nml`). +//! +//! Wer von Traktor kommt, bringt Jahre an Cue-Points und Beatgrids mit. Die neu +//! zu setzen wäre die unangenehmste Art, ein neues Werkzeug zu beginnen — +//! deshalb steht der Import früh und nicht am Ende. +//! +//! Die `.nml` ist XML und enthält **keine Audiodaten**, nur Pfade und +//! Metadaten. Positionen stehen darin in Millisekunden, was zur Ablage in +//! dieser Library passt. +//! +//! ## Was hier ungeprüft ist +//! +//! Das Format ist nicht offiziell dokumentiert. Feldbedeutungen stammen aus +//! öffentlichen Beschreibungen und sind hier **nur gegen selbst geschriebene +//! Beispiele getestet**, nicht gegen eine echte Traktor-Sammlung. Betroffen +//! sind vor allem: +//! +//! - die Zuordnung der `CUE_V2`-Typen (siehe [`cue_kind`]), +//! - die Nummerierung in `MUSICAL_KEY` (siehe [`traktor_key_name`]), +//! - der Umgang mit `VOLUME` unter Windows (siehe [`build_path`]). +//! +//! Der erste Lauf gegen eine echte Sammlung gehört deshalb stichprobenartig +//! nachgesehen, bevor man sich darauf verlässt. + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::{Reader, XmlVersion}; + +use crate::db::Library; +use crate::error::Result; +use crate::model::{CueKind, CueRecord, Source, TrackRecord}; + +/// Was der Import getan hat. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportReport { + pub entries_seen: usize, + pub tracks_imported: usize, + pub cues_imported: usize, + /// Einträge ohne verwertbaren Pfad — die gibt es in echten Sammlungen. + pub skipped_without_location: usize, +} + +/// Ein gelesener Eintrag, noch ohne Datenbank-ID. +#[derive(Debug, Clone, PartialEq)] +pub struct ImportedEntry { + pub track: TrackRecord, + pub cues: Vec, +} + +/// Liest eine `collection.nml` und schreibt sie in die Library. +pub fn import_nml(xml: &str, library: &Library) -> Result { + let entries = parse_nml(xml)?; + let mut report = ImportReport { + entries_seen: entries.len(), + ..Default::default() + }; + + for entry in entries { + if entry.track.path.is_empty() { + report.skipped_without_location += 1; + continue; + } + + let id = library.upsert_track(&entry.track)?; + report.tracks_imported += 1; + + if !entry.cues.is_empty() { + let cues: Vec = entry + .cues + .into_iter() + .map(|mut c| { + c.track_id = id; + c + }) + .collect(); + report.cues_imported += cues.len(); + library.replace_cues(id, &cues)?; + } + } + + Ok(report) +} + +/// Reines Parsen ohne Datenbank — der testbare Teil. +pub fn parse_nml(xml: &str) -> Result> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut entries = Vec::new(); + let mut current: Option = None; + let mut dir = String::new(); + let mut file = String::new(); + let mut volume = String::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) | Event::Empty(e) => { + let name = e.local_name(); + match name.as_ref() { + b"ENTRY" => { + let mut track = TrackRecord::from_path(""); + track.title = attr(&e, b"TITLE"); + track.artist = attr(&e, b"ARTIST"); + current = Some(ImportedEntry { + track, + cues: Vec::new(), + }); + dir.clear(); + file.clear(); + volume.clear(); + } + b"LOCATION" => { + dir = attr(&e, b"DIR").unwrap_or_default(); + file = attr(&e, b"FILE").unwrap_or_default(); + volume = attr(&e, b"VOLUME").unwrap_or_default(); + } + b"ALBUM" => { + if let Some(entry) = current.as_mut() { + entry.track.album = attr(&e, b"TITLE"); + } + } + b"INFO" => { + if let Some(entry) = current.as_mut() { + entry.track.genre = attr(&e, b"GENRE"); + entry.track.musical_key = attr(&e, b"KEY"); + entry.track.duration_secs = attr(&e, b"PLAYTIME_FLOAT") + .and_then(|v| v.parse().ok()) + .or_else(|| { + attr(&e, b"PLAYTIME").and_then(|v| v.parse::().ok()) + }); + } + } + b"TEMPO" => { + if let Some(entry) = current.as_mut() { + entry.track.bpm = attr(&e, b"BPM").and_then(|v| v.parse().ok()); + } + } + b"MUSICAL_KEY" => { + if let Some(entry) = current.as_mut() { + if entry.track.musical_key.is_none() { + entry.track.musical_key = attr(&e, b"VALUE") + .and_then(|v| v.parse::().ok()) + .and_then(traktor_key_name) + .map(str::to_string); + } + } + } + b"CUE_V2" => { + if let Some(entry) = current.as_mut() { + if let Some(cue) = read_cue(&e) { + // Der Grid-Marker ist kein Cue, sondern der + // Beat-Anker — er gehört an den Track. + if cue.kind == CueKind::Grid { + entry.track.beat_anchor_ms = Some(cue.position_ms); + } + entry.cues.push(cue); + } + } + } + _ => {} + } + } + Event::End(e) if e.local_name().as_ref() == b"ENTRY" => { + if let Some(mut entry) = current.take() { + entry.track.path = build_path(&volume, &dir, &file); + entry.track.source = Source::File; + entries.push(entry); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + Ok(entries) +} + +fn attr(element: &BytesStart, name: &[u8]) -> Option { + element + .attributes() + .flatten() + .find(|a| a.key.local_name().as_ref() == name) + .and_then(|a| a.normalized_value(XmlVersion::Explicit1_0).ok()) + .map(|v| v.into_owned()) + .filter(|v| !v.is_empty()) +} + +fn read_cue(element: &BytesStart) -> Option { + let start: f64 = attr(element, b"START")?.parse().ok()?; + let typ: i32 = attr(element, b"TYPE") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let hotcue: i32 = attr(element, b"HOTCUE") + .and_then(|v| v.parse().ok()) + .unwrap_or(-1); + + Some(CueRecord { + id: None, + track_id: 0, + hotcue: (0..=127).contains(&hotcue).then_some(hotcue as u8), + position_ms: start, + name: attr(element, b"NAME"), + kind: cue_kind(typ), + }) +} + +/// Typnummern der `CUE_V2`-Marker. +/// +/// Aus öffentlichen Beschreibungen des Formats; gegen eine echte Sammlung +/// ungeprüft. Unbekannte Werte landen als gewöhnlicher Cue, was schlimmstenfalls +/// einen Marker zu viel auf einer Taste bedeutet — nie einen Datenverlust. +fn cue_kind(typ: i32) -> CueKind { + match typ { + 1 => CueKind::FadeIn, + 2 => CueKind::FadeOut, + 3 => CueKind::Load, + 4 => CueKind::Grid, + 5 => CueKind::Loop, + _ => CueKind::Cue, + } +} + +/// Tonartnummern aus `MUSICAL_KEY`. +/// +/// 0–11 Dur aufsteigend ab C, 12–23 Moll ebenso. Aus öffentlichen +/// Beschreibungen; gegen eine echte Sammlung ungeprüft. Steht in `INFO@KEY` ein +/// Text, wird der bevorzugt — der stammt direkt aus Traktor. +fn traktor_key_name(value: u8) -> Option<&'static str> { + const DUR: [&str; 12] = [ + "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B", + ]; + const MOLL: [&str; 12] = [ + "Cm", "Dbm", "Dm", "Ebm", "Em", "Fm", "Gbm", "Gm", "Abm", "Am", "Bbm", "Bm", + ]; + + match value { + 0..=11 => Some(DUR[value as usize]), + 12..=23 => Some(MOLL[(value - 12) as usize]), + _ => None, + } +} + +/// Baut den Dateipfad aus `VOLUME`, `DIR` und `FILE`. +/// +/// Traktor trennt Verzeichnisse mit `/:` statt mit `/`. `VOLUME` ist unter +/// Windows der Laufwerksbuchstabe (`C:`), unter macOS ein Datenträgername — +/// deshalb wird nur übernommen, was auf einen Doppelpunkt endet. Ein +/// macOS-Datenträgername ergäbe sonst einen Pfad, den es nicht gibt. +fn build_path(volume: &str, dir: &str, file: &str) -> String { + if file.is_empty() { + return String::new(); + } + + let mut pfad = dir.replace("/:", "/"); + if !pfad.ends_with('/') && !pfad.is_empty() { + pfad.push('/'); + } + pfad.push_str(file); + + if volume.ends_with(':') { + format!("{volume}{pfad}") + } else { + pfad + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BEISPIEL: &str = r#" + + + + + + + + + + + + + + +"#; + + #[test] + fn liest_metadaten_und_tempo() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries.len(), 2); + + let t = &entries[0].track; + assert_eq!(t.title.as_deref(), Some("Fear Paralysis")); + assert_eq!(t.artist.as_deref(), Some("Rebekah")); + assert_eq!(t.album.as_deref(), Some("Fear Paralysis EP")); + assert_eq!(t.genre.as_deref(), Some("Techno")); + assert_eq!(t.musical_key.as_deref(), Some("Am")); + assert_eq!(t.bpm, Some(135.0)); + assert_eq!(t.duration_secs, Some(384.0)); + } + + #[test] + fn baut_den_pfad_aus_traktors_trennzeichen() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries[0].track.path, "/Users/felix/Music/fear.mp3"); + } + + #[test] + fn windows_laufwerk_kommt_davor_ein_mac_datentraeger_nicht() { + assert_eq!( + build_path("C:", "/:Musik/:Techno/:", "a.mp3"), + "C:/Musik/Techno/a.mp3" + ); + assert_eq!( + build_path("Macintosh HD", "/:Musik/:", "a.mp3"), + "/Musik/a.mp3" + ); + assert_eq!(build_path("C:", "/:Musik/:", ""), ""); + } + + #[test] + fn der_grid_marker_wird_zum_beat_anker() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries[0].track.beat_anchor_ms, Some(122.5)); + + let grid = entries[0] + .track + .beatgrid(44_100) + .expect("kein Beatgrid aufgebaut"); + assert!((grid.bpm - 135.0).abs() < 1e-4); + assert_eq!(grid.anchor_frames, 5_402); + } + + #[test] + fn hot_cues_behalten_nummer_und_position() { + let entries = parse_nml(BEISPIEL).unwrap(); + let cues = &entries[0].cues; + assert_eq!(cues.len(), 2); + + let grid = cues.iter().find(|c| c.kind == CueKind::Grid).unwrap(); + assert_eq!(grid.hotcue, None, "der Grid-Marker liegt auf keiner Taste"); + + let drop = cues.iter().find(|c| c.kind == CueKind::Cue).unwrap(); + assert_eq!(drop.hotcue, Some(1)); + assert_eq!(drop.position_ms, 64_000.25); + assert_eq!(drop.name.as_deref(), Some("Drop")); + assert_eq!(drop.frame(44_100), 2_822_411); + } + + #[test] + fn eintraege_ohne_pfad_werden_gezaehlt_nicht_verschluckt() { + let lib = Library::open_in_memory().unwrap(); + let report = import_nml(BEISPIEL, &lib).unwrap(); + + assert_eq!(report.entries_seen, 2); + assert_eq!(report.tracks_imported, 1); + assert_eq!(report.skipped_without_location, 1); + assert_eq!(report.cues_imported, 2); + assert_eq!(lib.track_count().unwrap(), 1); + } + + #[test] + fn import_landet_vollstaendig_in_der_datenbank() { + let lib = Library::open_in_memory().unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + + let t = lib + .track_by_path("/Users/felix/Music/fear.mp3") + .unwrap() + .expect("Track nicht gespeichert"); + assert_eq!(t.bpm, Some(135.0)); + assert_eq!(t.beat_anchor_ms, Some(122.5)); + + let cues = lib.cues(t.id.unwrap()).unwrap(); + assert_eq!(cues.len(), 2); + assert_eq!(cues[0].position_ms, 122.5, "nicht nach Position sortiert"); + } + + #[test] + fn zweimal_importieren_verdoppelt_nichts() { + let lib = Library::open_in_memory().unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + + assert_eq!(lib.track_count().unwrap(), 1); + + let t = lib + .track_by_path("/Users/felix/Music/fear.mp3") + .unwrap() + .unwrap(); + assert_eq!(lib.cues(t.id.unwrap()).unwrap().len(), 2, "Cues verdoppelt"); + } + + #[test] + fn numerische_tonart_wird_nur_ohne_textangabe_genutzt() { + let xml = r#" + + + "#; + + let entries = parse_nml(xml).unwrap(); + assert_eq!(entries[0].track.musical_key.as_deref(), Some("Am")); + + assert_eq!(traktor_key_name(0), Some("C")); + assert_eq!(traktor_key_name(12), Some("Cm")); + assert_eq!(traktor_key_name(99), None); + } + + #[test] + fn kaputtes_xml_wird_gemeldet_statt_zu_paniken() { + assert!(parse_nml(""#).unwrap(); + assert!(entries.is_empty()); + } +} diff --git a/crates/musik-cli/Cargo.toml b/crates/musik-cli/Cargo.toml index b1989da..eb73122 100644 --- a/crates/musik-cli/Cargo.toml +++ b/crates/musik-cli/Cargo.toml @@ -8,3 +8,4 @@ analysis = { version = "0.1.0", path = "../analysis" } anyhow = "1.0.104" audio-core = { version = "0.1.0", path = "../audio-core" } audio-engine = { version = "0.1.0", path = "../audio-engine" } +library = { version = "0.1.0", path = "../library" } diff --git a/crates/musik-cli/src/bin/musik-lib.rs b/crates/musik-cli/src/bin/musik-lib.rs new file mode 100644 index 0000000..0bf5a14 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-lib.rs @@ -0,0 +1,219 @@ +//! Die Sammlung von der Kommandozeile: einlesen, importieren, durchsuchen. +//! +//! Führt Analyse und Library zusammen. Beim Einlesen wird jede Datei dekodiert, +//! analysiert und mit Tempo, Beatgrid und Inhalts-Hash abgelegt — der Hash +//! sorgt dafür, dass ein zweiter Lauf nicht noch einmal rechnet. +//! +//! Braucht **kein** Audiogerät. + +use std::path::{Path, PathBuf}; + +use analysis::Store; +use anyhow::{Context, Result, bail}; +use audio_core::Track; +use library::{Library, Query, TrackRecord, import_nml}; + +const AUDIO_EXT: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "oga"]; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let mut db = PathBuf::from("musik.db"); + let mut cache = PathBuf::from(".musik-analyse"); + let mut befehl: Option = None; + let mut rest: Vec = Vec::new(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--db" => db = PathBuf::from(args.next().context("--db braucht einen Pfad")?), + "--cache" => cache = PathBuf::from(args.next().context("--cache braucht einen Pfad")?), + "-h" | "--help" => { + hilfe(); + return Ok(()); + } + other if befehl.is_none() => befehl = Some(other.to_string()), + other => rest.push(other.to_string()), + } + } + + let Some(befehl) = befehl else { + hilfe(); + std::process::exit(2); + }; + + let lib = Library::open(&db).with_context(|| format!("{} nicht nutzbar", db.display()))?; + println!("Sammlung: {} ({} Tracks)", db.display(), lib.track_count()?); + + match befehl.as_str() { + "scan" => { + let ordner = rest.first().context("scan braucht ein Verzeichnis")?; + scan(&lib, Path::new(ordner), &Store::new(&cache))? + } + "import-traktor" => { + let datei = rest.first().context("import-traktor braucht eine .nml")?; + let xml = + std::fs::read_to_string(datei).with_context(|| format!("{datei} nicht lesbar"))?; + let bericht = import_nml(&xml, &lib)?; + + println!( + "\n{} Einträge gelesen, {} übernommen, {} Marker, {} ohne Pfad übersprungen", + bericht.entries_seen, + bericht.tracks_imported, + bericht.cues_imported, + bericht.skipped_without_location + ); + } + "search" => { + let text = rest.join(" "); + let treffer = lib.search(&Query::text(text))?; + zeige(&treffer); + } + "mixable" => { + let bpm: f32 = rest + .first() + .context("mixable braucht ein Tempo")? + .parse() + .context("Tempo ist keine Zahl")?; + let treffer = lib.search(&Query::mixable_with(bpm, 0.06))?; + println!("\nMischbar mit {bpm:.1} BPM (±6 %):"); + zeige(&treffer); + } + "missing-attribution" => { + let luecken = lib.tracks_missing_attribution()?; + if luecken.is_empty() { + println!("\nAlle Samples haben Lizenz und Urheber."); + } else { + println!( + "\n{} Samples ohne vollständige Rechteangabe:", + luecken.len() + ); + zeige(&luecken); + } + } + other => bail!("unbekannter Befehl: {other}"), + } + + Ok(()) +} + +fn scan(lib: &Library, ordner: &Path, store: &Store) -> Result<()> { + let dateien = sammle_dateien(ordner)?; + println!("{} Audiodateien gefunden\n", dateien.len()); + + let mut neu = 0usize; + let mut fehler = 0usize; + + for pfad in &dateien { + match einlesen(lib, pfad, store) { + Ok(gerechnet) => { + if gerechnet { + neu += 1; + } + } + Err(e) => { + eprintln!(" {}: {e:#}", pfad.display()); + fehler += 1; + } + } + } + + println!( + "\n{} eingelesen, {} neu analysiert, {} Fehler", + dateien.len(), + neu, + fehler + ); + Ok(()) +} + +/// Gibt zurück, ob gerechnet werden musste. +fn einlesen(lib: &Library, pfad: &Path, store: &Store) -> Result { + let track = Track::decode_file(pfad)?; + let (analyse, gerechnet) = analysis::analyze_cached(&track, store); + if gerechnet { + store.save(&analyse)?; + } + + let mut eintrag = TrackRecord::from_path(pfad.to_string_lossy().to_string()); + eintrag.fingerprint = Some(analyse.fingerprint.clone()); + eintrag.duration_secs = Some(analyse.duration_secs); + eintrag.title = pfad.file_stem().map(|s| s.to_string_lossy().to_string()); + + if let (Some(bpm), Some(anker)) = (analyse.bpm, analyse.beat_anchor_frames) { + eintrag.set_beatgrid( + Some(audio_core::Beatgrid::new(bpm, anker, 1.0)), + analyse.sample_rate, + ); + } + + lib.upsert_track(&eintrag)?; + + let tempo = match analyse.bpm { + Some(bpm) => format!("{bpm:6.2} BPM"), + None => " kein Tempo".to_string(), + }; + println!( + " {tempo} {} {}", + if gerechnet { "neu " } else { "Cache" }, + pfad.display() + ); + + Ok(gerechnet) +} + +fn sammle_dateien(ordner: &Path) -> Result> { + let mut out = Vec::new(); + let mut offen = vec![ordner.to_path_buf()]; + + while let Some(dir) = offen.pop() { + let Ok(eintraege) = std::fs::read_dir(&dir) else { + continue; + }; + for eintrag in eintraege.flatten() { + let pfad = eintrag.path(); + if pfad.is_dir() { + offen.push(pfad); + } else if pfad + .extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXT.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) + { + out.push(pfad); + } + } + } + + out.sort(); + Ok(out) +} + +fn zeige(tracks: &[TrackRecord]) { + if tracks.is_empty() { + println!(" (nichts gefunden)"); + return; + } + for t in tracks { + let bpm = t + .bpm + .map(|b| format!("{b:6.2}")) + .unwrap_or_else(|| " -".into()); + println!( + " {bpm} {:<28} {}", + t.artist.as_deref().unwrap_or("—"), + t.title.as_deref().unwrap_or(&t.path) + ); + } +} + +fn hilfe() { + println!("Aufruf: musik-lib [--db ] [--cache ] [argumente]"); + println!(); + println!(" scan Audiodateien einlesen, analysieren und ablegen"); + println!(" import-traktor Traktor-Sammlung übernehmen (Tempo, Grid, Cues)"); + println!(" search Titel, Künstler und Album durchsuchen"); + println!(" mixable Tracks im Tempofenster ±6 %"); + println!(" missing-attribution Samples ohne Lizenz oder Urheber auflisten"); + println!(); + println!(" --db Sammlung (Vorgabe: musik.db)"); + println!(" --cache Analyse-Sidecars (Vorgabe: .musik-analyse)"); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index f369b54..6e0587f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -87,13 +87,13 @@ ein Deck als Tempo-Master hängt. ``` crates/ - audio-core/ steht — Dekodierung, WSOLA, ein Deck, CPAL-Ausgabe - audio-engine/ NEU — Mehrdeck-Graph, Mixer, Cue-Bus, Effekte, Master - analysis/ NEU — BPM, Beatgrid, Tonart, Waveform-Peaks (offline) - library/ NEU — SQLite, Metadaten, Playlists, Traktor-Import - sources/ NEU — Trackquellen: Datei, später Generierung - musik-app/ NEU — UI - musik-cli/ steht — bleibt als Testwerkzeug + audio-core/ steht — Dekodierung, WSOLA, Deck, Beatgrid, Cues, Loops + audio-engine/ steht — Mixer, Kanalzüge, Cue-Bus, AUX, Sync, Begrenzer + analysis/ steht — BPM, Beatgrid, Waveform-Peaks, Sidecar-Cache + library/ steht — SQLite, Suche, Playlists, Traktor-Import + musik-cli/ steht — Deck, Analyse, Offline-Mix, Sammlung + musik-app/ OFFEN — UI (egui), Phase 3 + Effekte, Stems und MIDI kommen in audio-engine bzw. analysis dazu. ``` `audio-core` behält seine Rolle als Deck-Innenleben; `audio-engine` ist alles @@ -172,9 +172,9 @@ Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). | 1 | Mehrdeck-Engine, Mixer, Cue-Bus, AUX | Code steht ✅ · Abnahme offen: Cue muss hörbar getrennt auf 3/4 liegen | M | 4-Kanal-Interface | | ~~2~~ | ~~Analyse-Pipeline~~ ✅ | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | | 3 | UI-Grundgerüst | Zwei Decks mit Waveform, Mixer bedienbar, stabile 60 fps | L | 1, 2 | -| 4 | Sync und Beatmatching | Zwei Tracks bleiben über 5 Minuten im Takt, Phase korrekt | M | 2 | -| 5 | Hot Cues und Loops | 8 Cues pro Deck, Loop in/out, beat-quantisiert | M | 4 | -| 6 | Library | Import, Suche, Playlists, Traktor-`collection.nml` | L | 2 | +| ~~4~~ | ~~Sync und Beatmatching~~ ✅ | Zwei Tracks bleiben über 5 Minuten im Takt — als Test abgesichert | M | 2 | +| ~~5~~ | ~~Hot Cues und Loops~~ ✅ | 8 Cues pro Deck, Loop beat-quantisiert · Bedienung fehlt noch (Phase 3) | M | 4 | +| ~~6~~ | ~~Library~~ ✅ | Import, Suche, Playlists, Traktor-`collection.nml` | L | 2 | | 7 | Effekte | 6–8 gute Effekte, Mixer-FX post-fader | M | 1 | | 8 | Stems | Beim Import vorberechnet, Stem-Deck-Modus | L | 6 | | 9 | Mitschnitt | Master-Bus als WAV/FLAC, ohne Aussetzer | S | 1 | @@ -189,6 +189,19 @@ Ab Phase 6 ist die Software als DJ-Werkzeug benutzbar. 7 bis 10 machen sie gut. benutzt niemand. Sinnvoll sind Filter, Delay, Reverb, Beatmasher/Gater, Flanger und ein Bitcrusher — richtig gebaut und mit vernünftigen Regelwegen. +## Was inzwischen steht + +Sechs von zehn Phasen sind gebaut. Was fehlt, ist vor allem die Oberfläche — +alles Bisherige lässt sich nur über die Kommandozeile und über Tests bedienen. + +| Crate | Inhalt | +| --- | --- | +| `audio-core` | Deck: Dekodierung, WSOLA, Beatgrid, Hot Cues, Loops | +| `audio-engine` | Mixer: Kanalzüge, EQ, Filter, Crossfader, Cue-Bus, Begrenzer, AUX, Sync | +| `analysis` | Tempo, Beatgrid, Wellenform-Spitzen, Sidecar-Cache | +| `library` | SQLite-Sammlung, Suche, Playlists, Traktor-Import | +| `musik-cli` | Deck fahren, analysieren, Mix rendern, Sammlung verwalten | + ## Offene Entscheidungen **Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier From 2634bd5cccea6012cb9088e5614ca33c8ff621f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:45:30 +0000 Subject: [PATCH 11/70] fix(analysis): judge tempo by z-score instead of peak-to-mean ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dichtes Material wurde abgewiesen, obwohl das Tempo richtig erkannt war. Ein Loop aus Kick, Bass und Hi-Hats kam auf eine Deutlichkeit von 2,06 und blieb an der Schwelle von 3,0 hängen — die Grobsuche hatte 127,8 BPM längst gefunden. Ursache ist das Maß, nicht die Schwelle. Spitze durch Mittelwert war an Klick-Tracks kalibriert, und die sind zwischen den Klicks still: die Autokorrelation liegt bei fast jeder Verschiebung nahe null, das Verhältnis wird riesig. Echte Musik hat durchgehend Energie, hebt damit den Sockel über den ganzen Suchbereich an und drückt das Verhältnis gegen 1 — auch bei schnurgeradem Beat. Der z-Wert misst stattdessen, ob eine Verschiebung aus dem Feld heraussticht. Ein konstanter Sockel verschiebt Spitze und Mittel gleich weit und kürzt sich heraus. Gemessen trennt er doppelt so deutlich: 3,46 gegen 1,71 statt 2,06 gegen 1,65. Der neue Test legt Klicks auf einen Dauerton — genau der Fall, an dem das alte Maß gescheitert ist. --- crates/analysis/src/tempo.rs | 125 +++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/crates/analysis/src/tempo.rs b/crates/analysis/src/tempo.rs index d158ace..de04a99 100644 --- a/crates/analysis/src/tempo.rs +++ b/crates/analysis/src/tempo.rs @@ -29,14 +29,17 @@ const PRIOR_WIDTH: f64 = 1.2; /// Ab welcher Güte die halbe Periode als die eigentliche gilt. Siehe [`fundamental`]. const FUNDAMENTAL_RATIO: f32 = 0.8; -/// Wie deutlich die Autokorrelationsspitze den Mittelwert übertreffen muss, -/// damit das Ergebnis als Aussage durchgeht statt als Rauschen. +/// Wie weit die Autokorrelationsspitze aus dem Feld der übrigen +/// Verschiebungen herausragen muss, damit das Ergebnis als Aussage durchgeht +/// statt als Rauschen. Gemessen in Standardabweichungen — siehe +/// [`coarse_period`], warum nicht als Verhältnis zum Mittelwert. /// -/// Gemessen: Klick-Tracks liegen bei 14–27, ein reiner Dauerton bei 1,65. Der -/// Wert liegt bewusst näher am unteren Ende, deckt aber nur die beiden -/// Extreme ab — gegen eine echte Sammlung ist er nie kalibriert worden. Siehe -/// `schwellen_trennen_perkussiv_von_dauerton`. -const MIN_SALIENCE: f32 = 3.0; +/// Gemessen: Klick-Tracks 4,2–6,0; ein dichter Loop aus Kick, Bass und Hi-Hats +/// 3,5; ein reiner Dauerton 1,7. Der Wert liegt bewusst näher am unteren Ende +/// — lieber ein zweifelhaftes Grid, das der Nutzer sieht und korrigieren kann, +/// als gar keins. Gegen eine echte Sammlung ist er nie kalibriert worden. +/// Siehe `schwellen_trennen_perkussiv_von_dauerton`. +const MIN_SALIENCE: f32 = 2.5; /// Glättung der Hüllkurve für die Grobstufe, in Frames. Macht die /// Autokorrelation an ganzzahligen Verschiebungen tolerant gegen den halben @@ -81,16 +84,29 @@ pub fn detect(env: &OnsetEnvelope) -> Option { Some(Beatgrid { bpm: (env.rate * 60.0 / period) as f32, anchor_frames: env.to_sample_frames(phase).round().max(0.0) as u64, - confidence: ((salience - MIN_SALIENCE) / 8.0).clamp(0.0, 1.0), + confidence: ((salience - MIN_SALIENCE) / 4.0).clamp(0.0, 1.0), }) } /// Beste Verschiebung nach Autokorrelation, gewichtet mit dem Oktav-Prior. /// -/// Zweiter Rückgabewert ist die Deutlichkeit: Spitze geteilt durch Mittelwert -/// über den Suchbereich. Bei einem Dauerton liegt die nahe 1, bei perkussivem -/// Material deutlich darüber — das ist das Kriterium, ob überhaupt ein Tempo -/// vorliegt. +/// Zweiter Rückgabewert ist die Deutlichkeit: wie viele Standardabweichungen +/// die Spitze über dem Mittel aller Verschiebungen liegt. +/// +/// Naheliegender wäre Spitze durch Mittelwert gewesen, und genau so stand es +/// hier auch — bis dichtes Material es widerlegt hat. Ein Klick-Track ist +/// zwischen den Klicks still, also korreliert er bei fast jeder Verschiebung +/// mit nahezu null und das Verhältnis wird riesig (14–27). Echte Musik hat +/// dagegen durchgehend Energie: die Korrelation hat überall einen hohen +/// Sockel, und das Verhältnis rutscht gegen 1, auch wenn der Beat schnurgerade +/// durchläuft. Ein dichter Loop aus Kick, Bass und Hi-Hats kam so auf 2,06 — +/// und wäre an einer Schwelle von 3,0 hängengeblieben, obwohl das Tempo längst +/// richtig erkannt war. +/// +/// Der z-Wert misst stattdessen, ob **eine** Verschiebung aus dem Feld +/// heraussticht. Ein konstanter Sockel verschiebt Spitze und Mittel gleich +/// weit und kürzt sich heraus. Auf demselben Material trennt er doppelt so +/// deutlich: 3,46 gegen 1,71 statt 2,06 gegen 1,65. fn coarse_period(v: &[f32], rate: f64, p_short: f64, p_long: f64) -> Option<(f64, f32)> { let lo = p_short.floor().max(2.0) as usize; let hi = p_long.ceil() as usize; @@ -101,13 +117,11 @@ fn coarse_period(v: &[f32], rate: f64, p_short: f64, p_long: f64) -> Option<(f64 let mut best = 0.0f64; let mut best_weighted = f32::NEG_INFINITY; let mut peak = 0.0f32; - let mut sum = 0.0f64; - let mut n = 0usize; + let mut korrelationen = Vec::with_capacity(hi - lo + 1); for lag in lo..=hi { let r = autocorrelation(v, lag as f64); - sum += r as f64; - n += 1; + korrelationen.push(r); peak = peak.max(r); let weighted = r * octave_prior(rate * 60.0 / lag as f64) as f32; @@ -117,16 +131,42 @@ fn coarse_period(v: &[f32], rate: f64, p_short: f64, p_long: f64) -> Option<(f64 } } - if best == 0.0 || n == 0 { + if best == 0.0 || korrelationen.is_empty() { return None; } - let mean = (sum / n as f64) as f32; - let salience = if mean > 0.0 { peak / mean } else { 0.0 }; - + let salience = z_wert(peak, &korrelationen); Some((best, salience)) } +/// Abstand eines Wertes vom Mittel, in Standardabweichungen. +/// +/// Null, wenn alle Werte gleich sind — dann sticht nichts heraus, und genau +/// das soll die Zahl aussagen. +fn z_wert(wert: f32, werte: &[f32]) -> f32 { + if werte.is_empty() { + return 0.0; + } + + let n = werte.len() as f64; + let mittel = werte.iter().map(|r| *r as f64).sum::() / n; + let varianz = werte + .iter() + .map(|r| { + let d = *r as f64 - mittel; + d * d + }) + .sum::() + / n; + let sd = varianz.sqrt(); + + if sd > 0.0 { + ((wert as f64 - mittel) / sd) as f32 + } else { + 0.0 + } +} + /// Steigt von einer Verschiebung zu ihrer Grundperiode ab. /// /// Der Kern des Oktavproblems: Die Autokorrelation hat Spitzen bei *jedem* @@ -363,15 +403,30 @@ mod tests { samples } + /// Klicks auf einem durchgehenden Klangteppich. + /// + /// Das ist der Fall, an dem die alte Deutlichkeit gescheitert ist: der Beat + /// ist so gerade wie beim Klick-Track, aber der Ton dazwischen hebt den + /// Sockel der Autokorrelation an. Echte Musik sieht so aus, ein nackter + /// Klick-Track nicht. + fn klicks_auf_teppich(bpm: f64, secs: f64) -> Vec { + let mut samples = click_track(bpm, RATE, secs, 0.0); + let ton = dauerton(secs); + for (s, t) in samples.iter_mut().zip(ton.iter()) { + *s += *t; + } + samples + } + /// Sichert den Abstand ab, auf dem [`MIN_SALIENCE`] beruht. Rutschen die - /// beiden Enden zusammen, ist die Schwelle nicht mehr begründet. + /// Enden zusammen, ist die Schwelle nicht mehr begründet. #[test] fn schwellen_trennen_perkussiv_von_dauerton() { for bpm in [100.0, 128.0, 174.0] { let sal = deutlichkeit(&click_track(bpm, RATE, 25.0, 0.0)) .unwrap_or_else(|| panic!("keine Periode bei {bpm} BPM")); assert!( - sal > 10.0, + sal > 4.0, "Klick-Track bei {bpm} BPM nur bei Deutlichkeit {sal:.2}" ); } @@ -383,6 +438,32 @@ mod tests { ); } + /// Der eigentliche Grund für den z-Wert: dichtes Material muss durchkommen. + /// + /// Mit Spitze-durch-Mittelwert lag genau dieser Fall bei rund 2 und wurde + /// abgewiesen, obwohl das Tempo richtig erkannt war. + #[test] + fn ein_durchgehender_klangteppich_verdeckt_den_beat_nicht() { + for bpm in [100.0, 128.0] { + let samples = klicks_auf_teppich(bpm, 25.0); + + let sal = + deutlichkeit(&samples).unwrap_or_else(|| panic!("keine Periode bei {bpm} BPM")); + assert!( + sal > MIN_SALIENCE, + "Beat unter Dauerton nur bei Deutlichkeit {sal:.2} — käme nicht durch" + ); + + let grid = detect(&onset_envelope(&samples, RATE)) + .unwrap_or_else(|| panic!("kein Grid bei {bpm} BPM")); + assert!( + (grid.bpm as f64 - bpm).abs() < 1.0, + "{} statt {bpm} BPM", + grid.bpm + ); + } + } + #[test] fn erkennt_gaengige_tempi() { for bpm in [100.0, 128.0, 140.0, 174.0] { From b656635db706325f38028bd0e0e4595d9b3332e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:45:45 +0000 Subject: [PATCH 12/70] feat(audio-engine): command queue and device output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Oberfläche darf den Mixer nicht direkt anfassen — sie läuft im UI-Thread, der Mixer im Audio-Callback. Atomics reichen für Skalare wie Position und Tempo, nicht für einen Kanalwechsel: ein neuer Track ist eine `Box`, und die kann man nicht atomar tauschen. Also eine lock-freie SPSC-Schlange für Kommandos und ein zweiter Kanal zurück für abgelöste Quellen. Der Rückkanal ist kein Beiwerk: würde der Callback die alte Quelle einfach fallen lassen, liefe dort ein `free` über mehrere Megabyte Samples — eine Allokator-Operation im Echtzeitpfad, also genau das, was nicht passieren darf. `drain_loads` nimmt deshalb keinen neuen Track an, solange der Rückkanal voll ist. Damit ist bewiesen, dass die alte Quelle immer einen Platz findet, und im Callback wird nie etwas freigegeben. `Output` sucht ein Gerät und übergibt ihm den Mixer. Scheitert das Öffnen, kommt der Runner **mit** dem Fehler zurück — sonst wäre der ganze Mixer verloren, nur weil keine Soundkarte da ist, und die Oberfläche stünde still. --- crates/audio-engine/Cargo.toml | 1 + crates/audio-engine/src/command.rs | 376 +++++++++++++++++++++++++++++ crates/audio-engine/src/lib.rs | 4 + crates/audio-engine/src/output.rs | 175 ++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 crates/audio-engine/src/command.rs create mode 100644 crates/audio-engine/src/output.rs diff --git a/crates/audio-engine/Cargo.toml b/crates/audio-engine/Cargo.toml index f1208c2..98a880a 100644 --- a/crates/audio-engine/Cargo.toml +++ b/crates/audio-engine/Cargo.toml @@ -6,4 +6,5 @@ publish.workspace = true [dependencies] audio-core = { version = "0.1.0", path = "../audio-core" } +cpal = "0.18.1" rtrb = "0.3.4" diff --git a/crates/audio-engine/src/command.rs b/crates/audio-engine/src/command.rs new file mode 100644 index 0000000..2f16852 --- /dev/null +++ b/crates/audio-engine/src/command.rs @@ -0,0 +1,376 @@ +//! Kommandos von der Oberfläche zum Audio-Thread. +//! +//! Der Mixer gehört dem Audio-Callback allein. Die Oberfläche fasst ihn nicht +//! an, sondern schickt Kommandos durch eine lock-freie Schlange, die der +//! Callback zu Beginn jedes Blocks leert. +//! +//! Ein `Mutex` wäre bequemer und wäre falsch: Der Callback müsste dann +//! auf die Oberfläche warten, und Warten im Audio-Pfad heißt Aussetzer. Genau +//! deshalb steht diese Schlange in `docs/PLAN.md` als eine der drei +//! Entscheidungen, die die Architektur prägen. +//! +//! Transportbefehle (Play, Tempo, Springen) laufen **nicht** hierüber — die +//! liegen als Atomics im `DeckState` und sind von beiden Seiten direkt +//! erreichbar. Hier steht nur, was den Mixer selbst betrifft. + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::crossfader::Assign; +use crate::mixer::Engine; +use crate::source::Source; + +/// Eine Änderung am Mixer. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Command { + Trim(usize, f32), + Fader(usize, f32), + Eq { + channel: usize, + low: f32, + mid: f32, + high: f32, + }, + Filter(usize, f32), + Cue(usize, bool), + Assign(usize, Assign), + Crossfader(f32), + CrossfaderCurve(f32), + MasterGain(f32), + CueGain(f32), + CueMix(f32), +} + +/// Die Seite der Oberfläche. +pub struct EngineHandle { + tx: Producer, + loads: Producer, + retired: Consumer>, + dropped: u64, +} + +impl EngineHandle { + /// Legt einen Track auf einen Kanal. Die alte Quelle kommt über + /// [`Self::collect_retired`] zurück und stirbt dort, nicht im Audio-Thread. + pub fn load(&mut self, channel: usize, source: Box) -> bool { + self.loads.push(LoadRequest { channel, source }).is_ok() + } + + /// Holt abgelöste Quellen ab und gibt sie frei. + /// + /// Muss regelmäßig aufgerufen werden — sonst verstopft der Rückweg, und + /// der Audio-Thread nimmt keine neuen Tracks mehr an. Einmal pro Bild der + /// Oberfläche reicht. + pub fn collect_retired(&mut self) -> usize { + let mut n = 0; + while let Ok(alt) = self.retired.pop() { + drop(alt); + n += 1; + } + n + } + /// Schickt ein Kommando. Ist die Schlange voll, geht es verloren und wird + /// gezählt — ein Reglerwert, der nicht ankommt, wird beim nächsten Zug + /// ohnehin überschrieben. Blockieren wäre hier schlimmer als verlieren. + pub fn send(&mut self, command: Command) { + if self.tx.push(command).is_err() { + self.dropped += 1; + } + } + + pub fn dropped(&self) -> u64 { + self.dropped + } +} + +/// Ein Track, der auf einen Kanal soll. +pub struct LoadRequest { + pub channel: usize, + pub source: Box, +} + +/// Die Seite des Audio-Threads. Besitzt den Mixer. +pub struct EngineRunner { + engine: Engine, + rx: Consumer, + loads: Consumer, + retire: Producer>, + /// Eine abgelöste Quelle, die noch nicht hinausgereicht werden konnte. + pending: Option>, +} + +impl EngineRunner { + /// Kommandos übernehmen und einen Block rendern. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + self.drain(); + self.engine.render(out, out_channels); + } + + fn drain(&mut self) { + while let Ok(command) = self.rx.pop() { + self.apply(command); + } + self.drain_loads(); + } + + /// Nimmt neue Quellen entgegen und reicht die abgelösten hinaus. + /// + /// Der Kern: Ein abgelöster Track hält hundert Megabyte. Fällt er hier, + /// gibt der letzte `Arc` sie frei — ein Syscall mitten im Callback und + /// damit genau der Aussetzer, den die Architektur vermeidet. + /// + /// Deshalb wird nichts übernommen, solange der Rückweg verstopft ist. Die + /// Anfrage bleibt dann in der Schlange und kommt einen Block später dran; + /// das ist ein paar Millisekunden später und stört niemanden. + fn drain_loads(&mut self) { + if let Some(alt) = self.pending.take() { + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(alt) { + self.pending = Some(zurueck); + return; + } + } + + while !self.retire.is_full() { + let Ok(request) = self.loads.pop() else { + return; + }; + if request.channel >= self.engine.channel_count() { + // Auch diese Quelle darf hier nicht fallen. Der Rückweg ist an + // dieser Stelle frei — die Schleifenbedingung hat es gerade + // geprüft —, also geht sie sofort zurück statt zu warten. + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(request.source) { + self.pending = Some(zurueck); + return; + } + continue; + } + + let alt = self + .engine + .channel(request.channel) + .set_source(request.source); + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(alt) { + self.pending = Some(zurueck); + return; + } + } + } + + fn apply(&mut self, command: Command) { + let channels = self.engine.channel_count(); + let gueltig = |index: usize| index < channels; + + match command { + Command::Trim(ch, value) if gueltig(ch) => self.engine.channel(ch).set_trim(value), + Command::Fader(ch, value) if gueltig(ch) => self.engine.channel(ch).set_fader(value), + Command::Eq { + channel, + low, + mid, + high, + } if gueltig(channel) => self.engine.channel(channel).set_eq(low, mid, high), + Command::Filter(ch, value) if gueltig(ch) => self.engine.channel(ch).set_filter(value), + Command::Cue(ch, on) if gueltig(ch) => self.engine.channel(ch).set_cue(on), + Command::Assign(ch, assign) if gueltig(ch) => { + self.engine.channel(ch).set_assign(assign) + } + Command::Crossfader(value) => self.engine.crossfader().set_position(value), + Command::CrossfaderCurve(value) => self.engine.crossfader().set_curve(value), + Command::MasterGain(value) => self.engine.set_master_gain(value), + Command::CueGain(value) => self.engine.set_cue_gain(value), + Command::CueMix(value) => self.engine.set_cue_mix(value), + // Kommandos für nicht vorhandene Kanäle werden verworfen statt zu + // paniken — die Oberfläche könnte einem Umbau hinterherhinken. + _ => {} + } + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + /// Ein Runner ohne Kanäle. + /// + /// Notnagel für den Fall, dass der echte Mixer in einem gescheiterten + /// Stream steckengeblieben ist. Er rendert Stille, damit der Aufrufer + /// weiterarbeiten kann statt zu paniken. + pub fn leer() -> EngineRunner { + let (_, runner) = channel(Engine::new(48_000.0), 8); + runner + } +} + +/// Verbindet Oberfläche und Audio-Thread. +pub fn channel(engine: Engine, capacity: usize) -> (EngineHandle, EngineRunner) { + let (tx, rx) = RingBuffer::new(capacity.max(8)); + // Der Rückweg ist etwas weiter als der Hinweg, damit er nie der Engpass ist. + let (load_tx, load_rx) = RingBuffer::new(8); + let (retire_tx, retire_rx) = RingBuffer::new(12); + + ( + EngineHandle { + tx, + loads: load_tx, + retired: retire_rx, + dropped: 0, + }, + EngineRunner { + engine, + rx, + loads: load_rx, + retire: retire_tx, + pending: None, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{LoopSource, SilentSource}; + use crate::testing::{rms_channel, sine_stereo}; + + const RATE: f32 = 48_000.0; + + fn aufbau() -> (EngineHandle, EngineRunner) { + let mut engine = Engine::new(RATE); + let samples = sine_stereo(220.0, RATE as u32, 1.0) + .into_iter() + .map(|v| v * 0.4) + .collect(); + engine.add_channel("A", Box::new(LoopSource::new(samples))); + engine.add_channel("leer", Box::new(SilentSource)); + channel(engine, 64) + } + + fn pegel(runner: &mut EngineRunner) -> f32 { + let mut out = vec![0.0; 4_800 * 4]; + runner.render(&mut out, 4); + rms_channel(&out, 0, 4) + } + + #[test] + fn kommandos_kommen_an() { + let (mut handle, mut runner) = aufbau(); + assert!(pegel(&mut runner) < 0.001, "Fader startet nicht unten"); + + handle.send(Command::Fader(0, 1.0)); + assert!(pegel(&mut runner) > 0.1, "Fader-Kommando wirkt nicht"); + } + + #[test] + fn kommandos_fuer_fehlende_kanaele_werden_verworfen() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(99, 1.0)); + handle.send(Command::Eq { + channel: 99, + low: 0.0, + mid: 0.0, + high: 0.0, + }); + + // Darf nicht paniken und darf nichts anderes verstellen. + pegel(&mut runner); + } + + #[test] + fn eine_volle_schlange_verliert_statt_zu_blockieren() { + let (mut handle, mut runner) = aufbau(); + for i in 0..10_000 { + handle.send(Command::Crossfader(i as f32 * 1e-4)); + } + + assert!(handle.dropped() > 0, "die Schlange ist nicht übergelaufen"); + pegel(&mut runner); + } + + #[test] + fn der_letzte_wert_gewinnt() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(0, 1.0)); + handle.send(Command::Fader(0, 0.0)); + + assert!( + pegel(&mut runner) < 0.001, + "die Reihenfolge der Kommandos stimmt nicht" + ); + } + + #[test] + fn ein_geladener_track_ist_zu_hoeren() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(1, 1.0)); + assert!(pegel(&mut runner) < 0.001, "der leere Kanal klingt"); + + let samples = sine_stereo(220.0, RATE as u32, 1.0) + .into_iter() + .map(|v| v * 0.4) + .collect(); + assert!(handle.load(1, Box::new(LoopSource::new(samples)))); + + assert!(pegel(&mut runner) > 0.1, "die neue Quelle kam nicht an"); + } + + #[test] + fn die_abgeloeste_quelle_kommt_zurueck_statt_im_callback_zu_sterben() { + let (mut handle, mut runner) = aufbau(); + + handle.load(0, Box::new(SilentSource)); + pegel(&mut runner); + + assert_eq!( + handle.collect_retired(), + 1, + "die alte Quelle wurde nicht herausgereicht" + ); + } + + #[test] + fn ohne_abholen_verstopft_der_rueckweg_statt_zu_verlieren() { + // Wird nie abgeholt, muss der Audio-Thread irgendwann aufhören, + // Tracks anzunehmen — und darf trotzdem nichts freigeben. + let (mut handle, mut runner) = aufbau(); + + for _ in 0..40 { + handle.load(0, Box::new(SilentSource)); + pegel(&mut runner); + } + + let zurueck = handle.collect_retired(); + assert!(zurueck > 0, "gar nichts kam zurück"); + assert!( + zurueck <= 12, + "mehr zurück als der Rückweg fasst: {zurueck}" + ); + } + + #[test] + fn laden_auf_einen_fehlenden_kanal_verliert_nichts() { + let (mut handle, mut runner) = aufbau(); + handle.load(99, Box::new(SilentSource)); + pegel(&mut runner); + + assert_eq!( + handle.collect_retired(), + 1, + "die Quelle für den falschen Kanal ist verschwunden" + ); + } + + #[test] + fn eq_kommando_traegt_alle_drei_baender() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(0, 1.0)); + let voll = pegel(&mut runner); + + handle.send(Command::Eq { + channel: 0, + low: 0.0, + mid: 0.0, + high: 0.0, + }); + let zu = pegel(&mut runner); + + assert!(voll > 0.1); + assert!(zu < voll * 0.1, "EQ-Kill kam nicht durch: {zu:.4}"); + } +} diff --git a/crates/audio-engine/src/lib.rs b/crates/audio-engine/src/lib.rs index 592a607..0c5640b 100644 --- a/crates/audio-engine/src/lib.rs +++ b/crates/audio-engine/src/lib.rs @@ -13,11 +13,13 @@ pub mod aux; pub mod channel; +pub mod command; pub mod crossfader; pub mod eq; pub mod filter; pub mod limiter; pub mod mixer; +pub mod output; pub mod source; pub mod svf; pub mod sync; @@ -27,10 +29,12 @@ pub(crate) mod testing; pub use aux::{aux_channel, AuxSource, AuxWriter}; pub use channel::Channel; +pub use command::{channel as engine_channel, Command, EngineHandle, EngineRunner}; pub use crossfader::{Assign, Crossfader}; pub use eq::ThreeBandEq; pub use filter::DjFilter; pub use limiter::Limiter; pub use mixer::{assign_deck_pair, Engine}; +pub use output::Output; pub use source::{DeckSource, LoopSource, SilentSource, Source}; pub use sync::{phase_error, sync, sync_tempo_only, SyncPlan}; diff --git a/crates/audio-engine/src/output.rs b/crates/audio-engine/src/output.rs new file mode 100644 index 0000000..7af2baa --- /dev/null +++ b/crates/audio-engine/src/output.rs @@ -0,0 +1,175 @@ +//! Ausgabe der Engine auf ein Audiogerät. +//! +//! Sucht eine Gerätekonfiguration mit vier Ausgängen, damit der Cue-Bus auf +//! 3/4 landet. Gibt es die nicht, läuft die Software trotzdem — dann eben ohne +//! Vorhören, und sie sagt das auch. + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample, StreamConfig}; + +use crate::command::EngineRunner; +use crate::mixer::Engine; + +/// Fehlschlag beim Öffnen — **mit** dem Runner zurück. +/// +/// Ohne ihn wäre der Mixer verloren, sobald kein Gerät da ist. Der Aufrufer +/// soll ihn weiterverwenden können, etwa für einen Leerlauf-Taktgeber. +/// +/// Wird als `Box` zurückgegeben: der Runner macht den Fehlerwert groß, und +/// jeder Erfolgsfall müsste ihn sonst mitschleppen. +pub struct OpenError { + pub message: String, + pub runner: EngineRunner, +} + +impl std::fmt::Display for OpenError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +pub struct Output { + device_name: String, + sample_rate: u32, + channels: usize, + _stream: cpal::Stream, +} + +impl Output { + /// Öffnet das Standardgerät und übergibt ihm den Mixer. + pub fn open(runner: EngineRunner) -> Result> { + macro_rules! fehler { + ($runner:expr, $($arg:tt)*) => { + return Err(Box::new(OpenError { message: format!($($arg)*), runner: $runner })) + }; + } + + let host = cpal::default_host(); + let Some(device) = host.default_output_device() else { + fehler!(runner, "kein Ausgabegerät gefunden"); + }; + let device_name = device + .id() + .map(|id| id.to_string()) + .unwrap_or_else(|_| "unbekannt".into()); + + let supported = match device.default_output_config() { + Ok(c) => c, + Err(e) => fehler!(runner, "Gerätekonfiguration nicht lesbar: {e}"), + }; + let sample_format = supported.sample_format(); + let config: StreamConfig = supported.into(); + + let sample_rate = config.sample_rate; + let channels = config.channels as usize; + + // Der Runner wandert in den Callback; scheitert das Öffnen, kommt er + // von dort zurück. + let gebaut = match sample_format { + SampleFormat::F32 => build::(&device, config, runner), + SampleFormat::I16 => build::(&device, config, runner), + SampleFormat::I32 => build::(&device, config, runner), + SampleFormat::U16 => build::(&device, config, runner), + SampleFormat::U8 => build::(&device, config, runner), + SampleFormat::F64 => build::(&device, config, runner), + other => fehler!(runner, "Sample-Format {other} nicht unterstützt"), + }; + + let stream = match gebaut { + Ok(s) => s, + Err(boxed) => { + let (e, runner) = *boxed; + fehler!(runner, "Stream ließ sich nicht öffnen: {e}") + } + }; + + if let Err(e) = stream.play() { + // Hier ist der Runner im Stream gefangen; der Stream wird verworfen. + return Err(Box::new(OpenError { + message: format!("Wiedergabe ließ sich nicht starten: {e}"), + runner: EngineRunner::leer(), + })); + } + + Ok(Output { + device_name, + sample_rate, + channels, + _stream: stream, + }) + } + + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> usize { + self.channels + } + + /// Ob der Cue-Bus einen eigenen Ausgang hat. + pub fn has_cue_output(&self) -> bool { + self.channels >= Engine::REQUIRED_OUTPUTS_FOR_CUE + } +} + +#[allow(clippy::type_complexity)] +fn build( + device: &cpal::Device, + config: StreamConfig, + runner: EngineRunner, +) -> Result> +where + T: SizedSample + FromSample, +{ + let channels = config.channels as usize; + let mut block: Vec = Vec::new(); + + // Der Runner muss im Fehlerfall wieder heraus, also über eine Zelle, die + // der Callback leert und die wir sonst zurücknehmen. + let geteilt = std::sync::Arc::new(std::sync::Mutex::new(Some(runner))); + let fuer_callback = std::sync::Arc::clone(&geteilt); + let mut eigener: Option = None; + + let ergebnis = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + // Einmalig übernehmen, danach nie wieder das Lock anfassen. + if eigener.is_none() { + eigener = fuer_callback.lock().ok().and_then(|mut g| g.take()); + } + let Some(runner) = eigener.as_mut() else { + data.fill(T::from_sample(0.0)); + return; + }; + + if block.len() < data.len() { + block.resize(data.len(), 0.0); + } + let scratch = &mut block[..data.len()]; + runner.render(scratch, channels); + + for (dst, src) in data.iter_mut().zip(scratch.iter()) { + *dst = T::from_sample(*src); + } + }, + |err| eprintln!("Audio-Stream: {err}"), + None, + ); + + match ergebnis { + Ok(stream) => Ok(stream), + Err(e) => { + let runner = geteilt + .lock() + .ok() + .and_then(|mut g| g.take()) + .unwrap_or_else(EngineRunner::leer); + Err(Box::new((e, runner))) + } + } +} From fd2c0c52755b036e485389301d67cc648007b283 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:46:04 +0000 Subject: [PATCH 13/70] feat(app): DJ interface with decks, mixer and crate browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bis hierher ließ sich alles nur über die Kommandozeile und über Tests bedienen. Jetzt gibt es die Anordnung, die man vom Pult kennt: zwei Decks oben, Mischpult in der Mitte, Plattenkiste unten. Decks zeigen zwei Wellenformen — Übersicht zum Springen, Ausschnitt zum Mixen — mit Beatgrid, Hot-Cue-Marken und Abspielkopf. Gezeichnet wird aus den vorberechneten Spitzen der Analyse; über dreizehn Millionen Rohwerte je Deck und Bild zu laufen wäre bei 60 Bildern pro Sekunde aussichtslos. Der Mixer hat vier Kanalzüge samt AUX, jeder mit Trim, Dreiband-EQ, DJ-Filter, Cue und Linefader, dazu Crossfader mit Kurve, Master, Kopfhörer und Cue-Mix. Die Plattenkiste durchsucht die Sammlung und legt Treffer auf ein Deck; „Mischbar zu A" filtert auf Tempo-Nachbarschaft. Drei Dinge, die beim Bauen erst sichtbar wurden: - Ein `Frame` erbt das Layout seines Elternteils. Im Mixer ist das waagerecht, also standen die Regler eines Kanalzuges nebeneinander statt übereinander — jeder Rahmen sagt jetzt ausdrücklich `vertical`. - Die Zoom-Ansicht ist um die Abspielposition zentriert und ragt am Anfang eines Tracks nach links über ihn hinaus. Dort wiederholte der Zeichner die erste Spitze für jede Spalte: ein solider Balken, den es nicht gibt. Auch das Beatgrid hörte vor Frame 0 nicht auf. - Die mitgelieferte Schrift hat keine Pfeilzeichen. „→ A" wurde zum leeren Kästchen, ebenso ◄ ► und ⟳. Ohne Audiogerät startet die Oberfläche trotzdem: der Mixer läuft dann von einem Taktgeber im Leerlauf. Sonst wäre sie in genau der Umgebung ein Standbild, in der man sie am ehesten prüfen möchte. Ein geladener Track startet gestoppt mit geschlossenem Fader. Ein Deck, das beim Laden auf die Anlage geht, ist ein Unfall. --- Cargo.lock | 4813 ++++++++++++++++++++++++++---- Cargo.toml | 2 +- README.md | 47 +- crates/musik-app/Cargo.toml | 15 + crates/musik-app/src/app.rs | 857 ++++++ crates/musik-app/src/demo.rs | 171 ++ crates/musik-app/src/laden.rs | 72 + crates/musik-app/src/main.rs | 241 ++ crates/musik-app/src/theme.rs | 54 + crates/musik-app/src/waveform.rs | 278 ++ docs/PLAN.md | 23 +- docs/bilder/oberflaeche.png | Bin 0 -> 192264 bytes 12 files changed, 5920 insertions(+), 653 deletions(-) create mode 100644 crates/musik-app/Cargo.toml create mode 100644 crates/musik-app/src/app.rs create mode 100644 crates/musik-app/src/demo.rs create mode 100644 crates/musik-app/src/laden.rs create mode 100644 crates/musik-app/src/main.rs create mode 100644 crates/musik-app/src/theme.rs create mode 100644 crates/musik-app/src/waveform.rs create mode 100644 docs/bilder/oberflaeche.png diff --git a/Cargo.lock b/Cargo.lock index 04cf1e2..5b21bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,160 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.11.0" @@ -9,7 +163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -36,12 +190,66 @@ dependencies = [ "serde_json", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -54,6 +262,195 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "audio-core" version = "0.1.0" @@ -68,6 +465,7 @@ name = "audio-engine" version = "0.1.0" dependencies = [ "audio-core", + "cpal", "rtrb", ] @@ -83,6 +481,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -103,13 +522,35 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] @@ -123,6 +564,26 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -131,21 +592,118 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "cc" -version = "1.4.2" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "find-msvc-tools", - "shlex", + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "combine" version = "4.6.7" @@ -156,19 +714,68 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -183,7 +790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" dependencies = [ "alsa", - "block2", + "block2 0.6.2", "coreaudio-rs", "dasp_sample", "jni", @@ -194,13 +801,13 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2", + "objc2 0.6.4", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "web-sys", "windows", "windows-core", @@ -215,1141 +822,4079 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.13.1", + "objc2 0.6.4", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] -name = "extended" -version = "0.1.0" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "find-msvc-tools" -version = "0.1.10" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "foldhash" -version = "0.2.0" +name = "ecolor" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] [[package]] -name = "futures-core" -version = "0.3.33" +name = "eframe" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "8dc8234e2f681b2afd2b2e8c332fa614de2fddbdcb28d0acf5c420449682ea90" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] [[package]] -name = "futures-task" -version = "0.3.33" +name = "egui" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] [[package]] -name = "futures-util" -version = "0.3.33" +name = "egui-wgpu" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.20", + "type-map", + "web-time", + "wgpu", + "winit", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "egui-winit" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" dependencies = [ - "foldhash", + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "egui_glow" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "52274b9bfb8d8e252cd0f00c9f6214f360d75a0962baa77a2d62ddb002fe99d4" dependencies = [ - "foldhash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "winit", ] [[package]] -name = "hashlink" -version = "0.12.1" +name = "either" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" -dependencies = [ - "hashbrown 0.17.1", -] +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] -name = "indexmap" -version = "2.14.0" +name = "emath" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" dependencies = [ - "equivalent", - "hashbrown 0.17.1", + "bytemuck", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] -name = "jni" -version = "0.22.4" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link", + "enumflags2_derive", + "serde", ] [[package]] -name = "jni-macros" -version = "0.22.4" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "rustc_version", - "simd_cesu8", "syn 2.0.119", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "epaint" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" dependencies = [ - "jni-sys 0.4.1", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", ] [[package]] -name = "jni-sys" -version = "0.4.1" +name = "epaint_default_fonts" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" [[package]] -name = "jni-sys-macros" -version = "0.4.1" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "js-sys" -version = "0.3.104" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "error-code" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] -name = "libc" -version = "0.2.189" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] [[package]] -name = "library" -version = "0.1.0" +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "audio-core", - "quick-xml", - "rusqlite", - "thiserror 2.0.20", + "parking", + "pin-project-lite", ] [[package]] -name = "libsqlite3-sys" -version = "0.38.2" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "cc", - "pkg-config", - "vcpkg", + "event-listener", + "pin-project-lite", ] [[package]] -name = "log" -version = "0.4.33" +name = "extended" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] -name = "mach2" -version = "0.6.0" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "memchr" -version = "2.8.3" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "musik-cli" -version = "0.1.0" -dependencies = [ - "analysis", - "anyhow", - "audio-core", - "audio-engine", - "library", -] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "ndk" -version = "0.9.0" +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ - "bitflags", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "thiserror 1.0.69", + "simd-adler32", ] [[package]] -name = "ndk-context" -version = "0.1.1" +name = "fearless_simd" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] -name = "ndk-sys" -version = "0.6.0+11769913" +name = "find-msvc-tools" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] -name = "num-complex" -version = "0.4.6" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "num-traits", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "num-derive" -version = "0.4.2" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "num-integer" -version = "0.1.46" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "num-traits" -version = "0.2.19" +name = "font-types" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" dependencies = [ - "autocfg", + "bytemuck", ] [[package]] -name = "num_enum" -version = "0.7.6" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ - "num_enum_derive", - "rustversion", + "foreign-types-macros", + "foreign-types-shared", ] [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "foreign-types-macros" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] -name = "objc2" -version = "0.6.4" +name = "foreign-types-shared" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] -name = "objc2-audio-toolbox" -version = "0.3.2" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "bitflags", - "libc", - "objc2", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation", + "percent-encoding", ] [[package]] -name = "objc2-avf-audio" -version = "0.3.2" +name = "futures-core" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] -name = "objc2-core-audio" -version = "0.3.2" +name = "futures-io" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "dispatch2", - "objc2", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation", + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", ] [[package]] -name = "objc2-core-audio-types" -version = "0.3.2" +name = "futures-macro" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ - "bitflags", - "objc2", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "objc2-core-foundation" -version = "0.3.2" +name = "futures-task" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] [[package]] -name = "objc2-foundation" -version = "0.3.2" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "bitflags", - "block2", + "cfg-if", "libc", - "objc2", - "objc2-core-foundation", + "r-efi 5.3.0", + "wasip2", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "gl_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "glifo" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] [[package]] -name = "primal-check" -version = "0.3.4" +name = "glow" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ - "num-integer", + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "glutin" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "toml_edit", + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "glutin-winit" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" dependencies = [ - "unicode-ident", + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", ] [[package]] -name = "quick-xml" -version = "0.41.0" +name = "glutin_egl_sys" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ - "memchr", + "gl_generator", + "windows-sys 0.52.0", ] [[package]] -name = "quote" -version = "1.0.47" +name = "glutin_glx_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" dependencies = [ - "proc-macro2", + "gl_generator", + "x11-dl", ] [[package]] -name = "regex-lite" -version = "0.1.9" +name = "glutin_wgl_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] [[package]] -name = "rsqlite-vfs" -version = "0.1.1" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ + "ash", "hashbrown 0.16.1", + "log", + "presser", "thiserror 2.0.20", + "windows", ] [[package]] -name = "rtrb" -version = "0.3.4" +name = "gpu-descriptor" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] [[package]] -name = "rusqlite" -version = "0.40.2" +name = "gpu-descriptor-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", + "bitflags 2.13.1", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "guillotiere" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ - "semver", + "euclid", ] [[package]] -name = "rustfft" -version = "6.4.1" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "num-complex", - "num-integer", + "cfg-if", + "crunchy", "num-traits", - "primal-check", - "strength_reduce", - "transpose", + "zerocopy", ] [[package]] -name = "rustversion" -version = "1.0.23" +name = "harfrust" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "winapi-util", + "foldhash 0.1.5", ] [[package]] -name = "semver" -version = "1.0.28" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] -name = "serde" -version = "1.0.229" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "serde_core", - "serde_derive", + "foldhash 0.2.0", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "hashlink" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" dependencies = [ - "serde_derive", + "hashbrown 0.17.1", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] [[package]] -name = "simd_cesu8" -version = "1.2.0" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "rustc_version", - "simdutf8", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "simdutf8" -version = "0.1.5" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "slab" -version = "0.4.12" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] [[package]] -name = "smallvec" -version = "1.15.2" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "strength_reduce" -version = "0.2.4" +name = "image" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] [[package]] -name = "symphonia" -version = "0.6.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-isomp4", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", + "equivalent", + "hashbrown 0.17.1", ] [[package]] -name = "symphonia-bundle-flac" -version = "0.6.0" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "log", - "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "either", ] [[package]] -name = "symphonia-bundle-mp3" -version = "0.6.0" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "lazy_static", + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", "log", - "symphonia-core", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", ] [[package]] -name = "symphonia-codec-aac" -version = "0.6.0" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "lazy_static", - "log", - "symphonia-common", - "symphonia-core", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] -name = "symphonia-codec-pcm" -version = "0.6.0" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - "log", - "symphonia-core", + "jni-sys 0.4.1", ] [[package]] -name = "symphonia-codec-vorbis" -version = "0.6.0" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "log", - "symphonia-common", - "symphonia-core", + "jni-sys-macros", ] [[package]] -name = "symphonia-common" -version = "0.6.0" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", + "quote", + "syn 2.0.119", ] [[package]] -name = "symphonia-core" -version = "0.6.0" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "bitflags", - "bytemuck", - "lazy_static", - "log", - "num-complex", - "smallvec", + "getrandom 0.4.3", + "libc", ] [[package]] -name = "symphonia-format-isomp4" -version = "0.6.0" +name = "js-sys" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "log", - "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] -name = "symphonia-format-ogg" -version = "0.6.0" +name = "khronos-egl" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ - "log", - "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", ] [[package]] -name = "symphonia-format-riff" -version = "0.6.0" +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "library" +version = "0.1.0" +dependencies = [ + "audio-core", + "quick-xml", + "rusqlite", + "thiserror 2.0.20", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "musik-app" +version = "0.1.0" +dependencies = [ + "analysis", + "anyhow", + "audio-core", + "audio-engine", + "eframe", + "egui", + "image", + "library", +] + +[[package]] +name = "musik-cli" +version = "0.1.0" +dependencies = [ + "analysis", + "anyhow", + "audio-core", + "audio-engine", + "library", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" -dependencies = [ - "extended", - "log", - "symphonia-core", - "symphonia-metadata", -] +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "symphonia-metadata" -version = "0.6.0" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" -dependencies = [ - "lazy_static", - "log", - "regex-lite", - "smallvec", - "symphonia-core", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "syn" -version = "2.0.119" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "syn" -version = "3.0.3" +name = "winit" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "thiserror-impl 1.0.69", + "memchr", ] [[package]] -name = "thiserror" -version = "2.0.20" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "thiserror-impl" -version = "2.0.20" +name = "x11-dl" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "libc", + "once_cell", + "pkg-config", ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "x11rb" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ - "serde_core", + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", ] [[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" +name = "x11rb-protocol" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" +name = "xcursor" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] -name = "transpose" -version = "0.2.3" +name = "xkbcommon-dl" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "num-integer", - "strength_reduce", + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "xkeysym" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] -name = "vcpkg" -version = "0.2.15" +name = "xml-rs" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] -name = "walkdir" -version = "2.5.0" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "same-file", - "winapi-util", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" +name = "zbus" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" +name = "zbus-lockstep" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.119", - "wasm-bindgen-shared", + "zbus-lockstep", + "zbus_xml", + "zvariant", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" +name = "zbus_macros" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "unicode-ident", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] -name = "web-sys" -version = "0.3.104" +name = "zbus_names" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ - "js-sys", - "wasm-bindgen", + "serde", + "winnow", + "zvariant", ] [[package]] -name = "winapi-util" -version = "0.1.11" +name = "zbus_xml" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ - "windows-sys", + "serde", + "winnow", + "zbus_names", + "zvariant", ] [[package]] -name = "windows" -version = "0.62.2" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "serde", ] [[package]] -name = "windows-collections" -version = "0.3.2" +name = "zerocopy" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ - "windows-core", + "zerocopy-derive", ] [[package]] -name = "windows-core" -version = "0.62.2" +name = "zerocopy-derive" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "windows-future" -version = "0.3.2" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "zerofrom-derive", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", + "synstructure", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zerovec" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "windows-numerics" -version = "0.3.1" +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ - "windows-core", - "windows-link", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "windows-result" -version = "0.4.1" +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] -name = "windows-strings" -version = "0.5.1" +name = "zune-core" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zune-jpeg" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "windows-link", + "zune-core", ] [[package]] -name = "windows-threading" -version = "0.2.1" +name = "zvariant" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ - "windows-link", + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", ] [[package]] -name = "winnow" -version = "1.0.4" +name = "zvariant_derive" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ - "memchr", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", ] [[package]] -name = "zmij" -version = "1.0.23" +name = "zvariant_utils" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index bce1a00..da1e4b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/library", "crates/musik-cli"] +members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/library", "crates/musik-app", "crates/musik-cli"] [workspace.package] edition = "2021" diff --git a/README.md b/README.md index 0130058..298e58a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den Reaktionen der Crowd formt. -Status: **sechs von zehn Phasen gebaut.** Decks mit Keylock, Hot Cues und +Status: **sieben von zehn Phasen gebaut.** Decks mit Keylock, Hot Cues und Loops; Mixer mit EQ, Filter, Crossfader, Cue-Bus und AUX; Analyse für Tempo, Beatgrid und Wellenform; Sync über Tempo *und* Phase; Sammlung mit -Traktor-Import. Nativ in Rust — was fehlt, ist die Oberfläche. +Traktor-Import; Oberfläche mit zwei Decks, Wellenform, Beatgrid und +Plattenkiste. Nativ in Rust. Was fehlt, sind Effekte, Stems, Mitschnitt und +MIDI. + +![Die Oberfläche](docs/bilder/oberflaeche.png) ## Zielbild @@ -50,8 +54,9 @@ Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap -**Sechs von zehn Phasen sind gebaut.** Was jetzt fehlt, ist vor allem die -Oberfläche — bedienen lässt sich alles bisher nur über die Kommandozeile. +**Sieben von zehn Phasen sind gebaut.** Die Software lässt sich bedienen: +Tracks aus der Sammlung auf die Decks legen, Wellenform und Beatgrid sehen, +mischen. Was fehlt, sind Effekte, Stems, Mitschnitt und MIDI. Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: @@ -60,14 +65,15 @@ Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: | --- | --- | | 1 ⚙️ | Mehrdeck-Engine mit Mixer, Cue-Bus und AUX — Code steht, Abnahme braucht Hardware | | ~~2~~ ✅ | ~~Analyse-Pipeline (BPM, Grid, Peaks)~~ | -| **3** | **UI mit Waveforms — das größte offene Stück** | +| ~~3~~ ✅ | ~~UI mit Wellenformen, Beatgrid und Plattenkiste~~ | | ~~4~~ ✅ | ~~Sync und Beatmatching über Tempo und Phase~~ | -| ~~5~~ ✅ | ~~Hot Cues und Loops~~ (Bedienung kommt mit Phase 3) | +| ~~5~~ ✅ | ~~Hot Cues und Loops~~ | | ~~6~~ ✅ | ~~Library inkl. Traktor-Import~~ | -| 7–10 | Effekte · Stems · Mitschnitt · MIDI-Controller | +| **7–10** | **Effekte · Stems · Mitschnitt · MIDI-Controller** | -Zum Auflegen fehlt vor allem Phase 3: Ohne Oberfläche mit Waveforms lässt sich -das Vorhandene zwar testen, aber nicht spielen. +Zum Auflegen fehlt jetzt nur noch Hardware: Phase 1 ist abgenommen, sobald der +Cue-Bus hörbar getrennt auf den Ausgängen 3/4 liegt, und das braucht ein +Interface mit vier Ausgängen. Darauf setzen die beiden Schichten aus dem Zielbild auf: @@ -97,6 +103,8 @@ und der Werkzeugkasten sollte sie nicht vorwegnehmen. | `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | | `rtrb` | MIT/Apache-2.0 | Lock-freier Ringpuffer für AUX | | `blake3`, `serde`, `base64` | permissiv | Fingerabdruck und Sidecar | +| `eframe`, `egui` | MIT/Apache-2.0 | Oberfläche | +| `image` | MIT/Apache-2.0 | Screenshots schreiben | Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert — damit ist der gesamte Stack MIT-verträglich. Rubber Band wäre beim Stretching besser, ist @@ -212,4 +220,25 @@ einer bestehenden Traktor-Sammlung — ⚠️ gegen eine *echte* `collection.nml das noch nicht geprüft, nur gegen selbst geschriebene Beispiele. Der erste Lauf gehört stichprobenartig nachgesehen. +Auflegen — **hier** braucht es ein Audiogerät: + +```sh +cargo run --release -p musik-app -- --db musik.db +``` + +| Aufruf | Wirkung | +| --- | --- | +| *(ohne Argumente)* | synthetische Demo-Tracks auf beiden Decks | +| `--db musik.db` | die eigene Sammlung in der Plattenkiste | +| `--a a.mp3 --b b.mp3` | zwei Dateien direkt auf die Decks | +| `--screenshot bild.png` | ein Bild aufnehmen und beenden | + +Ohne Audiogerät startet die Oberfläche trotzdem: der Mixer läuft dann von einem +Taktgeber im Leerlauf, die Decks bewegen sich, die Bedienung lässt sich +beurteilen — nur hören kann man nichts. Findet die Soundkarte weniger als vier +Ausgänge, sagt die Kopfzeile das und das Vorhören entfällt. + +`--release` ist keine Zierde: die Analyse eines frisch geladenen Tracks dauert +im Debug-Build spürbar länger. + Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/musik-app/Cargo.toml b/crates/musik-app/Cargo.toml new file mode 100644 index 0000000..d3155e8 --- /dev/null +++ b/crates/musik-app/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "musik-app" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +analysis = { version = "0.1.0", path = "../analysis" } +anyhow = "1.0.104" +audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } +eframe = "0.35.0" +egui = "0.35.0" +image = { version = "0.25.10", default-features = false, features = ["png"] } +library = { version = "0.1.0", path = "../library" } diff --git a/crates/musik-app/src/app.rs b/crates/musik-app/src/app.rs new file mode 100644 index 0000000..6e10147 --- /dev/null +++ b/crates/musik-app/src/app.rs @@ -0,0 +1,857 @@ +//! Die Oberfläche. +//! +//! Aufbau von oben nach unten: zwei Decks nebeneinander, darunter der Mixer, +//! darunter die Sammlung. Das ist die Anordnung, die man von einem DJ-Setup +//! kennt — Plattenspieler oben, Mischpult in der Mitte, Plattenkiste unten. +//! +//! Die Oberfläche fasst den Mixer nie direkt an. Sie hält gespiegelte Werte für +//! die Darstellung und schickt jede Änderung als Kommando in die lock-freie +//! Schlange. Den Transport steuert sie über die Atomics im `DeckState`. + +use std::path::PathBuf; +use std::sync::Arc; + +use analysis::peaks::PeakLevel; +use audio_core::deck::{DeckState, HOT_CUES}; +use audio_engine::{Assign, Command, EngineHandle, Output}; +use egui::{Color32, RichText, Ui}; +use library::{Library, Query, TrackRecord}; + +use crate::theme; +use crate::waveform; + +/// Breite eines Kanalzuges. Am Gerät sind das gut fünf Zentimeter — schmal +/// genug, dass vier Züge nebeneinander passen, breit genug zum Greifen. +const KANALBREITE: f32 = 132.0; +/// Länge des Linefaders. Ein kurzer Fader lässt sich nicht sauber einblenden. +const FADERHOEHE: f32 = 96.0; +/// Höhe des Mixerfeldes. Muss den ganzen Kanalzug fassen — ist es zu wenig, +/// schneidet das Panel oben die Beschriftung und unten die Fader ab. +const MIXER_HOEHE: f32 = 262.0; +/// Anfangshöhe der Plattenkiste. Sie ist ziehbar, weil man beim Suchen mehr +/// Liste will und beim Mixen mehr Wellenform. +const SAMMLUNG_HOEHE: f32 = 190.0; + +pub struct ChannelUi { + pub name: String, + pub channel: usize, + pub trim: f32, + pub low: f32, + pub mid: f32, + pub high: f32, + pub filter: f32, + pub fader: f32, + pub cue: bool, +} + +impl ChannelUi { + pub fn new(name: impl Into, channel: usize) -> Self { + ChannelUi { + name: name.into(), + channel, + trim: 1.0, + low: 1.0, + mid: 1.0, + high: 1.0, + filter: 0.0, + fader: 0.0, + cue: false, + } + } +} + +pub struct DeckUi { + pub name: String, + pub state: Arc, + pub artist: String, + pub titel: String, + pub peaks: Vec, + pub frames: u64, + pub sample_rate: u32, + pub strip: ChannelUi, + /// Sichtbarer Ausschnitt der Zoom-Ansicht, in Sekunden. + pub zoom_secs: f32, + pub loop_beats: f64, +} + +impl DeckUi { + pub fn position_secs(&self) -> f64 { + self.state.position_frames() as f64 / self.sample_rate as f64 + } + + pub fn dauer_secs(&self) -> f64 { + self.frames as f64 / self.sample_rate as f64 + } + + pub fn cues(&self) -> Vec<(usize, u64)> { + (0..HOT_CUES) + .filter_map(|i| self.state.cue(i).map(|f| (i, f))) + .collect() + } +} + +pub struct Screenshot { + pub pfad: PathBuf, + pub warte_bilder: u32, + pub angefordert: bool, +} + +pub struct MusikApp { + pub handle: EngineHandle, + /// Hält den Audio-Stream am Leben. Fällt er weg, verstummt die Ausgabe — + /// deshalb liegt er hier, auch wenn sonst niemand ihn anfasst. + #[allow(dead_code)] + pub output: Option, + pub audio_hinweis: String, + pub decks: Vec, + pub aux: ChannelUi, + pub crossfader: f32, + pub crossfader_kurve: f32, + pub master_gain: f32, + pub cue_mix: f32, + pub cue_gain: f32, + pub library: Option, + pub analyse_cache: PathBuf, + pub suche: String, + pub treffer: Vec, + pub status: String, + pub screenshot: Option, +} + +impl eframe::App for MusikApp { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + // Abgelöste Quellen abholen, sonst nimmt der Audio-Thread irgendwann + // keine neuen Tracks mehr an. + self.handle.collect_retired(); + + // Kopie, weil `ui` gleich selbst ausgeliehen wird und der Kontext + // danach noch für den Screenshot gebraucht wird. + let ctx = ui.ctx().clone(); + + // Die Decks laufen weiter, also muss auch weiter gezeichnet werden. + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + + egui::Panel::top("kopf").show(ui, |ui| self.kopfzeile(ui)); + + // Die Sammlung wird zuerst angemeldet und liegt damit ganz unten; der + // Mixer schiebt sich darüber, direkt unter die Decks. Genau die + // Reihenfolge, in der man vor dem Gerät sitzt. + egui::Panel::bottom("sammlung") + .resizable(true) + .default_size(SAMMLUNG_HOEHE) + .show(ui, |ui| self.sammlung(ui)); + egui::Panel::bottom("mixer") + .exact_size(MIXER_HOEHE) + .show(ui, |ui| self.mixer(ui)); + + egui::CentralPanel::default().show(ui, |ui| { + // `columns` gibt jeder Spalte ein eigenes Top-down-Layout. Ein + // `horizontal` täte das nicht — die Decks lägen dann seitwärts. + let anzahl = self.decks.len().max(1); + ui.columns(anzahl, |spalten| { + for (index, spalte) in spalten.iter_mut().enumerate() { + self.deck(spalte, index); + } + }); + }); + + self.screenshot_schritt(&ctx); + } +} + +impl MusikApp { + fn kopfzeile(&mut self, ui: &mut Ui) { + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.label(RichText::new("MUSIK").strong().size(15.0)); + ui.label( + RichText::new(&self.audio_hinweis) + .color(theme::TEXT_LEISE) + .size(11.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if !self.status.is_empty() { + ui.label( + RichText::new(&self.status) + .color(theme::TEXT_LEISE) + .size(11.0), + ); + } + }); + }); + ui.add_space(3.0); + } + + fn deck(&mut self, ui: &mut Ui, index: usize) { + let farbe = theme::deck_farbe(index); + + // Was nach Kopfzeilen, Transport und Hot Cues übrig bleibt, bekommen + // die Wellenformen. Sonst klebt das Deck oben und darunter ist Leere. + const RANDRUM: f32 = 178.0; + let rest = (ui.available_height() - RANDRUM).max(120.0); + let uebersicht = (rest * 0.26).clamp(38.0, 90.0); + let zoom = rest - uebersicht; + + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Der Rahmen erbt das Layout des Elternteils; im Mixer wäre das + // waagerecht. Explizit senkrecht, damit die Zeilen untereinander + // stehen und nicht nebeneinander. + ui.vertical(|ui| self.deck_inhalt(ui, index, farbe, uebersicht, zoom)); + }); + } + + fn deck_inhalt( + &mut self, + ui: &mut Ui, + index: usize, + farbe: Color32, + uebersicht: f32, + zoom: f32, + ) { + let (titel, artist, dauer, position, bpm, keylock, laeuft) = { + let deck = &self.decks[index]; + ( + deck.titel.clone(), + deck.artist.clone(), + deck.dauer_secs(), + deck.position_secs(), + deck.state.effective_bpm(), + deck.state.keylock(), + deck.state.is_playing(), + ) + }; + + ui.horizontal(|ui| { + ui.label(RichText::new(&self.decks[index].name).color(farbe).strong()); + ui.label(RichText::new(titel).strong()); + ui.label(RichText::new(artist).color(theme::TEXT_LEISE).size(11.0)); + }); + + ui.horizontal(|ui| { + let tempo = match bpm { + Some(b) => format!("{b:6.2} BPM"), + None => " — BPM".into(), + }; + ui.label( + RichText::new(tempo) + .monospace() + .color(farbe) + .size(17.0) + .strong(), + ); + ui.label( + RichText::new(format!("{} / {}", zeit(position), zeit(dauer))) + .monospace() + .color(theme::TEXT_LEISE), + ); + }); + + ui.add_space(2.0); + self.wellenformen(ui, index, farbe, uebersicht, zoom); + ui.add_space(4.0); + self.transport(ui, index, laeuft, keylock); + ui.add_space(2.0); + self.hot_cues(ui, index); + } + + fn wellenformen( + &mut self, + ui: &mut Ui, + index: usize, + farbe: Color32, + hoehe_uebersicht: f32, + hoehe_zoom: f32, + ) { + let deck = &self.decks[index]; + let gesamt = deck.frames as f64; + let pos = deck.state.position_frames() as f64; + let grid = deck.state.grid(); + let rate = deck.sample_rate; + let cues = deck.cues(); + let zoom_frames = (deck.zoom_secs as f64 * rate as f64).max(1.0); + + // Übersicht über den ganzen Track. + let (rect, antwort) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), hoehe_uebersicht), + egui::Sense::click(), + ); + if !deck.peaks.is_empty() && gesamt > 0.0 { + let stufe = waveform::passende_stufe(&deck.peaks, gesamt, rect.width()); + waveform::zeichne(ui.painter(), rect, stufe, 0.0, gesamt, farbe, Some(pos)); + waveform::zeichne_cues(ui.painter(), rect, &cues, 0.0, gesamt); + let x = rect.left() + (pos / gesamt) as f32 * rect.width(); + waveform::zeichne_playhead(ui.painter(), rect, x); + } + + // Klick in die Übersicht springt dorthin. + if antwort.clicked() { + if let Some(p) = antwort.interact_pointer_pos() { + let t = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0) as f64; + self.decks[index].state.seek_frames((t * gesamt) as u64); + } + } + + ui.add_space(3.0); + + // Ausschnitt um die Abspielposition. + let deck = &self.decks[index]; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), hoehe_zoom), + egui::Sense::hover(), + ); + let von = pos - zoom_frames * 0.5; + let bis = pos + zoom_frames * 0.5; + + if !deck.peaks.is_empty() { + let stufe = waveform::passende_stufe(&deck.peaks, zoom_frames, rect.width()); + waveform::zeichne(ui.painter(), rect, stufe, von, bis, farbe, Some(pos)); + if let Some(g) = grid { + waveform::zeichne_grid(ui.painter(), rect, &g, rate, von, bis, gesamt); + } + waveform::zeichne_cues(ui.painter(), rect, &cues, von, bis); + waveform::zeichne_playhead(ui.painter(), rect, rect.center().x); + } + } + + fn transport(&mut self, ui: &mut Ui, index: usize, laeuft: bool, keylock: bool) { + ui.horizontal(|ui| { + let beschriftung = if laeuft { "⏸ Pause" } else { "▶ Play" }; + if ui.button(RichText::new(beschriftung).size(13.0)).clicked() { + self.decks[index].state.toggle_playing(); + } + + if ui.button("⏮").on_hover_text("An den Anfang").clicked() { + self.decks[index].state.seek_frames(0); + } + + let mut kl = keylock; + if ui.checkbox(&mut kl, "Keylock").changed() { + self.decks[index].state.set_keylock(kl); + } + + // Sync richtet sich immer am jeweils anderen Deck aus. + let anderes = 1 - index.min(1); + if self.decks.len() > 1 && ui.button("SYNC").clicked() { + let rate = self.decks[index].sample_rate; + let master = Arc::clone(&self.decks[anderes].state); + let slave = Arc::clone(&self.decks[index].state); + + match audio_engine::sync(&master, &slave, rate) { + Some(plan) => { + self.status = format!( + "{} auf {} gezogen: Tempo ×{:.4}, Phase {:+.3} Beats", + self.decks[index].name, + self.decks[anderes].name, + plan.tempo, + plan.phase_error_beats + ) + } + None => self.status = "Sync braucht auf beiden Decks ein Beatgrid".into(), + } + } + }); + + ui.horizontal(|ui| { + let mut tempo = self.decks[index].state.tempo(); + let antwort = ui.add( + egui::Slider::new(&mut tempo, 0.92..=1.08) + .custom_formatter(|v, _| format!("{:+.2} %", (v - 1.0) * 100.0)) + .text("Tempo"), + ); + if antwort.changed() { + self.decks[index].state.set_tempo(tempo); + } + if antwort.double_clicked() { + self.decks[index].state.set_tempo(1.0); + } + }); + } + + fn hot_cues(&mut self, ui: &mut Ui, index: usize) { + ui.horizontal_wrapped(|ui| { + for i in 0..HOT_CUES { + let gesetzt = self.decks[index].state.cue(i).is_some(); + let farbe = if gesetzt { + theme::CUE + } else { + theme::TEXT_LEISE + }; + + let knopf = ui.add( + egui::Button::new(RichText::new(format!("{}", i + 1)).color(farbe).monospace()) + .min_size(egui::vec2(24.0, 20.0)), + ); + + if knopf.clicked() { + let deck = &self.decks[index]; + if gesetzt { + deck.state.jump_to_cue(i); + } else { + let pos = deck.state.position_frames(); + deck.state.set_cue(i, Some(pos)); + } + } + if knopf.secondary_clicked() { + self.decks[index].state.set_cue(i, None); + } + } + + ui.separator(); + + let mut beats = self.decks[index].loop_beats; + egui::ComboBox::from_id_salt(("loop", index)) + .width(58.0) + .selected_text(format!("{} Beat", kurz(beats))) + .show_ui(ui, |ui| { + for wert in [1.0, 2.0, 4.0, 8.0, 16.0] { + ui.selectable_value(&mut beats, wert, kurz(wert)); + } + }); + self.decks[index].loop_beats = beats; + + let laeuft = self.decks[index].state.is_looping(); + let text = if laeuft { "LOOP AUS" } else { "LOOP" }; + if ui + .add(egui::Button::new(text).fill(if laeuft { + theme::AKTIV + } else { + theme::PANEL_HELL + })) + .clicked() + { + let deck = &self.decks[index]; + if laeuft { + deck.state.set_loop_active(false); + } else if deck.state.set_loop_beats(beats, deck.sample_rate) { + deck.state.set_loop_active(true); + } else { + self.status = "Beat-Loop braucht ein Beatgrid".into(); + } + } + }); + } + + fn mixer(&mut self, ui: &mut Ui) { + ui.add_space(4.0); + ui.horizontal_top(|ui| { + for index in 0..self.decks.len() { + let farbe = theme::deck_farbe(index); + let mut strip = + std::mem::replace(&mut self.decks[index].strip, ChannelUi::new("", 0)); + self.kanalzug(ui, &mut strip, farbe); + self.decks[index].strip = strip; + } + + let mut aux = std::mem::replace(&mut self.aux, ChannelUi::new("", 0)); + self.kanalzug(ui, &mut aux, theme::AUX); + self.aux = aux; + + ui.separator(); + self.summe(ui); + }); + } + + fn kanalzug(&mut self, ui: &mut Ui, strip: &mut ChannelUi, farbe: Color32) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Senkrecht, sonst stünden die Regler eines Zuges nebeneinander + // statt übereinander — der Rahmen erbt hier ein waagerechtes + // Layout vom Mixer. + ui.vertical(|ui| self.kanalzug_inhalt(ui, strip, farbe)); + }); + } + + fn kanalzug_inhalt(&mut self, ui: &mut Ui, strip: &mut ChannelUi, farbe: Color32) { + ui.set_width(KANALBREITE); + ui.label(RichText::new(&strip.name).color(farbe).strong().size(12.0)); + + // Die Drehregler eines echten Mixers sind schmal; hier sind es liegende + // Schieber, also bekommen sie die Breite des Zuges und keine Zahl — + // beim Auflegen liest man die ohnehin nicht. + let regler = + |ui: &mut Ui, wert: &mut f32, name: &str, spanne: std::ops::RangeInclusive| { + ui.spacing_mut().slider_width = KANALBREITE - 34.0; + ui.add( + egui::Slider::new(wert, spanne) + .show_value(false) + .text(RichText::new(name).size(10.0)), + ) + .changed() + }; + + if regler(ui, &mut strip.trim, "TRIM", 0.0..=2.0) { + self.handle.send(Command::Trim(strip.channel, strip.trim)); + } + + let mut eq_geaendert = regler(ui, &mut strip.high, "HI", 0.0..=2.0); + eq_geaendert |= regler(ui, &mut strip.mid, "MID", 0.0..=2.0); + eq_geaendert |= regler(ui, &mut strip.low, "LOW", 0.0..=2.0); + if eq_geaendert { + self.handle.send(Command::Eq { + channel: strip.channel, + low: strip.low, + mid: strip.mid, + high: strip.high, + }); + } + + if regler(ui, &mut strip.filter, "FLT", -1.0..=1.0) { + self.handle + .send(Command::Filter(strip.channel, strip.filter)); + } + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + if ui + .add( + egui::Button::new(RichText::new("CUE").size(10.0)) + .fill(if strip.cue { + theme::CUE + } else { + theme::PANEL_HELL + }) + .min_size(egui::vec2(42.0, 22.0)), + ) + .clicked() + { + strip.cue = !strip.cue; + self.handle.send(Command::Cue(strip.channel, strip.cue)); + } + }); + + // Der Linefader steht senkrecht wie am Gerät. + ui.spacing_mut().slider_width = FADERHOEHE; + if ui + .add( + egui::Slider::new(&mut strip.fader, 0.0..=1.0) + .show_value(false) + .vertical(), + ) + .changed() + { + self.handle.send(Command::Fader(strip.channel, strip.fader)); + } + }); + } + + fn summe(&mut self, ui: &mut Ui) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + ui.vertical(|ui| self.summe_inhalt(ui)); + }); + } + + fn summe_inhalt(&mut self, ui: &mut Ui) { + ui.set_width(250.0); + ui.spacing_mut().slider_width = 214.0; + ui.label(RichText::new("SUMME").strong().size(12.0)); + + if ui + .add( + egui::Slider::new(&mut self.crossfader, -1.0..=1.0) + .show_value(false) + .text(RichText::new("A « Crossfader » B").size(10.0)), + ) + .changed() + { + self.handle.send(Command::Crossfader(self.crossfader)); + } + + if ui + .add( + egui::Slider::new(&mut self.crossfader_kurve, 0.0..=1.0) + .show_value(false) + .text(RichText::new("Kurve weich » hart").size(10.0)), + ) + .changed() + { + self.handle + .send(Command::CrossfaderCurve(self.crossfader_kurve)); + } + + ui.separator(); + + if ui + .add( + egui::Slider::new(&mut self.master_gain, 0.0..=1.5) + .show_value(false) + .text(RichText::new("MASTER").size(10.0)), + ) + .changed() + { + self.handle.send(Command::MasterGain(self.master_gain)); + } + + if ui + .add( + egui::Slider::new(&mut self.cue_gain, 0.0..=1.5) + .show_value(false) + .text(RichText::new("KOPFHÖRER").size(10.0)), + ) + .changed() + { + self.handle.send(Command::CueGain(self.cue_gain)); + } + + if ui + .add( + egui::Slider::new(&mut self.cue_mix, 0.0..=1.0) + .show_value(false) + .text(RichText::new("CUE « Mix » MASTER").size(10.0)), + ) + .changed() + { + self.handle.send(Command::CueMix(self.cue_mix)); + } + } + + fn sammlung(&mut self, ui: &mut Ui) { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("SAMMLUNG").strong().size(12.0)); + + let feld = ui.add( + egui::TextEdit::singleline(&mut self.suche) + .hint_text("Titel, Künstler, Album …") + .desired_width(240.0), + ); + if feld.changed() { + self.suchen(); + } + + if ui.button("Mischbar zu A").clicked() { + self.mischbar_suchen(0); + } + if ui.button("Mischbar zu B").clicked() { + self.mischbar_suchen(1); + } + + if self.library.is_none() { + ui.label( + RichText::new("keine Sammlung geladen — mit --db öffnen") + .color(theme::WARNUNG) + .size(11.0), + ); + } + }); + + ui.add_space(3.0); + + egui::ScrollArea::vertical() + // Ohne das schrumpft die Plattenkiste bei leerer Liste auf eine + // Zeile zusammen und das Panel klappt mit. + .auto_shrink(false) + .show(ui, |ui| { + egui::Grid::new("trefferliste") + .num_columns(4) + .striped(true) + .min_col_width(70.0) + .show(ui, |ui| { + for spalte in ["BPM", "KÜNSTLER", "TITEL", "LADEN"] { + ui.label( + RichText::new(spalte) + .color(theme::TEXT_LEISE) + .size(10.0) + .strong(), + ); + } + ui.end_row(); + + for eintrag in self.treffer.clone() { + let bpm = eintrag + .bpm + .map(|b| format!("{b:6.2}")) + .unwrap_or_else(|| " —".into()); + ui.label(RichText::new(bpm).monospace()); + ui.label(eintrag.artist.clone().unwrap_or_else(|| "—".into())); + ui.label(anzeigename(&eintrag)); + + // Nur „A" und „B": ein Pfeilzeichen wäre schöner, + // aber die mitgelieferte Schrift hat keins, und ein + // fehlender Glyph wird zum leeren Kästchen. + ui.horizontal(|ui| { + for (deck, name) in [(0usize, "A"), (1usize, "B")] { + let farbe = theme::deck_farbe(deck); + if ui + .small_button(RichText::new(name).color(farbe).strong()) + .on_hover_text(format!("auf Deck {name} laden")) + .clicked() + { + self.laden(deck, &eintrag); + } + } + }); + ui.end_row(); + } + }); + }); + } + + fn suchen(&mut self) { + let Some(lib) = self.library.as_ref() else { + return; + }; + let mut query = Query::text(self.suche.clone()); + query.limit = Some(200); + self.treffer = lib.search(&query).unwrap_or_default(); + } + + fn mischbar_suchen(&mut self, deck: usize) { + let Some(lib) = self.library.as_ref() else { + return; + }; + let Some(bpm) = self.decks.get(deck).and_then(|d| d.state.effective_bpm()) else { + self.status = "Das Deck hat kein Tempo".into(); + return; + }; + + let mut query = Query::mixable_with(bpm, 0.06); + query.limit = Some(200); + self.treffer = lib.search(&query).unwrap_or_default(); + self.status = format!("Mischbar mit {bpm:.2} BPM: {} Treffer", self.treffer.len()); + } + + fn laden(&mut self, deck: usize, eintrag: &TrackRecord) { + match crate::laden::track_auf_deck(self, deck, eintrag) { + Ok(()) => self.status = format!("{} → {}", anzeigename(eintrag), self.decks[deck].name), + Err(e) => self.status = format!("Laden fehlgeschlagen: {e}"), + } + } + + /// Nimmt nach ein paar Bildern ein Abbild auf und beendet sich. + fn screenshot_schritt(&mut self, ctx: &egui::Context) { + let Some(auftrag) = self.screenshot.as_mut() else { + return; + }; + + if !auftrag.angefordert { + if auftrag.warte_bilder > 0 { + auftrag.warte_bilder -= 1; + ctx.request_repaint(); + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + auftrag.angefordert = true; + ctx.request_repaint(); + return; + } + + let bild = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Screenshot { image, .. } => Some(Arc::clone(image)), + _ => None, + }) + }); + + if let Some(bild) = bild { + let pfad = auftrag.pfad.clone(); + match speichern(&bild, &pfad) { + Ok(()) => println!("Screenshot: {}", pfad.display()), + Err(e) => eprintln!("Screenshot fehlgeschlagen: {e}"), + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + ctx.request_repaint(); + } +} + +fn speichern(bild: &egui::ColorImage, pfad: &std::path::Path) -> anyhow::Result<()> { + let [breite, hoehe] = bild.size; + let mut roh = Vec::with_capacity(breite * hoehe * 4); + for pixel in &bild.pixels { + roh.extend_from_slice(&[pixel.r(), pixel.g(), pixel.b(), pixel.a()]); + } + + let puffer = image::RgbaImage::from_raw(breite as u32, hoehe as u32, roh) + .ok_or_else(|| anyhow::anyhow!("Bildgröße passt nicht zu den Daten"))?; + puffer.save(pfad)?; + Ok(()) +} + +/// Zuweisung eines Decks am Crossfader nach Index. +pub fn assign_fuer(index: usize) -> Assign { + match index { + 0 => Assign::A, + 1 => Assign::B, + _ => Assign::Thru, + } +} + +/// Was in der Liste als Titel steht. +/// +/// Ohne Tags — bei WAV-Dateien der Normalfall — bleibt der Dateiname. Der +/// ganze Pfad wäre in einer Trefferliste unbrauchbar: er ist zu lang, und der +/// unterscheidende Teil steht ganz hinten. +pub fn anzeigename(eintrag: &TrackRecord) -> String { + if let Some(titel) = eintrag.title.as_ref().filter(|t| !t.trim().is_empty()) { + return titel.clone(); + } + + std::path::Path::new(&eintrag.path) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| eintrag.path.clone()) +} + +/// Zahl ohne überflüssige Nachkommastellen — "4" statt "4.00". +fn kurz(wert: f64) -> String { + if wert.fract().abs() < 1e-9 { + format!("{}", wert.round() as i64) + } else { + format!("{wert:.2}") + } +} + +fn zeit(secs: f64) -> String { + let secs = secs.max(0.0); + let m = (secs / 60.0).floor() as u64; + format!("{m}:{:05.2}", secs - m as f64 * 60.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ganze_zahlen_werden_ohne_nachkomma_gezeigt() { + assert_eq!(kurz(4.0), "4"); + assert_eq!(kurz(16.0), "16"); + assert_eq!(kurz(0.5), "0.50"); + } + + #[test] + fn zeit_wird_als_minuten_und_sekunden_gezeigt() { + assert_eq!(zeit(0.0), "0:00.00"); + assert_eq!(zeit(65.5), "1:05.50"); + assert_eq!(zeit(-3.0), "0:00.00"); + } + + #[test] + fn decks_liegen_auf_den_crossfader_seiten() { + assert_eq!(assign_fuer(0), Assign::A); + assert_eq!(assign_fuer(1), Assign::B); + // Alles Weitere — AUX etwa — bleibt unberührt vom Crossfader. + assert_eq!(assign_fuer(2), Assign::Thru); + } + + #[test] + fn ein_neuer_kanalzug_startet_mit_geschlossenem_fader() { + let strip = ChannelUi::new("A", 0); + assert_eq!(strip.fader, 0.0); + assert!(!strip.cue); + assert_eq!((strip.low, strip.mid, strip.high), (1.0, 1.0, 1.0)); + assert_eq!(strip.filter, 0.0); + } +} diff --git a/crates/musik-app/src/demo.rs b/crates/musik-app/src/demo.rs new file mode 100644 index 0000000..a3596fb --- /dev/null +++ b/crates/musik-app/src/demo.rs @@ -0,0 +1,171 @@ +//! Synthetische Tracks für den Fall ohne Musiksammlung. +//! +//! Damit lässt sich die Oberfläche starten und beurteilen, ohne dass Dateien +//! vorliegen müssen — und sie zeigt dabei echte Wellenformen und ein echtes +//! Beatgrid, weil das Material durch dieselbe Analyse läuft wie alles andere. + +use std::f32::consts::PI; + +use audio_core::Track; + +pub struct DemoTrack { + pub track: Track, + pub artist: String, + pub title: String, +} + +pub fn deck_a(sample_rate: u32) -> DemoTrack { + DemoTrack { + track: bauen(sample_rate, 128.0, 16, muster_a), + artist: "Demo".into(), + title: "Vier auf die Eins · 128".into(), + } +} + +pub fn deck_b(sample_rate: u32) -> DemoTrack { + DemoTrack { + track: bauen(sample_rate, 124.0, 16, muster_b), + artist: "Demo".into(), + title: "Snare-Muster · 124".into(), + } +} + +fn muster_a(bar: usize, step: usize, add: &mut dyn FnMut(usize, f32), rate: u32) { + if step.is_multiple_of(4) { + kick(add, rate); + } + if step % 4 == 2 { + hat(add, rate, 0.22); + } + if step.is_multiple_of(2) { + let note = [55.0, 55.0, 73.42, 65.41][bar % 4]; + bass(add, note, rate, 0.30); + } +} + +fn muster_b(bar: usize, step: usize, add: &mut dyn FnMut(usize, f32), rate: u32) { + if step.is_multiple_of(4) { + kick(add, rate); + } + if step % 8 == 4 { + snare(add, rate); + } + if step % 2 == 1 { + let note = [49.0, 61.74, 49.0, 58.27][bar % 4]; + bass(add, note, rate, 0.26); + } +} + +/// Ein Muster füllt einen Sechzehntel-Schritt eines Taktes. +/// +/// Die Klänge werden über einen Rückruf addiert statt zurückgegeben, damit +/// sich mehrere Klänge im selben Schritt überlagern können. +type Muster = fn(usize, usize, &mut dyn FnMut(usize, f32), u32); + +fn bauen(rate: u32, bpm: f32, bars: usize, muster: Muster) -> Track { + let frames_je_step = rate as f32 * 60.0 / bpm / 4.0; + let total = (frames_je_step * 16.0 * bars as f32) as usize; + let mut mono = vec![0.0f32; total]; + + for bar in 0..bars { + for step in 0..16 { + let start = ((bar * 16 + step) as f32 * frames_je_step) as usize; + let mut add = |offset: usize, value: f32| { + let idx = start + offset; + if idx < total { + mono[idx] += value; + } + }; + muster(bar, step, &mut add, rate); + } + } + + let peak = mono.iter().fold(0.0f32, |m, v| m.max(v.abs())).max(1e-6); + let scale = 0.75 / peak; + + Track { + samples: mono + .into_iter() + .flat_map(|v| { + let s = v * scale; + [s, s] + }) + .collect(), + sample_rate: rate, + } +} + +fn kick(add: &mut dyn FnMut(usize, f32), rate: u32) { + let n = (rate as f32 * 0.25) as usize; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 22.0).exp(); + let f = 110.0 * (-t * 30.0).exp() + 45.0; + add(i, (2.0 * PI * f * t).sin() * env * 0.9); + } +} + +fn snare(add: &mut dyn FnMut(usize, f32), rate: u32) { + let n = (rate as f32 * 0.19) as usize; + let mut seed = 0x2545_F491u32; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 30.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add( + i, + (noise * 0.6 + (2.0 * PI * 190.0 * t).sin() * 0.4) * env * 0.6, + ); + } +} + +fn hat(add: &mut dyn FnMut(usize, f32), rate: u32, gain: f32) { + let n = (rate as f32 * 0.05) as usize; + let mut seed = 0x9E37_79B9u32; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 120.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add(i, noise * env * gain); + } +} + +fn bass(add: &mut dyn FnMut(usize, f32), freq: f32, rate: u32, gain: f32) { + let n = (rate as f32 * 0.3) as usize; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (1.0 - (-t * 120.0).exp()) * (-t * 6.0).exp(); + let saw = 2.0 * ((freq * t) % 1.0) - 1.0; + add(i, saw * env * gain); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Die Demo verspricht in ihrem Titel ein Tempo. Findet die Analyse es + /// nicht wieder, zeigt die Oberfläche „— BPM" und Sync bleibt tot — dann + /// taugt die Demo nicht als Vorführung. + #[test] + fn die_analyse_findet_das_versprochene_tempo() { + for (demo, erwartet) in [(deck_a(48_000), 128.0), (deck_b(48_000), 124.0)] { + let analyse = analysis::analyze(&demo.track); + let bpm = analyse + .bpm + .unwrap_or_else(|| panic!("{}: kein Tempo erkannt", demo.title)); + assert!( + (bpm - erwartet).abs() < 1.0, + "{}: {bpm} statt {erwartet}", + demo.title + ); + assert!( + analyse.beat_anchor_frames.is_some(), + "{}: kein Anker", + demo.title + ); + } + } +} diff --git a/crates/musik-app/src/laden.rs b/crates/musik-app/src/laden.rs new file mode 100644 index 0000000..5b198ca --- /dev/null +++ b/crates/musik-app/src/laden.rs @@ -0,0 +1,72 @@ +//! Einen Track auf ein Deck legen. +//! +//! Dekodieren und Analysieren dauern und blockieren — deshalb passiert beides +//! hier, im Thread der Oberfläche, und nicht im Audio-Callback. Der bekommt nur +//! die fertige Quelle zugeschoben und reicht die alte zurück. + +use std::sync::Arc; + +use analysis::Store; +use anyhow::{Context, Result}; +use audio_core::deck::Voice; +use audio_core::{Beatgrid, Track}; +use audio_engine::DeckSource; +use library::TrackRecord; + +use crate::app::MusikApp; + +/// Lädt den Track eines Library-Eintrags auf ein Deck. +pub fn track_auf_deck(app: &mut MusikApp, deck: usize, eintrag: &TrackRecord) -> Result<()> { + let rate = app + .decks + .get(deck) + .context("Deck gibt es nicht")? + .sample_rate; + + let track = Track::decode_file(std::path::Path::new(&eintrag.path)) + .with_context(|| format!("{} nicht lesbar", eintrag.path))? + .resampled_to(rate); + + let store = Store::new(&app.analyse_cache); + let (analyse, gerechnet) = analysis::analyze_cached(&track, &store); + if gerechnet { + let _ = store.save(&analyse); + } + + let peaks = analyse + .peaks + .iter() + .filter_map(|p| p.to_level()) + .collect::>(); + + let frames = track.frames() as u64; + let state = Arc::clone(&app.decks[deck].state); + + // Der neue Track startet gestoppt am Anfang — ein Deck, das beim Laden + // losläuft, ist ein Unfall. + state.set_playing(false); + state.seek_frames(0); + state.set_loop_active(false); + for i in 0..audio_core::deck::HOT_CUES { + state.set_cue(i, None); + } + state.set_grid( + analyse + .bpm + .map(|bpm| Beatgrid::new(bpm, analyse.beat_anchor_frames.unwrap_or(0), 1.0)), + ); + + let voice = Voice::new(Arc::new(track), Arc::clone(&state)); + let kanal = app.decks[deck].strip.channel; + if !app.handle.load(kanal, Box::new(DeckSource::new(voice))) { + anyhow::bail!("die Ladeschlange ist voll — gleich noch einmal versuchen"); + } + + let ui = &mut app.decks[deck]; + ui.peaks = peaks; + ui.frames = frames; + ui.titel = crate::app::anzeigename(eintrag); + ui.artist = eintrag.artist.clone().unwrap_or_default(); + + Ok(()) +} diff --git a/crates/musik-app/src/main.rs b/crates/musik-app/src/main.rs new file mode 100644 index 0000000..f01a160 --- /dev/null +++ b/crates/musik-app/src/main.rs @@ -0,0 +1,241 @@ +//! Die Oberfläche des DJ-Werkzeugs. +//! +//! ```text +//! musik-app # mit synthetischen Demo-Tracks +//! musik-app --db musik.db # mit der eigenen Sammlung +//! musik-app --a a.mp3 --b b.mp3 # zwei Dateien direkt auf die Decks +//! musik-app --screenshot bild.png # ein Bild aufnehmen und beenden +//! ``` +//! +//! Ohne Audiogerät läuft alles trotzdem: Der Mixer wird dann von einem +//! Taktgeber im Leerlauf gerendert, sodass die Decks sich bewegen und die +//! Oberfläche sich beurteilen lässt. Nur hören kann man nichts. + +mod app; +mod demo; +mod laden; +mod theme; +mod waveform; + +use std::path::PathBuf; +use std::sync::Arc; + +use analysis::Store; +use anyhow::{Context, Result}; +use audio_core::deck::{DeckState, Voice}; +use audio_core::{Beatgrid, Track}; +use audio_engine::{aux_channel, DeckSource, Engine, EngineRunner, Output}; +use library::Library; + +use app::{assign_fuer, ChannelUi, DeckUi, MusikApp, Screenshot}; + +const RATE: u32 = 48_000; + +struct Args { + db: Option, + cache: PathBuf, + a: Option, + b: Option, + screenshot: Option, +} + +fn main() -> Result<()> { + let args = parse_args()?; + + let mut engine = Engine::new(RATE as f32); + let cache = Store::new(&args.cache); + + let demo = args.a.is_none() && args.b.is_none(); + let mut decks = Vec::new(); + + for (index, (name, pfad)) in [("DECK A", &args.a), ("DECK B", &args.b)] + .into_iter() + .enumerate() + { + let (track, artist, titel) = match pfad { + Some(p) => { + let track = Track::decode_file(p) + .with_context(|| format!("{} nicht lesbar", p.display()))? + .resampled_to(RATE); + let titel = p + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + (track, String::new(), titel) + } + None => { + let d = if index == 0 { + demo::deck_a(RATE) + } else { + demo::deck_b(RATE) + }; + (d.track, d.artist, d.title) + } + }; + + let (analyse, gerechnet) = analysis::analyze_cached(&track, &cache); + if gerechnet && pfad.is_some() { + let _ = cache.save(&analyse); + } + + let state = Arc::new(DeckState::new()); + state.set_keylock(true); + state.set_grid( + analyse + .bpm + .map(|bpm| Beatgrid::new(bpm, analyse.beat_anchor_frames.unwrap_or(0), 1.0)), + ); + + let frames = track.frames() as u64; + let voice = Voice::new(Arc::new(track), Arc::clone(&state)); + let kanal = engine.add_channel(name, Box::new(DeckSource::new(voice))); + engine.channel(kanal).set_assign(assign_fuer(index)); + + // Im Demobetrieb laufen die Decks sofort, damit es etwas zu sehen gibt. + // Mit eigenen Dateien bleibt der Fader unten — ein Deck, das beim + // Start auf die Anlage geht, ist ein Unfall. + let fader = if demo { 0.8 } else { 0.0 }; + engine.channel(kanal).set_fader(fader); + state.set_playing(demo); + + let mut strip = ChannelUi::new(name, kanal); + strip.fader = fader; + + decks.push(DeckUi { + name: name.to_string(), + state, + artist, + titel, + peaks: analyse.peaks.iter().filter_map(|p| p.to_level()).collect(), + frames, + sample_rate: RATE, + strip, + zoom_secs: 8.0, + loop_beats: 4.0, + }); + } + + // AUX bleibt ohne Zuspieler still, ist aber vorhanden und bedienbar. + let (_aux_writer, aux_source) = aux_channel(RATE as usize * 2); + let aux_kanal = engine.add_channel("AUX", Box::new(aux_source)); + engine.channel(aux_kanal).set_assign(assign_fuer(2)); + + let (handle, runner) = audio_engine::engine_channel(engine, 512); + + let (output, hinweis) = match Output::open(runner) { + Ok(out) => { + let cue = if out.has_cue_output() { + "Cue auf 3/4".to_string() + } else { + format!("nur {} Kanäle — kein Vorhören", out.channels()) + }; + let text = format!("{} · {} Hz · {}", out.device_name(), out.sample_rate(), cue); + (Some(out), text) + } + Err(fehler) => { + let text = format!("kein Audiogerät ({fehler}) — Trockenlauf"); + // Der Mixer läuft weiter, nur eben ins Leere — sonst stünde die + // Oberfläche still und wäre nicht zu beurteilen. + trockenlauf(fehler.runner); + (None, text) + } + }; + + let library = args.db.as_ref().and_then(|p| Library::open(p).ok()); + let treffer = library + .as_ref() + .and_then(|l| l.search(&library::Query::default()).ok()) + .unwrap_or_default(); + + let anwendung = MusikApp { + handle, + output, + audio_hinweis: hinweis, + decks, + aux: ChannelUi::new("AUX", aux_kanal), + crossfader: 0.0, + crossfader_kurve: 0.0, + master_gain: 1.0, + cue_mix: 0.0, + cue_gain: 1.0, + library, + analyse_cache: args.cache.clone(), + suche: String::new(), + treffer, + status: if demo { + "Demobetrieb mit synthetischen Tracks".into() + } else { + String::new() + }, + screenshot: args.screenshot.map(|pfad| Screenshot { + pfad, + warte_bilder: 30, + angefordert: false, + }), + }; + + let optionen = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1500.0, 950.0]) + .with_min_inner_size([1100.0, 760.0]) + .with_title("Musik"), + ..Default::default() + }; + + eframe::run_native( + "Musik", + optionen, + Box::new(|cc| { + theme::stil(&cc.egui_ctx); + Ok(Box::new(anwendung)) + }), + ) + .map_err(|e| anyhow::anyhow!("Fenster ließ sich nicht öffnen: {e}")) +} + +fn parse_args() -> Result { + let mut args = Args { + db: None, + cache: PathBuf::from(".musik-analyse"), + a: None, + b: None, + screenshot: None, + }; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + let mut wert = || iter.next().context("fehlender Wert"); + match arg.as_str() { + "--db" => args.db = Some(PathBuf::from(wert()?)), + "--cache" => args.cache = PathBuf::from(wert()?), + "--a" => args.a = Some(PathBuf::from(wert()?)), + "--b" => args.b = Some(PathBuf::from(wert()?)), + "--screenshot" => args.screenshot = Some(PathBuf::from(wert()?)), + "-h" | "--help" => { + println!("Aufruf: musik-app [--db ] [--a ] [--b ]"); + println!(" [--cache ] [--screenshot ]"); + std::process::exit(0); + } + other => anyhow::bail!("unbekannte Option: {other}"), + } + } + + Ok(args) +} + +/// Rendert den Mixer im Leerlauf, wenn kein Audiogerät da ist. +/// +/// Ohne das stünden die Decks still und die Oberfläche wäre ein Standbild — +/// gerade in einer Umgebung ohne Soundkarte wäre sie dann nicht zu beurteilen. +fn trockenlauf(mut runner: EngineRunner) { + std::thread::spawn(move || { + const BLOCK: usize = 1_024; + let mut puffer = vec![0.0f32; BLOCK * 4]; + let takt = std::time::Duration::from_secs_f64(BLOCK as f64 / RATE as f64); + + loop { + runner.render(&mut puffer, 4); + std::thread::sleep(takt); + } + }); +} diff --git a/crates/musik-app/src/theme.rs b/crates/musik-app/src/theme.rs new file mode 100644 index 0000000..6e95f30 --- /dev/null +++ b/crates/musik-app/src/theme.rs @@ -0,0 +1,54 @@ +//! Farben und Maße. +//! +//! Dunkel, weil man in einem Club sitzt und nicht in einem Büro. Die Deckfarben +//! unterscheiden sich deutlich voneinander — im Halbdunkel muss auf einen Blick +//! klar sein, welche Hälfte gerade läuft. + +use egui::Color32; + +pub const BG: Color32 = Color32::from_rgb(16, 17, 21); +pub const PANEL: Color32 = Color32::from_rgb(24, 26, 32); +pub const PANEL_HELL: Color32 = Color32::from_rgb(34, 37, 45); +pub const RAHMEN: Color32 = Color32::from_rgb(52, 56, 68); + +pub const TEXT: Color32 = Color32::from_rgb(226, 229, 236); +pub const TEXT_LEISE: Color32 = Color32::from_rgb(138, 145, 160); + +pub const DECK_A: Color32 = Color32::from_rgb(94, 190, 255); +pub const DECK_B: Color32 = Color32::from_rgb(255, 158, 84); +pub const AUX: Color32 = Color32::from_rgb(150, 230, 160); + +pub const GESPIELT: Color32 = Color32::from_rgb(78, 84, 98); +pub const BEAT: Color32 = Color32::from_rgb(70, 76, 92); +pub const TAKT: Color32 = Color32::from_rgb(120, 128, 150); +pub const CUE: Color32 = Color32::from_rgb(255, 214, 92); +pub const PLAYHEAD: Color32 = Color32::from_rgb(255, 96, 96); + +pub const AKTIV: Color32 = Color32::from_rgb(96, 220, 140); +pub const WARNUNG: Color32 = Color32::from_rgb(255, 128, 96); + +/// Farbe eines Decks nach Index. +pub fn deck_farbe(index: usize) -> Color32 { + match index { + 0 => DECK_A, + 1 => DECK_B, + _ => AUX, + } +} + +pub fn stil(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = BG; + visuals.window_fill = PANEL; + visuals.extreme_bg_color = Color32::from_rgb(12, 13, 16); + visuals.widgets.noninteractive.bg_fill = PANEL; + visuals.widgets.inactive.bg_fill = PANEL_HELL; + visuals.widgets.hovered.bg_fill = RAHMEN; + visuals.override_text_color = Some(TEXT); + ctx.set_visuals(visuals); + + ctx.all_styles_mut(|style| { + style.spacing.item_spacing = egui::vec2(6.0, 5.0); + style.spacing.slider_width = 130.0; + }); +} diff --git a/crates/musik-app/src/waveform.rs b/crates/musik-app/src/waveform.rs new file mode 100644 index 0000000..883d9d2 --- /dev/null +++ b/crates/musik-app/src/waveform.rs @@ -0,0 +1,278 @@ +//! Wellenform-Darstellung. +//! +//! Zwei Ansichten, wie bei jedem DJ-Programm: eine Übersicht über den ganzen +//! Track und ein Ausschnitt um die Abspielposition. Die Übersicht dient dem +//! Springen, der Ausschnitt dem Mixen. +//! +//! Im Zeichenpfad wird **nicht gerechnet**. Die Spitzen liegen aus der Analyse +//! vorberechnet in mehreren Auflösungen vor; hier wird nur noch ausgewählt und +//! gemalt. Über die Rohdaten zu laufen hieße dreizehn Millionen Werte pro Deck +//! und Bild — bei 60 Bildern pro Sekunde aussichtslos. + +use analysis::peaks::PeakLevel; +use audio_core::Beatgrid; +use egui::{Color32, Painter, Pos2, Rect, Stroke}; + +use crate::theme; + +/// Wählt die Auflösung, die zur Zoomstufe passt. +/// +/// Zu fein heißt: hunderte Spitzen je Bildspalte zusammenfassen, also Arbeit +/// ohne Nutzen. Zu grob heißt: Treppenstufen. +pub fn passende_stufe(levels: &[PeakLevel], sichtbare_frames: f64, breite_px: f32) -> &PeakLevel { + let gewuenscht = (sichtbare_frames / breite_px.max(1.0) as f64).max(1.0); + + levels + .iter() + .filter(|l| !l.is_empty()) + .min_by_key(|l| { + let verhaeltnis = l.samples_per_peak as f64 / gewuenscht; + // Logarithmischer Abstand, damit „doppelt so fein" und „halb so + // fein" gleich schlecht zählen. + (verhaeltnis.log2().abs() * 1_000.0) as i64 + }) + .unwrap_or(&levels[0]) +} + +/// Welche Spitzen zu einer Bildspalte gehören. +/// +/// `None`, wenn die Spalte ganz vor dem Anfang oder hinter dem Ende des Tracks +/// liegt. Das ist der Normalfall in der Zoom-Ansicht: sie ist um die +/// Abspielposition zentriert, also ragt sie in den ersten Sekunden links über +/// den Track hinaus. Ohne die Prüfung wird dort die erste Spitze für jede +/// Spalte wiederholt — ein Balken, den es nicht gibt. +fn spitzen_bereich( + von_frame: f64, + bis_frame: f64, + samples_je_spitze: u32, + anzahl: usize, +) -> Option<(usize, usize)> { + if bis_frame <= 0.0 || anzahl == 0 || samples_je_spitze == 0 { + return None; + } + + let je = samples_je_spitze as f64; + let i0 = (von_frame / je).floor().max(0.0) as usize; + if i0 >= anzahl { + return None; + } + + let i1 = ((bis_frame / je).ceil().max(0.0) as usize).clamp(i0 + 1, anzahl); + Some((i0, i1)) +} + +/// Zeichnet einen Frame-Bereich als Wellenform. +#[allow(clippy::too_many_arguments)] +pub fn zeichne( + painter: &Painter, + rect: Rect, + level: &PeakLevel, + von_frame: f64, + bis_frame: f64, + farbe: Color32, + gespielt_bis: Option, +) { + painter.rect_filled(rect, 2.0, Color32::from_rgb(12, 13, 16)); + + if level.is_empty() || bis_frame <= von_frame { + return; + } + + let mitte = rect.center().y; + let halbe_hoehe = rect.height() * 0.5 - 1.0; + let spalten = rect.width().max(1.0) as usize; + let frames_je_spalte = (bis_frame - von_frame) / spalten as f64; + + for spalte in 0..spalten { + let x = rect.left() + spalte as f32 + 0.5; + let f0 = von_frame + spalte as f64 * frames_je_spalte; + let f1 = f0 + frames_je_spalte; + + let Some((i0, i1)) = spitzen_bereich(f0, f1, level.samples_per_peak, level.len()) else { + continue; + }; + + let mut lo = i8::MAX; + let mut hi = i8::MIN; + for i in i0..i1 { + lo = lo.min(level.min[i]); + hi = hi.max(level.max[i]); + } + if lo > hi { + continue; + } + + let y0 = mitte - hi as f32 / 127.0 * halbe_hoehe; + let y1 = mitte - lo as f32 / 127.0 * halbe_hoehe; + + let bereits_gespielt = gespielt_bis.is_some_and(|p| f1 <= p); + let farbe = if bereits_gespielt { + theme::GESPIELT + } else { + farbe + }; + + painter.line_segment( + [Pos2::new(x, y0), Pos2::new(x, y1)], + Stroke::new(1.0, farbe), + ); + } +} + +/// Legt Beat- und Taktlinien über einen Bereich. +/// +/// Takte kräftiger als Beats: Beim Mixen zählt man Vierertakte, nicht einzelne +/// Schläge. +#[allow(clippy::too_many_arguments)] +pub fn zeichne_grid( + painter: &Painter, + rect: Rect, + grid: &Beatgrid, + sample_rate: u32, + von_frame: f64, + bis_frame: f64, + laenge_frames: f64, +) { + let frames_je_beat = grid.frames_per_beat(sample_rate); + if frames_je_beat <= 0.0 || bis_frame <= von_frame { + return; + } + + let sichtbar = bis_frame - von_frame; + // Bei zu vielen Linien wird das Bild zum Zaun — dann nur noch Takte. + let nur_takte = sichtbar / frames_je_beat > 64.0; + + let erster = grid.beat_at(von_frame, sample_rate).floor() as i64; + let letzter = grid.beat_at(bis_frame, sample_rate).ceil() as i64; + + for beat in erster..=letzter { + let taktanfang = beat.rem_euclid(4) == 0; + if nur_takte && !taktanfang { + continue; + } + + let frame = grid.frame_of_beat(beat as f64, sample_rate); + // Vor dem ersten und hinter dem letzten Sample gibt es keine Beats. + // Die Zoom-Ansicht ist um die Abspielposition zentriert und ragt am + // Anfang eines Tracks über ihn hinaus — dort ein Raster zu zeigen, + // behauptete Musik, die noch gar nicht anfängt. + if frame < 0.0 || frame > laenge_frames { + continue; + } + + let t = (frame - von_frame) / sichtbar; + if !(0.0..=1.0).contains(&t) { + continue; + } + + let x = rect.left() + t as f32 * rect.width(); + let (farbe, breite) = if taktanfang { + (theme::TAKT, 1.4) + } else { + (theme::BEAT, 0.8) + }; + + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(breite, farbe), + ); + } +} + +/// Markiert Cue-Punkte. +pub fn zeichne_cues( + painter: &Painter, + rect: Rect, + cues: &[(usize, u64)], + von_frame: f64, + bis_frame: f64, +) { + if bis_frame <= von_frame { + return; + } + + for (index, frame) in cues { + let t = (*frame as f64 - von_frame) / (bis_frame - von_frame); + if !(0.0..=1.0).contains(&t) { + continue; + } + + let x = rect.left() + t as f32 * rect.width(); + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(1.5, theme::CUE), + ); + painter.text( + Pos2::new(x + 2.0, rect.top() + 1.0), + egui::Align2::LEFT_TOP, + format!("{}", index + 1), + egui::FontId::monospace(9.0), + theme::CUE, + ); + } +} + +pub fn zeichne_playhead(painter: &Painter, rect: Rect, x: f32) { + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(1.6, theme::PLAYHEAD), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stufe(samples_per_peak: u32, len: usize) -> PeakLevel { + PeakLevel { + samples_per_peak, + min: vec![-100; len], + max: vec![100; len], + } + } + + #[test] + fn die_stufe_passt_sich_dem_zoom_an() { + let levels = vec![stufe(256, 10_000), stufe(2_048, 1_250), stufe(16_384, 160)]; + + // Ganzer Track auf 800 px: grob genügt. + let weit = passende_stufe(&levels, 10_000_000.0, 800.0); + assert_eq!(weit.samples_per_peak, 16_384); + + // Wenige Sekunden auf 800 px: fein. + let nah = passende_stufe(&levels, 200_000.0, 800.0); + assert_eq!(nah.samples_per_peak, 256); + } + + #[test] + fn vor_dem_anfang_und_hinter_dem_ende_wird_nichts_gezeichnet() { + // Zoom-Ansicht in der ersten Sekunde: die linke Hälfte liegt vor dem + // Track. Dort darf keine Spitze wiederholt werden. + assert_eq!(spitzen_bereich(-8_000.0, -4_000.0, 256, 100), None); + assert_eq!(spitzen_bereich(30_000.0, 34_000.0, 256, 100), None); + + // Der Übergang gehört noch dazu, aber nur mit dem vorhandenen Teil. + assert_eq!(spitzen_bereich(-500.0, 500.0, 256, 100), Some((0, 2))); + assert_eq!( + spitzen_bereich(25_000.0, 26_000.0, 256, 100), + Some((97, 100)) + ); + } + + #[test] + fn leere_stufen_werden_uebersprungen() { + let levels = vec![ + PeakLevel { + samples_per_peak: 256, + min: vec![], + max: vec![], + }, + stufe(2_048, 100), + ]; + + assert_eq!( + passende_stufe(&levels, 100_000.0, 800.0).samples_per_peak, + 2_048 + ); + } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 6e0587f..992bca2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -92,7 +92,7 @@ crates/ analysis/ steht — BPM, Beatgrid, Waveform-Peaks, Sidecar-Cache library/ steht — SQLite, Suche, Playlists, Traktor-Import musik-cli/ steht — Deck, Analyse, Offline-Mix, Sammlung - musik-app/ OFFEN — UI (egui), Phase 3 + musik-app/ steht — Oberfläche (egui/eframe): Decks, Mixer, Plattenkiste Effekte, Stems und MIDI kommen in audio-engine bzw. analysis dazu. ``` @@ -171,9 +171,9 @@ Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). | --- | --- | --- | --- | --- | | 1 | Mehrdeck-Engine, Mixer, Cue-Bus, AUX | Code steht ✅ · Abnahme offen: Cue muss hörbar getrennt auf 3/4 liegen | M | 4-Kanal-Interface | | ~~2~~ | ~~Analyse-Pipeline~~ ✅ | BPM und Grid reproduzierbar als Sidecar, Peaks vorhanden | M | — | -| 3 | UI-Grundgerüst | Zwei Decks mit Waveform, Mixer bedienbar, stabile 60 fps | L | 1, 2 | +| ~~3~~ | ~~UI-Grundgerüst~~ ✅ | Zwei Decks mit Wellenform und Grid, Mixer bedienbar, Sammlung durchsuchbar · Offen: Bildrate auf echter Hardware messen | L | 1, 2 | | ~~4~~ | ~~Sync und Beatmatching~~ ✅ | Zwei Tracks bleiben über 5 Minuten im Takt — als Test abgesichert | M | 2 | -| ~~5~~ | ~~Hot Cues und Loops~~ ✅ | 8 Cues pro Deck, Loop beat-quantisiert · Bedienung fehlt noch (Phase 3) | M | 4 | +| ~~5~~ | ~~Hot Cues und Loops~~ ✅ | 8 Cues pro Deck, Loop beat-quantisiert, in der Oberfläche bedienbar | M | 4 | | ~~6~~ | ~~Library~~ ✅ | Import, Suche, Playlists, Traktor-`collection.nml` | L | 2 | | 7 | Effekte | 6–8 gute Effekte, Mixer-FX post-fader | M | 1 | | 8 | Stems | Beim Import vorberechnet, Stem-Deck-Modus | L | 6 | @@ -191,16 +191,18 @@ und ein Bitcrusher — richtig gebaut und mit vernünftigen Regelwegen. ## Was inzwischen steht -Sechs von zehn Phasen sind gebaut. Was fehlt, ist vor allem die Oberfläche — -alles Bisherige lässt sich nur über die Kommandozeile und über Tests bedienen. +Sieben von zehn Phasen sind gebaut. Die Software lässt sich bedienen: Tracks +aus der Sammlung auf die Decks legen, Wellenform und Beatgrid sehen, mischen. +Was fehlt, sind Effekte, Stems, Mitschnitt und MIDI. | Crate | Inhalt | | --- | --- | | `audio-core` | Deck: Dekodierung, WSOLA, Beatgrid, Hot Cues, Loops | -| `audio-engine` | Mixer: Kanalzüge, EQ, Filter, Crossfader, Cue-Bus, Begrenzer, AUX, Sync | +| `audio-engine` | Mixer: Kanalzüge, EQ, Filter, Crossfader, Cue-Bus, Begrenzer, AUX, Sync, Kommandoschlange, Geräteausgabe | | `analysis` | Tempo, Beatgrid, Wellenform-Spitzen, Sidecar-Cache | | `library` | SQLite-Sammlung, Suche, Playlists, Traktor-Import | | `musik-cli` | Deck fahren, analysieren, Mix rendern, Sammlung verwalten | +| `musik-app` | Oberfläche: zwei Decks, Mixer mit AUX, Plattenkiste | ## Offene Entscheidungen @@ -209,9 +211,12 @@ Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. **Kalibrierung der Tempo-Schwelle.** Die Grenze, ab der ein erkanntes Tempo als -Aussage gilt statt als Rauschen, trennt derzeit nur die beiden Extreme — -Klick-Track gegen Dauerton. Gegen echte Musik ist sie nie geprüft worden; das -geht erst mit einer Sammlung auf der Platte. +Aussage gilt statt als Rauschen, ist inzwischen ein z-Wert statt eines +Verhältnisses zum Mittelwert — das alte Maß hat dichtes Material +fälschlich abgewiesen, weil dessen durchgehende Energie den Sockel der +Autokorrelation anhebt (Details in `crates/analysis/src/tempo.rs`). Sie trennt +jetzt Klick-Track, dichten Loop und Dauerton sauber, ist gegen echte Musik aber +weiterhin nicht geprüft; das geht erst mit einer Sammlung auf der Platte. ## Entschiedene Punkte diff --git a/docs/bilder/oberflaeche.png b/docs/bilder/oberflaeche.png new file mode 100644 index 0000000000000000000000000000000000000000..e5351560b31862692dd0704d954339c95d0f2e71 GIT binary patch literal 192264 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 @I9viL>0M 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 Date: Wed, 12 Aug 2026 08:50:05 +0000 Subject: [PATCH 14/70] docs: assess Mixxx as a reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mixxx ist die naheliegende Frage — zwanzig Jahre freie DJ-Software, die alles kann, was hier noch drei Phasen entfernt ist. Die Antwort ist zweigeteilt, und beide Hälften waren bisher nirgends festgehalten. Lizenz: die `LICENSE` sagt GPLv2+, ein gebautes Binary ist aber faktisch GPLv3+, weil libKeyFinder (GPLv3) mitgelinkt wird. Für uns egal — Copyleft bleibt Copyleft, und Mixxx landet damit in derselben Schublade wie Rubber Band und aubio: nutzbar, solange nichts weitergegeben wird, ausgeschlossen, sobald der VibeMind-Anschluss ernst wird. Übernehmbar ist trotzdem einiges, weil Urheberrecht die Ausdrucksform schützt und nicht die Idee: das Control-System als Vorbild für die MCP-Schnittstelle, die zwei Beatgrid-Stufen (unser Anker+BPM ist die konstante — variables Grid ist der benannte Aufrüstpfad), das Streaming von Platte, und vor allem das Mapping-Verzeichnis als Dokumentation der Geräte-Landschaft für Phase 10. Welche CC-Nummer ein Jogwheel sendet, ist eine Tatsache über die Hardware; die Mapping-Datei ist ein Werk. Was Mixxx nicht löst und was dieses Projekt eigentlich beiträgt: Steuerung von außen. Mixxx spricht OSC nur ausgehend, das Control-System ist prozessintern. Und der ehrliche Gegenpunkt, der ins Dokument gehört statt weggelassen zu werden: für rein persönlichen Gebrauch entstehen aus Mixxx' GPL null Pflichten. Wer heute Abend auflegen will, nimmt Mixxx. Der Eigenbau rechtfertigt sich über genau zwei Ziele — VibeMind und Agentensteuerung. Fallen die weg, fällt der Grund weg. --- README.md | 1 + docs/BAUSTEINE.md | 1 + docs/MIXXX.md | 176 ++++++++++++++++++++++++++++++++++++++++++++++ docs/PLAN.md | 12 +++- 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 docs/MIXXX.md diff --git a/README.md b/README.md index 298e58a..9758de5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ es nichts, was Agenten steuern könnten. | --- | --- | | **[docs/PLAN.md](docs/PLAN.md)** | **Vollständiger Bauplan der DJ-Software — Architektur, Phasen, Risiken** | | [docs/TRAKTOR-REFERENZ.md](docs/TRAKTOR-REFERENZ.md) | Was Traktor Pro 4 kann, was wir davon übernehmen | +| [docs/MIXXX.md](docs/MIXXX.md) | Mixxx als Referenz — was übernehmbar ist, warum es nicht eingebettet werden kann | | [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | | [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | | [docs/AGENTEN.md](docs/AGENTEN.md) | Multi-Agenten-Team und Crowd-Feedback-Schleife | diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md index 44bc43f..753494a 100644 --- a/docs/BAUSTEINE.md +++ b/docs/BAUSTEINE.md @@ -36,6 +36,7 @@ GPL-Abhängigkeit eine Tür, die sich hinter einem schließt. Siehe | CPAL (Rust) | MIT / Apache-2.0 | Frei | | Symphonia (Rust) | MPL-2.0 | Frei | | Demucs | MIT (Code) | Modellgewichte separat prüfen | +| Mixxx | GPLv2+ (Binary faktisch GPLv3+) | Nutzbar — als Referenz lesen, nicht einbetten: [MIXXX.md](MIXXX.md) | Zwei Punkte bleiben trotzdem stehen: diff --git a/docs/MIXXX.md b/docs/MIXXX.md new file mode 100644 index 0000000..0ffa26f --- /dev/null +++ b/docs/MIXXX.md @@ -0,0 +1,176 @@ +# Mixxx als Referenz + +[Mixxx](https://github.com/mixxxdj/mixxx) ist die ausgereifteste freie +DJ-Software, die es gibt — seit rund zwanzig Jahren in Entwicklung, C++ mit Qt, +vier Decks, Sampler, Effekte, DVS/Timecode, Broadcasting, Mitschnitt, +Controller-Mappings für über hundert Geräte. Alles, was hier in Phasen 7 bis 10 +noch aussteht, hat Mixxx längst. + +Die naheliegende Frage lautet deshalb: warum nicht einfach Mixxx? + +Dieses Dokument beantwortet beides — was wir übernehmen können und was nicht, +und unter welchen Umständen „einfach Mixxx nehmen" die richtige Antwort wäre. + +## Die Lizenz, genau + +Zwei Zahlen, die auseinanderlaufen: + +| Ort | Lizenz | +| --- | --- | +| `LICENSE` im Repo | GPL **v2** or later | +| Ein gebautes Binary | faktisch GPL **v3** or later | + +Der Unterschied kommt von [libKeyFinder](https://github.com/mixxxdj/libkeyfinder) +(Tonarterkennung, GPLv3), das seit Mixxx 2.3 mitgelinkt wird — GPLv3 ist mit +GPLv2-only unverträglich, die „or later"-Klausel hebt das Ganze also auf v3. + +Für uns macht das keinen Unterschied: **Copyleft ist Copyleft.** Mixxx fällt in +dieselbe Schublade wie Rubber Band, aubio und Essentia in +[BAUSTEINE.md](BAUSTEINE.md#lizenzen--entschärft-nicht-erledigt) — nutzbar, +solange nichts weitergegeben wird, und ausgeschlossen, sobald der Anschluss an +[VibeMind](VIBEMIND.md) (MIT, zur Veröffentlichung vorgesehen) ernst wird. + +## Lesen ist nicht kopieren + +Der Punkt, an dem viele zu vorsichtig sind: **Urheberrecht schützt die +Ausdrucksform, nicht die Idee.** Ein Algorithmus, eine Architektur, die +Erkenntnis „der Cue-Bus muss pre-fader und post-EQ abgegriffen werden" — das +sind keine geschützten Werke. Mixxx zu lesen, um zu verstehen *wie* etwas gelöst +ist, und es danach selbst zu schreiben, ist zulässig und üblich. + +Was nicht geht: Code übernehmen, Dateien vendorn, Zeile für Zeile übersetzen. +Eine Rust-Portierung einer C++-Funktion ist eine Bearbeitung, keine Neuschöpfung. + +**Praktische Regel für dieses Projekt:** zuerst ins +[Wiki](https://github.com/mixxxdj/mixxx/wiki) und ins +[Handbuch](https://manual.mixxx.org/), nicht in den Quelltext. Beide beschreiben +die Architektur ausführlich genug, und wer die Lösung aus der Prosa +rekonstruiert statt aus dem Code, hat das Problem der versehentlichen Nähe gar +nicht erst. + +## Was uns wirklich weiterhilft + +Nach absteigendem Wert: + +### 1. Die Controller-Mappings als Hardware-Dokumentation + +`res/controllers/` enthält über hundert Mappings als `.midi.xml`, `.hid.xml` +und `.js` — Pioneer, Denon, Numark, Hercules, Traktor Kontrol, Allen & Heath +und so weiter, teils vom Team zertifiziert, teils aus der Community. + +Der Inhalt ist zweierlei, und die Trennung ist wichtig: + +- **Welche MIDI-CC-Nummer das Jogwheel eines DDJ-400 sendet**, ist eine + *Tatsache über die Hardware*. Tatsachen sind nicht urheberrechtlich + geschützt. Als Nachschlagewerk dafür, wie die Geräte-Landschaft aussieht, ist + das Verzeichnis Gold wert — genau das, was Phase 10 sonst mühsam durch + Ausprobieren am Gerät ermitteln müsste. +- **Die Mapping-Dateien selbst** sind Werke unter GPL. Nicht kopieren, nicht + mitliefern, nicht konvertieren. + +Also: als Doku lesen, eigenes Format schreiben. + +### 2. Das Control-System als Vorbild für die MCP-Schnittstelle + +Mixxx hat eine interne API, über die **alles** läuft — Tastatur, MIDI, HID und +die grafische Oberfläche greifen auf dieselben benannten Controls zu, adressiert +über einen zweiteiligen `ConfigKey` aus Gruppe und Element, etwa +`[Channel1],play`. + +Das ist genau die Form, die unsere Anbindung an VibeMind braucht: ein flacher, +benannter Steuerraum, den ein Agent adressieren kann, ohne die Oberfläche zu +kennen. Wir haben das bisher nicht — Transport läuft über Atomics im +`DeckState`, der Mixer über die Kommandoschlange, beides ohne gemeinsame +Namensgebung. Bevor die MCP-Schicht aus [VIBEMIND.md](VIBEMIND.md) gebaut wird, +lohnt sich dieser Zwischenschritt. + +Architektonische Idee, frei übernehmbar. + +### 3. Zwei Beatgrid-Stufen statt einer + +Mixxx bietet die Wahl zwischen zwei Analysatoren, und der Unterschied ist genau +der, den wir noch vor uns haben: + +| Verfahren | Ergebnis | +| --- | --- | +| SoundTouch | eine mittlere BPM-Zahl → **konstantes** Grid | +| Queen Mary | einzelne Beat-Positionen → **variables** Grid | + +Unser Modell (Anker + konstante BPM, siehe `crates/analysis/src/tempo.rs`) ist +die erste Stufe. Das ist für produzierte elektronische Musik richtig und +billig. Für live eingespieltes Material — Bands, ältere Aufnahmen, alles ohne +Klick — schwankt das Tempo, und ein konstantes Grid läuft über vier Minuten +sichtbar weg. + +Damit ist der Aufrüstpfad benannt: eine Beat-Liste statt Anker+BPM, und +`Beatgrid` müsste beides können. Kein akutes Problem, aber ein Grund, die +Schnittstelle nicht auf „eine Zahl" festzunageln. + +### 4. Streaming von Platte + +Mixxx dekodiert Tracks nicht vollständig in den Speicher, sondern liest mit +einem Vorpuffer nach. Genau die offene Entscheidung aus +[PLAN.md](PLAN.md#offene-entscheidungen), die spätestens bei Stems fällig wird — +vier Decks à vier Stems sprengen den Vollentschlüsselungs-Ansatz. + +Das Wiki beschreibt den Aufbau. Lesen lohnt, bevor wir das selbst entwerfen. + +### 5. Library-Importe, die wir nicht haben + +Wir lesen Traktors `collection.nml`. Mixxx liest zusätzlich Rekordbox und +Serato, beides durch Rückentwicklung erarbeitet. Wenn diese Formate je +gebraucht werden, ist dort dokumentiert, wie sie aussehen — wieder: +Formatwissen ist Tatsache, der Parser ist Werk. + +## Was Mixxx *nicht* löst + +**Steuerung von außen.** Mixxx spricht OSC nur in eine Richtung: es *sendet* +Zustand (Titel, Position, Lautstärke, Play-Status) an einen OSC-Server. +Das Empfangen von Befehlen ist ein seit Jahren offener Wunsch mit +Community-Patches, aber kein offizieller Bestandteil. Das Control-System selbst +ist prozessintern. + +Für einen Agenten, der auflegen soll, ist Mixxx damit keine fertige Basis. Genau +diese Lücke ist das, was dieses Projekt eigentlich beiträgt — nicht der +zwanzigste Mixer, sondern ein DJ-Werkzeug, das von Anfang an eine +Steuerschnittstelle nach außen hat. + +**Die Lizenz für den VibeMind-Weg.** Einbetten geht nicht, und ein Fork wäre +GPL. Beides schließt die Veröffentlichung als MIT aus. + +## Und wenn wir doch Mixxx nehmen? + +Der Vollständigkeit halber, weil es die ehrliche Alternative ist: + +> **Solange dies ein persönliches Werkzeug bleibt und nicht weitergegeben wird, +> entstehen aus Mixxx' GPL null Pflichten.** Es ist fertige, ausgereifte +> Software, die alles kann, was hier noch drei Phasen entfernt ist. Wer heute +> Abend auflegen will, nimmt Mixxx. + +Die Gründe, trotzdem selbst zu bauen, sind genau zwei: + +1. **Der Anschluss an VibeMind** — der braucht MIT und Veröffentlichung. +2. **Die Steuerung durch Agenten** — die es in Mixxx nicht gibt und die sich + nicht nachrüsten lässt, ohne den Kern anzufassen (und damit zu forken, und + damit unter GPL zu landen). + +Fallen beide Ziele weg, ist der Eigenbau schwer zu rechtfertigen. Bleiben sie, +ist er der einzige Weg. Das ist keine technische, sondern eine Zielfrage — und +sie sollte bewusst beantwortet und nicht durch Weiterbauen umgangen werden. + +## Quellen + +- [mixxxdj/mixxx auf GitHub](https://github.com/mixxxdj/mixxx) — + [LICENSE](https://github.com/mixxxdj/mixxx/blob/main/LICENSE) (GPLv2+), + [`res/controllers/`](https://github.com/mixxxdj/mixxx/tree/main/res/controllers) +- [PR #3476 — Lizenzhebung auf GPLv3 wegen libKeyFinder](https://github.com/mixxxdj/mixxx/pull/3476) +- [mixxxdj/libkeyfinder](https://github.com/mixxxdj/libkeyfinder) — GPLv3 +- [Developer Guide: Control](https://github.com/mixxxdj/mixxx/wiki/Developer-Guide-Control) — das ConfigKey-System +- [Developer Guide: Analysers](https://github.com/mixxxdj/mixxx/wiki/Developer-Guide-Analysers) — SoundTouch gegen Queen Mary +- [Handbuch: Beat Detection](https://manual.mixxx.org/2.3/id/chapters/preferences/beat_detection) +- [Wiki: OSC Client](https://github.com/mixxxdj/mixxx/wiki/osc-client) und + [OSC Backend](https://github.com/mixxxdj/mixxx/wiki/Osc-Backend) +- [Wiki: Contributing Mappings](https://github.com/mixxxdj/mixxx/wiki/Contributing-Mappings) + +Stand: August 2026. Nicht selbst nachgebaut — alles aus Dokumentation und +Repository-Metadaten, bewusst nicht aus dem Quelltext. diff --git a/docs/PLAN.md b/docs/PLAN.md index 992bca2..4f87083 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -180,6 +180,10 @@ Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). | 9 | Mitschnitt | Master-Bus als WAV/FLAC, ohne Aussetzer | S | 1 | | 10 | MIDI-Controller | Generic-MIDI-Mapping, speicherbar | M | 3 | +Für Phase 10 ist Mixxx' Mapping-Verzeichnis die beste verfügbare Dokumentation +der Geräte-Landschaft — als Nachschlagewerk, nicht als Vorlage zum Kopieren. +Warum das ein Unterschied ist, steht in [MIXXX.md](MIXXX.md). + Phase 1 und 2 sind unabhängig und können parallel laufen — die Analyse braucht die Engine nicht. @@ -208,7 +212,9 @@ Was fehlt, sind Effekte, Stems, Mitschnitt und MIDI. **Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird -aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. Mixxx hat genau das +gelöst und beschreibt es im Wiki — lesen, bevor wir es selbst entwerfen, siehe +[MIXXX.md](MIXXX.md#4-streaming-von-platte). **Kalibrierung der Tempo-Schwelle.** Die Grenze, ab der ein erkanntes Tempo als Aussage gilt statt als Rauschen, ist inzwischen ein z-Wert statt eines @@ -240,7 +246,9 @@ Zwei Alternativen wurden verworfen: **Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird -aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. Mixxx hat genau das +gelöst und beschreibt es im Wiki — lesen, bevor wir es selbst entwerfen, siehe +[MIXXX.md](MIXXX.md#4-streaming-von-platte). ## Risiken From b07a58e7e222abb2e008d8e308a478de06c60f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:47:46 +0000 Subject: [PATCH 15/70] feat(control): named, self-describing control surface with remote access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bis hierher lag die Bedienung an drei Stellen: Transport in den Atomics des DeckState, der Mixer in der Kommandoschlange, und die Reglerstellungen als Spiegel in der Oberfläche. Wer von außen steuern wollte, hätte alle drei anfassen müssen — und die Oberfläche war die einzige Stelle, die wusste, wo ein Fader gerade steht. Das neue Crate `control` dreht das um. Jedes bedienbare Ding hat einen Namen (`deck1.play`, `channel2.eq_low`, `master.crossfader`), das Pult besitzt den Spiegel und schickt jede Änderung selbst in die Schlange. Die Oberfläche wird damit zu einem von mehreren Bedienern, gleichberechtigt mit einem Skript am Socket. Die Idee stammt von Mixxx — dort laufen Tastatur, MIDI, HID und Oberfläche über dieselben benannten Controls. Sie ist frei: geschützt ist die Ausdrucksform, nicht der Entwurf. Drei Dinge sind bewusst anders: Der Steuerraum beschreibt sich zur Laufzeit selbst. `list` liefert zu jedem Control Typ, Bereich, Einheit, Schreibbarkeit und Bedeutung. Bei Mixxx steht das im Handbuch, und ein laufendes Programm lässt sich nicht fragen. Der Gewinn ist doppelt: MCP-Werkzeugbeschreibungen lassen sich daraus erzeugen statt doppelt pflegen, und die Schieberegler im Mixer holen sich Bereich und Tooltip aus demselben Katalog — ein neues Control ist ohne eine Zeile in der Oberfläche bedienbar. Werte sind typisiert. Bei Mixxx ist alles ein double, in das jeder Tippfehler als gültiger Wert hineinpasst. Hier weist `set channel1.assign mitte` mit den erlaubten Namen zurück. Zwei Ausnahmen von der Strenge sind Absicht: Werte außerhalb des Bereichs werden begrenzt statt abgelehnt, weil ein MIDI-Regler auf Anschlag das Maximum meint und keinen Fehler; und die Bestätigung nennt den angekommenen Wert, nicht den gesendeten. Er ist von außen erreichbar. Ein Unix-Socket, kein TCP-Port — wer hineinschreibt, steuert die Anlage, und ein offener Port täte das für jeden im selben Netz. Der Socket erbt die Rechte des Dateisystems und liegt in XDG_RUNTIME_DIR, nicht in /tmp. Echtzeit bleibt unangetastet: Das Pult schreibt nie in den Audio-Callback, sondern in dieselbe lock-freie Schlange, und liest aus Atomics und Spiegel. Der Mutex wird nur von Bedienern genommen. Zwei Funde beim Bauen, beide von Tests aufgedeckt: Der Wortzerleger machte aus Leerzeichenfolgen leere Felder, weil splitn nicht zusammenfasst. Und Tempo liegt im Deck als f32 — 1.04 kommt als 1.0399999618530273 zurück. Der Test forderte exakte Gleichheit und behauptete damit eine Genauigkeit, die es dort nicht gibt. Nachgewiesen an der laufenden Anwendung: zwei Decks von außen ineinander gefahren, Deck B per `set deck2.tempo 1.032` auf Deck A gezogen (127,98 gegen 127,99 BPM). Screenshot in docs/bilder/fernsteuerung.png. --- Cargo.lock | 9 + Cargo.toml | 2 +- README.md | 23 +- crates/control/Cargo.toml | 9 + crates/control/src/katalog.rs | 354 +++++++++++++++ crates/control/src/lib.rs | 45 ++ crates/control/src/protokoll.rs | 334 ++++++++++++++ crates/control/src/pult.rs | 730 +++++++++++++++++++++++++++++++ crates/control/src/schluessel.rs | 138 ++++++ crates/control/src/server.rs | 241 ++++++++++ crates/control/src/testing.rs | 57 +++ crates/control/src/wert.rs | 137 ++++++ crates/musik-app/Cargo.toml | 1 + crates/musik-app/src/app.rs | 443 +++++++++---------- crates/musik-app/src/laden.rs | 34 +- crates/musik-app/src/main.rs | 87 +++- docs/MIXXX.md | 17 +- docs/PLAN.md | 7 + docs/STEUERUNG.md | 153 +++++++ docs/bilder/fernsteuerung.png | Bin 0 -> 208802 bytes 20 files changed, 2561 insertions(+), 260 deletions(-) create mode 100644 crates/control/Cargo.toml create mode 100644 crates/control/src/katalog.rs create mode 100644 crates/control/src/lib.rs create mode 100644 crates/control/src/protokoll.rs create mode 100644 crates/control/src/pult.rs create mode 100644 crates/control/src/schluessel.rs create mode 100644 crates/control/src/server.rs create mode 100644 crates/control/src/testing.rs create mode 100644 crates/control/src/wert.rs create mode 100644 docs/STEUERUNG.md create mode 100644 docs/bilder/fernsteuerung.png diff --git a/Cargo.lock b/Cargo.lock index 5b21bb6..8acb8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,6 +729,14 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "control" +version = "0.1.0" +dependencies = [ + "audio-core", + "audio-engine", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2006,6 +2014,7 @@ dependencies = [ "anyhow", "audio-core", "audio-engine", + "control", "eframe", "egui", "image", diff --git a/Cargo.toml b/Cargo.toml index da1e4b4..811a407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/library", "crates/musik-app", "crates/musik-cli"] +members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/control", "crates/library", "crates/musik-app", "crates/musik-cli"] [workspace.package] edition = "2021" diff --git a/README.md b/README.md index 9758de5..49073f1 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Status: **sieben von zehn Phasen gebaut.** Decks mit Keylock, Hot Cues und Loops; Mixer mit EQ, Filter, Crossfader, Cue-Bus und AUX; Analyse für Tempo, Beatgrid und Wellenform; Sync über Tempo *und* Phase; Sammlung mit Traktor-Import; Oberfläche mit zwei Decks, Wellenform, Beatgrid und -Plattenkiste. Nativ in Rust. Was fehlt, sind Effekte, Stems, Mitschnitt und -MIDI. +Plattenkiste; **Fernsteuerung über einen selbstbeschreibenden Steuerraum**. +Nativ in Rust. Was fehlt, sind Effekte, Stems, Mitschnitt und MIDI. ![Die Oberfläche](docs/bilder/oberflaeche.png) @@ -35,6 +35,7 @@ es nichts, was Agenten steuern könnten. | **[docs/PLAN.md](docs/PLAN.md)** | **Vollständiger Bauplan der DJ-Software — Architektur, Phasen, Risiken** | | [docs/TRAKTOR-REFERENZ.md](docs/TRAKTOR-REFERENZ.md) | Was Traktor Pro 4 kann, was wir davon übernehmen | | [docs/MIXXX.md](docs/MIXXX.md) | Mixxx als Referenz — was übernehmbar ist, warum es nicht eingebettet werden kann | +| **[docs/STEUERUNG.md](docs/STEUERUNG.md)** | **Fernsteuerung: Socket, Protokoll, selbstbeschreibender Steuerraum** | | [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | | [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | | [docs/AGENTEN.md](docs/AGENTEN.md) | Multi-Agenten-Team und Crowd-Feedback-Schleife | @@ -70,6 +71,7 @@ Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: | ~~4~~ ✅ | ~~Sync und Beatmatching über Tempo und Phase~~ | | ~~5~~ ✅ | ~~Hot Cues und Loops~~ | | ~~6~~ ✅ | ~~Library inkl. Traktor-Import~~ | +| ~~S~~ ✅ | ~~Steuerraum und Fernsteuerung~~ — [docs/STEUERUNG.md](docs/STEUERUNG.md) | | **7–10** | **Effekte · Stems · Mitschnitt · MIDI-Controller** | Zum Auflegen fehlt jetzt nur noch Hardware: Phase 1 ist abgenommen, sobald der @@ -242,4 +244,21 @@ Ausgänge, sagt die Kopfzeile das und das Vorhören entfällt. `--release` ist keine Zierde: die Analyse eines frisch geladenen Tracks dauert im Debug-Build spürbar länger. +Von außen steuern, während sie läuft: + +```sh +$ nc -U "$XDG_RUNTIME_DIR/musik.sock" +list deck1. +set deck1.play 1 +set channel1.fader 0.8 +setn channel2.eq_low 0 # normiert, für MIDI +``` + +`list` liefert zu jedem Control Typ, Bereich, Einheit, Schreibbarkeit und +Bedeutung — der Steuerraum beschreibt sich selbst, ein Handbuch ist dafür nicht +nötig. Das ist die Grundlage für die Agenten-Schicht und für den Anschluss an +VibeMind. Details in [docs/STEUERUNG.md](docs/STEUERUNG.md). + +![Von außen gesteuert](docs/bilder/fernsteuerung.png) + Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/control/Cargo.toml b/crates/control/Cargo.toml new file mode 100644 index 0000000..856f7f2 --- /dev/null +++ b/crates/control/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "control" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } diff --git a/crates/control/src/katalog.rs b/crates/control/src/katalog.rs new file mode 100644 index 0000000..1a0fdee --- /dev/null +++ b/crates/control/src/katalog.rs @@ -0,0 +1,354 @@ +//! Was es gibt — und was es bedeutet. +//! +//! Das ist der Teil, den Mixxx nicht hat. Dort ist die Liste der Controls +//! *Dokumentation*: Sie steht im Handbuch, und wer von außen steuern will, +//! liest sie und tippt die Namen ab. Läuft das Programm, lässt sich nicht +//! erfragen, was es kann. +//! +//! Hier trägt jedes Control seinen Bereich, seine Einheit, seinen Standardwert +//! und einen Satz zur Bedeutung mit sich. Ein Agent, ein Controller-Mapping +//! oder ein Skript kann das Pult zur Laufzeit **fragen**, statt es zu wissen. +//! Genau darauf setzt später die MCP-Schicht auf: Werkzeugbeschreibungen lassen +//! sich daraus erzeugen, statt sie von Hand doppelt zu pflegen. +//! +//! Die Namen sind englisch, obwohl der Rest des Codes deutsch kommentiert ist. +//! Sie sind die Schnittstelle nach außen, und die spricht die Sprache, die +//! Controller-Mappings und Agenten ohnehin verwenden. + +use crate::wert::{Art, Einheit}; + +#[derive(Debug, Clone)] +pub struct Beschreibung { + pub element: &'static str, + pub art: Art, + /// Nur bei [`Art::Zahl`]: kleinster und größter sinnvoller Wert. + pub bereich: Option<(f64, f64)>, + pub einheit: Einheit, + pub auswahl: &'static [&'static str], + pub schreibbar: bool, + pub text: &'static str, +} + +impl Beschreibung { + /// Wert innerhalb des Bereichs zurechtstutzen. + /// + /// Ein Fader über 1.0 ist kein Fehler, den man dem Aufrufer um die Ohren + /// hauen muss — ein MIDI-Regler, der 127 sendet, meint das Maximum. Werte + /// außerhalb werden deshalb begrenzt, nicht abgelehnt. + pub fn begrenzen(&self, wert: f64) -> f64 { + match self.bereich { + Some((min, max)) => wert.clamp(min, max), + None => wert, + } + } + + /// 0..1 in den echten Bereich. Für Controller, die nur Prozent kennen. + pub fn aus_normiert(&self, norm: f64) -> f64 { + match self.bereich { + Some((min, max)) => min + norm.clamp(0.0, 1.0) * (max - min), + None => norm, + } + } + + /// Echter Bereich nach 0..1. + pub fn nach_normiert(&self, wert: f64) -> f64 { + match self.bereich { + Some((min, max)) if max > min => ((wert - min) / (max - min)).clamp(0.0, 1.0), + _ => wert, + } + } +} + +const fn zahl( + element: &'static str, + min: f64, + max: f64, + einheit: Einheit, + schreibbar: bool, + text: &'static str, +) -> Beschreibung { + Beschreibung { + element, + art: Art::Zahl, + bereich: Some((min, max)), + einheit, + auswahl: &[], + schreibbar, + text, + } +} + +const fn schalter(element: &'static str, schreibbar: bool, text: &'static str) -> Beschreibung { + Beschreibung { + element, + art: Art::Schalter, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + schreibbar, + text, + } +} + +const fn text_feld(element: &'static str, text: &'static str) -> Beschreibung { + Beschreibung { + element, + art: Art::Text, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + schreibbar: false, + text, + } +} + +/// Obergrenze des Tempo-Reglers. ±8 % ist der Regelweg eines Technics-Decks +/// und damit das, was DJs im Griff haben. +pub const TEMPO_MIN: f64 = 0.92; +pub const TEMPO_MAX: f64 = 1.08; + +/// Hot Cues, die es je Deck gibt. Muss zu `audio_core::deck::HOT_CUES` passen. +pub const HOT_CUES: usize = audio_core::deck::HOT_CUES; + +pub static DECK: &[Beschreibung] = &[ + schalter("play", true, "Läuft das Deck"), + zahl( + "position", + 0.0, + f64::MAX, + Einheit::Sekunden, + true, + "Abspielposition; Schreiben springt", + ), + zahl( + "duration", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Länge des geladenen Tracks", + ), + zahl( + "bpm", + 0.0, + 400.0, + Einheit::Bpm, + false, + "Tempo einschließlich Tempo-Regler", + ), + zahl( + "bpm_grid", + 0.0, + 400.0, + Einheit::Bpm, + false, + "Tempo des Beatgrids, ohne Regler", + ), + zahl( + "tempo", + TEMPO_MIN, + TEMPO_MAX, + Einheit::Faktor, + true, + "Tempo-Regler; 1.0 ist die Originalgeschwindigkeit", + ), + schalter("keylock", true, "Tonhöhe beim Tempowechsel halten"), + zahl( + "beat_phase", + 0.0, + 1.0, + Einheit::Beats, + false, + "Lage im Beat, 0 ist auf dem Schlag", + ), + schalter("loop_active", true, "Läuft gerade eine Schleife"), + zahl( + "loop_beats", + 0.0, + 64.0, + Einheit::Beats, + true, + "Schleife dieser Länge ab der Position setzen", + ), + text_feld("title", "Titel des geladenen Tracks"), + text_feld("artist", "Künstler des geladenen Tracks"), +]; + +/// Hot Cues heißen `cue1` bis `cue8` und lassen sich nicht als feste Liste +/// hinschreiben, ohne sie achtmal zu wiederholen. +pub fn hot_cue_beschreibung(index: usize) -> Option { + if index >= HOT_CUES { + return None; + } + + Some(Beschreibung { + // Der Name wird zur Laufzeit gebraucht, `element` ist aber `'static`. + // Deshalb eine kleine feste Tabelle statt einer geleakten Zeichenkette. + element: HOT_CUE_NAMEN[index], + art: Art::Zahl, + bereich: Some((0.0, f64::MAX)), + einheit: Einheit::Sekunden, + auswahl: &[], + schreibbar: true, + text: "Hot Cue; Schreiben setzt ihn, Lesen gibt seine Position oder '-'", + }) +} + +static HOT_CUE_NAMEN: [&str; HOT_CUES] = [ + "cue1", "cue2", "cue3", "cue4", "cue5", "cue6", "cue7", "cue8", +]; + +pub static KANAL: &[Beschreibung] = &[ + zahl( + "trim", + 0.0, + 2.0, + Einheit::Faktor, + true, + "Eingangsverstärkung vor dem EQ", + ), + zahl( + "eq_low", + 0.0, + 2.0, + Einheit::Faktor, + true, + "Bässe; 0 ist ein echter Kill", + ), + zahl("eq_mid", 0.0, 2.0, Einheit::Faktor, true, "Mitten"), + zahl("eq_high", 0.0, 2.0, Einheit::Faktor, true, "Höhen"), + zahl( + "filter", + -1.0, + 1.0, + Einheit::Bipolar, + true, + "DJ-Filter; negativ Tiefpass, positiv Hochpass", + ), + zahl("fader", 0.0, 1.0, Einheit::Faktor, true, "Linefader"), + schalter("cue", true, "Kanal auf den Kopfhörer legen"), + Beschreibung { + element: "assign", + art: Art::Auswahl, + bereich: None, + einheit: Einheit::Keine, + auswahl: &["a", "b", "thru"], + schreibbar: true, + text: "Seite am Crossfader; 'thru' geht am Crossfader vorbei", + }, +]; + +pub static MASTER: &[Beschreibung] = &[ + zahl( + "crossfader", + -1.0, + 1.0, + Einheit::Bipolar, + true, + "Crossfader; -1 ist ganz A, +1 ganz B", + ), + zahl( + "crossfader_curve", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Kurve; 0 weich, 1 hart", + ), + zahl("gain", 0.0, 1.5, Einheit::Faktor, true, "Summenlautstärke"), + zahl( + "cue_gain", + 0.0, + 1.5, + Einheit::Faktor, + true, + "Kopfhörerlautstärke", + ), + zahl( + "cue_mix", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Kopfhörer zwischen Vorhören (0) und Summe (1)", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jedes_control_beschreibt_sich_selbst() { + // Der ganze Sinn des Katalogs: Wer fragt, bekommt eine Antwort, die + // ohne Handbuch trägt. + let alle = DECK.iter().chain(KANAL).chain(MASTER); + for b in alle { + assert!(!b.element.is_empty()); + assert!(!b.text.is_empty(), "{} hat keine Beschreibung", b.element); + if b.art == Art::Zahl { + assert!( + b.bereich.is_some(), + "{} ist eine Zahl ohne Bereich", + b.element + ); + } + if b.art == Art::Auswahl { + assert!( + !b.auswahl.is_empty(), + "{} ist eine Auswahl ohne Optionen", + b.element + ); + } + } + } + + #[test] + fn namen_kommen_innerhalb_einer_gruppe_nur_einmal_vor() { + for gruppe in [DECK, KANAL, MASTER] { + let mut namen: Vec<_> = gruppe.iter().map(|b| b.element).collect(); + let vorher = namen.len(); + namen.sort_unstable(); + namen.dedup(); + assert_eq!(namen.len(), vorher, "doppelter Name in einer Gruppe"); + } + } + + #[test] + fn normierung_geht_hin_und_zurueck() { + let b = &KANAL[0]; // trim, 0..2 + assert_eq!(b.aus_normiert(0.0), 0.0); + assert_eq!(b.aus_normiert(1.0), 2.0); + assert_eq!(b.nach_normiert(1.0), 0.5); + + // Ein bipolares Control hat seine Mitte bei 0.5 normiert — das ist der + // Punkt, an dem ein MIDI-Regler in der Raste steht. + let filter = KANAL.iter().find(|b| b.element == "filter").unwrap(); + assert_eq!(filter.nach_normiert(0.0), 0.5); + assert_eq!(filter.aus_normiert(0.5), 0.0); + } + + #[test] + fn werte_ausserhalb_werden_begrenzt_statt_abgelehnt() { + // Ein MIDI-Regler auf Anschlag meint das Maximum, keinen Fehler. + let fader = KANAL.iter().find(|b| b.element == "fader").unwrap(); + assert_eq!(fader.begrenzen(1.7), 1.0); + assert_eq!(fader.begrenzen(-0.2), 0.0); + } + + #[test] + fn es_gibt_genau_so_viele_hot_cues_wie_im_deck() { + assert!(hot_cue_beschreibung(HOT_CUES - 1).is_some()); + assert!(hot_cue_beschreibung(HOT_CUES).is_none()); + assert_eq!(hot_cue_beschreibung(0).unwrap().element, "cue1"); + } + + #[test] + fn nur_gelesene_controls_sind_auch_als_solche_markiert() { + let dauer = DECK.iter().find(|b| b.element == "duration").unwrap(); + assert!( + !dauer.schreibbar, + "die Länge eines Tracks lässt sich nicht setzen" + ); + } +} diff --git a/crates/control/src/lib.rs b/crates/control/src/lib.rs new file mode 100644 index 0000000..c95a366 --- /dev/null +++ b/crates/control/src/lib.rs @@ -0,0 +1,45 @@ +//! Ein benannter Steuerraum für alles, was bedienbar ist. +//! +//! Der Gedanke stammt von Mixxx: Dort laufen Tastatur, MIDI, HID und die +//! Oberfläche über *dieselben* benannten Controls, statt dass jede Bedienart +//! ihren eigenen Draht zum Mixer zieht. Das ist die richtige Idee, und sie ist +//! frei — geschützt ist die Ausdrucksform, nicht der Entwurf (siehe +//! `docs/MIXXX.md`). +//! +//! Drei Dinge machen wir anders, und sie sind der Grund, warum sich der Bau +//! lohnt: +//! +//! 1. **Der Steuerraum beschreibt sich selbst.** Jedes Control trägt Bereich, +//! Einheit, Schreibbarkeit und einen Satz zur Bedeutung. Bei Mixxx steht das +//! im Handbuch; ein laufendes Programm lässt sich nicht fragen, was es kann. +//! Ein Agent, der hier `list` sagt, weiß danach genug, um zu bedienen. +//! 2. **Werte sind typisiert.** Ein Schalter ist ein Schalter, eine Auswahl +//! trägt ihre Namen. Nicht alles ist ein `double`, in das jeder Tippfehler +//! als gültiger Wert hineinpasst. +//! 3. **Er ist von außen erreichbar.** Mixxx spricht OSC nur ausgehend; +//! Befehle entgegenzunehmen ist dort ein seit Jahren offener Wunsch. Hier +//! ist es der Zweck. +//! +//! Echtzeit bleibt unangetastet: Das Pult schreibt nie in den Audio-Callback, +//! sondern schickt Kommandos in dieselbe lock-freie Schlange, die die +//! Oberfläche benutzt. Gelesen wird aus Atomics und aus dem Spiegel, nie aus +//! dem Mixer selbst. + +pub mod katalog; +pub mod protokoll; +pub mod pult; +pub mod schluessel; +pub mod server; +pub mod wert; + +#[cfg(test)] +pub(crate) mod testing; + +pub use katalog::Beschreibung; +pub use protokoll::behandle; +pub use pult::{ + assign_aus_name, assign_name, DeckEintrag, Fehler, KanalSpiegel, MasterSpiegel, Steuerpult, +}; +pub use schluessel::{Gruppe, Schluessel}; +pub use server::{Server, ServerFehler}; +pub use wert::{Art, Einheit, Wert}; diff --git a/crates/control/src/protokoll.rs b/crates/control/src/protokoll.rs new file mode 100644 index 0000000..fec386b --- /dev/null +++ b/crates/control/src/protokoll.rs @@ -0,0 +1,334 @@ +//! Zeilenprotokoll für die Steuerung von außen. +//! +//! Bewusst Text und bewusst zeilenweise: Man kann es mit `nc` von Hand +//! sprechen, in einem Log lesen und ohne Bibliothek erzeugen. Ein +//! Binärprotokoll wäre schneller, aber hier fließen Reglerbewegungen, keine +//! Samples — ein paar Dutzend Zeilen je Sekunde im schlimmsten Fall. +//! +//! Die Trennung von der Steckdose ist Absicht: Hier drin kommt kein Socket vor, +//! nur Zeile rein, Zeile raus. Damit lässt sich das ganze Protokoll ohne +//! Netzwerk testen, und der Server daneben ist nur noch Verkabelung. +//! +//! ```text +//! > list +//! < control deck1.play schalter - rw Läuft das Deck +//! > get deck1.bpm +//! < value deck1.bpm 128.021 +//! > set channel1.fader 0.8 +//! < ok channel1.fader 0.8 +//! > setn channel1.fader 1.0 (normiert, 0..1 — für MIDI) +//! < ok channel1.fader 1 +//! ``` + +use crate::pult::Steuerpult; +use crate::schluessel::Schluessel; +use crate::wert::{Art, Wert}; + +/// Beantwortet eine Zeile. Die Antwort kann mehrzeilig sein. +pub fn behandle(pult: &mut Steuerpult, zeile: &str) -> String { + let zeile = zeile.trim(); + if zeile.is_empty() { + return String::new(); + } + + // Nicht `splitn`: Zwischen zwei Wörtern können mehrere Leerzeichen + // stehen, und `splitn` macht daraus leere Felder. Der Rest bleibt am + // Stück, damit ein Wert später auch einmal Leerzeichen enthalten darf. + let mut rest = zeile; + let befehl = wort(&mut rest); + let erstes = Some(wort(&mut rest)).filter(|w| !w.is_empty()); + let zweites = Some(rest.trim()).filter(|w| !w.is_empty()); + + match befehl { + "list" => list(pult, erstes), + "get" => get(pult, erstes), + "set" => set(pult, erstes, zweites, false), + "setn" => set(pult, erstes, zweites, true), + "help" => HILFE.to_string(), + andere => format!("err unbekannter Befehl: {andere} (help hilft)"), + } +} + +/// Nimmt das nächste Wort und lässt den Rest stehen. +fn wort<'a>(rest: &mut &'a str) -> &'a str { + let getrimmt = rest.trim_start(); + match getrimmt.find(char::is_whitespace) { + Some(ende) => { + *rest = &getrimmt[ende..]; + &getrimmt[..ende] + } + None => { + *rest = ""; + getrimmt + } + } +} + +const HILFE: &str = "\ +ok Befehle: + list [praefix] Controls aufzählen, gefiltert nach Namensanfang + get Wert lesen + set Wert setzen; '-' leert (Hot Cues) + setn <0..1> Wert normiert setzen, für MIDI + help diese Übersicht"; + +fn list(pult: &Steuerpult, praefix: Option<&str>) -> String { + let mut zeilen = Vec::new(); + + for (schluessel, b) in pult.liste() { + let name = schluessel.to_string(); + if let Some(p) = praefix { + if !name.starts_with(p) { + continue; + } + } + + // Bereich und Auswahl in eine Spalte: Beide sagen dasselbe — welche + // Werte erlaubt sind. + let raum = match (b.bereich, b.auswahl) { + (_, auswahl) if !auswahl.is_empty() => auswahl.join("|"), + (Some((min, max)), _) if max == f64::MAX => format!("{min}.."), + (Some((min, max)), _) => format!("{min}..{max}"), + _ => "-".to_string(), + }; + + zeilen.push(format!( + "control {name} {} {raum} {} {} {}", + b.art.name(), + b.einheit.name(), + if b.schreibbar { "rw" } else { "r" }, + b.text + )); + } + + if zeilen.is_empty() { + return "ok 0 Controls".to_string(); + } + + let anzahl = zeilen.len(); + zeilen.push(format!("ok {anzahl} Controls")); + zeilen.join("\n") +} + +fn get(pult: &Steuerpult, control: Option<&str>) -> String { + let Some(text) = control.filter(|t| !t.is_empty()) else { + return "err get braucht ein Control".to_string(); + }; + let Some(schluessel) = Schluessel::parse(text) else { + return format!("err kein gültiger Name: {text}"); + }; + + match pult.lies(&schluessel) { + Ok(wert) => format!("value {schluessel} {wert}"), + Err(e) => format!("err {e}"), + } +} + +fn set(pult: &mut Steuerpult, control: Option<&str>, wert: Option<&str>, normiert: bool) -> String { + let Some(text) = control.filter(|t| !t.is_empty()) else { + return "err set braucht ein Control".to_string(); + }; + let Some(roh) = wert.filter(|t| !t.is_empty()) else { + return format!("err set {text} braucht einen Wert"); + }; + let Some(schluessel) = Schluessel::parse(text) else { + return format!("err kein gültiger Name: {text}"); + }; + + if normiert { + let Ok(norm) = roh.parse::() else { + return format!("err {roh} ist keine Zahl"); + }; + return match pult.schreibe_normiert(&schluessel, norm) { + Ok(()) => bestaetigen(pult, &schluessel), + Err(e) => format!("err {e}"), + }; + } + + let Some(b) = pult.beschreibung(&schluessel) else { + return format!("err unbekanntes Control: {schluessel}"); + }; + + let parsed = match b.art { + Art::Schalter => match roh { + "1" | "on" | "an" | "true" => Wert::Schalter(true), + "0" | "off" | "aus" | "false" => Wert::Schalter(false), + _ => return format!("err {schluessel} erwartet 0 oder 1, nicht {roh}"), + }, + Art::Auswahl => Wert::Auswahl(roh.to_string()), + Art::Text => Wert::Text(roh.to_string()), + Art::Zahl => { + if roh == "-" { + // Leeren statt auf null setzen — bei einem Hot Cue ist das ein + // Unterschied. + Wert::Leer + } else { + match roh.parse::() { + Ok(v) => Wert::Zahl(v), + Err(_) => return format!("err {roh} ist keine Zahl"), + } + } + } + }; + + match pult.schreibe(&schluessel, parsed) { + Ok(()) => bestaetigen(pult, &schluessel), + Err(e) => format!("err {e}"), + } +} + +/// Bestätigt mit dem Wert, der **wirklich** angekommen ist. +/// +/// Nicht mit dem, der gesendet wurde: Begrenzung und Rundung können ihn +/// verändert haben, und wer steuert, soll das erfahren, ohne nachzufragen. +fn bestaetigen(pult: &Steuerpult, schluessel: &Schluessel) -> String { + match pult.lies(schluessel) { + Ok(wert) => format!("ok {schluessel} {wert}"), + Err(e) => format!("err {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + #[test] + fn setzen_und_lesen_ueber_das_protokoll() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!( + behandle(&mut pult, "set channel1.fader 0.8"), + "ok channel1.fader 0.800000" + ); + assert_eq!( + behandle(&mut pult, "get channel1.fader"), + "value channel1.fader 0.800000" + ); + } + + #[test] + fn die_bestaetigung_zeigt_den_angekommenen_wert() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // 9.0 wird auf 1.0 begrenzt — die Antwort sagt 1, nicht 9. + assert_eq!( + behandle(&mut pult, "set channel1.fader 9"), + "ok channel1.fader 1" + ); + } + + #[test] + fn schalter_verstehen_wort_und_zahl() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for eingabe in ["1", "on", "an", "true"] { + behandle(&mut pult, "set deck1.play 0"); + assert_eq!( + behandle(&mut pult, &format!("set deck1.play {eingabe}")), + "ok deck1.play 1", + "{eingabe} hätte einschalten müssen" + ); + } + + assert!(behandle(&mut pult, "set deck1.play vielleicht").starts_with("err")); + } + + #[test] + fn list_zaehlt_auf_und_laesst_sich_filtern() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + let alles = behandle(&mut pult, "list"); + assert!(alles.contains("control deck1.play schalter")); + assert!(alles.contains("control master.crossfader")); + + let nur_deck2 = behandle(&mut pult, "list deck2."); + assert!(nur_deck2.contains("deck2.play")); + assert!(!nur_deck2.contains("deck1.play")); + assert!(!nur_deck2.contains("master.")); + } + + #[test] + fn jede_zeile_der_liste_traegt_ihre_bedeutung() { + // Der Unterschied zu Mixxx: Wer die Liste hat, braucht kein Handbuch. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let antwort = behandle(&mut pult, "list deck1.tempo"); + + let zeile = antwort.lines().next().unwrap(); + assert!(zeile.contains("zahl"), "Typ fehlt: {zeile}"); + assert!(zeile.contains("0.92..1.08"), "Bereich fehlt: {zeile}"); + assert!(zeile.contains("faktor"), "Einheit fehlt: {zeile}"); + assert!(zeile.contains(" rw "), "Schreibbarkeit fehlt: {zeile}"); + assert!( + zeile.contains("Originalgeschwindigkeit"), + "Text fehlt: {zeile}" + ); + } + + #[test] + fn normiertes_setzen_ist_fuer_midi_da() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // Ein MIDI-Regler auf Anschlag: 127/127 = 1.0 normiert. + assert_eq!( + behandle(&mut pult, "setn channel1.trim 1.0"), + "ok channel1.trim 2" + ); + // In der Raste bei einem bipolaren Control. + assert_eq!( + behandle(&mut pult, "setn channel1.filter 0.5"), + "ok channel1.filter 0" + ); + } + + #[test] + fn hot_cues_lassen_sich_ueber_das_protokoll_leeren() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + behandle(&mut pult, "set deck1.cue1 8.25"); + assert_eq!( + behandle(&mut pult, "get deck1.cue1"), + "value deck1.cue1 8.250000" + ); + + assert_eq!(behandle(&mut pult, "set deck1.cue1 -"), "ok deck1.cue1 -"); + } + + #[test] + fn fehler_sind_immer_als_solche_erkennbar() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for zeile in [ + "quatsch", + "get", + "get deck1.quatsch", + "get nichtsda.play", + "set channel1.fader", + "set channel1.fader laut", + "set deck1.duration 5", + "setn channel1.fader viel", + ] { + let antwort = behandle(&mut pult, zeile); + assert!( + antwort.starts_with("err"), + "'{zeile}' hätte einen Fehler geben müssen, gab: {antwort}" + ); + } + } + + #[test] + fn leere_zeilen_erzeugen_kein_rauschen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert_eq!(behandle(&mut pult, ""), ""); + assert_eq!(behandle(&mut pult, " "), ""); + } + + #[test] + fn ueberzaehlige_leerzeichen_stoeren_nicht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert_eq!( + behandle(&mut pult, " set channel1.fader 0.5 "), + "ok channel1.fader 0.500000" + ); + } +} diff --git a/crates/control/src/pult.rs b/crates/control/src/pult.rs new file mode 100644 index 0000000..e500b38 --- /dev/null +++ b/crates/control/src/pult.rs @@ -0,0 +1,730 @@ +//! Das Steuerpult: ein benannter Zugriff auf alles, was bedienbar ist. +//! +//! Bisher lag die Bedienung an drei Stellen — Transport in den Atomics des +//! `DeckState`, Mixer in der Kommandoschlange, und die gespiegelten Reglerwerte +//! in der Oberfläche. Wer von außen steuern wollte, hätte alle drei anfassen +//! müssen, und die Oberfläche war die einzige Stelle, die wusste, wo ein Fader +//! gerade steht. +//! +//! Das Pult dreht das um: Es **besitzt** den Spiegel, schickt jede Änderung +//! selbst in die Schlange und lässt sich unter einem Namen ansprechen. Die +//! Oberfläche wird damit zu einem von mehreren Bedienern — gleichberechtigt mit +//! einem MIDI-Controller, einem Skript oder einem Agenten. +//! +//! Der Mixer selbst wird nie direkt gelesen: Er lebt im Audio-Callback, und +//! dort etwas zu lesen hieße, ein Lock zu nehmen. Stattdessen gilt der +//! Spiegelwert als Wahrheit — er ist es auch, denn niemand sonst schreibt. + +use std::sync::Arc; + +use audio_core::deck::DeckState; +use audio_engine::{Assign, Command, EngineHandle}; + +use crate::katalog::{self, Beschreibung}; +use crate::schluessel::{Gruppe, Schluessel}; +use crate::wert::{Art, Wert}; + +#[derive(Debug, PartialEq, Eq)] +pub enum Fehler { + UnbekanntesControl(String), + NichtSchreibbar(String), + FalscherTyp { + control: String, + erwartet: Art, + }, + UnbekannteAuswahl { + control: String, + erlaubt: Vec, + }, +} + +impl std::fmt::Display for Fehler { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Fehler::UnbekanntesControl(k) => write!(f, "unbekanntes Control: {k}"), + Fehler::NichtSchreibbar(k) => write!(f, "{k} lässt sich nur lesen"), + Fehler::FalscherTyp { control, erwartet } => { + write!(f, "{control} erwartet {}", erwartet.name()) + } + Fehler::UnbekannteAuswahl { control, erlaubt } => { + write!(f, "{control} kennt nur: {}", erlaubt.join(", ")) + } + } + } +} + +impl std::error::Error for Fehler {} + +/// Ein Deck, wie das Pult es sieht. +pub struct DeckEintrag { + pub state: Arc, + /// Welcher Kanalzug dieses Deck führt. + pub kanal: usize, + pub sample_rate: u32, + pub frames: u64, + pub titel: String, + pub artist: String, +} + +/// Gespiegelte Reglerstellung eines Kanalzuges. +#[derive(Debug, Clone, PartialEq)] +pub struct KanalSpiegel { + pub name: String, + pub trim: f64, + pub eq_low: f64, + pub eq_mid: f64, + pub eq_high: f64, + pub filter: f64, + pub fader: f64, + pub cue: bool, + pub assign: Assign, +} + +impl KanalSpiegel { + pub fn neu(name: impl Into, assign: Assign) -> KanalSpiegel { + KanalSpiegel { + name: name.into(), + trim: 1.0, + eq_low: 1.0, + eq_mid: 1.0, + eq_high: 1.0, + filter: 0.0, + // Zu: ein Kanal, der beim Start auf die Anlage geht, ist ein Unfall. + fader: 0.0, + cue: false, + assign, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MasterSpiegel { + pub crossfader: f64, + pub crossfader_curve: f64, + pub gain: f64, + pub cue_gain: f64, + pub cue_mix: f64, +} + +impl Default for MasterSpiegel { + fn default() -> Self { + MasterSpiegel { + crossfader: 0.0, + crossfader_curve: 0.0, + gain: 1.0, + cue_gain: 1.0, + cue_mix: 0.0, + } + } +} + +pub struct Steuerpult { + decks: Vec, + kanaele: Vec, + master: MasterSpiegel, + handle: EngineHandle, +} + +impl Steuerpult { + pub fn neu(handle: EngineHandle) -> Steuerpult { + Steuerpult { + decks: Vec::new(), + kanaele: Vec::new(), + master: MasterSpiegel::default(), + handle, + } + } + + pub fn deck_hinzufuegen(&mut self, eintrag: DeckEintrag) -> usize { + self.decks.push(eintrag); + self.decks.len() - 1 + } + + pub fn kanal_hinzufuegen(&mut self, spiegel: KanalSpiegel) -> usize { + self.kanaele.push(spiegel); + self.kanaele.len() - 1 + } + + pub fn decks(&self) -> &[DeckEintrag] { + &self.decks + } + + pub fn deck_mut(&mut self, index: usize) -> Option<&mut DeckEintrag> { + self.decks.get_mut(index) + } + + pub fn kanaele(&self) -> &[KanalSpiegel] { + &self.kanaele + } + + pub fn master(&self) -> &MasterSpiegel { + &self.master + } + + pub fn handle_mut(&mut self) -> &mut EngineHandle { + &mut self.handle + } + + /// Alle Controls, die es gerade gibt — mit Beschreibung. + /// + /// „Gerade" ist wörtlich zu nehmen: Die Liste wächst mit den angemeldeten + /// Decks und Kanälen. Ein Agent, der zwei Decks sieht, bekommt zwei Decks + /// aufgezählt, nicht die theoretisch möglichen vier. + pub fn liste(&self) -> Vec<(Schluessel, Beschreibung)> { + let mut aus = Vec::new(); + + for (i, _) in self.decks.iter().enumerate() { + for b in katalog::DECK { + aus.push((Schluessel::neu(Gruppe::Deck(i), b.element), b.clone())); + } + for c in 0..katalog::HOT_CUES { + if let Some(b) = katalog::hot_cue_beschreibung(c) { + aus.push((Schluessel::neu(Gruppe::Deck(i), b.element), b)); + } + } + } + + for (i, _) in self.kanaele.iter().enumerate() { + for b in katalog::KANAL { + aus.push((Schluessel::neu(Gruppe::Kanal(i), b.element), b.clone())); + } + } + + for b in katalog::MASTER { + aus.push((Schluessel::neu(Gruppe::Master, b.element), b.clone())); + } + + aus + } + + /// Beschreibung eines einzelnen Controls, sofern es das gibt. + pub fn beschreibung(&self, k: &Schluessel) -> Option { + match k.gruppe { + Gruppe::Deck(i) if i < self.decks.len() => katalog::DECK + .iter() + .find(|b| b.element == k.element) + .cloned() + .or_else(|| { + self.hot_cue_index(&k.element) + .and_then(katalog::hot_cue_beschreibung) + }), + Gruppe::Kanal(i) if i < self.kanaele.len() => katalog::KANAL + .iter() + .find(|b| b.element == k.element) + .cloned(), + Gruppe::Master => katalog::MASTER + .iter() + .find(|b| b.element == k.element) + .cloned(), + _ => None, + } + } + + fn hot_cue_index(&self, element: &str) -> Option { + let rest = element.strip_prefix("cue")?; + let nummer: usize = rest.parse().ok()?; + nummer.checked_sub(1).filter(|i| *i < katalog::HOT_CUES) + } + + pub fn lies(&self, k: &Schluessel) -> Result { + if self.beschreibung(k).is_none() { + return Err(Fehler::UnbekanntesControl(k.to_string())); + } + + match k.gruppe { + Gruppe::Deck(i) => self.lies_deck(i, &k.element), + Gruppe::Kanal(i) => self.lies_kanal(i, &k.element), + Gruppe::Master => self.lies_master(&k.element), + } + .ok_or_else(|| Fehler::UnbekanntesControl(k.to_string())) + } + + fn lies_deck(&self, i: usize, element: &str) -> Option { + let d = self.decks.get(i)?; + let rate = d.sample_rate as f64; + let sek = |frames: u64| Wert::Zahl(frames as f64 / rate); + + let wert = match element { + "play" => Wert::Schalter(d.state.is_playing()), + "position" => sek(d.state.position_frames()), + "duration" => sek(d.frames), + "bpm" => match d.state.effective_bpm() { + Some(b) => Wert::Zahl(b as f64), + None => Wert::Leer, + }, + "bpm_grid" => match d.state.grid() { + Some(g) => Wert::Zahl(g.bpm as f64), + None => Wert::Leer, + }, + "tempo" => Wert::Zahl(d.state.tempo() as f64), + "keylock" => Wert::Schalter(d.state.keylock()), + "beat_phase" => match d.state.beat_phase(d.sample_rate) { + Some(p) => Wert::Zahl(p), + None => Wert::Leer, + }, + "loop_active" => Wert::Schalter(d.state.is_looping()), + "loop_beats" => match d.state.loop_range() { + // Aus der Schleifenlänge zurückgerechnet: Das Deck merkt sich + // Frames, nicht Beats. + Some((a, b)) => match d.state.grid() { + Some(g) => Wert::Zahl((b - a) as f64 / g.frames_per_beat(d.sample_rate)), + None => Wert::Leer, + }, + None => Wert::Leer, + }, + "title" => Wert::Text(d.titel.clone()), + "artist" => Wert::Text(d.artist.clone()), + _ => { + let c = self.hot_cue_index(element)?; + match d.state.cue(c) { + Some(f) => sek(f), + None => Wert::Leer, + } + } + }; + + Some(wert) + } + + fn lies_kanal(&self, i: usize, element: &str) -> Option { + let k = self.kanaele.get(i)?; + let wert = match element { + "trim" => Wert::Zahl(k.trim), + "eq_low" => Wert::Zahl(k.eq_low), + "eq_mid" => Wert::Zahl(k.eq_mid), + "eq_high" => Wert::Zahl(k.eq_high), + "filter" => Wert::Zahl(k.filter), + "fader" => Wert::Zahl(k.fader), + "cue" => Wert::Schalter(k.cue), + "assign" => Wert::Auswahl(assign_name(k.assign).to_string()), + _ => return None, + }; + Some(wert) + } + + fn lies_master(&self, element: &str) -> Option { + let m = &self.master; + let wert = match element { + "crossfader" => Wert::Zahl(m.crossfader), + "crossfader_curve" => Wert::Zahl(m.crossfader_curve), + "gain" => Wert::Zahl(m.gain), + "cue_gain" => Wert::Zahl(m.cue_gain), + "cue_mix" => Wert::Zahl(m.cue_mix), + _ => return None, + }; + Some(wert) + } + + pub fn schreibe(&mut self, k: &Schluessel, wert: Wert) -> Result<(), Fehler> { + let Some(b) = self.beschreibung(k) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + if !b.schreibbar { + return Err(Fehler::NichtSchreibbar(k.to_string())); + } + + match k.gruppe { + Gruppe::Deck(i) => self.schreibe_deck(i, &b, k, wert), + Gruppe::Kanal(i) => self.schreibe_kanal(i, &b, k, wert), + Gruppe::Master => self.schreibe_master(&b, k, wert), + } + } + + /// Schreiben mit einem Wert aus 0..1, in den echten Bereich gedehnt. + /// + /// Das ist der Weg, den ein MIDI-Controller nimmt: Er kennt nur 0 bis 127 + /// und weiß nichts über Faktoren oder Bereiche. + pub fn schreibe_normiert(&mut self, k: &Schluessel, norm: f64) -> Result<(), Fehler> { + let Some(b) = self.beschreibung(k) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + + let wert = match b.art { + Art::Schalter => Wert::Schalter(norm >= 0.5), + _ => Wert::Zahl(b.aus_normiert(norm)), + }; + self.schreibe(k, wert) + } + + fn zahl(b: &Beschreibung, k: &Schluessel, wert: &Wert) -> Result { + wert.als_zahl() + .map(|v| b.begrenzen(v)) + .ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: b.art, + }) + } + + fn schalter(b: &Beschreibung, k: &Schluessel, wert: &Wert) -> Result { + wert.als_schalter().ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: b.art, + }) + } + + fn schreibe_deck( + &mut self, + i: usize, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + let Some(d) = self.decks.get(i) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + let rate = d.sample_rate as f64; + + match k.element.as_str() { + "play" => d.state.set_playing(Self::schalter(b, k, &wert)?), + "keylock" => d.state.set_keylock(Self::schalter(b, k, &wert)?), + "loop_active" => { + d.state.set_loop_active(Self::schalter(b, k, &wert)?); + } + "tempo" => d.state.set_tempo(Self::zahl(b, k, &wert)? as f32), + "position" => d + .state + .seek_frames((Self::zahl(b, k, &wert)? * rate).max(0.0) as u64), + "loop_beats" => { + let beats = Self::zahl(b, k, &wert)?; + // Ohne Grid gibt es keine Beats — dann passiert nichts, statt + // eine Schleife an einer geratenen Stelle zu setzen. + if d.state.set_loop_beats(beats, d.sample_rate) { + d.state.set_loop_active(true); + } + } + _ => { + let Some(c) = self.hot_cue_index(&k.element) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + match wert { + // Ein Hot Cue lässt sich löschen, indem man ihn leert. + Wert::Leer => d.state.set_cue(c, None), + _ => { + let sek = Self::zahl(b, k, &wert)?; + d.state.set_cue(c, Some((sek * rate).max(0.0) as u64)); + } + } + } + } + + Ok(()) + } + + fn schreibe_kanal( + &mut self, + i: usize, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + if i >= self.kanaele.len() { + return Err(Fehler::UnbekanntesControl(k.to_string())); + } + + // Erst prüfen und umrechnen, dann spiegeln, dann senden. Scheitert die + // Prüfung, ist der Spiegel unberührt und stimmt weiter mit dem Mixer + // überein. + let element = k.element.as_str(); + if element == "assign" { + let name = wert.als_text().ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: Art::Auswahl, + })?; + let assign = assign_aus_name(name).ok_or_else(|| Fehler::UnbekannteAuswahl { + control: k.to_string(), + erlaubt: b.auswahl.iter().map(|s| s.to_string()).collect(), + })?; + self.kanaele[i].assign = assign; + self.handle.send(Command::Assign(i, assign)); + return Ok(()); + } + + if element == "cue" { + let an = Self::schalter(b, k, &wert)?; + self.kanaele[i].cue = an; + self.handle.send(Command::Cue(i, an)); + return Ok(()); + } + + let v = Self::zahl(b, k, &wert)?; + let kanal = &mut self.kanaele[i]; + + let befehl = match element { + "trim" => { + kanal.trim = v; + Command::Trim(i, v as f32) + } + "fader" => { + kanal.fader = v; + Command::Fader(i, v as f32) + } + "filter" => { + kanal.filter = v; + Command::Filter(i, v as f32) + } + "eq_low" | "eq_mid" | "eq_high" => { + match element { + "eq_low" => kanal.eq_low = v, + "eq_mid" => kanal.eq_mid = v, + _ => kanal.eq_high = v, + } + // Der EQ wird als Ganzes geschickt — die drei Bänder hängen im + // Mixer an einem Filtersatz. + Command::Eq { + channel: i, + low: kanal.eq_low as f32, + mid: kanal.eq_mid as f32, + high: kanal.eq_high as f32, + } + } + _ => return Err(Fehler::UnbekanntesControl(k.to_string())), + }; + + self.handle.send(befehl); + Ok(()) + } + + fn schreibe_master( + &mut self, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + let v = Self::zahl(b, k, &wert)?; + + let befehl = match k.element.as_str() { + "crossfader" => { + self.master.crossfader = v; + Command::Crossfader(v as f32) + } + "crossfader_curve" => { + self.master.crossfader_curve = v; + Command::CrossfaderCurve(v as f32) + } + "gain" => { + self.master.gain = v; + Command::MasterGain(v as f32) + } + "cue_gain" => { + self.master.cue_gain = v; + Command::CueGain(v as f32) + } + "cue_mix" => { + self.master.cue_mix = v; + Command::CueMix(v as f32) + } + _ => return Err(Fehler::UnbekanntesControl(k.to_string())), + }; + + self.handle.send(befehl); + Ok(()) + } +} + +pub fn assign_name(assign: Assign) -> &'static str { + match assign { + Assign::A => "a", + Assign::B => "b", + Assign::Thru => "thru", + } +} + +pub fn assign_aus_name(name: &str) -> Option { + match name { + "a" | "A" => Some(Assign::A), + "b" | "B" => Some(Assign::B), + "thru" | "Thru" => Some(Assign::Thru), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// Vergleich mit Toleranz. + /// + /// Tempo und Reglerwerte liegen im Deck und im Mixer als `f32` — dort + /// gehört die Genauigkeit hin, denn sie werden pro Sample benutzt. Auf + /// exakte Gleichheit zu prüfen hieße, eine Genauigkeit zu behaupten, die + /// es nicht gibt: 1.04 als `f32` und zurück ist 1.0399999618530273. + fn gleich(a: &Wert, b: &Wert) -> bool { + match (a, b) { + (Wert::Zahl(x), Wert::Zahl(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } + } + + #[test] + fn geschriebene_werte_lassen_sich_wieder_lesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for (control, wert) in [ + ("channel1.fader", Wert::Zahl(0.75)), + ("channel1.eq_low", Wert::Zahl(0.0)), + ("channel2.trim", Wert::Zahl(1.5)), + ("master.crossfader", Wert::Zahl(-0.5)), + ("master.gain", Wert::Zahl(0.9)), + ("deck1.tempo", Wert::Zahl(1.04)), + ("deck2.keylock", Wert::Schalter(true)), + ("deck1.play", Wert::Schalter(true)), + ] { + pult.schreibe(&k(control), wert.clone()).expect(control); + let gelesen = pult.lies(&k(control)).expect(control); + assert!( + gleich(&gelesen, &wert), + "{control}: {gelesen:?} statt {wert:?}" + ); + } + } + + #[test] + fn der_spiegel_bleibt_bei_einem_fehler_unberuehrt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(0.6)) + .unwrap(); + + // Ein Text auf einem Zahlen-Control muss scheitern … + let fehler = pult.schreibe(&k("channel1.fader"), Wert::Text("laut".into())); + assert!(fehler.is_err()); + + // … und darf den vorherigen Wert nicht angetastet haben, sonst liefen + // Spiegel und Mixer auseinander. + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(0.6)); + } + + #[test] + fn werte_ausserhalb_des_bereichs_werden_begrenzt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("channel1.fader"), Wert::Zahl(9.0)) + .unwrap(); + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(1.0)); + + pult.schreibe(&k("deck1.tempo"), Wert::Zahl(3.0)).unwrap(); + assert!(gleich( + &pult.lies(&k("deck1.tempo")).unwrap(), + &Wert::Zahl(katalog::TEMPO_MAX) + )); + } + + #[test] + fn nur_lesbare_controls_weisen_das_schreiben_ab() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.schreibe(&k("deck1.duration"), Wert::Zahl(10.0)); + assert_eq!( + fehler, + Err(Fehler::NichtSchreibbar("deck1.duration".into())) + ); + } + + #[test] + fn unbekannte_controls_werden_benannt_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!( + pult.lies(&k("deck1.quatsch")), + Err(Fehler::UnbekanntesControl("deck1.quatsch".into())) + ); + // Ein Deck, das es nicht gibt, ist kein stiller Fehlschlag. + assert_eq!( + pult.lies(&k("deck9.play")), + Err(Fehler::UnbekanntesControl("deck9.play".into())) + ); + assert!(pult + .schreibe(&k("channel9.fader"), Wert::Zahl(1.0)) + .is_err()); + } + + #[test] + fn die_auswahl_nimmt_nur_ihre_eigenen_namen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("channel1.assign"), Wert::Auswahl("thru".into())) + .unwrap(); + assert_eq!( + pult.lies(&k("channel1.assign")).unwrap(), + Wert::Auswahl("thru".into()) + ); + + let fehler = pult.schreibe(&k("channel1.assign"), Wert::Auswahl("mitte".into())); + assert!(matches!(fehler, Err(Fehler::UnbekannteAuswahl { .. }))); + } + + #[test] + fn normiertes_schreiben_trifft_die_enden_und_die_mitte() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // Ein MIDI-Regler in der Raste steht bei einem bipolaren Control auf 0. + pult.schreibe_normiert(&k("channel1.filter"), 0.5).unwrap(); + assert_eq!(pult.lies(&k("channel1.filter")).unwrap(), Wert::Zahl(0.0)); + + pult.schreibe_normiert(&k("channel1.filter"), 1.0).unwrap(); + assert_eq!(pult.lies(&k("channel1.filter")).unwrap(), Wert::Zahl(1.0)); + + // Auf einem Schalter ist die obere Hälfte „an". + pult.schreibe_normiert(&k("deck1.play"), 1.0).unwrap(); + assert_eq!(pult.lies(&k("deck1.play")).unwrap(), Wert::Schalter(true)); + pult.schreibe_normiert(&k("deck1.play"), 0.0).unwrap(); + assert_eq!(pult.lies(&k("deck1.play")).unwrap(), Wert::Schalter(false)); + } + + #[test] + fn hot_cues_lassen_sich_setzen_und_wieder_leeren() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Leer); + + pult.schreibe(&k("deck1.cue3"), Wert::Zahl(12.5)).unwrap(); + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Zahl(12.5)); + + pult.schreibe(&k("deck1.cue3"), Wert::Leer).unwrap(); + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Leer); + + // Über die Anzahl hinaus gibt es keine. + assert!(pult.lies(&k("deck1.cue9")).is_err()); + } + + #[test] + fn die_liste_waechst_mit_den_angemeldeten_geraeten() { + let (pult, _runner) = pult_mit_zwei_decks(); + let liste = pult.liste(); + + // Zwei Decks, drei Kanäle, ein Master — nichts Erfundenes. + assert!(liste.iter().any(|(k, _)| k.to_string() == "deck2.play")); + assert!(liste.iter().any(|(k, _)| k.to_string() == "channel3.fader")); + assert!(!liste.iter().any(|(k, _)| k.to_string() == "deck3.play")); + assert!(!liste.iter().any(|(k, _)| k.to_string() == "channel4.fader")); + + // Und jedes aufgezählte Control lässt sich auch wirklich lesen. + for (schluessel, _) in &liste { + pult.lies(schluessel) + .unwrap_or_else(|e| panic!("{schluessel} steht in der Liste, aber: {e}")); + } + } + + #[test] + fn jedes_schreibbare_control_laesst_sich_auch_schreiben() { + // Der Katalog verspricht Schreibbarkeit — das hier prüft, dass die + // Umsetzung dahinter existiert und nicht nur die Beschreibung. + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for (schluessel, b) in pult.liste() { + if !b.schreibbar { + continue; + } + let wert = match b.art { + Art::Schalter => Wert::Schalter(true), + Art::Auswahl => Wert::Auswahl(b.auswahl[0].to_string()), + _ => Wert::Zahl(b.aus_normiert(0.5)), + }; + pult.schreibe(&schluessel, wert) + .unwrap_or_else(|e| panic!("{schluessel} ist als schreibbar gemeldet, aber: {e}")); + } + } +} diff --git a/crates/control/src/schluessel.rs b/crates/control/src/schluessel.rs new file mode 100644 index 0000000..b570e4c --- /dev/null +++ b/crates/control/src/schluessel.rs @@ -0,0 +1,138 @@ +//! Adressierung eines Controls. +//! +//! Zweiteilig wie bei Mixxx — Gruppe und Element — aber in einer Schreibweise, +//! die man tippen und in eine URL schreiben kann: `deck1.play` statt +//! `[Channel1],play`. +//! +//! Die Gruppe ist getippt und nicht bloß eine Zeichenkette. `deck1` und +//! `channel1` sind nicht dasselbe: Das Deck ist der Abspieler, der Kanal ist +//! der Zug am Mischpult. Bei Mixxx fallen beide in `[Channel1]` zusammen, was +//! genau so lange gutgeht, bis ein Kanal etwas anderes als ein Deck führt — +//! bei uns der AUX-Eingang. + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Gruppe { + /// Ein Abspieler. Eins-basiert nach außen, null-basiert im Inneren. + Deck(usize), + /// Ein Zug am Mischpult. Führt ein Deck oder den AUX-Eingang. + Kanal(usize), + /// Summe, Crossfader, Kopfhörer. + Master, +} + +impl fmt::Display for Gruppe { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Gruppe::Deck(i) => write!(f, "deck{}", i + 1), + Gruppe::Kanal(i) => write!(f, "channel{}", i + 1), + Gruppe::Master => f.write_str("master"), + } + } +} + +impl Gruppe { + pub fn parse(text: &str) -> Option { + if text == "master" { + return Some(Gruppe::Master); + } + + for (prefix, bauen) in [ + ("deck", Gruppe::Deck as fn(usize) -> Gruppe), + ("channel", Gruppe::Kanal as fn(usize) -> Gruppe), + ] { + if let Some(rest) = text.strip_prefix(prefix) { + // Eins-basiert nach außen: „deck0" gibt es nicht, und es + // stillschweigend als Deck 1 zu lesen würde Tippfehler + // verstecken. + let nummer: usize = rest.parse().ok()?; + return nummer.checked_sub(1).map(bauen); + } + } + + None + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Schluessel { + pub gruppe: Gruppe, + pub element: String, +} + +impl Schluessel { + pub fn neu(gruppe: Gruppe, element: impl Into) -> Schluessel { + Schluessel { + gruppe, + element: element.into(), + } + } + + pub fn parse(text: &str) -> Option { + let (gruppe, element) = text.split_once('.')?; + if element.is_empty() || element.contains('.') { + return None; + } + Some(Schluessel::neu(Gruppe::parse(gruppe)?, element)) + } +} + +impl fmt::Display for Schluessel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}.{}", self.gruppe, self.element) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schluessel_ueberleben_hin_und_zurueck() { + for text in [ + "deck1.play", + "deck2.tempo", + "channel3.eq_low", + "master.gain", + ] { + let k = Schluessel::parse(text).unwrap_or_else(|| panic!("{text} nicht lesbar")); + assert_eq!(k.to_string(), text); + } + } + + #[test] + fn nummern_sind_nach_aussen_eins_basiert() { + assert_eq!(Gruppe::parse("deck1"), Some(Gruppe::Deck(0))); + assert_eq!(Gruppe::parse("channel4"), Some(Gruppe::Kanal(3))); + + // Kein stilles Umdeuten: Deck 0 gibt es nicht. + assert_eq!(Gruppe::parse("deck0"), None); + } + + #[test] + fn deck_und_kanal_sind_nicht_dasselbe() { + // Der AUX-Eingang ist ein Kanal ohne Deck. Fielen beide zusammen, + // gäbe es für ihn keine Adresse. + assert_ne!(Gruppe::parse("deck1"), Gruppe::parse("channel1")); + } + + #[test] + fn kaputte_schluessel_werden_abgewiesen() { + for text in [ + "", + "deck1", + "deck1.", + ".play", + "deck1.play.now", + "deckX.play", + "quatsch.play", + ] { + assert_eq!( + Schluessel::parse(text), + None, + "{text} hätte scheitern müssen" + ); + } + } +} diff --git a/crates/control/src/server.rs b/crates/control/src/server.rs new file mode 100644 index 0000000..8b79130 --- /dev/null +++ b/crates/control/src/server.rs @@ -0,0 +1,241 @@ +//! Die Steckdose: das Protokoll über einen Unix-Socket. +//! +//! Ein Socket im Dateisystem und nicht TCP, und das ist eine +//! Sicherheitsentscheidung, keine Bequemlichkeit. Wer hier hineinschreibt, +//! steuert die Anlage. Ein offener TCP-Port täte das für jeden im selben +//! Netz — auf einer Clubbühne mit fremdem WLAN ist das keine theoretische +//! Sorge. Ein Unix-Socket erbt dagegen die Rechte des Dateisystems: Wer die +//! Datei nicht öffnen darf, kommt nicht hinein. +//! +//! Ein Thread je Verbindung, ein Mutex um das Pult. Das ist grob und hier +//! völlig ausreichend: Es fließen Reglerbewegungen, keine Samples, und der +//! Audio-Thread ist an keiner Stelle beteiligt — er sieht nur die lock-freie +//! Kommandoschlange, die das Pult füllt. +//! +//! ```sh +//! nc -U /tmp/musik.sock +//! list deck1. +//! set deck1.play 1 +//! ``` + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::protokoll; +use crate::pult::Steuerpult; + +#[derive(Debug)] +pub enum ServerFehler { + Belegt(PathBuf), + Io(std::io::Error), +} + +impl std::fmt::Display for ServerFehler { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ServerFehler::Belegt(p) => write!( + f, + "{} wird bereits von einer laufenden Instanz benutzt", + p.display() + ), + ServerFehler::Io(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for ServerFehler {} + +impl From for ServerFehler { + fn from(e: std::io::Error) -> Self { + ServerFehler::Io(e) + } +} + +pub struct Server { + pfad: PathBuf, +} + +impl Server { + /// Startet den Server im Hintergrund. + /// + /// Die Verbindung zum Pult läuft über den geteilten Mutex; der Aufrufer + /// behält seinen eigenen Zugriff und kann weiter bedienen. + #[cfg(unix)] + pub fn starten(pfad: &Path, pult: Arc>) -> Result { + use std::os::unix::net::{UnixListener, UnixStream}; + + // Ein liegengebliebener Socket einer abgestürzten Instanz blockiert + // sonst dauerhaft. Verbinden entscheidet, ob wirklich jemand lauscht: + // gelingt es, läuft eine zweite Instanz und wir treten zurück. + if pfad.exists() { + if UnixStream::connect(pfad).is_ok() { + return Err(ServerFehler::Belegt(pfad.to_path_buf())); + } + std::fs::remove_file(pfad)?; + } + + if let Some(ordner) = pfad.parent() { + std::fs::create_dir_all(ordner)?; + } + + let listener = UnixListener::bind(pfad)?; + let pfad_kopie = pfad.to_path_buf(); + + std::thread::Builder::new() + .name("control-server".into()) + .spawn(move || { + for verbindung in listener.incoming() { + let Ok(strom) = verbindung else { continue }; + let pult = Arc::clone(&pult); + // Ein Thread je Verbindung. Wer die Anlage steuert, hat + // selten mehr als eine Handvoll offen. + let _ = std::thread::Builder::new() + .name("control-verbindung".into()) + .spawn(move || bediene(strom, pult)); + } + })?; + + Ok(Server { pfad: pfad_kopie }) + } + + #[cfg(not(unix))] + pub fn starten(_pfad: &Path, _pult: Arc>) -> Result { + Err(ServerFehler::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Unix-Sockets gibt es hier nicht — eine Named Pipe fehlt noch", + ))) + } + + pub fn pfad(&self) -> &Path { + &self.pfad + } +} + +impl Drop for Server { + fn drop(&mut self) { + // Aufräumen, damit der nächste Start nicht über die eigene Leiche + // stolpert. + let _ = std::fs::remove_file(&self.pfad); + } +} + +#[cfg(unix)] +fn bediene(strom: std::os::unix::net::UnixStream, pult: Arc>) { + let Ok(schreiber) = strom.try_clone() else { + return; + }; + let leser = BufReader::new(strom); + let mut aus = schreiber; + + for zeile in leser.lines() { + let Ok(zeile) = zeile else { break }; + + let antwort = match pult.lock() { + Ok(mut p) => protokoll::behandle(&mut p, &zeile), + // Ein vergifteter Mutex heißt: irgendwo ist ein Thread mit dem + // Pult in der Hand gestorben. Weiterbedienen wäre geraten. + Err(_) => "err Steuerpult ist in einem unklaren Zustand".to_string(), + }; + + if antwort.is_empty() { + continue; + } + if writeln!(aus, "{antwort}").is_err() || aus.flush().is_err() { + break; + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use std::io::BufRead; + use std::os::unix::net::UnixStream; + + /// Ein Socketpfad, der zu diesem Testlauf gehört und sonst niemandem. + fn pfad(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("musik-test-{}-{name}.sock", std::process::id())); + p + } + + fn sprich(strom: &UnixStream, zeile: &str) -> String { + let mut schreiber = strom.try_clone().unwrap(); + writeln!(schreiber, "{zeile}").unwrap(); + schreiber.flush().unwrap(); + + let mut leser = BufReader::new(strom.try_clone().unwrap()); + let mut antwort = String::new(); + leser.read_line(&mut antwort).unwrap(); + antwort.trim_end().to_string() + } + + #[test] + fn ein_fremder_prozess_kann_die_anlage_bedienen() { + // Das ist der Punkt der ganzen Übung — und genau das, was Mixxx + // nicht kann. + let (pult, _runner) = pult_mit_zwei_decks(); + let pult = Arc::new(Mutex::new(pult)); + let p = pfad("bedienen"); + let server = Server::starten(&p, Arc::clone(&pult)).expect("Server startet nicht"); + + let strom = UnixStream::connect(server.pfad()).expect("keine Verbindung"); + assert_eq!( + sprich(&strom, "set channel1.fader 0.8"), + "ok channel1.fader 0.800000" + ); + assert_eq!( + sprich(&strom, "get channel1.fader"), + "value channel1.fader 0.800000" + ); + + // Und die Änderung ist wirklich im Pult angekommen, nicht nur in der + // Antwort. + let gelesen = pult + .lock() + .unwrap() + .lies(&crate::Schluessel::parse("channel1.fader").unwrap()) + .unwrap(); + assert_eq!(gelesen, crate::Wert::Zahl(0.8)); + } + + #[test] + fn mehrere_bediener_gleichzeitig() { + let (pult, _runner) = pult_mit_zwei_decks(); + let p = pfad("mehrere"); + let server = Server::starten(&p, Arc::new(Mutex::new(pult))).unwrap(); + + let a = UnixStream::connect(server.pfad()).unwrap(); + let b = UnixStream::connect(server.pfad()).unwrap(); + + assert_eq!(sprich(&a, "set deck1.play 1"), "ok deck1.play 1"); + // Der zweite Bediener sieht, was der erste getan hat. + assert_eq!(sprich(&b, "get deck1.play"), "value deck1.play 1"); + } + + #[test] + fn ein_liegengebliebener_socket_blockiert_den_start_nicht() { + let p = pfad("leiche"); + std::fs::write(&p, b"").unwrap(); + + let (pult, _runner) = pult_mit_zwei_decks(); + let server = Server::starten(&p, Arc::new(Mutex::new(pult))); + assert!( + server.is_ok(), + "abgestürzte Vorgänger dürfen nicht dauerhaft blockieren" + ); + } + + #[test] + fn der_socket_verschwindet_beim_beenden() { + let p = pfad("aufraeumen"); + let (pult, _runner) = pult_mit_zwei_decks(); + { + let _server = Server::starten(&p, Arc::new(Mutex::new(pult))).unwrap(); + assert!(p.exists()); + } + assert!(!p.exists(), "der Socket bleibt liegen"); + } +} diff --git a/crates/control/src/testing.rs b/crates/control/src/testing.rs new file mode 100644 index 0000000..f8b1a91 --- /dev/null +++ b/crates/control/src/testing.rs @@ -0,0 +1,57 @@ +//! Ein vollständig verkabeltes Pult für Tests. +//! +//! Mit echter Engine und echter Kommandoschlange, nur ohne Audiogerät. Der +//! `EngineRunner` muss dabei am Leben bleiben: Fällt er weg, bricht die +//! Schlange, und jedes gesendete Kommando liefe ins Nichts — die Tests würden +//! grün, obwohl nichts ankommt. + +use std::sync::Arc; + +use audio_core::deck::DeckState; +use audio_engine::{Assign, DeckSource, Engine, EngineRunner, SilentSource}; + +use crate::pult::{DeckEintrag, KanalSpiegel, Steuerpult}; + +pub const RATE: u32 = 48_000; + +/// Zwei Decks auf Kanal 1 und 2, dazu ein AUX-Kanal auf Thru. +pub fn pult_mit_zwei_decks() -> (Steuerpult, EngineRunner) { + let mut engine = Engine::new(RATE as f32); + + let mut kanaele = Vec::new(); + for (name, assign) in [("DECK A", Assign::A), ("DECK B", Assign::B)] { + let kanal = engine.add_channel(name, Box::new(SilentSource)); + engine.channel(kanal).set_assign(assign); + kanaele.push((name, kanal, assign)); + } + let aux = engine.add_channel("AUX", Box::new(SilentSource)); + engine.channel(aux).set_assign(Assign::Thru); + + let (handle, runner) = audio_engine::engine_channel(engine, 256); + let mut pult = Steuerpult::neu(handle); + + for (name, kanal, assign) in &kanaele { + pult.kanal_hinzufuegen(KanalSpiegel::neu(*name, *assign)); + pult.deck_hinzufuegen(DeckEintrag { + state: Arc::new(DeckState::new()), + kanal: *kanal, + sample_rate: RATE, + // Eine Minute, damit Positionen und Hot Cues Platz haben. + frames: RATE as u64 * 60, + titel: format!("Testtrack {name}"), + artist: "Test".into(), + }); + } + pult.kanal_hinzufuegen(KanalSpiegel::neu("AUX", Assign::Thru)); + + // Ein Deck ohne Grid könnte `loop_beats` nicht setzen, und der Test, der + // jedes schreibbare Control durchgeht, würde das nicht bemerken. + for eintrag in pult.decks() { + eintrag + .state + .set_grid(Some(audio_core::Beatgrid::new(128.0, 0, 1.0))); + } + + let _ = DeckSource::new as fn(_) -> _; + (pult, runner) +} diff --git a/crates/control/src/wert.rs b/crates/control/src/wert.rs new file mode 100644 index 0000000..7207d40 --- /dev/null +++ b/crates/control/src/wert.rs @@ -0,0 +1,137 @@ +//! Werte, die über das Steuerpult laufen. +//! +//! Bewusst **typisiert** statt alles als Fließkommazahl. Mixxx macht jedes +//! Control zu einem `double` — `play` ist dort 0.0 oder 1.0, die +//! Crossfader-Zuweisung eine durchnummerierte Zahl. Das ist einfach zu bauen +//! und schwer zu benutzen: Wer von außen steuert, muss wissen, dass 2.0 bei +//! `orientation` „rechts" heißt, und ein Tippfehler wird zu einem gültigen +//! Wert statt zu einem Fehler. +//! +//! Hier ist ein Schalter ein Schalter, eine Auswahl trägt ihre erlaubten Namen +//! und eine Zahl ihren Bereich. Falsches lässt sich damit zurückweisen, statt +//! es stumm zu übernehmen. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum Wert { + Schalter(bool), + Zahl(f64), + Auswahl(String), + Text(String), + /// Ein Wert, den es gerade nicht gibt — ein ungesetzter Hot Cue, das Tempo + /// eines Decks ohne Beatgrid. + Leer, +} + +impl Wert { + pub fn als_zahl(&self) -> Option { + match self { + Wert::Zahl(v) => Some(*v), + Wert::Schalter(b) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + } + } + + pub fn als_schalter(&self) -> Option { + match self { + Wert::Schalter(b) => Some(*b), + Wert::Zahl(v) => Some(*v >= 0.5), + _ => None, + } + } + + pub fn als_text(&self) -> Option<&str> { + match self { + Wert::Text(s) | Wert::Auswahl(s) => Some(s), + _ => None, + } + } +} + +impl fmt::Display for Wert { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Wert::Schalter(true) => f.write_str("1"), + Wert::Schalter(false) => f.write_str("0"), + // Kurz halten, aber nichts verschlucken: Positionen brauchen + // Millisekunden-Auflösung, Verstärkungen nicht. + Wert::Zahl(v) if v.fract() == 0.0 && v.abs() < 1e15 => write!(f, "{v:.0}"), + Wert::Zahl(v) => write!(f, "{v:.6}"), + Wert::Auswahl(s) | Wert::Text(s) => f.write_str(s), + Wert::Leer => f.write_str("-"), + } + } +} + +/// Was für ein Wert an einem Control hängt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Art { + Schalter, + Zahl, + Auswahl, + Text, +} + +impl Art { + pub fn name(&self) -> &'static str { + match self { + Art::Schalter => "schalter", + Art::Zahl => "zahl", + Art::Auswahl => "auswahl", + Art::Text => "text", + } + } +} + +/// Wofür die Zahl steht. Ohne das ist „0.8" nicht interpretierbar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Einheit { + Keine, + /// 1.0 = unverändert. Tempo, Verstärkung, EQ. + Faktor, + Sekunden, + Beats, + Bpm, + /// −1 bis +1, Mitte 0. Crossfader, Filter. + Bipolar, +} + +impl Einheit { + pub fn name(&self) -> &'static str { + match self { + Einheit::Keine => "-", + Einheit::Faktor => "faktor", + Einheit::Sekunden => "s", + Einheit::Beats => "beats", + Einheit::Bpm => "bpm", + Einheit::Bipolar => "bipolar", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schalter_werden_als_null_und_eins_geschrieben() { + assert_eq!(Wert::Schalter(true).to_string(), "1"); + assert_eq!(Wert::Schalter(false).to_string(), "0"); + } + + #[test] + fn zahlen_verlieren_beim_schreiben_keine_aufloesung() { + // Eine Position auf die zweite Nachkommastelle zu runden wäre bei + // 44 100 Hz ein Fehler von hunderten Samples. + assert_eq!(Wert::Zahl(12.345678).to_string(), "12.345678"); + assert_eq!(Wert::Zahl(4.0).to_string(), "4"); + } + + #[test] + fn ein_leerer_wert_ist_kein_null() { + // Ein ungesetzter Hot Cue liegt nicht auf Sekunde 0. + assert_eq!(Wert::Leer.to_string(), "-"); + assert_eq!(Wert::Leer.als_zahl(), None); + } +} diff --git a/crates/musik-app/Cargo.toml b/crates/musik-app/Cargo.toml index d3155e8..114abcf 100644 --- a/crates/musik-app/Cargo.toml +++ b/crates/musik-app/Cargo.toml @@ -9,6 +9,7 @@ analysis = { version = "0.1.0", path = "../analysis" } anyhow = "1.0.104" audio-core = { version = "0.1.0", path = "../audio-core" } audio-engine = { version = "0.1.0", path = "../audio-engine" } +control = { version = "0.1.0", path = "../control" } eframe = "0.35.0" egui = "0.35.0" image = { version = "0.25.10", default-features = false, features = ["png"] } diff --git a/crates/musik-app/src/app.rs b/crates/musik-app/src/app.rs index 6e10147..4943c65 100644 --- a/crates/musik-app/src/app.rs +++ b/crates/musik-app/src/app.rs @@ -4,16 +4,19 @@ //! darunter die Sammlung. Das ist die Anordnung, die man von einem DJ-Setup //! kennt — Plattenspieler oben, Mischpult in der Mitte, Plattenkiste unten. //! -//! Die Oberfläche fasst den Mixer nie direkt an. Sie hält gespiegelte Werte für -//! die Darstellung und schickt jede Änderung als Kommando in die lock-freie -//! Schlange. Den Transport steuert sie über die Atomics im `DeckState`. +//! Die Oberfläche fasst den Mixer nie direkt an — und sie hält auch keine +//! eigenen Werte mehr. Alles Bedienbare liegt im Steuerpult (`control`), das +//! die Kommandos in die lock-freie Schlange schickt. Damit ist die Oberfläche +//! einer von mehreren Bedienern: Ein Skript am Socket bewegt denselben Fader, +//! und beide sehen sofort dasselbe. use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use analysis::peaks::PeakLevel; use audio_core::deck::{DeckState, HOT_CUES}; -use audio_engine::{Assign, Command, EngineHandle, Output}; +use audio_engine::{Assign, Output}; +use control::{Einheit, Gruppe, Schluessel, Steuerpult, Wert}; use egui::{Color32, RichText, Ui}; use library::{Library, Query, TrackRecord}; @@ -32,43 +35,16 @@ const MIXER_HOEHE: f32 = 262.0; /// Liste will und beim Mixen mehr Wellenform. const SAMMLUNG_HOEHE: f32 = 190.0; -pub struct ChannelUi { - pub name: String, - pub channel: usize, - pub trim: f32, - pub low: f32, - pub mid: f32, - pub high: f32, - pub filter: f32, - pub fader: f32, - pub cue: bool, -} - -impl ChannelUi { - pub fn new(name: impl Into, channel: usize) -> Self { - ChannelUi { - name: name.into(), - channel, - trim: 1.0, - low: 1.0, - mid: 1.0, - high: 1.0, - filter: 0.0, - fader: 0.0, - cue: false, - } - } -} - +/// Was die Oberfläche über ein Deck weiß, das im Steuerpult nicht steht. +/// +/// Titel, Tempo, Position und Reglerstellungen liegen im Pult — hier bleibt +/// nur, was allein zum Zeichnen gebraucht wird. pub struct DeckUi { pub name: String, pub state: Arc, - pub artist: String, - pub titel: String, pub peaks: Vec, pub frames: u64, pub sample_rate: u32, - pub strip: ChannelUi, /// Sichtbarer Ausschnitt der Zoom-Ansicht, in Sekunden. pub zoom_secs: f32, pub loop_beats: f64, @@ -92,24 +68,26 @@ impl DeckUi { pub struct Screenshot { pub pfad: PathBuf, - pub warte_bilder: u32, + /// Erst ab diesem Zeitpunkt wird ausgelöst. + /// + /// Eine feste Zahl von Bildern wäre willkürlich und je nach Rechner + /// unterschiedlich lang. Über die Zeit lässt sich außerdem etwas + /// dazwischenschieben — etwa die Anlage über den Socket in einen + /// bestimmten Zustand fahren und *den* aufnehmen. + pub warte_bis: std::time::Instant, pub angefordert: bool, } pub struct MusikApp { - pub handle: EngineHandle, + /// Der gemeinsame Steuerraum. Die Oberfläche ist einer von mehreren + /// Bedienern — ein Skript am Socket sieht dieselben Werte. + pub pult: Arc>, /// Hält den Audio-Stream am Leben. Fällt er weg, verstummt die Ausgabe — /// deshalb liegt er hier, auch wenn sonst niemand ihn anfasst. #[allow(dead_code)] pub output: Option, pub audio_hinweis: String, pub decks: Vec, - pub aux: ChannelUi, - pub crossfader: f32, - pub crossfader_kurve: f32, - pub master_gain: f32, - pub cue_mix: f32, - pub cue_gain: f32, pub library: Option, pub analyse_cache: PathBuf, pub suche: String, @@ -122,7 +100,9 @@ impl eframe::App for MusikApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { // Abgelöste Quellen abholen, sonst nimmt der Audio-Thread irgendwann // keine neuen Tracks mehr an. - self.handle.collect_retired(); + if let Ok(mut pult) = self.pult.lock() { + pult.handle_mut().collect_retired(); + } // Kopie, weil `ui` gleich selbst ausgeliehen wird und der Kontext // danach noch für den Screenshot gebraucht wird. @@ -213,11 +193,17 @@ impl MusikApp { uebersicht: f32, zoom: f32, ) { - let (titel, artist, dauer, position, bpm, keylock, laeuft) = { + let (titel, artist) = match self.pult.lock() { + Ok(pult) => match pult.decks().get(index) { + Some(d) => (d.titel.clone(), d.artist.clone()), + None => (String::new(), String::new()), + }, + Err(_) => (String::new(), String::new()), + }; + + let (dauer, position, bpm, keylock, laeuft) = { let deck = &self.decks[index]; ( - deck.titel.clone(), - deck.artist.clone(), deck.dauer_secs(), deck.position_secs(), deck.state.effective_bpm(), @@ -438,185 +424,24 @@ impl MusikApp { fn mixer(&mut self, ui: &mut Ui) { ui.add_space(4.0); - ui.horizontal_top(|ui| { - for index in 0..self.decks.len() { - let farbe = theme::deck_farbe(index); - let mut strip = - std::mem::replace(&mut self.decks[index].strip, ChannelUi::new("", 0)); - self.kanalzug(ui, &mut strip, farbe); - self.decks[index].strip = strip; - } - - let mut aux = std::mem::replace(&mut self.aux, ChannelUi::new("", 0)); - self.kanalzug(ui, &mut aux, theme::AUX); - self.aux = aux; - - ui.separator(); - self.summe(ui); - }); - } - - fn kanalzug(&mut self, ui: &mut Ui, strip: &mut ChannelUi, farbe: Color32) { - egui::Frame::new() - .fill(theme::PANEL) - .inner_margin(7.0) - .corner_radius(4.0) - .show(ui, |ui| { - // Senkrecht, sonst stünden die Regler eines Zuges nebeneinander - // statt übereinander — der Rahmen erbt hier ein waagerechtes - // Layout vom Mixer. - ui.vertical(|ui| self.kanalzug_inhalt(ui, strip, farbe)); - }); - } - - fn kanalzug_inhalt(&mut self, ui: &mut Ui, strip: &mut ChannelUi, farbe: Color32) { - ui.set_width(KANALBREITE); - ui.label(RichText::new(&strip.name).color(farbe).strong().size(12.0)); - - // Die Drehregler eines echten Mixers sind schmal; hier sind es liegende - // Schieber, also bekommen sie die Breite des Zuges und keine Zahl — - // beim Auflegen liest man die ohnehin nicht. - let regler = - |ui: &mut Ui, wert: &mut f32, name: &str, spanne: std::ops::RangeInclusive| { - ui.spacing_mut().slider_width = KANALBREITE - 34.0; - ui.add( - egui::Slider::new(wert, spanne) - .show_value(false) - .text(RichText::new(name).size(10.0)), - ) - .changed() - }; - - if regler(ui, &mut strip.trim, "TRIM", 0.0..=2.0) { - self.handle.send(Command::Trim(strip.channel, strip.trim)); - } - let mut eq_geaendert = regler(ui, &mut strip.high, "HI", 0.0..=2.0); - eq_geaendert |= regler(ui, &mut strip.mid, "MID", 0.0..=2.0); - eq_geaendert |= regler(ui, &mut strip.low, "LOW", 0.0..=2.0); - if eq_geaendert { - self.handle.send(Command::Eq { - channel: strip.channel, - low: strip.low, - mid: strip.mid, - high: strip.high, - }); - } - - if regler(ui, &mut strip.filter, "FLT", -1.0..=1.0) { - self.handle - .send(Command::Filter(strip.channel, strip.filter)); - } - - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.vertical(|ui| { - if ui - .add( - egui::Button::new(RichText::new("CUE").size(10.0)) - .fill(if strip.cue { - theme::CUE - } else { - theme::PANEL_HELL - }) - .min_size(egui::vec2(42.0, 22.0)), - ) - .clicked() - { - strip.cue = !strip.cue; - self.handle.send(Command::Cue(strip.channel, strip.cue)); - } - }); + // Einmal je Bild sperren. Der Audio-Thread ist daran nicht beteiligt — + // er sieht nur die lock-freie Schlange, die das Pult füllt. Hier + // konkurriert die Oberfläche nur mit anderen Bedienern, und die sind + // langsam. + let Ok(mut pult) = self.pult.lock() else { + ui.label(RichText::new("Steuerpult nicht erreichbar").color(theme::WARNUNG)); + return; + }; - // Der Linefader steht senkrecht wie am Gerät. - ui.spacing_mut().slider_width = FADERHOEHE; - if ui - .add( - egui::Slider::new(&mut strip.fader, 0.0..=1.0) - .show_value(false) - .vertical(), - ) - .changed() - { - self.handle.send(Command::Fader(strip.channel, strip.fader)); + ui.horizontal_top(|ui| { + for i in 0..pult.kanaele().len() { + kanalzug(ui, &mut pult, i, theme::deck_farbe(i)); } + ui.separator(); + summe(ui, &mut pult); }); } - - fn summe(&mut self, ui: &mut Ui) { - egui::Frame::new() - .fill(theme::PANEL) - .inner_margin(7.0) - .corner_radius(4.0) - .show(ui, |ui| { - ui.vertical(|ui| self.summe_inhalt(ui)); - }); - } - - fn summe_inhalt(&mut self, ui: &mut Ui) { - ui.set_width(250.0); - ui.spacing_mut().slider_width = 214.0; - ui.label(RichText::new("SUMME").strong().size(12.0)); - - if ui - .add( - egui::Slider::new(&mut self.crossfader, -1.0..=1.0) - .show_value(false) - .text(RichText::new("A « Crossfader » B").size(10.0)), - ) - .changed() - { - self.handle.send(Command::Crossfader(self.crossfader)); - } - - if ui - .add( - egui::Slider::new(&mut self.crossfader_kurve, 0.0..=1.0) - .show_value(false) - .text(RichText::new("Kurve weich » hart").size(10.0)), - ) - .changed() - { - self.handle - .send(Command::CrossfaderCurve(self.crossfader_kurve)); - } - - ui.separator(); - - if ui - .add( - egui::Slider::new(&mut self.master_gain, 0.0..=1.5) - .show_value(false) - .text(RichText::new("MASTER").size(10.0)), - ) - .changed() - { - self.handle.send(Command::MasterGain(self.master_gain)); - } - - if ui - .add( - egui::Slider::new(&mut self.cue_gain, 0.0..=1.5) - .show_value(false) - .text(RichText::new("KOPFHÖRER").size(10.0)), - ) - .changed() - { - self.handle.send(Command::CueGain(self.cue_gain)); - } - - if ui - .add( - egui::Slider::new(&mut self.cue_mix, 0.0..=1.0) - .show_value(false) - .text(RichText::new("CUE « Mix » MASTER").size(10.0)), - ) - .changed() - { - self.handle.send(Command::CueMix(self.cue_mix)); - } - } - fn sammlung(&mut self, ui: &mut Ui) { ui.add_space(4.0); ui.horizontal(|ui| { @@ -737,8 +562,7 @@ impl MusikApp { }; if !auftrag.angefordert { - if auftrag.warte_bilder > 0 { - auftrag.warte_bilder -= 1; + if std::time::Instant::now() < auftrag.warte_bis { ctx.request_repaint(); return; } @@ -820,6 +644,160 @@ fn zeit(secs: f64) -> String { format!("{m}:{:05.2}", secs - m as f64 * 60.0) } +/// Ein Schieberegler, der an einem Control hängt. +/// +/// Die Oberfläche kennt weder Bereich noch Einheit noch Bedeutung — das steht +/// alles im Katalog, und der Regler holt es sich von dort. Ein neues Control +/// ist damit ohne eine Zeile hier bedienbar, und der Hilfetext, den ein Agent +/// über `list` bekommt, ist derselbe, der hier als Tooltip erscheint. Zwei +/// Beschreibungen, die auseinanderlaufen könnten, gibt es nicht. +fn regler(ui: &mut Ui, pult: &mut Steuerpult, key: &Schluessel, name: &str, senkrecht: bool) { + let Some(b) = pult.beschreibung(key) else { + return; + }; + let Some((min, max)) = b.bereich else { + return; + }; + let Ok(Wert::Zahl(aktuell)) = pult.lies(key) else { + return; + }; + + let mut wert = aktuell; + let mut schieber = egui::Slider::new(&mut wert, min..=max).show_value(false); + schieber = if senkrecht { + schieber.vertical() + } else { + schieber.text(RichText::new(name).size(10.0)) + }; + + let antwort = ui.add(schieber).on_hover_text(b.text); + if antwort.changed() { + let _ = pult.schreibe(key, Wert::Zahl(wert)); + } + // Doppelklick stellt zurück auf den Anfangswert des Bereichs bzw. die + // Mitte — bei einem bipolaren Control ist das die Raste. + if antwort.double_clicked() { + let zurueck = if b.einheit == Einheit::Bipolar { + 0.0 + } else if min <= 1.0 && max >= 1.0 { + 1.0 + } else { + min + }; + let _ = pult.schreibe(key, Wert::Zahl(zurueck)); + } +} + +fn kanalzug(ui: &mut Ui, pult: &mut Steuerpult, index: usize, farbe: Color32) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Senkrecht, sonst stünden die Regler eines Zuges nebeneinander + // statt übereinander — der Rahmen erbt hier ein waagerechtes + // Layout vom Mixer. + ui.vertical(|ui| kanalzug_inhalt(ui, pult, index, farbe)); + }); +} + +fn kanalzug_inhalt(ui: &mut Ui, pult: &mut Steuerpult, index: usize, farbe: Color32) { + let Some(kanal) = pult.kanaele().get(index) else { + return; + }; + let name = kanal.name.clone(); + let cue_an = kanal.cue; + + ui.set_width(KANALBREITE); + ui.label(RichText::new(&name).color(farbe).strong().size(12.0)); + ui.spacing_mut().slider_width = KANALBREITE - 34.0; + + let gruppe = Gruppe::Kanal(index); + for (element, beschriftung) in [ + ("trim", "TRIM"), + ("eq_high", "HI"), + ("eq_mid", "MID"), + ("eq_low", "LOW"), + ("filter", "FLT"), + ] { + regler( + ui, + pult, + &Schluessel::neu(gruppe, element), + beschriftung, + false, + ); + } + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + let knopf = ui.add( + egui::Button::new(RichText::new("CUE").size(10.0)) + .fill(if cue_an { + theme::CUE + } else { + theme::PANEL_HELL + }) + .min_size(egui::vec2(42.0, 22.0)), + ); + if knopf.clicked() { + let _ = pult.schreibe(&Schluessel::neu(gruppe, "cue"), Wert::Schalter(!cue_an)); + } + }); + + // Der Linefader steht senkrecht wie am Gerät. + ui.spacing_mut().slider_width = FADERHOEHE; + regler(ui, pult, &Schluessel::neu(gruppe, "fader"), "", true); + }); +} + +fn summe(ui: &mut Ui, pult: &mut Steuerpult) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + ui.spacing_mut().slider_width = 214.0; + ui.label(RichText::new("SUMME").strong().size(12.0)); + + let g = Gruppe::Master; + regler( + ui, + pult, + &Schluessel::neu(g, "crossfader"), + "A « Crossfader » B", + false, + ); + regler( + ui, + pult, + &Schluessel::neu(g, "crossfader_curve"), + "Kurve weich » hart", + false, + ); + ui.separator(); + regler(ui, pult, &Schluessel::neu(g, "gain"), "MASTER", false); + regler( + ui, + pult, + &Schluessel::neu(g, "cue_gain"), + "KOPFHÖRER", + false, + ); + regler( + ui, + pult, + &Schluessel::neu(g, "cue_mix"), + "CUE « Mix » MASTER", + false, + ); + }); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -847,11 +825,16 @@ mod tests { } #[test] - fn ein_neuer_kanalzug_startet_mit_geschlossenem_fader() { - let strip = ChannelUi::new("A", 0); - assert_eq!(strip.fader, 0.0); - assert!(!strip.cue); - assert_eq!((strip.low, strip.mid, strip.high), (1.0, 1.0, 1.0)); - assert_eq!(strip.filter, 0.0); + fn ohne_titel_bleibt_der_dateiname_stehen() { + let mut eintrag = + TrackRecord::from_path("/musik/Ordner/Alpenglühen - Vier auf die Eins.wav"); + assert_eq!(anzeigename(&eintrag), "Alpenglühen - Vier auf die Eins"); + + eintrag.title = Some("Richtiger Titel".into()); + assert_eq!(anzeigename(&eintrag), "Richtiger Titel"); + + // Ein leerer Tag ist so gut wie keiner. + eintrag.title = Some(" ".into()); + assert_eq!(anzeigename(&eintrag), "Alpenglühen - Vier auf die Eins"); } } diff --git a/crates/musik-app/src/laden.rs b/crates/musik-app/src/laden.rs index 5b198ca..30ee79c 100644 --- a/crates/musik-app/src/laden.rs +++ b/crates/musik-app/src/laden.rs @@ -41,6 +41,8 @@ pub fn track_auf_deck(app: &mut MusikApp, deck: usize, eintrag: &TrackRecord) -> let frames = track.frames() as u64; let state = Arc::clone(&app.decks[deck].state); + let titel = crate::app::anzeigename(eintrag); + let artist = eintrag.artist.clone().unwrap_or_default(); // Der neue Track startet gestoppt am Anfang — ein Deck, das beim Laden // losläuft, ist ein Unfall. @@ -57,16 +59,38 @@ pub fn track_auf_deck(app: &mut MusikApp, deck: usize, eintrag: &TrackRecord) -> ); let voice = Voice::new(Arc::new(track), Arc::clone(&state)); - let kanal = app.decks[deck].strip.channel; - if !app.handle.load(kanal, Box::new(DeckSource::new(voice))) { - anyhow::bail!("die Ladeschlange ist voll — gleich noch einmal versuchen"); + + { + let mut pult = app + .pult + .lock() + .map_err(|_| anyhow::anyhow!("Steuerpult ist in einem unklaren Zustand"))?; + + let kanal = pult + .decks() + .get(deck) + .context("Deck gibt es im Steuerpult nicht")? + .kanal; + + if !pult + .handle_mut() + .load(kanal, Box::new(DeckSource::new(voice))) + { + anyhow::bail!("die Ladeschlange ist voll — gleich noch einmal versuchen"); + } + + // Erst nach dem erfolgreichen Laden umbenennen: Sonst zeigte das Pult + // einen Titel an, der gar nicht auf dem Deck liegt. + if let Some(eintrag) = pult.deck_mut(deck) { + eintrag.titel = titel; + eintrag.artist = artist; + eintrag.frames = frames; + } } let ui = &mut app.decks[deck]; ui.peaks = peaks; ui.frames = frames; - ui.titel = crate::app::anzeigename(eintrag); - ui.artist = eintrag.artist.clone().unwrap_or_default(); Ok(()) } diff --git a/crates/musik-app/src/main.rs b/crates/musik-app/src/main.rs index f01a160..afd259e 100644 --- a/crates/musik-app/src/main.rs +++ b/crates/musik-app/src/main.rs @@ -25,9 +25,10 @@ use anyhow::{Context, Result}; use audio_core::deck::{DeckState, Voice}; use audio_core::{Beatgrid, Track}; use audio_engine::{aux_channel, DeckSource, Engine, EngineRunner, Output}; +use control::{DeckEintrag, KanalSpiegel, Server, Steuerpult}; use library::Library; -use app::{assign_fuer, ChannelUi, DeckUi, MusikApp, Screenshot}; +use app::{assign_fuer, DeckUi, MusikApp, Screenshot}; const RATE: u32 = 48_000; @@ -37,6 +38,17 @@ struct Args { a: Option, b: Option, screenshot: Option, + screenshot_nach: std::time::Duration, + socket: PathBuf, +} + +/// Wo die Steuerung lauscht. Im Laufzeitverzeichnis des Benutzers, nicht in +/// `/tmp` — dort könnte jeder andere Benutzer des Rechners mitsteuern. +fn standard_socket() -> PathBuf { + match std::env::var_os("XDG_RUNTIME_DIR") { + Some(dir) => PathBuf::from(dir).join("musik.sock"), + None => std::env::temp_dir().join(format!("musik-{}.sock", std::process::id())), + } } fn main() -> Result<()> { @@ -47,6 +59,7 @@ fn main() -> Result<()> { let demo = args.a.is_none() && args.b.is_none(); let mut decks = Vec::new(); + let mut eintraege = Vec::new(); for (index, (name, pfad)) in [("DECK A", &args.a), ("DECK B", &args.b)] .into_iter() @@ -98,18 +111,25 @@ fn main() -> Result<()> { engine.channel(kanal).set_fader(fader); state.set_playing(demo); - let mut strip = ChannelUi::new(name, kanal); - strip.fader = fader; + eintraege.push(( + DeckEintrag { + state: Arc::clone(&state), + kanal, + sample_rate: RATE, + frames, + titel, + artist, + }, + fader, + name, + )); decks.push(DeckUi { name: name.to_string(), state, - artist, - titel, peaks: analyse.peaks.iter().filter_map(|p| p.to_level()).collect(), frames, sample_rate: RATE, - strip, zoom_secs: 8.0, loop_beats: 4.0, }); @@ -122,6 +142,19 @@ fn main() -> Result<()> { let (handle, runner) = audio_engine::engine_channel(engine, 512); + // Das Steuerpult bekommt alles, was bedienbar ist — und ist danach die + // einzige Stelle, an der Reglerstellungen stehen. + let mut pult = Steuerpult::neu(handle); + for (eintrag, fader, name) in eintraege { + let assign = assign_fuer(pult.decks().len()); + let mut spiegel = KanalSpiegel::neu(name, assign); + spiegel.fader = fader as f64; + pult.kanal_hinzufuegen(spiegel); + pult.deck_hinzufuegen(eintrag); + } + pult.kanal_hinzufuegen(KanalSpiegel::neu("AUX", assign_fuer(2))); + let pult = Arc::new(std::sync::Mutex::new(pult)); + let (output, hinweis) = match Output::open(runner) { Ok(out) => { let cue = if out.has_cue_output() { @@ -147,17 +180,24 @@ fn main() -> Result<()> { .and_then(|l| l.search(&library::Query::default()).ok()) .unwrap_or_default(); + // Ab hier ist die Anlage von außen bedienbar. Scheitert das, läuft die + // Oberfläche trotzdem — nur eben allein. + let steuerung = match Server::starten(&args.socket, Arc::clone(&pult)) { + Ok(server) => { + println!("Steuerung: {}", server.pfad().display()); + Some(server) + } + Err(e) => { + eprintln!("Steuerung nicht verfügbar: {e}"); + None + } + }; + let anwendung = MusikApp { - handle, + pult, output, audio_hinweis: hinweis, decks, - aux: ChannelUi::new("AUX", aux_kanal), - crossfader: 0.0, - crossfader_kurve: 0.0, - master_gain: 1.0, - cue_mix: 0.0, - cue_gain: 1.0, library, analyse_cache: args.cache.clone(), suche: String::new(), @@ -169,7 +209,7 @@ fn main() -> Result<()> { }, screenshot: args.screenshot.map(|pfad| Screenshot { pfad, - warte_bilder: 30, + warte_bis: std::time::Instant::now() + args.screenshot_nach, angefordert: false, }), }; @@ -190,7 +230,12 @@ fn main() -> Result<()> { Ok(Box::new(anwendung)) }), ) - .map_err(|e| anyhow::anyhow!("Fenster ließ sich nicht öffnen: {e}")) + .map_err(|e| anyhow::anyhow!("Fenster ließ sich nicht öffnen: {e}"))?; + + // Erst hier fallen lassen: Der Socket wird beim Aufräumen entfernt, und + // das soll nicht schon passieren, während das Fenster noch steht. + drop(steuerung); + Ok(()) } fn parse_args() -> Result { @@ -200,6 +245,10 @@ fn parse_args() -> Result { a: None, b: None, screenshot: None, + // Lang genug, dass das Fenster steht und die erste Wellenform + // gezeichnet ist. + screenshot_nach: std::time::Duration::from_millis(600), + socket: standard_socket(), }; let mut iter = std::env::args().skip(1); @@ -211,9 +260,17 @@ fn parse_args() -> Result { "--a" => args.a = Some(PathBuf::from(wert()?)), "--b" => args.b = Some(PathBuf::from(wert()?)), "--screenshot" => args.screenshot = Some(PathBuf::from(wert()?)), + "--socket" => args.socket = PathBuf::from(wert()?), + "--screenshot-nach" => { + let sekunden: f64 = wert()? + .parse() + .context("--screenshot-nach braucht Sekunden")?; + args.screenshot_nach = std::time::Duration::from_secs_f64(sekunden.max(0.0)); + } "-h" | "--help" => { println!("Aufruf: musik-app [--db ] [--a ] [--b ]"); println!(" [--cache ] [--screenshot ]"); + println!(" [--socket ] [--screenshot-nach ]"); std::process::exit(0); } other => anyhow::bail!("unbekannte Option: {other}"), diff --git a/docs/MIXXX.md b/docs/MIXXX.md index 0ffa26f..1ccbc6f 100644 --- a/docs/MIXXX.md +++ b/docs/MIXXX.md @@ -70,7 +70,7 @@ Der Inhalt ist zweierlei, und die Trennung ist wichtig: Also: als Doku lesen, eigenes Format schreiben. -### 2. Das Control-System als Vorbild für die MCP-Schnittstelle +### 2. Das Control-System als Vorbild für die MCP-Schnittstelle ✅ gebaut Mixxx hat eine interne API, über die **alles** läuft — Tastatur, MIDI, HID und die grafische Oberfläche greifen auf dieselben benannten Controls zu, adressiert @@ -79,12 +79,14 @@ die grafische Oberfläche greifen auf dieselben benannten Controls zu, adressier Das ist genau die Form, die unsere Anbindung an VibeMind braucht: ein flacher, benannter Steuerraum, den ein Agent adressieren kann, ohne die Oberfläche zu -kennen. Wir haben das bisher nicht — Transport läuft über Atomics im -`DeckState`, der Mixer über die Kommandoschlange, beides ohne gemeinsame -Namensgebung. Bevor die MCP-Schicht aus [VIBEMIND.md](VIBEMIND.md) gebaut wird, -lohnt sich dieser Zwischenschritt. +kennen. Vorher lag die Bedienung bei uns an drei Stellen — Transport in den +Atomics des `DeckState`, der Mixer in der Kommandoschlange, die +Reglerstellungen als Spiegel in der Oberfläche — ohne gemeinsame Namensgebung. -Architektonische Idee, frei übernehmbar. +Architektonische Idee, frei übernehmbar — **inzwischen gebaut**, als Crate +`control`, und in drei Punkten weitergedreht: Der Steuerraum beschreibt sich +zur Laufzeit selbst, die Werte sind typisiert statt durchweg `double`, und er +ist von außen erreichbar. Siehe [STEUERUNG.md](STEUERUNG.md). ### 3. Zwei Beatgrid-Stufen statt einer @@ -133,7 +135,8 @@ ist prozessintern. Für einen Agenten, der auflegen soll, ist Mixxx damit keine fertige Basis. Genau diese Lücke ist das, was dieses Projekt eigentlich beiträgt — nicht der zwanzigste Mixer, sondern ein DJ-Werkzeug, das von Anfang an eine -Steuerschnittstelle nach außen hat. +Steuerschnittstelle nach außen hat. **Diese Lücke ist inzwischen geschlossen:** +[STEUERUNG.md](STEUERUNG.md). **Die Lizenz für den VibeMind-Weg.** Einbetten geht nicht, und ein Fork wäre GPL. Beides schließt die Veröffentlichung als MIT aus. diff --git a/docs/PLAN.md b/docs/PLAN.md index 4f87083..fc7eb71 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -92,6 +92,7 @@ crates/ analysis/ steht — BPM, Beatgrid, Waveform-Peaks, Sidecar-Cache library/ steht — SQLite, Suche, Playlists, Traktor-Import musik-cli/ steht — Deck, Analyse, Offline-Mix, Sammlung + control/ steht — benannter Steuerraum, Fernsteuerung über Socket musik-app/ steht — Oberfläche (egui/eframe): Decks, Mixer, Plattenkiste Effekte, Stems und MIDI kommen in audio-engine bzw. analysis dazu. ``` @@ -184,6 +185,11 @@ Für Phase 10 ist Mixxx' Mapping-Verzeichnis die beste verfügbare Dokumentation der Geräte-Landschaft — als Nachschlagewerk, nicht als Vorlage zum Kopieren. Warum das ein Unterschied ist, steht in [MIXXX.md](MIXXX.md). +Phase 10 ist seit dem Steuerraum deutlich kleiner geworden: Ein Mapping ist +jetzt eine Tabelle von MIDI-Nachricht auf Control-Namen, und den normierten +Schreibweg (`setn`, 0..1) gibt es bereits. Siehe +[STEUERUNG.md](STEUERUNG.md). + Phase 1 und 2 sind unabhängig und können parallel laufen — die Analyse braucht die Engine nicht. @@ -206,6 +212,7 @@ Was fehlt, sind Effekte, Stems, Mitschnitt und MIDI. | `analysis` | Tempo, Beatgrid, Wellenform-Spitzen, Sidecar-Cache | | `library` | SQLite-Sammlung, Suche, Playlists, Traktor-Import | | `musik-cli` | Deck fahren, analysieren, Mix rendern, Sammlung verwalten | +| `control` | Steuerraum: benannte Controls, Katalog, Zeilenprotokoll, Socket | | `musik-app` | Oberfläche: zwei Decks, Mixer mit AUX, Plattenkiste | ## Offene Entscheidungen diff --git a/docs/STEUERUNG.md b/docs/STEUERUNG.md new file mode 100644 index 0000000..a16400a --- /dev/null +++ b/docs/STEUERUNG.md @@ -0,0 +1,153 @@ +# Steuerung von außen + +Die Anlage lässt sich fernsteuern, während sie läuft. Ein Socket, ein +Zeilenprotokoll, keine Bibliothek nötig: + +```sh +$ nc -U "$XDG_RUNTIME_DIR/musik.sock" +set deck1.play 1 +ok deck1.play 1 +``` + +Das ist der Punkt, an dem sich dieses Projekt von den fertigen Alternativen +unterscheidet. Mixxx hat ein sehr ähnliches Control-System im Inneren — dort +laufen Tastatur, MIDI, HID und Oberfläche über dieselben benannten Werte — +aber es ist prozessintern. Nach außen spricht Mixxx OSC nur *ausgehend*: +Es sendet Zustand, es nimmt keine Befehle entgegen. Siehe +[MIXXX.md](MIXXX.md#was-mixxx-nicht-löst). + +Ohne diese Schnittstelle gäbe es die Agenten-Schicht aus +[AGENTEN.md](AGENTEN.md) nicht, und der Anschluss an +[VibeMind](VIBEMIND.md) hätte nichts, woran er andocken könnte. + +## Der Steuerraum + +Jedes Control heißt `gruppe.element`: + +| Gruppe | Was | +| --- | --- | +| `deck1`, `deck2`, … | Abspieler: Transport, Tempo, Hot Cues, Schleifen | +| `channel1`, `channel2`, … | Züge am Mischpult: Trim, EQ, Filter, Fader, Cue | +| `master` | Crossfader, Summe, Kopfhörer | + +Deck und Kanal sind absichtlich getrennt. Bei Mixxx fallen sie in `[Channel1]` +zusammen, was so lange gutgeht, bis ein Kanal etwas führt, das kein Deck ist — +bei uns der AUX-Eingang, der einen Zug hat und kein Deck. + +## Befehle + +| Befehl | Wirkung | +| --- | --- | +| `list [praefix]` | Controls aufzählen, gefiltert nach Namensanfang | +| `get ` | Wert lesen | +| `set ` | Wert setzen; `-` leert (Hot Cues) | +| `setn <0..1>` | normiert setzen — für MIDI | +| `help` | Übersicht | + +Antworten beginnen mit `control`, `value`, `ok` oder `err`. Ein `err` ist immer +als solches erkennbar; nichts scheitert stumm. + +## Der Steuerraum beschreibt sich selbst + +Das ist der zweite Unterschied. `list` liefert nicht bloß Namen, sondern +Typ, Bereich, Einheit, Schreibbarkeit und eine Bedeutung: + +```text +$ echo 'list deck1.tempo' | nc -U -q1 "$XDG_RUNTIME_DIR/musik.sock" +control deck1.tempo zahl 0.92..1.08 faktor rw Tempo-Regler; 1.0 ist die Originalgeschwindigkeit +ok 1 Controls +``` + +Wer das gelesen hat, braucht kein Handbuch: Er weiß, dass es eine Zahl ist, +zwischen 0,92 und 1,08 liegt, ein Faktor ist, sich schreiben lässt und was sie +bedeutet. Bei Mixxx steht all das in der Dokumentation, und ein laufendes +Programm lässt sich nicht fragen. + +Der praktische Gewinn ist doppelt: + +- **Für Agenten.** Die MCP-Werkzeugbeschreibungen lassen sich aus dem Katalog + erzeugen, statt sie von Hand doppelt zu pflegen — zwei Beschreibungen, die + auseinanderlaufen, gibt es dann nicht. +- **Für die eigene Oberfläche.** Die Schieberegler im Mixer holen sich Bereich + und Tooltip aus demselben Katalog. Ein neues Control ist ohne eine Zeile in + der Oberfläche bedienbar, und der Hilfetext, den ein Agent sieht, ist + derselbe, der unter der Maus erscheint. + +## Werte sind typisiert + +Bei Mixxx ist jedes Control ein `double`; `play` ist 0.0 oder 1.0, eine +Zuweisung eine durchnummerierte Zahl. Das ist einfach zu bauen und schwer zu +benutzen — ein Tippfehler wird zu einem gültigen Wert statt zu einem Fehler. + +Hier gibt es Schalter, Zahlen mit Bereich, Auswahlen mit Namen und Text: + +```text +set channel1.assign thru → ok channel1.assign thru +set channel1.assign mitte → err channel1.assign kennt nur: a, b, thru +set deck1.duration 5 → err deck1.duration lässt sich nur lesen +``` + +Zwei bewusste Ausnahmen von der Strenge: + +- **Werte außerhalb des Bereichs werden begrenzt, nicht abgelehnt.** Ein + MIDI-Regler auf Anschlag meint das Maximum, keinen Fehler. +- **Die Bestätigung nennt den Wert, der wirklich angekommen ist** — nicht den + gesendeten. `set channel1.fader 9` antwortet `ok channel1.fader 1`. + +## Ein Beispiel, das wirklich lief + +Zwei Decks von außen ineinandergefahren, während die Oberfläche zusah: + +```sh +set deck1.play 1 +set deck2.play 1 +set channel1.fader 0.9 +set channel2.fader 0.55 +setn channel2.eq_low 0.0 # Bass-Kill über den normierten Weg +set channel2.filter 0.45 +set channel1.cue 1 +set master.crossfader -0.35 +set deck1.cue1 4.0 +set deck1.loop_beats 4 +set deck2.tempo 1.032 # 124 × 1,032 = 127,97 — auf Deck A gezogen +``` + +Danach zeigte die Oberfläche 127,99 gegen 127,98 BPM, den gesetzten Loop, die +Hot-Cue-Marken in der Wellenform und jeden bewegten Regler an der richtigen +Stelle. Screenshot: [`bilder/fernsteuerung.png`](bilder/fernsteuerung.png). + +## Warum ein Unix-Socket und kein TCP-Port + +Eine Sicherheitsentscheidung, keine Bequemlichkeit. Wer hier hineinschreibt, +steuert die Anlage. Ein offener TCP-Port täte das für jeden im selben Netz — +auf einer Bühne mit fremdem WLAN ist das keine theoretische Sorge. Ein +Unix-Socket erbt die Rechte des Dateisystems: Wer die Datei nicht öffnen darf, +kommt nicht hinein. + +Der Standardpfad liegt aus demselben Grund in `$XDG_RUNTIME_DIR` und nicht in +`/tmp` — dort könnte jeder andere Benutzer des Rechners mitsteuern. Mit +`--socket ` lässt er sich verlegen. + +Es gibt **keine Authentifizierung**. Sie wäre bei Dateisystemrechten +verdoppelt. Sollte die Steuerung je über das Netz gehen, ist das die Stelle, an +der sie nachzurüsten ist — vorher nicht. + +## Echtzeit bleibt unangetastet + +Das Pult schreibt nie in den Audio-Callback. Es schickt Kommandos in dieselbe +lock-freie Schlange, die auch die Oberfläche benutzt, und liest aus Atomics +und aus dem eigenen Spiegel. Der Mutex um das Pult wird nur von Bedienern +genommen — Oberfläche, Socket, später MIDI. Der Audio-Thread sieht ihn nie. + +## Was noch fehlt + +- **Abonnieren.** Wer den Zustand verfolgen will, muss zurzeit pollen. Ein + `sub deck1.position` mit Meldungen bei Änderung ist der nächste Schritt und + Voraussetzung für Controller mit Motorfadern und Displays. +- **Laden von außen.** `deck1.load ` fehlt. Dekodieren und Analysieren + dauern; das braucht einen asynchronen Auftrag statt einer Antwortzeile. +- **Windows.** Unix-Sockets gibt es dort nicht; eine Named Pipe wäre die + Entsprechung. +- **MIDI.** Der normierte Weg (`setn`) ist da, der Übersetzer von MIDI-CC auf + Control-Namen noch nicht. Das ist Phase 10 — und mit dem Steuerraum im Rücken + fast nur noch eine Tabelle. diff --git a/docs/bilder/fernsteuerung.png b/docs/bilder/fernsteuerung.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe0a594763b381fa4f2d70678d7afb4ac3c34f6 GIT binary patch literal 208802 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 NXcONN77f<=u1-+nX&;iA# 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νg_u`2Q{F$#!?6>LUyVtcRmYOjA0d5gZs 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