@chordbook/tuner
Version:
The tuner used by ChordBook.app
92 lines (76 loc) • 3.13 kB
text/typescript
import { PitchDetector } from 'pitchy'
const semitone = 69
const noteStrings = ['C', 'C♯', 'D', 'D♯', 'E', 'F', 'F♯', 'G', 'G♯', 'A', 'A♯', 'B']
export interface TunerConfig {
a4?: number
clarityThreshold?: number
minVolumeDecibels?: number
bufferSize?: number
smoothingTimeConstant?: number
minFrequency?: number
maxFrequency?: number
updateInterval?: number
sampleRate?: number
onNote?: (note: Note) => void
}
export const TunerDefaults: TunerConfig = {
a4: 440,
clarityThreshold: 0.9,
minVolumeDecibels: -1000,
bufferSize: 8192,
smoothingTimeConstant: 0.8,
minFrequency: 27.5, // A0, Lowest note on a piano
maxFrequency: 4186.01, // C8, Highest note on a piano
updateInterval: 50,
sampleRate: 44100, // Seems to work better than 48000 for some reason
}
export interface Note {
name: string
value: number
cents: number
octave: number
frequency: number
clarity: number
}
export function createTuner(config: TunerConfig = {}) {
config = { ...TunerDefaults, ...config }
const context = new AudioContext({ sampleRate: config.sampleRate })
const highpass = new BiquadFilterNode(context, { type: "highpass", frequency: config.minFrequency })
const lowpass = new BiquadFilterNode(context, { type: "lowpass", frequency: config.maxFrequency })
const analyser = new AnalyserNode(context, { fftSize: config.bufferSize, smoothingTimeConstant: config.smoothingTimeConstant })
lowpass.connect(highpass).connect(analyser)
const detector = PitchDetector.forFloat32Array(analyser.fftSize)
detector.minVolumeDecibels = config.minVolumeDecibels!;
const inputBuffer = new Float32Array(detector.inputLength)
let stream: MediaStream
let source: MediaStreamAudioSourceNode
let interval = 0;
async function start () {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
source = context.createMediaStreamSource(stream)
source.connect(lowpass)
interval = setInterval(process, config.updateInterval!)
}
function process() {
const { clarityThreshold, minFrequency, maxFrequency, onNote } = config
analyser.getFloatTimeDomainData(inputBuffer);
const [frequency, clarity] = detector.findPitch(inputBuffer, context.sampleRate)
if (clarity > clarityThreshold! && frequency > minFrequency! && frequency < maxFrequency!) {
onNote?.(getNote(frequency, clarity))
}
}
function getNote (frequency: number, clarity?: number) {
const value = Math.round(12 * (Math.log(frequency / config.a4!) / Math.log(2))) + semitone
const standardFrequency = config.a4! * Math.pow(2, (value - semitone) / 12)
const cents = Math.floor((1200 * Math.log(frequency / standardFrequency)) / Math.log(2))
const name = noteStrings[value % 12]
const octave: number = Math.floor(value / 12) - 1
return { frequency, name, value, cents, octave, clarity } as Note
}
async function stop() {
clearInterval(interval)
stream.getTracks().forEach(track => track.stop())
stream.removeTrack(stream.getAudioTracks()[0])
}
return { start, stop, context, analyser, detector, config, getNote }
}