# KeySelector — Spezifikation > Übergabe-Dokument für die Entwicklung. > Stand: 2026-08-26 > Verzeichnis: `C:\GitHub\_Ideen\` ## 1. Konzept (ein Satz) Browser-basiertes DJ-Tool, das per Mikrofon-Audio den laufenden Song erkennt (AcoustID-Fingerprinting), automatisch dessen Tonart (als Camelot-Code) und BPM ermittelt, und basierend darauf harmonisch + rhythmisch passende Folge-Songs vorschlägt. Läuft dauerhaft kostenlos, ist für den rein privaten Gebrauch gedacht, und ist auch vom Smartphone aus nutzbar. **Vorgeschlagener Name:** KeyCue / MixMaestro / Cue (zur Auswahl) **Domain:** `keyselector.orfel.de`, `keysel.orfel.de`, `ks.orfel.de` **Hosting:** Jannik-Cloud, im `services/keyselector/`-Pattern ## 2. Architektur ``` keyselector/ ├── public/ # Frontend (Vanilla JS, kein Build) │ ├── index.html # Standalone HTML (KeySelector_Mockup_Wheel.html als Vorlage) │ ├── app.js # Frontend-Logik (Recognize-Polling, Hero-Toggles) │ ├── wheel.js # SVG-Wheel-Renderer (12 + 12 Polygone in 2 Ringen) │ ├── cover.js # Cover-Loader (iTunes Search API) │ ├── search.js # Suchfeld-Fallback (Inline-Expand bei nicht-erkanntem Song) │ └── styles.css # CSS mit Custom Properties für Dark/Light ├── server/ │ ├── index.js # Node.js (Express oder Fastify) │ ├── routes/ │ │ ├── recognize.js # POST /api/recognize (Audio-Sample → AcoustID → Track) │ │ ├── metadata.js # GET /api/metadata?track=... (Key, BPM, Tags via GetSongBPM, Last.fm, Essentia.js) │ │ ├── suggestions.js # GET /api/suggestions?key=4B&bpm=118 (Camelot-Logik + AcousticBrainz-DB) │ │ └── cover.js # GET /api/cover?artist=...&title=... (iTunes + 24h Cache) │ └── workers/ │ └── poll-quota.js # ntfy-Notification bei niedrigen Quota-Levels ├── db/ │ ├── schema.sql # AcousticBrainz-Import-Schema │ ├── seed-acousticbrainz.sh # Init-Seed beim Deploy │ └── migrations/ ├── shared/ │ ├── mixing-rules.js # Komplette Mixed In Key Tabelle (siehe §5.1) │ ├── color-matrix.js # Camelot-Code → Hex-Farbe (siehe §5.3) │ ├── camelot-angle.js # Camelot-Code → Winkel (siehe §5.2) │ └── camelot-to-musickey.js # Camelot-Code → Standard-Musik-Key ├── docker-compose.yml # PostgreSQL + Backend + Caddy ├── generate-env.sh # API-Keys: AcoustID, GetSongBPM, Last.fm, ntfy └── README.md ``` **Stack-Entscheidungen:** - **Frontend**: Vanilla JS/HTML, kein Build-Prozess. Wheel als reines SVG. Dauerhören im Browser via `navigator.mediaDevices.getUserMedia` + `MediaRecorder` + `AudioContext`. AcousticBrainz-Modelle für Genre/Mood als TensorFlow.js im Browser. - **Backend**: Node.js (passt zu Essentia.js und Jannik-Cloud). AcoustID via Chromaprint, GetSongBPM via REST, Last.fm via REST, ntfy selbst gehostet. - **Datenbank**: PostgreSQL (gemeinsame Instanz in Jannik-Cloud). AcousticBrainz-Daten-Import als Init-Seed (CC0, ca. 7,5 Mio. Songs). Wird durch eigene Erkennungen erweitert. - **Caching**: Redis für kurzen State (zuletzt erkannter Song, adaptiver Status). PostgreSQL für persistente Daten. Optional: 24h-Cache für Cover-URLs. ## 3. Referenz-Mockup `C:\GitHub\_Ideen\KeySelector_Mockup_Wheel.html` (31 KB, standalone, UTF-8 ohne BOM) **Was der Mockup zeigt:** - Topbar: Brand, 🎤 Listening-Status, Light/Dark-Toggle - Hero-Karte: 240×240 Cover (links) + Song-Info (Titel, Artist, 3 Stats: Key/BPM/Match, Tags) + persistent Wheel (140×140, unten rechts in Info) - Suggestions-Grid: 10 Tracks, sortiert nach Kompatibilitäts-Stufe - Demo-Daten: Alphaville – "Made in Japan" (4B / A-Dur, 118 BPM, 98% Match) - Cover via iTunes Search API (braucht Server wegen CORS, nicht file://) **Was funktioniert:** - ✅ Dark/Light Toggle mit CSS-Custom-Properties - ✅ Wheel inline expand (Klick → 520×520, × zum Schließen) - ✅ Cover inline expand (gleiche Mechanik) - ✅ Cover-Loading via iTunes API mit Fallback - ✅ Musik-Keys unter Camelot-Codes im Wheel - ✅ Stats semantisch eingefärbt (Key grün, BPM amber, Match grün) **Was noch zu fixen ist (laut User 2026-08-26, "ist egal"):** - ⚠ Wheel-Geometrie visuell noch nicht perfekt. Die Logik (`codeToAngle` + 24 Polygone) ist korrekt, das Rendering muss der Entwickler nachjustieren (Stroke-Widths, Anti-Aliasing auf den Polygon-Kanten, evtl. ViewBox-Anpassung). **Was noch fehlt für Production (aus dem Mockup, im Detail siehe §6):** - Listening-Toggle (Pause) - Suchfeld-Fallback - Echte AcoustID-Anbindung - AcousticBrainz-Initial-Seed - Suggestions-Engine (Camelot-Logik mit DB) ## 4. Datenstrukturen (alle aus dem Mockup übernehmen) ### 4.1 `keys` — alle 24 Camelot-Keys mit Music-Key und Farbe ```js const keys = [ // Major (B) außen, Minor (A) innen — direkt unter dem entsprechenden Major ['12B', 'F', '#2EC4B6'], ['12A', 'Dm', '#2EC4B6'], ['1B', 'C', '#D62828'], ['1A', 'Am', '#D62828'], ['2B', 'G', '#E77D11'], ['2A', 'Em', '#E77D11'], ['3B', 'D', '#E0B423'], ['3A', 'Bm', '#E0B423'], ['4B', 'A', '#4F9D69'], ['4A', 'F#m', '#4F9D69'], ['5B', 'E', '#1B998B'], ['5A', 'C#m', '#1B998B'], ['6B', 'B', '#6B4E9C'], ['6A', 'G#m', '#6B4E9C'], ['7B', 'F#', '#3B7DD8'], ['7A', 'D#m', '#3B7DD8'], ['8B', 'Db', '#3461A8'], ['8A', 'Bbm', '#3461A8'], ['9B', 'Ab', '#5B3A8C'], ['9A', 'Fm', '#5B3A8C'], ['10B', 'Eb', '#B07ECC'], ['10A', 'Cm', '#B07ECC'], ['11B', 'Bb', '#D64966'], ['11A', 'Gm', '#D64966'], ]; ``` ### 4.2 `codeToAngle` — Camelot-Code → Winkel im Wheel Major und Minor am GLEICHEN Winkel. 12 Uhr = 0°, clockwise. ```js const codeToAngle = { '12B': 0, '12A': 0, '1B': 15, '1A': 15, '2B': 30, '2A': 30, '3B': 45, '3A': 45, '4B': 60, '4A': 60, '5B': 75, '5A': 75, '6B': 90, '6A': 90, '7B': 105, '7A': 105, '8B': 120, '8A': 120, '9B': 135, '9A': 135, '10B': 150, '10A': 150, '11B': 165, '11A': 165, }; ``` ### 4.3 `colorMatrix` — separate Farb-Matrix (vom User gewünscht, single source of truth) ```js const colorMatrix = { '12B': '#2EC4B6', '12A': '#2EC4B6', '1B': '#D62828', '1A': '#D62828', '2B': '#E77D11', '2A': '#E77D11', '3B': '#E0B423', '3A': '#E0B423', '4B': '#4F9D69', '4A': '#4F9D69', '5B': '#1B998B', '5A': '#1B998B', '6B': '#6B4E9C', '6A': '#6B4E9C', '7B': '#3B7DD8', '7A': '#3B7DD8', '8B': '#3461A8', '8A': '#3461A8', '9B': '#5B3A8C', '9A': '#5B3A8C', '10B': '#B07ECC', '10A': '#B07ECC', '11B': '#D64966', '11A': '#D64966', }; ``` Globale Farbänderung: Edit dieser Matrix → alle Komponenten (Wheel, Suggestions, Stats) folgen automatisch. ### 4.4 `mixingRules` — komplette Mixed In Key Harmonielehre Pro Camelot-Code 8 Kompatibilitäts-Stufen: `[Perfect, Quinte -1, Quinte +1, Energy, Scale, Diagonal, Jaw's, Mood]` ```js const mixingRules = { '1A': ['1A', '12A', '2A', '3A', '1B', '12B', '8A', '4B' ], '2A': ['2A', '1A', '3A', '4A', '2B', '1B', '9A', '5B' ], '3A': ['3A', '2A', '4A', '5A', '3B', '2B', '10A', '6B' ], '4A': ['4A', '3A', '5A', '6A', '4B', '3B', '11A', '7B' ], '5A': ['5A', '4A', '6A', '7A', '5B', '4B', '12A', '8B' ], '6A': ['6A', '5A', '7A', '8A', '6B', '5B', '1A', '9B' ], '7A': ['7A', '6A', '8A', '9A', '7B', '6B', '2A', '10B'], '8A': ['8A', '7A', '9A', '10A', '8B', '7B', '3A', '11B'], '9A': ['9A', '8A', '10A', '11A', '9B', '8B', '4A', '12B'], '10A': ['10A', '9A', '11A', '12A', '10B', '9B', '5A', '1B' ], '11A': ['11A', '10A', '12A', '1A', '11B', '10B', '6A', '2B' ], '12A': ['12A', '11A', '1A', '2A', '12B', '11B', '7A', '3B' ], '1B': ['1B', '12B', '2B', '3B', '1A', '2A', '8B', '10A'], '2B': ['2B', '1B', '3B', '4B', '2A', '3A', '9B', '11A'], '3B': ['3B', '2B', '4B', '5B', '3A', '4A', '10B', '12A'], '4B': ['4B', '3B', '5B', '6B', '4A', '5A', '11B', '1A' ], '5B': ['5B', '4B', '6B', '7B', '5A', '6A', '12B', '2A' ], '6B': ['6B', '5B', '7B', '8B', '6A', '7A', '1B', '3A' ], '7B': ['7B', '6B', '8B', '9B', '7A', '8A', '2B', '4A' ], '8B': ['8B', '7B', '9B', '10B', '8A', '9A', '3B', '5A' ], '9B': ['9B', '8B', '10B', '11B', '9A', '10A', '4B', '6A' ], '10B': ['10B', '9B', '11B', '12B', '10A', '11A', '5B', '7A' ], '11B': ['11B', '10B', '12B', '1B', '11A', '12A', '6B', '8A' ], '12B': ['12B', '11B', '1B', '2B', '12A', '1A', '7B', '9A' ], }; const compatLabels = ['Perfect', 'Quinte -1', 'Quinte +1', 'Energy', 'Scale', 'Diagonal', "Jaw's", 'Mood']; ``` ### 4.5 Music-Key Mapping | Camelot | Music-Key | |---|---| | 1B / 1A | C / Am | | 2B / 2A | G / Em | | 3B / 3A | D / Bm | | 4B / 4A | A / F#m | | 5B / 5A | E / C#m | | 6B / 6A | B / G#m | | 7B / 7A | F# / D#m | | 8B / 8A | Db / Bbm | | 9B / 9A | Ab / Fm | | 10B / 10A | Eb / Cm | | 11B / 11A | Bb / Gm | | 12B / 12A | F / Dm | ## 5. UI-Komponenten (aus dem Mockup extrahieren) ### 5.1 Hero-Karte ``` ┌──────────────────────────────────────────────────────────────┐ │ ▌ ┌────────┐ │ │ ▌ │ │ (30px, bold) │ │ ▌ │ Cover │ (15px, dim) │ │ ▌ │ 240px │ │ │ ▌ │ │ ┌────────┬────────┬────────┐ │ │ ▌ │ │ │ Key │ BPM │ Match │ ← Stats │ │ ▌ │ │ │ 4B │ 118 │ 98% │ (semantisch │ │ ▌ │ │ │ A-Dur │ ± 2 │acoustid│ eingefärbt)│ │ ▌ │ │ └────────┴────────┴────────┘ │ │ ▌ │ │ │ │ ▌ │ │ [4B] [Synth-Pop] [New Wave] [80s] │ │ ▌ │ │ [Energetic] [Retro] [Euphoric] │ │ ▌ │ │ │ │ ▌ │ │ ┌──────────┐ │ │ ▌ │ │ │ Wheel │ │ │ ▌ │ │ │ 140px │ ← persistent│ │ ▌ │ │ └──────────┘ │ │ ▌ └────────┘ │ │ ▌ ▌ = 3px Akzent-Border in Key-Farbe (var(--key-current)) │ └──────────────────────────────────────────────────────────────┘ ``` **Hero-Expand-Modi (CSS-Klassen):** - `.hero.wheel-mode` → Grid wird 1-Spalte, Cover + Info-Top + Info-Mid `display:none`, Wheel expandiert auf 520×520, ×-Button sichtbar - `.hero.cover-mode` → Cover auf 480×480, Info `display:none`, ×-Button sichtbar - `.hero` (normal) → 240px Cover | flex Info, persistent Wheel 140×140 unten rechts ### 5.2 Camelot-Wheel (24 Polygone in 2 Ringen) - **Außenring (Major, B)**: 12 Polygone, 15° pro Segment - **Innenring (Minor, A)**: 12 Polygone, je direkt innen neben dem entsprechenden Major - **Current-Key**: voll in Key-Farbe gefüllt - **Kompatible Keys (primary)**: stark getönt (55% Alpha) - **Kompatible Keys (secondary)**: leicht getönt (28% Alpha) - **Andere Keys**: dezent getönt (10% Alpha) - **Center**: Current-Key + Music-Key in großer Schrift **Wheel-Render-Algorithmus (pro Segment):** ```js // 1. Hole Winkel aus codeToAngle[c] (NICHT aus Index!) // 2. Bestimme rOut und rIn basierend auf isMajor // 3. Berechne 4 Eckpunkte: p(rOut, a1), p(rOut, a2), p(rIn, a2), p(rIn, a1) // p(r, a) = [r * sin(rad), -r * cos(rad)] // 4. Zeichne mit den 4 Punkten // 5. Platziere -Labels (Camelot-Code + Music-Key) bei // p((rOut + rIn) / 2, aMid) = Mittelpunkt des Segments ``` ### 5.3 Suggestions-Grid (10 Tracks) Sortiert nach Kompatibilitäts-Stufe zu `currentKey`: | Stufe | Compat-Label | Visuelle Gewichtung | |---|---|---| | 0 | Perfect | Höchste Priorität | | 1 | Quinte -1 | Hoch | | 2 | Quinte +1 | Hoch | | 3 | Energy | Mittel-Hoch | | 4 | Scale | Mittel-Hoch | | 5 | Diagonal | Mittel | | 6 | Jaw's | Niedrig | | 7 | Mood | Niedrigste | Jede Suggestion-Card: ``` ┌──────────┐ │ Cover │ ← 1:1 Thumbnail (iTunes API) │ 200px │ └──────────┘ [4B grün] 120 BPM ← Key-Badge in Key-Farbe, BPM rechts Big in Japan ← Titel Alphaville · A ← Artist + Music-Key ───────────────────── PERFECT ← Compat-Badge ``` ## 6. Acceptance Criteria - [ ] Wheel rendert 24 Segmente in 2 Ringen, **Major und Minor am GLEICHEN Winkel** (12B+12A beide bei 0°) - [ ] Current-Key voll in Key-Farbe, primary-compat stark getönt, secondary-compat leicht getönt - [ ] Music-Keys (C, D, E, F#, etc.) sichtbar unter den Camelot-Codes im Wheel - [ ] Center zeigt Current-Key + Music-Key prominent - [ ] Hero expandiert inline bei Klick auf Wheel (smooth transition zu wheel-mode) - [ ] Hero expandiert inline bei Klick auf Cover (smooth transition zu cover-mode) - [ ] ×-Button schließt Expanded-Mode - [ ] Suggestions-Grid mit 10 Tracks, sortiert nach Compat-Stufe - [ ] Jede Suggestion zeigt Key-Badge in Key-Farbe + Compat-Badge - [ ] Dark/Light Toggle funktioniert, alle CSS-Variablen wechseln - [ ] Cover lädt via iTunes API (CORS-Server, nicht file://) - [ ] Layout passt in 1920×1080 ohne Scroll - [ ] Mobile Fallback (Single-Column Hero bei <760px) - [ ] UTF-8 ohne BOM (PowerShell-Encoding-Falle beachten) - [ ] Globale Farbänderung erfolgt durch Edit der `colorMatrix` (single source of truth) - [ ] Listening-Toggle: Klick auf 🎤 → Paused, Icon-Wechsel zu ⏸ - [ ] Suchfeld-Fallback: Inline-Expand bei nicht-erkanntem Song - [ ] Echte AcoustID-Anbindung statt iTunes (Audio-Fingerprinting) - [ ] GetSongBPM-Integration mit Camelot↔Open-Key-Umrechnung - [ ] Last.fm + Essentia.js für Tags + Genre/Mood - [ ] AcousticBrainz-Postgres-Initial-Seed (7,5 Mio. Songs) - [ ] Suggestions-Engine nutzt `mixingRules` für Camelot-kompatible Vorschläge ## 7. Noch zu klären (vom User) 1. **Wheel-Geometrie visuell polieren** — Logik ist korrekt, Rendering braucht Feinschliff (laut User "ist egal" vorerst zurückgestellt) 2. **Branding/Theme**: DJ-Live-Look (Mockup v3, AI-slop), schlicht-monochrome, oder Synthwave/Neon? Empfehlung: schlicht, mit einzelnen Farbakzenten aus der `colorMatrix`. 3. **Listening-Toggle UX**: Icon-Wechsel auf Pause-Icon, oder zusätzlicher Pause-Button neben dem Mic-Icon? 4. **Suchfeld-Position**: Modal, Inline-Expand im Hero, oder separate Seite? 5. **Auth-Modell**: Basic Auth über Caddy (jetzt), Authentik-SSO wenn verfügbar (später)? ## 8. Production-Schritte (Reihenfolge, mit Zeitaufwand) ### Phase 1: Mockup → Production-Komponente (1-2 Tage) 1. Wheel-Rendering finalisieren (Stroke-Widths, Anti-Aliasing) — 2-4h 2. CSS in separate `styles.css` extrahieren — 1h 3. JS in Module aufteilen (`wheel.js`, `cover.js`, `app.js`) — 2h 4. Listening-Toggle implementieren — 30min 5. Suchfeld-Fallback (Inline-Expand) — 2-3h ### Phase 2: Backend + AcoustID (1-2 Tage) 6. Express/Fastify Server-Setup — 2h 7. AcoustID-Anbindung (`POST /api/recognize`) — 4-6h 8. GetSongBPM + Last.fm (`GET /api/metadata`) — 4-6h 9. iTunes Cover-Cache (24h) — 1-2h ### Phase 3: Suggestions-Engine + DB (2-3 Tage) 10. AcousticBrainz-Import (Init-Seed) — 6-8h 11. Postgres-Schema + Migrations — 2-3h 12. Suggestions-Engine (`GET /api/suggestions`) mit `mixingRules` — 8-12h 13. ntfy-Worker für Quota-Alerts — 2h ### Phase 4: Polish + Deploy (1 Tag) 14. PWA (manifest, Service Worker) — 2-3h 15. Docker-Setup (`docker-compose.yml`, Caddy, `generate-env.sh`) — 1-2h 16. Auth (Basic Auth) — 1-2h 17. Backup-Hook — 30min **Total-Aufwand**: ca. 40-60 Stunden ## 9. Datei-Referenzen (in diesem Ideen-Ordner) | Datei | Zweck | |---|---| | `KeySelector.txt` | Vollständige Spezifikation (Text-Form, deploy-ready als Prompt) | | `KeySelector_Mockup_Wheel.html` | Interaktiver Mockup (standalone, im Browser öffnen) | | `KeySelector_Mockup_Wheel.css` | **CSS-Stylesheet**, aus dem Mockup extrahiert (10 KB, UTF-8 ohne BOM) | | `KeySelector_SPEC.md` | **Diese Datei** — Übergabe-Briefing für Entwickler | | `erledigt/KeySelector_Konzept.txt` | Original-Konzept (8 KB, gemerged) | | `erledigt/Music-Chooser.txt` | Vorheriger, obsoleter Entwurf | ## 10. Jannik-Cloud-Integration ```yaml # services/keyselector/docker-compose.yml services: keyselector: build: . restart: unless-stopped ports: [] # kein direkter Port, geht über Caddy environment: ACOUSTID_API_KEY: ${ACOUSTID_API_KEY} GETSONGBPM_API_KEY: ${GETSONGBPM_API_KEY} LASTFM_API_KEY: ${LASTFM_API_KEY} NTFY_URL: ${NTFY_URL} DATABASE_URL: postgresql://keyselector:${DB_PW}@postgres:5432/keyselector REDIS_URL: redis://redis:6379 depends_on: - postgres - redis # services/keyselector/keyselector.caddy keyselector.orfel.de { reverse_proxy keyselector:3000 encode gzip } ``` API-Keys werden via `generate-env.sh` erzeugt und als `.env.age` (AGE-verschlüsselt) abgelegt. Shared Postgres-Instanz (eine DB pro Service, wird beim Deploy automatisch angelegt). Shared Redis für kurzen State. ## 11. Kontakt - **User**: Jannik - **Workspace**: `C:\GitHub\` - **Cloud**: Jannik-Cloud - **Domain-Wildcard**: `*.orfel.de` - **Verfügbare Tools**: AcoustID, GetSongBPM, Last.fm, ntfy (selbst gehostet), iTunes Search API - **Domain für KeySelector**: `keyselector.orfel.de`, `keysel.orfel.de`, `ks.orfel.de` --- *Erstellt 2026-08-26. Übergabe-Dokument für die Entwicklung von KeySelector.*