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 34331fb86..10d3edb08 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.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(); + }); + }); +}); 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..83cb27717 --- /dev/null +++ b/apps/desktop/src/features/workspace/CountInClick.test.tsx @@ -0,0 +1,180 @@ +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(); + }); + }); + + 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/CountInClick.tsx b/apps/desktop/src/features/workspace/CountInClick.tsx new file mode 100644 index 000000000..12b516f1f --- /dev/null +++ b/apps/desktop/src/features/workspace/CountInClick.tsx @@ -0,0 +1,144 @@ +import { useEffect, 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); + + useEffect(() => { + playGeneration.current += 1; + inFlight.current = false; + setStatus("idle"); + + return () => { + playGeneration.current += 1; + inFlight.current = false; + defaultEngine.stop(); + }; + }, [defaultEngine, plan?.beats, plan?.intervalMs, plan?.sectionLabel, plan?.tempoBpm]); + + 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) + }); + + /** 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; + } + + 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; + } + } + }; + + /** Stop the active count-in and invalidate any completion still in flight. */ + 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..4f387eb11 --- /dev/null +++ b/apps/desktop/src/features/workspace/countInClickEngine.test.ts @@ -0,0 +1,215 @@ +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[]; + gains: CountInGain[]; +} { + const oscillators: CountInOscillator[] = []; + const gains: CountInGain[] = []; + const context: CountInAudioContext = { + currentTime: 1, + destination: {}, + state: "running", + resume: vi.fn(async () => undefined), + 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(), + disconnect: vi.fn(), + frequency: { value: 0 }, + type: "sine", + start: vi.fn(), + stop: vi.fn() + }; + oscillators.push(oscillator); + return oscillator; + } + }; + return { context, oscillators, gains }; +} + +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("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); + 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..de3eb32df --- /dev/null +++ b/apps/desktop/src/features/workspace/countInClickEngine.ts @@ -0,0 +1,193 @@ +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; + +type LiveClickNode = { + oscillator: CountInOscillator; + gain: CountInGain; +}; + +/** + * 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 liveNodes: LiveClickNode[] = []; + let playbackGeneration = 0; + let completionTimer: ReturnType | null = null; + let finishPendingPlayback: (() => void) | null = null; + + /** 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 { + node.oscillator.disconnect(); + } catch { + // Disconnect is best-effort after stop or natural completion. + } + try { + node.gain.disconnect(); + } catch { + // 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(); + } + if (playbackGeneration !== generation) { + return; + } + + 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) => { + finishPendingPlayback = resolve; + completionTimer = globalThis.setTimeout(() => { + completionTimer = null; + const finish = finishPendingPlayback; + finishPendingPlayback = null; + if (playbackGeneration === generation) { + releaseLiveNodes(false); + } + finish?.(); + }, 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..af1a8fc59 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountIn.test.ts @@ -0,0 +1,102 @@ +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([]); + }); + + 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", () => { + 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..fc45058cc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountIn.ts @@ -0,0 +1,126 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +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 = { + 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) { + const onsetMs = beat * intervalMs; + if (!Number.isFinite(onsetMs)) { + return []; + } + onsets.push(onsetMs); + } + 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..f6c901fc7 --- /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. (2024). *Web Audio API 1.1*. World Wide Web Consortium. https://www.w3.org/TR/2024/WD-webaudio-1.1-20241105/