commit 48cdda01ca44bb057fee202a81447ee50e33ab9b Author: orfelorfel23 Date: Wed Aug 26 00:32:17 2026 +0200 Initial commit: KeySelector implementation diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..bbfba31 --- /dev/null +++ b/.env.sample @@ -0,0 +1,5 @@ +ACOUSTID_API_KEY=sample_acoustid_key +GETSONGBPM_API_KEY=sample_getsongbpm_key +LASTFM_API_KEY=sample_lastfm_key +NTFY_URL=https://ntfy.sh/sample_topic +DB_PW=sample_db_password diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0af4bf3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY server/package*.json ./server/ +RUN cd server && npm install +COPY . . +WORKDIR /app/server +CMD ["npm", "start"] +EXPOSE 3000 diff --git a/Idea/Camelot-Wheel-Chart.png b/Idea/Camelot-Wheel-Chart.png new file mode 100644 index 0000000..c7eb0dd Binary files /dev/null and b/Idea/Camelot-Wheel-Chart.png differ diff --git a/Idea/Camelot-Wheel-Mixed-In-Key-Harmonic-Mixing.png b/Idea/Camelot-Wheel-Mixed-In-Key-Harmonic-Mixing.png new file mode 100644 index 0000000..9be3d42 Binary files /dev/null and b/Idea/Camelot-Wheel-Mixed-In-Key-Harmonic-Mixing.png differ diff --git a/Idea/KeySelector_Mockup_Wheel.css b/Idea/KeySelector_Mockup_Wheel.css new file mode 100644 index 0000000..88632d4 --- /dev/null +++ b/Idea/KeySelector_Mockup_Wheel.css @@ -0,0 +1,200 @@ +/* ------------------------------------------------------------------ */ + /* THEME */ + /* ------------------------------------------------------------------ */ + :root, [data-theme="dark"] { + --bg: #0E0E14; + --surface: #16161E; + --surface-2: #1E1E28; + --border: #2A2A36; + --text: #ECECF2; + --text-dim: #7878A0; + --text-bright: #FFFFFF; + --key-current: #4F9D69; /* 4B = A-Dur green */ + --bpm: #E0B423; + --match: #4F9D69; + --mic: #D62828; + } + [data-theme="light"] { + --bg: #F4F4F8; + --surface: #FFFFFF; + --surface-2: #ECECF1; + --border: #DCDCE4; + --text: #1A1A22; + --text-dim: #6A6A78; + --text-bright: #000000; + --key-current: #2E7A48; + --bpm: #B88A1A; + --match: #2E7A48; + --mic: #C4202A; + } + * { box-sizing: border-box; margin: 0; padding: 0; } + html, body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + transition: background-color .18s, color .18s; + } + body { padding: 24px 32px; min-height: 100vh; } + .container { max-width: 1600px; margin: 0 auto; } + + /* ------------------------------------------------------------------ */ + /* TOPBAR */ + /* ------------------------------------------------------------------ */ + .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } + .brand { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; letter-spacing: 0.3em; } + .topbar-right { display: flex; align-items: center; gap: 18px; } + .status { display: flex; align-items: center; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; } + .mic { width: 14px; height: 14px; fill: var(--mic); } + .theme-toggle { + background: none; border: 1px solid var(--border); border-radius: 4px; + padding: 5px 12px; color: var(--text-dim); + font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + cursor: pointer; transition: all .12s; + } + .theme-toggle:hover { color: var(--text); border-color: var(--text-dim); } + + /* ------------------------------------------------------------------ */ + /* HERO */ + /* ------------------------------------------------------------------ */ + .hero { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + position: relative; + overflow: hidden; + transition: grid-template-columns .3s ease, padding .3s ease, min-height .3s ease; + min-height: 280px; + } + .hero::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 3px; background: var(--key-current); + transition: background-color .18s; + } + /* WHEEL expanded mode */ + .hero.wheel-mode { + grid-template-columns: 1fr; + padding: 48px; + min-height: 600px; + } + .hero.wheel-mode .cover, + .hero.wheel-mode .info-top, + .hero.wheel-mode .info-mid { display: none; } + .hero.wheel-mode .info-bot { padding-top: 0; justify-content: center; } + .hero.wheel-mode .wheel-container { width: 100%; max-width: 520px; height: 520px; } + /* COVER expanded mode */ + .hero.cover-mode { grid-template-columns: 1fr; min-height: 540px; } + .hero.cover-mode .info { display: none; } + .hero.cover-mode .cover { + width: 100%; max-width: 480px; margin: 0 auto; + aspect-ratio: 1; max-height: 460px; + } + @media (max-width: 760px) { + .hero { grid-template-columns: 1fr; } + } + + .cover { + width: 100%; aspect-ratio: 1; max-height: 240px; + border-radius: 8px; background: var(--surface-2); + overflow: hidden; position: relative; cursor: pointer; + transition: transform .15s; + } + .cover:hover { transform: scale(1.01); } + .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } + .cover::after { + content: ''; position: absolute; inset: 0; + background: linear-gradient(135deg, rgba(79, 157, 105, 0.20), transparent 60%); + pointer-events: none; + } + .cover-placeholder { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; + font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); + letter-spacing: 0.2em; text-transform: uppercase; + } + .cover-placeholder .note { font-size: 32px; opacity: 0.3; } + + .info { display: flex; flex-direction: column; min-width: 0; } + .info-top { display: flex; flex-direction: column; } + .info-mid { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; } + .info-bot { margin-top: auto; padding-top: 20px; display: flex; justify-content: flex-end; align-items: flex-end; min-height: 160px; } + + .track-title { font-size: 30px; font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; margin-bottom: 4px; } + .track-artist { font-size: 15px; color: var(--text-dim); } + + .stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } + .stat { background: var(--surface); padding: 12px 14px; } + .stat-label { font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px; } + .stat-value { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 700; font-feature-settings: 'tnum'; line-height: 1; } + .stat-value.k { color: var(--key-current); } + .stat-value.b { color: var(--bpm); } + .stat-value.m { color: var(--match); } + .stat-sub { font-size: 10px; color: var(--text-dim); margin-top: 4px; font-family: 'JetBrains Mono', monospace; } + + .tags-row { display: flex; flex-wrap: wrap; gap: 6px; } + .tag { padding: 4px 10px; border-radius: 4px; font-size: 11px; font-family: 'JetBrains Mono', monospace; letter-spacing: 0.04em; } + .tag-key { background: var(--key-current); color: #fff; font-weight: 700; } + .tag-genre { background: rgba(90, 138, 229, 0.13); color: #8AA8D8; border: 1px solid rgba(90, 138, 229, 0.32); } + [data-theme="light"] .tag-genre { background: rgba(52, 90, 156, 0.10); color: #345A9C; border-color: rgba(52, 90, 156, 0.28); } + .tag-mood { background: rgba(214, 41, 102, 0.13); color: #D88096; border: 1px solid rgba(214, 41, 102, 0.32); } + [data-theme="light"] .tag-mood { background: rgba(168, 51, 106, 0.10); color: #A8336A; border-color: rgba(168, 51, 106, 0.28); } + + /* ------------------------------------------------------------------ */ + /* WHEEL */ + /* ------------------------------------------------------------------ */ + .wheel-container { + width: 140px; height: 140px; + position: relative; cursor: pointer; + transition: transform .15s, width .3s ease, height .3s ease; + } + .wheel-container:hover { transform: scale(1.04); } + .wheel-container svg { width: 100%; height: 100%; display: block; } + .close-btn { + position: absolute; top: 12px; right: 12px; + width: 32px; height: 32px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 50%; color: var(--text-dim); + font-size: 18px; cursor: pointer; + display: none; align-items: center; justify-content: center; + line-height: 1; z-index: 5; + transition: color .12s, border-color .12s; + } + .close-btn:hover { color: var(--text); border-color: var(--text-dim); } + .hero.wheel-mode .close-btn, + .hero.cover-mode .close-btn { display: flex; } + + /* ------------------------------------------------------------------ */ + /* SUGGESTIONS */ + /* ------------------------------------------------------------------ */ + .section-title { + font-family: 'JetBrains Mono', monospace; font-size: 11px; + letter-spacing: 0.25em; text-transform: uppercase; color: var(--text-dim); + margin: 28px 0 12px; + } + .suggestions { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } + .sug { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; padding: 10px; + display: flex; flex-direction: column; gap: 8px; + cursor: pointer; + transition: border-color .12s, transform .12s; + } + .sug:hover { border-color: var(--text-dim); transform: translateY(-2px); } + .sug-cover { width: 100%; aspect-ratio: 1; border-radius: 4px; background: var(--surface-2); overflow: hidden; position: relative; } + .sug-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } + .sug-cover-placeholder { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 20px; opacity: 0.25; } + .sug-meta { display: flex; justify-content: space-between; align-items: center; } + .sug-key { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 3px; color: #fff; letter-spacing: 0.04em; } + .sug-bpm { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); } + .sug-title { font-size: 13px; font-weight: 500; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .sug-artist { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); } + .sug-compat { + font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--text-dim); + padding-top: 4px; border-top: 1px solid var(--border); + } \ No newline at end of file diff --git a/Idea/KeySelector_Mockup_Wheel.html b/Idea/KeySelector_Mockup_Wheel.html new file mode 100644 index 0000000..186c7f3 --- /dev/null +++ b/Idea/KeySelector_Mockup_Wheel.html @@ -0,0 +1,647 @@ + + + + + + KeySelector + + + + + + +
+
+
KeySelector
+
+
+ + Listening +
+ +
+
+ +
+
+
+ ♪ + No Cover +
+
+
+
+
Made in Japan
+
Alphaville
+
+
+
+
+
Key
+
4B
+
A-Dur
+
+
+
BPM
+
118
+
± 2
+
+
+
Match
+
98%
+
acoustid
+
+
+
+ 4B + Synth-Pop + New Wave + 80s + Energetic + Retro + Euphoric +
+
+
+
+ +
+
+
+ +
+ +
Up Next
+
+
+ + + + diff --git a/Idea/KeySelector_SPEC.md b/Idea/KeySelector_SPEC.md new file mode 100644 index 0000000..788d2b3 --- /dev/null +++ b/Idea/KeySelector_SPEC.md @@ -0,0 +1,395 @@ +# 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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca90356 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# KeySelector + +Browser-basiertes DJ-Tool für Harmonic Mixing mit automatischer Song-Erkennung. + +## Setup +1. Kopiere `.env.sample` zu `.env` und fülle die API-Keys aus (via `./generate-env.sh`) +2. Starte mit Docker Compose: + ``` + docker-compose up -d + ``` + +## Development +- Frontend ist reines Vanilla JS in `public/` +- Backend ist Express in `server/` +- `shared/` enthält Logik, die in beiden genutzt wird (Farben, Camelot-Angles, Mixing-Rules). + +## Architecture +Siehe [KeySelector_SPEC.md](Idea/KeySelector_SPEC.md) für detaillierte Spezifikationen. diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..83c15f0 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,20 @@ +-- Schema for KeySelector and AcousticBrainz data + +CREATE TABLE IF NOT EXISTS tracks ( + id SERIAL PRIMARY KEY, + mbid VARCHAR(36) UNIQUE NOT NULL, -- MusicBrainz ID + title VARCHAR(255), + artist VARCHAR(255), + camelot_key VARCHAR(3), + bpm INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS track_tags ( + track_id INTEGER REFERENCES tracks(id), + tag VARCHAR(100), + PRIMARY KEY (track_id, tag) +); + +CREATE INDEX IF NOT EXISTS idx_camelot_key ON tracks(camelot_key); +CREATE INDEX IF NOT EXISTS idx_bpm ON tracks(bpm); diff --git a/db/seed-acousticbrainz.sh b/db/seed-acousticbrainz.sh new file mode 100644 index 0000000..896b98f --- /dev/null +++ b/db/seed-acousticbrainz.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Mock script to seed AcousticBrainz data +echo "Seeding AcousticBrainz data..." +echo "This would typically download the AcousticBrainz dataset," +echo "parse the JSON archives, calculate the Camelot keys," +echo "and import them into the PostgreSQL database." +echo "Done." diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9f23182 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + keyselector: + build: . + restart: unless-stopped + ports: + - "3000:3000" + 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 diff --git a/generate-env.sh b/generate-env.sh new file mode 100644 index 0000000..583137d --- /dev/null +++ b/generate-env.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "Generating .env file with sample values for testing..." +cp .env.sample .env +echo ".env generated successfully. Please edit it to add real API keys." diff --git a/keyselector.caddy b/keyselector.caddy new file mode 100644 index 0000000..f4c6225 --- /dev/null +++ b/keyselector.caddy @@ -0,0 +1,4 @@ +keyselector.orfel.de { + reverse_proxy keyselector:3000 + encode gzip +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..875aed4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,59 @@ +let isListening = true; +let currentTrack = null; + +function toggleTheme() { + const html = document.documentElement; + const isLight = html.getAttribute('data-theme') === 'light'; + const next = isLight ? 'dark' : 'light'; + html.setAttribute('data-theme', next); + document.getElementById('theme-btn').textContent = isLight ? 'Light' : 'Dark'; + if (currentTrack && currentTrack.key) { + window.updateWheel(currentTrack.key); + } else { + window.updateWheel(null); + } +} + +function toggleListening() { + isListening = !isListening; + const statusEl = document.getElementById('listening-status'); + const icon = statusEl.querySelector('i'); + const text = document.getElementById('listening-text'); + + if (isListening) { + statusEl.classList.remove('paused'); + icon.className = 'fa-solid fa-microphone'; + text.textContent = 'Listening'; + } else { + statusEl.classList.add('paused'); + icon.className = 'fa-solid fa-pause'; + text.textContent = 'Paused'; + } +} + +function toggleWheel() { + const hero = document.getElementById('hero'); + if (hero.classList.contains('wheel-mode')) { + resetHero(); + } else { + hero.classList.remove('cover-mode'); + hero.classList.add('wheel-mode'); + } +} + +function toggleCover() { + const hero = document.getElementById('hero'); + if (hero.classList.contains('cover-mode')) { + resetHero(); + } else { + hero.classList.remove('wheel-mode'); + hero.classList.add('cover-mode'); + } +} + +function resetHero() { + document.getElementById('hero').classList.remove('wheel-mode', 'cover-mode'); +} + +// Initial draw +window.updateWheel(null); diff --git a/public/cover.js b/public/cover.js new file mode 100644 index 0000000..54de124 --- /dev/null +++ b/public/cover.js @@ -0,0 +1,36 @@ +async function fetchCover(query, targetEl) { + try { + // In production, this should hit our /api/cover to avoid CORS and add caching, + // but for the mockup functionality we can use iTunes directly if we don't have backend running. + // The spec mentioned using server for CORS, but browser often allows GET to iTunes. + // We will use the backend route to be safe. + + // For now, simple fallback to iTunes directly if backend is missing. + let url = `/api/cover?q=${encodeURIComponent(query)}`; + + // Temporary direct fetch for local testing if no backend + if (window.location.protocol === 'file:') { + url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=music&entity=song&limit=1`; + } + + const r = await fetch(url); + if (!r.ok) throw new Error('fetch error'); + + const data = await r.json(); + let src = ''; + + if (data.url) { // our api + src = data.url; + } else if (data.results && data.results[0] && data.results[0].artworkUrl100) { // direct itunes + src = data.results[0].artworkUrl100.replace('100x100bb', '300x300bb'); + } + + if (src) { + targetEl.innerHTML = `cover`; + } + } catch (e) { + console.error('Cover fetch failed', e); + } +} + +window.fetchCover = fetchCover; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a34ee68 --- /dev/null +++ b/public/index.html @@ -0,0 +1,84 @@ + + + + + + KeySelector + + + + + + + +
+
+
KeySelector
+
+
+ + Listening +
+ +
+
+ +
+
+
+ ♪ + No Cover +
+
+
+
+ +
Waiting...
+
for audio input
+
+
+
+
+
Key
+
-
+
-
+
+
+
BPM
+
-
+
-
+
+
+
Match
+
-
+
acoustid
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+ +
Up Next
+
+
+ + + + + + + + + + + + diff --git a/public/search.js b/public/search.js new file mode 100644 index 0000000..3faf15b --- /dev/null +++ b/public/search.js @@ -0,0 +1,118 @@ +function promptManualSearch() { + const query = prompt("Enter track name and artist manually (e.g. 'Alphaville Big in Japan'):"); + if (query && query.trim()) { + simulateRecognition(query.trim()); + } +} + +function renderSuggestions(currentKey) { + const sugContainer = document.getElementById('suggestions'); + sugContainer.innerHTML = ''; + + if (!currentKey || !window.mixingRules[currentKey]) { + sugContainer.innerHTML = '
No suggestions available.
'; + return; + } + + // Create some dummy suggestions based on the rules for the mockup + const compatKeys = window.mixingRules[currentKey].slice(1); + const dummySuggestions = compatKeys.map((c, i) => { + return { + c: c, + m: window.camelotToMusicKey[c], + color: window.colorMatrix[c], + title: `Dummy Track ${i+1}`, + artist: `Artist ${c}`, + bpm: 120 + Math.floor(Math.random() * 10 - 5), + compat: i + 1 // 1=Quinte-1, 2=Quinte+1, etc. + }; + }); + + dummySuggestions.forEach(s => { + const div = document.createElement('div'); + div.className = 'sug'; + const idSafe = `sug-cover-${s.c.replace(/\s/g,'_')}-${s.title.replace(/[^A-Za-z0-9]/g,'')}`; + div.innerHTML = ` +
+
♪
+
+
+
${s.c}
+
${s.bpm} BPM
+
+
+
${s.title}
+
${s.artist} · ${s.m}
+
+
${window.compatLabels[s.compat]}
+ `; + sugContainer.appendChild(div); + window.fetchCover(`${s.artist} ${s.title}`, div.querySelector('.sug-cover')); + }); +} + +// Temporary simulation function to show the UI working +function simulateRecognition(query) { + // Try to extract artist and title simply + const parts = query.split(' '); + const title = parts.slice(0, Math.max(1, parts.length - 1)).join(' '); + const artist = parts[parts.length - 1] || 'Unknown'; + + // Pick a random key for the demo + const keysArr = Object.keys(window.colorMatrix); + const randomKey = keysArr[Math.floor(Math.random() * keysArr.length)]; + const bpm = 110 + Math.floor(Math.random() * 30); + + updateHero({ + title, + artist, + key: randomKey, + bpm: bpm, + match: '99%', + tags: [randomKey, 'Electronic', 'Pop'] + }); +} + +function updateHero(track) { + window.currentTrack = track; + + document.getElementById('track-title').textContent = track.title; + document.getElementById('track-artist').textContent = track.artist; + + document.getElementById('stat-key').textContent = track.key; + document.getElementById('stat-key').style.color = window.colorMatrix[track.key]; + document.getElementById('stat-key-sub').textContent = window.camelotToMusicKey[track.key]; + + document.getElementById('stat-bpm').textContent = track.bpm; + document.getElementById('stat-match').textContent = track.match; + + // Update tags + const tagsRow = document.getElementById('tags-row'); + tagsRow.innerHTML = ''; + track.tags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'tag ' + (tag === track.key ? 'tag-key' : 'tag-genre'); + if (tag === track.key) { + span.style.background = window.colorMatrix[track.key]; + } + span.textContent = tag; + tagsRow.appendChild(span); + }); + + // Update Hero Border + document.documentElement.style.setProperty('--key-current', window.colorMatrix[track.key]); + + // Update Wheel + window.updateWheel(track.key); + + // Fetch Cover + window.fetchCover(`${track.artist} ${track.title}`, document.getElementById('cover')); + + // Update Suggestions + renderSuggestions(track.key); +} + +// Set initial mock state for preview +setTimeout(() => { + simulateRecognition("Alphaville Made in Japan"); +}, 500); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..5aa9186 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,198 @@ +:root, [data-theme="dark"] { + --bg: #0E0E14; + --surface: #16161E; + --surface-2: #1E1E28; + --border: #2A2A36; + --text: #ECECF2; + --text-dim: #7878A0; + --text-bright: #FFFFFF; + --key-current: #4F9D69; /* 4B = A-Dur green */ + --bpm: #E0B423; + --match: #4F9D69; + --mic: #D62828; +} +[data-theme="light"] { + --bg: #F4F4F8; + --surface: #FFFFFF; + --surface-2: #ECECF1; + --border: #DCDCE4; + --text: #1A1A22; + --text-dim: #6A6A78; + --text-bright: #000000; + --key-current: #2E7A48; + --bpm: #B88A1A; + --match: #2E7A48; + --mic: #C4202A; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + transition: background-color .18s, color .18s; +} +body { padding: 24px 32px; min-height: 100vh; } +.container { max-width: 1600px; margin: 0 auto; } + +.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } +.brand { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; letter-spacing: 0.3em; } +.topbar-right { display: flex; align-items: center; gap: 18px; } +.status { display: flex; align-items: center; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer; transition: color 0.12s; } +.status:hover { color: var(--text); } +.status i { color: var(--mic); font-size: 14px; } +.status.paused i { color: var(--text-dim); } + +.theme-toggle { + background: none; border: 1px solid var(--border); border-radius: 4px; + padding: 5px 12px; color: var(--text-dim); + font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + cursor: pointer; transition: all .12s; +} +.theme-toggle:hover { color: var(--text); border-color: var(--text-dim); } + +.hero { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + position: relative; + overflow: hidden; + transition: grid-template-columns .3s ease, padding .3s ease, min-height .3s ease; + min-height: 280px; +} +.hero::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 3px; background: var(--key-current); + transition: background-color .18s; +} +/* WHEEL expanded mode */ +.hero.wheel-mode { + grid-template-columns: 1fr; + padding: 48px; + min-height: 600px; +} +.hero.wheel-mode .cover, +.hero.wheel-mode .info-top, +.hero.wheel-mode .info-mid { display: none; } +.hero.wheel-mode .info-bot { padding-top: 0; justify-content: center; } +.hero.wheel-mode .wheel-container { width: 100%; max-width: 520px; height: 520px; } +/* COVER expanded mode */ +.hero.cover-mode { grid-template-columns: 1fr; min-height: 540px; } +.hero.cover-mode .info { display: none; } +.hero.cover-mode .cover { + width: 100%; max-width: 480px; margin: 0 auto; + aspect-ratio: 1; max-height: 460px; +} +@media (max-width: 760px) { + .hero { grid-template-columns: 1fr; } +} + +.cover { + width: 100%; aspect-ratio: 1; max-height: 240px; + border-radius: 8px; background: var(--surface-2); + overflow: hidden; position: relative; cursor: pointer; + transition: transform .15s; +} +.cover:hover { transform: scale(1.01); } +.cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.cover::after { + content: ''; position: absolute; inset: 0; + background: linear-gradient(135deg, rgba(79, 157, 105, 0.20), transparent 60%); + pointer-events: none; +} +.cover-placeholder { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; + font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); + letter-spacing: 0.2em; text-transform: uppercase; +} +.cover-placeholder .note { font-size: 32px; opacity: 0.3; } + +.info { display: flex; flex-direction: column; min-width: 0; } +.info-top { display: flex; flex-direction: column; position: relative; } +.info-mid { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; } +.info-bot { margin-top: auto; padding-top: 20px; display: flex; justify-content: flex-end; align-items: flex-end; min-height: 160px; } + +.track-title { font-size: 30px; font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; margin-bottom: 4px; } +.track-artist { font-size: 15px; color: var(--text-dim); } + +.search-fallback-btn { + position: absolute; top: 0; right: 0; + background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; + padding: 6px 12px; color: var(--text-dim); font-size: 11px; cursor: pointer; + font-family: 'JetBrains Mono', monospace; letter-spacing: 0.1em; text-transform: uppercase; + transition: all .12s; +} +.search-fallback-btn:hover { background: var(--border); color: var(--text); } +.search-fallback-btn i { margin-right: 6px; } + +.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.stat { background: var(--surface); padding: 12px 14px; } +.stat-label { font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px; } +.stat-value { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 700; font-feature-settings: 'tnum'; line-height: 1; } +.stat-value.k { color: var(--key-current); } +.stat-value.b { color: var(--bpm); } +.stat-value.m { color: var(--match); } +.stat-sub { font-size: 10px; color: var(--text-dim); margin-top: 4px; font-family: 'JetBrains Mono', monospace; } + +.tags-row { display: flex; flex-wrap: wrap; gap: 6px; } +.tag { padding: 4px 10px; border-radius: 4px; font-size: 11px; font-family: 'JetBrains Mono', monospace; letter-spacing: 0.04em; } +.tag-key { background: var(--key-current); color: #fff; font-weight: 700; } +.tag-genre { background: rgba(90, 138, 229, 0.13); color: #8AA8D8; border: 1px solid rgba(90, 138, 229, 0.32); } +[data-theme="light"] .tag-genre { background: rgba(52, 90, 156, 0.10); color: #345A9C; border-color: rgba(52, 90, 156, 0.28); } +.tag-mood { background: rgba(214, 41, 102, 0.13); color: #D88096; border: 1px solid rgba(214, 41, 102, 0.32); } +[data-theme="light"] .tag-mood { background: rgba(168, 51, 106, 0.10); color: #A8336A; border-color: rgba(168, 51, 106, 0.28); } + +.wheel-container { + width: 140px; height: 140px; + position: relative; cursor: pointer; + transition: transform .15s, width .3s ease, height .3s ease; +} +.wheel-container:hover { transform: scale(1.04); } +.wheel-container svg { width: 100%; height: 100%; display: block; } +.close-btn { + position: absolute; top: 12px; right: 12px; + width: 32px; height: 32px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 50%; color: var(--text-dim); + font-size: 18px; cursor: pointer; + display: none; align-items: center; justify-content: center; + line-height: 1; z-index: 5; + transition: color .12s, border-color .12s; +} +.close-btn:hover { color: var(--text); border-color: var(--text-dim); } +.hero.wheel-mode .close-btn, +.hero.cover-mode .close-btn { display: flex; } + +.section-title { + font-family: 'JetBrains Mono', monospace; font-size: 11px; + letter-spacing: 0.25em; text-transform: uppercase; color: var(--text-dim); + margin: 28px 0 12px; +} +.suggestions { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.sug { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; padding: 10px; + display: flex; flex-direction: column; gap: 8px; + cursor: pointer; + transition: border-color .12s, transform .12s; +} +.sug:hover { border-color: var(--text-dim); transform: translateY(-2px); } +.sug-cover { width: 100%; aspect-ratio: 1; border-radius: 4px; background: var(--surface-2); overflow: hidden; position: relative; } +.sug-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.sug-cover-placeholder { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 20px; opacity: 0.25; } +.sug-meta { display: flex; justify-content: space-between; align-items: center; } +.sug-key { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 3px; color: #fff; letter-spacing: 0.04em; } +.sug-bpm { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); } +.sug-title { font-size: 13px; font-weight: 500; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.sug-artist { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); } +.sug-compat { + font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--text-dim); + padding-top: 4px; border-top: 1px solid var(--border); +} diff --git a/public/wheel.js b/public/wheel.js new file mode 100644 index 0000000..ca0186c --- /dev/null +++ b/public/wheel.js @@ -0,0 +1,213 @@ +/* ================================================================== */ +/* Wheel-Builder */ +/* viewBox = quadratisch (z. B. -110 -110 220 220) */ +/* 24 Segmente à 15°, 12B oben (12 Uhr), clockwise */ +/* ================================================================== */ + +const keys = [ + ['12B', 'F', '#2EC4B6'], ['1B', 'C', '#D62828'], ['2B', 'G', '#E77D11'], + ['3B', 'D', '#E0B423'], ['4B', 'A', '#4F9D69'], ['5B', 'E', '#1B998B'], + ['6B', 'B', '#6B4E9C'], ['7B', 'F#', '#3B7DD8'], ['8B', 'Db', '#3461A8'], + ['9B', 'Ab', '#5B3A8C'], ['10B', 'Eb', '#B07ECC'], ['11B', 'Bb', '#D64966'], + ['12A', 'Dm', '#2EC4B6'], ['1A', 'Am', '#D62828'], ['2A', 'Em', '#E77D11'], + ['3A', 'Bm', '#E0B423'], ['4A', 'F#m', '#4F9D69'], ['5A', 'C#m', '#1B998B'], + ['6A', 'G#m', '#6B4E9C'], ['7A', 'D#m', '#3B7DD8'], ['8A', 'Bbm', '#3461A8'], + ['9A', 'Fm', '#5B3A8C'], ['10A', 'Cm', '#B07ECC'], ['11A', 'Gm', '#D64966'], +]; + +const hexRgba = (h, a) => { + const r = parseInt(h.slice(1,3),16); + const g = parseInt(h.slice(3,5),16); + const b = parseInt(h.slice(5,7),16); + return `rgba(${r},${g},${b},${a})`; +}; + +function getThemeColors() { + const isLight = document.documentElement.getAttribute('data-theme') === 'light'; + return { + textBright: isLight ? '#000000' : '#FFFFFF', + textDim: isLight ? '#7A7A88' : '#9090B0', + bg: isLight ? '#F4F4F8' : '#0E0E14', + border: isLight ? '#DCDCE4' : '#2A2A36' + }; +} + +function buildWheel(svgId, viewBoxSize, currentKey = null, compatIdxs = { primary: new Set(), secondary: new Set() }) { + const svg = document.getElementById(svgId); + if (!svg) return; + svg.innerHTML = ''; + const NS = 'http://www.w3.org/2000/svg'; + + const currentIdx = keys.findIndex(k => k[0] === currentKey); + + const rOuter = viewBoxSize / 2 - 4; + const rMid = rOuter * 0.62; + const rInner = rOuter * 0.30; + const theme = getThemeColors(); + + const p = (r, a) => { + const rad = a * Math.PI / 180; + return [r * Math.sin(rad), -r * Math.cos(rad)]; + }; + + keys.forEach(([c, m, color], i) => { + const isMajor = c.endsWith('B'); + const isCurrent = i === currentIdx; + const isPrimary = compatIdxs.primary.has(i); + const isSecondary = compatIdxs.secondary.has(i); + const rOut = isMajor ? rOuter : rMid; + const rIn = isMajor ? rMid : rInner; + + // -7.5 to align 12B/12A so the center of the segment is at 0 degrees (top) + // The previous implementation had them start at 0, putting the segment from 0 to 15. + // We want the text to be at 0, so the segment goes from -7.5 to +7.5. + const a1 = window.codeToAngle[c] - 7.5; + const a2 = a1 + 15; + const aMid = (a1 + a2) / 2; + + const [x1,y1] = p(rOut, a1); + const [x2,y2] = p(rOut, a2); + const [x3,y3] = p(rIn, a2); + const [x4,y4] = p(rIn, a1); + + const poly = document.createElementNS(NS, 'polygon'); + poly.setAttribute('points', + `${x1.toFixed(2)},${y1.toFixed(2)} ` + + `${x2.toFixed(2)},${y2.toFixed(2)} ` + + `${x3.toFixed(2)},${y3.toFixed(2)} ` + + `${x4.toFixed(2)},${y4.toFixed(2)}`); + + // Better stroke rendering per user request + const baseStroke = '#000000'; // According to the chart reference, borders are black or dark + + if (isCurrent) { + poly.setAttribute('fill', window.colorMatrix[c]); + poly.setAttribute('stroke', baseStroke); + poly.setAttribute('stroke-width', '1.5'); + } else if (isPrimary) { + poly.setAttribute('fill', hexRgba(window.colorMatrix[c], 0.70)); + poly.setAttribute('stroke', baseStroke); + poly.setAttribute('stroke-width', '1'); + } else if (isSecondary) { + poly.setAttribute('fill', hexRgba(window.colorMatrix[c], 0.40)); + poly.setAttribute('stroke', baseStroke); + poly.setAttribute('stroke-width', '1'); + } else { + poly.setAttribute('fill', hexRgba(window.colorMatrix[c], 0.15)); + poly.setAttribute('stroke', baseStroke); + poly.setAttribute('stroke-width', '0.5'); + } + poly.setAttribute('stroke-linejoin', 'miter'); + svg.appendChild(poly); + + const [lx, ly] = p((rOut + rIn) / 2, aMid); + + const t1 = document.createElementNS(NS, 'text'); + t1.setAttribute('x', lx); + t1.setAttribute('y', ly - viewBoxSize/60); + t1.setAttribute('text-anchor', 'middle'); + t1.setAttribute('dominant-baseline', 'middle'); + t1.setAttribute('font-family', 'JetBrains Mono, monospace'); + t1.setAttribute('font-size', isMajor ? viewBoxSize/15 : viewBoxSize/22); + t1.setAttribute('font-weight', '700'); + t1.setAttribute('fill', (isCurrent || isPrimary) ? '#FFFFFF' : theme.textBright); + t1.textContent = c; + svg.appendChild(t1); + + const t2 = document.createElementNS(NS, 'text'); + t2.setAttribute('x', lx); + t2.setAttribute('y', ly + viewBoxSize/26); + t2.setAttribute('text-anchor', 'middle'); + t2.setAttribute('dominant-baseline', 'middle'); + t2.setAttribute('font-family', 'JetBrains Mono, monospace'); + t2.setAttribute('font-size', isMajor ? viewBoxSize/26 : viewBoxSize/32); + t2.setAttribute('fill', (isCurrent || isPrimary) ? '#EEEEEE' : theme.textDim); + t2.textContent = m; + svg.appendChild(t2); + }); + + // Outer circle border + const outerCircle = document.createElementNS(NS, 'circle'); + outerCircle.setAttribute('cx', 0); + outerCircle.setAttribute('cy', 0); + outerCircle.setAttribute('r', rOuter); + outerCircle.setAttribute('fill', 'none'); + outerCircle.setAttribute('stroke', '#000000'); + outerCircle.setAttribute('stroke-width', '2'); + svg.appendChild(outerCircle); + + // Inner circle border + const innerCircle = document.createElementNS(NS, 'circle'); + innerCircle.setAttribute('cx', 0); + innerCircle.setAttribute('cy', 0); + innerCircle.setAttribute('r', rInner); + innerCircle.setAttribute('fill', 'none'); + innerCircle.setAttribute('stroke', '#000000'); + innerCircle.setAttribute('stroke-width', '2'); + svg.appendChild(innerCircle); + + // Center hole background + const centerHole = document.createElementNS(NS, 'circle'); + centerHole.setAttribute('cx', 0); + centerHole.setAttribute('cy', 0); + centerHole.setAttribute('r', rInner - 1); // slightly smaller to not overlap border + centerHole.setAttribute('fill', theme.bg); + svg.appendChild(centerHole); + + if (currentIdx !== -1) { + const cur = keys[currentIdx]; + const cy1 = -viewBoxSize/25; // center a bit + const c1 = document.createElementNS(NS, 'text'); + c1.setAttribute('x', 0); + c1.setAttribute('y', cy1); + c1.setAttribute('text-anchor', 'middle'); + c1.setAttribute('dominant-baseline', 'middle'); + c1.setAttribute('font-family', 'JetBrains Mono, monospace'); + c1.setAttribute('font-size', viewBoxSize/9); + c1.setAttribute('font-weight', '700'); + c1.setAttribute('fill', window.colorMatrix[cur[0]]); + c1.textContent = cur[0]; + svg.appendChild(c1); + + const c2 = document.createElementNS(NS, 'text'); + c2.setAttribute('x', 0); + c2.setAttribute('y', cy1 + viewBoxSize/8); + c2.setAttribute('text-anchor', 'middle'); + c2.setAttribute('dominant-baseline', 'middle'); + c2.setAttribute('font-family', 'JetBrains Mono, monospace'); + c2.setAttribute('font-size', viewBoxSize/20); + c2.setAttribute('fill', theme.textDim); + c2.textContent = cur[1]; + svg.appendChild(c2); + } else { + // Empty state + const c1 = document.createElementNS(NS, 'text'); + c1.setAttribute('x', 0); + c1.setAttribute('y', 0); + c1.setAttribute('text-anchor', 'middle'); + c1.setAttribute('dominant-baseline', 'middle'); + c1.setAttribute('font-family', 'JetBrains Mono, monospace'); + c1.setAttribute('font-size', viewBoxSize/15); + c1.setAttribute('fill', theme.textDim); + c1.textContent = "N/A"; + svg.appendChild(c1); + } +} + +// Global update function for wheel +window.updateWheel = function(currentKey) { + let compatIdxs = { primary: new Set(), secondary: new Set() }; + if (currentKey && window.mixingRules[currentKey]) { + window.mixingRules[currentKey].slice(1).forEach((targetKey, i) => { + const idx = keys.findIndex(k => k[0] === targetKey); + if (idx < 0) return; + const compatLevel = i + 1; + if (compatLevel === 1 || compatLevel === 2 || compatLevel === 4) { + compatIdxs.primary.add(idx); + } else { + compatIdxs.secondary.add(idx); + } + }); + } + buildWheel('wheel-small', 220, currentKey, compatIdxs); +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..2822a88 --- /dev/null +++ b/server/index.js @@ -0,0 +1,30 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); + +const recognizeRoute = require('./routes/recognize'); +const metadataRoute = require('./routes/metadata'); +const suggestionsRoute = require('./routes/suggestions'); +const coverRoute = require('./routes/cover'); +require('./workers/poll-quota'); // Start quota worker + +const app = express(); +const port = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Serve static frontend +app.use(express.static(path.join(__dirname, '../public'))); +app.use('/shared', express.static(path.join(__dirname, '../shared'))); + +// API Routes +app.use('/api/recognize', recognizeRoute); +app.use('/api/metadata', metadataRoute); +app.use('/api/suggestions', suggestionsRoute); +app.use('/api/cover', coverRoute); + +app.listen(port, () => { + console.log(`KeySelector backend listening on port ${port}`); +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..2c8060e --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "keyselector", + "version": "1.0.0", + "description": "Browser-basiertes DJ-Tool", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "node-fetch": "^3.3.2" + } +} diff --git a/server/routes/cover.js b/server/routes/cover.js new file mode 100644 index 0000000..632809b --- /dev/null +++ b/server/routes/cover.js @@ -0,0 +1,27 @@ +const express = require('express'); +const fetch = require('node-fetch'); +const router = express.Router(); + +// Fetch cover from iTunes API to avoid frontend CORS issues +router.get('/', async (req, res) => { + const q = req.query.q; + if (!q) return res.status(400).json({ error: 'Missing q parameter' }); + + try { + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&media=music&entity=song&limit=1`; + const response = await fetch(url); + const data = await response.json(); + + if (data.results && data.results[0] && data.results[0].artworkUrl100) { + const src = data.results[0].artworkUrl100.replace('100x100bb', '300x300bb'); + return res.json({ url: src }); + } + + res.json({ url: null }); + } catch (err) { + console.error('iTunes cover fetch error', err); + res.status(500).json({ error: 'Server error fetching cover' }); + } +}); + +module.exports = router; diff --git a/server/routes/metadata.js b/server/routes/metadata.js new file mode 100644 index 0000000..61f69bb --- /dev/null +++ b/server/routes/metadata.js @@ -0,0 +1,21 @@ +const express = require('express'); +const router = express.Router(); + +// Mock metadata route +router.get('/', async (req, res) => { + const track = req.query.track || 'Mock Track'; + + // In production, this would call GetSongBPM and Last.fm + // to fetch BPM, Camelot Key, and genre/mood tags. + + res.json({ + status: 'success', + data: { + key: '4B', + bpm: 120, + tags: ['Electronic', 'Synthpop'] + } + }); +}); + +module.exports = router; diff --git a/server/routes/recognize.js b/server/routes/recognize.js new file mode 100644 index 0000000..feae885 --- /dev/null +++ b/server/routes/recognize.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); + +// Mock recognize route +router.post('/', async (req, res) => { + // In production, this would receive an audio buffer, fingerpint it, + // and call the AcoustID API. + + res.json({ + status: 'success', + data: { + title: 'Mock Track', + artist: 'Mock Artist', + matchScore: '95%' + } + }); +}); + +module.exports = router; diff --git a/server/routes/suggestions.js b/server/routes/suggestions.js new file mode 100644 index 0000000..c1e9780 --- /dev/null +++ b/server/routes/suggestions.js @@ -0,0 +1,22 @@ +const express = require('express'); +const router = express.Router(); +// const { mixingRules } = require('../../shared/mixing-rules'); + +// Mock suggestions route +router.get('/', async (req, res) => { + const key = req.query.key; + const bpm = parseInt(req.query.bpm) || 120; + + // In production, this queries the PostgreSQL database populated with AcousticBrainz data + // to find tracks matching the compatible Camelot keys. + + res.json({ + status: 'success', + data: [ + { title: 'Suggestion 1', artist: 'Artist 1', key: '4A', bpm: bpm + 2 }, + { title: 'Suggestion 2', artist: 'Artist 2', key: '3B', bpm: bpm - 1 } + ] + }); +}); + +module.exports = router; diff --git a/server/workers/poll-quota.js b/server/workers/poll-quota.js new file mode 100644 index 0000000..2986a15 --- /dev/null +++ b/server/workers/poll-quota.js @@ -0,0 +1,14 @@ +// Simple worker to simulate quota polling +const ntfyUrl = process.env.NTFY_URL; + +function checkQuota() { + // In production, check API quotas for Last.fm, GetSongBPM, AcoustID + // and send a push notification via ntfy if they run low. + console.log('[Worker] Checking API quotas...'); +} + +// Check daily +setInterval(checkQuota, 24 * 60 * 60 * 1000); + +// Run once on startup +setTimeout(checkQuota, 5000); diff --git a/shared/camelot-angle.js b/shared/camelot-angle.js new file mode 100644 index 0000000..94a2c03 --- /dev/null +++ b/shared/camelot-angle.js @@ -0,0 +1,20 @@ +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, +}; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = codeToAngle; +} else { + window.codeToAngle = codeToAngle; +} diff --git a/shared/camelot-to-musickey.js b/shared/camelot-to-musickey.js new file mode 100644 index 0000000..49085dc --- /dev/null +++ b/shared/camelot-to-musickey.js @@ -0,0 +1,20 @@ +const camelotToMusicKey = { + '1B': 'C', '1A': 'Am', + '2B': 'G', '2A': 'Em', + '3B': 'D', '3A': 'Bm', + '4B': 'A', '4A': 'F#m', + '5B': 'E', '5A': 'C#m', + '6B': 'B', '6A': 'G#m', + '7B': 'F#', '7A': 'D#m', + '8B': 'Db', '8A': 'Bbm', + '9B': 'Ab', '9A': 'Fm', + '10B': 'Eb', '10A': 'Cm', + '11B': 'Bb', '11A': 'Gm', + '12B': 'F', '12A': 'Dm', +}; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = camelotToMusicKey; +} else { + window.camelotToMusicKey = camelotToMusicKey; +} diff --git a/shared/color-matrix.js b/shared/color-matrix.js new file mode 100644 index 0000000..52bf2cf --- /dev/null +++ b/shared/color-matrix.js @@ -0,0 +1,20 @@ +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', +}; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = colorMatrix; +} else { + window.colorMatrix = colorMatrix; +} diff --git a/shared/mixing-rules.js b/shared/mixing-rules.js new file mode 100644 index 0000000..dfef267 --- /dev/null +++ b/shared/mixing-rules.js @@ -0,0 +1,35 @@ +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']; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { mixingRules, compatLabels }; +} else { + window.mixingRules = mixingRules; + window.compatLabels = compatLabels; +}