diff --git a/ROADMAP.md b/ROADMAP.md index ec6a71e..b11a8e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,12 +77,15 @@ dans le `CHANGELOG`. pour le confirmer ; sans lecture de confirmation à temps (ou si elle se passe mal), le morceau redescend en perfectionnement. -- **Mode micro (non MIDI)** — détecter les notes jouées via le microphone pour - les pianos acoustiques et claviers sans MIDI. Ouvrirait l'application à un - public beaucoup plus large. Grande inconnue : la qualité atteignable de la - détection de pitch, surtout en polyphonie (accords, deux mains) et en - conditions réelles (micro de laptop, acoustique de la pièce). À prototyper - pour évaluer la faisabilité avant d'en faire un vrai chantier. +- **Mode micro : polyphonie** — le prototype monophonique est livré (bouton 🎤 + sur la page partition : détection de pitch dans le navigateur via + [pitchy](https://github.com/ianprime0509/pitchy) (McLeod Pitch Method), une + note à la fois — mélodies et travail mains séparées). La grande inconnue + reste entière pour la polyphonie (accords, deux mains), qui demande une + autre approche que le DSP classique — l'état de l'art navigateur est un + modèle de transcription type Onsets and Frames (Magenta.js) ou Basic Pitch + (Spotify), pensés pour l'offline plus que le temps réel — et à évaluer en + conditions réelles (micro de laptop, acoustique de la pièce). - **App iOS (wrapper natif)** — même objectif d'élargir le public, mais plus simple et balisé que le mode micro : Safari/iOS ne supporte pas le Web MIDI, diff --git a/public/js/app.js b/public/js/app.js index 160b8c0..6facbc8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,4 +1,5 @@ import { initMidi } from './midi.js' +import { initMicInput } from './micInput.js' import { initMusicXML } from './musicxml.js' import { initFingeringEditor } from './fingeringEditor.js' import { initCassettes } from './cassettes.js' @@ -20,6 +21,7 @@ const CHART_DATE_AXIS = new Intl.DateTimeFormat(locale(), { day: 'numeric', mont export function midiApp() { const midi = initMidi() + const micInput = initMicInput() const musicxml = initMusicXML() const fingeringEditor = initFingeringEditor({ getOsmdInstance: musicxml.getOsmdInstance, @@ -38,6 +40,7 @@ export function midiApp() { ...headerMenu(), bluetoothConnected: false, midiDeviceName: null, + micActive: false, osmdInstance: null, isRecording: false, isReplaying: false, @@ -239,6 +242,14 @@ export function midiApp() { } }, + // Mic mode (non-MIDI): detected notes are turned into synthetic MIDI + // messages and fed through parseMidiMessage, like cassette replay. + async toggleMic() { + if (micInput.state.micActive) micInput.stop() + else await micInput.start((data) => midi.parseMidiMessage(data)) + this.micActive = micInput.state.micActive + }, + detectedOS() { const ua = navigator.userAgent if (/Mac/.test(ua)) return 'mac' diff --git a/public/js/changelog.js b/public/js/changelog.js index 31e0fc4..4b30706 100644 --- a/public/js/changelog.js +++ b/public/js/changelog.js @@ -15,6 +15,17 @@ // See CLAUDE.md ("Changelog in-app") for the update rule. export const CHANGELOG = [ + { + date: '2026-07-06', + items: { + fr: [ + "Mode micro (bêta) : pas de clavier MIDI ? Sur la page partition, le bouton 🎤 fait écouter votre piano par le microphone et valide les notes jouées. Une note à la fois pour l'instant — idéal pour les mélodies et le travail mains séparées ; les accords ne sont pas encore reconnus.", + ], + en: [ + "Mic mode (beta): no MIDI keyboard? On the score page, the 🎤 button lets the app listen to your piano through the microphone and validate the notes you play. One note at a time for now — great for melodies and hands-separate practice; chords aren't recognised yet.", + ], + }, + }, { date: '2026-06-24', items: { diff --git a/public/js/locales/en.js b/public/js/locales/en.js index d6c3bb5..48da612 100644 --- a/public/js/locales/en.js +++ b/public/js/locales/en.js @@ -125,6 +125,9 @@ export default { midiConnected: 'MIDI keyboard connected', midiKeyboard: 'MIDI keyboard', connectMidi: '🎹 Connect MIDI keyboard', + micButton: '🎤 Mic mode', + micActive: '🎤 Listening…', + micTitle: 'Mic mode (beta): note detection through the microphone, one note at a time — chords are not recognised', history: '📜 History', historyAria: 'History', modeGroupAria: 'Practice mode', @@ -209,6 +212,9 @@ export default { windowsStep3: 'Set up BLE MIDI Bridge to route to the loopMIDI port', windowsReturn: 'Once the bridge is set up, come back to this page and click “Connect MIDI keyboard”.', linuxBody: 'Connect your MIDI keyboard over USB or set up the Bluetooth MIDI connection via BlueZ. Then come back to this page and click “Connect MIDI keyboard”.', + micHelpAlt: + 'Acoustic piano or no MIDI? Try mic mode (beta): the app listens to your piano through the microphone and validates the notes you play, one note at a time (chords are not recognised yet).', + midiHelpTryMic: '🎤 Try mic mode (beta)', midiHelpRetry: 'Retry the connection', resultTitleStrict: '⏱ Strict playthrough finished', resultTitleTraining: '🎉 Congratulations — Training finished', @@ -227,6 +233,8 @@ export default { invalidMusicXml: 'This file does not appear to be a valid MusicXML file', musicXmlLoad: 'Error loading the MusicXML file', cassetteSave: 'Error saving the cassette', + micUnsupported: 'Microphone access is not supported by this browser', + micDenied: 'Could not access the microphone. Check your browser permissions.', }, landing: { pageTitle: 'Piano Trainer · Learn the piano with real-time feedback', diff --git a/public/js/locales/fr.js b/public/js/locales/fr.js index d72d548..1cad368 100644 --- a/public/js/locales/fr.js +++ b/public/js/locales/fr.js @@ -125,6 +125,10 @@ export default { midiConnected: 'Clavier MIDI connecté', midiKeyboard: 'Clavier MIDI', connectMidi: '🎹 Connecter clavier MIDI', + micButton: '🎤 Mode micro', + micActive: '🎤 Écoute en cours…', + micTitle: + 'Mode micro (bêta) : détection des notes via le microphone, une note à la fois — les accords ne sont pas reconnus', history: '📜 Historique', historyAria: 'Historique', modeGroupAria: 'Mode de pratique', @@ -209,6 +213,9 @@ export default { windowsStep3: 'Configurez BLE MIDI Bridge pour rediriger vers le port loopMIDI', windowsReturn: 'Une fois le pont configuré, revenez sur cette page et cliquez sur « Connecter clavier MIDI ».', linuxBody: 'Connectez votre clavier MIDI via USB ou configurez la connexion Bluetooth MIDI via BlueZ. Revenez ensuite sur cette page et cliquez sur « Connecter clavier MIDI ».', + micHelpAlt: + "Piano acoustique ou pas de MIDI ? Essayez le mode micro (bêta) : l'application écoute votre piano via le microphone et valide les notes jouées, une note à la fois (les accords ne sont pas encore reconnus).", + midiHelpTryMic: '🎤 Essayer le mode micro (bêta)', midiHelpRetry: 'Réessayer la connexion', resultTitleStrict: '⏱ Playthrough strict terminé', resultTitleTraining: '🎉 Félicitations — Entraînement terminé', @@ -227,6 +234,8 @@ export default { invalidMusicXml: 'Ce fichier ne semble pas être un fichier MusicXML valide', musicXmlLoad: 'Erreur lors du chargement du fichier MusicXML', cassetteSave: 'Erreur lors de la sauvegarde de la cassette', + micUnsupported: "L'accès au microphone n'est pas supporté par ce navigateur", + micDenied: "Impossible d'accéder au microphone. Vérifiez les permissions du navigateur.", }, landing: { pageTitle: 'Piano Trainer · Apprenez le piano avec un retour en temps réel', diff --git a/public/js/micInput.js b/public/js/micInput.js new file mode 100644 index 0000000..7614861 --- /dev/null +++ b/public/js/micInput.js @@ -0,0 +1,90 @@ +// Mic mode (non-MIDI input) — prototype. +// +// Listens to the microphone, detects the played pitch (pitchDetection.js) +// and emits synthetic MIDI Note On/Off messages through the provided +// callback — the same path cassette replay uses — so validation, training, +// strict mode and recording all work unchanged. + +import { detectPitch, freqToMidi, computeRms, createNoteTracker } from './pitchDetection.js' +import { NOTE_ON, NOTE_OFF } from './midi.js' +import { t } from './i18n.js' + +// 4096 samples ≈ 93 ms at 44.1 kHz — long enough to resolve the lowest +// piano strings, short enough to keep note-on latency playable. +const FFT_SIZE = 4096 +const FRAME_MS = 40 + +const state = { + micActive: false, +} + +let audioContext = null +let mediaStream = null +let frameTimer = null +let tracker = null + +export function initMicInput() { + return { start, stop, state } +} + +async function start(emitMidiMessage) { + if (state.micActive) return true + + if (!navigator.mediaDevices?.getUserMedia) { + alert(t('errors.micUnsupported')) + return false + } + + try { + // Voice-call processing (echo cancellation & co) eats piano partials — + // ask for the raw signal. + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, + }) + } catch (e) { + console.error('Accès micro refusé:', e) + alert(t('errors.micDenied')) + return false + } + + audioContext = new AudioContext() + const analyser = audioContext.createAnalyser() + analyser.fftSize = FFT_SIZE + audioContext.createMediaStreamSource(mediaStream).connect(analyser) + + tracker = createNoteTracker({ + onNoteOn: (midiNote, rms) => emitMidiMessage([NOTE_ON, midiNote, velocityFromRms(rms)]), + onNoteOff: (midiNote) => emitMidiMessage([NOTE_OFF, midiNote, 0]), + }) + + const samples = new Float32Array(FFT_SIZE) + frameTimer = setInterval(() => { + analyser.getFloatTimeDomainData(samples) + const frequency = detectPitch(samples, audioContext.sampleRate) + tracker.push({ midi: frequency === null ? null : freqToMidi(frequency), rms: computeRms(samples) }) + }, FRAME_MS) + + state.micActive = true + console.log('Micro connecté (mode micro)') + return true +} + +function stop() { + if (!state.micActive) return + clearInterval(frameTimer) + frameTimer = null + tracker.flush() // release any held note so validation doesn't hang + tracker = null + mediaStream.getTracks().forEach((track) => track.stop()) + mediaStream = null + audioContext.close() + audioContext = null + state.micActive = false + console.log('Micro déconnecté') +} + +// The mic gives loudness, not key velocity — a rough monotonic mapping is +// enough (velocity only feeds recordings, validation ignores it). +function velocityFromRms(rms) { + return Math.max(20, Math.min(110, Math.round(Math.sqrt(rms) * 300))) +} diff --git a/public/js/pitchDetection.js b/public/js/pitchDetection.js new file mode 100644 index 0000000..099576e --- /dev/null +++ b/public/js/pitchDetection.js @@ -0,0 +1,107 @@ +// Pitch detection for the mic mode (non-MIDI input) — prototype. +// +// No Web Audio dependency, so everything here is unit-testable in node. +// micInput.js owns the browser glue (getUserMedia, AnalyserNode) and feeds +// audio frames into these functions. +// +// Monophonic only: MPM finds a single fundamental per frame. Polyphony +// (chords, two hands) needs a different approach entirely (e.g. an Onsets +// and Frames-style transcription model) — see ROADMAP. + +import { PitchDetector } from '../vendor/pitchy.4.1.0.bundle.min.js' + +// One detector per buffer size (it preallocates FFT scratch space). +const detectors = new Map() + +// Wraps pitchy's McLeod Pitch Method detector. Returns the detected +// fundamental in Hz, or null when the detection isn't confident enough or +// falls outside the accepted range — for note validation, a miss is much +// cheaper than a false positive. +// +// maxFreq stops at ~C7: mic detection of the top piano octave is unreliable, +// and staying below C8 keeps a stray high detection from ever hitting the +// app's C8 navigate-back key. +export function detectPitch(buffer, sampleRate, { minFreq = 27.5, maxFreq = 2200, minClarity = 0.9 } = {}) { + let detector = detectors.get(buffer.length) + if (!detector) { + detector = PitchDetector.forFloat32Array(buffer.length) + detectors.set(buffer.length, detector) + } + const [frequency, clarity] = detector.findPitch(buffer, sampleRate) + if (clarity < minClarity || frequency < minFreq || frequency > maxFreq) return null + return frequency +} + +export function freqToMidi(frequency) { + return Math.round(69 + 12 * Math.log2(frequency / 440)) +} + +export function computeRms(buffer) { + let sum = 0 + for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i] + return Math.sqrt(sum / buffer.length) +} + +// Turns a per-frame stream of { midi, rms } observations into debounced +// Note On / Note Off events. A note fires after minOnFrames consecutive +// frames of the same pitch (kills single-frame glitches) and releases after +// offFrames frames where that pitch is no longer heard (decay, damper, or +// another note taking over). Monophonic: confirming a new note releases the +// previous one. +export function createNoteTracker({ onNoteOn, onNoteOff, minOnFrames = 2, offFrames = 3, minRms = 0.01 } = {}) { + let activeNote = null + let candidate = null + let candidateCount = 0 + let missCount = 0 + + function push({ midi, rms }) { + const heard = rms >= minRms && midi != null ? midi : null + + if (heard !== null && heard === activeNote) { + missCount = 0 + candidate = null + candidateCount = 0 + return + } + + if (heard === null) { + candidate = null + candidateCount = 0 + } else if (heard === candidate) { + candidateCount++ + } else { + candidate = heard + candidateCount = 1 + } + + if (candidate !== null && candidateCount >= minOnFrames) { + if (activeNote !== null) onNoteOff(activeNote) + activeNote = candidate + candidate = null + candidateCount = 0 + missCount = 0 + onNoteOn(activeNote, rms) + return + } + + if (activeNote !== null) { + missCount++ + if (missCount >= offFrames) { + onNoteOff(activeNote) + activeNote = null + missCount = 0 + } + } + } + + // Release whatever is held — called when the mic is switched off. + function flush() { + if (activeNote !== null) onNoteOff(activeNote) + activeNote = null + candidate = null + candidateCount = 0 + missCount = 0 + } + + return { push, flush } +} diff --git a/public/score.html b/public/score.html index 090fe62..54b5d3a 100644 --- a/public/score.html +++ b/public/score.html @@ -79,6 +79,16 @@ x-text="$t('score.connectMidi')" >🎹 Connecter clavier MIDI + + + diff --git a/public/vendor/pitchy.4.1.0.bundle.min.js b/public/vendor/pitchy.4.1.0.bundle.min.js new file mode 100644 index 0000000..11b341b --- /dev/null +++ b/public/vendor/pitchy.4.1.0.bundle.min.js @@ -0,0 +1,6 @@ +/* pitchy 4.1.0 — https://github.com/ianprime0509/pitchy — MIT License. + McLeod Pitch Method (MPM) pitch detector. Self-contained esm.sh bundle + (inlines its fft.js dependency, also MIT). */ +/* esm.sh - pitchy@4.1.0 */ +var Bt=Object.create;var it=Object.defineProperty;var It=Object.getOwnPropertyDescriptor;var xt=Object.getOwnPropertyNames;var zt=Object.getPrototypeOf,Rt=Object.prototype.hasOwnProperty;var Mt=(e,t,r)=>t in e?it(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var Ct=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Vt=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of xt(t))!Rt.call(e,s)&&s!==r&&it(e,s,{get:()=>t[s],enumerable:!(n=It(t,s))||n.enumerable});return e};var Dt=(e,t,r)=>(r=e!=null?Bt(zt(e)):{},Vt(t||!e||!e.__esModule?it(r,"default",{value:e,enumerable:!0}):r,e));var w=(e,t,r)=>Mt(e,typeof t!="symbol"?t+"":t,r);var lt=Ct((Pt,ft)=>{"use strict";function g(e){if(this.size=e|0,this.size<=1||(this.size&this.size-1)!==0)throw new Error("FFT size must be a power of two and bigger than 1");this._csize=e<<1;for(var t=new Array(this.size*2),r=0;rs;s<<=1)n++;this._width=n%2===0?n-1:n,this._bitrev=new Array(1<>>i&3)<>>1),s=0;s>>1]=t[s];return n};g.prototype.createComplexArray=function(){let t=new Array(this._csize);for(var r=0;r>>1],n[s+1]=0;return n};g.prototype.completeSpectrum=function(t){for(var r=this._csize,n=r>>>1,s=2;s>=2;s>=2;s>>=2){o=r/s<<1;var _=o>>>2;for(i=0;i>>1,s>>>1)}else for(i=0,a=0;i>>1,s>>>1)}var h=this._inv?-1:1,f=this.table;for(s>>=2;s>=2;s>>=2){o=r/s<<1;var _=o>>>1,v=_>>>1,b=v>>>1;for(i=0;inew Float32Array(r))}static forFloat64Array(t){return new e(t,r=>new Float64Array(r))}static forNumberArray(t){return new e(t,r=>Array(r))}get inputLength(){return this._inputLength}autocorrelate(t,r=this._bufferSupplier(t.length)){if(t.length!==this._inputLength)throw new Error(`Input must have length ${this._inputLength} but had length ${t.length}`);for(let s=0;s0?(r=!0,s=o,n=e[o]):e[o-1]>0&&e[o]<=0?(r=!1,s!==-1&&t.push(s)):r&&e[o]>n&&(n=e[o],s=o);return t}function St(e,t){let[r,n,s]=[e-1,e,e+1],[o,i,a]=[t[r],t[n],t[s]],c=o/2-i+a/2,h=-(o/2)*(n+s)+i*(r+s)-a/2*(r+n),f=o*n*s/2-i*r*s+a*r*n/2,_=-h/(2*c),v=c*_*_+h*_+f;return[_,v]}var ut=class e{constructor(t,r){w(this,"_autocorrelator");w(this,"_nsdfBuffer");w(this,"_clarityThreshold",.9);w(this,"_minVolumeAbsolute",0);w(this,"_maxInputAmplitude",1);this._autocorrelator=new at(t,r),this._nsdfBuffer=r(t)}static forFloat32Array(t){return new e(t,r=>new Float32Array(r))}static forFloat64Array(t){return new e(t,r=>new Float64Array(r))}static forNumberArray(t){return new e(t,r=>Array(r))}get inputLength(){return this._autocorrelator.inputLength}set clarityThreshold(t){if(!Number.isFinite(t)||t<=0||t>1)throw new Error("clarityThreshold must be a number in the range (0, 1]");this._clarityThreshold=t}set minVolumeAbsolute(t){if(!Number.isFinite(t)||t<0||t>this._maxInputAmplitude)throw new Error(`minVolumeAbsolute must be a number in the range [0, ${this._maxInputAmplitude}]`);this._minVolumeAbsolute=t}set minVolumeDecibels(t){if(!Number.isFinite(t)||t>0)throw new Error("minVolumeDecibels must be a number <= 0");this._minVolumeAbsolute=this._maxInputAmplitude*10**(t/10)}set maxInputAmplitude(t){if(!Number.isFinite(t)||t<=0)throw new Error("maxInputAmplitude must be a number > 0");this._maxInputAmplitude=t}findPitch(t,r){if(this._belowMinimumVolume(t))return[0,0];this._nsdf(t);let n=Et(this._nsdfBuffer);if(n.length===0)return[0,0];let s=Math.max(...n.map(c=>this._nsdfBuffer[c])),o=n.find(c=>this._nsdfBuffer[c]>=this._clarityThreshold*s),[i,a]=St(o,this._nsdfBuffer);return[r/i,Math.min(a,1)]}_belowMinimumVolume(t){if(this._minVolumeAbsolute===0)return!1;let r=0;for(let n=0;n0;n++)this._nsdfBuffer[n]=2*this._nsdfBuffer[n]/r,r-=t[n]**2+t[t.length-n-1]**2;for(;n>1,e|=e>>2,e|=e>>4,e|=e>>8,e|=e>>16,e++,e}export{at as Autocorrelator,ut as PitchDetector}; +//# sourceMappingURL=pitchy.bundle.mjs.map \ No newline at end of file diff --git a/test/js/pitchDetection.test.js b/test/js/pitchDetection.test.js new file mode 100644 index 0000000..28e1777 --- /dev/null +++ b/test/js/pitchDetection.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { detectPitch, freqToMidi, computeRms, createNoteTracker } from '../../public/js/pitchDetection.js' + +const SAMPLE_RATE = 44100 +const BUFFER_SIZE = 4096 + +function sine(frequency, { amplitude = 0.5, length = BUFFER_SIZE } = {}) { + const buffer = new Float32Array(length) + for (let i = 0; i < length; i++) { + buffer[i] = amplitude * Math.sin((2 * Math.PI * frequency * i) / SAMPLE_RATE) + } + return buffer +} + +// Harmonic-rich tone, closer to a real piano: strong upper partials are the +// classic trap for autocorrelation (octave errors). +function pianoLike(frequency, { length = BUFFER_SIZE } = {}) { + const partials = [1, 0.6, 0.4, 0.25, 0.15, 0.08] + const buffer = new Float32Array(length) + for (let i = 0; i < length; i++) { + let sample = 0 + partials.forEach((amp, k) => { + sample += amp * Math.sin((2 * Math.PI * frequency * (k + 1) * i) / SAMPLE_RATE) + }) + buffer[i] = sample * 0.3 + } + return buffer +} + +// Deterministic pseudo-random noise (LCG) so the test can't flake. +function noise({ amplitude = 0.3, length = BUFFER_SIZE } = {}) { + const buffer = new Float32Array(length) + let seed = 42 + for (let i = 0; i < length; i++) { + seed = (seed * 1103515245 + 12345) % 2147483648 + buffer[i] = amplitude * ((seed / 1073741824) - 1) + } + return buffer +} + +describe('detectPitch', () => { + it('detects a pure sine at A4', () => { + const detected = detectPitch(sine(440), SAMPLE_RATE) + expect(Math.abs(detected - 440)).toBeLessThan(2) + }) + + it('detects the fundamental of a harmonic-rich tone (no octave error)', () => { + const detected = detectPitch(pianoLike(220), SAMPLE_RATE) + expect(Math.abs(detected - 220)).toBeLessThan(2) + }) + + it('detects a low note (A1, 55 Hz)', () => { + const detected = detectPitch(pianoLike(55), SAMPLE_RATE) + expect(Math.abs(detected - 55)).toBeLessThan(1) + }) + + it('detects a high note (B6, ~1976 Hz)', () => { + const detected = detectPitch(sine(1975.5), SAMPLE_RATE) + expect(Math.abs(detected - 1975.5)).toBeLessThan(10) + }) + + it('resolves adjacent semitones in the low register (A1 vs A#1)', () => { + const a1 = detectPitch(pianoLike(55), SAMPLE_RATE) + const aSharp1 = detectPitch(pianoLike(58.27), SAMPLE_RATE) + expect(freqToMidi(a1)).toBe(33) + expect(freqToMidi(aSharp1)).toBe(34) + }) + + it('returns null on silence', () => { + expect(detectPitch(new Float32Array(BUFFER_SIZE), SAMPLE_RATE)).toBeNull() + }) + + it('returns null on noise', () => { + expect(detectPitch(noise(), SAMPLE_RATE)).toBeNull() + }) + + it('ignores pitches above the piano detection range', () => { + // ~C8 (4186 Hz) is above maxFreq — must not be reported (and must never + // trigger the app's C8 navigate-back key). + expect(detectPitch(sine(4186), SAMPLE_RATE)).toBeNull() + }) +}) + +describe('freqToMidi', () => { + it('maps reference frequencies to MIDI numbers', () => { + expect(freqToMidi(440)).toBe(69) // A4 + expect(freqToMidi(261.63)).toBe(60) // C4 + expect(freqToMidi(27.5)).toBe(21) // A0 + }) + + it('rounds to the nearest semitone', () => { + expect(freqToMidi(445)).toBe(69) // slightly sharp A4 + expect(freqToMidi(430)).toBe(69) // slightly flat A4 + }) +}) + +describe('computeRms', () => { + it('is 0 for silence and ~amplitude/√2 for a sine', () => { + expect(computeRms(new Float32Array(BUFFER_SIZE))).toBe(0) + expect(computeRms(sine(440, { amplitude: 0.5 }))).toBeCloseTo(0.5 / Math.SQRT2, 2) + }) +}) + +describe('createNoteTracker', () => { + function trackerWithLog(options = {}) { + const events = [] + const tracker = createNoteTracker({ + onNoteOn: (midi) => events.push(['on', midi]), + onNoteOff: (midi) => events.push(['off', midi]), + ...options, + }) + return { tracker, events } + } + + const LOUD = 0.1 + + it('fires note-on only after minOnFrames consistent frames', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2 }) + tracker.push({ midi: 60, rms: LOUD }) + expect(events).toEqual([]) + tracker.push({ midi: 60, rms: LOUD }) + expect(events).toEqual([['on', 60]]) + }) + + it('ignores a single-frame glitch', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2 }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: null, rms: 0 }) + tracker.push({ midi: 72, rms: LOUD }) + tracker.push({ midi: null, rms: 0 }) + expect(events).toEqual([]) + }) + + it('releases the note after offFrames of silence', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2, offFrames: 3 }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: null, rms: 0 }) + tracker.push({ midi: null, rms: 0 }) + expect(events).toEqual([['on', 60]]) + tracker.push({ midi: null, rms: 0 }) + expect(events).toEqual([['on', 60], ['off', 60]]) + }) + + it('switching notes releases the previous one first', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2 }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: 62, rms: LOUD }) + tracker.push({ midi: 62, rms: LOUD }) + expect(events).toEqual([['on', 60], ['off', 60], ['on', 62]]) + }) + + it('treats frames below minRms as silence even when a pitch is reported', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2, minRms: 0.01 }) + tracker.push({ midi: 60, rms: 0.001 }) + tracker.push({ midi: 60, rms: 0.001 }) + expect(events).toEqual([]) + }) + + it('flush releases the active note', () => { + const { tracker, events } = trackerWithLog({ minOnFrames: 2 }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.push({ midi: 60, rms: LOUD }) + tracker.flush() + expect(events).toEqual([['on', 60], ['off', 60]]) + tracker.flush() + expect(events).toHaveLength(2) + }) +})