10 Commits

Author SHA1 Message Date
vadimwit 4a45e82abe Merge branch 'main' of https://github.com/whattheflat/whattheflat 2026-03-17 22:41:35 +00:00
vadimwit b94f79f408 additions formatting 2026-03-17 22:41:12 +00:00
James Twose 7d5ad306b4 updating the build productName to JamBuddy 2026-03-14 21:03:31 +00:00
James Twose 2efc785694 adding more build info to the README and bumping version - v2 2026-03-14 20:56:02 +00:00
James Twose 3ddf854123 adding more build info to the README and bumping version 2026-03-14 20:47:05 +00:00
James Twose a1048b0e55 attempt 1 at redoing the release yaml 2026-03-14 20:43:18 +00:00
James Twose 2ce6d1e244 adding mic selection in the settings 2026-03-14 20:29:48 +00:00
vadimwit dec0fc1595 restructuring and layout improvements + save config locally 2026-03-10 12:06:29 +00:00
vadimwit b989238373 debugview and loop improvements 2026-03-10 00:21:40 +00:00
vadimwit 3aadf1afbb Bass fretboard addition 2026-03-09 22:47:41 +00:00
15 changed files with 1212 additions and 154 deletions
+59 -23
View File
@@ -9,33 +9,69 @@ permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:win
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
WIN_CSC_LINK: ''
- uses: softprops/action-gh-release@v2
with:
files: releases/*.exe
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Ensures one OS failing doesn't kill the others
matrix:
include:
- os: windows-latest
command: npm run electron:build:win
artifact_pattern: "release/*.exe"
- os: macos-latest
# Added --universal here if you want to support both Intel and Apple Silicon
command: npm run electron:build:mac -- --universal
artifact_pattern: "release/*.dmg"
- os: ubuntu-latest
command: npm run electron:build:linux
artifact_pattern: "release/*.AppImage"
env:
# This fixes the "GitHub Personal Access Token is not set" error
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prevents errors related to missing code-signing certificates
CSC_IDENTITY_AUTO_DISCOVERY: false
build-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:mac
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- uses: softprops/action-gh-release@v2
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build Application
run: ${{ matrix.command }}
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
files: releases/*.dmg
name: artifacts-${{ matrix.os }}
path: ${{ matrix.artifact_pattern }}
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
# This downloads all "artifacts-*" into a folder named 'all-outputs'
path: all-outputs
merge-multiple: true
- name: List files for debugging
run: ls -R all-outputs
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
# Point directly to the folder where all OS builds are merged
files: all-outputs/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+36
View File
@@ -70,6 +70,42 @@ npm run electron:build:linux
Output is placed in `frontend/release/`.
## Releasing / Tagging
To create a GitHub release and trigger the CI build pipeline, create an annotated tag and push it to origin. The release workflow runs on tags matching `v*` (for example `v0.6.1`).
Local tagging example:
```bash
# update package.json version first if desired
git tag -a v0.6.1 -m "Release v0.6.1"
git push origin v0.6.1
```
What the GitHub Action does (`.github/workflows/release.yml`):
- Listens for pushed tags `v*` and runs a matrix build across Windows, macOS and Linux.
- macOS is built as a universal binary (`--universal`) so a single DMG supports both Intel and Apple Silicon.
- Each matrix job builds the installer using `electron-builder`, uploads its artifacts, and a final `publish` job aggregates all artifacts into one GitHub release.
If you prefer to run builds locally before tagging, use the npm scripts in the repository root:
```bash
# Windows NSIS
npm run electron:build:win
# macOS DMG (universal)
npm run electron:build:mac -- --universal
# Linux AppImage
npm run electron:build:linux
```
CI notes / troubleshooting
- The workflow uploads artifacts from `release/` into the release. Ensure `package.json` build `directories.output` matches the workflow's expected `release/` folder.
- If mac packaging for x64 on ARM-hosted runners fails, switch to `--universal` (already configured) or build x64 on an Intel runner.
- To test the workflow locally, consider using `nektos/act` or push a temporary tag like `vtest`.
## Design Tokens
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
+1 -1
View File
@@ -23,7 +23,7 @@ function createWindow() {
height: 900,
minWidth: 620,
minHeight: 600,
title: 'WhatTheFlat',
title: 'WhatTheFlat ♭? - JamBuddy',
icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://localhost:5173 ws://localhost:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<title>WhatTheFlat</title>
<title>WhatTheFlat ♭? - JamBuddy</title>
</head>
<body>
<div id="root"></div>
+68 -4
View File
@@ -1,12 +1,12 @@
{
"name": "whattheflat",
"version": "0.1.0",
"name": "jambuddy",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whattheflat",
"version": "0.1.0",
"name": "jambuddy",
"version": "0.6.2",
"dependencies": {
"audiomotion-analyzer": "^4.5.4",
"pitchy": "^4.1.0",
@@ -2189,6 +2189,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "whattheflat",
"name": "jambuddy",
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
"version": "0.6.0",
"version": "0.6.2",
"private": true,
"type": "module",
"main": "electron/main.cjs",
@@ -13,7 +13,7 @@
"electron:build": "vite build && electron-builder",
"electron:build:win": "vite build && electron-builder --win --publish never",
"electron:build:mac": "vite build && electron-builder --mac --publish never",
"electron:build:linux": "vite build && electron-builder --linux"
"electron:build:linux": "vite build && electron-builder --linux --publish never"
},
"dependencies": {
"audiomotion-analyzer": "^4.5.4",
@@ -34,8 +34,8 @@
"wait-on": "^9.0.4"
},
"build": {
"appId": "com.whattheflat.app",
"productName": "WhatTheFlat",
"appId": "com.jambuddy.app",
"productName": "JamBuddy",
"files": [
"dist/**/*",
"electron/**/*",
+108 -58
View File
@@ -3,13 +3,14 @@ import AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner'
import ProgressionSuggestions from './components/ProgressionSuggestions'
import Fretboard from './components/Fretboard'
import BassFretboard from './components/BassFretboard'
import Tuner from './components/Tuner'
import Piano from './components/Piano'
import Settings from './components/Settings'
import DebugView from './components/DebugView'
import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import settingIcon from './assets/setting-icon.png'
import viewIcon from './assets/view.png'
const DEFAULTS = {
// Key detection
@@ -24,13 +25,20 @@ const DEFAULTS = {
// Audio input
minClarity: 0.80,
minVolume: 0.01,
// Selected device (null = system default)
audioDeviceId: null,
}
function loadStored(key, fallback) {
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback }
catch { return fallback }
}
export default function App() {
// ── Config ───────────────────────────────────────────────────────────────────
const [config, setConfig] = useState(DEFAULTS)
const configRef = useRef(DEFAULTS)
useEffect(() => { configRef.current = config }, [config])
const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) }))
const configRef = useRef(config)
useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config])
const [showSettings, setShowSettings] = useState(false)
@@ -42,10 +50,11 @@ export default function App() {
const [isListening, setIsListening] = useState(false)
// ── Instrument view + tuner ───────────────────────────────────────────────────
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'piano'
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
const [showTuner, setShowTuner] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const [monoColor, setMonoColor] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const [showDrumView, setShowDrumView] = useState(false)
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
// ── Mic permission error ──────────────────────────────────────────────────────
const [micError, setMicError] = useState(null)
@@ -54,11 +63,17 @@ export default function App() {
const [debugChroma, setDebugChroma] = useState(null)
const [debugCandidates, setDebugCandidates] = useState([])
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
const [debugWaveform, setDebugWaveform] = useState(null)
// ── Stable refs for values used inside callbacks ──────────────────────────────
const showDebugRef = useRef(showDebug)
const lockedKeyRef = useRef(null)
const showDebugRef = useRef(showDebug)
const showDrumViewRef = useRef(showDrumView)
const lockedKeyRef = useRef(null)
const listenStartRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView])
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
// ── BPM estimation from onset timestamps ─────────────────────────────────────
const [bpm, setBpm] = useState(null)
@@ -91,6 +106,7 @@ export default function App() {
const chromaIdxRef = useRef(0)
const chordVotesRef = useRef([])
const progressionVoteRef = useRef(null)
const progressionMissRef = useRef(0)
const pendingKeyRef = useRef(null)
// Keep refs in sync
@@ -108,7 +124,16 @@ export default function App() {
// ── Detect progression — require 2 consecutive identical results to commit ────
useEffect(() => {
const detected = detectRepeatingProgression(chordHistory)
if (!detected) return
if (!detected) {
progressionMissRef.current++
// Clear stale loop after 4 chord changes with no pattern found
if (progressionMissRef.current >= 4) {
setDetectedProgression(null)
progressionVoteRef.current = null
}
return
}
progressionMissRef.current = 0
const key = detected.join(',')
if (progressionVoteRef.current === key) {
setDetectedProgression(detected)
@@ -124,11 +149,13 @@ export default function App() {
keyVotesRef.current = []
chordVotesRef.current = []
progressionVoteRef.current = null
progressionMissRef.current = 0
pendingKeyRef.current = null
chromaIdxRef.current = 0
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
onsetTimestampsRef.current = []
bpmSmoothRef.current = null
listenStartRef.current = Date.now()
setKeyInfo(null)
setLockedKey(null)
effectiveKeyRef.current = null
@@ -140,6 +167,7 @@ export default function App() {
setDebugChroma(null)
setDebugCandidates([])
setDebugNoteAnalysis(null)
setDebugWaveform(null)
}
// ── Key lock handlers ─────────────────────────────────────────────────────────
@@ -162,6 +190,13 @@ export default function App() {
effectiveKeyRef.current = keyInfo
}
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
const handleWaveform = useCallback((data) => {
if (showDebugRef.current || showDrumViewRef.current) {
setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] })
}
}, [])
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
const handleNote = useCallback(({ pitchClass }) => {
const cfg = configRef.current
@@ -173,7 +208,11 @@ export default function App() {
const result = detectKey(history)
setTopKeyCandidates(detectTopKeys(history))
if (showDebugRef.current) setDebugNoteAnalysis(getNoteHistoryAnalysis(history))
if (showDebugRef.current) {
const analysis = getNoteHistoryAnalysis(history)
analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0
setDebugNoteAnalysis(analysis)
}
if (result.confidence < 0.5) return
const votes = keyVotesRef.current
@@ -225,7 +264,7 @@ export default function App() {
if (showDebugRef.current) {
setDebugChroma([...avg])
setDebugCandidates(getChordCandidates(avg, key, bassPC))
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
}
// Stability gate — if chroma is still changing across frames, we're mid-transition.
@@ -236,10 +275,7 @@ export default function App() {
for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
}
if (maxVar > 0.05) {
chordVotesRef.current = []
return
}
if (maxVar > 0.05) return
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
if (!chord) {
@@ -255,7 +291,7 @@ export default function App() {
const winner = votes[0]
setChordHistory(prev => {
if (prev[prev.length - 1] === winner) return prev
return [...prev.slice(-30), winner]
return [...prev.slice(-48), winner]
})
// Inject chord tones into note history to anchor key detection
@@ -322,7 +358,9 @@ export default function App() {
config={config}
onChange={updateConfig}
onClose={() => setShowSettings(false)}
onReset={() => setConfig(DEFAULTS)}
onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
monoColor={monoColor}
onMonoColorChange={setMonoColor}
/>
)
}
@@ -334,28 +372,11 @@ export default function App() {
<header className="mb-2 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span>
WhatTheFlat <span className="text-gray-600">&#9837;?</span> <span className="text-amber-400">- JamBuddy</span>
</h1>
<p className="text-xs text-gray-600">Real-time key detection for live jams</p>
</div>
<div className="flex gap-2 items-center">
<button
onClick={() => setMonoColor(v => !v)}
className={`p-2 rounded-full border transition-all ${monoColor ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
title="Mono color mode"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ opacity: 0.75 }}>
<circle cx="6" cy="10" r="4" fill={monoColor ? '#a855f7' : '#a855f7'} />
<circle cx="13" cy="10" r="4" fill={monoColor ? '#c084fc' : '#f59e0b'} />
</svg>
</button>
<button
onClick={() => setShowDebug(v => !v)}
className={`p-2 rounded-full border transition-all ${showDebug ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
title="Behind the scenes"
>
<img src={viewIcon} alt="Debug view" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
</button>
<button
onClick={() => setShowSettings(true)}
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
@@ -393,8 +414,9 @@ export default function App() {
onChange={e => setInstrument(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"
>
<option value="guitar">Guitar</option>
<option value="piano">Piano</option>
<option value="guitar">Guitar</option>
<option value="bass">Bass</option>
</select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
@@ -490,9 +512,11 @@ export default function App() {
onNote={handleNote}
onChroma={handleChroma}
onOnset={handleOnset}
onWaveform={handleWaveform}
isListening={isListening}
minClarity={config.minClarity}
minVolume={config.minVolume}
audioDeviceId={config.audioDeviceId}
onPermissionError={() => {
setMicError(true)
setIsListening(false)
@@ -517,10 +541,9 @@ export default function App() {
{/* ── Instrument + progressions row ── */}
<div className="flex gap-3 mb-3 items-stretch">
<div className="w-full lg:w-[70%] min-w-0">
{instrument === 'guitar'
? <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />
: <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />
}
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
</div>
<div className="hidden lg:block w-[30%] min-w-0 relative">
@@ -530,30 +553,57 @@ export default function App() {
</div>
</div>
{/* ── Behind the scenes debug view ── */}
{showDebug && (
<div className="mb-3">
<DebugView
chroma={debugChroma}
chordCandidates={debugCandidates}
noteAnalysis={debugNoteAnalysis}
keyInfo={effectiveKey}
currentChord={currentChord}
instrument={instrument}
/>
</div>
)}
{/* ── Behind the scenes — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowDebug(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>BEHIND THE SCENES</span>
<span>{showDebug ? '▲' : '▼'}</span>
</button>
{showDebug && (
<div className="border-t border-border p-4">
<DebugView
chroma={debugChroma}
chordCandidates={debugCandidates}
noteAnalysis={debugNoteAnalysis}
waveform={debugWaveform}
keyInfo={effectiveKey}
currentChord={currentChord}
instrument={instrument}
monoColor={monoColor}
/>
</div>
)}
</div>
{/* ── Rhythm / drum analyser — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowDrumView(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>RHYTHM ANALYSER</span>
<span>{showDrumView ? '▲' : '▼'}</span>
</button>
{showDrumView && (
<div className="border-t border-border p-4">
<DrumView waveform={debugWaveform} bpm={bpm} />
</div>
)}
</div>
{/* ── Tuner — collapsible ── */}
<div>
<div className="bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowTuner(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all"
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>Tuner</span>
<span>TUNER</span>
<span>{showTuner ? '▲' : '▼'}</span>
</button>
{showTuner && <div className="mt-2"><Tuner /></div>}
{showTuner && <div className="border-t border-border"><Tuner /></div>}
</div>
</div>
)
+80 -2
View File
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
return ((bestMidi % 12) + 12) % 12
}
export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null }) {
const audioCtxRef = useRef(null)
const timeBufRef = useRef(null)
const freqBufRef = useRef(null)
@@ -94,20 +94,24 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
const onNoteRef = useRef(onNote)
const onChromaRef = useRef(onChroma)
const onOnsetRef = useRef(onOnset)
const onWaveformRef = useRef(onWaveform)
const onPermissionErrorRef = useRef(onPermissionError)
const minClarityRef = useRef(minClarity)
const minVolumeRef = useRef(minVolume)
const smoothRmsRef = useRef(0)
const lastOnsetRef = useRef(0)
const specPeakRef = useRef(null) // peak-hold spectrum for display lingering
useEffect(() => { onNoteRef.current = onNote }, [onNote])
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
const stop = useCallback(() => {
activeRef.current = false
specPeakRef.current = null
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
@@ -117,8 +121,34 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
stop()
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Helpful debug: list available media devices before requesting permission
try {
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
const devices = await navigator.mediaDevices.enumerateDevices()
const audioIns = devices.filter(d => d.kind === 'audioinput')
console.log('Audio inputs available:', audioIns)
}
} catch (e) {
console.warn('enumerateDevices failed', e)
}
const constraints = audioDeviceId
? { audio: { deviceId: { exact: audioDeviceId } } }
: { audio: true }
console.log('Requesting getUserMedia with constraints:', constraints)
stream = await navigator.mediaDevices.getUserMedia(constraints)
} catch (err) {
// If permission denied or other error, surface extra diagnostics when possible
console.warn('getUserMedia failed', err)
try {
if (navigator.permissions && navigator.permissions.query) {
const p = await navigator.permissions.query({ name: 'microphone' })
console.log('microphone permission state:', p.state)
}
} catch (e) {
// ignore; not all environments support Permissions API for microphone
}
onPermissionErrorRef.current?.(err)
return
}
@@ -131,6 +161,14 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
audioCtxRef.current = ctx
const source = ctx.createMediaStreamSource(stream)
// Debug: log the acquired audio tracks and labels/deviceIds
try {
const tracks = stream.getAudioTracks()
console.log('Acquired audio tracks:', tracks.map(t => ({ label: t.label, id: t.id, enabled: t.enabled, muted: t.muted })))
} catch (e) {
console.warn('Could not inspect stream tracks', e)
}
// Small analyser — pitch detection needs fast time-domain data
const pa = ctx.createAnalyser()
pa.fftSize = PITCH_FFT
@@ -163,6 +201,46 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
onOnsetRef.current?.()
}
// Always fire waveform callback — downsample 4096 → 512 points + log-binned spectrum
if (onWaveformRef.current) {
const stride = 8 // 4096 / 8 = 512 points
const wave = new Float32Array(PITCH_FFT / stride)
for (let i = 0; i < wave.length; i++) wave[i] = timeBuf[i * stride]
// Log-binned frequency spectrum: 256 bins from 40 Hz → 4000 Hz
const LOG_BINS = 256
const F_MIN = 40, F_MAX = 4000
const binHz = ctx.sampleRate / ca.fftSize
const freqBuf = freqBufRef.current
ca.getFloatFrequencyData(freqBuf)
const spectrum = new Float32Array(LOG_BINS)
for (let b = 0; b < LOG_BINS; b++) {
const f = F_MIN * Math.pow(F_MAX / F_MIN, b / (LOG_BINS - 1))
const bin = Math.round(f / binHz)
if (bin < freqBuf.length) {
const db = freqBuf[bin]
spectrum[b] = db < NOISE_FLOOR ? 0 : Math.max(0, (db - NOISE_FLOOR) / (-NOISE_FLOOR))
}
}
// Peak-hold with exponential decay — spectrum rises instantly, falls slowly
if (!specPeakRef.current) specPeakRef.current = new Float32Array(LOG_BINS)
const peak = specPeakRef.current
for (let b = 0; b < LOG_BINS; b++) {
peak[b] = spectrum[b] > peak[b] ? spectrum[b] : peak[b] * 0.92
}
let detectedFreq = null, detectedNote = null
if (rms >= minVolumeRef.current) {
const [f, c] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (c >= minClarityRef.current && f > 60 && f < 4200) {
detectedFreq = f
detectedNote = NOTES[((Math.round(12 * Math.log2(f / 440) + 69) % 12) + 12) % 12]
}
}
onWaveformRef.current({ wave, rms, detectedFreq, detectedNote, spectrum: peak })
}
if (rms >= minVolumeRef.current) {
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
+149
View File
@@ -0,0 +1,149 @@
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
// Standard bass tuning (top of diagram = highest string)
const STRINGS = [
{ label: 'G', root: 7, thickness: 1.5 },
{ label: 'D', root: 2, thickness: 2 },
{ label: 'A', root: 9, thickness: 2.5 },
{ label: 'E', root: 4, thickness: 3 },
]
const NUM_FRETS = 13
const FRET_MARKERS = [3, 5, 7, 9]
const DOUBLE_MARKER = 12
// Layout
const NUT_X = 40
const OPEN_X = 18
const FRET_W = 52
const STRING_H = 36 // wider spacing than guitar — 4 strings feel more spread
const PAD_T = 28
const PAD_B = 18
const BOARD_W = NUT_X + (NUM_FRETS - 1) * FRET_W + 10
const BOARD_H = PAD_T + 3 * STRING_H + PAD_B
const DOT_R = 10
const fretX = f => NUT_X + (f - 0.5) * FRET_W
const stringY = si => PAD_T + si * STRING_H
function noteColor(isChordTone, isPenta, isScale, mono = false) {
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
return null
}
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
const chordSet = currentChord
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
: new Set()
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Bass {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
<div>
<svg
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
width="100%"
height="auto"
style={{ display: 'block' }}
>
{/* Fretboard background */}
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
fill="#1a120b" rx={2} />
{/* Position marker dots (centred between strings 12) */}
{FRET_MARKERS.map(f => (
<circle key={f}
cx={fretX(f)} cy={PAD_T + 1.5 * STRING_H}
r={5} fill="#3a2a1a" />
))}
{/* Double dot at 12 */}
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 0.5 * STRING_H} r={5} fill="#3a2a1a" />
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 2.5 * STRING_H} r={5} fill="#3a2a1a" />
{/* Fret lines */}
{Array.from({ length: NUM_FRETS - 1 }, (_, i) => i + 1).map(f => (
<line key={f}
x1={NUT_X + f * FRET_W} y1={PAD_T - 6}
x2={NUT_X + f * FRET_W} y2={PAD_T + 3 * STRING_H + 6}
stroke={f === DOUBLE_MARKER ? '#888' : '#4a3a2a'}
strokeWidth={f === DOUBLE_MARKER ? 2 : 1} />
))}
{/* Nut */}
<line x1={NUT_X} y1={PAD_T - 6} x2={NUT_X} y2={PAD_T + 3 * STRING_H + 6}
stroke="#c0b090" strokeWidth={4} />
{/* Strings — thicker as pitch drops */}
{STRINGS.map((s, si) => (
<line key={si}
x1={OPEN_X - DOT_R - 2} y1={stringY(si)}
x2={BOARD_W - 8} y2={stringY(si)}
stroke="#9ca3af"
strokeWidth={s.thickness} />
))}
{/* Fret numbers */}
{[3, 5, 7, 9, 12].map(f => (
<text key={f}
x={fretX(f)} y={PAD_T - 10}
textAnchor="middle" fontSize={10} fill="#6b7280"
>{f}</text>
))}
{/* String labels */}
{STRINGS.map((s, si) => (
<text key={si}
x={6} y={stringY(si) + 4}
textAnchor="middle" fontSize={10} fill="#6b7280"
>{s.label}</text>
))}
{/* Note dots */}
{STRINGS.flatMap((str, si) =>
Array.from({ length: NUM_FRETS }, (_, fi) => {
const pc = (str.root + fi) % 12
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
if (!color) return null
const cx = fi === 0 ? OPEN_X : fretX(fi)
const cy = stringY(si)
return (
<g key={`${si}-${fi}`}>
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
<text
x={cx} y={cy + 4}
textAnchor="middle"
fontSize={9}
fontWeight="600"
fill={color.text}
>
{NOTES[pc]}
</text>
</g>
)
})
)}
</svg>
</div>
<div className="mt-3 flex gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
</div>
</div>
)
}
+370 -27
View File
@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { getScale, getChordTones, NOTES } from '../lib/theory'
// ─── SVG Piano — 2 octaves (C3B4) ───────────────────────────────────────────
@@ -14,7 +15,7 @@ const BLACK_OCT = [
]
const WHITE_LABELS = ['C3','D3','E3','F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4']
function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false }) {
function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false, monoColor = false }) {
const max = Math.max(...values, 0.01)
const wKeys = []
const bKeys = []
@@ -35,7 +36,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
const fillColor = inChord
? `rgba(167,139,250,${0.12 + energy * 0.88})`
: inKey
? `rgba(251,191,36,${0.1 + energy * 0.7})`
? monoColor ? `rgba(192,132,252,${0.1 + energy * 0.7})` : `rgba(251,191,36,${0.1 + energy * 0.7})`
: `rgba(180,180,190,${0.05 + energy * 0.2})`
const pct = showPct ? Math.round(values[pc] * 100) : 0
@@ -43,7 +44,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
<g key={`w${wi}`}>
<rect x={x+1} y={3} width={KEY_W-2} height={keyH}
rx={3} fill="rgb(20,20,26)" stroke="rgba(255,255,255,0.08)" strokeWidth={1} />
{energy > 0.04 && (
{energy > (inChord || inKey ? 0.12 : 0.35) && (
<rect
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
@@ -55,7 +56,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
</text>
{showPct && pct > 0 && (
<text x={x + KEY_W/2} y={keyH + 14} textAnchor="middle" fontSize={8}
fill={inKey ? 'rgb(251,191,36)' : 'rgba(100,100,110,0.8)'}>
fill={inKey ? (monoColor ? 'rgb(192,132,252)' : 'rgb(251,191,36)') : 'rgba(100,100,110,0.8)'}>
{pct}%
</text>
)}
@@ -69,17 +70,34 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
const inChord = chordNotes?.has(pc)
const inKey = keyNotes?.has(pc)
const x = wi * KEY_W + KEY_W - BLACK_W / 2
const bg = inChord
? `rgba(139,92,246,${0.4 + energy * 0.6})`
const fillColor = inChord
? 'rgba(139,92,246,0.9)'
: inKey
? `rgba(180,130,0,${0.35 + energy * 0.55})`
: `rgba(12,12,16,0.95)`
? monoColor ? 'rgba(192,132,252,0.85)' : 'rgba(180,130,0,0.85)'
: 'rgba(70,70,80,0.75)'
const pct = showPct ? Math.round(values[pc] * 100) : 0
return (
<g key={`b${i}`}>
{/* Base */}
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
rx={2} fill={bg} stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
<text x={x + BLACK_W/2} y={BLACK_H - 5} textAnchor="middle" fontSize={7}
rx={2} fill="rgb(14,14,18)" stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
{/* Partial fill from bottom — same mechanic as white keys */}
{energy > (inChord || inKey ? 0.05 : 0.35) && (
<rect
x={x} y={3 + BLACK_H * (1 - Math.min(energy, 1) * 0.9)}
width={BLACK_W} height={BLACK_H * Math.min(energy, 1) * 0.9}
rx={1} fill={fillColor} />
)}
{/* % label near top of key (inside) */}
{showPct && pct > 0 && (
<text x={x + BLACK_W/2} y={3 + 10} textAnchor="middle" fontSize={7}
fill={inKey || inChord ? 'rgba(220,220,230,0.9)' : 'rgba(110,110,120,0.7)'}>
{pct}%
</text>
)}
{/* Note name near bottom of key */}
<text x={x + BLACK_W/2} y={3 + BLACK_H - 5} textAnchor="middle" fontSize={7}
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
{NOTES[pc]}
</text>
@@ -113,7 +131,7 @@ const MF_H = MF_PAD_T + 5 * MF_STR_H + MF_PAD_B
const mfFretX = f => MF_NUT_X + (f - 0.5) * MF_FRET_W
const mfStringY = si => MF_PAD_T + si * MF_STR_H
function MiniFretboard({ values, keyNotes, chordNotes }) {
function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
const max = Math.max(...values, 0.01)
return (
@@ -170,7 +188,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
const energy = values[pc] / max
const inChord = chordNotes?.has(pc)
const inKey = keyNotes?.has(pc)
if (!inChord && !inKey && energy < 0.12) return null
if (!inChord && !inKey && energy < 0.35) return null
if ((inChord || inKey) && energy < 0.08) return null
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
const cy = mfStringY(si)
@@ -180,8 +199,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
fill = `rgba(168,85,247,${0.3 + energy * 0.7})`
textFill = '#fff'
} else if (inKey) {
fill = `rgba(245,158,11,${0.2 + energy * 0.75})`
textFill = 'rgba(0,0,0,0.85)'
fill = monoColor ? `rgba(192,132,252,${0.2 + energy * 0.75})` : `rgba(245,158,11,${0.2 + energy * 0.75})`
textFill = monoColor ? '#fff' : 'rgba(0,0,0,0.85)'
} else {
fill = `rgba(100,100,120,${energy * 0.7})`
textFill = 'rgba(180,180,190,0.7)'
@@ -191,7 +210,7 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
<g key={`${si}-${fi}`}>
{energy > 0.3 && (inChord || inKey) && (
<circle cx={cx} cy={cy} r={MF_DOT_R + 4}
fill={inChord ? 'rgba(168,85,247,0.25)' : 'rgba(245,158,11,0.2)'}
fill={inChord ? 'rgba(168,85,247,0.25)' : monoColor ? 'rgba(192,132,252,0.2)' : 'rgba(245,158,11,0.2)'}
style={{ filter: 'blur(4px)' }} />
)}
<circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} />
@@ -206,27 +225,338 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
)
}
// ─── Oscilloscope strip ───────────────────────────────────────────────────────
const OSC_W = 600
const OSC_H = 110
function Oscilloscope({ waveform }) {
const { wave, rms, detectedFreq, detectedNote } = waveform || {}
const silent = !rms || rms < 0.005
// ── Note scroll history — last 5 distinct notes ───────────────────────────
const noteHistoryRef = useRef([]) // [{ note, freq, id }, ...] oldest first
const lastNoteRef = useRef(null)
const noteIdRef = useRef(0)
const lastDisplayRef = useRef(null) // last detected note shown in header — never flickers
if (detectedNote && detectedNote !== lastNoteRef.current) {
lastNoteRef.current = detectedNote
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
noteHistoryRef.current.push({ note: detectedNote, freq: detectedFreq, id: noteIdRef.current++ })
if (noteHistoryRef.current.length > 5) noteHistoryRef.current.shift()
} else if (detectedFreq && detectedNote) {
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
}
// ── Ghost waveform — holds the last clear-pitch shape, fades slowly ───────
const ghostRef = useRef({ path: '', fill: '', opacity: 0 })
if (detectedFreq) {
ghostRef.current = { path: '', fill: '', opacity: 1 } // will be filled below
} else {
ghostRef.current = { ...ghostRef.current, opacity: ghostRef.current.opacity * 0.97 }
}
let path = '', sinePath = ''
if (wave?.length) {
const mid = OSC_H / 2
const waveAmp = Math.max(...wave.map(Math.abs), 0.001)
const gain = Math.min((OSC_H * 0.44) / waveAmp, OSC_H * 0.44)
const lo = Math.floor(wave.length / 4)
const hi = Math.floor(wave.length / 2)
let offset = lo
for (let i = lo; i < hi - 1; i++) {
if (wave[i] <= 0 && wave[i + 1] > 0) { offset = i; break }
}
const drawLen = Math.min(wave.length - offset, Math.floor(wave.length * 0.85))
const step = OSC_W / drawLen
path = Array.from({ length: drawLen }, (_, i) => {
const v = wave[offset + i]
return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}`
}).join(' ')
// Capture ghost path when we have a clear pitch
if (detectedFreq) {
ghostRef.current.path = path
ghostRef.current.fill = path + ` L${OSC_W},${mid} L0,${mid} Z`
}
if (detectedFreq) {
const effectiveSR = 44100 / 8
const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38)
sinePath = Array.from({ length: 300 }, (_, i) => {
const t = i / 299
const x = (t * OSC_W).toFixed(1)
const phase = ((offset + t * drawLen) / effectiveSR) * detectedFreq * Math.PI * 2
const y = (mid - Math.sin(phase) * sineAmp).toFixed(1)
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
}
}
const lineColor = detectedFreq
? 'rgba(168,85,247,0.9)'
: silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)'
const ghost = ghostRef.current
const ghostOp = ghost.opacity
const noteHistory = noteHistoryRef.current
const lastDisplay = lastDisplayRef.current
return (
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope raw mic input</p>
<div className="flex items-center gap-3">
{lastDisplay && (
<>
<span className={`text-xs font-bold ${detectedFreq ? 'text-accent' : 'text-gray-500'}`}>{lastDisplay.note}</span>
<span className="text-xs text-gray-500 tabular-nums">{lastDisplay.freq.toFixed(1)} Hz</span>
<span className="text-xs text-gray-600 tabular-nums">{(1000 / lastDisplay.freq).toFixed(2)} ms / cycle</span>
</>
)}
{silent && <span className="text-xs text-gray-700">silence</span>}
<span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span>
</div>
</div>
<svg viewBox={`0 0 ${OSC_W} ${OSC_H}`} width="100%" style={{ display: 'block' }}
className="rounded-lg bg-surface border border-border">
{/* Zero line */}
<line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2}
stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
{/* Ghost waveform — previous clear-pitch shape fading out */}
{ghost.path && ghostOp > 0.04 && !detectedFreq && (
<>
<path d={ghost.fill} fill={`rgba(168,85,247,${(ghostOp * 0.06).toFixed(3)})`} />
<path d={ghost.path} fill="none"
stroke={`rgba(150,120,200,${(ghostOp * 0.35).toFixed(3)})`}
strokeWidth={0.8} strokeLinejoin="round" strokeLinecap="round" />
</>
)}
{/* Fill body */}
{path && (
<path
d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`}
fill={detectedFreq
? 'rgba(168,85,247,0.08)'
: silent ? 'none' : 'rgba(90,190,130,0.07)'}
/>
)}
{/* Waveform line */}
{path && <path d={path} fill="none" stroke={lineColor} strokeWidth={0.9}
strokeLinejoin="round" strokeLinecap="round" />}
{/* Sine overlay */}
{sinePath && <path d={sinePath} fill="none"
stroke="rgba(168,85,247,0.28)" strokeWidth={0.9}
strokeLinejoin="round" strokeDasharray="5 4" />}
{/* Scrolling note history — newest on right, slides left on each new note */}
{noteHistory.map((entry, i) => {
const age = noteHistory.length - 1 - i // 0 = newest
const x = OSC_W - 28 - age * 100
const op = (1 - age * 0.18).toFixed(2)
const isNew = age === 0
return (
<g key={entry.id}
style={{ transform: `translateX(${x}px)`, transition: 'transform 0.45s cubic-bezier(0.4,0,0.2,1)' }}>
<text x={0} y={OSC_H - 18} textAnchor="middle"
fontSize={isNew ? 13 : 11} fontWeight={isNew ? '700' : '400'}
fill={isNew ? `rgba(168,85,247,${op})` : `rgba(160,130,210,${op})`}>
{entry.note}
</text>
<text x={0} y={OSC_H - 7} textAnchor="middle" fontSize={7}
fill={`rgba(120,100,160,${(parseFloat(op) * 0.7).toFixed(2)})`}>
{entry.freq ? entry.freq.toFixed(0) : ''}Hz
</text>
</g>
)
})}
</svg>
</div>
)
}
// ─── Frequency spectrum ───────────────────────────────────────────────────────
const SPEC_H = 130
const SPEC_F_MIN = 40
const SPEC_F_MAX = 4000
const SPEC_LOG = Math.log(SPEC_F_MAX / SPEC_F_MIN)
// Map a frequency in Hz to an x pixel position (log scale)
function specX(f, w) {
if (f <= SPEC_F_MIN) return 0
if (f >= SPEC_F_MAX) return w
return w * Math.log(f / SPEC_F_MIN) / SPEC_LOG
}
const SPEC_GRID = [
{ label: 'E2', freq: 82.4 },
{ label: 'C3', freq: 130.8 },
{ label: 'E3', freq: 164.8 },
{ label: 'A3', freq: 220 },
{ label: 'C4', freq: 261.6 },
{ label: 'E4', freq: 329.6 },
{ label: 'A4', freq: 440 },
{ label: 'C5', freq: 523.3 },
{ label: 'C6', freq: 1046.5},
{ label: 'C7', freq: 2093 },
]
function SpectrumPanel({ spectrum, detectedFreq }) {
const W = OSC_W
const ghostRef = useRef(null)
const ghostFreqRef = useRef(null) // { freq, opacity }
// Ghost frequency lines — lock on detection, decay slowly when gone
if (detectedFreq) {
ghostFreqRef.current = { freq: detectedFreq, opacity: 1 }
} else if (ghostFreqRef.current) {
ghostFreqRef.current = { freq: ghostFreqRef.current.freq, opacity: ghostFreqRef.current.opacity * 0.97 }
}
const ghostFreq = ghostFreqRef.current?.opacity > 0.04 ? ghostFreqRef.current.freq : null
const ghostOpacity = ghostFreqRef.current?.opacity ?? 0
// Ghost: rises instantly with signal, decays very slowly — lingers as grey
if (spectrum?.length) {
if (!ghostRef.current) ghostRef.current = new Float32Array(spectrum.length)
const ghost = ghostRef.current
for (let i = 0; i < spectrum.length; i++) {
ghost[i] = spectrum[i] > ghost[i] ? spectrum[i] : ghost[i] * 0.988
}
}
let fillPath = '', strokePath = '', ghostFill = '', ghostStroke = ''
if (spectrum?.length) {
const n = spectrum.length
const pts = Array.from({ length: n }, (_, i) => {
const x = ((i / (n - 1)) * W).toFixed(1)
const y = (SPEC_H * (1 - spectrum[i])).toFixed(1)
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
strokePath = pts
fillPath = pts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
const ghost = ghostRef.current
if (ghost) {
const gpts = Array.from({ length: n }, (_, i) => {
const x = ((i / (n - 1)) * W).toFixed(1)
const y = (SPEC_H * (1 - ghost[i])).toFixed(1)
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
ghostStroke = gpts
ghostFill = gpts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
}
}
return (
<div>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-1">
Frequency spectrum 40 Hz 4 kHz (log scale)
</p>
<svg viewBox={`0 0 ${W} ${SPEC_H}`} width="100%" style={{ display: 'block' }}
className="rounded-lg bg-surface border border-border">
{/* Note grid lines */}
{SPEC_GRID.map(({ label, freq }) => {
const x = specX(freq, W).toFixed(1)
return (
<g key={label}>
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
<text x={x} y={SPEC_H - 3} textAnchor="middle" fontSize={7.5}
fill="rgba(80,80,95,0.9)">{label}</text>
</g>
)
})}
{/* Ghost — slow-decaying grey residue from previous peaks */}
{ghostFill && (
<>
<path d={ghostFill} fill="rgba(120,120,130,0.08)" />
<path d={ghostStroke} fill="none" stroke="rgba(130,130,145,0.30)" strokeWidth={0.7} />
</>
)}
{/* Live spectrum fill + stroke */}
{fillPath && (
<>
<path d={fillPath} fill="rgba(80,180,130,0.13)" />
<path d={strokePath} fill="none" stroke="rgba(90,200,145,0.55)" strokeWidth={0.8} />
</>
)}
{/* Fundamental + harmonics */}
{ghostFreq && [1, 2, 3, 4, 5].map(h => {
const hf = ghostFreq * h
if (hf > SPEC_F_MAX) return null
const xNum = specX(hf, W)
const x = xNum.toFixed(1)
const midi = Math.round(12 * Math.log2(hf / 440) + 69)
const note = NOTES[((midi % 12) + 12) % 12]
const oct = Math.floor(midi / 12) - 1
// Place label left of line near the right edge, right of line elsewhere
const labelX = xNum > W - 40 ? xNum - 3 : xNum + 3
const anchor = xNum > W - 40 ? 'end' : 'start'
if (h === 1) {
const op = (0.9 * ghostOpacity).toFixed(3)
const textOp = (ghostOpacity * 0.95).toFixed(3)
return (
<g key={h}>
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
stroke={`rgba(168,85,247,${op})`} strokeWidth={1.2} />
<text x={labelX} y={10} textAnchor={anchor} fontSize={8} fontWeight="700"
fill={`rgba(168,85,247,${textOp})`}>{note}{oct}</text>
<text x={labelX} y={20} textAnchor={anchor} fontSize={7}
fill={`rgba(168,85,247,${(ghostOpacity * 0.55).toFixed(3)})`}>f</text>
</g>
)
}
const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3)
return (
<g key={h}>
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" />
<text x={labelX} y={10} textAnchor={anchor} fontSize={7.5}
fill={`rgba(168,85,247,${op})`}>{note}{oct}</text>
<text x={labelX} y={19} textAnchor={anchor} fontSize={7}
fill={`rgba(168,85,247,${(parseFloat(op) * 0.7).toFixed(3)})`}>{h}f</text>
</g>
)
})}
</svg>
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord, instrument = 'guitar' }) {
export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar', monoColor = false }) {
const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : [])
const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
const totalNotes = noteAnalysis?.total ?? 0
const sessionSecs = noteAnalysis?.sessionSecs ?? 0
const sessionLabel = sessionSecs >= 60
? `${Math.floor(sessionSecs / 60)}m ${sessionSecs % 60}s`
: `${sessionSecs}s`
const topScore = chordCandidates[0]?.score ?? 1
const topKeyScore = topKeys[0]?.score ?? 1
return (
<div className="bg-panel border border-border rounded-2xl p-4 flex flex-col gap-4">
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">Behind the Scenes</p>
{/* ── Live chroma visualization (instrument-synced) ── */}
<div className="flex flex-col gap-4">
{/* ── Live chroma visualization (instrument-synced) ── */}
<div>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma what the engine hears right now</p>
{instrument === 'guitar'
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} />
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} />
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} monoColor={monoColor} />
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} monoColor={monoColor} />
}
</div>
@@ -269,8 +599,15 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
{/* Col 2: Note history piano with % labels */}
<div>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Note history key evidence</p>
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} />
<div className="flex items-baseline justify-between mb-2">
<p className="text-xs text-gray-600 uppercase tracking-widest">Note history</p>
{totalNotes > 0 && (
<span className="text-[10px] text-gray-600 tabular-nums">
{totalNotes.toLocaleString()} notes · {sessionLabel}
</span>
)}
</div>
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} monoColor={monoColor} />
</div>
{/* Col 3: Key candidates */}
@@ -298,6 +635,12 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
</div>
</div>
{/* ── Oscilloscope + spectrum ── */}
<div className="flex flex-col gap-3">
<Oscilloscope waveform={waveform} />
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
</div>
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { useRef, useEffect } from 'react'
// Map a frequency (Hz) to a bin index in the 256-bin log spectrum (404000 Hz)
function freqToBin(freq) {
return Math.round(255 * Math.log(freq / 40) / Math.log(4000 / 40))
}
function bandMax(spectrum, lo, hi) {
if (!spectrum) return 0
const a = freqToBin(lo)
const b = Math.min(freqToBin(hi), spectrum.length - 1)
let max = 0
for (let i = a; i <= b; i++) if (spectrum[i] > max) max = spectrum[i]
return max
}
const BANDS = [
{ label: 'Kick', lo: 40, hi: 120, color: '#ef4444' },
{ label: 'Snare', lo: 120, hi: 300, color: '#f59e0b' },
{ label: 'Mid', lo: 300, hi: 1000, color: '#22c55e' },
{ label: 'Presence', lo: 1000, hi: 4000, color: '#60a5fa' },
]
const TIMELINE_MS = 4000 // onset timeline window
const RMS_HISTORY = 180 // ~3s at 60fps
export default function DrumView({ waveform, bpm }) {
const rmsHistRef = useRef([])
const beatCanvasRef = useRef(null)
const rmsCanvasRef = useRef(null)
const spectrum = waveform?.spectrum ?? null
const bandLevels = BANDS.map(b => bandMax(spectrum, b.lo, b.hi))
// Accumulate RMS history
useEffect(() => {
if (waveform == null) return
const h = rmsHistRef.current
h.push(Math.min(waveform.rms * 10, 1))
if (h.length > RMS_HISTORY) h.shift()
}, [waveform])
// Draw onset / beat timeline
useEffect(() => {
const canvas = beatCanvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const W = canvas.width, H = canvas.height
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, W, H)
const onsets = waveform?.onsets ?? []
const now = performance.now()
// Beat grid aligned to the most recent onset
if (bpm) {
const beatMs = 60000 / bpm
const numBeats = Math.ceil(TIMELINE_MS / beatMs) + 1
const latest = onsets[onsets.length - 1]
const phase = latest != null ? (now - latest) % beatMs : 0
for (let b = 0; b <= numBeats; b++) {
const ageMs = b * beatMs - phase
if (ageMs < 0 || ageMs > TIMELINE_MS) continue
const x = W * (1 - ageMs / TIMELINE_MS)
ctx.strokeStyle = 'rgba(255,255,255,0.07)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
}
}
// Onset dots + vertical tails
const recent = onsets.filter(t => now - t <= TIMELINE_MS)
for (const t of recent) {
const age = now - t
const x = W * (1 - age / TIMELINE_MS)
const alpha = Math.pow(1 - age / TIMELINE_MS, 0.4)
ctx.strokeStyle = `rgba(168,85,247,${(alpha * 0.35).toFixed(2)})`
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
ctx.fillStyle = `rgba(168,85,247,${alpha.toFixed(2)})`
ctx.beginPath(); ctx.arc(x, H / 2, 5, 0, Math.PI * 2); ctx.fill()
}
// "Now" edge
ctx.strokeStyle = 'rgba(255,255,255,0.18)'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(W - 1, 0); ctx.lineTo(W - 1, H); ctx.stroke()
}, [waveform, bpm])
// Draw RMS envelope
useEffect(() => {
const canvas = rmsCanvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const W = canvas.width, H = canvas.height
const h = rmsHistRef.current
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, W, H)
if (h.length < 2) return
const barW = W / RMS_HISTORY
for (let i = 0; i < h.length; i++) {
const x = W * (i / RMS_HISTORY)
const barH = h[i] * H
const v = Math.round(h[i] * 160 + 60)
ctx.fillStyle = `rgb(${v},30,${v})`
ctx.fillRect(x, H - barH, Math.max(barW - 0.5, 1), barH)
}
}, [waveform])
const noData = !waveform
return (
<div className="space-y-5">
{/* Band meters */}
<div>
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">Frequency Bands</p>
<div className="flex gap-3" style={{ height: 96 }}>
{BANDS.map((b, i) => (
<div key={b.label} className="flex flex-col items-center gap-1 flex-1">
<div className="flex-1 w-full bg-gray-900 rounded-sm relative overflow-hidden">
{noData ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[9px] text-gray-700 font-mono"></span>
</div>
) : (
<div
className="absolute bottom-0 left-0 right-0 rounded-sm"
style={{
height: `${bandLevels[i] * 100}%`,
backgroundColor: b.color,
transition: 'height 60ms linear',
}}
/>
)}
</div>
<span className="text-[10px] font-mono text-gray-500 uppercase">{b.label}</span>
</div>
))}
</div>
</div>
{/* Onset timeline */}
<div>
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
Onset Timeline{bpm ? ` · ${bpm} BPM` : ''}
<span className="ml-2 text-gray-700 normal-case"> 4 seconds</span>
</p>
<canvas
ref={beatCanvasRef}
width={800}
height={56}
className="w-full rounded"
style={{ height: 56 }}
/>
{noData && (
<p className="text-xs text-gray-700 font-mono mt-1 text-center">Start listening to see hits</p>
)}
</div>
{/* Volume envelope */}
<div>
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
Volume Envelope
<span className="ml-2 text-gray-700 normal-case"> ~3 seconds</span>
</p>
<canvas
ref={rmsCanvasRef}
width={800}
height={56}
className="w-full rounded"
style={{ height: 56 }}
/>
</div>
</div>
)
}
+83 -2
View File
@@ -1,3 +1,5 @@
import { useRef, useEffect, useState } from 'react'
const SETTINGS = [
{
section: 'Chord Detection',
@@ -70,7 +72,52 @@ const SETTINGS = [
},
]
export default function Settings({ config, onChange, onClose, onReset }) {
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">
@@ -87,16 +134,42 @@ export default function Settings({ config, onChange, onClose, onReset }) {
>
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"
>
Done
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">
@@ -137,6 +210,14 @@ export default function Settings({ config, onChange, onClose, onReset }) {
</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>
+8 -11
View File
@@ -108,17 +108,14 @@ export default function Tuner() {
return (
<div className="p-6 bg-panel border border-border rounded-xl text-center">
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold">Tuner</h3>
<div>
<button
onClick={() => (isListening ? stopListening() : startListening())}
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
>
{isListening ? 'Stop' : 'Start'}
</button>
</div>
<div className="p-6 text-center">
<div className="flex items-start justify-end mb-4">
<button
onClick={() => (isListening ? stopListening() : startListening())}
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
>
{isListening ? 'Stop' : 'Start'}
</button>
</div>
<div className="w-full flex flex-col items-center">
+52 -19
View File
@@ -360,40 +360,72 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) {
// ─── Repeating progression detection ─────────────────────────────────────────
// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true)
function isPeriodicPattern(arr) {
for (let p = 1; p <= Math.floor(arr.length / 2); p++) {
if (arr.length % p !== 0) continue
const unit = arr.slice(0, p)
if (arr.every((v, i) => v === unit[i % p])) return true
}
return false
}
// Returns the lexicographically smallest rotation so the same loop always
// produces the same string regardless of where in the cycle we currently are.
function canonicalize(pattern) {
let best = pattern
for (let i = 1; i < pattern.length; i++) {
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
if (rot.join('\0') < best.join('\0')) best = rot
}
return best
}
/**
* detectRepeatingProgression(history) chord[] or null
* Returns the most-recently-completed repeating pattern (length 26).
* Uses non-overlapping match counting to avoid over-counting.
*
* Tests every unique subsequence of every length (not just the tail) so the
* result is stable regardless of where in the loop the musician currently is.
* Returns the canonical (rotation-normalised) form of the best pattern found.
*/
export function detectRepeatingProgression(history) {
if (!history || history.length < 4) return null
if (!history || history.length < 6) return null
const window = history.slice(-20)
const win = history.slice(-32)
let best = null, bestScore = 0
for (let len = 2; len <= 6; len++) {
if (len * 2 > window.length) break
if (len * 2 > win.length) break
const candidate = window.slice(-len)
let reps = 0, i = 0
const seen = new Set()
while (i <= window.length - len) {
if (candidate.every((c, j) => c === window[i + j])) {
reps++
i += len // skip past match — non-overlapping
} else {
i++
for (let start = 0; start <= win.length - len; start++) {
const candidate = win.slice(start, start + len)
const key = candidate.join('\0')
if (seen.has(key)) continue
seen.add(key)
// A pattern that is itself a repetition of something shorter will be
// found at that shorter length — skip it here to avoid inflating scores.
if (len >= 4 && isPeriodicPattern(candidate)) continue
let reps = 0, i = 0
while (i <= win.length - len) {
if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len }
else i++
}
}
const score = reps * len
if (reps >= 2 && score > bestScore) {
bestScore = score
best = candidate
if (reps < 2) continue
const score = reps * len * len // square length — prevents sub-patterns from beating full loop
if (score > bestScore) {
bestScore = score
best = candidate
}
}
}
return best
return best ? canonicalize(best) : null
}
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
@@ -450,6 +482,7 @@ export function getNoteHistoryAnalysis(noteHistory) {
}
return {
freq: normalized,
total,
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
}
}
+11 -1
View File
@@ -78,7 +78,17 @@ export function useAudioTuner() {
const startListening = async () => {
if (isListening) return
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Read saved device selection from config (if present)
let deviceId = null
try {
const cfg = JSON.parse(localStorage.getItem('wtf_config') || '{}')
deviceId = cfg.audioDeviceId || null
} catch (e) {
// ignore
}
const constraints = deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true }
console.log('Tuner requesting getUserMedia with', constraints)
const stream = await navigator.mediaDevices.getUserMedia(constraints)
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const analyser = ctx.createAnalyser()
analyser.fftSize = 4096