diff --git a/roadscore/prototype/operator_click_process.py b/roadscore/prototype/operator_click_process.py index c54c610c6a..1719afd8af 100644 --- a/roadscore/prototype/operator_click_process.py +++ b/roadscore/prototype/operator_click_process.py @@ -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') diff --git a/roadscore/prototype/operator_output.py b/roadscore/prototype/operator_output.py index c47cd4ed65..df393afe76 100644 --- a/roadscore/prototype/operator_output.py +++ b/roadscore/prototype/operator_output.py @@ -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=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
- + diff --git a/starpilot/system/the_galaxy/assets/mobile/js/app.js b/starpilot/system/the_galaxy/assets/mobile/js/app.js index 82e2e9fd04..2adbc73bee 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/app.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/app.js @@ -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" diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js b/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js index fc67e3b3ac..7311c7db2d 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.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 = {

Current: {{status.latency_ms == null ? 'Unavailable' : status.latency_ms + ' ms'}}

Delay music cues on screen to align with heard audio. This cannot make live sound arrive earlier or read ahead on the route.

-

Park and listen on the selected speaker. Tap along with the clicks. Tap rhythm includes human timing bias; fine-tune afterward.

+

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.

- +

Suggested correction: {{result.latency_ms}} ms · {{result.accepted_taps}} accepted taps. Review and save above.

{{status.session_muted ? 'Audio is muted by the session owner. Unmute intentionally before calibration.' : 'Connect an output and finish playback or judging before calibration.'}}

diff --git a/starpilot/system/the_galaxy/roadscore.py b/starpilot/system/the_galaxy/roadscore.py index 5793495b51..8ea7952927 100644 --- a/starpilot/system/the_galaxy/roadscore.py +++ b/starpilot/system/the_galaxy/roadscore.py @@ -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 ')) diff --git a/starpilot/system/the_galaxy/tests/test_roadscore_operator.py b/starpilot/system/the_galaxy/tests/test_roadscore_operator.py index b02892bebe..1680ce44c3 100644 --- a/starpilot/system/the_galaxy/tests/test_roadscore_operator.py +++ b/starpilot/system/the_galaxy/tests/test_roadscore_operator.py @@ -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),{})