notepilot / scripts /mic_loop.mjs
jasonmdong's picture
Sync from GitHub via hub-sync
11330a4 verified
Raw History Blame Contribute Delete
15.4 kB
#!/usr/bin/env node
// Audible browser → speaker → real microphone test. Requires an existing
// NotePilot Chrome tab with CDP on :9222. Outputs a NEW file, never latest.json.
import fs from 'node:fs';
import path from 'node:path';
import { parseArgs } from 'node:util';
import { connectNotePilot, nextAttempt, retryAlreadyCommitted, summariseRun } from './mic_loop_support.mjs';
const { values: args } = parseArgs({ options: {
part: { type: 'string', default: '1' }, beats: { type: 'string', default: '30' },
seconds: { type: 'string', default: '120' }, bpm: { type: 'string', default: '60' },
'settle-seconds': { type: 'string', default: '8' },
mic: { type: 'string', default: 'default' },
speaker: { type: 'string', default: 'default' },
volume: { type: 'string', default: '0.5' }, out: { type: 'string' },
'max-retries': { type: 'string', default: '6' },
opt: { type: 'string', multiple: true, default: [] },
'no-calibrate': { type: 'boolean', default: false },
'allow-hidden': { type: 'boolean', default: false },
'record-reference': { type: 'boolean', default: false },
'reference-role': { type: 'string', default: 'accompaniment' },
reference: { type: 'boolean', default: false },
'solo-only': { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
} });
if (args.help) {
console.log('node scripts/mic_loop.mjs --part 1 --beats 30 --out /tmp/left.json [--reference --solo-only] [--volume 0.5] [--settle-seconds 8] [--no-calibrate] [--allow-hidden] [--record-reference --reference-role solo|accompaniment] [--opt win=4096]');
process.exit(0);
}
const outPath = path.resolve(args.out || '');
if (!args.out || fs.existsSync(outPath) || outPath.includes(`${path.sep}.accompy_traces${path.sep}`)) {
throw Error('--out must name a new scratch file outside .accompy_traces');
}
const part = Number(args.part), beats = Number(args.beats), bpm = Number(args.bpm);
const seconds = Number(args.seconds), volume = Number(args.volume), settleSeconds = Number(args['settle-seconds']), maxRetries = Number(args['max-retries']);
if (![part, beats, bpm, seconds, volume, settleSeconds].every(Number.isFinite)
|| !Number.isInteger(part) || part < 0 || !(beats > 0) || !(seconds > 0)
|| !(bpm > 0) || !(settleSeconds >= 0) || !Number.isFinite(volume) || volume < 0 || volume > 1 || !Number.isInteger(maxRetries) || maxRetries < 0) throw Error('Invalid run settings');
if (!['solo', 'accompaniment'].includes(args['reference-role'])) throw Error('--reference-role must be solo or accompaniment');
const overrides = {};
for (const item of args.opt) {
const eq = item.indexOf('=');
if (eq < 1) throw Error('Use --opt name=value');
overrides[item.slice(0, eq)] = JSON.parse(item.slice(eq + 1));
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const c = await connectNotePilot();
const ev = c.evaluate;
let started = false, recording = false;
try {
const before = await ev('({playing:state.playing, trace:NPTrace.toObject()})');
if (before.playing) throw Error('An existing performance is active; end it before running the harness');
// Preserve the page's last take before a hard reload, including an unsaved one.
if (before.trace) fs.writeFileSync(outPath + '.previous.json', JSON.stringify(before.trace), { flag: 'wx' });
await c.send('Page.bringToFront');
await c.send('Page.reload', { ignoreCache: true });
await sleep(1500);
let ready = false;
for (let i = 0; i < 60; i++) {
ready = await ev('typeof state !== "undefined" && !!state.current').catch(() => false);
if (ready) break;
await sleep(500);
}
if (!ready) throw Error('Open a score in NotePilot before running the harness');
await ev(`setSelectedPartIndices([${part}])`);
await sleep(1500);
// The calibration sweep builds its filterbank from these options too.
// Apply them before calibration so the rig and performance share a window.
await ev(`Object.assign(FOLLOWER_OPTIONS, ${JSON.stringify(overrides)})`);
await ev(`(async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
const mic = devices.find(d => d.kind === 'audioinput' &&
(d.deviceId === ${JSON.stringify(args.mic)} || d.label.toLowerCase().includes(${JSON.stringify(args.mic.toLowerCase())})));
if (!mic) throw Error('Requested microphone was not found');
_selectedMicId = mic.deviceId;
const select = document.getElementById('mic-select');
select.value = mic.deviceId;
// Select before choosing the engine. onMicEngineChange() then performs
// the one necessary replacement of a running mic node. A second restart
// here used to leave its first AudioWorklet posting alongside the
// replacement in some Chrome runs.
await onOutputDeviceChange('solo', 'default');
await onOutputDeviceChange('accompaniment', 'default');
const speaker = devices.find(d => d.kind === 'audiooutput' &&
(d.deviceId === ${JSON.stringify(args.speaker)} || d.label.toLowerCase().includes(${JSON.stringify(args.speaker.toLowerCase())})));
if (!speaker) throw Error('Requested speaker was not found');
await onOutputDeviceChange('solo', speaker.deviceId);
// Chrome treats the literal 'default' sink ID as a dedicated context.
// The paired recorder needs the mic and render reference in one clock;
// empty IDs still use the same system-default speaker.
if (${!!args['record-reference']}) {
_outputSinkIds.solo = '';
_outputSinkIds.accompaniment = '';
}
setPlaybackVolume('solo', 100);
setPlaybackVolume('accompaniment', ${volume * 100});
})()`);
await ev(`window.__npReferenceSoloOnly = ${args['solo-only'] === true}; onMicEngineChange(${JSON.stringify(args.reference ? 'reference' : 'follower')})`);
await ev(`setInputMode('mic')`);
await sleep(3000);
if (!args['no-calibrate']) {
console.log('Calibrating latency, gate and room response (audible)…');
await ev('calculateMicLatency()', 120000);
let calibrationFinished = false;
for (let i = 0; i < 240; i++) {
calibrationFinished = await ev('!_micLatencyCalibration.active && !_calibrationBusy && !_rigSweepActive');
if (calibrationFinished) break;
await sleep(500);
}
if (!calibrationFinished) throw Error('Latency, gate, and room-response calibration did not all finish');
console.log(await ev("document.getElementById('mic-latency-result')?.textContent"));
}
if (settleSeconds > 0) {
console.log(`Settling mic and output for ${settleSeconds}s after calibration…`);
await sleep(settleSeconds * 1000);
}
await ev(`(() => {
const input = document.getElementById('bpm-input');
input.value = ${JSON.stringify(String(bpm))};
input.dispatchEvent(new Event('change', {bubbles:true}));
state.practiceStartBeat = 0;
})()`);
const info = await ev(`({score:state.current.name, parts:selectedPartIndices(), hidden:document.hidden,
mic:_selectedMicId, tag:FOLLOWER_VERSION_TAG, volume:state.playbackVolumes,
events:getRightHand().map(e => [eventPitches(e),e[1]])})`);
if (info.parts.length !== 1 || info.parts[0] !== part) throw Error('Solo part selection did not take effect');
if (Math.abs(info.volume.solo - 1) > 1e-6 || Math.abs(info.volume.accompaniment - volume) > 1e-6) throw Error('Playback volume settings did not take effect');
if (info.hidden) {
// A sleeping secondary display can leave a frontmost tab hidden.
const { windowId } = await c.send('Browser.getWindowForTarget');
await c.send('Browser.setWindowBounds', {windowId, bounds:{windowState:'normal'}});
await c.send('Browser.setWindowBounds', {windowId, bounds:{left:40,top:40,width:1200,height:850}});
await c.send('Page.bringToFront');
await sleep(500);
info.hidden = await ev('document.hidden');
if (info.hidden && !args['allow-hidden']) throw Error('NotePilot remains hidden; use --allow-hidden to test the timer-backed audio path explicitly');
}
const score = info.events.filter(e => e[1] <= beats);
console.log(JSON.stringify({ ...info, events: score.length }));
if (args['record-reference']) {
// The app tees the selected playback role into this bus. Both channels
// then share one browser audio clock: mic on left, output reference on right.
await ev(`(() => {
const ctx = outputCtx('accompaniment');
if (ctx !== audioCtx()) throw Error('Paired capture needs both roles on the default output context');
window.__npReferenceBus = ctx.createGain();
window.__npReferenceRole = ${JSON.stringify(args['reference-role'])};
})()`);
}
started = true; // also rescue a partially started session if startPlaying throws
await ev('startPlaying()', 30000);
if (!await ev(`state.playing && ${args.reference ? '!!_referenceListener' : '!!_scoreFollower'}`)) throw Error('Mic listener session failed to start');
if (args['record-reference']) {
await ev(`(() => {
const ctx = audioCtx(), source = _pitchDetector?._source;
if (!source || window.__npReferenceBus?.context !== ctx) throw Error('Paired capture source unavailable');
const merger = ctx.createChannelMerger(2), dest = ctx.createMediaStreamDestination();
source.connect(merger, 0, 0);
window.__npReferenceBus.connect(merger, 0, 1);
merger.connect(dest);
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
const recorder = new MediaRecorder(dest.stream, {mimeType:mime});
const chunks = [];
recorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
window.__npPairedCapture = {recorder,chunks,merger,dest,mime};
recorder.start(1000);
NPTrace.log('test.captureStart', {sampleRate:ctx.sampleRate,mime});
})()`);
recording = true;
}
const runId = `mic-loop-${Date.now()}`;
await ev(`NPTrace.meta({testRun:{harness:'mic_loop',runId:${JSON.stringify(runId)},part:${part},maxBeat:${beats},settleMs:${settleSeconds * 1000},hiddenAtStart:document.hidden,pairedCapture:${!!args['record-reference']},referenceRole:${JSON.stringify(args['reference-role'])},referenceListener:${!!args.reference},soloOnly:${!!args['solo-only']},physicalOutput:${!!args['record-reference']},physicalMic:${!!args['record-reference']},options:${JSON.stringify(overrides)}}})`);
let i = 0, retry = 0, retries = 0, gaveUp = 0;
const start = Date.now(), spb = 60000 / bpm;
let anchor = start;
while (i < score.length && Date.now() - start < seconds * 1000) {
const wait = anchor + score[i][1] * spb - Date.now();
if (wait > 0) await sleep(Math.min(wait, Math.max(0, seconds * 1000 - (Date.now() - start))));
if (Date.now() - start >= seconds * 1000) break;
// A late commit may arrive during the retry grace beat. Never emit the
// queued input after the follower has already moved beyond this event.
const beforeRetryPos = await ev('state.tracker.position');
if (retry > 0 && retryAlreadyCommitted(i, beforeRetryPos)) {
await ev(`NPTrace.log('input.retryCancelled', {scoreEventId:${i},index:${i},attempt:${retry},attemptId:${JSON.stringify(`${runId}:e${i}:a${retry}`)},position:${beforeRetryPos}})`);
i = beforeRetryPos; retry = 0;
anchor = Date.now();
continue;
}
// One browser evaluation for the chord: no CDP round trips between voices.
await ev(`(() => {
if (!state.playing || state.paused) throw Error('Performance stopped or paused');
NPTrace.log('input.testEvent', {scoreEventId:${i},index:${i},pitches:${JSON.stringify(score[i][0])},attempt:${retry},attemptId:${JSON.stringify(`${runId}:e${i}:a${retry}`)},beat:${score[i][1]}});
for (const p of ${JSON.stringify(score[i][0])}) handleNote(p);
})()`);
let pos = 0;
for (let elapsed = 0; elapsed < 450; elapsed += 50) {
await sleep(50);
pos = await ev('state.tracker.position');
if (pos > i) break;
}
// A follower jump past the event the harness just played is evidence to
// record, never permission for the synthetic soloist to teleport forward.
// Continuing at `pos` used to manufacture a musically impossible cascade:
// the accompaniment then passed every intervening beat and the harness
// injected notes from a later measure. End this take as invalid instead.
if (pos > i + 1) {
await ev(`NPTrace.log('test.invalid', {reason:'unexpected-follower-jump', playedIndex:${i}, trackerPosition:${pos}})`);
console.log(`Stopping: follower jumped from requested #${i} to position ${pos}`);
break;
}
const next = nextAttempt(i, pos, retry, maxRetries);
if (next.retry) retries++;
if (next.gaveUp) { gaveUp++; console.log(`Gave up event ${i} ${score[i][0]} after ${maxRetries} retries`); }
i = next.index; retry = next.attempt;
// A retry is a musical retry, not an immediate six-note machine-gun burst.
// Leave a beat for the accompaniment's current sound to finish and for its
// normal checkpoint hold to become audible before playing the same event.
if (next.retry && i < score.length) anchor = Date.now() - score[i][1] * spb + spb;
else if (next.gaveUp && i < score.length) anchor = Date.now() - score[i][1] * spb;
}
await ev(`NPTrace.log('test.endInput', {retries:${retries},gaveUp:${gaveUp},nextIndex:${i},plannedEvents:${score.length},timeLimit:${Date.now() - start >= seconds * 1000}})`);
await sleep(1000);
console.log(JSON.stringify({retries, gaveUp, attemptedThrough:i, plannedEvents:score.length}));
} catch (error) {
// A trace can end normally even when the driving process failed. Keep the
// harness exception beside it so an interrupted take is diagnosable.
fs.writeFileSync(outPath + '.failure.json', JSON.stringify({
at: new Date().toISOString(), started, message: String(error), stack: error?.stack,
}, null, 2), { flag: 'wx' });
if (started) {
try { await ev(`NPTrace.log('test.failure', {message:${JSON.stringify(String(error))}})`); } catch (_) {}
}
throw error;
} finally {
try {
if (started) {
if (recording) {
const dataUrl = await ev(`(async () => {
const c = window.__npPairedCapture;
if (c.recorder.state !== 'inactive') await new Promise((resolve,reject) => {
c.recorder.onstop = resolve; c.recorder.onerror = reject; c.recorder.stop();
});
const blob = new Blob(c.chunks, {type:c.mime});
return await new Promise((resolve,reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
})()`, 30000);
fs.writeFileSync(outPath + '.paired.webm', Buffer.from(dataUrl.split(',')[1], 'base64'), {flag:'wx'});
console.log('Saved paired mic/reference audio beside trace');
}
await ev("stopPlaying('mic-loop')");
const trace = await ev('JSON.stringify(NPTrace.toObject())');
if (trace && trace !== 'null') {
fs.writeFileSync(outPath, trace, { flag: 'wx' });
console.log('Saved', outPath);
console.log('Event-based result', JSON.stringify(summariseRun(JSON.parse(trace))));
}
}
} finally {
try { await ev('window.__npPairedCapture = null; window.__npReferenceBus = null; window.__npReferenceRole = null'); } catch (_) {}
c.close();
}
}