From 64576f2edd4b981f403d81520757e99845fff5eb Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 21:17:42 +0000 Subject: [PATCH 1/7] feat(workspace): count in tonight's first click before the range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play a local four-beat click from a trusted 20–400 BPM so the ready map names a next action a player can take before checking tonight's first range. Missing tempo or AudioContext fails closed to an ear count. This is not song playback, stem isolation, or a parallel MIR product. --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../features/workspace/CountInClick.test.tsx | 137 +++++++++++++++ .../src/features/workspace/CountInClick.tsx | 130 ++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 13 ++ .../src/features/workspace/Workspace.tsx | 5 + .../workspace/countInClickEngine.test.ts | 165 ++++++++++++++++++ .../features/workspace/countInClickEngine.ts | 142 +++++++++++++++ .../features/workspace/firstCountIn.test.ts | 98 +++++++++++ .../src/features/workspace/firstCountIn.ts | 118 +++++++++++++ apps/desktop/src/locales/en/common.json | 10 ++ apps/desktop/src/locales/ko/common.json | 10 ++ apps/desktop/vite.config.ts | 5 +- .../workspace-first-count-in-click.md | 21 +++ 16 files changed, 858 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/features/workspace/CountInClick.test.tsx create mode 100644 apps/desktop/src/features/workspace/CountInClick.tsx create mode 100644 apps/desktop/src/features/workspace/countInClickEngine.test.ts create mode 100644 apps/desktop/src/features/workspace/countInClickEngine.ts create mode 100644 apps/desktop/src/features/workspace/firstCountIn.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCountIn.ts create mode 100644 docs/doctoring/workspace-first-count-in-click.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..2e3a3c1f6 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 local count-in click before tonight's first range, 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..e3986da44 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 local count-in click from trusted tempo, 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..c38d16b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first count-in on the ready rehearsal map and play a local click at the trusted tempo before the first range check. - 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..cdae565b2 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, plays a local count-in click, 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/CountInClick.test.tsx b/apps/desktop/src/features/workspace/CountInClick.test.tsx new file mode 100644 index 000000000..7ed138da9 --- /dev/null +++ b/apps/desktop/src/features/workspace/CountInClick.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CountInClick } from "./CountInClick"; +import type { CountInClickEngine } from "./countInClickEngine"; +import type { FirstCountInPlan } from "./firstCountIn"; +import { createTranslator } from "../../i18n"; + +const t = createTranslator("en"); +const plan: FirstCountInPlan = { + tempoBpm: 120, + beats: 4, + intervalMs: 500, + sectionLabel: "verse" +}; + +function renderCountIn(engine: CountInClickEngine, nextPlan: FirstCountInPlan | null = plan) { + return render(); +} + +describe("CountInClick", () => { + it("names the count-in next action and plays a local click", async () => { + const engine: CountInClickEngine = { + available: true, + play: vi.fn(async () => undefined), + stop: vi.fn() + }; + renderCountIn(engine); + + const region = screen.getByTestId("first-count-in"); + expect(region).toHaveTextContent("Tonight's first count-in"); + expect(region).toHaveTextContent( + "Count in 4 at 120 BPM, then check tonight's first range before the verse." + ); + + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + expect(engine.play).toHaveBeenCalledWith(plan); + await waitFor(() => { + expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); + }); + }); + + it("asks the room to name a section when tempo is trusted but unlabeled", () => { + const engine: CountInClickEngine = { + available: true, + play: vi.fn(async () => undefined), + stop: vi.fn() + }; + renderCountIn(engine, { ...plan, sectionLabel: undefined }); + expect(screen.getByTestId("first-count-in")).toHaveTextContent( + "Count in 4 at 120 BPM, then name the first section so the room knows where it starts." + ); + }); + + it("fails closed without a tempo and does not start a click", () => { + const engine: CountInClickEngine = { + available: true, + play: vi.fn(async () => undefined), + stop: vi.fn() + }; + renderCountIn(engine, null); + expect(screen.getByTestId("first-count-in")).toHaveTextContent( + "Tonight's first count-in still needs a tempo. Count the first section in by ear before you start." + ); + fireEvent.click(screen.getByRole("button", { name: /^count in$/i })); + expect(engine.play).not.toHaveBeenCalled(); + }); + + it("blocks when the host cannot synthesize a click and when play throws", async () => { + const unavailable: CountInClickEngine = { + available: false, + play: vi.fn(async () => undefined), + stop: vi.fn() + }; + const { rerender } = renderCountIn(unavailable); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy(); + + const failing: CountInClickEngine = { + available: true, + play: vi.fn(async () => { + throw new Error("context failed"); + }), + stop: vi.fn() + }; + rerender(); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + await waitFor(() => { + expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy(); + }); + }); + + it("ignores a second count-in click while the first is in flight", async () => { + let finishPlay: (() => void) | undefined; + const engine: CountInClickEngine = { + available: true, + play: vi.fn( + () => + new Promise((resolve) => { + finishPlay = resolve; + }) + ), + stop: vi.fn() + }; + renderCountIn(engine); + const button = screen.getByRole("button", { name: /count in 4 at 120 bpm/i }); + fireEvent.click(button); + fireEvent.click(button); + expect(engine.play).toHaveBeenCalledTimes(1); + finishPlay?.(); + await waitFor(() => { + expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); + }); + }); + + it("stops a playing count-in and ignores a stale completion", async () => { + let finishPlay: (() => void) | undefined; + const engine: CountInClickEngine = { + available: true, + play: vi.fn( + () => + new Promise((resolve) => { + finishPlay = resolve; + }) + ), + stop: vi.fn() + }; + renderCountIn(engine); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in"); + fireEvent.click(screen.getByRole("button", { name: /stop count-in/i })); + expect(engine.stop).toHaveBeenCalled(); + finishPlay?.(); + await waitFor(() => { + expect(screen.queryByText("Now check that span on your instrument.")).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/CountInClick.tsx b/apps/desktop/src/features/workspace/CountInClick.tsx new file mode 100644 index 000000000..799bf4434 --- /dev/null +++ b/apps/desktop/src/features/workspace/CountInClick.tsx @@ -0,0 +1,130 @@ +import { useMemo, useRef, useState } from "react"; +import { Timer } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { createWebAudioCountInEngine, type CountInClickEngine } from "./countInClickEngine"; +import { fillCountInCopy, type FirstCountInPlan } from "./firstCountIn"; +import { createTranslator } from "../../i18n"; + +type Translator = ReturnType; + +interface CountInClickProps { + plan: FirstCountInPlan | null; + t: Translator; + engine?: CountInClickEngine; +} + +/** + * Play tonight's local count-in click, then send the player to the range check. + * + * This is a metronome click, not song playback and not stem isolation. A + * missing audio context fails closed with an ear-count next action. + */ +export function CountInClick({ plan, t, engine }: CountInClickProps) { + const defaultEngine = useMemo(() => engine ?? createWebAudioCountInEngine(), [engine]); + const engineRef = useRef(defaultEngine); + engineRef.current = defaultEngine; + const [status, setStatus] = useState<"idle" | "playing" | "done" | "blocked">("idle"); + const playGeneration = useRef(0); + const inFlight = useRef(false); + + const guidance = useMemo(() => { + if (!plan) { + return t("workspaceFirstCountInMissing"); + } + const values = { + beats: String(plan.beats), + tempo: String(plan.tempoBpm), + sectionLabel: plan.sectionLabel ?? "" + }; + if (plan.sectionLabel) { + return fillCountInCopy(t("workspaceFirstCountInReady"), values); + } + return fillCountInCopy(t("workspaceFirstCountInReadyNoSection"), values); + }, [plan, t]); + + const unavailableCopy = fillCountInCopy(t("workspaceFirstCountInUnavailable"), { + beats: String(plan?.beats ?? 4) + }); + + const handleCountIn = async (): Promise => { + if (!plan || !engineRef.current.available || inFlight.current) { + return; + } + + const generation = playGeneration.current + 1; + playGeneration.current = generation; + inFlight.current = true; + setStatus("playing"); + try { + await engineRef.current.play(plan); + if (playGeneration.current === generation) { + setStatus("done"); + } + } catch { + if (playGeneration.current === generation) { + setStatus("blocked"); + } + } finally { + if (playGeneration.current === generation) { + inFlight.current = false; + } + } + }; + + const handleStop = (): void => { + playGeneration.current += 1; + inFlight.current = false; + engineRef.current.stop(); + setStatus("idle"); + }; + + const canPlay = Boolean(plan) && defaultEngine.available && status !== "playing"; + const actionLabel = status === "playing" ? t("workspaceFirstCountInPlaying") : t("workspaceFirstCountInAction"); + const doneCopy = status === "done" ? t("workspaceFirstCountInDone") : null; + const blockedCopy = + Boolean(plan) && (!defaultEngine.available || status === "blocked") ? unavailableCopy : null; + + return ( +
+

{t("workspaceFirstCountInTitle")}

+

{guidance}

+ {doneCopy ?

{doneCopy}

: null} + {blockedCopy ?

{blockedCopy}

: null} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..819a80335 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,19 @@ describe("Workspace", () => { ); }); + it("names tonight's first count-in and the next range check", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-count-in"); + expect(callout).toHaveTextContent("Tonight's first count-in"); + expect(callout).toHaveTextContent( + "Count in 4 at 120 BPM, then check tonight's first range before the verse." + ); + }); + 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..ab71a7e66 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 { CountInClick } from "./CountInClick"; +import { firstCountInPlan } from "./firstCountIn"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +165,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const countInPlan = useMemo(() => firstCountInPlan(song), [song]); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +313,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ +

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/countInClickEngine.test.ts b/apps/desktop/src/features/workspace/countInClickEngine.test.ts new file mode 100644 index 000000000..a0009ae1d --- /dev/null +++ b/apps/desktop/src/features/workspace/countInClickEngine.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createWebAudioCountInEngine, + defaultCountInContextFactory, + type CountInAudioContext, + type CountInGain, + type CountInOscillator +} from "./countInClickEngine"; +import type { FirstCountInPlan } from "./firstCountIn"; + +const plan: FirstCountInPlan = { + tempoBpm: 120, + beats: 2, + intervalMs: 500, + sectionLabel: "verse" +}; + +function createFakeContext(): { + context: CountInAudioContext; + oscillators: CountInOscillator[]; +} { + const oscillators: CountInOscillator[] = []; + const gain: CountInGain = { + connect: () => gain, + disconnect: vi.fn(), + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn() + } + }; + const context: CountInAudioContext = { + currentTime: 1, + destination: {}, + state: "running", + resume: vi.fn(async () => undefined), + createGain: () => gain, + createOscillator: () => { + const oscillator: CountInOscillator = { + connect: vi.fn(), + disconnect: vi.fn(), + frequency: { value: 0 }, + type: "sine", + start: vi.fn(), + stop: vi.fn() + }; + oscillators.push(oscillator); + return oscillator; + } + }; + return { context, oscillators }; +} + +describe("defaultCountInContextFactory", () => { + const originalAudioContext = window.AudioContext; + + afterEach(() => { + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: originalAudioContext + }); + Reflect.deleteProperty(window, "webkitAudioContext"); + }); + + it("returns null when the host has no AudioContext constructor", () => { + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: undefined + }); + expect(defaultCountInContextFactory()).toBeNull(); + }); + + it("constructs a context from the host AudioContext", () => { + class FakeAudioContext { + currentTime = 0; + } + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: FakeAudioContext + }); + const factory = defaultCountInContextFactory(); + expect(factory).not.toBeNull(); + expect(factory?.()).toBeInstanceOf(FakeAudioContext); + }); + + it("falls back to webkitAudioContext when AudioContext is missing", () => { + class FakeWebkitAudioContext { + currentTime = 0; + } + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: undefined + }); + Object.defineProperty(window, "webkitAudioContext", { + configurable: true, + writable: true, + value: FakeWebkitAudioContext + }); + const factory = defaultCountInContextFactory(); + expect(factory).not.toBeNull(); + expect(factory?.()).toBeInstanceOf(FakeWebkitAudioContext); + }); +}); + +describe("createWebAudioCountInEngine", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("is unavailable and rejects play when no factory exists", async () => { + const engine = createWebAudioCountInEngine(null); + expect(engine.available).toBe(false); + await expect(engine.play(plan)).rejects.toThrow(/unavailable/i); + }); + + it("schedules an accented first click, resumes a suspended context, and stops live oscillators", async () => { + vi.useFakeTimers(); + const { context, oscillators } = createFakeContext(); + context.state = "suspended"; + const engine = createWebAudioCountInEngine(() => context); + + const playPromise = engine.play(plan); + await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(1); + expect(oscillators).toHaveLength(2); + expect(oscillators[0]?.frequency.value).toBe(1200); + expect(oscillators[1]?.frequency.value).toBe(800); + expect(oscillators[0]?.type).toBe("square"); + expect(oscillators[0]?.start).toHaveBeenCalled(); + expect(oscillators[0]?.stop).toHaveBeenCalled(); + + engine.stop(); + expect(oscillators[0]?.stop).toHaveBeenCalled(); + expect(oscillators[0]?.disconnect).toHaveBeenCalled(); + + vi.runAllTimers(); + await playPromise; + }); + + it("rejects a plan with no trusted beats", async () => { + const { context } = createFakeContext(); + const engine = createWebAudioCountInEngine(() => context); + await expect(engine.play({ ...plan, beats: 0 })).rejects.toThrow(/trusted beats/i); + }); + + it("swallows stop errors from already-finished oscillators", async () => { + const { context, oscillators } = createFakeContext(); + const engine = createWebAudioCountInEngine(() => context); + vi.useFakeTimers(); + const playPromise = engine.play(plan); + await Promise.resolve(); + oscillators[0]!.stop = () => { + throw new Error("already stopped"); + }; + oscillators[0]!.disconnect = () => { + throw new Error("already disconnected"); + }; + engine.stop(); + vi.runAllTimers(); + await playPromise; + }); +}); diff --git a/apps/desktop/src/features/workspace/countInClickEngine.ts b/apps/desktop/src/features/workspace/countInClickEngine.ts new file mode 100644 index 000000000..cc543c3e6 --- /dev/null +++ b/apps/desktop/src/features/workspace/countInClickEngine.ts @@ -0,0 +1,142 @@ +import { countInOnsetsMs, type FirstCountInPlan } from "./firstCountIn"; + +/** Minimal oscillator used by the local count-in click engine. */ +export type CountInOscillator = { + connect: (destination: unknown) => void; + disconnect: () => void; + frequency: { value: number }; + type: string; + start: (when?: number) => void; + stop: (when?: number) => void; +}; + +/** Minimal gain node used to envelope each click. */ +export type CountInGain = { + connect: (destination: unknown) => CountInGain; + disconnect: () => void; + gain: { + setValueAtTime: (value: number, when: number) => void; + exponentialRampToValueAtTime: (value: number, when: number) => void; + }; +}; + +/** Browser audio graph surface required to render a local click. */ +export type CountInAudioContext = { + currentTime: number; + destination: unknown; + state: string; + resume: () => Promise; + createOscillator: () => CountInOscillator; + createGain: () => CountInGain; +}; + +/** Factory that returns a local audio context or throws. */ +export type CountInContextFactory = () => CountInAudioContext; + +/** Local click engine for tonight's count-in. No files, URLs, or song audio. */ +export type CountInClickEngine = { + available: boolean; + play: (plan: FirstCountInPlan) => Promise; + stop: () => void; +}; + +const ACCENT_FREQUENCY_HZ = 1200; +const TAP_FREQUENCY_HZ = 800; +const CLICK_SECONDS = 0.05; + +/** + * Return a Web Audio context factory when the host exposes AudioContext. + * + * Missing constructors fail closed. This never fetches, decodes, or plays + * rehearsal audio; it only synthesizes a short local click. + */ +export function defaultCountInContextFactory(): CountInContextFactory | null { + const AudioContextCtor = + typeof window === "undefined" + ? undefined + : window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextCtor) { + return null; + } + return () => new AudioContextCtor() as unknown as CountInAudioContext; +} + +/** + * Create a bounded local click engine for a trusted count-in plan. + * + * Scheduled oscillators are tracked and stopped on `stop()` or the next play. + * A missing factory is unavailable rather than throwing at construction. + */ +export function createWebAudioCountInEngine( + contextFactory: CountInContextFactory | null = defaultCountInContextFactory() +): CountInClickEngine { + let context: CountInAudioContext | null = null; + const liveOscillators: CountInOscillator[] = []; + + const stop = (): void => { + while (liveOscillators.length > 0) { + const oscillator = liveOscillators.pop(); + if (!oscillator) { + continue; + } + try { + oscillator.stop(); + } catch { + // Already stopped oscillators throw; the engine still releases them. + } + try { + oscillator.disconnect(); + } catch { + // Disconnect is best-effort after stop. + } + } + }; + + return { + available: contextFactory !== null, + async play(plan: FirstCountInPlan): Promise { + if (!contextFactory) { + throw new Error("Count-in click is unavailable."); + } + + stop(); + if (!context) { + context = contextFactory(); + } + if (context.state === "suspended") { + await context.resume(); + } + + const onsets = countInOnsetsMs(plan); + if (onsets.length === 0) { + throw new Error("Count-in click has no trusted beats."); + } + + const origin = context.currentTime + 0.02; + for (const [index, onsetMs] of onsets.entries()) { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const when = origin + onsetMs / 1000; + oscillator.type = "square"; + oscillator.frequency.value = index === 0 ? ACCENT_FREQUENCY_HZ : TAP_FREQUENCY_HZ; + gain.gain.setValueAtTime(0.0001, when); + gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002); + gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(when); + oscillator.stop(when + CLICK_SECONDS + 0.01); + liveOscillators.push(oscillator); + } + + const lastOnset = onsets[onsets.length - 1] ?? 0; + await new Promise((resolve) => { + globalThis.setTimeout(() => { + resolve(); + }, lastOnset + CLICK_SECONDS * 1000 + 40); + }); + }, + stop + }; +} diff --git a/apps/desktop/src/features/workspace/firstCountIn.test.ts b/apps/desktop/src/features/workspace/firstCountIn.test.ts new file mode 100644 index 000000000..b5aab9519 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountIn.test.ts @@ -0,0 +1,98 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + countInOnsetsMs, + DEFAULT_COUNT_IN_BEATS, + fillCountInCopy, + firstCountInPlan, + firstNamedSectionLabel, + MAX_COUNT_IN_BEATS, + MAX_TRUSTED_TEMPO_BPM, + MIN_TRUSTED_TEMPO_BPM, + trustedTempoBpm +} from "./firstCountIn"; + +describe("trustedTempoBpm", () => { + it("admits only finite rehearsal-usable BPM in 20–400", () => { + expect(trustedTempoBpm(120)).toBe(120); + expect(trustedTempoBpm(MIN_TRUSTED_TEMPO_BPM)).toBe(20); + expect(trustedTempoBpm(MAX_TRUSTED_TEMPO_BPM)).toBe(400); + expect(trustedTempoBpm(19)).toBeNull(); + expect(trustedTempoBpm(401)).toBeNull(); + expect(trustedTempoBpm(0)).toBeNull(); + expect(trustedTempoBpm(-80)).toBeNull(); + expect(trustedTempoBpm(Number.NaN)).toBeNull(); + expect(trustedTempoBpm(Number.POSITIVE_INFINITY)).toBeNull(); + expect(trustedTempoBpm("120")).toBeNull(); + expect(trustedTempoBpm(undefined)).toBeNull(); + }); +}); + +describe("firstNamedSectionLabel", () => { + it("returns the first meaningful section label and isolates malformed entries", () => { + const song = createDemoRehearsalSong(); + expect(firstNamedSectionLabel(song)).toBe("verse"); + + song.sections[0]!.label = " none "; + expect(firstNamedSectionLabel(song)).toBe(song.sections[1]?.label); + + expect(firstNamedSectionLabel(null)).toBeUndefined(); + expect(firstNamedSectionLabel({ sections: "nope" })).toBeUndefined(); + expect(firstNamedSectionLabel({ sections: [null, "x", { label: " " }, { label: "chorus" }] })).toBe( + "chorus" + ); + }); +}); + +describe("firstCountInPlan", () => { + it("builds a four-beat plan from the demo song tempo and first named section", () => { + const plan = firstCountInPlan(createDemoRehearsalSong()); + expect(plan).toEqual({ + tempoBpm: 120, + beats: DEFAULT_COUNT_IN_BEATS, + intervalMs: 500, + sectionLabel: "verse" + }); + }); + + it("fails closed without a trusted tempo and omits blank section labels", () => { + const song = createDemoRehearsalSong(); + song.tempo = 12; + expect(firstCountInPlan(song)).toBeNull(); + expect(firstCountInPlan(undefined)).toBeNull(); + expect(firstCountInPlan([])).toBeNull(); + + const unlabeled = createDemoRehearsalSong(); + unlabeled.sections = unlabeled.sections.map((section) => ({ ...section, label: "none" })); + expect(firstCountInPlan(unlabeled)).toEqual({ + tempoBpm: 120, + beats: DEFAULT_COUNT_IN_BEATS, + intervalMs: 500, + sectionLabel: undefined + }); + }); +}); + +describe("countInOnsetsMs", () => { + it("returns one onset per trusted beat and rejects malformed plans", () => { + expect(countInOnsetsMs({ tempoBpm: 120, beats: 4, intervalMs: 500 })).toEqual([0, 500, 1000, 1500]); + expect(countInOnsetsMs({ tempoBpm: 120, beats: 0, intervalMs: 500 })).toEqual([]); + expect(countInOnsetsMs({ tempoBpm: 120, beats: MAX_COUNT_IN_BEATS + 1, intervalMs: 500 })).toEqual([]); + expect(countInOnsetsMs({ tempoBpm: 120, beats: 4, intervalMs: 0 })).toEqual([]); + expect(countInOnsetsMs({ tempoBpm: 120, beats: 4, intervalMs: Number.NaN })).toEqual([]); + expect(countInOnsetsMs(null)).toEqual([]); + }); +}); + +describe("fillCountInCopy", () => { + it("fills own-property tokens once and keeps rehearsal values literal", () => { + expect( + fillCountInCopy("Count in {beats} at {tempo} BPM before the {sectionLabel}.", { + beats: "4", + tempo: "120", + sectionLabel: "verse {tempo}" + }) + ).toBe("Count in 4 at 120 BPM before the verse {tempo}."); + expect(fillCountInCopy("keep {toString}", {})).toBe("keep {toString}"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCountIn.ts b/apps/desktop/src/features/workspace/firstCountIn.ts new file mode 100644 index 000000000..1c74bbae3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountIn.ts @@ -0,0 +1,118 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Inclusive lower bound for a rehearsal-usable click tempo. */ +export const MIN_TRUSTED_TEMPO_BPM = 20; +/** Inclusive upper bound for a rehearsal-usable click tempo. */ +export const MAX_TRUSTED_TEMPO_BPM = 400; +/** Default count-in length when meter is not present on the song contract. */ +export const DEFAULT_COUNT_IN_BEATS = 4; +/** Hard ceiling so a malformed beat count cannot schedule unbounded clicks. */ +export const MAX_COUNT_IN_BEATS = 16; + +/** Tonight's first audible count-in plan for the ready rehearsal map. */ +export type FirstCountInPlan = { + tempoBpm: number; + beats: number; + intervalMs: number; + sectionLabel?: string; +}; + +/** 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 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; +} + +/** + * Return the first named section label, skipping blank/`none` sentinels. + * + * Runtime roots and collection members are untrusted. Malformed entries are + * isolated instead of becoming count-in section authority. + */ +export function firstNamedSectionLabel(song: unknown): string | undefined { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return undefined; + } + + for (const sectionValue of song.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const label = meaningfulRangeText(sectionValue.label); + if (label) { + return label; + } + } + + return undefined; +} + +/** + * Build tonight's count-in from a trusted tempo, fail closed otherwise. + * + * Prefers a named first section so the click lands before a real entrance. + * Missing or unusable tempo is not a playable click. + */ +export function firstCountInPlan(song: RehearsalSong | unknown): FirstCountInPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + + const tempoBpm = trustedTempoBpm(song.tempo); + if (tempoBpm === null) { + return null; + } + + return { + tempoBpm, + beats: DEFAULT_COUNT_IN_BEATS, + intervalMs: 60_000 / tempoBpm, + sectionLabel: firstNamedSectionLabel(song) + }; +} + +/** + * Return millisecond onsets for each count-in beat, fail closed on bad plans. + */ +export function countInOnsetsMs(plan: FirstCountInPlan | unknown): number[] { + if (!isRuntimeObject(plan)) { + return []; + } + + const beats = plan.beats; + const intervalMs = plan.intervalMs; + if (typeof beats !== "number" || !Number.isFinite(beats) || beats < 1 || beats > MAX_COUNT_IN_BEATS) { + return []; + } + if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) { + return []; + } + + const onsets: number[] = []; + const safeBeats = Math.floor(beats); + for (let beat = 0; beat < safeBeats; beat += 1) { + onsets.push(beat * intervalMs); + } + return onsets; +} + +/** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ +export function fillCountInCopy(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..9a73358d7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,16 @@ "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.", + "workspaceFirstCountInTitle": "Tonight's first count-in", + "workspaceFirstCountInReady": "Count in {beats} at {tempo} BPM, then check tonight's first range before the {sectionLabel}.", + "workspaceFirstCountInReadyNoSection": "Count in {beats} at {tempo} BPM, then name the first section so the room knows where it starts.", + "workspaceFirstCountInMissing": "Tonight's first count-in still needs a tempo. Count the first section in by ear before you start.", + "workspaceFirstCountInAction": "Count in", + "workspaceFirstCountInActionLabel": "Count in {beats} at {tempo} BPM", + "workspaceFirstCountInPlaying": "Counting in", + "workspaceFirstCountInStop": "Stop count-in", + "workspaceFirstCountInDone": "Now check that span on your instrument.", + "workspaceFirstCountInUnavailable": "This browser cannot play a click. Count {beats} by ear, then check the range.", "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..025b38d28 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,16 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstCountInTitle": "오늘 먼저 맞출 카운트인", + "workspaceFirstCountInReady": "{tempo} BPM으로 {beats}박 카운트인한 다음, {sectionLabel} 들어가기 전에 오늘 첫 음역을 확인하세요.", + "workspaceFirstCountInReadyNoSection": "{tempo} BPM으로 {beats}박 카운트인한 다음, 첫 구간 이름을 정해 시작 위치를 공유하세요.", + "workspaceFirstCountInMissing": "오늘 첫 카운트인에는 아직 템포가 필요합니다. 첫 구간을 귀로 세고 들어가세요.", + "workspaceFirstCountInAction": "카운트인", + "workspaceFirstCountInActionLabel": "{tempo} BPM으로 {beats}박 카운트인", + "workspaceFirstCountInPlaying": "카운트인 중", + "workspaceFirstCountInStop": "카운트인 멈추기", + "workspaceFirstCountInDone": "이제 그 음역을 악기로 확인하세요.", + "workspaceFirstCountInUnavailable": "이 브라우저에서는 클릭을 재생할 수 없습니다. {beats}박을 귀로 센 다음 음역을 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..e397a7a49 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,10 @@ 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/firstCountIn.ts", + "src/features/workspace/countInClickEngine.ts", + "src/features/workspace/CountInClick.tsx" ], thresholds: { lines: 90, diff --git a/docs/doctoring/workspace-first-count-in-click.md b/docs/doctoring/workspace-first-count-in-click.md new file mode 100644 index 000000000..f64542164 --- /dev/null +++ b/docs/doctoring/workspace-first-count-in-click.md @@ -0,0 +1,21 @@ +# Tonight's first count-in click + +## Decision + +The ready rehearsal map plays a local Web Audio click for a trusted song tempo so a player can count in, then check tonight's first range. This is not song playback, stem isolation, or MIR tempo detection. + +## Authority + +- Trusted click tempo is a finite `song.tempo` in 20–400 BPM already stored on the rehearsal song. +- Count-in length is four beats unless a later meter field is admitted through the shared contract. +- Missing, non-finite, or out-of-range tempo fails closed to an ear-count next action. + +## Trust boundary + +- Untrusted input: runtime song roots, `tempo`, and section labels. +- Local synthesis only: `AudioContext` oscillators. No files, URLs, subprocesses, IPC, model artifacts, or persistence. +- Missing `AudioContext` is unavailable, not a crash, and still names the next ear-count action. + +## Primary standard + +W3C. (2025). *Web Audio API*. World Wide Web Consortium. https://www.w3.org/TR/webaudio/ From 35bde907fa9b5bdb9d84b42e80c8c0e9746bef6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:36:41 -0700 Subject: [PATCH 2/7] test(count-in): reproduce cancellation and cleanup races Pin the current review findings before changing production behavior: a stop during suspended AudioContext resume must prevent oscillator creation, completed nodes must disconnect, plan replacement/unmount must invalidate stale completion, and finite onset multiplication must fail closed on overflow. --- .../features/workspace/CountInClick.test.tsx | 43 ++++++++++++ .../workspace/countInClickEngine.test.ts | 70 ++++++++++++++++--- .../features/workspace/firstCountIn.test.ts | 4 ++ 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/CountInClick.test.tsx b/apps/desktop/src/features/workspace/CountInClick.test.tsx index 7ed138da9..83cb27717 100644 --- a/apps/desktop/src/features/workspace/CountInClick.test.tsx +++ b/apps/desktop/src/features/workspace/CountInClick.test.tsx @@ -134,4 +134,47 @@ describe("CountInClick", () => { expect(screen.queryByText("Now check that span on your instrument.")).toBeNull(); }); }); + + it("stops the old engine and invalidates completion when the active plan changes", async () => { + let finishPlay: (() => void) | undefined; + const engine: CountInClickEngine = { + available: true, + play: vi.fn( + () => + new Promise((resolve) => { + finishPlay = resolve; + }) + ), + stop: vi.fn() + }; + const { rerender } = renderCountIn(engine); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + + const nextPlan: FirstCountInPlan = { + tempoBpm: 90, + beats: 4, + intervalMs: 60_000 / 90, + sectionLabel: "chorus" + }; + rerender(); + + expect(engine.stop).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: /count in 4 at 90 bpm/i })).toHaveTextContent("Count in"); + finishPlay?.(); + await waitFor(() => { + expect(screen.queryByText("Now check that span on your instrument.")).toBeNull(); + }); + }); + + it("stops the active engine when the count-in surface unmounts", () => { + const engine: CountInClickEngine = { + available: true, + play: vi.fn(() => new Promise(() => undefined)), + stop: vi.fn() + }; + const { unmount } = renderCountIn(engine); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + unmount(); + expect(engine.stop).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/features/workspace/countInClickEngine.test.ts b/apps/desktop/src/features/workspace/countInClickEngine.test.ts index a0009ae1d..4f387eb11 100644 --- a/apps/desktop/src/features/workspace/countInClickEngine.test.ts +++ b/apps/desktop/src/features/workspace/countInClickEngine.test.ts @@ -18,22 +18,27 @@ const plan: FirstCountInPlan = { function createFakeContext(): { context: CountInAudioContext; oscillators: CountInOscillator[]; + gains: CountInGain[]; } { const oscillators: CountInOscillator[] = []; - const gain: CountInGain = { - connect: () => gain, - disconnect: vi.fn(), - gain: { - setValueAtTime: vi.fn(), - exponentialRampToValueAtTime: vi.fn() - } - }; + const gains: CountInGain[] = []; const context: CountInAudioContext = { currentTime: 1, destination: {}, state: "running", resume: vi.fn(async () => undefined), - createGain: () => gain, + createGain: () => { + const gain: CountInGain = { + connect: () => gain, + disconnect: vi.fn(), + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn() + } + }; + gains.push(gain); + return gain; + }, createOscillator: () => { const oscillator: CountInOscillator = { connect: vi.fn(), @@ -47,7 +52,7 @@ function createFakeContext(): { return oscillator; } }; - return { context, oscillators }; + return { context, oscillators, gains }; } describe("defaultCountInContextFactory", () => { @@ -140,6 +145,51 @@ describe("createWebAudioCountInEngine", () => { await playPromise; }); + it("cancels a play that is stopped while AudioContext resume is pending", async () => { + vi.useFakeTimers(); + const { context, oscillators } = createFakeContext(); + context.state = "suspended"; + let finishResume: (() => void) | undefined; + context.resume = vi.fn( + () => + new Promise((resolve) => { + finishResume = resolve; + }) + ); + const engine = createWebAudioCountInEngine(() => context); + + const playPromise = engine.play(plan); + await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(1); + engine.stop(); + finishResume?.(); + await Promise.resolve(); + vi.runAllTimers(); + await playPromise; + + expect(oscillators).toHaveLength(0); + }); + + it("disconnects completed oscillators and gain nodes after the count-in settles", async () => { + vi.useFakeTimers(); + const { context, oscillators, gains } = createFakeContext(); + const engine = createWebAudioCountInEngine(() => context); + + const playPromise = engine.play(plan); + await Promise.resolve(); + expect(oscillators).toHaveLength(2); + expect(gains).toHaveLength(2); + vi.runAllTimers(); + await playPromise; + + for (const oscillator of oscillators) { + expect(oscillator.disconnect).toHaveBeenCalled(); + } + for (const gain of gains) { + expect(gain.disconnect).toHaveBeenCalled(); + } + }); + it("rejects a plan with no trusted beats", async () => { const { context } = createFakeContext(); const engine = createWebAudioCountInEngine(() => context); diff --git a/apps/desktop/src/features/workspace/firstCountIn.test.ts b/apps/desktop/src/features/workspace/firstCountIn.test.ts index b5aab9519..af1a8fc59 100644 --- a/apps/desktop/src/features/workspace/firstCountIn.test.ts +++ b/apps/desktop/src/features/workspace/firstCountIn.test.ts @@ -82,6 +82,10 @@ describe("countInOnsetsMs", () => { expect(countInOnsetsMs({ tempoBpm: 120, beats: 4, intervalMs: Number.NaN })).toEqual([]); expect(countInOnsetsMs(null)).toEqual([]); }); + + it("fails closed when a finite interval overflows a later beat onset", () => { + expect(countInOnsetsMs({ tempoBpm: 120, beats: 3, intervalMs: Number.MAX_VALUE })).toEqual([]); + }); }); describe("fillCountInCopy", () => { From ceaff8aae22681c7fe3a4134901a08043ab9c1cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:38:37 -0700 Subject: [PATCH 3/7] fix(count-in): cancel stale playback and release audio nodes Invalidate plays that are stopped while AudioContext resume is pending, settle pending completion waits, disconnect oscillator and gain nodes after completion, reset component state across plan/engine changes and unmount, and fail closed if onset multiplication overflows. Add required JSDoc on the touched local handlers. --- .../src/features/workspace/CountInClick.tsx | 16 ++- .../features/workspace/countInClickEngine.ts | 111 +++++++++++++----- .../src/features/workspace/firstCountIn.ts | 6 +- 3 files changed, 101 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/features/workspace/CountInClick.tsx b/apps/desktop/src/features/workspace/CountInClick.tsx index 799bf4434..c8c56b29b 100644 --- a/apps/desktop/src/features/workspace/CountInClick.tsx +++ b/apps/desktop/src/features/workspace/CountInClick.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Timer } from "lucide-react"; import { Button } from "@/components/ui/button"; import { createWebAudioCountInEngine, type CountInClickEngine } from "./countInClickEngine"; @@ -27,6 +27,18 @@ export function CountInClick({ plan, t, engine }: CountInClickProps) { const playGeneration = useRef(0); const inFlight = useRef(false); + useEffect(() => { + playGeneration.current += 1; + inFlight.current = false; + setStatus("idle"); + + return () => { + playGeneration.current += 1; + inFlight.current = false; + defaultEngine.stop(); + }; + }, [defaultEngine, plan]); + const guidance = useMemo(() => { if (!plan) { return t("workspaceFirstCountInMissing"); @@ -46,6 +58,7 @@ export function CountInClick({ plan, t, engine }: CountInClickProps) { beats: String(plan?.beats ?? 4) }); + /** Start the active count-in unless playback is unavailable or already in flight. */ const handleCountIn = async (): Promise => { if (!plan || !engineRef.current.available || inFlight.current) { return; @@ -71,6 +84,7 @@ export function CountInClick({ plan, t, engine }: CountInClickProps) { } }; + /** Stop the active count-in and invalidate any completion still in flight. */ const handleStop = (): void => { playGeneration.current += 1; inFlight.current = false; diff --git a/apps/desktop/src/features/workspace/countInClickEngine.ts b/apps/desktop/src/features/workspace/countInClickEngine.ts index cc543c3e6..de3eb32df 100644 --- a/apps/desktop/src/features/workspace/countInClickEngine.ts +++ b/apps/desktop/src/features/workspace/countInClickEngine.ts @@ -44,6 +44,11 @@ const ACCENT_FREQUENCY_HZ = 1200; const TAP_FREQUENCY_HZ = 800; const CLICK_SECONDS = 0.05; +type LiveClickNode = { + oscillator: CountInOscillator; + gain: CountInGain; +}; + /** * Return a Web Audio context factory when the host exposes AudioContext. * @@ -72,68 +77,114 @@ export function createWebAudioCountInEngine( contextFactory: CountInContextFactory | null = defaultCountInContextFactory() ): CountInClickEngine { let context: CountInAudioContext | null = null; - const liveOscillators: CountInOscillator[] = []; + const liveNodes: LiveClickNode[] = []; + let playbackGeneration = 0; + let completionTimer: ReturnType | null = null; + let finishPendingPlayback: (() => void) | null = null; - const stop = (): void => { - while (liveOscillators.length > 0) { - const oscillator = liveOscillators.pop(); - if (!oscillator) { + /** Release tracked audio graph nodes, optionally stopping scheduled oscillators first. */ + const releaseLiveNodes = (stopOscillators: boolean): void => { + while (liveNodes.length > 0) { + const node = liveNodes.pop(); + if (!node) { continue; } + if (stopOscillators) { + try { + node.oscillator.stop(); + } catch { + // Already stopped oscillators throw; the engine still releases them. + } + } try { - oscillator.stop(); + node.oscillator.disconnect(); } catch { - // Already stopped oscillators throw; the engine still releases them. + // Disconnect is best-effort after stop or natural completion. } try { - oscillator.disconnect(); + node.gain.disconnect(); } catch { - // Disconnect is best-effort after stop. + // Gain disconnect is best-effort after its oscillator is released. } } }; + /** Resolve and clear any completion wait owned by the current playback. */ + const settlePendingPlayback = (): void => { + if (completionTimer !== null) { + globalThis.clearTimeout(completionTimer); + completionTimer = null; + } + const finish = finishPendingPlayback; + finishPendingPlayback = null; + finish?.(); + }; + + /** Invalidate pending playback and release every currently tracked audio node. */ + const stop = (): void => { + playbackGeneration += 1; + settlePendingPlayback(); + releaseLiveNodes(true); + }; + return { available: contextFactory !== null, + /** Play one trusted count-in unless a later stop or play invalidates it. */ async play(plan: FirstCountInPlan): Promise { if (!contextFactory) { throw new Error("Count-in click is unavailable."); } + const onsets = countInOnsetsMs(plan); + if (onsets.length === 0) { + throw new Error("Count-in click has no trusted beats."); + } + stop(); + const generation = playbackGeneration; if (!context) { context = contextFactory(); } if (context.state === "suspended") { await context.resume(); } - - const onsets = countInOnsetsMs(plan); - if (onsets.length === 0) { - throw new Error("Count-in click has no trusted beats."); + if (playbackGeneration !== generation) { + return; } - const origin = context.currentTime + 0.02; - for (const [index, onsetMs] of onsets.entries()) { - const oscillator = context.createOscillator(); - const gain = context.createGain(); - const when = origin + onsetMs / 1000; - oscillator.type = "square"; - oscillator.frequency.value = index === 0 ? ACCENT_FREQUENCY_HZ : TAP_FREQUENCY_HZ; - gain.gain.setValueAtTime(0.0001, when); - gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002); - gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS); - oscillator.connect(gain); - gain.connect(context.destination); - oscillator.start(when); - oscillator.stop(when + CLICK_SECONDS + 0.01); - liveOscillators.push(oscillator); + try { + const origin = context.currentTime + 0.02; + for (const [index, onsetMs] of onsets.entries()) { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + liveNodes.push({ oscillator, gain }); + const when = origin + onsetMs / 1000; + oscillator.type = "square"; + oscillator.frequency.value = index === 0 ? ACCENT_FREQUENCY_HZ : TAP_FREQUENCY_HZ; + gain.gain.setValueAtTime(0.0001, when); + gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002); + gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(when); + oscillator.stop(when + CLICK_SECONDS + 0.01); + } + } catch (error) { + releaseLiveNodes(true); + throw error; } const lastOnset = onsets[onsets.length - 1] ?? 0; await new Promise((resolve) => { - globalThis.setTimeout(() => { - resolve(); + finishPendingPlayback = resolve; + completionTimer = globalThis.setTimeout(() => { + completionTimer = null; + const finish = finishPendingPlayback; + finishPendingPlayback = null; + if (playbackGeneration === generation) { + releaseLiveNodes(false); + } + finish?.(); }, lastOnset + CLICK_SECONDS * 1000 + 40); }); }, diff --git a/apps/desktop/src/features/workspace/firstCountIn.ts b/apps/desktop/src/features/workspace/firstCountIn.ts index 1c74bbae3..2894e78f7 100644 --- a/apps/desktop/src/features/workspace/firstCountIn.ts +++ b/apps/desktop/src/features/workspace/firstCountIn.ts @@ -107,7 +107,11 @@ export function countInOnsetsMs(plan: FirstCountInPlan | unknown): number[] { const onsets: number[] = []; const safeBeats = Math.floor(beats); for (let beat = 0; beat < safeBeats; beat += 1) { - onsets.push(beat * intervalMs); + const onsetMs = beat * intervalMs; + if (!Number.isFinite(onsetMs)) { + return []; + } + onsets.push(onsetMs); } return onsets; } From fbdd543908c8137d320cce7dceef82a9a2cb6387 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:40:27 -0700 Subject: [PATCH 4/7] docs(count-in): pin Web Audio 1.1 citation --- docs/doctoring/workspace-first-count-in-click.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/workspace-first-count-in-click.md b/docs/doctoring/workspace-first-count-in-click.md index f64542164..f6c901fc7 100644 --- a/docs/doctoring/workspace-first-count-in-click.md +++ b/docs/doctoring/workspace-first-count-in-click.md @@ -18,4 +18,4 @@ The ready rehearsal map plays a local Web Audio click for a trusted song tempo s ## Primary standard -W3C. (2025). *Web Audio API*. World Wide Web Consortium. https://www.w3.org/TR/webaudio/ +W3C. (2024). *Web Audio API 1.1*. World Wide Web Consortium. https://www.w3.org/TR/2024/WD-webaudio-1.1-20241105/ From 79a863b3fa0dd4ece183f0254276bf83bfc8af76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:41:25 -0700 Subject: [PATCH 5/7] test(count-in): reproduce equivalent-plan interruption Pin the current-head review finding: recreating an equivalent count-in plan must not stop active playback or clear completion state when timing and section authority are unchanged. --- .../CountInClick.semantic-plan.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/desktop/src/features/workspace/CountInClick.semantic-plan.test.tsx diff --git a/apps/desktop/src/features/workspace/CountInClick.semantic-plan.test.tsx b/apps/desktop/src/features/workspace/CountInClick.semantic-plan.test.tsx new file mode 100644 index 000000000..8fa5d9ddd --- /dev/null +++ b/apps/desktop/src/features/workspace/CountInClick.semantic-plan.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { createTranslator } from "../../i18n"; +import { CountInClick } from "./CountInClick"; +import type { CountInClickEngine } from "./countInClickEngine"; +import type { FirstCountInPlan } from "./firstCountIn"; + +const t = createTranslator("en"); +const plan: FirstCountInPlan = { + tempoBpm: 120, + beats: 4, + intervalMs: 500, + sectionLabel: "verse" +}; + +describe("CountInClick semantic plan lifecycle", () => { + it("keeps an active count-in running when an equivalent plan object replaces the prior object", async () => { + let finishPlay: (() => void) | undefined; + const engine: CountInClickEngine = { + available: true, + play: vi.fn( + () => + new Promise((resolve) => { + finishPlay = resolve; + }) + ), + stop: vi.fn() + }; + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); + + rerender(); + + expect(engine.stop).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in"); + finishPlay?.(); + await waitFor(() => { + expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); + }); + }); +}); From b35d8b0ca01db56276e22c6929b79e4750bacdac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:42:12 -0700 Subject: [PATCH 6/7] fix(count-in): preserve playback across equivalent plans Key the lifecycle reset to the count-in plan's semantic timing and section fields instead of object identity so unrelated song updates cannot stop a still-valid click or clear its completion guidance. --- apps/desktop/src/features/workspace/CountInClick.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/CountInClick.tsx b/apps/desktop/src/features/workspace/CountInClick.tsx index c8c56b29b..12b516f1f 100644 --- a/apps/desktop/src/features/workspace/CountInClick.tsx +++ b/apps/desktop/src/features/workspace/CountInClick.tsx @@ -37,7 +37,7 @@ export function CountInClick({ plan, t, engine }: CountInClickProps) { inFlight.current = false; defaultEngine.stop(); }; - }, [defaultEngine, plan]); + }, [defaultEngine, plan?.beats, plan?.intervalMs, plan?.sectionLabel, plan?.tempoBpm]); const guidance = useMemo(() => { if (!plan) { From 92d8ffe3df69f33f43367e610cc2ffa08105e768 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 07:19:50 +0900 Subject: [PATCH 7/7] fix(count-in): document exported tempo bounds --- .../src/features/workspace/firstCountIn.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstCountIn.ts b/apps/desktop/src/features/workspace/firstCountIn.ts index 2894e78f7..fc45058cc 100644 --- a/apps/desktop/src/features/workspace/firstCountIn.ts +++ b/apps/desktop/src/features/workspace/firstCountIn.ts @@ -1,14 +1,18 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; -/** Inclusive lower bound for a rehearsal-usable click tempo. */ -export const MIN_TRUSTED_TEMPO_BPM = 20; -/** Inclusive upper bound for a rehearsal-usable click tempo. */ -export const MAX_TRUSTED_TEMPO_BPM = 400; -/** Default count-in length when meter is not present on the song contract. */ -export const DEFAULT_COUNT_IN_BEATS = 4; -/** Hard ceiling so a malformed beat count cannot schedule unbounded clicks. */ -export const MAX_COUNT_IN_BEATS = 16; +export /** + * Inclusive lower bound for a rehearsal-usable click tempo. + */ const MIN_TRUSTED_TEMPO_BPM = 20; +export /** + * Inclusive upper bound for a rehearsal-usable click tempo. + */ const MAX_TRUSTED_TEMPO_BPM = 400; +export /** + * Default count-in length when meter is not present on the song contract. + */ const DEFAULT_COUNT_IN_BEATS = 4; +export /** + * Hard ceiling so a malformed beat count cannot schedule unbounded clicks. + */ const MAX_COUNT_IN_BEATS = 16; /** Tonight's first audible count-in plan for the ready rehearsal map. */ export type FirstCountInPlan = {