From ac1a020140518b4a53f8a10230ed493aeb08bbec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:33:29 +0000 Subject: [PATCH 01/16] feat(workspace): tap a session tempo when the song has none Measure tonight's count-in BPM from at least four player taps when song.tempo is missing or untrusted, then count in at that tempo and check the first range. Session-only; this is not MIR and does not write the song contract. --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/TapTempo.test.tsx | 47 +++++ .../src/features/workspace/TapTempo.tsx | 88 +++++++++ .../src/features/workspace/Workspace.test.tsx | 20 ++ .../src/features/workspace/Workspace.tsx | 3 + .../src/features/workspace/tapTempo.test.ts | 103 ++++++++++ .../src/features/workspace/tapTempo.ts | 176 ++++++++++++++++++ apps/desktop/src/locales/en/common.json | 8 + apps/desktop/src/locales/ko/common.json | 8 + apps/desktop/vite.config.ts | 4 +- docs/doctoring/workspace-tap-tempo.md | 21 +++ 14 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/features/workspace/TapTempo.test.tsx create mode 100644 apps/desktop/src/features/workspace/TapTempo.tsx create mode 100644 apps/desktop/src/features/workspace/tapTempo.test.ts create mode 100644 apps/desktop/src/features/workspace/tapTempo.ts create mode 100644 docs/doctoring/workspace-tap-tempo.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ac2ea854b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, a session tap tempo when the song has no trusted BPM, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..4545a06f2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-30 ## Brand source @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, a session tap tempo when the song has no trusted BPM, and the next instrument check - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..800c907ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Measure tonight's count-in tempo from at least four player taps when the song has no trusted BPM, then count in at that tempo and check the first range. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..8bcee36cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, offers a session tap tempo when the song has no trusted BPM, and names the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/TapTempo.test.tsx b/apps/desktop/src/features/workspace/TapTempo.test.tsx new file mode 100644 index 000000000..a7da6af1d --- /dev/null +++ b/apps/desktop/src/features/workspace/TapTempo.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { createTranslator } from "../../i18n"; +import { TapTempo } from "./TapTempo"; + +const t = createTranslator("en"); + +describe("TapTempo", () => { + it("names the tap next action and unlocks a 120 BPM count-in after four steady taps", () => { + let now = 10_000; + render( now} />); + + const region = screen.getByTestId("tap-tempo"); + expect(region).toHaveTextContent("Tonight's tap tempo"); + expect(region).toHaveTextContent( + "Tonight's first count-in still needs a tempo. Tap a steady groove at least four times, then count in at that tempo and check the first range." + ); + + const tap = screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i }); + fireEvent.click(tap); + now += 500; + fireEvent.click(tap); + expect(region).toHaveTextContent("Keep tapping a steady groove"); + now += 500; + fireEvent.click(tap); + now += 500; + fireEvent.click(tap); + + expect(region).toHaveTextContent("120 BPM from 4 taps. Count in 4 at 120 BPM, then check tonight's first range."); + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + expect(screen.getByTestId("tap-lamp-3").className).toContain("bg-amber-300"); + }); + + it("resets the session taps without writing a song tempo", () => { + let now = 1_000; + render( now} />); + const tap = screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i }); + fireEvent.click(tap); + now += 500; + fireEvent.click(tap); + fireEvent.click(screen.getByRole("button", { name: /reset tonight's tap tempo/i })); + expect(screen.getByTestId("tap-tempo")).toHaveTextContent( + "Tonight's first count-in still needs a tempo. Tap a steady groove at least four times, then count in at that tempo and check the first range." + ); + expect(screen.getByRole("button", { name: /reset tonight's tap tempo/i })).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/TapTempo.tsx b/apps/desktop/src/features/workspace/TapTempo.tsx new file mode 100644 index 000000000..f8ca05667 --- /dev/null +++ b/apps/desktop/src/features/workspace/TapTempo.tsx @@ -0,0 +1,88 @@ +import { useMemo, useState } from "react"; +import { CircleDot } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { createTranslator } from "../../i18n"; +import { + emptyTapTempo, + fillTapCopy, + MIN_TAP_COUNT, + recordTap, + tapTempoReading, + type TapTempoState +} from "./tapTempo"; + +type Translator = ReturnType; + +interface TapTempoProps { + t: Translator; + nowMs?: () => number; +} + +/** + * Measure tonight's count-in tempo from the player's taps when the song + * has no trusted BPM. Session-only; this does not write `song.tempo`. + */ +export function TapTempo({ t, nowMs }: TapTempoProps) { + const [state, setState] = useState(emptyTapTempo); + const reading = useMemo(() => tapTempoReading(state), [state]); + const clock = nowMs ?? Date.now; + + const guidance = reading + ? fillTapCopy(t("workspaceTapTempoReady"), { + tempo: String(reading.tempoBpm), + taps: String(reading.tapCount) + }) + : state.tapsMs.length > 0 + ? t("workspaceTapTempoKeep") + : t("workspaceTapTempoNeed"); + + const filledLamps = Math.min(state.tapsMs.length, MIN_TAP_COUNT); + + return ( +
+

{t("workspaceTapTempoTitle")}

+

{guidance}

+ +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..6845b9d4c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,26 @@ describe("Workspace", () => { ); }); + it("offers a tap tempo when the song has no trusted BPM", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.tempo = undefined; + + render(); + + const callout = screen.getByTestId("tap-tempo"); + expect(callout).toHaveTextContent("Tonight's tap tempo"); + expect(callout).toHaveTextContent( + "Tonight's first count-in still needs a tempo. Tap a steady groove at least four times, then count in at that tempo and check the first range." + ); + }); + + it("hides tap tempo when the song already has a trusted BPM", () => { + setNavigatorLanguage("en-US"); + render(); + expect(screen.queryByTestId("tap-tempo")).toBeNull(); + }); + it("asks for an ear check when the selected part has no named span", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..bb43e1f1b 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,8 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { TapTempo } from "./TapTempo"; +import { songNeedsTapTempo } from "./tapTempo"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -309,6 +311,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+ {songNeedsTapTempo(song) ? : null}
diff --git a/apps/desktop/src/features/workspace/tapTempo.test.ts b/apps/desktop/src/features/workspace/tapTempo.test.ts new file mode 100644 index 000000000..446bf7895 --- /dev/null +++ b/apps/desktop/src/features/workspace/tapTempo.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { + emptyTapTempo, + fillTapCopy, + MAX_TAP_HISTORY, + MIN_TAP_COUNT, + recordTap, + songNeedsTapTempo, + tapTempoReading, + trustedTempoBpm +} from "./tapTempo"; + +function tapsAt(startMs: number, intervalMs: number, count: number) { + let state = emptyTapTempo(); + for (let index = 0; index < count; index += 1) { + state = recordTap(state, startMs + index * intervalMs); + } + return state; +} + +describe("trustedTempoBpm", () => { + it("admits only finite rehearsal-usable BPM in 20–400", () => { + expect(trustedTempoBpm(120)).toBe(120); + expect(trustedTempoBpm(20)).toBe(20); + expect(trustedTempoBpm(400)).toBe(400); + expect(trustedTempoBpm(19)).toBeNull(); + expect(trustedTempoBpm(401)).toBeNull(); + expect(trustedTempoBpm(0)).toBeNull(); + expect(trustedTempoBpm(Number.NaN)).toBeNull(); + expect(trustedTempoBpm("120")).toBeNull(); + }); +}); + +describe("recordTap", () => { + it("ignores non-finite clocks, resets after a long pause, and caps history", () => { + expect(recordTap(emptyTapTempo(), Number.NaN)).toEqual({ tapsMs: [] }); + expect(recordTap({ tapsMs: [1000] }, 900)).toEqual({ tapsMs: [1000] }); + + const reset = recordTap({ tapsMs: [1000] }, 1000 + 3_501); + expect(reset).toEqual({ tapsMs: [4_501] }); + + let state = emptyTapTempo(); + for (let index = 0; index < MAX_TAP_HISTORY + 3; index += 1) { + state = recordTap(state, 1_000 + index * 500); + } + expect(state.tapsMs).toHaveLength(MAX_TAP_HISTORY); + expect(state.tapsMs[0]).toBe(1_000 + 3 * 500); + }); + + it("isolates malformed prior state instead of inheriting it", () => { + const recovered = recordTap({ tapsMs: ["nope", 250, null, 750] }, 1_250); + expect(recovered.tapsMs).toEqual([250, 750, 1_250]); + expect(recordTap(null, 40).tapsMs).toEqual([40]); + }); +}); + +describe("tapTempoReading", () => { + it("needs four taps and reads 120 BPM from a steady 500 ms groove", () => { + expect(tapTempoReading(tapsAt(0, 500, MIN_TAP_COUNT - 1))).toBeNull(); + expect(tapTempoReading(tapsAt(0, 500, MIN_TAP_COUNT))).toEqual({ + tempoBpm: 120, + tapCount: 4, + intervalMs: 500 + }); + expect(tapTempoReading(tapsAt(0, 500, 5))?.tempoBpm).toBe(120); + expect(tapTempoReading({ tapsMs: [0, 500, 500, 1_000] })).toBeNull(); + }); + + it("uses the median interval and fails closed on an unsteady or out-of-range window", () => { + const medianState = recordTap(recordTap(recordTap(recordTap(emptyTapTempo(), 0), 480), 1_000), 1_500); + expect(tapTempoReading(medianState)?.tempoBpm).toBe(120); + + expect(tapTempoReading(tapsAt(0, 100, 4))).toBeNull(); + expect(tapTempoReading(tapsAt(0, 4_000, 4))).toBeNull(); + expect(tapTempoReading({ tapsMs: [0, 200, 1_200, 1_400] })).toBeNull(); + expect(tapTempoReading(null)).toBeNull(); + }); +}); + +describe("songNeedsTapTempo", () => { + it("hides the tap control when the song already has a trusted tempo", () => { + const song = createDemoRehearsalSong(); + expect(songNeedsTapTempo(song)).toBe(false); + song.tempo = undefined; + expect(songNeedsTapTempo(song)).toBe(true); + song.tempo = 12; + expect(songNeedsTapTempo(song)).toBe(true); + expect(songNeedsTapTempo(null)).toBe(true); + }); +}); + +describe("fillTapCopy", () => { + it("fills own-property tokens once and keeps rehearsal values literal", () => { + expect( + fillTapCopy("{tempo} BPM from {taps} taps. Count in 4 at {tempo} BPM.", { + tempo: "118", + taps: "4" + }) + ).toBe("118 BPM from 4 taps. Count in 4 at 118 BPM."); + expect(fillTapCopy("keep {toString}", {})).toBe("keep {toString}"); + }); +}); diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts new file mode 100644 index 000000000..0f367f1ce --- /dev/null +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -0,0 +1,176 @@ +import { fillRangeCopy } from "./firstRangeSqueeze"; + +/** Inclusive lower bound for a rehearsal-usable tap tempo. */ +export const MIN_TRUSTED_TEMPO_BPM = 20; +/** Inclusive upper bound for a rehearsal-usable tap tempo. */ +export const MAX_TRUSTED_TEMPO_BPM = 400; +/** Four taps yield three intervals, the minimum for a median BPM. */ +export const MIN_TAP_COUNT = 4; +/** Sliding window so a long groove cannot grow without bound. */ +export const MAX_TAP_HISTORY = 8; +/** A pause longer than a 20 BPM interval starts a new tap group. */ +export const TAP_GAP_RESET_MS = 3_500; +/** Reject a window whose fastest and slowest intervals disagree by more than 2×. */ +export const MAX_INTERVAL_SPREAD = 2; + +/** Session-only tap timestamps. Never persisted onto the song contract. */ +export type TapTempoState = { + tapsMs: number[]; +}; + +/** Trusted count-in tempo measured from the player's taps. */ +export type TapTempoReading = { + tempoBpm: number; + tapCount: number; + intervalMs: number; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Admit a finite non-negative millisecond timestamp. */ +function trustedTimestampMs(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + return value; +} + +/** Copy finite tap timestamps out of an untrusted state object. */ +function trustedTapTimestamps(value: unknown): number[] { + if (!isRuntimeObject(value) || !Array.isArray(value.tapsMs)) { + return []; + } + + const taps: number[] = []; + for (const item of value.tapsMs) { + const timestamp = trustedTimestampMs(item); + if (timestamp === null) { + continue; + } + taps.push(timestamp); + } + return taps; +} + +/** Median of a non-empty finite number list. */ +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[middle - 1]! + sorted[middle]!) / 2; + } + return sorted[middle]!; +} + +/** + * Admit only a finite rehearsal-usable BPM in 20–400. + * + * Non-numeric, non-finite, non-positive, and out-of-range values are not + * click authority. This is not a tempo detector and does not invent MIR. + */ +export function trustedTempoBpm(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + if (value < MIN_TRUSTED_TEMPO_BPM || value > MAX_TRUSTED_TEMPO_BPM) { + return null; + } + return value; +} + +/** Empty session tap window. */ +export function emptyTapTempo(): TapTempoState { + return { tapsMs: [] }; +} + +/** + * Record one tap, fail closed on a bad clock, and reset after a long pause. + * + * Runtime clocks and prior state are untrusted. A backwards or non-finite + * timestamp is ignored. A gap longer than `TAP_GAP_RESET_MS` starts a new + * window so a late entrance cannot drag the median. + */ +export function recordTap(state: TapTempoState | unknown, nowMs: unknown): TapTempoState { + const timestamp = trustedTimestampMs(nowMs); + const taps = trustedTapTimestamps(state); + if (timestamp === null) { + return { tapsMs: taps }; + } + + const last = taps[taps.length - 1]; + if (last !== undefined) { + if (timestamp <= last) { + return { tapsMs: taps }; + } + if (timestamp - last > TAP_GAP_RESET_MS) { + return { tapsMs: [timestamp] }; + } + } + + taps.push(timestamp); + if (taps.length > MAX_TAP_HISTORY) { + taps.splice(0, taps.length - MAX_TAP_HISTORY); + } + return { tapsMs: taps }; +} + +/** + * Read a trusted BPM from at least four taps, fail closed otherwise. + * + * Uses the median interval so one rushed or late tap cannot own the tempo. + * A window whose intervals disagree by more than 2× is unsteady, not a click. + */ +export function tapTempoReading(state: TapTempoState | unknown): TapTempoReading | null { + const taps = trustedTapTimestamps(state); + if (taps.length < MIN_TAP_COUNT) { + return null; + } + + const intervals: number[] = []; + for (let index = 1; index < taps.length; index += 1) { + const interval = taps[index]! - taps[index - 1]!; + if (!Number.isFinite(interval) || interval <= 0) { + return null; + } + intervals.push(interval); + } + + const fastest = Math.min(...intervals); + const slowest = Math.max(...intervals); + if (fastest <= 0 || slowest / fastest > MAX_INTERVAL_SPREAD) { + return null; + } + + const intervalMs = median(intervals); + const tempoBpm = Math.round(60_000 / intervalMs); + if (trustedTempoBpm(tempoBpm) === null) { + return null; + } + + return { + tempoBpm, + tapCount: taps.length, + intervalMs: 60_000 / tempoBpm + }; +} + +/** + * Return whether the ready map still needs a session tap tempo. + * + * Missing, non-finite, or out-of-range `song.tempo` is not click authority. + * A trusted stored tempo hides the tap control so it cannot override analysis. + */ +export function songNeedsTapTempo(song: unknown): boolean { + if (!isRuntimeObject(song)) { + return true; + } + return trustedTempoBpm(song.tempo) === null; +} + +/** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ +export function fillTapCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..0f4d71209 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,14 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceTapTempoTitle": "Tonight's tap tempo", + "workspaceTapTempoNeed": "Tonight's first count-in still needs a tempo. Tap a steady groove at least four times, then count in at that tempo and check the first range.", + "workspaceTapTempoKeep": "Keep tapping a steady groove. Four taps unlock tonight's count-in tempo.", + "workspaceTapTempoReady": "{tempo} BPM from {taps} taps. Count in 4 at {tempo} BPM, then check tonight's first range.", + "workspaceTapTempoAction": "Tap", + "workspaceTapTempoActionLabel": "Tap the groove to set tonight's tempo", + "workspaceTapTempoReset": "Reset taps", + "workspaceTapTempoResetLabel": "Reset tonight's tap tempo", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..3e2d16afa 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,14 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceTapTempoTitle": "오늘 탭으로 맞출 템포", + "workspaceTapTempoNeed": "오늘 첫 카운트인에는 아직 템포가 필요합니다. 일정한 그루브를 네 번 이상 탭한 다음, 그 템포로 카운트인하고 첫 음역을 확인하세요.", + "workspaceTapTempoKeep": "일정한 그루브를 계속 탭하세요. 네 번이면 오늘 카운트인 템포가 열립니다.", + "workspaceTapTempoReady": "{taps}번 탭으로 {tempo} BPM입니다. {tempo} BPM으로 4박 카운트인한 다음, 오늘 첫 음역을 확인하세요.", + "workspaceTapTempoAction": "탭", + "workspaceTapTempoActionLabel": "그루브를 탭해 오늘 템포를 정하세요", + "workspaceTapTempoReset": "탭 초기화", + "workspaceTapTempoResetLabel": "오늘 탭 템포 초기화", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..fdadbbb79 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/tapTempo.ts", + "src/features/workspace/TapTempo.tsx" ], thresholds: { lines: 90, diff --git a/docs/doctoring/workspace-tap-tempo.md b/docs/doctoring/workspace-tap-tempo.md new file mode 100644 index 000000000..4df03360e --- /dev/null +++ b/docs/doctoring/workspace-tap-tempo.md @@ -0,0 +1,21 @@ +# Tonight's tap tempo + +## Decision + +When the ready rehearsal map has no trusted song tempo, the player taps a steady groove (at least four times) to measure a session BPM, then counts in at that tempo and checks tonight's first range. This is not MIR tempo detection, song playback, stem isolation, or a write to `song.tempo`. + +## Authority + +- Trusted stored tempo is a finite `song.tempo` in 20–400 BPM. That hides the tap control so a session cannot override analysis. +- A session reading needs four taps, a median interval, integer BPM still inside 20–400, and a fastest/slowest interval ratio of at most 2×. +- A pause longer than 3500 ms starts a new window. Malformed clocks and prior state fail closed. + +## Trust boundary + +- Untrusted input: runtime song roots, `tempo`, tap timestamps, and prior tap state. +- Session memory only. No files, URLs, subprocesses, IPC, model artifacts, or persistence. +- This does not invent a click engine. The next action is to count in at the measured BPM, then check the first range. + +## Primary standard + +International Organization for Standardization. (2013). *ISO 80000-3:2013 Quantities and units — Part 3: Space and time* (seconds as the time unit for frequency). https://www.iso.org/standard/31888.html From d4876732e2380cc25d07f98f557fcaeca5db8065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:52:39 -0700 Subject: [PATCH 02/16] test(tap-tempo): reproduce cross-song session carryover Pin the current review finding before changing production behavior: a different tempo-less song must start with an empty tap window even when analysis reuses the same song id, while a same-song practice-progress update must preserve the active session taps. --- .../Workspace.tap-tempo-session.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx new file mode 100644 index 000000000..edb47022f --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +describe("Workspace tap-tempo session ownership", () => { + it("resets session taps when a different tempo-less song replaces a same-id analysis result", () => { + const firstSong = createDemoRehearsalSong(); + firstSong.tempo = undefined; + firstSong.title = "First room song"; + + const nextSong = createDemoRehearsalSong(); + nextSong.tempo = undefined; + nextSong.title = "Second room song"; + expect(nextSong.id).toBe(firstSong.id); + + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i })); + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + + rerender(); + + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-white/15"); + expect(screen.getByRole("button", { name: /reset tonight's tap tempo/i })).toBeDisabled(); + }); + + it("preserves session taps across an immutable practice-progress update to the same song", () => { + const song = createDemoRehearsalSong(); + song.tempo = undefined; + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i })); + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + + const progressOnlyUpdate = { + ...song, + sections: song.sections.map((section, sectionIndex) => ({ + ...section, + roles: section.roles.map((role, roleIndex) => + sectionIndex === 0 && roleIndex === 0 ? { ...role, practiceProgress: 60 } : role + ) + })) + }; + rerender(); + + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + }); +}); From bb862e6094442d0182c6525e867bb4207ded0819 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:55:18 -0700 Subject: [PATCH 03/16] fix(tap-tempo): scope session taps to song identity Reset the session-only tap window when a different song or project owns the workspace, even when analysis reuses a constant song id. Preserve taps across same-song practice-progress and collaboration updates by keying only stable project and musical structure identity. --- .../src/features/workspace/Workspace.tsx | 4 +- .../src/features/workspace/tapTempo.ts | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index bb43e1f1b..8289ddc01 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -6,7 +6,7 @@ import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { TapTempo } from "./TapTempo"; -import { songNeedsTapTempo } from "./tapTempo"; +import { songNeedsTapTempo, tapTempoSessionKey } from "./tapTempo"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -311,7 +311,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

- {songNeedsTapTempo(song) ? : null} + {songNeedsTapTempo(song) ? : null}
diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index 0f367f1ce..8fcd86de4 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -65,6 +65,73 @@ function median(values: number[]): number { return sorted[middle]!; } +/** Return a bounded text scalar for a session-identity projection. */ +function identityText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** Return a finite numeric scalar for a session-identity projection. */ +function identityNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Build a deterministic identity for the song instance that owns session taps. + * + * Project identity is preferred when available, then paired with stable musical + * structure because analysis-engine songs can reuse ids such as `analyzed-song`. + * Mutable practice progress and collaboration state are deliberately excluded so + * ordinary same-song rehearsal updates do not erase the player's active tap window. + */ +export function tapTempoSessionKey(song: unknown, projectId: unknown = null): string { + const projectKey = identityText(projectId); + if (!isRuntimeObject(song)) { + return JSON.stringify([projectKey, null]); + } + + const sections = Array.isArray(song.sections) + ? song.sections.map((sectionValue) => { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const timeRange = isRuntimeObject(sectionValue.timeRange) ? sectionValue.timeRange : null; + const roles = Array.isArray(sectionValue.roles) + ? sectionValue.roles.map((roleValue) => { + if (!isRuntimeObject(roleValue)) { + return null; + } + const harmony = isRuntimeObject(roleValue.harmony) ? roleValue.harmony : null; + const range = isRuntimeObject(roleValue.range) ? roleValue.range : null; + return [ + identityText(roleValue.id), + identityText(roleValue.name), + identityText(roleValue.roleType), + harmony ? identityText(harmony.chord) : "", + harmony ? identityText(harmony.functionLabel) : "", + range ? identityText(range.lowestNote) : "", + range ? identityText(range.highestNote) : "" + ]; + }) + : []; + return [ + identityText(sectionValue.id), + identityText(sectionValue.label), + identityText(sectionValue.groove), + timeRange ? identityNumber(timeRange.start) : null, + timeRange ? identityNumber(timeRange.end) : null, + roles + ]; + }) + : []; + + return JSON.stringify([ + projectKey, + identityText(song.id), + identityText(song.title), + sections + ]); +} + /** * Admit only a finite rehearsal-usable BPM in 20–400. * From d6ad7840ed12c3b51e19229edcce2bea5c379d45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:07:35 -0700 Subject: [PATCH 04/16] test(workspace): preserve tapped tempo across chord edits --- .../Workspace.tap-tempo-session.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx index edb47022f..51170f93e 100644 --- a/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx @@ -45,4 +45,39 @@ describe("Workspace tap-tempo session ownership", () => { expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); }); + + it("preserves session taps across a harmony edit to the current song", () => { + const song = createDemoRehearsalSong(); + song.tempo = undefined; + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i })); + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + + let changedHarmony = false; + const harmonyUpdate = { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => { + if (changedHarmony || !role.harmony) { + return role; + } + changedHarmony = true; + return { + ...role, + harmony: { + ...role.harmony, + chord: `${role.harmony.chord}sus4` + } + }; + }) + })) + }; + expect(changedHarmony).toBe(true); + + rerender(); + + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + }); }); From 10af39ee5e816ea9f477948b5b23195f508e5027 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:09:16 -0700 Subject: [PATCH 05/16] fix(workspace): keep tapped tempo across song edits --- .../src/features/workspace/tapTempo.ts | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index 8fcd86de4..cb2dbfd53 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -76,12 +76,17 @@ function identityNumber(value: unknown): number | null { } /** - * Build a deterministic identity for the song instance that owns session taps. + * Build a stable identity for the song instance that owns session taps. * - * Project identity is preferred when available, then paired with stable musical - * structure because analysis-engine songs can reuse ids such as `analyzed-song`. - * Mutable practice progress and collaboration state are deliberately excluded so - * ordinary same-song rehearsal updates do not erase the player's active tap window. + * Project identity is preferred when available. The fallback deliberately uses + * only song/form identity fields that the mounted rehearsal editor does not + * mutate. Harmony, ranges, groove labels, practice progress, collaboration, and + * other editable rehearsal content are excluded so ordinary same-song edits do + * not remount TapTempo and erase the player's active tap window. + * + * Section ids/timing still distinguish same-id analysis results when no project + * identity is available, while a title change also represents a different loaded + * song for the current local project boundary. */ export function tapTempoSessionKey(song: unknown, projectId: unknown = null): string { const projectKey = identityText(projectId); @@ -95,31 +100,10 @@ export function tapTempoSessionKey(song: unknown, projectId: unknown = null): st return null; } const timeRange = isRuntimeObject(sectionValue.timeRange) ? sectionValue.timeRange : null; - const roles = Array.isArray(sectionValue.roles) - ? sectionValue.roles.map((roleValue) => { - if (!isRuntimeObject(roleValue)) { - return null; - } - const harmony = isRuntimeObject(roleValue.harmony) ? roleValue.harmony : null; - const range = isRuntimeObject(roleValue.range) ? roleValue.range : null; - return [ - identityText(roleValue.id), - identityText(roleValue.name), - identityText(roleValue.roleType), - harmony ? identityText(harmony.chord) : "", - harmony ? identityText(harmony.functionLabel) : "", - range ? identityText(range.lowestNote) : "", - range ? identityText(range.highestNote) : "" - ]; - }) - : []; return [ identityText(sectionValue.id), - identityText(sectionValue.label), - identityText(sectionValue.groove), timeRange ? identityNumber(timeRange.start) : null, - timeRange ? identityNumber(timeRange.end) : null, - roles + timeRange ? identityNumber(timeRange.end) : null ]; }) : []; From 39c1b53351f765ae6d6ffc8153a747c8c52ca573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:11:05 -0700 Subject: [PATCH 06/16] test(workspace): align stored tempo authority with contract --- apps/desktop/src/features/workspace/tapTempo.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.test.ts b/apps/desktop/src/features/workspace/tapTempo.test.ts index 446bf7895..e34569410 100644 --- a/apps/desktop/src/features/workspace/tapTempo.test.ts +++ b/apps/desktop/src/features/workspace/tapTempo.test.ts @@ -79,12 +79,18 @@ describe("tapTempoReading", () => { }); describe("songNeedsTapTempo", () => { - it("hides the tap control when the song already has a trusted tempo", () => { + it("hides the tap control for every stored tempo admitted by the shared song contract", () => { const song = createDemoRehearsalSong(); expect(songNeedsTapTempo(song)).toBe(false); song.tempo = undefined; expect(songNeedsTapTempo(song)).toBe(true); song.tempo = 12; + expect(songNeedsTapTempo(song)).toBe(false); + song.tempo = 401; + expect(songNeedsTapTempo(song)).toBe(false); + song.tempo = Number.NaN; + expect(songNeedsTapTempo(song)).toBe(true); + song.tempo = -1; expect(songNeedsTapTempo(song)).toBe(true); expect(songNeedsTapTempo(null)).toBe(true); }); From c03c87d65c7f82c80b8fa3227b63c4a26520ab8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:11:33 -0700 Subject: [PATCH 07/16] fix(workspace): honor stored tempo contract consistently --- apps/desktop/src/features/workspace/tapTempo.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index cb2dbfd53..8c6ad7804 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -119,8 +119,8 @@ export function tapTempoSessionKey(song: unknown, projectId: unknown = null): st /** * Admit only a finite rehearsal-usable BPM in 20–400. * - * Non-numeric, non-finite, non-positive, and out-of-range values are not - * click authority. This is not a tempo detector and does not invent MIR. + * This bound applies to tempo measured from taps. Stored song tempo follows the + * shared RehearsalSong contract, which accepts any finite positive BPM. */ export function trustedTempoBpm(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value)) { @@ -211,14 +211,15 @@ export function tapTempoReading(state: TapTempoState | unknown): TapTempoReading /** * Return whether the ready map still needs a session tap tempo. * - * Missing, non-finite, or out-of-range `song.tempo` is not click authority. - * A trusted stored tempo hides the tap control so it cannot override analysis. + * A stored tempo uses the shared song-contract authority: finite and positive. + * That same value is already displayed by the workspace badge, so it must also + * suppress session tapping even when it is outside the narrower 20–400 tap range. */ export function songNeedsTapTempo(song: unknown): boolean { if (!isRuntimeObject(song)) { return true; } - return trustedTempoBpm(song.tempo) === null; + return typeof song.tempo !== "number" || !Number.isFinite(song.tempo) || song.tempo <= 0; } /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ From 7dc56fd566d955ef8e32994918bfe402ad18741a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:13:54 -0700 Subject: [PATCH 08/16] test(workspace): bind tap sessions to loaded song instances --- .../Workspace.tap-tempo-session.test.tsx | 71 ++++++++----------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx index 51170f93e..f314bc70a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx @@ -1,8 +1,14 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; +function EditableWorkspace({ initialSong }: { initialSong: RehearsalSong }) { + const [song, setSong] = useState(initialSong); + return ; +} + describe("Workspace tap-tempo session ownership", () => { it("resets session taps when a different tempo-less song replaces a same-id analysis result", () => { const firstSong = createDemoRehearsalSong(); @@ -24,60 +30,41 @@ describe("Workspace tap-tempo session ownership", () => { expect(screen.getByRole("button", { name: /reset tonight's tap tempo/i })).toBeDisabled(); }); - it("preserves session taps across an immutable practice-progress update to the same song", () => { - const song = createDemoRehearsalSong(); - song.tempo = undefined; - const { rerender } = render(); + it("resets session taps when a distinct loaded song collides on projected identity", () => { + const firstSong = createDemoRehearsalSong(); + firstSong.tempo = undefined; + const nextSong = structuredClone(firstSong); + nextSong.sections[0]!.roles[0]!.harmony.chord = `${nextSong.sections[0]!.roles[0]!.harmony.chord}sus4`; + + expect(nextSong.id).toBe(firstSong.id); + expect(nextSong.title).toBe(firstSong.title); + expect(nextSong.sections.map(({ id, timeRange }) => ({ id, timeRange }))).toEqual( + firstSong.sections.map(({ id, timeRange }) => ({ id, timeRange })) + ); + const { rerender } = render(); fireEvent.click(screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i })); expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); - const progressOnlyUpdate = { - ...song, - sections: song.sections.map((section, sectionIndex) => ({ - ...section, - roles: section.roles.map((role, roleIndex) => - sectionIndex === 0 && roleIndex === 0 ? { ...role, practiceProgress: 60 } : role - ) - })) - }; - rerender(); + rerender(); - expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-white/15"); + expect(screen.getByRole("button", { name: /reset tonight's tap tempo/i })).toBeDisabled(); }); - it("preserves session taps across a harmony edit to the current song", () => { + it("preserves session taps when the supported chord editor updates the current song", () => { const song = createDemoRehearsalSong(); song.tempo = undefined; - const { rerender } = render(); + const prompt = vi.spyOn(window, "prompt").mockReturnValue("Dm7"); + render(); fireEvent.click(screen.getByRole("button", { name: /tap the groove to set tonight's tempo/i })); expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); - let changedHarmony = false; - const harmonyUpdate = { - ...song, - sections: song.sections.map((section) => ({ - ...section, - roles: section.roles.map((role) => { - if (changedHarmony || !role.harmony) { - return role; - } - changedHarmony = true; - return { - ...role, - harmony: { - ...role.harmony, - chord: `${role.harmony.chord}sus4` - } - }; - }) - })) - }; - expect(changedHarmony).toBe(true); - - rerender(); + fireEvent.click(screen.getAllByRole("button", { name: /edit chord for/i })[0]!); + expect(prompt).toHaveBeenCalled(); expect(screen.getByTestId("tap-lamp-0").className).toContain("bg-amber-300"); + prompt.mockRestore(); }); }); From 571698b44cb2929b94b407e4abbffafcccefdda9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:14:42 -0700 Subject: [PATCH 09/16] fix(workspace): key tap tempo by loaded song instance --- .../src/features/workspace/tapTempo.ts | 68 +++++++------------ 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index 8c6ad7804..f3b0557e1 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -1,3 +1,4 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy } from "./firstRangeSqueeze"; /** Inclusive lower bound for a rehearsal-usable tap tempo. */ @@ -13,6 +14,9 @@ export const TAP_GAP_RESET_MS = 3_500; /** Reject a window whose fastest and slowest intervals disagree by more than 2×. */ export const MAX_INTERVAL_SPREAD = 2; +const TAP_TEMPO_SESSION_KEYS = new WeakMap(); +let nextTapTempoSession = 1; + /** Session-only tap timestamps. Never persisted onto the song contract. */ export type TapTempoState = { tapsMs: number[]; @@ -65,55 +69,33 @@ function median(values: number[]): number { return sorted[middle]!; } -/** Return a bounded text scalar for a session-identity projection. */ -function identityText(value: unknown): string { - return typeof value === "string" ? value : ""; -} +/** + * Return the in-memory identity of one loaded song instance. + * + * The key is tied to the object instance delivered by the load/analysis boundary, + * not to musical content. Two distinct loaded songs therefore cannot collide even + * when their ids, titles, timings, and harmony happen to match. + */ +export function tapTempoSessionKey(song: RehearsalSong, _projectId: unknown = null): string { + const existing = TAP_TEMPO_SESSION_KEYS.get(song); + if (existing) { + return existing; + } -/** Return a finite numeric scalar for a session-identity projection. */ -function identityNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; + const sessionKey = `tap-tempo-session-${nextTapTempoSession}`; + nextTapTempoSession += 1; + TAP_TEMPO_SESSION_KEYS.set(song, sessionKey); + return sessionKey; } /** - * Build a stable identity for the song instance that owns session taps. - * - * Project identity is preferred when available. The fallback deliberately uses - * only song/form identity fields that the mounted rehearsal editor does not - * mutate. Harmony, ranges, groove labels, practice progress, collaboration, and - * other editable rehearsal content are excluded so ordinary same-song edits do - * not remount TapTempo and erase the player's active tap window. + * Preserve the current tap session when BandScope creates an immutable edit of a song. * - * Section ids/timing still distinguish same-id analysis results when no project - * identity is available, while a title change also represents a different loaded - * song for the current local project boundary. + * Only the workspace's supported edit path calls this function. New objects arriving + * from load/analysis are intentionally left unmarked and receive a fresh session key. */ -export function tapTempoSessionKey(song: unknown, projectId: unknown = null): string { - const projectKey = identityText(projectId); - if (!isRuntimeObject(song)) { - return JSON.stringify([projectKey, null]); - } - - const sections = Array.isArray(song.sections) - ? song.sections.map((sectionValue) => { - if (!isRuntimeObject(sectionValue)) { - return null; - } - const timeRange = isRuntimeObject(sectionValue.timeRange) ? sectionValue.timeRange : null; - return [ - identityText(sectionValue.id), - timeRange ? identityNumber(timeRange.start) : null, - timeRange ? identityNumber(timeRange.end) : null - ]; - }) - : []; - - return JSON.stringify([ - projectKey, - identityText(song.id), - identityText(song.title), - sections - ]); +export function inheritTapTempoSession(sourceSong: RehearsalSong, updatedSong: RehearsalSong): void { + TAP_TEMPO_SESSION_KEYS.set(updatedSong, tapTempoSessionKey(sourceSong)); } /** From f475cc95077da8816cce37b5820db0e79200d82b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:15:55 -0700 Subject: [PATCH 10/16] fix(workspace): preserve taps only across owned edits --- apps/desktop/src/features/workspace/Workspace.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 8289ddc01..e2b576c5a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -6,7 +6,7 @@ import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { TapTempo } from "./TapTempo"; -import { songNeedsTapTempo, tapTempoSessionKey } from "./tapTempo"; +import { inheritTapTempoSession, songNeedsTapTempo, tapTempoSessionKey } from "./tapTempo"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -125,6 +125,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const [activeRole, setActiveRole] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + /** Preserve tap-session ownership only for updates emitted by this mounted workspace. */ + const forwardSongUpdate = (updatedSong: RehearsalSong) => { + if (!onSongUpdate) return; + inheritTapTempoSession(song, updatedSong); + onSongUpdate(updatedSong); + }; + // Extract all unique roles from the song's sections const roleMap = useMemo(() => { const map = new Map(); @@ -190,7 +197,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) }; - onSongUpdate(nextSong); + forwardSongUpdate(nextSong); }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), @@ -508,7 +515,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
From 31d854162004b76ad6749607f9ae811668d82ae1 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 07:25:17 +0900 Subject: [PATCH 11/16] fix(workspace): avoid tap tempo module case collision --- .../src/features/workspace/TapTempo.test.tsx | 2 +- .../{TapTempo.tsx => TapTempoPanel.tsx} | 0 .../src/features/workspace/Workspace.tsx | 4 +-- .../src/features/workspace/tapTempo.ts | 32 +++++++++++-------- 4 files changed, 22 insertions(+), 16 deletions(-) rename apps/desktop/src/features/workspace/{TapTempo.tsx => TapTempoPanel.tsx} (100%) diff --git a/apps/desktop/src/features/workspace/TapTempo.test.tsx b/apps/desktop/src/features/workspace/TapTempo.test.tsx index a7da6af1d..4abb0fc51 100644 --- a/apps/desktop/src/features/workspace/TapTempo.test.tsx +++ b/apps/desktop/src/features/workspace/TapTempo.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { createTranslator } from "../../i18n"; -import { TapTempo } from "./TapTempo"; +import { TapTempo } from "./TapTempoPanel"; const t = createTranslator("en"); diff --git a/apps/desktop/src/features/workspace/TapTempo.tsx b/apps/desktop/src/features/workspace/TapTempoPanel.tsx similarity index 100% rename from apps/desktop/src/features/workspace/TapTempo.tsx rename to apps/desktop/src/features/workspace/TapTempoPanel.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index e2b576c5a..9bdce678a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,7 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { TapTempo } from "./TapTempo"; +import { TapTempo } from "./TapTempoPanel"; import { inheritTapTempoSession, songNeedsTapTempo, tapTempoSessionKey } from "./tapTempo"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -318,7 +318,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

- {songNeedsTapTempo(song) ? : null} + {songNeedsTapTempo(song) ? : null}
diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index f3b0557e1..8e5751525 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -1,18 +1,24 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy } from "./firstRangeSqueeze"; -/** Inclusive lower bound for a rehearsal-usable tap tempo. */ -export const MIN_TRUSTED_TEMPO_BPM = 20; -/** Inclusive upper bound for a rehearsal-usable tap tempo. */ -export const MAX_TRUSTED_TEMPO_BPM = 400; -/** Four taps yield three intervals, the minimum for a median BPM. */ -export const MIN_TAP_COUNT = 4; -/** Sliding window so a long groove cannot grow without bound. */ -export const MAX_TAP_HISTORY = 8; -/** A pause longer than a 20 BPM interval starts a new tap group. */ -export const TAP_GAP_RESET_MS = 3_500; -/** Reject a window whose fastest and slowest intervals disagree by more than 2×. */ -export const MAX_INTERVAL_SPREAD = 2; +export /** + * Inclusive lower bound for a rehearsal-usable tap tempo. + */ const MIN_TRUSTED_TEMPO_BPM = 20; +export /** + * Inclusive upper bound for a rehearsal-usable tap tempo. + */ const MAX_TRUSTED_TEMPO_BPM = 400; +export /** + * Four taps yield three intervals, the minimum for a median BPM. + */ const MIN_TAP_COUNT = 4; +export /** + * Sliding window so a long groove cannot grow without bound. + */ const MAX_TAP_HISTORY = 8; +export /** + * A pause longer than a 20 BPM interval starts a new tap group. + */ const TAP_GAP_RESET_MS = 3_500; +export /** + * Reject a window whose fastest and slowest intervals disagree by more than 2×. + */ const MAX_INTERVAL_SPREAD = 2; const TAP_TEMPO_SESSION_KEYS = new WeakMap(); let nextTapTempoSession = 1; @@ -76,7 +82,7 @@ function median(values: number[]): number { * not to musical content. Two distinct loaded songs therefore cannot collide even * when their ids, titles, timings, and harmony happen to match. */ -export function tapTempoSessionKey(song: RehearsalSong, _projectId: unknown = null): string { +export function tapTempoSessionKey(song: RehearsalSong): string { const existing = TAP_TEMPO_SESSION_KEYS.get(song); if (existing) { return existing; From a213f6f5933cc5b1c5da9717cf40b861572b59b3 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 09:02:06 +0900 Subject: [PATCH 12/16] docs(workspace): align tap tempo authority --- docs/doctoring/workspace-tap-tempo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/workspace-tap-tempo.md b/docs/doctoring/workspace-tap-tempo.md index 4df03360e..d355466dc 100644 --- a/docs/doctoring/workspace-tap-tempo.md +++ b/docs/doctoring/workspace-tap-tempo.md @@ -6,7 +6,7 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady ## Authority -- Trusted stored tempo is a finite `song.tempo` in 20–400 BPM. That hides the tap control so a session cannot override analysis. +- A stored tempo is trusted when `song.tempo` is finite and positive under the shared song contract. That hides the tap control so a session cannot override analysis; the narrower 20–400 BPM bound applies only to newly measured taps. - A session reading needs four taps, a median interval, integer BPM still inside 20–400, and a fastest/slowest interval ratio of at most 2×. - A pause longer than 3500 ms starts a new window. Malformed clocks and prior state fail closed. From abe144465ee1905d3447dfd7e3e6919985c3b399 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 09:05:45 +0900 Subject: [PATCH 13/16] fix(workspace): remove unsupported tap tempo heuristics --- .../src/features/workspace/tapTempo.test.ts | 13 +++++----- .../src/features/workspace/tapTempo.ts | 25 ++++--------------- docs/doctoring/workspace-tap-tempo.md | 7 +++--- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.test.ts b/apps/desktop/src/features/workspace/tapTempo.test.ts index e34569410..438614936 100644 --- a/apps/desktop/src/features/workspace/tapTempo.test.ts +++ b/apps/desktop/src/features/workspace/tapTempo.test.ts @@ -33,12 +33,10 @@ describe("trustedTempoBpm", () => { }); describe("recordTap", () => { - it("ignores non-finite clocks, resets after a long pause, and caps history", () => { + it("ignores non-finite or backwards clocks and caps history", () => { expect(recordTap(emptyTapTempo(), Number.NaN)).toEqual({ tapsMs: [] }); expect(recordTap({ tapsMs: [1000] }, 900)).toEqual({ tapsMs: [1000] }); - - const reset = recordTap({ tapsMs: [1000] }, 1000 + 3_501); - expect(reset).toEqual({ tapsMs: [4_501] }); + expect(recordTap({ tapsMs: [1000] }, 4_501)).toEqual({ tapsMs: [1000, 4_501] }); let state = emptyTapTempo(); for (let index = 0; index < MAX_TAP_HISTORY + 3; index += 1) { @@ -67,13 +65,16 @@ describe("tapTempoReading", () => { expect(tapTempoReading({ tapsMs: [0, 500, 500, 1_000] })).toBeNull(); }); - it("uses the median interval and fails closed on an unsteady or out-of-range window", () => { + it("uses the median interval and fails closed on an out-of-range window", () => { const medianState = recordTap(recordTap(recordTap(recordTap(emptyTapTempo(), 0), 480), 1_000), 1_500); expect(tapTempoReading(medianState)?.tempoBpm).toBe(120); expect(tapTempoReading(tapsAt(0, 100, 4))).toBeNull(); expect(tapTempoReading(tapsAt(0, 4_000, 4))).toBeNull(); - expect(tapTempoReading({ tapsMs: [0, 200, 1_200, 1_400] })).toBeNull(); + expect(tapTempoReading({ tapsMs: [0, 200, 1_200, 1_400] })).toMatchObject({ + tempoBpm: 300, + intervalMs: 200 + }); expect(tapTempoReading(null)).toBeNull(); }); }); diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index 8e5751525..272c4ea3f 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -13,12 +13,6 @@ export /** export /** * Sliding window so a long groove cannot grow without bound. */ const MAX_TAP_HISTORY = 8; -export /** - * A pause longer than a 20 BPM interval starts a new tap group. - */ const TAP_GAP_RESET_MS = 3_500; -export /** - * Reject a window whose fastest and slowest intervals disagree by more than 2×. - */ const MAX_INTERVAL_SPREAD = 2; const TAP_TEMPO_SESSION_KEYS = new WeakMap(); let nextTapTempoSession = 1; @@ -126,11 +120,11 @@ export function emptyTapTempo(): TapTempoState { } /** - * Record one tap, fail closed on a bad clock, and reset after a long pause. + * Record one tap, fail closed on a bad clock, and keep a bounded history. * * Runtime clocks and prior state are untrusted. A backwards or non-finite - * timestamp is ignored. A gap longer than `TAP_GAP_RESET_MS` starts a new - * window so a late entrance cannot drag the median. + * timestamp is ignored. Long pauses stay in the bounded history; the median + * interval estimator limits their influence without an arbitrary reset gap. */ export function recordTap(state: TapTempoState | unknown, nowMs: unknown): TapTempoState { const timestamp = trustedTimestampMs(nowMs); @@ -144,9 +138,6 @@ export function recordTap(state: TapTempoState | unknown, nowMs: unknown): TapTe if (timestamp <= last) { return { tapsMs: taps }; } - if (timestamp - last > TAP_GAP_RESET_MS) { - return { tapsMs: [timestamp] }; - } } taps.push(timestamp); @@ -159,8 +150,8 @@ export function recordTap(state: TapTempoState | unknown, nowMs: unknown): TapTe /** * Read a trusted BPM from at least four taps, fail closed otherwise. * - * Uses the median interval so one rushed or late tap cannot own the tempo. - * A window whose intervals disagree by more than 2× is unsteady, not a click. + * Uses the median interval so one rushed, late, or paused tap cannot own the + * tempo. The bounded history keeps the session state predictable. */ export function tapTempoReading(state: TapTempoState | unknown): TapTempoReading | null { const taps = trustedTapTimestamps(state); @@ -177,12 +168,6 @@ export function tapTempoReading(state: TapTempoState | unknown): TapTempoReading intervals.push(interval); } - const fastest = Math.min(...intervals); - const slowest = Math.max(...intervals); - if (fastest <= 0 || slowest / fastest > MAX_INTERVAL_SPREAD) { - return null; - } - const intervalMs = median(intervals); const tempoBpm = Math.round(60_000 / intervalMs); if (trustedTempoBpm(tempoBpm) === null) { diff --git a/docs/doctoring/workspace-tap-tempo.md b/docs/doctoring/workspace-tap-tempo.md index d355466dc..dcc96124c 100644 --- a/docs/doctoring/workspace-tap-tempo.md +++ b/docs/doctoring/workspace-tap-tempo.md @@ -7,8 +7,7 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady ## Authority - A stored tempo is trusted when `song.tempo` is finite and positive under the shared song contract. That hides the tap control so a session cannot override analysis; the narrower 20–400 BPM bound applies only to newly measured taps. -- A session reading needs four taps, a median interval, integer BPM still inside 20–400, and a fastest/slowest interval ratio of at most 2×. -- A pause longer than 3500 ms starts a new window. Malformed clocks and prior state fail closed. +- A session reading needs four taps, the median of the bounded history's intervals, and integer BPM still inside 20–400. The median limits the influence of a rushed, late, or paused tap; malformed clocks and prior state fail closed. ## Trust boundary @@ -18,4 +17,6 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady ## Primary standard -International Organization for Standardization. (2013). *ISO 80000-3:2013 Quantities and units — Part 3: Space and time* (seconds as the time unit for frequency). https://www.iso.org/standard/31888.html +International Organization for Standardization. (2019). *ISO 80000-3:2019 Quantities and units — Part 3: Space and time* (current edition; reviewed and confirmed in 2023). https://www.iso.org/standard/64974.html + +Kaya, E., & Henry, M. J. (2022). Reliable estimation of internal oscillator properties from a novel, fast-paced tapping paradigm. *Scientific Reports, 12*, 20466. https://doi.org/10.1038/s41598-022-24453-6 From b8cbfe0449c2fb7b0e280c5e7a718b8323219872 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 09:10:14 +0900 Subject: [PATCH 14/16] fix(workspace): ground tap tempo bounds in research --- apps/desktop/src/features/workspace/tapTempo.test.ts | 11 ++++++----- apps/desktop/src/features/workspace/tapTempo.ts | 10 +++++----- docs/doctoring/workspace-tap-tempo.md | 4 +++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.test.ts b/apps/desktop/src/features/workspace/tapTempo.test.ts index 438614936..e6c8b0f76 100644 --- a/apps/desktop/src/features/workspace/tapTempo.test.ts +++ b/apps/desktop/src/features/workspace/tapTempo.test.ts @@ -20,12 +20,12 @@ function tapsAt(startMs: number, intervalMs: number, count: number) { } describe("trustedTempoBpm", () => { - it("admits only finite rehearsal-usable BPM in 20–400", () => { + it("admits only finite BPM in the documented 33–300 tapping range", () => { expect(trustedTempoBpm(120)).toBe(120); - expect(trustedTempoBpm(20)).toBe(20); - expect(trustedTempoBpm(400)).toBe(400); - expect(trustedTempoBpm(19)).toBeNull(); - expect(trustedTempoBpm(401)).toBeNull(); + expect(trustedTempoBpm(33)).toBe(33); + expect(trustedTempoBpm(300)).toBe(300); + expect(trustedTempoBpm(32)).toBeNull(); + expect(trustedTempoBpm(301)).toBeNull(); expect(trustedTempoBpm(0)).toBeNull(); expect(trustedTempoBpm(Number.NaN)).toBeNull(); expect(trustedTempoBpm("120")).toBeNull(); @@ -70,6 +70,7 @@ describe("tapTempoReading", () => { expect(tapTempoReading(medianState)?.tempoBpm).toBe(120); expect(tapTempoReading(tapsAt(0, 100, 4))).toBeNull(); + expect(tapTempoReading(tapsAt(0, 1_800, 4))?.tempoBpm).toBe(33); expect(tapTempoReading(tapsAt(0, 4_000, 4))).toBeNull(); expect(tapTempoReading({ tapsMs: [0, 200, 1_200, 1_400] })).toMatchObject({ tempoBpm: 300, diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index 272c4ea3f..f88a3bcc7 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -2,11 +2,11 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy } from "./firstRangeSqueeze"; export /** - * Inclusive lower bound for a rehearsal-usable tap tempo. - */ const MIN_TRUSTED_TEMPO_BPM = 20; + * Inclusive lower bound supported by the documented human tapping range. + */ const MIN_TRUSTED_TEMPO_BPM = 33; export /** - * Inclusive upper bound for a rehearsal-usable tap tempo. - */ const MAX_TRUSTED_TEMPO_BPM = 400; + * Inclusive upper bound supported by the documented human tapping range. + */ const MAX_TRUSTED_TEMPO_BPM = 300; export /** * Four taps yield three intervals, the minimum for a median BPM. */ const MIN_TAP_COUNT = 4; @@ -99,7 +99,7 @@ export function inheritTapTempoSession(sourceSong: RehearsalSong, updatedSong: R } /** - * Admit only a finite rehearsal-usable BPM in 20–400. + * Admit only a finite BPM in the documented 33–300 human tapping range. * * This bound applies to tempo measured from taps. Stored song tempo follows the * shared RehearsalSong contract, which accepts any finite positive BPM. diff --git a/docs/doctoring/workspace-tap-tempo.md b/docs/doctoring/workspace-tap-tempo.md index dcc96124c..e54ec0945 100644 --- a/docs/doctoring/workspace-tap-tempo.md +++ b/docs/doctoring/workspace-tap-tempo.md @@ -7,7 +7,7 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady ## Authority - A stored tempo is trusted when `song.tempo` is finite and positive under the shared song contract. That hides the tap control so a session cannot override analysis; the narrower 20–400 BPM bound applies only to newly measured taps. -- A session reading needs four taps, the median of the bounded history's intervals, and integer BPM still inside 20–400. The median limits the influence of a rushed, late, or paused tap; malformed clocks and prior state fail closed. +- A session reading needs four taps, the median of the bounded history's intervals, and integer BPM still inside 33–300. The median limits the influence of a rushed, late, or paused tap; malformed clocks and prior state fail closed. The 33–300 BPM (200–1800 ms) boundary follows the documented human sensorimotor-synchronization range rather than an arbitrary UI threshold. ## Trust boundary @@ -20,3 +20,5 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady International Organization for Standardization. (2019). *ISO 80000-3:2019 Quantities and units — Part 3: Space and time* (current edition; reviewed and confirmed in 2023). https://www.iso.org/standard/64974.html Kaya, E., & Henry, M. J. (2022). Reliable estimation of internal oscillator properties from a novel, fast-paced tapping paradigm. *Scientific Reports, 12*, 20466. https://doi.org/10.1038/s41598-022-24453-6 + +Lem, N., & Fujioka, T. (2023). Individual differences of limitation to extract beat from Kuramoto coupled oscillators: Transition from beat-based tapping to frequent tapping with weaker coupling. *PLOS ONE, 18*(10), e0292059. https://doi.org/10.1371/journal.pone.0292059 From 3e9dc17a7a72514bbd358e2a7e486d3a71b4d255 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 09:13:36 +0900 Subject: [PATCH 15/16] test(workspace): cover tap tempo panel --- apps/desktop/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index fdadbbb79..346821d42 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ "src/features/score/ScoreView.tsx", "src/features/score/scoreStorage.ts", "src/features/workspace/tapTempo.ts", - "src/features/workspace/TapTempo.tsx" + "src/features/workspace/TapTempoPanel.tsx" ], thresholds: { lines: 90, From 759d571af92ddc6b13045e4f3ddc800cc44fcedd Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 09:14:42 +0900 Subject: [PATCH 16/16] docs(workspace): align tap tempo range --- apps/desktop/src/features/workspace/tapTempo.ts | 2 +- docs/doctoring/workspace-tap-tempo.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/tapTempo.ts b/apps/desktop/src/features/workspace/tapTempo.ts index f88a3bcc7..3300bcbb3 100644 --- a/apps/desktop/src/features/workspace/tapTempo.ts +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -186,7 +186,7 @@ export function tapTempoReading(state: TapTempoState | unknown): TapTempoReading * * A stored tempo uses the shared song-contract authority: finite and positive. * That same value is already displayed by the workspace badge, so it must also - * suppress session tapping even when it is outside the narrower 20–400 tap range. + * suppress session tapping even when it is outside the narrower 33–300 tap range. */ export function songNeedsTapTempo(song: unknown): boolean { if (!isRuntimeObject(song)) { diff --git a/docs/doctoring/workspace-tap-tempo.md b/docs/doctoring/workspace-tap-tempo.md index e54ec0945..8d5b1edd7 100644 --- a/docs/doctoring/workspace-tap-tempo.md +++ b/docs/doctoring/workspace-tap-tempo.md @@ -6,7 +6,7 @@ When the ready rehearsal map has no trusted song tempo, the player taps a steady ## Authority -- A stored tempo is trusted when `song.tempo` is finite and positive under the shared song contract. That hides the tap control so a session cannot override analysis; the narrower 20–400 BPM bound applies only to newly measured taps. +- A stored tempo is trusted when `song.tempo` is finite and positive under the shared song contract. That hides the tap control so a session cannot override analysis; the narrower 33–300 BPM bound applies only to newly measured taps. - A session reading needs four taps, the median of the bounded history's intervals, and integer BPM still inside 33–300. The median limits the influence of a rushed, late, or paused tap; malformed clocks and prior state fail closed. The 33–300 BPM (200–1800 ms) boundary follows the documented human sensorimotor-synchronization range rather than an arbitrary UI threshold. ## Trust boundary