Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
@@ -0,0 +1,80 @@
'use strict';
/**
* Minimal PCM WAV parser for Node.js test environments.
* Supports: mono/stereo, 16-bit and 24-bit signed PCM (audio format 1).
* Mixes all channels down to mono and returns a normalized Float32Array in [-1, 1].
*/
function parseWav(nodeBuffer) {
// Node.js Buffer.buffer is a pooled ArrayBuffer; slice to get an exact view.
const ab = nodeBuffer.buffer.slice(
nodeBuffer.byteOffset,
nodeBuffer.byteOffset + nodeBuffer.byteLength
);
const view = new DataView(ab);
const bytes = new Uint8Array(ab);
function fourCC(offset) {
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
}
if (fourCC(0) !== 'RIFF' || fourCC(8) !== 'WAVE') {
throw new Error('Not a valid WAV file');
}
let audioFormat = 0, numChannels = 0, sampleRate = 0, bitsPerSample = 0;
let dataStart = -1, dataSize = 0;
let offset = 12;
while (offset + 8 <= ab.byteLength) {
const id = fourCC(offset);
const size = view.getUint32(offset + 4, true);
if (id === 'fmt ') {
audioFormat = view.getUint16(offset + 8, true);
numChannels = view.getUint16(offset + 10, true);
sampleRate = view.getUint32(offset + 12, true);
bitsPerSample = view.getUint16(offset + 22, true);
} else if (id === 'data') {
dataStart = offset + 8;
dataSize = size;
break;
}
offset += 8 + size + (size & 1); // chunks are word-aligned
}
if (dataStart === -1) throw new Error('No data chunk found in WAV file');
if (audioFormat !== 1) throw new Error(`Only PCM WAV supported (audio format ${audioFormat})`);
if (bitsPerSample !== 16 && bitsPerSample !== 24) {
throw new Error(`Only 16-bit and 24-bit PCM supported (got ${bitsPerSample}-bit)`);
}
const bytesPerSample = bitsPerSample >> 3;
const bytesPerFrame = numChannels * bytesPerSample;
const nFrames = Math.floor(dataSize / bytesPerFrame);
if (dataStart + nFrames * bytesPerFrame > ab.byteLength) {
throw new Error('WAV data chunk extends beyond end of file');
}
const samples = new Float32Array(nFrames);
for (let i = 0; i < nFrames; i++) {
let sum = 0;
for (let ch = 0; ch < numChannels; ch++) {
const byteOffset = dataStart + i * bytesPerFrame + ch * bytesPerSample;
if (bitsPerSample === 16) {
sum += view.getInt16(byteOffset, true);
} else {
// 24-bit: read 3 bytes little-endian, sign-extend from bit 23.
const lo = bytes[byteOffset];
const mi = bytes[byteOffset + 1];
const hi = bytes[byteOffset + 2];
const raw = (lo | (mi << 8) | (hi << 16));
sum += raw & 0x800000 ? raw - 0x1000000 : raw;
}
}
const scale = bitsPerSample === 16 ? 32768.0 : 8388608.0;
samples[i] = (sum / numChannels) / scale;
}
return { samples, sampleRate };
}
module.exports = { parseWav };
+198
View File
@@ -0,0 +1,198 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
// tuning-utils.js targets browsers (uses window global). Provide a stub so
// the IIFE's final line `window._tunerUtils = {...}` writes into this object.
const window = {};
global.window = window;
require('../../../../plugins/tuner/utils/tuning-utils.js');
const { freqToMidi, midiToFreq, midiToNote, offsetsToFreqs, getTuningName, preferFlats } = window._tunerUtils;
// ── preferFlats ──────────────────────────────────────────────────────────────
test('preferFlats: returns true for "Eb Standard"', () => {
assert.equal(preferFlats('Eb Standard'), true);
});
test('preferFlats: returns true for "Bb Standard"', () => {
assert.equal(preferFlats('Bb Standard'), true);
});
test('preferFlats: returns true for "Ab Standard"', () => {
assert.equal(preferFlats('Ab Standard'), true);
});
test('preferFlats: returns true for "Db Standard"', () => {
assert.equal(preferFlats('Db Standard'), true);
});
test('preferFlats: returns false for "Standard"', () => {
assert.equal(preferFlats('Standard'), false);
});
test('preferFlats: returns false for "Drop D"', () => {
assert.equal(preferFlats('Drop D'), false);
});
test('preferFlats: returns false for "D Standard"', () => {
assert.equal(preferFlats('D Standard'), false);
});
test('preferFlats: returns false for "Drop C#"', () => {
assert.equal(preferFlats('Drop C#'), false);
});
test('preferFlats: returns false for null', () => {
assert.equal(preferFlats(null), false);
});
test('preferFlats: returns false for undefined', () => {
assert.equal(preferFlats(undefined), false);
});
test('preferFlats: returns false for empty string', () => {
assert.equal(preferFlats(''), false);
});
// ── midiToNote (sharp names) ──────────────────────────────────────────────────
test('midiToNote: A4 (midi 69) is "A" regardless of useFlats', () => {
assert.equal(midiToNote(69, false), 'A');
assert.equal(midiToNote(69, true), 'A');
});
test('midiToNote: C4 (midi 60) is "C" regardless of useFlats', () => {
assert.equal(midiToNote(60, false), 'C');
assert.equal(midiToNote(60, true), 'C');
});
test('midiToNote: midi 61 is "C#" with sharps', () => {
assert.equal(midiToNote(61, false), 'C#');
});
test('midiToNote: midi 61 is "Db" with flats', () => {
assert.equal(midiToNote(61, true), 'Db');
});
test('midiToNote: midi 63 is "D#" with sharps', () => {
assert.equal(midiToNote(63, false), 'D#');
});
test('midiToNote: midi 63 is "Eb" with flats', () => {
assert.equal(midiToNote(63, true), 'Eb');
});
test('midiToNote: midi 66 is "F#" with sharps', () => {
assert.equal(midiToNote(66, false), 'F#');
});
test('midiToNote: midi 66 is "Gb" with flats', () => {
assert.equal(midiToNote(66, true), 'Gb');
});
test('midiToNote: midi 68 is "G#" with sharps', () => {
assert.equal(midiToNote(68, false), 'G#');
});
test('midiToNote: midi 68 is "Ab" with flats', () => {
assert.equal(midiToNote(68, true), 'Ab');
});
test('midiToNote: midi 70 is "A#" with sharps', () => {
assert.equal(midiToNote(70, false), 'A#');
});
test('midiToNote: midi 70 is "Bb" with flats', () => {
assert.equal(midiToNote(70, true), 'Bb');
});
test('midiToNote: natural notes are identical regardless of useFlats (E)', () => {
// E2 = midi 40
assert.equal(midiToNote(40, false), midiToNote(40, true));
});
test('midiToNote: natural notes are identical regardless of useFlats (B)', () => {
// B3 = midi 59
assert.equal(midiToNote(59, false), midiToNote(59, true));
});
test('midiToNote: works with fractional midi (rounds to nearest semitone)', () => {
// 60.4 rounds to 60 → C
assert.equal(midiToNote(60.4, false), 'C');
// 60.6 rounds to 61 → C#
assert.equal(midiToNote(60.6, false), 'C#');
});
test('midiToNote: handles midi values below C4 (wraps correctly)', () => {
// midi 48 = C3
assert.equal(midiToNote(48, false), 'C');
// midi 47 = B2
assert.equal(midiToNote(47, false), 'B');
});
// ── freqToMidi / midiToFreq round-trip ───────────────────────────────────────
test('freqToMidi(440) equals 69', () => {
assert.ok(Math.abs(freqToMidi(440) - 69) < 0.001);
});
test('midiToFreq(69) equals 440 Hz', () => {
assert.ok(Math.abs(midiToFreq(69) - 440) < 0.01);
});
test('freqToMidi / midiToFreq round-trip within 0.01 Hz', () => {
for (const freq of [82.41, 110, 146.83, 196, 246.94, 329.63]) {
const back = midiToFreq(freqToMidi(freq));
assert.ok(Math.abs(back - freq) < 0.01, `round-trip failed for ${freq} Hz`);
}
});
// ── offsetsToFreqs ────────────────────────────────────────────────────────────
test('offsetsToFreqs: all-zero 6-string is E Standard open strings', () => {
const freqs = offsetsToFreqs([0, 0, 0, 0, 0, 0], false);
// E2 A2 D3 G3 B3 E4 = 82.41 110 146.83 196 246.94 329.63
const expected = [82.41, 110.00, 146.83, 196.00, 246.94, 329.63];
freqs.forEach((f, i) => assert.ok(Math.abs(f - expected[i]) < 0.5, `string ${i}: ${f} vs ${expected[i]}`));
});
test('offsetsToFreqs: -1 offset on all strings gives Eb Standard', () => {
const freqs = offsetsToFreqs([-1, -1, -1, -1, -1, -1], false);
const eStandard = offsetsToFreqs([0, 0, 0, 0, 0, 0], false);
// Each string should be one semitone below E Standard
freqs.forEach((f, i) => {
const ratio = eStandard[i] / f;
assert.ok(Math.abs(ratio - Math.pow(2, 1/12)) < 0.01, `string ${i} ratio off`);
});
});
test('offsetsToFreqs: all-zero 4-string bass is E Standard bass', () => {
const freqs = offsetsToFreqs([0, 0, 0, 0], true);
// E1 A1 D2 G2 = 41.20 55.00 73.42 98.00
const expected = [41.20, 55.00, 73.42, 98.00];
freqs.forEach((f, i) => assert.ok(Math.abs(f - expected[i]) < 0.5, `string ${i}: ${f} vs ${expected[i]}`));
});
// ── getTuningName ─────────────────────────────────────────────────────────────
test('getTuningName: all zeros → "E Standard"', () => {
assert.equal(getTuningName([0, 0, 0, 0, 0, 0]), 'E Standard');
});
test('getTuningName: all -1 → "Eb Standard"', () => {
assert.equal(getTuningName([-1, -1, -1, -1, -1, -1]), 'Eb Standard');
});
test('getTuningName: Drop D pattern → "Drop D"', () => {
assert.equal(getTuningName([-2, 0, 0, 0, 0, 0]), 'Drop D');
});
test('getTuningName: empty → "Unknown"', () => {
assert.equal(getTuningName([]), 'Unknown');
});
test('getTuningName: null → "Unknown"', () => {
assert.equal(getTuningName(null), 'Unknown');
});
@@ -0,0 +1,68 @@
'use strict';
/**
* Real-instrument WAV fixture tests for the YIN pitch detector.
*
* Drop a 16-bit PCM WAV recording (mono or stereo) named
* <NoteClass><Octave>_<FreqHz>Hz.wav into tests/plugins/tuner/fixtures/audio/real/
* and it is automatically picked up and tested.
*
* Examples:
* E2_82.41Hz.wav → expected 82.41 Hz (open low-E on guitar)
* A#3_233.08Hz.wav → expected 233.08 Hz (A#3)
*
* Tolerance: ±20 cents (computed as 1200 * |log2(detected / expected)|).
* This is tighter than the synthetic-fixture suite (≈34 cents at 2%) to
* surface real-world detection regressions.
*
* See tests/plugins/tuner/fixtures/audio/real/README.md for recording guidelines.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { _yinDetect } = require('../../../../plugins/tuner/workers/yin.js');
const { parseWav } = require('./helpers/wav-parser.js');
const FIXTURES_DIR = path.join(__dirname, '../fixtures/audio/real');
const WAV_NAME_RE = /^([A-G]#?[0-9])_([\d.]+)Hz[^.]*\.wav$/i;
const MIN_FRAME = 4096;
const TOLERANCE_CENTS = 20;
const wavFiles = fs.readdirSync(FIXTURES_DIR).filter(f => WAV_NAME_RE.test(f));
if (wavFiles.length === 0) {
test('Real-instrument WAV fixtures', {
skip: 'No real-instrument WAV fixtures found in tests/plugins/tuner/fixtures/audio/real/ — record a note and name it e.g. E2_82.41Hz.wav'
}, () => {});
} else {
for (const filename of wavFiles) {
const [, note, freqStr] = filename.match(WAV_NAME_RE);
const expectedHz = parseFloat(freqStr);
test(`Real WAV ${filename}: detects ${freqStr} Hz (${note})`, () => {
const fileBuffer = fs.readFileSync(path.join(FIXTURES_DIR, filename));
const { samples, sampleRate } = parseWav(fileBuffer);
assert.ok(
samples.length >= MIN_FRAME,
`WAV too short: need ≥${MIN_FRAME} samples, got ${samples.length}`
);
// Extract MIN_FRAME samples from the middle to skip attack/release transients.
const mid = Math.floor(samples.length / 2);
const start = Math.max(0, mid - MIN_FRAME / 2);
const frame = samples.slice(start, start + MIN_FRAME);
const result = _yinDetect(frame, sampleRate);
assert.ok(result.freq > 0, `No pitch detected for ${filename} (rms=${result.rms.toFixed(4)})`);
const centsError = 1200 * Math.abs(Math.log2(result.freq / expectedHz));
assert.ok(
centsError <= TOLERANCE_CENTS,
`${filename}: expected ${expectedHz} Hz, got ${result.freq.toFixed(2)} Hz (${centsError.toFixed(1)} cents off — limit ${TOLERANCE_CENTS} cents)`
);
});
}
}
+96
View File
@@ -0,0 +1,96 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { _yinDetect } = require('../../../../plugins/tuner/workers/yin.js');
const SAMPLE_RATE = 44100;
const FRAME = 4096;
function sine(freq, nSamples, amplitude = 0.8) {
const buf = new Float32Array(nSamples);
for (let i = 0; i < nSamples; i++) {
buf[i] = amplitude * Math.sin(2 * Math.PI * freq * i / SAMPLE_RATE);
}
return buf;
}
function assertFreq(result, expected, tolerancePct = 1.5, label = '') {
assert.ok(result.freq > 0, `${label}: expected a detected frequency, got 0`);
const errorPct = Math.abs(result.freq - expected) / expected * 100;
assert.ok(
errorPct < tolerancePct,
`${label}: expected ${expected} Hz ±${tolerancePct}%, got ${result.freq.toFixed(2)} Hz (error ${errorPct.toFixed(2)}%)`
);
}
// ── Silence / no-signal ──────────────────────────────────────────────────────
test('silence (all zeros) returns freq 0', () => {
const result = _yinDetect(new Float32Array(FRAME), SAMPLE_RATE);
assert.equal(result.freq, 0);
assert.equal(result.confidence, 0);
});
test('sub-threshold amplitude returns freq 0', () => {
// rms < 0.01 must be rejected before any pitch estimation
const buf = sine(440, FRAME, 0.001);
const result = _yinDetect(buf, SAMPLE_RATE);
assert.equal(result.freq, 0);
});
// ── Clean sine pitches — guitar range ────────────────────────────────────────
test('detects E2 (82.41 Hz) — low E string', () => {
assertFreq(_yinDetect(sine(82.41, FRAME), SAMPLE_RATE), 82.41, 1.5, 'E2');
});
test('detects A2 (110 Hz) — A string', () => {
assertFreq(_yinDetect(sine(110, FRAME), SAMPLE_RATE), 110, 1.5, 'A2');
});
test('detects D3 (146.83 Hz) — D string', () => {
assertFreq(_yinDetect(sine(146.83, FRAME), SAMPLE_RATE), 146.83, 1.5, 'D3');
});
test('detects G3 (196 Hz) — G string', () => {
assertFreq(_yinDetect(sine(196, FRAME), SAMPLE_RATE), 196, 1.5, 'G3');
});
test('detects B3 (246.94 Hz) — B string', () => {
assertFreq(_yinDetect(sine(246.94, FRAME), SAMPLE_RATE), 246.94, 1.5, 'B3');
});
test('detects E4 (329.63 Hz) — high E string', () => {
assertFreq(_yinDetect(sine(329.63, FRAME), SAMPLE_RATE), 329.63, 1.5, 'E4');
});
test('detects A4 (440 Hz) — concert A', () => {
assertFreq(_yinDetect(sine(440, FRAME), SAMPLE_RATE), 440, 1.5, 'A4');
});
// ── Confidence ────────────────────────────────────────────────────────────────
test('high confidence for clean sine at A4', () => {
const result = _yinDetect(sine(440, FRAME), SAMPLE_RATE);
assert.ok(result.confidence > 0.8, `Expected confidence > 0.8, got ${result.confidence.toFixed(3)}`);
});
test('rms reflects signal level', () => {
const quiet = _yinDetect(sine(440, FRAME, 0.1), SAMPLE_RATE);
const loud = _yinDetect(sine(440, FRAME, 0.9), SAMPLE_RATE);
assert.ok(loud.rms > quiet.rms, 'louder signal should have higher rms');
});
// ── Edge cases ────────────────────────────────────────────────────────────────
test('minimum buffer size (4096 samples) does not throw', () => {
assert.doesNotThrow(() => _yinDetect(sine(440, 4096), SAMPLE_RATE));
});
test('returns object with freq, confidence, rms keys', () => {
const result = _yinDetect(sine(440, FRAME), SAMPLE_RATE);
assert.ok('freq' in result, 'missing freq');
assert.ok('confidence' in result, 'missing confidence');
assert.ok('rms' in result, 'missing rms');
});
+65
View File
@@ -0,0 +1,65 @@
'use strict';
/**
* WAV fixture integration tests for the YIN pitch detector.
*
* Drop any 16-bit PCM WAV file named <Note><Octave>_<FreqHz>Hz.wav into
* tests/plugins/tuner/fixtures/audio/ and it is automatically picked up and tested.
*
* Examples:
* A4_440.0Hz.wav → expected 440.0 Hz
* E2_82.41Hz.wav → expected 82.41 Hz
* D3_146.83Hz.wav → expected 146.83 Hz
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { _yinDetect } = require('../../../../plugins/tuner/workers/yin.js');
const { parseWav } = require('./helpers/wav-parser.js');
const FIXTURES_DIR = path.join(__dirname, '../fixtures/audio');
const WAV_NAME_RE = /^([A-G]#?[0-9])_([\d.]+)Hz[^.]*\.wav$/i;
const MIN_FRAME = 4096;
// 2% tolerance ≈ 35 cents — generous enough for real recordings, tight enough
// to catch octave errors or misidentified notes.
const TOLERANCE_PCT = 2;
const wavFiles = fs.readdirSync(FIXTURES_DIR).filter(f => WAV_NAME_RE.test(f));
if (wavFiles.length === 0) {
test('WAV fixtures', {
skip: 'No WAV fixtures found in tests/plugins/tuner/fixtures/audio/ — run: python tests/plugins/tuner/fixtures/generate_audio.py'
}, () => {});
} else {
for (const filename of wavFiles) {
const [, note, freqStr] = filename.match(WAV_NAME_RE);
const expectedHz = parseFloat(freqStr);
test(`WAV ${filename}: detects ${expectedHz} Hz (${note})`, () => {
const fileBuffer = fs.readFileSync(path.join(FIXTURES_DIR, filename));
const { samples, sampleRate } = parseWav(fileBuffer);
assert.ok(
samples.length >= MIN_FRAME,
`WAV too short: need ≥${MIN_FRAME} samples, got ${samples.length}`
);
// Take MIN_FRAME samples from the middle of the file to avoid
// attack/release transients at the edges.
const mid = Math.floor(samples.length / 2);
const start = Math.max(0, mid - MIN_FRAME / 2);
const frame = samples.slice(start, start + MIN_FRAME);
const result = _yinDetect(frame, sampleRate);
assert.ok(result.freq > 0, `No pitch detected for ${filename} (rms=${result.rms.toFixed(4)})`);
const errorPct = Math.abs(result.freq - expectedHz) / expectedHz * 100;
assert.ok(
errorPct < TOLERANCE_PCT,
`${filename}: expected ${expectedHz} Hz ±${TOLERANCE_PCT}%, got ${result.freq.toFixed(2)} Hz (error ${errorPct.toFixed(2)}%)`
);
});
}
}