mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -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.
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.
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 1–2 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 };
|
||||
@@ -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)`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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)}%)`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user