18 KiB
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
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.
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)
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]
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
┌──────────────────────────────────────────────────────────────┐
│ ▌ ┌────────┐ │
│ ▌ │ │ <Song-Titel> (30px, bold) │
│ ▌ │ Cover │ <Artist> (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-Middisplay:none, Wheel expandiert auf 520×520, ×-Button sichtbar.hero.cover-mode→ Cover auf 480×480, Infodisplay: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):
// 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 <polygon> mit den 4 Punkten
// 5. Platziere <text>-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
mixingRulesfür Camelot-kompatible Vorschläge
7. Noch zu klären (vom User)
- Wheel-Geometrie visuell polieren — Logik ist korrekt, Rendering braucht Feinschliff (laut User "ist egal" vorerst zurückgestellt)
- Branding/Theme: DJ-Live-Look (Mockup v3, AI-slop), schlicht-monochrome, oder Synthwave/Neon? Empfehlung: schlicht, mit einzelnen Farbakzenten aus der
colorMatrix. - Listening-Toggle UX: Icon-Wechsel auf Pause-Icon, oder zusätzlicher Pause-Button neben dem Mic-Icon?
- Suchfeld-Position: Modal, Inline-Expand im Hero, oder separate Seite?
- 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)
- Wheel-Rendering finalisieren (Stroke-Widths, Anti-Aliasing) — 2-4h
- CSS in separate
styles.cssextrahieren — 1h - JS in Module aufteilen (
wheel.js,cover.js,app.js) — 2h - Listening-Toggle implementieren — 30min
- Suchfeld-Fallback (Inline-Expand) — 2-3h
Phase 2: Backend + AcoustID (1-2 Tage)
- Express/Fastify Server-Setup — 2h
- AcoustID-Anbindung (
POST /api/recognize) — 4-6h - GetSongBPM + Last.fm (
GET /api/metadata) — 4-6h - iTunes Cover-Cache (24h) — 1-2h
Phase 3: Suggestions-Engine + DB (2-3 Tage)
- AcousticBrainz-Import (Init-Seed) — 6-8h
- Postgres-Schema + Migrations — 2-3h
- Suggestions-Engine (
GET /api/suggestions) mitmixingRules— 8-12h - ntfy-Worker für Quota-Alerts — 2h
Phase 4: Polish + Deploy (1 Tag)
- PWA (manifest, Service Worker) — 2-3h
- Docker-Setup (
docker-compose.yml, Caddy,generate-env.sh) — 1-2h - Auth (Basic Auth) — 1-2h
- 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
# 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.