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
View File
View File
+24
View File
@@ -0,0 +1,24 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'tuner'))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes as tuner_routes
@pytest.fixture
def config_dir(tmp_path):
return tmp_path
@pytest.fixture
def client(config_dir):
app = FastAPI()
tuner_routes.setup(app, {
"config_dir": str(config_dir),
"register_tuning_provider": lambda pid, fn: None,
"unregister_tuning_provider": lambda pid: None,
})
return TestClient(app)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
# Real-Instrument WAV Fixtures
Drop WAV recordings from your actual instruments here and they will be picked up automatically by the real-instrument test suite (`tests/js/yin.realinstrument.test.js`).
## Naming Convention
```text
<NoteClass><Octave>_<FreqHz>Hz[_label].wav
```
An optional label suffix (e.g. `_8string`, `_guitar`, `_take2`) can be added between `Hz` and `.wav` — it is ignored by the test runner.
Examples:
| File | Note | Expected frequency |
|------|------|--------------------|
| `E2_82.41Hz.wav` | E2 | 82.41 Hz |
| `E2_82.41Hz_8string.wav` | E2 | 82.41 Hz |
| `A2_110Hz_bass.wav` | A2 | 110.00 Hz |
| `A#3_233.08Hz.wav` | A#3 | 233.08 Hz |
| `D3_146.83Hz.wav` | D3 | 146.83 Hz |
The test asserts that YIN detects a frequency within **±20 cents** of the value in the filename.
## Recording Guidelines
1. **Capture the sustain portion** — not the attack or release. A 12 second clip of a note ringing cleanly is ideal. The test extracts a 4096-sample window from the middle of the file to skip transients.
2. **Length** — aim for ≤ 2 seconds to keep repository size small.
3. **Format****16-bit PCM WAV** (mono or stereo). Export at 44100 Hz or 48000 Hz from your DAW or audio interface.
- 32-bit float WAV is not supported by the parser — convert to 16-bit PCM first.
4. **Channels** — mono or stereo both work. Stereo files are averaged to mono before detection.
5. **Tune first** — play the note in tune against a reference tuner before recording. The file name encodes the expected frequency; a note recorded sharp or flat will fail the test.
## Frequency Reference (standard MIDI A4 = 440 Hz)
| Note | Hz |
|------|----|
| E2 | 82.41 |
| A2 | 110.00 |
| D3 | 146.83 |
| G3 | 196.00 |
| B3 | 246.94 |
| E4 | 329.63 |
| A4 | 440.00 |
| A#3 / Bb3 | 233.08 |
For any other note: `f = 440 * 2^((midi - 69) / 12)`.
## Committing Recordings
These WAV files **are committed to the repository** as regression fixtures. If a code change causes the detected pitch to drift outside ±20 cents, CI will flag it.
> **Repository size note:** Keep clips short (≤ 2 s). If the fixture corpus grows large, Git LFS is a future option — configure it with `git lfs track "tests/fixtures/audio/real/*.wav"`.
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Generate WAV fixture files for YIN pitch detection tests.
Naming convention: <Note><Octave>_<FreqHz>Hz.wav
e.g. A4_440.0Hz.wav → 440.0 Hz sine wave
E2_82.41Hz.wav → 82.41 Hz sine wave
To add more fixtures, just call generate() with the desired note/frequency
and re-run this script, or drop in your own WAV files following the naming
convention (16-bit PCM mono, any sample rate ≥ 8000 Hz).
"""
import math
import struct
import wave
from pathlib import Path
FIXTURES_DIR = Path(__file__).parent / "audio" # tests/plugins/tuner/fixtures/audio/
SAMPLE_RATE = 44100
DURATION_SEC = 0.5
AMPLITUDE = 0.8
FIXTURES = [
("E2", 82.41),
("A2", 110.00),
("D3", 146.83),
("G3", 196.00),
("B3", 246.94),
("E4", 329.63),
("A4", 440.00),
]
def _freq_to_str(hz: float) -> str:
return f"{hz:.2f}".rstrip("0").rstrip(".")
def generate(note: str, freq_hz: float, sample_rate: int = SAMPLE_RATE,
duration_sec: float = DURATION_SEC, amplitude: float = AMPLITUDE) -> Path:
filename = f"{note}_{_freq_to_str(freq_hz)}Hz.wav"
path = FIXTURES_DIR / filename
n = int(sample_rate * duration_sec)
frames = b"".join(
struct.pack("<h", int(amplitude * math.sin(2 * math.pi * freq_hz * i / sample_rate) * 32767))
for i in range(n)
)
with wave.open(str(path), "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(frames)
print(f" wrote {path.name} ({freq_hz} Hz, {n} samples)")
return path
if __name__ == "__main__":
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
print(f"Generating {len(FIXTURES)} WAV fixtures in {FIXTURES_DIR}/")
for note, freq in FIXTURES:
generate(note, freq)
print("Done.")
@@ -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)}%)`
);
});
}
}
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for config logic (module-level helpers) and config read/write via HTTP."""
import json
import pytest
import routes
# ── _migrate_custom_tuning ────────────────────────────────────────────────────
class TestMigrateCustomTuning:
def test_old_flat_list_guitar6(self):
result = routes._migrate_custom_tuning("My Tuning", [82.41, 110.00, 146.83, 196.00, 246.94, 329.63])
assert result == {"instrument": "guitar-6", "strings": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63]}
def test_old_flat_list_bass4(self):
result = routes._migrate_custom_tuning("Drop D Bass", [36.71, 55.00, 73.42, 98.00])
assert result["instrument"] == "bass-4"
assert result["strings"] == [36.71, 55.00, 73.42, 98.00]
def test_old_flat_list_bass5(self):
result = routes._migrate_custom_tuning("5-String", [30.87, 41.20, 55.00, 73.42, 98.00])
assert result["instrument"] == "bass-5"
def test_old_flat_list_guitar7(self):
result = routes._migrate_custom_tuning("7-String", [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63])
assert result["instrument"] == "guitar-7"
def test_old_flat_list_guitar8(self):
strings = [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63]
result = routes._migrate_custom_tuning("8-String", strings)
assert result["instrument"] == "guitar-8"
def test_old_flat_list_unknown_count_defaults_guitar6(self):
result = routes._migrate_custom_tuning("3-String", [100.0, 200.0, 300.0])
assert result["instrument"] == "guitar-6"
def test_new_dict_format_passthrough(self):
value = {"instrument": "bass-4", "strings": [41.20, 55.00, 73.42, 98.00]}
result = routes._migrate_custom_tuning("My Bass", value)
assert result == value
def test_malformed_dict_returns_empty_guitar6(self):
result = routes._migrate_custom_tuning("Bad", {"foo": "bar"})
assert result == {"instrument": "guitar-6", "strings": []}
# ── Config read/write via HTTP ────────────────────────────────────────────────
class TestConfigDefaults:
def test_get_returns_all_default_keys(self, client):
r = client.get("/api/plugins/tuner/config")
assert r.status_code == 200
body = r.json()
assert body["lastTuning"] == "Standard"
assert body["lastInstrument"] == "guitar-6"
assert body["audioInputMode"] == "auto"
assert body["showFloatingButton"] is True
assert body["visualizationMode"] == "default"
assert body["customTunings"] == {}
assert body["disabledTunings"] == []
def test_get_does_not_include_default_tunings(self, client):
# defaultTunings moved to GET /api/tunings (core tuning.read capability).
body = client.get("/api/plugins/tuner/config").json()
assert "defaultTunings" not in body
class TestConfigPersistence:
def test_partial_update_persisted(self, client):
client.post("/api/plugins/tuner/config", json={"lastTuning": "Drop D"})
r = client.get("/api/plugins/tuner/config")
assert r.json()["lastTuning"] == "Drop D"
def test_unmodified_fields_survive_partial_update(self, client):
client.post("/api/plugins/tuner/config", json={"lastTuning": "Drop D"})
client.post("/api/plugins/tuner/config", json={"visualizationMode": "strobe"})
body = client.get("/api/plugins/tuner/config").json()
assert body["lastTuning"] == "Drop D"
assert body["visualizationMode"] == "strobe"
def test_default_tunings_not_written_to_file(self, client, config_dir):
client.post("/api/plugins/tuner/config", json={
"lastTuning": "Open G",
"defaultTunings": {"guitar-6": {"Standard": []}},
})
saved = json.loads((config_dir / "tuner.json").read_text())
assert "defaultTunings" not in saved
def test_invalid_audio_mode_resets_to_auto(self, client):
client.post("/api/plugins/tuner/config", json={"audioInputMode": "invalid"})
body = client.get("/api/plugins/tuner/config").json()
assert body["audioInputMode"] == "auto"
def test_valid_audio_mode_browser_accepted(self, client):
client.post("/api/plugins/tuner/config", json={"audioInputMode": "browser"})
assert client.get("/api/plugins/tuner/config").json()["audioInputMode"] == "browser"
def test_disabled_tunings_strips_entries_without_colon(self, client):
client.post("/api/plugins/tuner/config", json={
"disabledTunings": ["guitar-6:Drop D", "legacy-entry", "bass-4:Standard"]
})
body = client.get("/api/plugins/tuner/config").json()
assert "legacy-entry" not in body["disabledTunings"]
assert "guitar-6:Drop D" in body["disabledTunings"]
assert "bass-4:Standard" in body["disabledTunings"]
def test_custom_tuning_old_format_migrated_on_read(self, client, config_dir):
(config_dir / "tuner.json").write_text(json.dumps({
"customTunings": {"My Tuning": [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]}
}))
body = client.get("/api/plugins/tuner/config").json()
assert body["customTunings"]["My Tuning"]["instrument"] == "guitar-6"
assert isinstance(body["customTunings"]["My Tuning"]["strings"], list)
def test_malformed_config_file_returns_defaults(self, client, config_dir):
(config_dir / "tuner.json").write_text("not json at all {{")
r = client.get("/api/plugins/tuner/config")
assert r.status_code == 200
assert r.json()["lastTuning"] == "Standard"
# ── referencePitch — now a core setting, not a tuner config field ─────────────
# referencePitch moved to GET/POST /api/settings (core tuning.read capability).
# The tuner config must not expose or persist it.
class TestReferencePitchNotInTunerConfig:
def test_not_present_in_default_response(self, client):
body = client.get("/api/plugins/tuner/config").json()
assert "referencePitch" not in body
def test_posting_reference_pitch_is_ignored(self, client):
# POSTing referencePitch must not break the request or leak the field back.
r = client.post("/api/plugins/tuner/config", json={"referencePitch": 432, "lastTuning": "Drop D"})
assert r.status_code == 200
body = client.get("/api/plugins/tuner/config").json()
assert "referencePitch" not in body
assert body["lastTuning"] == "Drop D"
def test_reference_pitch_not_written_to_file(self, client, config_dir):
client.post("/api/plugins/tuner/config", json={"referencePitch": 443})
saved = json.loads((config_dir / "tuner.json").read_text())
assert "referencePitch" not in saved
+41
View File
@@ -0,0 +1,41 @@
"""Integration tests for HTTP file-serving routes and path traversal guards."""
import pytest
class TestFileServing:
def test_get_yin_worker_returns_js(self, client):
r = client.get("/api/plugins/tuner/workers/yin.js")
assert r.status_code == 200
assert "yinDetect" in r.text
assert r.headers["content-type"].startswith("application/javascript")
def test_get_nonexistent_worker_returns_404(self, client):
assert client.get("/api/plugins/tuner/workers/nonexistent.js").status_code == 404
def test_get_nonexistent_viz_returns_404(self, client):
assert client.get("/api/plugins/tuner/visualization/nonexistent.js").status_code == 404
def test_get_nonexistent_utils_returns_404(self, client):
assert client.get("/api/plugins/tuner/utils/nonexistent.js").status_code == 404
class TestPathTraversal:
def test_worker_path_traversal_blocked(self, client):
assert client.get("/api/plugins/tuner/workers/../routes.py").status_code in (404, 422)
def test_viz_path_traversal_blocked(self, client):
assert client.get("/api/plugins/tuner/visualization/../routes.py").status_code in (404, 422)
def test_utils_path_traversal_blocked(self, client):
assert client.get("/api/plugins/tuner/utils/../routes.py").status_code in (404, 422)
class TestConfigEndpoint:
def test_post_returns_ok(self, client):
r = client.post("/api/plugins/tuner/config", json={"lastTuning": "Drop D"})
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_get_config_status_200(self, client):
assert client.get("/api/plugins/tuner/config").status_code == 200