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..4abb0fc51 --- /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 "./TapTempoPanel"; + +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/TapTempoPanel.tsx b/apps/desktop/src/features/workspace/TapTempoPanel.tsx new file mode 100644 index 000000000..f8ca05667 --- /dev/null +++ b/apps/desktop/src/features/workspace/TapTempoPanel.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.tap-tempo-session.test.tsx b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx new file mode 100644 index 000000000..f314bc70a --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.tap-tempo-session.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +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(); + 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("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"); + + 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 when the supported chord editor updates the current song", () => { + const song = createDemoRehearsalSong(); + song.tempo = undefined; + 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"); + + 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(); + }); +}); 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..9bdce678a 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 "./TapTempoPanel"; +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"; @@ -123,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(); @@ -188,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 : []), @@ -309,6 +318,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+ {songNeedsTapTempo(song) ? : null}
@@ -505,7 +515,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
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..e6c8b0f76 --- /dev/null +++ b/apps/desktop/src/features/workspace/tapTempo.test.ts @@ -0,0 +1,111 @@ +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 BPM in the documented 33–300 tapping range", () => { + expect(trustedTempoBpm(120)).toBe(120); + 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(); + }); +}); + +describe("recordTap", () => { + 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] }); + 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) { + 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 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, 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, + intervalMs: 200 + }); + expect(tapTempoReading(null)).toBeNull(); + }); +}); + +describe("songNeedsTapTempo", () => { + 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); + }); +}); + +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..3300bcbb3 --- /dev/null +++ b/apps/desktop/src/features/workspace/tapTempo.ts @@ -0,0 +1,201 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy } from "./firstRangeSqueeze"; + +export /** + * Inclusive lower bound supported by the documented human tapping range. + */ const MIN_TRUSTED_TEMPO_BPM = 33; +export /** + * 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; +export /** + * Sliding window so a long groove cannot grow without bound. + */ const MAX_TAP_HISTORY = 8; + +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[]; +}; + +/** 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]!; +} + +/** + * 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): string { + const existing = TAP_TEMPO_SESSION_KEYS.get(song); + if (existing) { + return existing; + } + + const sessionKey = `tap-tempo-session-${nextTapTempoSession}`; + nextTapTempoSession += 1; + TAP_TEMPO_SESSION_KEYS.set(song, sessionKey); + return sessionKey; +} + +/** + * Preserve the current tap session when BandScope creates an immutable edit of a song. + * + * 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 inheritTapTempoSession(sourceSong: RehearsalSong, updatedSong: RehearsalSong): void { + TAP_TEMPO_SESSION_KEYS.set(updatedSong, tapTempoSessionKey(sourceSong)); +} + +/** + * 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. + */ +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 keep a bounded history. + * + * Runtime clocks and prior state are untrusted. A backwards or non-finite + * 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); + 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 }; + } + } + + 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, 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); + 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 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. + * + * 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 33–300 tap range. + */ +export function songNeedsTapTempo(song: unknown): boolean { + if (!isRuntimeObject(song)) { + return true; + } + return typeof song.tempo !== "number" || !Number.isFinite(song.tempo) || song.tempo <= 0; +} + +/** 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..346821d42 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/TapTempoPanel.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..8d5b1edd7 --- /dev/null +++ b/docs/doctoring/workspace-tap-tempo.md @@ -0,0 +1,24 @@ +# 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 + +- 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 + +- 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. (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