Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3258060661 |
Generated
+21
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "whattheflat",
|
"name": "whattheflat",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "whattheflat",
|
"name": "whattheflat",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audiomotion-analyzer": "^4.5.4",
|
"audiomotion-analyzer": "^4.5.4",
|
||||||
|
"essentia.js": "^0.1.3",
|
||||||
"pitchy": "^4.1.0",
|
"pitchy": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
@@ -4183,6 +4184,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/essentia.js": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/essentia.js/-/essentia.js-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-vVEPgeVMEBLRXbM5o5H5Rgu53EPHu25vyFKYg+flWLzI/nEoegJQez9FKRv8GR/KxIBwm+fXDEFL+MkQeoHaLw==",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"node-wav": "0.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -5865,6 +5875,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-wav": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-wav/-/node-wav-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audiomotion-analyzer": "^4.5.4",
|
"audiomotion-analyzer": "^4.5.4",
|
||||||
|
"essentia.js": "^0.1.3",
|
||||||
"pitchy": "^4.1.0",
|
"pitchy": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
|
|||||||
+9
-5
@@ -3,6 +3,7 @@ import AudioCapture from './components/AudioCapture'
|
|||||||
import ProgressionBanner from './components/ProgressionBanner'
|
import ProgressionBanner from './components/ProgressionBanner'
|
||||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||||
import Fretboard from './components/Fretboard'
|
import Fretboard from './components/Fretboard'
|
||||||
|
import BassFretboard from './components/BassFretboard'
|
||||||
import Tuner from './components/Tuner'
|
import Tuner from './components/Tuner'
|
||||||
import Piano from './components/Piano'
|
import Piano from './components/Piano'
|
||||||
import Settings from './components/Settings'
|
import Settings from './components/Settings'
|
||||||
@@ -24,6 +25,8 @@ const DEFAULTS = {
|
|||||||
// Audio input
|
// Audio input
|
||||||
minClarity: 0.80,
|
minClarity: 0.80,
|
||||||
minVolume: 0.01,
|
minVolume: 0.01,
|
||||||
|
// Experimental
|
||||||
|
useEssentia: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -42,7 +45,7 @@ export default function App() {
|
|||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
|
||||||
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||||
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'piano'
|
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'bass' | 'piano'
|
||||||
const [showTuner, setShowTuner] = useState(false)
|
const [showTuner, setShowTuner] = useState(false)
|
||||||
const [showDebug, setShowDebug] = useState(false)
|
const [showDebug, setShowDebug] = useState(false)
|
||||||
const [monoColor, setMonoColor] = useState(false)
|
const [monoColor, setMonoColor] = useState(false)
|
||||||
@@ -394,6 +397,7 @@ export default function App() {
|
|||||||
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"
|
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="guitar">Guitar</option>
|
||||||
|
<option value="bass">Bass</option>
|
||||||
<option value="piano">Piano</option>
|
<option value="piano">Piano</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
@@ -493,6 +497,7 @@ export default function App() {
|
|||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
minClarity={config.minClarity}
|
minClarity={config.minClarity}
|
||||||
minVolume={config.minVolume}
|
minVolume={config.minVolume}
|
||||||
|
useEssentia={config.useEssentia}
|
||||||
onPermissionError={() => {
|
onPermissionError={() => {
|
||||||
setMicError(true)
|
setMicError(true)
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
@@ -517,10 +522,9 @@ export default function App() {
|
|||||||
{/* ── Instrument + progressions row ── */}
|
{/* ── Instrument + progressions row ── */}
|
||||||
<div className="flex gap-3 mb-3 items-stretch">
|
<div className="flex gap-3 mb-3 items-stretch">
|
||||||
<div className="w-full lg:w-[70%] min-w-0">
|
<div className="w-full lg:w-[70%] min-w-0">
|
||||||
{instrument === 'guitar'
|
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
||||||
? <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />
|
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||||
: <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />
|
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { PitchDetector } from 'pitchy'
|
import { PitchDetector } from 'pitchy'
|
||||||
import { NOTES } from '../lib/theory'
|
import { NOTES } from '../lib/theory'
|
||||||
|
import { initEssentia, getEssentia, computeHPCP } from '../lib/essentiaHPCP'
|
||||||
|
|
||||||
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -81,7 +82,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
|||||||
return ((bestMidi % 12) + 12) % 12
|
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, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, useEssentia = false }) {
|
||||||
const audioCtxRef = useRef(null)
|
const audioCtxRef = useRef(null)
|
||||||
const timeBufRef = useRef(null)
|
const timeBufRef = useRef(null)
|
||||||
const freqBufRef = useRef(null)
|
const freqBufRef = useRef(null)
|
||||||
@@ -97,6 +98,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
|
|||||||
const onPermissionErrorRef = useRef(onPermissionError)
|
const onPermissionErrorRef = useRef(onPermissionError)
|
||||||
const minClarityRef = useRef(minClarity)
|
const minClarityRef = useRef(minClarity)
|
||||||
const minVolumeRef = useRef(minVolume)
|
const minVolumeRef = useRef(minVolume)
|
||||||
|
const useEssentiaRef = useRef(useEssentia)
|
||||||
const smoothRmsRef = useRef(0)
|
const smoothRmsRef = useRef(0)
|
||||||
const lastOnsetRef = useRef(0)
|
const lastOnsetRef = useRef(0)
|
||||||
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
||||||
@@ -105,6 +107,11 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
|
|||||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||||
|
useEffect(() => { useEssentiaRef.current = useEssentia }, [useEssentia])
|
||||||
|
|
||||||
|
// Pre-load Essentia WASM as soon as the component mounts — it's a singleton,
|
||||||
|
// so repeated calls just return the cached instance.
|
||||||
|
useEffect(() => { initEssentia().catch(console.error) }, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
activeRef.current = false
|
activeRef.current = false
|
||||||
@@ -174,8 +181,20 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
|
|||||||
if (onChromaRef.current) {
|
if (onChromaRef.current) {
|
||||||
const freqBuf = freqBufRef.current
|
const freqBuf = freqBufRef.current
|
||||||
ca.getFloatFrequencyData(freqBuf)
|
ca.getFloatFrequencyData(freqBuf)
|
||||||
|
let chroma
|
||||||
|
const essentia = useEssentiaRef.current ? getEssentia() : null
|
||||||
|
if (essentia) {
|
||||||
|
try {
|
||||||
|
chroma = computeHPCP(essentia, freqBuf, ctx.sampleRate)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Essentia] computeHPCP failed, falling back to custom chroma:', err)
|
||||||
|
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||||
|
}
|
||||||
onChromaRef.current(
|
onChromaRef.current(
|
||||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
chroma,
|
||||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 1–2) */}
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,6 +70,19 @@ const SETTINGS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const TOGGLES = [
|
||||||
|
{
|
||||||
|
section: 'Experimental',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'useEssentia',
|
||||||
|
label: 'Essentia HPCP',
|
||||||
|
desc: 'Replace custom chroma with Essentia\'s Harmonic Pitch Class Profile (spectral peaks + 8 harmonics). More accurate on polyphonic input. May be slower.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export default function Settings({ config, onChange, onClose, onReset }) {
|
export default function Settings({ config, onChange, onClose, onReset }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||||
@@ -97,6 +110,31 @@ export default function Settings({ config, onChange, onClose, onReset }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{TOGGLES.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-4">
|
||||||
|
{section.items.map(item => (
|
||||||
|
<label key={item.key} className="flex items-start gap-3 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!config[item.key]}
|
||||||
|
onChange={e => onChange(item.key, e.target.checked)}
|
||||||
|
className="mt-0.5 accent-purple-500 w-4 h-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-200 group-hover:text-white transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-0.5">{item.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{SETTINGS.map(section => (
|
{SETTINGS.map(section => (
|
||||||
<div key={section.section}>
|
<div key={section.section}>
|
||||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Essentia HPCP pipeline — replaces our custom computeChroma() when enabled.
|
||||||
|
//
|
||||||
|
// Pipeline (using Web Audio FFT data directly to skip WASM re-FFT):
|
||||||
|
// freqBuf (dB from AnalyserNode) → linear magnitude → SpectralPeaks → HPCP
|
||||||
|
//
|
||||||
|
// Pitch class ordering:
|
||||||
|
// Essentia HPCP[0] = A (referenceFrequency=440)
|
||||||
|
// Our chroma[0] = C
|
||||||
|
// Rotation applied: ourChroma[(i + 9) % 12] = HPCP[i]
|
||||||
|
|
||||||
|
import { EssentiaWASM } from 'essentia.js/dist/essentia-wasm.es.js'
|
||||||
|
import Essentia from 'essentia.js/dist/essentia.js-core.es.js'
|
||||||
|
|
||||||
|
let instance = null
|
||||||
|
|
||||||
|
// Call once at startup — safe to call multiple times (returns cached instance).
|
||||||
|
export async function initEssentia() {
|
||||||
|
if (instance) return instance
|
||||||
|
console.log('[Essentia] loading WASM…')
|
||||||
|
await EssentiaWASM['ready']
|
||||||
|
instance = new Essentia(EssentiaWASM)
|
||||||
|
console.log('[Essentia] ready —', instance.version)
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEssentia() { return instance }
|
||||||
|
|
||||||
|
const NOISE_FLOOR_DB = -65
|
||||||
|
|
||||||
|
// Compute 12-bin HPCP from Web Audio frequency-domain data.
|
||||||
|
//
|
||||||
|
// freqData — Float32Array from AnalyserNode.getFloatFrequencyData() (dB values)
|
||||||
|
// sampleRate — AudioContext.sampleRate
|
||||||
|
//
|
||||||
|
// Returns Float32Array(12) with same [C, C#, D, …, B] ordering as computeChroma().
|
||||||
|
export function computeHPCP(essentia, freqData, sampleRate) {
|
||||||
|
const N = freqData.length
|
||||||
|
|
||||||
|
// Convert dB → linear magnitude. Bins below noise floor stay 0.
|
||||||
|
const magSpectrum = new Float32Array(N)
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
if (freqData[i] > NOISE_FLOOR_DB) {
|
||||||
|
magSpectrum[i] = Math.pow(10, freqData[i] / 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const specVec = essentia.arrayToVector(magSpectrum)
|
||||||
|
|
||||||
|
// SpectralPeaks derives bin → Hz as: freq = binIndex * sampleRate / (2*(N-1))
|
||||||
|
// which matches Web Audio's FFT bin spacing (sampleRate / fftSize).
|
||||||
|
const peaks = essentia.SpectralPeaks(
|
||||||
|
specVec,
|
||||||
|
0, // magnitudeThreshold — noise already zeroed above
|
||||||
|
4000, // maxFrequency (Hz)
|
||||||
|
60, // maxPeaks
|
||||||
|
40, // minFrequency (Hz)
|
||||||
|
'byMagnitude',
|
||||||
|
sampleRate
|
||||||
|
)
|
||||||
|
specVec.delete()
|
||||||
|
|
||||||
|
// If no peaks found (silence / below noise floor), return zeros.
|
||||||
|
if (peaks.frequencies.size() === 0) {
|
||||||
|
peaks.frequencies.delete()
|
||||||
|
peaks.magnitudes.delete()
|
||||||
|
return new Float32Array(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hpcpResult = essentia.HPCP(
|
||||||
|
peaks.frequencies,
|
||||||
|
peaks.magnitudes,
|
||||||
|
false, // bandPreset
|
||||||
|
500, // bandSplitFrequency (unused when bandPreset=false)
|
||||||
|
8, // harmonics
|
||||||
|
4000, // maxFrequency (Hz)
|
||||||
|
false, // maxShifted
|
||||||
|
40, // minFrequency (Hz)
|
||||||
|
false, // nonLinear
|
||||||
|
'unitMax', // normalized
|
||||||
|
440, // referenceFrequency (A4 = 440 Hz → HPCP[0] = A)
|
||||||
|
sampleRate,
|
||||||
|
12, // size (bins per octave)
|
||||||
|
'squaredCosine', // weightType
|
||||||
|
0.5 // windowSize (octaves)
|
||||||
|
)
|
||||||
|
peaks.frequencies.delete()
|
||||||
|
peaks.magnitudes.delete()
|
||||||
|
|
||||||
|
// Rotate A-origin → C-origin to match our chroma convention.
|
||||||
|
// HPCP[0]=A → ourChroma[9]=A, so ourChroma[(i+9)%12] = HPCP[i]
|
||||||
|
const result = new Float32Array(12)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
result[(i + 9) % 12] = hpcpResult.hpcp.get(i)
|
||||||
|
}
|
||||||
|
hpcpResult.hpcp.delete()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -11,4 +11,9 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// Essentia uses emscripten output that esbuild can't pre-bundle reliably.
|
||||||
|
// Exclude it so Vite serves the files directly from node_modules.
|
||||||
|
exclude: ['essentia.js'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user