Files
JamBuddy/src/components/Settings.jsx
T
2026-03-14 20:29:48 +00:00

227 lines
8.5 KiB
React

import { useRef, useEffect, useState } from 'react'
const SETTINGS = [
{
section: 'Chord Detection',
items: [
{
key: 'chromaSmooth',
label: 'Chroma Smoothing',
min: 1, max: 20, step: 1,
desc: 'Frames to average for chord chroma. More = smoother but slower to react to chord changes.',
},
{
key: 'chordVoteThreshold',
label: 'Chord Vote Threshold',
min: 1, max: 8, step: 1,
desc: 'Consecutive identical detections required to confirm a chord. Higher = more stable, slower.',
},
{
key: 'chordMinScore',
label: 'Chord Min Score',
min: 0.10, max: 0.80, step: 0.01,
desc: 'Minimum coverage score to accept a chord match. Lower = more chord types detected (may add false positives).',
},
],
},
{
section: 'Key Detection',
items: [
{
key: 'noteHistorySize',
label: 'Note History Size',
min: 100, max: 12000, step: 100,
desc: 'Pitch readings kept for key detection. ~2000 ≈ 1 min, 12000 ≈ whole session. Larger = more stable key.',
},
{
key: 'keyVoteWindow',
label: 'Key Vote Window',
min: 4, max: 30, step: 1,
desc: 'Rolling window of key votes. Larger = more inertia — key changes need sustained evidence.',
},
{
key: 'keyVoteThreshold',
label: 'Key Vote Threshold',
min: 1, max: 30, step: 1,
desc: 'Votes needed within the window to confirm a key. Higher = stricter consensus required.',
},
{
key: 'chordNoteBoost',
label: 'Chord Note Boost',
min: 0, max: 10, step: 1,
desc: 'Times confirmed chord tones are injected into note history. Higher = chords dominate over transient melody notes.',
},
],
},
{
section: 'Audio Input',
items: [
{
key: 'minClarity',
label: 'Min Pitch Clarity',
min: 0.50, max: 0.99, step: 0.01,
desc: 'Autocorrelation clarity threshold to accept a pitch reading. Higher = only clean, in-tune notes count.',
},
{
key: 'minVolume',
label: 'Min Volume (RMS)',
min: 0.001, max: 0.05, step: 0.001,
desc: 'Minimum signal level before processing. Increase to cut through room noise.',
},
],
},
]
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
// Snapshot on mount so Cancel can restore
const savedConfig = useRef(config)
const savedMono = useRef(monoColor)
function handleCancel() {
Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v))
onMonoColorChange(savedMono.current)
onClose()
}
function DeviceSelector({ config, onChange }) {
const [devices, setDevices] = useState([])
async function refresh() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
const list = await navigator.mediaDevices.enumerateDevices()
setDevices(list.filter(d => d.kind === 'audioinput'))
} catch (e) {
console.warn('enumerateDevices failed', e)
}
}
useEffect(() => { refresh() }, [])
return (
<div className="space-y-3">
<div className="flex gap-3 items-center">
<select
value={config.audioDeviceId ?? ''}
onChange={e => onChange('audioDeviceId', e.target.value === '' ? null : e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors w-full"
>
<option value="">System default</option>
{devices.map((d, i) => (
<option key={d.deviceId || i} value={d.deviceId}>{d.label || `Microphone ${i + 1}`}</option>
))}
</select>
<button onClick={refresh} className="px-3 py-1 rounded-lg border border-border text-sm text-gray-400">Refresh</button>
</div>
<div className="text-xs text-gray-600">If device labels are empty, grant microphone permission first and hit Refresh.</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-xl font-bold text-accent">Detection Settings</h2>
<p className="text-xs text-gray-500 mt-0.5">Tune chord and key recognition sensitivity in real time</p>
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
>
Reset defaults
</button>
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
>
Cancel
</button>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
>
Save
</button>
</div>
</div>
<div className="space-y-8">
{/* ── Display ── */}
<div>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
Display
</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-200">Mono Color Mode</p>
<p className="text-xs text-gray-600 mt-0.5">Use a single purple palette instead of purple + amber for note tiers.</p>
</div>
<button
onClick={() => onMonoColorChange(v => !v)}
className={`relative w-11 h-6 rounded-full transition-colors ${monoColor ? 'bg-accent' : 'bg-gray-700'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${monoColor ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
</div>
{SETTINGS.map(section => (
<div key={section.section}>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
{section.section}
</h3>
<div className="space-y-6">
{section.items.map(item => (
<div key={item.key}>
<div className="flex items-baseline justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-200">{item.label}</label>
<span className="text-sm font-mono text-accent w-20 text-right">
{Number.isInteger(config[item.key])
? config[item.key]
: config[item.key].toFixed(item.step < 0.01 ? 3 : 2)}
</span>
</div>
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
value={config[item.key]}
onChange={e => {
const val = item.step < 1
? parseFloat(e.target.value)
: parseInt(e.target.value, 10)
onChange(item.key, val)
}}
className="w-full accent-purple-500"
/>
<div className="flex justify-between text-xs text-gray-700 mt-0.5">
<span>{item.min}</span>
<span className="text-gray-600 text-center flex-1 px-2">{item.desc}</span>
<span>{item.max}</span>
</div>
</div>
))}
</div>
</div>
))}
{/* Audio device selector */}
<div>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
Microphone
</h3>
<DeviceSelector config={config} onChange={onChange} />
</div>
</div>
</div>
</div>
)
}