Files
KeySelector/docs/KeySelector_SPEC.md
T

396 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
┌──────────────────────────────────────────────────────────────┐
│ ▌ ┌────────┐ │
│ ▌ │ │ <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-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 <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 `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.*