diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 8bc047f..cdef05a 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -1,4 +1,5 @@ import { useRef, useState, useEffect } from 'react' +import { getKeyColor, getBPMColor } from '../utils/musicColors' interface Clip { id: string @@ -260,18 +261,34 @@ export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploa

{(clip.file_size_bytes / 1024).toFixed(0)} KB

)} - {clip.bpm && ( -
- -

{Math.round(clip.bpm)}

-
- )} - {clip.key && ( -
- -

{clip.key}

-
- )} + {clip.bpm && (() => { + const c = getBPMColor(clip.bpm) + return c ? ( +
+ + + {Math.round(clip.bpm)} + +
+ ) : null + })()} + {clip.key && (() => { + const c = getKeyColor(clip.key) + return c ? ( +
+ + + {clip.key} + +
+ ) : null + })()}

{formatDate(clip.created_at)}

diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx index 3171d5e..02dae7a 100644 --- a/frontend/src/components/Gallery.tsx +++ b/frontend/src/components/Gallery.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useRef } from 'react' +import { getKeyColor, getBPMColor } from '../utils/musicColors' const PAGE_SIZE = 10 @@ -246,16 +247,28 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o {clip.duration_seconds}s {modelName(clip.model)} {formatDate(clip.created_at)} - {clip.bpm && ( - - {Math.round(clip.bpm)} BPM - - )} - {clip.key && ( - - {clip.key} - - )} + {clip.bpm && (() => { + const c = getBPMColor(clip.bpm) + return c ? ( + + {Math.round(clip.bpm)} BPM + + ) : null + })()} + {clip.key && (() => { + const c = getKeyColor(clip.key) + return c ? ( + + {clip.key} + + ) : null + })()}
{clip.status === 'failed' && clip.error_message && (

@@ -386,16 +399,28 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o {clip.duration_seconds}s {modelName(clip.model)} - {clip.bpm && ( - - {Math.round(clip.bpm)} BPM - - )} - {clip.key && ( - - {clip.key} - - )} + {clip.bpm && (() => { + const c = getBPMColor(clip.bpm) + return c ? ( + + {Math.round(clip.bpm)} BPM + + ) : null + })()} + {clip.key && (() => { + const c = getKeyColor(clip.key) + return c ? ( + + {clip.key} + + ) : null + })()} {/* Error */} diff --git a/frontend/src/utils/musicColors.ts b/frontend/src/utils/musicColors.ts new file mode 100644 index 0000000..221d6aa --- /dev/null +++ b/frontend/src/utils/musicColors.ts @@ -0,0 +1,128 @@ +/** + * Music color utilities — Camelot wheel key colors + BPM gradient. + * + * Key colors: Each key maps to a hue based on its Camelot wheel position. + * 12 positions × 30° = full color wheel. Major = bright, minor = slightly darker. + * + * BPM gradient: Two root tempos define the hue range. + * Default: 60 BPM (blue, calm) → 180 BPM (red, intense). + * Anything outside the range clamps to the nearest edge. + */ + +// ─── Camelot Wheel Mapping ─────────────────────────────────────── + +// Standard Camelot wheel: position → key name (as returned by librosa) +const CAMELOT_MAJOR: Record = { + 1: ['B major'], + 2: ['F# major', 'Gb major'], + 3: ['Db major', 'C# major'], + 4: ['Ab major', 'G# major'], + 5: ['Eb major', 'D# major'], + 6: ['Bb major', 'A# major'], + 7: ['F major'], + 8: ['C major'], + 9: ['G major'], + 10: ['D major'], + 11: ['A major'], + 12: ['E major'], +} + +const CAMELOT_MINOR: Record = { + 1: ['Ab minor', 'G# minor'], + 2: ['Eb minor', 'D# minor'], + 3: ['Bb minor', 'A# minor'], + 4: ['F minor'], + 5: ['C minor'], + 6: ['G minor'], + 7: ['D minor'], + 8: ['A minor'], + 9: ['E minor'], + 10: ['B minor'], + 11: ['F# minor', 'Gb minor'], + 12: ['C# minor', 'Db minor'], +} + +// Build reverse lookup: key name (lowercase) → { position, isMajor } +const keyToCamelot = new Map() + +for (const [pos, keys] of Object.entries(CAMELOT_MAJOR)) { + for (const key of keys) { + keyToCamelot.set(key.toLowerCase(), { position: parseInt(pos), isMajor: true }) + } +} +for (const [pos, keys] of Object.entries(CAMELOT_MINOR)) { + for (const key of keys) { + keyToCamelot.set(key.toLowerCase(), { position: parseInt(pos), isMajor: false }) + } +} + +// ─── Key Color ─────────────────────────────────────────────────── + +export function getKeyColor(key: string | null | undefined): { bg: string; text: string; border: string } | null { + if (!key) return null + + const normalized = key.trim().toLowerCase() + const camelot = keyToCamelot.get(normalized) + if (!camelot) return null + + // Hue: position 1 = 0° (red), each step +30° + const hue = ((camelot.position - 1) * 30) % 360 + + // Major: brighter, more saturated. Minor: slightly darker, less saturated. + const saturation = camelot.isMajor ? 85 : 70 + const lightness = camelot.isMajor ? 55 : 50 + + return { + bg: `hsla(${hue}, ${saturation}%, ${lightness}%, 0.15)`, + text: `hsl(${hue}, ${saturation}%, ${lightness}%)`, + border: `hsla(${hue}, ${saturation}%, ${lightness}%, 0.3)`, + } +} + +// ─── BPM Color ───────────────────────────────────────────────────── + +export interface BPMRange { + min: number // slow edge BPM + max: number // fast edge BPM + minHue: number // hue for slow (default: 240 = blue) + maxHue: number // hue for fast (default: 0 = red) +} + +const DEFAULT_BPM_RANGE: BPMRange = { + min: 60, + max: 180, + minHue: 240, // blue — calm, slow + maxHue: 0, // red — intense, fast +} + +export function getBPMColor( + bpm: number | null | undefined, + range: Partial = {} +): { bg: string; text: string; border: string } | null { + if (bpm == null) return null + + const { min, max, minHue, maxHue } = { ...DEFAULT_BPM_RANGE, ...range } + + // Clamp BPM to range + const clamped = Math.max(min, Math.min(max, bpm)) + + // Interpolate hue (handle wrap-around if minHue > maxHue) + let t = (clamped - min) / (max - min) + let hue: number + if (minHue > maxHue) { + // Going across the 0° boundary (e.g., 240 → 0) + const span = (360 - minHue) + maxHue + hue = (minHue + t * span) % 360 + } else { + hue = minHue + t * (maxHue - minHue) + } + + const saturation = 85 + const lightness = 55 + + return { + bg: `hsla(${hue}, ${saturation}%, ${lightness}%, 0.15)`, + text: `hsl(${hue}, ${saturation}%, ${lightness}%)`, + border: `hsla(${hue}, ${saturation}%, ${lightness}%, 0.3)`, + } +}