Spaces:
Running
Running
Download scripts/mic_loop.mjs from jasonmdong/notepilot: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/jasonmdong/notepilot/resolve/main/scripts/mic_loop.mjs
- Command line
-
hf download hf://spaces/jasonmdong/notepilot/scripts/mic_loop.mjs
-
curl -L -o mic_loop.mjs https://huggingface.co/spaces/jasonmdong/notepilot/resolve/main/scripts/mic_loop.mjs
15.4 kB
| // 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(); | |
| } | |
| } | |