Initial commit: KeySelector implementation
This commit is contained in:
commit
48cdda01ca
30 files changed
+2272
No files matched your search
@@ -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
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -0,0 +1,4 @@
|
||||
keyselector.orfel.de {
|
||||
reverse_proxy keyselector:3000
|
||||
encode gzip
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user