notepilot / scripts /gsharp_ab_probe.mjs
jasonmdong's picture
Sync from GitHub via hub-sync
11330a4 verified
Raw History Blame Contribute Delete
9.59 kB
#!/usr/bin/env node
// Controlled physical-mic A/B probe for the opening G#2 / A♭2.
// Records only a scratch trace and stereo WebM: left = physical microphone,
// right = exact digital accompaniment output. The solo is deliberately not a
// third channel; its MIDI, audio-context time, and condition are logged.
import fs from 'node:fs';
import path from 'node:path';
import { parseArgs } from 'node:util';
import { connectNotePilot } from './mic_loop_support.mjs';
const { values: args } = parseArgs({ options: {
out: { type: 'string' }, mic: { type: 'string', default: 'default' },
volume: { type: 'string', default: '0.5' }, 'settle-seconds': { type: 'string', default: '8' },
repetitions: { type: 'string', default: '4' }, help: { type: 'boolean', default: false },
} });
if (args.help) {
console.log('node scripts/gsharp_ab_probe.mjs --out /tmp/gsharp-ab.json [--repetitions 4]');
process.exit(0);
}
const out = path.resolve(args.out || '');
const repetitions = Number(args.repetitions), volume = Number(args.volume), settle = Number(args['settle-seconds']);
if (!args.out || fs.existsSync(out) || out.includes(`${path.sep}.accompy_traces${path.sep}`)) throw Error('--out must be a new scratch file outside .accompy_traces');
if (!Number.isInteger(repetitions) || repetitions < 3 || repetitions > 5 || !(volume >= 0 && volume <= 1) || !(settle >= 0)) throw Error('Use 3–5 repetitions, volume 0–1, and a non-negative settle time');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const c = await connectNotePilot();
let started = false, recording = false;
try {
const before = await c.evaluate('({playing:state.playing,trace:NPTrace.toObject()})');
if (before.playing) throw Error('End the current performance before running the probe');
if (before.trace) fs.writeFileSync(out + '.previous.json', JSON.stringify(before.trace), {flag:'wx'});
await c.send('Page.bringToFront');
await c.send('Page.reload', {ignoreCache:true});
for (let i = 0; i < 60; i++) {
if (await c.evaluate('typeof state !== "undefined" && !!state.current').catch(() => false)) break;
if (i === 59) throw Error('Open a score in NotePilot before running this probe');
await sleep(500);
}
await c.evaluate(`(async () => {
setSelectedPartIndices([1]);
await onMicEngineChange('follower');
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.dispatchEvent(new Event('change', {bubbles:true}));
await onOutputDeviceChange('solo', 'default');
await onOutputDeviceChange('accompaniment', 'default');
// One Web Audio clock is required for the paired recorder. Empty IDs still
// select the system-default speaker rather than a different device.
_outputSinkIds.solo = ''; _outputSinkIds.accompaniment = '';
setPlaybackVolume('solo', 100); setPlaybackVolume('accompaniment', ${volume * 100});
setInputMode('mic');
})()`);
await sleep(3000);
console.log('Calibrating latency, gate and room response (audible)…');
await c.evaluate('calculateMicLatency()', 120000);
let finished = false;
for (let i = 0; i < 240; i++) {
finished = await c.evaluate('!_micLatencyCalibration.active && !_calibrationBusy && !_rigSweepActive');
if (finished) break;
await sleep(500);
}
if (!finished) throw Error('Latency, gate, and room-response calibration did not all finish');
const calibration = await c.evaluate(`({result:document.getElementById('mic-latency-result')?.textContent,
active:_micLatencyCalibration.active,busy:_calibrationBusy,sweep:_rigSweepActive,gate:document.getElementById('noise-gate')?.value})`);
console.log(calibration.result);
console.log(`Settling mic and output for ${settle}s after calibration…`);
await sleep(settle * 1000);
await c.evaluate(`(() => { const b=document.getElementById('bpm-input'); b.value='60'; b.dispatchEvent(new Event('change',{bubbles:true})); state.practiceStartBeat=0; })()`);
await c.evaluate('startPlaying()', 30000);
if (!await c.evaluate('state.playing && !!_scoreFollower && !!_pitchDetector')) throw Error('Follower session did not start');
// This probe supplies its own known accompaniment. Stop the ordinary clock so
// neither condition receives unlogged score playback. Disabling only the
// test session's watchdog avoids artificial score advances during silence.
await c.evaluate(`(() => {
state.accompanist.stop(); _skipAutoEnabled = false;
const ctx = audioCtx();
window.__npReferenceBus = ctx.createGain();
window.__npReferenceRole = 'accompaniment';
const source = _pitchDetector._source, 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}), 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});
NPTrace.meta({testRun:{harness:'gsharp_ab_probe',protocol:'ABBA',repetitions:${repetitions},
settleMs:${settle * 1000}, calibration:${JSON.stringify(calibration)}, pairedCapture:true, referenceRole:'accompaniment',
micRole:'physical', solo:{midi:44,name:'G#2',hz:103.826},
accompaniment:[{midi:60,offsetMs:0},{midi:63,offsetMs:500},{midi:68,offsetMs:1000}],
trialSpacingMs:3500, soloOffsetMs:250, outputPath:'web-audio-sample'}});
})()`);
recording = true; started = true;
// ABBA pairs: each trial repeats the opening accompaniment arpeggio C4,
// D#4, G#4. B adds only G#2 at +250 ms. The follower is reset to score
// index 1 before each trial solely to expose its real G#2 likelihood; its
// watchdog and score-driven accompaniment are disabled above.
const order = Array.from({length:repetitions}, () => ['A','B','B','A']).flat();
const schedule = await c.evaluate(`(() => {
const ctx=audioCtx(), start=ctx.currentTime+1, spacing=3.5, offset=.25, n=${order.length};
const order=${JSON.stringify(order)};
const traceT = at => performanceTimeForAudioContextTime(ctx,at)-NPTrace.current().t0;
for(let i=0;i<n;i++) {
const at=start+i*spacing, condition=order[i], resetAt=Math.max(ctx.currentTime,at-.35);
setTimeout(async()=>{
if(!state.playing) return;
state.tracker.position=1; await resetScoreFollower(); syncExpectedMicNote();
NPTrace.log('test.abReset',{trial:i,condition,index:1,expectedMidi:44,atMs:Math.round(traceT(resetAt))});
},Math.max(0,(resetAt-ctx.currentTime)*1000));
const acc=[[60,0],[63,.5],[68,1]];
NPTrace.log('test.abTrial',{trial:i,condition,atMs:Math.round(traceT(at)),soloAtMs:Math.round(traceT(at+offset)),
accompaniment:acc.map(([m,dt])=>({midi:m,atMs:Math.round(traceT(at+dt))}))});
for(const [m,dt] of acc) {
playWebAudioSampleNote(m,${volume},'piano',{outputRole:'accompaniment',startAt:at+dt,duration:.42,suppressPedalResonance:true});
_scoreFollower?.accompanimentFired({pitches:[m],atMs:performanceTimeForAudioContextTime(ctx,at+dt),durMs:420,beat:null});
NPTrace.log('audio.note',{midi:m,name:pitchName(m),role:'accompaniment',instrument:'piano',path:'web-audio-sample',sent:true,velocity:${volume},audioTime:+(at+dt).toFixed(4),probe:true,trial:i,condition});
}
if(condition==='B') {
playWebAudioSampleNote(44,.6,'piano',{outputRole:'solo',startAt:at+offset,duration:.42,suppressPedalResonance:true});
NPTrace.log('audio.note',{midi:44,name:'G#2',role:'solo',instrument:'piano',path:'web-audio-sample',sent:true,velocity:.6,audioTime:+(at+offset).toFixed(4),probe:true,trial:i,condition});
}
}
return {order, durationMs:Math.ceil((start+(n-1)*spacing+3-ctx.currentTime)*1000)};
})()`);
console.log(JSON.stringify(schedule));
await sleep(schedule.durationMs);
await c.evaluate(`NPTrace.log('test.abEnd',{order:${JSON.stringify(order)}})`);
} finally {
try {
if (recording) {
const data = await c.evaluate(`(async()=>{const c=window.__npPairedCapture;if(c.recorder.state!=='inactive')await new Promise((ok,bad)=>{c.recorder.onstop=ok;c.recorder.onerror=bad;c.recorder.stop()});const b=new Blob(c.chunks,{type:c.mime});return await new Promise((ok,bad)=>{const r=new FileReader();r.onload=()=>ok(r.result);r.onerror=bad;r.readAsDataURL(b)})})()`,30000);
fs.writeFileSync(out+'.paired.webm',Buffer.from(data.split(',')[1],'base64'),{flag:'wx'});
console.log('Saved paired mic/accompaniment audio beside trace');
}
if (started) {
await c.evaluate('_skipAutoEnabled = true; stopPlaying("gsharp-ab-probe")');
const trace=await c.evaluate('JSON.stringify(NPTrace.toObject())');
fs.writeFileSync(out,trace,{flag:'wx'}); console.log('Saved',out);
}
} finally {
try { await c.evaluate('window.__npPairedCapture=null;window.__npReferenceBus=null;window.__npReferenceRole=null'); } catch (_) {}
c.close();
}
}