mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-04 13:24:13 +08:00
Add two-bar metronome count-in and fullscreen touch or Space calibration
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),{})
|
||||
|
||||
Reference in New Issue
Block a user