Guide isolated chime calibration and calculate completed taps automatically

This commit is contained in:
firestar5683
2026-09-19 19:37:23 -07:00
parent e5e97e7720
commit 2c7f4992bb
3 changed files with 19 additions and 13 deletions
@@ -36,7 +36,7 @@
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
<script type="module" src="/assets/mobile/js/app.js?v=roadscore-calibration-2"></script>
<script type="module" src="/assets/mobile/js/app.js?v=roadscore-calibration-3"></script>
</body>
</html>
@@ -1,4 +1,4 @@
import { RoadScore } from "./views/RoadScore.js?v=calibration-bars-2"
import { RoadScore } from "./views/RoadScore.js?v=calibration-markers-3"
import { createApp, h } from "vue"
import { AppShell } from "./components/AppShell.js?v=roadscore-calibration-2"
import { Home } from "./views/Home.js"
@@ -2,7 +2,7 @@ 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, countIn: 8, bpm: 100, beatIndex: -1, cue: 'Getting ready…', lastTapInput: -1000 } },
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, targetTaps: 12, finishing: false } },
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: {
@@ -46,7 +46,7 @@ export const RoadScore = {
if (best.uncertainty > 25) throw new Error('Connection timing is too uncertain. Try again on a faster connection.')
this.clockOffset = best.offset; this.uncertainty = best.uncertainty
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.targetTaps = value.target_taps || 12; this.finishing = false; this.lastTapInput = -1000; this.session = value.session; this.taps = 0; this.result = null; this.testMode = test; this.sessionStarted = performance.now()
this.calibrationTimer = setInterval(() => this.pollClicks(), 100)
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 }
@@ -60,7 +60,10 @@ 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 : 30000)) await this.cancel()
if (performance.now() - this.sessionStarted > (this.testMode ? 14000 : 55000)) {
if (!this.testMode && this.taps >= 8) await this.finish()
else { await this.cancel(); if (!this.testMode) this.error = 'Not enough chime taps. Start again and tap only when you hear each chime.' }
}
} catch (error) { if (this.session === token) { this.error = error.message; this.session = null; clearInterval(this.calibrationTimer) } }
finally { this.polling = false }
},
@@ -70,8 +73,8 @@ export const RoadScore = {
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.beat = index >= 0 && index < this.countIn ? 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' : 'Tap once when you hear each chime · wait through the gaps'
this.animation = requestAnimationFrame(() => this.animateBeat())
},
keyTap(event) {
@@ -82,13 +85,13 @@ export const RoadScore = {
if (!event.repeat) this.tap(event)
},
async tap(event) {
if (!this.session || this.testMode || this.beatIndex < this.countIn || this.taps >= 16) return
if (!this.session || this.testMode || this.beatIndex < this.countIn || this.taps >= this.targetTaps) 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 = ''
this.taps = value.accepted_taps; this.error = ''; if (this.taps >= this.targetTaps) await this.finish()
} catch (error) { this.error = error.message }
},
async cancel() {
@@ -97,10 +100,13 @@ export const RoadScore = {
if (token) await this.action('calibration_cancel', {session: token})
},
async finish() {
if (this.finishing || !this.session) return
this.finishing = true
clearInterval(this.calibrationTimer); cancelAnimationFrame(this.animation)
const token = this.session; this.session = null
this.result = await this.action('calibration_result', {session: token})
if (this.result) this.latency = this.result.latency_ms
this.finishing = false
},
async saveLatency(value) { const result = await this.action('settings', {latency_ms: Number(value)}); if (result) { this.result = null; this.latency = Number(value) } },
},
@@ -123,15 +129,15 @@ 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">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>
<p class="gx-row__desc">Listen to two full bars (8 clicks), then tap only when you hear each two-tone chime. The gaps vary: wait and listen, don’t keep tapping the beat. Use the screen or Space. After 12 taps, the estimate calculates automatically. It includes your reaction time.</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><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" style="flex:1;width:100%;touch-action:manipulation;font-size:28px" :disabled="beatIndex < countIn || taps >= 16" @pointerdown.prevent="tap">{{beatIndex < countIn ? 'Listen first' : 'Hear a chime? Tap here or press Space'}}<br>{{taps}} / {{targetTaps}} chimes</button>
<p>Listen to the speaker, not the screen · one tap per chime</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>
<p v-if="result">Suggested correction: {{result.latency_ms}} ms · {{result.accepted_taps}} accepted taps · {{result.spread_ms}} ms spread. Includes tap reaction time. Review and Save above; the saved value has not changed.</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>
</div></GalaxySection>