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