Add two-bar metronome count-in and fullscreen touch or Space calibration

This commit is contained in:
firestar5683
2026-09-19 18:36:47 -07:00
parent a3031c6d11
commit 784a75012f
8 changed files with 79 additions and 37 deletions
@@ -1,11 +1,11 @@
"""Explicitly attended calibration process; no import-time audio output."""
import time
RATE=48000
INTERVAL=2.4
INTERVAL=.6
class ClickSequence:
"""Prebuilt low-level click PCM; callback only copies memory and timestamps."""
def __init__(self, on_click, device, count=12):
def __init__(self, on_click, device, count=24):
import numpy as np
import sounddevice as sd
self.sd = sd
@@ -17,8 +17,8 @@ class ClickSequence:
self.pcm = np.zeros((self.beats[-1] + RATE, 2), dtype='float32')
t = np.arange(round(.018 * RATE)) / RATE
click = (.07 * np.sin(2 * np.pi * 1100 * t) * np.exp(-t * 180)).astype('float32')
for beat in self.beats:
self.pcm[beat:beat + len(click)] = click[:, None]
for index,beat in enumerate(self.beats):
self.pcm[beat:beat + len(click)] = click[:, None]*(1.0 if index%4==0 else .65)
self.stream = sd.OutputStream(device=device, samplerate=RATE, channels=2, dtype='float32', blocksize=480,
callback=self.callback)
@@ -49,7 +49,7 @@ def main():
from pathlib import Path
from bluetooth_output import prepare_output,select_device
from operator_output import real_offroad,selected_output
p=argparse.ArgumentParser();p.add_argument('--address',required=True);p.add_argument('--count',type=int,choices=[4,12],required=True);args=p.parse_args()
p=argparse.ArgumentParser();p.add_argument('--address',required=True);p.add_argument('--count',type=int,choices=[4,24],required=True);args=p.parse_args()
output=selected_output()
if not real_offroad() or not output or not output['connected'] or output['address']!=args.address:
raise RuntimeError('Park and reconnect the selected Bluetooth speaker before calibration')
+18 -8
View File
@@ -16,8 +16,10 @@ import subprocess
import threading
import time
INTERVAL = 2.4
COUNT = 12
BPM = 100
INTERVAL = 60 / BPM
COUNT_IN = 8
COUNT = 24
RATE = 48000
@@ -246,7 +248,7 @@ class OutputOwner:
session_lease.__exit__(None, None, None); operator.__exit__(None, None, None)
raise
threading.Thread(target=self._watch, args=(token,), daemon=True).start()
return dict(ok=True, session=token, interval_ms=round(INTERVAL * 1000), beats=COUNT)
return dict(ok=True, session=token, interval_ms=round(INTERVAL * 1000), beats=COUNT,count_in=COUNT_IN,bpm=BPM,beats_per_bar=4)
if action == 'set_latency':
value = data.get('latency_ms')
if type(value) is not int or not 0 <= value <= 1500:
@@ -277,16 +279,24 @@ class OutputOwner:
raise ValueError('Clock synchronization is too uncertain; retry')
if abs(at - self.clock() * 1000) > 2000:
raise ValueError('Stale tap')
# At 2400 ms spacing, -500..1500 ms windows cannot overlap.
candidates = [(index, click) for index, click in list(session['clicks'].items()) if -500 <= at - click <= 1500]
if len(candidates) != 1 or candidates[0][0] in session['taps']:
raise ValueError('Tap once per click, in time with the sound')
index, click = candidates[0]
if session['test']:raise ValueError('Test mode does not collect taps')
if COUNT_IN not in session['clicks'] or at<session['clicks'][COUNT_IN]:
raise ValueError('Listen for two full bars; start tapping on bar three')
index=COUNT_IN+len(session['taps'])
if index>=COUNT or index not in session['clicks']:
raise ValueError('Wait for the next beat')
click=session['clicks'][index]
previous=max((tap for _,tap in session['taps'].values()),default=float('-inf'))
if at-previous<INTERVAL*1000*.45:
raise ValueError('Tap once per beat')
if not 0<=at-click<=1500:
raise ValueError('Beat sequence was missed; restart with the two-bar count-in')
session['taps'][index] = (click, at)
return dict(ok=True, accepted_taps=len(session['taps']))
if action == 'calibration_result':
try:
result = robust_offset(list(session['taps'].values()))
result.update(bpm=BPM,count_in_beats=COUNT_IN,beat_pairing='sequential after two-bar count-in',whole_beat_ambiguity_possible=True)
finally:
self._close()
return dict(ok=True, **result)
+9 -4
View File
@@ -50,14 +50,17 @@ class OutputTests(unittest.TestCase):
def test_paired_beats_outliers_duplicate_and_save(self):
token=self.owner.dispatch('calibration_start', attended=True)['session']
offsets=[200,201,198,202,199,203,197,200,201,199,1000,205]
for i, offset in enumerate(offsets):
click=100000+i*2400; self.clock.value=(click+offset)/1000
for i in range(8):self.owner.session['sink'].callback(i,100000+i*600)
with self.assertRaises(ValueError):self.owner.dispatch('calibration_tap',session=token,server_ms=100200,uncertainty_ms=3)
offsets=[200,201,198,202,199,203,197,200,201,199,400,205,200,201,199,200]
for index, offset in enumerate(offsets):
i=index+8;click=100000+i*600;self.clock.value=(click+offset)/1000
self.owner.session['sink'].callback(i,click)
self.owner.dispatch('calibration_tap', session=token, server_ms=click+offset, uncertainty_ms=3)
with self.assertRaises(ValueError): self.owner.dispatch('calibration_tap', session=token, server_ms=126605, uncertainty_ms=3)
with self.assertRaises(ValueError):self.owner.dispatch('calibration_tap',session=token,server_ms=click+offset+5,uncertainty_ms=3)
result=self.owner.dispatch('calibration_result',session=token)
self.assertEqual(result['latency_ms'],200);self.assertEqual(result['rejected_taps'],1)
self.assertEqual(result['count_in_beats'],8);self.assertTrue(result['whole_beat_ambiguity_possible'])
self.assertFalse((self.root/'generated/output_timing.json').exists())
self.owner.dispatch('set_latency',latency_ms=result['latency_ms'])
self.assertEqual(self.owner.status()['latency_ms'],200)
@@ -145,6 +148,8 @@ class OutputTests(unittest.TestCase):
second=ClickSequence(lambda *_:None,0,count=4)
self.assertTrue(np.array_equal(sink.pcm,second.pcm))
self.assertLessEqual(abs(sink.pcm).max(),.071)
self.assertEqual(sink.beats[1]-sink.beats[0],28800)
self.assertGreater(abs(sink.pcm[sink.beats[0]:sink.beats[0]+1000]).max(),abs(sink.pcm[sink.beats[1]:sink.beats[1]+1000]).max())
sink.index=sink.beats[0]
with patch('operator_click_process.time.monotonic',return_value=50):
out=np.zeros((480,2),dtype=np.float32)
@@ -36,7 +36,7 @@
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
<script type="module" src="/assets/mobile/js/app.js"></script>
<script type="module" src="/assets/mobile/js/app.js?v=roadscore-calibration-2"></script>
</body>
</html>
@@ -1,15 +1,15 @@
import { RoadScore } from "./views/RoadScore.js"
import { RoadScore } from "./views/RoadScore.js?v=calibration-bars-2"
import { createApp, h } from "vue"
import { AppShell } from "./components/AppShell.js"
import { AppShell } from "./components/AppShell.js?v=roadscore-calibration-2"
import { Home } from "./views/Home.js"
import { Settings } from "./views/Settings.js"
import { Tools } from "./views/Tools.js"
import { Tools } from "./views/Tools.js?v=roadscore-calibration-2"
import { Recordings } from "./views/Recordings.js"
import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js?v=nav-destination-6"
import { Vehicle } from "./views/Vehicle.js"
import { Bluetooth } from "./views/Bluetooth.js"
import { Bluetooth } from "./views/Bluetooth.js?v=roadscore-calibration-2"
import { SystemTools } from "./views/SystemTools.js"
import { ToolEmbed } from "./views/ToolEmbed.js"
import { Doors } from "./views/Doors.js"
@@ -2,9 +2,9 @@ import { GalaxySection } from '../components/GalaxySection.js'
export const RoadScore = {
name: 'RoadScore', components: { GalaxySection },
data() { return { status: {}, error: '', busy: false, profile: 'prism', latency: 0, session: null, taps: 0, result: null, timer: null, calibrationTimer: null, clockOffset: 0, uncertainty: null, testMode: false, beat: '', sessionStarted: 0, latencyInitialized: false, outputIdentity: null, testClicks: [], animation: null } },
mounted() { this.refresh(); this.timer = setInterval(() => this.refresh(), 2000) },
beforeUnmount() { clearInterval(this.timer); clearInterval(this.calibrationTimer); cancelAnimationFrame(this.animation); if (this.session) this.action('calibration_cancel', {session: this.session}) },
data() { return { status: {}, error: '', busy: false, profile: 'prism', latency: 0, session: null, taps: 0, result: null, timer: null, calibrationTimer: null, clockOffset: 0, uncertainty: null, testMode: false, beat: '', sessionStarted: 0, latencyInitialized: false, outputIdentity: null, testClicks: [], animation: null, countIn: 8, bpm: 100, beatIndex: -1, cue: 'Getting ready…', lastTapInput: -1000 } },
mounted() { window.addEventListener('keydown', this.keyTap); this.refresh(); this.timer = setInterval(() => this.refresh(), 2000) },
beforeUnmount() { window.removeEventListener('keydown', this.keyTap); clearInterval(this.timer); clearInterval(this.calibrationTimer); cancelAnimationFrame(this.animation); if (this.session) this.action('calibration_cancel', {session: this.session}) },
methods: {
async refresh() {
try {
@@ -48,7 +48,7 @@ export const RoadScore = {
const value = await this.quick(test ? 'test' : 'calibration_start', {attended: true})
this.session = value.session; this.taps = 0; this.result = null; this.testMode = test; this.sessionStarted = performance.now()
this.calibrationTimer = setInterval(() => this.pollClicks(), 100)
if (test) this.animateBeat()
this.countIn = value.count_in || 8; this.bpm = value.bpm || 100; this.beatIndex = -1; this.testClicks = []; this.cue = 'Getting ready…'; this.animateBeat()
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
@@ -60,20 +60,32 @@ export const RoadScore = {
const value = await this.quick('calibration_poll', {session: token})
if (this.session !== token) return
this.testClicks = value.clicks
if (performance.now() - this.sessionStarted > (this.testMode ? 14000 : 37000)) await this.cancel()
if (performance.now() - this.sessionStarted > (this.testMode ? 14000 : 30000)) await this.cancel()
} catch (error) { if (this.session === token) { this.error = error.message; this.session = null; clearInterval(this.calibrationTimer) } }
finally { this.polling = false }
},
animateBeat() {
if (!this.session || !this.testMode) return
if (!this.session) return
const now = performance.now() + this.clockOffset
const active = this.testClicks.find(click => now >= click.server_ms && now - click.server_ms < 160)
this.beat = active ? String(active.beat + 1) : ''
const first = this.testClicks.find(click => click.beat === 0)
const index = first ? Math.floor((now - first.server_ms) / (60000 / this.bpm)) : -1
this.beatIndex = index
this.beat = index >= 0 ? String(index % 4 + 1) : '·'
this.cue = index < 0 ? 'Getting ready…' : index < this.countIn && !this.testMode ? `Listen · count-in bar ${Math.floor(index / 4) + 1} of 2` : this.testMode ? 'Watch and listen' : index < 24 ? `Tap now · bar ${Math.floor(index / 4) + 1}` : 'Finish and calculate'
this.animation = requestAnimationFrame(() => this.animateBeat())
},
keyTap(event) {
if (!this.session || this.testMode || event.code !== 'Space') return
const element = event.target
if (element?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element?.tagName || '')) return
event.preventDefault()
if (!event.repeat) this.tap(event)
},
async tap(event) {
if (!this.session || this.testMode) return
if (!this.session || this.testMode || this.beatIndex < this.countIn || this.taps >= 16) return
const stamp = performance.now()
if (stamp - this.lastTapInput < 150) return
this.lastTapInput = stamp
try {
const value = await this.quick('calibration_tap', {session: this.session, server_ms: stamp + this.clockOffset, uncertainty_ms: this.uncertainty})
this.taps = value.accepted_taps; this.error = ''
@@ -111,9 +123,14 @@ export const RoadScore = {
<p>Current: <strong>{{status.latency_ms == null ? 'Unavailable' : status.latency_ms + ' ms'}}</strong></p>
<p class="gx-row__desc">Delay music cues on screen to align with heard audio. This cannot make live sound arrive earlier or read ahead on the route.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="gx-btn" :disabled="busy || !status.can_adjust" @click="saveLatency(Math.max(0, (status.latency_ms || 0) - 10))">−10 ms</button><input class="gx-field" aria-label="Timing correction in milliseconds" type="number" min="0" max="1500" step="1" v-model="latency" style="width:100px"><button class="gx-btn" :disabled="busy || !status.can_adjust" @click="saveLatency(Math.min(1500, (status.latency_ms || 0) + 10))">+10 ms</button><button class="gx-btn" :disabled="busy || !status.can_adjust" @click="saveLatency(latency)">Save</button><button class="gx-btn" :disabled="busy || !status.can_adjust" @click="saveLatency(0)">Reset</button></div>
<p class="gx-row__desc">Park and listen on the selected speaker. Tap along with the clicks. Tap rhythm includes human timing bias; fine-tune afterward.</p>
<p class="gx-row__desc">100 BPM · 4/4. Listen to two full bars (8 clicks), then tap every beat from bar three for four bars. On a computer, press Space. The estimate includes human timing bias; a missed beat can shift it by a whole beat.</p>
<button v-if="!session" class="gx-btn" :disabled="busy || !status.can_calibrate" @click="start(false)">I'm ready · Start clicks</button>
<template v-else><div v-if="testMode" aria-live="polite" style="font-size:48px;min-height:72px;text-align:center">{{beat || '·'}}</div><button v-if="!testMode" class="gx-btn" style="width:100%;min-height:90px;touch-action:manipulation" @pointerdown.prevent="tap">Tap with each click · {{taps}}</button><button v-if="!testMode" class="gx-btn" :disabled="taps < 8 || busy" @click="finish">Calculate correction</button><button class="gx-btn" @click="cancel">Cancel</button></template>
<template v-else><Teleport to="body"><div role="dialog" aria-modal="true" aria-label="Bluetooth timing calibration" style="position:fixed;inset:0;z-index:100000;background:var(--bg-primary,#090914);display:flex;flex-direction:column;padding:24px;gap:16px;text-align:center">
<strong>{{cue}}</strong><div style="font-size:72px" aria-live="off">{{beat}}</div>
<button v-if="!testMode" class="gx-btn" style="flex:1;width:100%;touch-action:manipulation;font-size:28px" :disabled="beatIndex < countIn || taps >= 16" @pointerdown.prevent="tap">{{beatIndex < countIn ? 'Listen first' : 'Tap here or press Space'}}<br>{{taps}} / 16 taps</button>
<p>100 BPM · 4/4 · selected Bluetooth speaker</p>
<button v-if="!testMode" class="gx-btn" :disabled="taps < 8 || busy" @click="finish">Calculate correction</button><button class="gx-btn" @click="cancel">Cancel</button>
</div></Teleport></template>
<p v-if="result">Suggested correction: {{result.latency_ms}} ms · {{result.accepted_taps}} accepted taps. Review and save above.</p>
<button class="gx-btn" :disabled="busy || session || !status.can_calibrate" @click="start(true)">Test timing</button>
<p v-if="!status.can_calibrate" class="gx-row__desc">{{status.session_muted ? 'Audio is muted by the session owner. Unmute intentionally before calibration.' : 'Connect an output and finish playback or judging before calibration.'}}</p>
+9 -4
View File
@@ -36,8 +36,8 @@ def current_worker(root, proc=Path('/proc')):
root=Path(root);worker_path=root/'generated/ace_worker_state.json'
worker=read_json(worker_path);owner=read_json(root/'generated/resident_owner.json')
try:
pid=int(worker['pid']);parent=int(owner['power_worker_pid'])
if pid<=0 or parent<=0:return {}
pid=int(worker['pid']);parent=int(owner.get('power_worker_pid',0))
if pid<=0:return {}
def process(identity,suffix):
directory=proc/str(identity)
arguments=(directory/'cmdline').read_bytes().split(b'\0')
@@ -45,8 +45,13 @@ def current_worker(root, proc=Path('/proc')):
fields=(directory/'stat').read_text().rsplit(') ',1)[1].split()
if fields[0]=='Z':raise ValueError('Exited process')
return fields
child=process(pid,'/ace_worker.py');supervisor=process(parent,'/power_worker.py')
if int(child[1])!=parent or str(owner['process_start_ticks'])!=supervisor[19]:return {}
child=process(pid,'/ace_worker.py')
actual_parent=int(child[1]);supervisor=process(actual_parent,'/power_worker.py')
resident_valid=actual_parent==parent and str(owner.get('process_start_ticks',''))==supervisor[19]
if not resident_valid:
service=read_json(root/'generated/worker_service.json')
service_pid=int(service['pid']);service_process=process(service_pid,'/worker_service.py')
if int(supervisor[1])!=service_pid or str(service['start_ticks'])!=service_process[19]:return {}
recorded=worker.get('process_start_ticks',worker.get('start_ticks'))
if recorded is not None and str(recorded)!=child[19]:return {}
boot=next(int(line.split()[1]) for line in (proc/'stat').read_text().splitlines() if line.startswith('btime '))
@@ -79,6 +79,11 @@ class CurrentWorkerTests(unittest.TestCase):
worker['phase']='PREPARING';(generated/'ace_worker_state.json').write_text(json.dumps(worker))
self.assertEqual(operator.current_worker(root,proc)['phase'],'PREPARING')
process(10,1,101,'prototype/power_worker.py');self.assertEqual(operator.current_worker(root,proc),{})
process(9,1,90,'prototype/worker_service.py');process(10,9,101,'prototype/power_worker.py')
(generated/'worker_service.json').write_text(json.dumps(dict(pid=9,start_ticks='90')))
self.assertEqual(operator.current_worker(root,proc)['phase'],'PREPARING')
process(9,1,91,'prototype/worker_service.py');self.assertEqual(operator.current_worker(root,proc),{})
process(10,1,100,'prototype/power_worker.py')
os.utime(generated/'ace_worker_state.json',(0,0));self.assertEqual(operator.current_worker(root,proc),{})
(proc/'11/cmdline').unlink();self.assertEqual(operator.current_worker(root,proc),{})