156 lines
5.1 KiB
JavaScript
156 lines
5.1 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
export const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
|
|
|
// simple autocorrelation from existing tuner
|
|
function autoCorrelate(buffer, sampleRate) {
|
|
let SIZE = buffer.length
|
|
let sum = 0
|
|
for (let i = 0; i < SIZE; i++) {
|
|
const val = buffer[i]
|
|
sum += val * val
|
|
}
|
|
if (sum < 1e-8) return -1
|
|
|
|
let r1 = 0, r2 = SIZE - 1, thres = 0.2
|
|
for (let i = 0; i < SIZE / 2; i++) {
|
|
if (Math.abs(buffer[i]) < thres) { r1 = i; break }
|
|
}
|
|
for (let i = 1; i < SIZE / 2; i++) {
|
|
if (Math.abs(buffer[SIZE - i]) < thres) { r2 = SIZE - i; break }
|
|
}
|
|
|
|
buffer = buffer.slice(r1, r2)
|
|
SIZE = buffer.length
|
|
|
|
const c = new Array(SIZE).fill(0)
|
|
for (let i = 0; i < SIZE; i++) for (let j = 0; j < SIZE - i; j++) c[i] += buffer[j] * buffer[j + i]
|
|
|
|
let d = 0
|
|
while (c[d] > c[d + 1]) d++
|
|
|
|
let maxval = -1, maxpos = -1
|
|
for (let i = d; i < SIZE; i++) if (c[i] > maxval) { maxval = c[i]; maxpos = i }
|
|
let T0 = maxpos
|
|
if (!T0) return -1
|
|
|
|
const x1 = c[T0 - 1]
|
|
const x2 = c[T0]
|
|
const x3 = c[T0 + 1]
|
|
const a = (x1 + x3 - 2 * x2) / 2
|
|
const b = (x3 - x1) / 2
|
|
if (a) T0 = T0 - b / (2 * a)
|
|
|
|
return sampleRate / T0
|
|
}
|
|
|
|
function frequencyToNoteData(freq) {
|
|
if (!freq || freq <= 0) return null
|
|
const A4 = 440
|
|
const noteNum = 12 * Math.log2(freq / A4) + 69
|
|
const rounded = Math.round(noteNum)
|
|
const cents = Math.floor((noteNum - rounded) * 100)
|
|
const noteNames = NOTES
|
|
const name = noteNames[(rounded + 120) % 12]
|
|
const octave = Math.floor(rounded / 12) - 1
|
|
return { freq, note: name, octave, cents }
|
|
}
|
|
|
|
export function useAudioTuner() {
|
|
const [pitchData, setPitchData] = useState(null)
|
|
const [isListening, setIsListening] = useState(false)
|
|
|
|
const audioRef = useRef({ ctx: null, analyser: null, source: null, stream: null, raf: null, buf: null })
|
|
const prevPitchRef = useRef(null)
|
|
const lastUpdateRef = useRef(0)
|
|
const UPDATE_INTERVAL_MS = 80 // ~12.5 updates/sec
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const { raf, stream, source, ctx } = audioRef.current
|
|
if (raf) cancelAnimationFrame(raf)
|
|
if (source) try { source.disconnect() } catch (e) {}
|
|
if (stream) for (const t of stream.getTracks()) t.stop()
|
|
if (ctx) try { ctx.close() } catch (e) {}
|
|
}
|
|
}, [])
|
|
|
|
const startListening = async () => {
|
|
if (isListening) return
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
|
const analyser = ctx.createAnalyser()
|
|
analyser.fftSize = 4096
|
|
analyser.smoothingTimeConstant = 0.6
|
|
const source = ctx.createMediaStreamSource(stream)
|
|
source.connect(analyser)
|
|
|
|
const buf = new Float32Array(analyser.fftSize)
|
|
audioRef.current = { ctx, analyser, source, stream, raf: null, buf }
|
|
|
|
setIsListening(true)
|
|
|
|
const tick = () => {
|
|
const { analyser, buf, ctx } = audioRef.current
|
|
analyser.getFloatTimeDomainData(buf)
|
|
const freq = autoCorrelate(buf, ctx.sampleRate)
|
|
const data = frequencyToNoteData(freq)
|
|
|
|
// compute RMS signal strength; simple noise gate
|
|
let sum = 0
|
|
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
|
|
const rms = Math.sqrt(sum / buf.length)
|
|
|
|
// throttle updates to avoid excessive React state churn
|
|
const now = performance.now()
|
|
const prev = prevPitchRef.current
|
|
if (rms < 0.001) {
|
|
if (prev !== null && now - lastUpdateRef.current >= UPDATE_INTERVAL_MS) {
|
|
prevPitchRef.current = null
|
|
lastUpdateRef.current = now
|
|
setPitchData(null)
|
|
}
|
|
} else {
|
|
const shouldUpdateByTime = now - lastUpdateRef.current >= UPDATE_INTERVAL_MS
|
|
if (data && prev) {
|
|
const freqDiff = Math.abs((prev.freq || 0) - (data.freq || 0))
|
|
const centsDiff = Math.abs((prev.cents || 0) - (data.cents || 0))
|
|
const significant = freqDiff >= 0.5 || centsDiff >= 1
|
|
if (significant && shouldUpdateByTime) {
|
|
prevPitchRef.current = data
|
|
lastUpdateRef.current = now
|
|
setPitchData(data)
|
|
}
|
|
// otherwise skip update (either insignificant or too soon)
|
|
} else if (shouldUpdateByTime) {
|
|
// first time audible or no prev — update
|
|
prevPitchRef.current = data
|
|
lastUpdateRef.current = now
|
|
setPitchData(data)
|
|
}
|
|
}
|
|
|
|
audioRef.current.raf = requestAnimationFrame(tick)
|
|
}
|
|
|
|
audioRef.current.raf = requestAnimationFrame(tick)
|
|
} catch (e) {
|
|
console.error('startListening error', e)
|
|
}
|
|
}
|
|
|
|
const stopListening = () => {
|
|
const { raf, stream, source, ctx } = audioRef.current
|
|
if (raf) cancelAnimationFrame(raf)
|
|
if (source) try { source.disconnect() } catch (e) {}
|
|
if (stream) for (const t of stream.getTracks()) t.stop()
|
|
if (ctx) try { ctx.close() } catch (e) {}
|
|
audioRef.current = { ctx: null, analyser: null, source: null, stream: null, raf: null, buf: null }
|
|
setIsListening(false)
|
|
setPitchData(null)
|
|
}
|
|
|
|
return { pitchData, isListening, startListening, stopListening }
|
|
}
|