Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ web-ext.config.ts
.browsers
# Generated once by `node e2e/make-tone.mjs` / `node e2e/make-stereo-mix.mjs`.
e2e/fixtures/tone-440.wav
e2e/fixtures/tone-432.wav
e2e/fixtures/stereo-mix.wav
# Generated at postinstall / build:before from the @echogarden/rubberband-wasm
# glue (rb.wasm itself is committed). See scripts/build-rubberband-worklet.mjs.
Expand Down
9 changes: 6 additions & 3 deletions e2e/make-tone.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// Generates a 30s 440 Hz sine WAV used by the E2E test page.
// Generates the 30s sine WAVs used by the E2E test page: tone-440.wav (the
// main track) and tone-432.wav (a detuned copy for reference-tuning detection).
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const dir = dirname(fileURLToPath(import.meta.url));
const out = resolve(dir, 'fixtures', 'tone-440.wav');

for (const freq of [440, 432]) writeTone(freq, resolve(dir, 'fixtures', `tone-${freq}.wav`));

function writeTone(freq, out) {
const sampleRate = 44100;
const seconds = 30;
const freq = 440;
const samples = sampleRate * seconds;
const dataSize = samples * 2;
const buf = Buffer.alloc(44 + dataSize);
Expand All @@ -35,3 +37,4 @@ for (let i = 0; i < samples; i++) {
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, buf);
console.log(`wrote ${out}`);
}
38 changes: 37 additions & 1 deletion e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const server = createServer((req, res) => {
'/stereo-mix.wav',
),
);
} else if (url.pathname === '/tone-440.wav' || url.pathname === '/stereo-mix.wav') {
} else if (/^\/(tone-440|tone-432|stereo-mix)\.wav$/.test(url.pathname)) {
const wav = readFileSync(join(dir, 'fixtures', url.pathname.slice(1)));
const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? '');
if (range) {
Expand Down Expand Up @@ -124,6 +124,11 @@ try {

// Open the media page.
const mediaPage = await browser.newPage();
mediaPage.on('console', (msg) => {
if (msg.type() === 'error' || msg.text().includes('note-by-note')) {
console.log(`media console [${msg.type()}]`, msg.text());
}
});
await mediaPage.goto(`http://localhost:${PORT}/test-page.html`);

// Resolve its tabId via the background SW.
Expand Down Expand Up @@ -193,6 +198,37 @@ try {
`${basePitch?.toFixed(1)} Hz`,
);

// ── Reference-tuning detection: swap in a 432 Hz tone, DETECT → trackHz 432,
// and the correction to the (440) target must lift the output back to 440. ──
await probe(`(() => { const el = document.getElementById('player'); el.src = '/tone-432.wav'; return el.play().catch(() => {}); })()`);
await new Promise((r) => setTimeout(r, 2500));
const detuned = await probe('window.__noteByNoteDebug.outputPitch()');
check('432 Hz tone playing', Math.abs(detuned - 432) < 6, `${detuned?.toFixed(1)} Hz`);
await panelPage.bringToFront();
const pitchExpand = await panelPage.$('section[aria-label="Pitch"] button[aria-label="Expand"]');
if (pitchExpand) await pitchExpand.click();
await new Promise((r) => setTimeout(r, 300));
const detectTuning = await panelPage.$('section[aria-label="Pitch"] button[aria-label="Detect song tuning"]');
check('tuning DETECT button found', !!detectTuning);
if (detectTuning) {
const enabledBtn = await detectTuning.evaluate((el) => !el.disabled);
check('tuning DETECT enabled while playing', enabledBtn);
await detectTuning.click();
await new Promise((r) => setTimeout(r, 6000));
const tuning = (await probe('window.__noteByNoteDebug.params()'))?.tuning;
check('detected song tuning = 432 Hz', tuning?.trackHz === 432, JSON.stringify(tuning));
const corrected = await probe('window.__noteByNoteDebug.outputPitch()');
check('tuning correction lifts output to ≈ 440 Hz', Math.abs(corrected - 440) < 6, `${corrected?.toFixed(1)} Hz`);
const pitchReset = await panelPage.$('section[aria-label="Pitch"] button[aria-label="Reset Pitch"]');
check('pitch reset button shown', !!pitchReset);
if (pitchReset) await pitchReset.click();
}
// Back to the 440 Hz track for the remaining checks.
await probe(`(() => { const el = document.getElementById('player'); el.src = '/tone-440.wav'; return el.play().catch(() => {}); })()`);
await new Promise((r) => setTimeout(r, 2500));
const restored = await probe('window.__noteByNoteDebug.outputPitch()');
check('440 Hz tone restored', Math.abs(restored - 440) < 6, `${restored?.toFixed(1)} Hz`);

// ── Transpose +12 via the side panel UI (stepper hold not needed: click 12×) ──
await panelPage.bringToFront();
const plus = await panelPage.$('section[aria-label="Transpose"] button[aria-label="Increase Transpose"]');
Expand Down
58 changes: 57 additions & 1 deletion src/core/engine/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// the controller.
import { attachAudio } from '@/core/engine/attach-audio';
import { detectBpmFromAnalyser } from '@/features/speed/engine/detect-bpm';
import { detectTuningFromAnalyser } from '@/features/pitch/engine/detect-tuning';
import type { PcmTap } from '@/features/chords/engine/pcm-tap';
import type { AudioPipeline } from '@/core/audio/pipeline';
import {
Expand Down Expand Up @@ -110,10 +111,12 @@ export class Controller {
ports = new Set<UiPort>();
/** Whether the DSP chain is attached ('direct') or blocked ('unavailable'). */
pitchMode: 'pending' | 'direct' | 'unavailable' = 'pending';
/** The attached pipeline (analyser tap for BPM detection); null when blocked. */
/** The attached pipeline (analyser tap for BPM/tuning detection); null when blocked. */
#pipeline: AudioPipeline | null = null;
/** True while a BPM detection run is in flight (blocks overlapping runs). */
#detectingBpm = false;
/** True while a reference-tuning detection run is in flight. */
#detectingTuning = false;
/** User intent: stream PCM to the panel for chord detection while on. */
#chordEnabled = false;
/** The silent PCM tap (lives on the pipeline), while streaming is active. */
Expand Down Expand Up @@ -376,6 +379,7 @@ export class Controller {
// A run in flight aborts via #abortDetect (engine identity change); flip the
// flag now so its 'bpm' completion event still reports detecting:false.
this.#detectingBpm = false;
this.#detectingTuning = false;
// Stop PCM streaming — the pipeline (and its tap) is gone. #chordEnabled
// persists so streaming resumes when the next chain attaches.
this.#stopPcm();
Expand Down Expand Up @@ -414,6 +418,55 @@ export class Controller {
}
}

/** Measure the recording's reference A4 and report it (Hz). Direct/local
* only — the analyser tap lives here. The tap is pre-stretch and the element
* plays with preservesPitch, so speed/transpose don't colour the reading. */
async #detectTuning() {
const engine = this.engine;
if (
this.#detectingTuning ||
this.pitchMode !== 'direct' ||
!engine ||
!engine.playing ||
!this.#pipeline
) {
console.debug('[note-by-note] tuning: detect skipped', {
inFlight: this.#detectingTuning,
pitchMode: this.pitchMode,
playing: engine?.playing ?? null,
hasPipeline: !!this.#pipeline,
});
return;
}
this.#detectingTuning = true;
this.broadcast({ type: 'tuning', detecting: true, hz: null });
let hz: number | null = null;
try {
const est = await detectTuningFromAnalyser(
this.#pipeline.analyser,
{ durationMs: 4000 },
() => this.#abortDetect(engine),
);
const aborted = this.#abortDetect(engine);
const d = est.details;
// One line per run: enough to tell a weak signal from a refused one.
console.debug('[note-by-note] tuning: estimate', {
hz: est.hz,
confidence: Number(est.confidence.toFixed(3)),
aborted,
frames: `${d.usedFrames}/${d.frames}`,
devCents: Number(d.devCents.toFixed(1)),
runnerUpCents: d.runnerUpCents,
});
if (est.hz != null && !aborted) hz = est.hz;
} catch (err) {
console.warn('[note-by-note] tuning: detection failed', err);
} finally {
this.#detectingTuning = false;
this.broadcast({ type: 'tuning', detecting: false, hz });
}
}

/** True once detection should give up: element/pipeline swapped or paused. */
#abortDetect(engine: MediaEngine): boolean {
return (
Expand Down Expand Up @@ -571,6 +624,9 @@ export class Controller {
case 'detectBpm':
void this.#detectBpm();
break;
case 'detectTuning':
void this.#detectTuning();
break;
case 'chordDetect':
this.#chordDetect(cmd.on);
break;
Expand Down
3 changes: 3 additions & 0 deletions src/core/messaging/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import type { ChordCommand, ChordEngineEvent } from '../../features/chords/protocol';
import type { CountInEngineEvent } from '../../features/count-in/protocol';
import type { LoopCommand, LoopEngineEvent } from '../../features/loops/protocol';
import type { PitchCommand, PitchEngineEvent } from '../../features/pitch/protocol';
import type { SnippetCommand, SnippetEngineEvent } from '../../features/snippets/protocol';
import type { SpeedCommand, SpeedEngineEvent } from '../../features/speed/protocol';

Expand Down Expand Up @@ -71,6 +72,7 @@ export type EngineEvent =
| CountInEngineEvent
| SnippetEngineEvent
| SpeedEngineEvent
| PitchEngineEvent
| ChordEngineEvent;

/** Core side panel → engine commands: transport/params/volume/settings. */
Expand Down Expand Up @@ -106,6 +108,7 @@ export type EngineCommand =
| LoopCommand
| SnippetCommand
| SpeedCommand
| PitchCommand
| ChordCommand;

/** Background → offscreen document (runtime messages, offscreen filters by target). */
Expand Down
2 changes: 1 addition & 1 deletion src/core/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface EffectParams {
* the backing. Both use the same amount slider. */
vocalMode: 'reduce' | 'isolate';
eq: { enabled: boolean; gains: number[] };
/** Reference tuning: recording's and instrument's A4 in Hz. */
/** Reference tuning: the song's A4 (detectable) and the A4 to change it to, in Hz. */
tuning: { trackHz: number; instrumentHz: number };
/** false = processing bypass (Power toggle). */
power: boolean;
Expand Down
42 changes: 42 additions & 0 deletions src/core/state/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ class SessionStore {
/** Briefly true after a detection run that found no tempo (drives the hint). */
bpmNoResult = $state(false);
#bpmHintTimer: ReturnType<typeof setTimeout> | undefined;
/** True while the engine is measuring the reference tuning (DETECT spinner). */
tuningDetecting = $state(false);
/** Briefly true after a tuning run that found nothing pitched (drives the hint). */
tuningNoResult = $state(false);
/** The A4 a successful run just measured, shown on the button for a moment. */
tuningResult = $state<number | null>(null);
#tuningHintTimer: ReturnType<typeof setTimeout> | undefined;
/** True between a source swap (SPA navigation) and the next media info:
* the mirrored duration/markers/snippets belong to the OLD track, so seeks
* issued from them would land on the new video at meaningless positions. */
Expand Down Expand Up @@ -88,8 +95,12 @@ class SessionStore {
this.countIn = null;
this.bpmDetecting = false;
this.bpmNoResult = false;
this.tuningDetecting = false;
this.tuningNoResult = false;
this.tuningResult = null;
this.#dspBlocked = false;
clearTimeout(this.#bpmHintTimer);
clearTimeout(this.#tuningHintTimer);
this.onEngineDetached?.();
}

Expand Down Expand Up @@ -182,6 +193,31 @@ class SessionStore {
this.#bpmHintTimer = setTimeout(() => (this.bpmNoResult = false), 3000);
}
break;
case 'tuning':
this.tuningDetecting = event.detecting;
if (event.detecting) {
this.tuningNoResult = false;
this.tuningResult = null;
clearTimeout(this.#tuningHintTimer);
} else if (event.hz != null) {
// Store the measured A4 through the user-param path so it persists
// per-track (like a manual entry). Any fine-tune the user had dialled
// in was a guess at this very offset — the measurement replaces it.
this.patchParams({
tuning: { ...this.params.tuning, trackHz: event.hz },
pitchCents: 0,
});
// Flash the measured value on the button for a moment.
this.tuningResult = event.hz;
clearTimeout(this.#tuningHintTimer);
this.#tuningHintTimer = setTimeout(() => (this.tuningResult = null), 1500);
} else {
// Finished without anything pitched to measure — flash a brief hint.
this.tuningNoResult = true;
clearTimeout(this.#tuningHintTimer);
this.#tuningHintTimer = setTimeout(() => (this.tuningNoResult = false), 3000);
}
break;
case 'error':
this.lastError = { code: event.code, detail: event.detail };
break;
Expand Down Expand Up @@ -279,6 +315,12 @@ class SessionStore {
this.send({ type: 'detectBpm' });
}

/** Ask the engine to measure the song's reference A4 and set
* `tuning.trackHz`. The engine replies with 'tuning' events (see apply). */
detectTuning() {
this.send({ type: 'detectTuning' });
}

/** True when every given param still holds its default value. */
isDefault(keys: (keyof EffectParams)[]): boolean {
return keys.every(
Expand Down
87 changes: 87 additions & 0 deletions src/features/pitch/engine/detect-tuning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Run with: pnpm test:dsp (node --test, Node 24 type-stripping — hence the
// explicit .ts import extension).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { estimateTuningFromSpectra } from './detect-tuning.ts';
import { ComplexFft, makeHannWindow } from '../../../core/audio/fft.ts';

const SR = 48000;
const N = 32768;

/** Deterministic pseudo-random in [−1, 1] (LCG). */
function makeNoise(len: number, seed = 12345): Float32Array {
const out = new Float32Array(len);
let s = seed >>> 0;
for (let i = 0; i < len; i++) {
s = (s * 1664525 + 1013904223) >>> 0;
out[i] = s / 0x80000000 - 1;
}
return out;
}

/** dB-magnitude spectrum of a windowed frame (AnalyserNode-style layout). */
function spectrumOf(frame: Float32Array): Float32Array {
const fft = new ComplexFft(N);
const win = makeHannWindow(N);
const re = new Float32Array(N);
const im = new Float32Array(N);
for (let i = 0; i < N; i++) re[i] = frame[i] * win[i];
fft.forward(re, im);
const out = new Float32Array(N / 2);
for (let k = 0; k < N / 2; k++) {
out[k] = 20 * Math.log10(Math.hypot(re[k], im[k]) / N + 1e-12);
}
return out;
}

/** A chord of ET pitches (MIDI notes, with a few harmonics) tuned to `a4`,
* over a bed of noise. */
function chordFrame(a4: number, midi: number[], noiseAmp: number): Float32Array {
const frame = makeNoise(N);
for (let i = 0; i < N; i++) frame[i] *= noiseAmp;
for (const m of midi) {
const f0 = a4 * 2 ** ((m - 69) / 12);
for (let h = 1; h <= 4; h++) {
const amp = 0.3 / h;
const f = f0 * h;
for (let i = 0; i < N; i++) {
frame[i] += amp * Math.sin((2 * Math.PI * f * i) / SR);
}
}
}
return frame;
}

for (const a4 of [432, 440, 442, 445]) {
test(`recovers A4 = ${a4} Hz from an ET chord`, () => {
const spectra = [
spectrumOf(chordFrame(a4, [48, 55, 60, 64, 67], 0.01)),
spectrumOf(chordFrame(a4, [45, 52, 57, 60, 64], 0.01)),
];
const est = estimateTuningFromSpectra(spectra, SR, N, { minFrames: 2 });
assert.equal(est.hz, a4);
assert.ok(est.confidence > 0.3, `confidence ${est.confidence}`);
assert.equal(est.details.usedFrames, 2);
});
}

test('silence yields no tuning', () => {
const silent = new Float32Array(N / 2).fill(-Infinity);
const est = estimateTuningFromSpectra([silent], SR, N);
assert.equal(est.hz, null);
});

test('broadband noise yields no tuning', () => {
// A realistic run's worth of frames (~4 s at the extension's cadence).
const spectra = Array.from({ length: 10 }, (_, i) => spectrumOf(makeNoise(N, i + 1)));
const est = estimateTuningFromSpectra(spectra, SR, N);
assert.equal(est.hz, null, `got ${est.hz} at confidence ${est.confidence}`);
});

test('too few frames yields no tuning even when confident', () => {
const spectra = [spectrumOf(chordFrame(442, [48, 55, 60, 64, 67], 0.01))];
const est = estimateTuningFromSpectra(spectra, SR, N, { minFrames: 3 });
assert.equal(est.hz, null);
assert.ok(est.confidence > 0.3, 'the comb itself was confident');
assert.equal(est.details.accepted, false);
});
Loading
Loading