#!/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(); } }