Initial commit: KeySelector implementation

This commit is contained in:
Jannik committed 2026-08-26 00:32:17 +02:00
commit 48cdda01ca
30 files changed
+2272

No files matched your search

+5
View File
@@ -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
+8
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+200
View File
@@ -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);
}
+647
View File
@@ -0,0 +1,647 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KeySelector</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ------------------------------------------------------------------ */
/* 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);
}
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<div class="brand">KeySelector</div>
<div class="topbar-right">
<div class="status">
<svg class="mic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span>Listening</span>
</div>
<button class="theme-toggle" id="theme-btn">Light</button>
</div>
</div>
<div class="hero" id="hero">
<div class="cover" id="cover" onclick="toggleCover()" title="Click to expand">
<div class="cover-placeholder">
<span class="note">♪</span>
<span>No Cover</span>
</div>
</div>
<div class="info">
<div class="info-top">
<div class="track-title">Made in Japan</div>
<div class="track-artist">Alphaville</div>
</div>
<div class="info-mid">
<div class="stats-row">
<div class="stat">
<div class="stat-label">Key</div>
<div class="stat-value k">4B</div>
<div class="stat-sub">A-Dur</div>
</div>
<div class="stat">
<div class="stat-label">BPM</div>
<div class="stat-value b">118</div>
<div class="stat-sub">± 2</div>
</div>
<div class="stat">
<div class="stat-label">Match</div>
<div class="stat-value m">98%</div>
<div class="stat-sub">acoustid</div>
</div>
</div>
<div class="tags-row">
<span class="tag tag-key">4B</span>
<span class="tag tag-genre">Synth-Pop</span>
<span class="tag tag-genre">New Wave</span>
<span class="tag tag-genre">80s</span>
<span class="tag tag-mood">Energetic</span>
<span class="tag tag-mood">Retro</span>
<span class="tag tag-mood">Euphoric</span>
</div>
</div>
<div class="info-bot">
<div class="wheel-container" id="wheel-container" onclick="toggleWheel()" title="Click to expand">
<svg viewBox="-110 -110 220 220" id="wheel-small" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</div>
</div>
<button class="close-btn" id="close-btn" onclick="resetHero()" title="Close">×</button>
</div>
<div class="section-title">Up Next</div>
<div class="suggestions" id="suggestions"></div>
</div>
<script>
/* ================================================================== */
/* Camelot Wheel: 24 keys, clockwise from 12B (top) */
/* Indices 0–11: Major (B), außen. Indices 12–23: Minor (A), innen. */
/* ================================================================== */
const keys = [
/* Major (außen) */ /* color */
['12B', 'F', '#2EC4B6'], /* 0 */
['1B', 'C', '#D62828'], /* 1 */
['2B', 'G', '#E77D11'], /* 2 */
['3B', 'D', '#E0B423'], /* 3 */
['4B', 'A', '#4F9D69'], /* 4 ← CURRENT (A-Dur) */
['5B', 'E', '#1B998B'], /* 5 */
['6B', 'B', '#6B4E9C'], /* 6 */
['7B', 'F#', '#3B7DD8'], /* 7 */
['8B', 'Db', '#3461A8'], /* 8 */
['9B', 'Ab', '#5B3A8C'], /* 9 */
['10B', 'Eb', '#B07ECC'], /* 10 */
['11B', 'Bb', '#D64966'], /* 11 */
/* Minor (innen) — direkt innen neben dem entsprechenden Major */
['12A', 'Dm', '#2EC4B6'], /* 12 */
['1A', 'Am', '#D62828'], /* 13 */
['2A', 'Em', '#E77D11'], /* 14 */
['3A', 'Bm', '#E0B423'], /* 15 */
['4A', 'F#m', '#4F9D69'], /* 16 ← kompatibel (relative Moll) */
['5A', 'C#m', '#1B998B'], /* 17 */
['6A', 'G#m', '#6B4E9C'], /* 18 */
['7A', 'D#m', '#3B7DD8'], /* 19 ← kompatibel (Quinte -1) */
['8A', 'Bbm', '#3461A8'], /* 20 */
['9A', 'Fm', '#5B3A8C'], /* 21 */
['10A', 'Cm', '#B07ECC'], /* 22 */
['11A', 'Gm', '#D64966'], /* 23 ← kompatibel (Quinte +1) */
];
const currentKey = '4B';
const currentIdx = keys.findIndex(k => k[0] === currentKey);
/* ------------------------------------------------------------------ */
/* Camelot-Code → Winkel-Position (12 Uhr = 0°, clockwise) */
/* Major und Minor am GLEICHEN Winkel (12B außen + 12A innen) */
/* ------------------------------------------------------------------ */
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,
};
/* ------------------------------------------------------------------ */
/* Farb-Matrix: pro Camelot-Code ein Hex-Wert */
/* Ändern hier = globale Farbänderung für alle 24 Segmente */
/* ------------------------------------------------------------------ */
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',
};
/* ------------------------------------------------------------------ */
/* Mixed In Key Harmonic Mixing Rules */
/* Pro Key: [Perfect, -1, +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'];
/* Strong compat: Quinte ±1 + Scale (relative Moll) */
/* Light compat: Energy + Diagonal + Jaw's + Mood */
const compatIdxs = { primary: new Set(), secondary: new Set() };
mixingRules[currentKey].slice(1).forEach((targetKey, i) => {
const idx = keys.findIndex(k => k[0] === targetKey);
if (idx < 0) return;
const compatLevel = i + 1; // 1=Quinte-1, 2=Quinte+1, 3=Energy, 4=Scale, 5=Diagonal, 6=Jaw's, 7=Mood
if (compatLevel === 1 || compatLevel === 2 || compatLevel === 4) {
compatIdxs.primary.add(idx);
} else {
compatIdxs.secondary.add(idx);
}
});
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',
};
}
/* ================================================================== */
/* Wheel-Builder */
/* viewBox = quadratisch (z. B. -110 -110 220 220) */
/* 24 Segmente à 15°, 12B oben (12 Uhr), clockwise */
/* ================================================================== */
function buildWheel(svgId, viewBoxSize) {
const svg = document.getElementById(svgId);
if (!svg) return;
svg.innerHTML = '';
const NS = 'http://www.w3.org/2000/svg';
const segAngle = 360 / keys.length; // 15°
const rOuter = viewBoxSize / 2 - 4;
const rMid = rOuter * 0.62;
const rInner = rOuter * 0.30;
const theme = getThemeColors();
// Punkt bei Winkel a (von 12 Uhr, im Uhrzeigersinn) und Radius r
// SVG: a=0° = oben (-y), a=90° = rechts (+x)
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;
// Winkel kommt aus dem Camelot-Code (12B+12A am gleichen Winkel)
const a1 = codeToAngle[c];
const a2 = a1 + 15;
const aMid = (a1 + a2) / 2;
// 4 Eckpunkte des Annular-Sektors
const [x1,y1] = p(rOut, a1); // outer-start
const [x2,y2] = p(rOut, a2); // outer-end
const [x3,y3] = p(rIn, a2); // inner-end
const [x4,y4] = p(rIn, a1); // inner-start
// Polygon (annular sector)
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)}`);
if (isCurrent) {
poly.setAttribute('fill', color);
poly.setAttribute('stroke', color);
poly.setAttribute('stroke-width', '0.8');
} else if (isPrimary) {
poly.setAttribute('fill', hexRgba(color, 0.55));
poly.setAttribute('stroke', hexRgba(color, 0.95));
poly.setAttribute('stroke-width', '0.4');
} else if (isSecondary) {
poly.setAttribute('fill', hexRgba(color, 0.28));
poly.setAttribute('stroke', hexRgba(color, 0.55));
poly.setAttribute('stroke-width', '0.35');
} else {
poly.setAttribute('fill', hexRgba(color, 0.10));
poly.setAttribute('stroke', hexRgba(color, 0.30));
poly.setAttribute('stroke-width', '0.3');
}
poly.setAttribute('stroke-linejoin', 'round');
svg.appendChild(poly);
// Labels: Camelot-Code (groß) + Music-Key (klein darunter)
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 ? '#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 ? '#FFFFFF' : theme.textDim);
t2.textContent = m;
svg.appendChild(t2);
});
// Center: Current-Key + Music-Key
const cur = keys[currentIdx];
const [, cy1] = p(rInner * 0.55, 270);
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/11);
c1.setAttribute('font-weight', '700');
c1.setAttribute('fill', cur[2]);
c1.textContent = cur[0];
svg.appendChild(c1);
const c2 = document.createElementNS(NS, 'text');
c2.setAttribute('x', 0);
c2.setAttribute('y', cy1 + viewBoxSize/7);
c2.setAttribute('text-anchor', 'middle');
c2.setAttribute('dominant-baseline', 'middle');
c2.setAttribute('font-family', 'JetBrains Mono, monospace');
c2.setAttribute('font-size', viewBoxSize/22);
c2.setAttribute('fill', theme.textDim);
c2.textContent = cur[1];
svg.appendChild(c2);
}
buildWheel('wheel-small', 220);
/* ================================================================== */
/* Theme Toggle */
/* ================================================================== */
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';
buildWheel('wheel-small', 220);
}
/* ================================================================== */
/* Hero Mode Toggles (inline expand) */
/* ================================================================== */
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');
}
/* ================================================================== */
/* Suggestions: 10 Synth-Pop / New Wave Tracks, sortiert nach */
/* Mixed-In-Key-Kompatibilitäts-Stufe zu 4B (A-Dur). */
/* Stufen: 0=Perfect, 1=-1, 2=+1, 3=Energy, 4=Scale, */
/* 5=Diagonal, 6=Jaw's, 7=Mood */
/* ================================================================== */
const suggestions = [
/* Stufe 0: Perfect (4B = A) */
{ c: '4B', m: 'A', color: '#4F9D69', title: 'Big in Japan', artist: 'Alphaville', bpm: 120, compat: 0 },
{ c: '4B', m: 'A', color: '#4F9D69', title: 'Forever Young', artist: 'Alphaville', bpm: 116, compat: 0 },
{ c: '4B', m: 'A', color: '#4F9D69', title: 'Enola Gay', artist: 'OMD', bpm: 108, compat: 0 },
/* Stufe 1: Quinte -1 (3B = D) */
{ c: '3B', m: 'D', color: '#E0B423', title: 'West End Girls', artist: 'Pet Shop Boys', bpm: 122, compat: 1 },
{ c: '3B', m: 'D', color: '#E0B423', title: 'Tainted Love', artist: 'Soft Cell', bpm: 82, compat: 1 },
/* Stufe 2: Quinte +1 (5B = E) */
{ c: '5B', m: 'E', color: '#1B998B', title: 'Blue Monday', artist: 'New Order', bpm: 130, compat: 2 },
{ c: '5B', m: 'E', color: '#1B998B', title: 'Everything Counts', artist: 'Depeche Mode', bpm: 113, compat: 2 },
/* Stufe 3: Energy (6B = B) */
{ c: '6B', m: 'B', color: '#6B4E9C', title: 'Enjoy the Silence', artist: 'Depeche Mode', bpm: 113, compat: 3 },
/* Stufe 4: Scale (4A = F#m, relative Moll) */
{ c: '4A', m: 'F#m', color: '#4F9D69', title: 'Shout', artist: 'Tears for Fears', bpm: 102, compat: 4 },
/* Stufe 7: Mood (1A = Am) */
{ c: '1A', m: 'Am', color: '#D62828', title: 'Sweet Dreams', artist: 'Eurythmics', bpm: 125, compat: 7 },
];
const sugContainer = document.getElementById('suggestions');
suggestions.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 = `
<div class="sug-cover" id="${idSafe}">
<div class="sug-cover-placeholder">♪</div>
</div>
<div class="sug-meta">
<div class="sug-key" style="background:${s.color}">${s.c}</div>
<div class="sug-bpm">${s.bpm} BPM</div>
</div>
<div>
<div class="sug-title">${s.title}</div>
<div class="sug-artist">${s.artist} · ${s.m}</div>
</div>
<div class="sug-compat">${compatLabels[s.compat]}</div>
`;
sugContainer.appendChild(div);
});
/* ================================================================== */
/* iTunes Search API für Cover-Art (mit Fallback auf Platzhalter) */
/* Funktioniert nur über einen Server wegen CORS, nicht bei file:// */
/* ================================================================== */
async function fetchCover(query, targetEl) {
try {
const 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('itunes error');
const data = await r.json();
if (data.results && data.results[0] && data.results[0].artworkUrl100) {
const src = data.results[0].artworkUrl100.replace('100x100bb', '300x300bb');
targetEl.innerHTML = `<img src="${src}" alt="cover" loading="lazy">`;
}
} catch (e) { /* leave placeholder */ }
}
fetchCover('Alphaville Made in Japan', document.getElementById('cover'));
suggestions.forEach(s => {
const el = document.getElementById(`sug-cover-${s.c.replace(/\s/g,'_')}-${s.title.replace(/[^A-Za-z0-9]/g,'')}`);
if (el) fetchCover(`${s.artist} ${s.title}`, el);
});
</script>
</body>
</html>
+395
View File
@@ -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
```
┌──────────────────────────────────────────────────────────────┐
│ ▌ ┌────────┐ │
│ ▌ │ │ <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.*
+18
View File
@@ -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.
+20
View File
@@ -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);
+7
View File
@@ -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."
+13
View File
@@ -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
+4
View File
@@ -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."
+4
View File
@@ -0,0 +1,4 @@
keyselector.orfel.de {
reverse_proxy keyselector:3000
encode gzip
}
+59
View File
@@ -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);
+36
View File
@@ -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 = `<img src="${src}" alt="cover" loading="lazy">`;
}
} catch (e) {
console.error('Cover fetch failed', e);
}
}
window.fetchCover = fetchCover;
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KeySelector</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="topbar">
<div class="brand">KeySelector</div>
<div class="topbar-right">
<div class="status" id="listening-status" onclick="toggleListening()">
<i class="fa-solid fa-microphone"></i>
<span id="listening-text">Listening</span>
</div>
<button class="theme-toggle" id="theme-btn" onclick="toggleTheme()">Light</button>
</div>
</div>
<div class="hero" id="hero">
<div class="cover" id="cover" onclick="toggleCover()" title="Click to expand">
<div class="cover-placeholder">
<span class="note">♪</span>
<span>No Cover</span>
</div>
</div>
<div class="info">
<div class="info-top">
<button class="search-fallback-btn" onclick="promptManualSearch()"><i class="fa-solid fa-magnifying-glass"></i> Search</button>
<div class="track-title" id="track-title">Waiting...</div>
<div class="track-artist" id="track-artist">for audio input</div>
</div>
<div class="info-mid">
<div class="stats-row">
<div class="stat">
<div class="stat-label">Key</div>
<div class="stat-value k" id="stat-key">-</div>
<div class="stat-sub" id="stat-key-sub">-</div>
</div>
<div class="stat">
<div class="stat-label">BPM</div>
<div class="stat-value b" id="stat-bpm">-</div>
<div class="stat-sub">-</div>
</div>
<div class="stat">
<div class="stat-label">Match</div>
<div class="stat-value m" id="stat-match">-</div>
<div class="stat-sub">acoustid</div>
</div>
</div>
<div class="tags-row" id="tags-row">
<!-- Tags will be populated here -->
</div>
</div>
<div class="info-bot">
<div class="wheel-container" id="wheel-container" onclick="toggleWheel()" title="Click to expand">
<svg viewBox="-110 -110 220 220" id="wheel-small" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</div>
</div>
<button class="close-btn" id="close-btn" onclick="resetHero()" title="Close">×</button>
</div>
<div class="section-title">Up Next</div>
<div class="suggestions" id="suggestions"></div>
</div>
<script src="../shared/mixing-rules.js"></script>
<script src="../shared/color-matrix.js"></script>
<script src="../shared/camelot-angle.js"></script>
<script src="../shared/camelot-to-musickey.js"></script>
<script src="wheel.js"></script>
<script src="cover.js"></script>
<script src="search.js"></script>
<script src="app.js"></script>
</body>
</html>
+118
View File
@@ -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 = '<div style="color:var(--text-dim);font-size:12px;">No suggestions available.</div>';
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 = `
<div class="sug-cover" id="${idSafe}">
<div class="sug-cover-placeholder">♪</div>
</div>
<div class="sug-meta">
<div class="sug-key" style="background:${s.color}">${s.c}</div>
<div class="sug-bpm">${s.bpm} BPM</div>
</div>
<div>
<div class="sug-title">${s.title}</div>
<div class="sug-artist">${s.artist} · ${s.m}</div>
</div>
<div class="sug-compat">${window.compatLabels[s.compat]}</div>
`;
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);
+198
View File
@@ -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);
}
+213
View File
@@ -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);
};
+30
View File
@@ -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}`);
});
+15
View File
@@ -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"
}
}
+27
View File
@@ -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;
+21
View File
@@ -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;
+19
View File
@@ -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;
+22
View File
@@ -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;
+14
View File
@@ -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);
+20
View File
@@ -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;
}
+20
View File
@@ -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;
}
+20
View File
@@ -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;
}
+35
View File
@@ -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;
}