diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..792217635 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Name tonight's first stop with the holding part when an active role is corroborated, the labeled cut, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..aa9d904a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Workspace and player copy for tonight's first stop must name the holding part when corroborated, the labeled cut, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..e754f4ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first stop on the workspace and player so the room can hold the cut; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 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..b67e95254 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). Workspace and player name tonight's first stop so the room can hold the cut; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. The ready workspace also 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/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/FirstStopCallout.localization.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.localization.test.tsx new file mode 100644 index 000000000..754ac6bb6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.localization.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; + +function songWithLocalizedStop() { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const role = { + ...section.roles[0]!, + id: "keyboard-stop", + name: "피아노", + rehearsalPriority: "high" as const + }; + section.id = "localized-stop"; + section.label = "stop"; + section.timeRange = { start: 10, end: 12 }; + section.roles = [role]; + section.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + return song; +} + +describe("FirstStopCallout runtime and locale boundary", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No stop yet. Stay on tonight's map until a cut is marked.") + ).toBeTruthy(); + }); + + it("uses particle-safe Korean section-form copy instead of exposing the raw stop enum", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("0:10 스톱에서 피아노 파트가 컷합니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:10 스톱에서 컷합니다.")).toBeNull(); + expect(screen.queryByText(/ stop에서 /)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..a834ab939 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +describe("FirstStopCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal("matchMedia", matchMedia); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx new file mode 100644 index 000000000..f86d33582 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx @@ -0,0 +1,180 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + first.dataset.sectionPosition = "0"; + const target = document.createElement("div"); + target.dataset.sectionPosition = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +function appendSongStructureTargetAfterExtraChild() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const gridHeader = document.createElement("div"); + const firstSection = document.createElement("div"); + firstSection.dataset.sectionPosition = "0"; + const targetSection = document.createElement("div"); + targetSection.dataset.sectionPosition = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(targetSection, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(gridHeader); + grid.appendChild(firstSection); + grid.appendChild(targetSection); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstStopCallout", () => { + it("names the first stop as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal stop at 0:18" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + + grid.remove(); + }); + + it("uses renderer-owned section position instead of direct child order", () => { + const { grid, scrollIntoView } = appendSongStructureTargetAfterExtraChild(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + + expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy(); + expect(screen.queryByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearStop = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(onHearStop).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithStop(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first stop changes or returns later", () => { + const initialSong = songWithStop(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + + const nextSong = songWithStop(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 24, end: 25 }; + rerender(); + expect(screen.getByText("Lead Vocal cuts the stop at 0:24.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable stop guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No stop yet. Stay on tonight's map until a cut is marked.") + ).toBeTruthy(); + }); + + it("names a band-wide cut when no part holds the stop", () => { + const song = songWithStop(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first stop at 0:18" })).toBeTruthy(); + expect(screen.getByText("The band cuts the stop at 0:18.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearStop = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal cut at 0:18" })); + expect(onHearStop).toHaveBeenCalledWith(18); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.tsx new file mode 100644 index 000000000..b0e2136fc --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff"; + +/** Props for the first-stop rehearsal callout. */ +export interface FirstStopCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearStop?: (atSeconds: number) => void; +} + +type StopCopyValues = Readonly< + Record<"role" | "section" | "at" | "previousSection" | "nextSection", string> +>; + +type HeardStop = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate stop placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatStopCopy(template: string, values: StopCopyValues): string { + return template.replace(/\{(role|section|at|previousSection|nextSection)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof StopCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredStopScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first stop and offer only an action that the current surface can execute. */ +export function FirstStopCallout({ + song, + actionMode = "workspace-scroll", + onHearStop +}: FirstStopCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const stop = resolveFirstStopHandoff(song); + const stopSectionIndex = + stop && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(stop.section) + : -1; + const [heardStop, setHeardStop] = useState(null); + + useEffect(() => { + setHeardStop(null); + }, [songId, stopSectionIndex, stop?.section.id, stop?.holdingRole?.id, stop?.atSeconds]); + + if (!stop) { + return ( + + ); + } + + const heard = + heardStop?.songId === songId && + heardStop.sectionId === stop.section.id && + heardStop.sectionIndex === stopSectionIndex && + heardStop.holdingRoleId === (stop.holdingRole?.id ?? null) && + heardStop.atSeconds === stop.atSeconds; + const at = formatStopTime(stop.atSeconds); + const copyValues: StopCopyValues = { + role: stop.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, stop.section.label), + at, + previousSection: stop.previousSectionLabel + ? translateSectionFormLabel(locale, stop.previousSectionLabel) + : "", + nextSection: stop.nextSectionLabel + ? translateSectionFormLabel(locale, stop.nextSectionLabel) + : "" + }; + const hasRole = stop.holdingRole !== null; + const actionLabel = formatStopCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstStopAction" + : "firstStopActionBand" + : hasRole + ? "firstStopOpenAction" + : "firstStopOpenActionBand" + ), + copyValues + ); + const body = formatStopCopy(t(hasRole ? "firstStopBody" : "firstStopBodyBand"), copyValues); + const armed = formatStopCopy(t(hasRole ? "firstStopArmed" : "firstStopArmedBand"), copyValues); + const route = stop.hasFollowingSection + ? stop.previousSectionLabel && stop.nextSectionLabel + ? formatStopCopy(t("firstStopRouteBoth"), copyValues) + : stop.nextSectionLabel + ? formatStopCopy(t("firstStopRouteNext"), copyValues) + : stop.previousSectionLabel + ? formatStopCopy(t("firstStopRoutePrevious"), copyValues) + : null + : null; + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearStop === "function"; + /** Record completion only after the owning surface has executed the selected stop action. */ + const markStopActionComplete = () => { + setHeardStop({ + songId, + sectionId: stop.section.id, + sectionIndex: stopSectionIndex, + holdingRoleId: stop.holdingRole?.id ?? null, + atSeconds: stop.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..24385b419 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,8 +1,9 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; import { useId, useMemo } from "react"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const tonightStop = useMemo(() => resolveFirstStopHandoff(song), [song]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -120,6 +122,19 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove} + {tonightStop?.section === section && tonightStop.hasFollowingSection ? ( +

+ {tonightStop.nextSectionLabel + ? t("sectionStopNextAction").replace( + "{nextSectionLabel}", + translateSectionFormLabel(locale, tonightStop.nextSectionLabel) + ) + : t("sectionStopNextActionBare")} +

+ ) : null} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..93c3142eb 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,48 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first stop as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(1); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal stop at 0:18" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..725f5616a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstStopCallout } from "./FirstStopCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -353,6 +354,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp + +
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts new file mode 100644 index 000000000..d8f702ce4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstStopHandoff activity-type authority", () => { + it("does not treat a string false flag as an active stop holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstStopHandoff(song)?.holdingRole?.id).toBe("active-bass"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts new file mode 100644 index 000000000..dd1b37a7a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + const role = { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high" as const + }; + + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [role]; + stop.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return { song, stop, role }; +} + +describe("resolveFirstStopHandoff ambiguous identities", () => { + it("keeps a band-wide cut when a stop repeats one role identity", () => { + const { song, stop, role } = songWithStop(); + stop.roles = [role, { ...role, name: "Duplicate Bass" }]; + + const result = resolveFirstStopHandoff(song); + + expect(result?.section).toBe(stop); + expect(result?.holdingRole).toBeNull(); + }); + + it("keeps a band-wide cut when a stop repeats one graph-node identity", () => { + const { song, stop, role } = songWithStop(); + stop.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: role.id, + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + + const result = resolveFirstStopHandoff(song); + + expect(result?.section).toBe(stop); + expect(result?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts new file mode 100644 index 000000000..127cc4ca2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff inactive labeled holder", () => { + it("does not name an inactive labeled role as the stop holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts new file mode 100644 index 000000000..29f5e3f39 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return { song, stop }; +} + +describe("resolveFirstStopHandoff runtime holder collections", () => { + it("keeps the cut band-wide when runtime roles are not an array", () => { + const { song, stop } = songWithStop(); + stop.roles = null as unknown as typeof stop.roles; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); + + it("keeps the cut band-wide when runtime partGraph is not an array", () => { + const { song, stop } = songWithStop(); + stop.partGraph = null as unknown as typeof stop.partGraph; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts new file mode 100644 index 000000000..be41e8be4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return { song, stop }; +} + +describe("resolveFirstStopHandoff runtime holder elements", () => { + it("keeps the cut band-wide when runtime roles contain a non-object element", () => { + for (const malformedRole of [null, 42]) { + const { song, stop } = songWithStop(); + stop.roles = [malformedRole] as unknown as typeof stop.roles; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + } + }); + + it("keeps the cut band-wide when runtime partGraph contains a non-object element", () => { + for (const malformedNode of [null, 42]) { + const { song, stop } = songWithStop(); + stop.partGraph = [malformedNode] as unknown as typeof stop.partGraph; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..ef2dc5cc4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff runtime role identity", () => { + it("ignores an active stop role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + + const safeRole = { + ...section.roles[2]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "high" as const + }; + const malformedRole = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole?.id).toBe("safe-vocal"); + }); + + it("does not surface a malformed runtime role name as the holding part", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + section.roles = [ + { + ...section.roles[0]!, + id: "malformed-name", + name: { unsafe: "object" } as unknown as string, + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "malformed-name", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts new file mode 100644 index 000000000..0e4ee8860 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithRuntimeSections(sections: unknown): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = sections as RehearsalSong["sections"]; + return song; +} + +describe("resolveFirstStopHandoff runtime section collection", () => { + it("fails closed when the runtime section collection is not an array", () => { + const song = songWithRuntimeSections(null); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements instead of dereferencing them", () => { + const song = songWithRuntimeSections([null, 42]); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts new file mode 100644 index 000000000..d4b700183 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function malformedStop(sectionId: unknown) { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = sectionId as string; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return song; +} + +describe("resolveFirstStopHandoff runtime section identity", () => { + it("rejects stop sections whose runtime id is not a non-empty string", () => { + for (const invalidId of [42, " "]) { + const song = malformedStop(invalidId); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts new file mode 100644 index 000000000..db86f9857 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts @@ -0,0 +1,65 @@ +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff runtime time range", () => { + it("rejects a stop whose runtime timeRange is not an object", () => { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = null as unknown as typeof stop.timeRange; + song.sections = [stop]; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); + + it("skips a zero-length stop window and selects the next valid cut", () => { + const song = createDemoRehearsalSong(); + const zeroLengthStop = structuredClone(song.sections[0]!); + zeroLengthStop.id = "stop-zero-length"; + zeroLengthStop.label = "stop"; + zeroLengthStop.timeRange = { start: 10, end: 10 }; + + const validStop = structuredClone(song.sections[0]!); + validStop.id = "stop-valid"; + validStop.label = "stop"; + validStop.timeRange = { start: 18, end: 19 }; + song.sections = [zeroLengthStop, validStop]; + + expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid"); + }); + + it("skips a stop whose runtime window exceeds the shared u32 timing contract", () => { + const song = createDemoRehearsalSong(); + const overflowingStop = structuredClone(song.sections[0]!); + overflowingStop.id = "stop-overflow"; + overflowingStop.label = "stop"; + overflowingStop.timeRange = { start: 10, end: MAX_SECTION_TIME_SECONDS + 1 }; + + const validStop = structuredClone(song.sections[0]!); + validStop.id = "stop-valid"; + validStop.label = "stop"; + validStop.timeRange = { start: 18, end: 19 }; + song.sections = [overflowingStop, validStop]; + + expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid"); + }); + + it("skips a stop whose runtime window uses fractional seconds outside the shared timing contract", () => { + const song = createDemoRehearsalSong(); + const fractionalStop = structuredClone(song.sections[0]!); + fractionalStop.id = "stop-fractional"; + fractionalStop.label = "stop"; + fractionalStop.timeRange = { start: 10.5, end: 11.5 }; + + const validStop = structuredClone(song.sections[0]!); + validStop.id = "stop-valid"; + validStop.label = "stop"; + validStop.timeRange = { start: 18, end: 19 }; + song.sections = [fractionalStop, validStop]; + + expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.route-context.test.tsx b/apps/desktop/src/features/workspace/firstStopHandoff.route-context.test.tsx new file mode 100644 index 000000000..5ccee22f0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.route-context.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; +import { SectionRoadmap } from "./SectionRoadmap"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithRouteContext() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + const stop = structuredClone(seed); + const chorus = structuredClone(seed); + + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 0, end: 10 }; + + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 10, end: 11 }; + stop.roles = stop.roles.map((role, index) => ({ + ...role, + id: `stop-role-${index}`, + name: index === 0 ? "Bass" : role.name + })); + stop.partGraph = stop.roles.map((role) => ({ + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + })); + + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 11, end: 24 }; + chorus.roles = chorus.roles.map((role, index) => ({ ...role, id: `chorus-role-${index}` })); + + song.sections = [verse, stop, chorus]; + return song; +} + +function songWithTerminalStop() { + const terminalSong = songWithRouteContext(); + terminalSong.sections = terminalSong.sections.slice(0, 2); + return terminalSong; +} + +describe("first-stop route context succession", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps the previous and next named form around the validated stop", () => { + const result = resolveFirstStopHandoff(songWithRouteContext()); + + expect(result?.previousSectionLabel).toBe("verse"); + expect(result?.nextSectionLabel).toBe("chorus"); + expect(result?.hasFollowingSection).toBe(true); + }); + + it("distinguishes a terminal stop from an unnamed following section", () => { + const result = resolveFirstStopHandoff(songWithTerminalStop()); + + expect(result?.previousSectionLabel).toBe("verse"); + expect(result?.nextSectionLabel).toBeNull(); + expect(result?.hasFollowingSection).toBe(false); + }); + + it("names the re-entry in the existing actionable stop callout without adding a second static card", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.getByText("After verse, cut together here. Come back in on chorus.")).toBeTruthy(); + expect(screen.getAllByLabelText("Tonight's first stop")).toHaveLength(1); + }); + + it("omits invented re-entry copy when the validated stop ends the song", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.queryByTestId("first-stop-route")).toBeNull(); + }); + + it("puts the re-entry action only on the validated stop roadmap card", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.getByTestId("first-stop-action-stop-1")).toHaveTextContent( + "Cut together here, then come back in on chorus." + ); + expect(screen.queryByTestId("first-stop-action-verse-1")).toBeNull(); + expect(screen.queryByTestId("first-stop-action-chorus-1")).toBeNull(); + }); + + it("omits a roadmap re-entry action when the validated stop is terminal", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.queryByTestId("first-stop-action-stop-1")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx b/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx new file mode 100644 index 000000000..9645f0f20 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; +import { SectionRoadmap } from "./SectionRoadmap"; + +function songWithTerminalStop() { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const stop = structuredClone(song.sections[0]!); + + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 0, end: 18 }; + + stop.id = "stop-final"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = stop.roles.map((role, index) => ({ + ...role, + id: `terminal-role-${index}` + })); + stop.partGraph = stop.roles.map((role) => ({ + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + })); + + song.sections = [verse, stop]; + return song; +} + +describe("terminal first-stop guidance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not invent a re-entry after the final stop in the callout", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.getByText("After verse, cut together here. Hold the cut.")).toBeTruthy(); + expect(screen.queryByText(/downbeat/i)).toBeNull(); + }); + + it("keeps the terminal roadmap action free of a nonexistent re-entry", () => { + vi.stubGlobal("navigator", { language: "en-US" }); + + render(); + + expect(screen.getByTestId("first-stop-action-stop-final")).toHaveTextContent( + "Cut together here. Hold the cut." + ); + expect(screen.queryByText(/come back in/i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts new file mode 100644 index 000000000..d09129610 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff"; + +function withStopSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = overrides.id ?? "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: overrides.start ?? 18, end: overrides.end ?? 19 }; + const roleId = overrides.roleId ?? "lead-vocal"; + stop.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + stop.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +describe("resolveFirstStopHandoff", () => { + it("returns null when the demo song has no labeled stop", () => { + expect(resolveFirstStopHandoff(createDemoRehearsalSong())).toBeNull(); + expect(formatStopTime(Number.NaN)).toBe("0:00"); + expect(formatStopTime(-4)).toBe("0:00"); + }); + + it("picks the earliest labeled stop and the part that holds the cut", () => { + const song = withStopSection({ start: 18, end: 19 }); + const stop = resolveFirstStopHandoff(song); + + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole?.id).toBe("lead-vocal"); + expect(stop?.atSeconds).toBe(18); + expect(formatStopTime(stop?.atSeconds ?? -1)).toBe("0:18"); + }); + + it("prefers the earlier of two labeled stops", () => { + const song = withStopSection({ id: "stop-late", start: 40, end: 41 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "stop-early"; + earlier.timeRange = { start: 12, end: 13 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[0]!, song.sections[1]!, earlier]; + + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-early"); + expect(stop?.holdingRole?.id).toBe("bass-guitar"); + expect(stop?.atSeconds).toBe(12); + }); + + it("keeps a band-wide cut when no active ranked role holds it", () => { + const song = withStopSection({ isActive: false }); + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole).toBeNull(); + expect(stop?.atSeconds).toBe(18); + }); + + it("skips a stop whose rehearsal window is unbounded", () => { + const song = withStopSection({ start: Number.NaN, end: 19 }); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); + + it("skips a stop whose end precedes its start", () => { + const song = withStopSection({ start: 20, end: 10 }); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts new file mode 100644 index 000000000..b4d0f68a1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -0,0 +1,219 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const SECTION_FORM_LABELS = new Set([ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" +]); + +/** Tonight's first stop: the earliest labeled cut, its holder, and adjacent form context. */ +export type FirstStopHandoff = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + previousSectionLabel: RehearsalSection["label"] | null; + nextSectionLabel: RehearsalSection["label"] | null; + hasFollowingSection: boolean; +}; + +/** Format a non-negative stop time as m:ss for rehearsal copy. */ +export function formatStopTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + typeof role.name === "string" && + role.name.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the highest-priority ranked role, then a stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!Array.isArray(section.roles) || !Array.isArray(section.partGraph)) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Validate section identities once so a duplicate cannot redirect a map action. */ +function uniqueRuntimeSections(song: RehearsalSong): RehearsalSection[] | null { + const sections: RehearsalSection[] = []; + const seenSectionIds = new Set(); + for (const sectionValue of song.sections as unknown[]) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const section = sectionValue as RehearsalSection; + if (typeof section.id !== "string" || section.id.trim().length === 0) { + return null; + } + const sectionId = section.id.trim(); + if (seenSectionIds.has(sectionId)) { + return null; + } + seenSectionIds.add(sectionId); + sections.push(section); + } + return sections; +} + +/** Return a supported buyer-safe form label or null rather than echoing malformed runtime data. */ +function safeSectionLabel(section: RehearsalSection | undefined): RehearsalSection["label"] | null { + if (!section) { + return null; + } + const label: unknown = section.label; + if (typeof label !== "string" || !SECTION_FORM_LABELS.has(label as RehearsalSection["label"])) { + return null; + } + return label as RehearsalSection["label"]; +} + +/** Return the first labeled stop, or null when no safe cut remains. */ +export function resolveFirstStopHandoff(song: RehearsalSong): FirstStopHandoff | null { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return null; + } + + const uniqueSections = uniqueRuntimeSections(song); + if (!uniqueSections) { + return null; + } + + const timelineSections = uniqueSections + .filter((section) => hasBoundedTimeRange(section)) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return left.id.localeCompare(right.id); + }); + const stopIndex = timelineSections.findIndex((section) => section.label === "stop"); + if (stopIndex < 0) { + return null; + } + + const section = timelineSections[stopIndex]; + if (!section) { + return null; + } + + const followingSection = timelineSections[stopIndex + 1]; + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start, + previousSectionLabel: safeSectionLabel(timelineSections[stopIndex - 1]), + nextSectionLabel: safeSectionLabel(followingSection), + hasFollowingSection: followingSection !== undefined + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..ecd368e65 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -74,5 +74,14 @@ describe("i18n", () => { koDictionary.appSubtitle = originalSubtitle; } }); + + it("keeps first-stop keys in both baseline locales", () => { + const tEn = createTranslator("en"); + const tKo = createTranslator("ko"); + expect(tEn("firstStopLabel")).toBe("Tonight's first stop"); + expect(tKo("firstStopLabel")).toBe("오늘 첫 스톱"); + expect(tEn("firstStopOpenAction")).toContain("{role}"); + expect(tKo("firstStopOpenAction")).toContain("{role}"); + }); }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..aec0b6f9d 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized buyer copy for a validated section form label. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label]; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..bc91440cd 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,10 +149,26 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstStopLabel": "Tonight's first stop", + "firstStopAction": "Hear {role} cut at {at}", + "firstStopActionBand": "Hear the first stop at {at}", + "firstStopOpenAction": "Open {role} stop at {at}", + "firstStopOpenActionBand": "Open the first stop at {at}", + "firstStopBody": "{role} cuts the {section} at {at}.", + "firstStopBodyBand": "The band cuts the {section} at {at}.", + "firstStopRouteBoth": "After {previousSection}, cut together here. Come back in on {nextSection}.", + "firstStopRouteNext": "Cut together here. Come back in on {nextSection}.", + "firstStopRoutePrevious": "After {previousSection}, cut together here. Hold the cut.", + "firstStopArmed": "Hold {role}'s cut at {at}. Do not play through it.", + "firstStopArmedBand": "Hold the cut at {at}. Do not play through it.", + "firstStopUnavailable": "No stop yet. Stay on tonight's map until a cut is marked.", + "firstStopNeedsSong": "Analyze tonight's song first, then hear the first stop from this player.", "workspaceFirstRangeTitle": "Tonight's first range", "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.", "sectionRangeLabel": "Range", - "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." + "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.", + "sectionStopNextAction": "Cut together here, then come back in on {nextSectionLabel}.", + "sectionStopNextActionBare": "Cut together here. Hold the cut." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..c0f51d226 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,10 +149,26 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstStopLabel": "오늘 첫 스톱", + "firstStopAction": "{at}에 {role} 컷 듣기", + "firstStopActionBand": "{at} 첫 스톱 듣기", + "firstStopOpenAction": "{at} {role} 스톱 위치 열기", + "firstStopOpenActionBand": "{at} 첫 스톱 위치 열기", + "firstStopBody": "{at} {section}에서 {role} 파트가 컷합니다.", + "firstStopBodyBand": "밴드가 {at} {section}에서 컷합니다.", + "firstStopRouteBoth": "{previousSection} 다음에 여기서 함께 끊고, {nextSection}에서 다시 들어옵니다.", + "firstStopRouteNext": "여기서 함께 끊고, {nextSection}에서 다시 들어옵니다.", + "firstStopRoutePrevious": "{previousSection} 다음에 여기서 함께 끊고, 컷을 유지합니다.", + "firstStopArmed": "{at}에서 {role} 컷을 지키세요. 그대로 밀고 가지 마세요.", + "firstStopArmedBand": "{at}에서 컷을 지키세요. 그대로 밀고 가지 마세요.", + "firstStopUnavailable": "아직 스톱이 없습니다. 컷이 표시될 때까지 오늘 지도에 머무르세요.", + "firstStopNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 스톱을 들으세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "sectionStopNextAction": "여기서 함께 끊고, {nextSectionLabel}에서 다시 들어옵니다.", + "sectionStopNextActionBare": "여기서 함께 끊고, 컷을 유지합니다." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..141c1257c 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -9,7 +9,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c ## Core rehearsal artifacts - likely harmony by section and by role -- section roadmap with entries, dropouts, pickups, stops, and handoffs +- section roadmap with entries, dropouts, pickups, stops, and handoffs; the ready workspace names tonight's first stop and the next entrance when one actually exists - groove and timing cues - role ranges, overlap warnings, and simplification guidance - transposition, capo, tuning, or setup cues where relevant diff --git a/docs/architecture/rehearsal-domain-model.md b/docs/architecture/rehearsal-domain-model.md index 4b177dbf1..29e7ba739 100644 --- a/docs/architecture/rehearsal-domain-model.md +++ b/docs/architecture/rehearsal-domain-model.md @@ -24,6 +24,7 @@ BandScope models a song as rehearsal-facing roles, not only as a single global h - A section model should support intro, verse, pre-chorus, chorus, bridge, outro, tags, pickups, stops, and handoffs. - A rehearsal roadmap should expose who enters, who drops out, and where the band must re-enter together. +- The ready workspace names tonight's first validated stop so the room can cut together before the next entrance when a following section actually exists; terminal stops do not invent a re-entry. - Cue anchors should support lyric phrases, count-based entries, or section-transition markers. ## Groove cues and rhythmic feel diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..2874beb8e 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Stop Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstStopCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled stop, and the time. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearStop` exists and delegates the exact stop second to that callback. Keep the unavailable state guidance-only. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-stop-navigation.md b/docs/doctoring/reduced-motion-first-stop-navigation.md new file mode 100644 index 000000000..6490c2c38 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-stop-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-stop navigation + +Workspace map navigation for tonight's first stop follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstStopCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Stop resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index 3867248e8..020a6b910 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -1,6 +1,14 @@ -"""BandScope analysis engine package.""" +"""BandScope analysis engine package and application composition root.""" -from .api import get_analysis_status +from . import api as _api from .health import build_health_report +from .stop_projection import with_detected_stop_projection + +# Keep the public API module as the stable import boundary while composing the +# rehearsal-cue projector around the existing analysis builder. Internal job +# orchestration resolves this module global at call time, so local-audio jobs +# and direct API callers share the same decoded-stop behavior. +_api.build_demo_rehearsal_song = with_detected_stop_projection(_api.build_demo_rehearsal_song) +get_analysis_status = _api.get_analysis_status __all__ = ["build_health_report", "get_analysis_status"] diff --git a/services/analysis-engine/src/bandscope_analysis/stop_projection.py b/services/analysis-engine/src/bandscope_analysis/stop_projection.py new file mode 100644 index 000000000..61c75e8d0 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/stop_projection.py @@ -0,0 +1,187 @@ +"""Project signal-derived full-band cuts into rehearsal section cues. + +The structural segmenter intentionally models musical form at multi-second +resolution, while stop-time detection models short full-band coordination +breaks. This adapter keeps those responsibilities separate: it never relabels +or rewrites a structural section. Instead, a validated stop-time moment becomes +an additional ``stop`` rehearsal cue section anchored inside the structural +section that owns the cut. + +Security Notes: +- Operates only on already-decoded in-memory stem arrays and analysis payloads. +- Does no file, network, subprocess, model, or persistence I/O. +- Invalid timing, malformed section ranges, and unanchored moments are dropped. +- Synthetic cue ids are deterministic and collision-safe within one song. +""" + +from __future__ import annotations + +import copy +import math +from collections.abc import Callable +from typing import Any, TypeVar, cast + +from .temporal.hits import detect_stop_time + +SongT = TypeVar("SongT", bound=dict[str, Any]) +BuildSong = Callable[[dict[str, Any] | None], SongT] + + +def _valid_section_range(section: object) -> tuple[int, int] | None: + """Return a bounded positive integer section range from untrusted payload data.""" + if not isinstance(section, dict): + return None + time_range = section.get("timeRange") + if not isinstance(time_range, dict): + return None + start = time_range.get("start") + end = time_range.get("end") + if ( + not isinstance(start, int) + or isinstance(start, bool) + or not isinstance(end, int) + or isinstance(end, bool) + or start < 0 + or end <= start + ): + return None + return start, end + + +def _owning_section(sections: list[object], start_time: float) -> dict[str, Any] | None: + """Find the structural section whose half-open range owns a stop start time.""" + for section in sections: + section_range = _valid_section_range(section) + if section_range is None or not isinstance(section, dict): + continue + start, end = section_range + if start <= start_time < end: + return section + return None + + +def _covered_by_existing_stop(sections: list[object], start_time: float) -> bool: + """Avoid duplicating a stop that a future structural pipeline already emits.""" + for section in sections: + if not isinstance(section, dict) or section.get("label") != "stop": + continue + section_range = _valid_section_range(section) + if section_range is None: + continue + start, end = section_range + if start <= start_time < end: + return True + return False + + +def project_detected_stop_sections( + song: SongT, + audio_features: dict[str, Any] | None, +) -> SongT: + """Add deterministic rehearsal cue sections for validated full-band stop moments. + + Stop-time detection keeps its native 100 ms analysis resolution. The current + shared section contract is whole-second ``u32`` timing, so the cue section is + conservatively quantized to the containing structural section: floor the + detected start, ceil the detected end, and keep at least one second. The + detector's scientific output is not reinterpreted as a form label; the + additional ``stop`` section is explicitly a rehearsal cue projection. + """ + if not isinstance(song, dict) or not isinstance(audio_features, dict): + return song + + stems = audio_features.get("stems") + sr = audio_features.get("sr") + sections_value = song.get("sections") + if not isinstance(stems, dict) or not stems or not isinstance(sr, int) or sr <= 0: + return song + if not isinstance(sections_value, list) or not sections_value: + return song + + moments = detect_stop_time(stems, sr) + if not moments: + return song + + sections = list(sections_value) + existing_ids = { + section.get("id") + for section in sections + if isinstance(section, dict) and isinstance(section.get("id"), str) + } + projected: list[dict[str, Any]] = [] + next_id = 1 + + for moment in moments: + start_time = moment.get("start_time") + end_time = moment.get("end_time") + if ( + not isinstance(start_time, (int, float)) + or isinstance(start_time, bool) + or not isinstance(end_time, (int, float)) + or isinstance(end_time, bool) + ): + continue + start_time = float(start_time) + end_time = float(end_time) + if not math.isfinite(start_time) or not math.isfinite(end_time): + continue + if start_time < 0 or end_time <= start_time: + continue + if _covered_by_existing_stop(sections, start_time): + continue + + owner = _owning_section(sections, start_time) + owner_range = _valid_section_range(owner) + if owner is None or owner_range is None: + continue + owner_start, owner_end = owner_range + + cue_start = max(owner_start, math.floor(start_time)) + cue_end = min(owner_end, max(cue_start + 1, math.ceil(end_time))) + if cue_end <= cue_start: + continue + + while f"detected-stop-{next_id}" in existing_ids: + next_id += 1 + cue_id = f"detected-stop-{next_id}" + next_id += 1 + existing_ids.add(cue_id) + + cue = copy.deepcopy(owner) + cue["id"] = cue_id + cue["label"] = "stop" + cue["timeRange"] = {"start": int(cue_start), "end": int(cue_end)} + cue["confidence"] = { + "level": "low", + "source": "model", + "notes": "Detected from a full-band quiet interval; confirm the cut by ear.", + } + projected.append(cue) + + if not projected: + return song + + combined = sections + projected + combined.sort( + key=lambda section: ( + (_valid_section_range(section) or (2**63 - 1, 2**63 - 1))[0], + 1 if isinstance(section, dict) and section.get("label") == "stop" else 0, + str(section.get("id", "")) if isinstance(section, dict) else "", + ) + ) + result = dict(song) + result["sections"] = combined + return cast(SongT, result) + + +def with_detected_stop_projection(build_song: BuildSong[SongT]) -> BuildSong[SongT]: + """Decorate the analysis-song builder at the package composition boundary.""" + if getattr(build_song, "__bandscope_stop_projection__", False): + return build_song + + def wrapped(audio_features: dict[str, Any] | None = None) -> SongT: + song = build_song(audio_features) + return project_detected_stop_sections(song, audio_features) + + setattr(wrapped, "__bandscope_stop_projection__", True) + return wrapped diff --git a/services/analysis-engine/tests/test_stop_handoff_pipeline.py b/services/analysis-engine/tests/test_stop_handoff_pipeline.py new file mode 100644 index 000000000..df64ed555 --- /dev/null +++ b/services/analysis-engine/tests/test_stop_handoff_pipeline.py @@ -0,0 +1,77 @@ +"""Integration regression for real decoded-audio stop handoff evidence.""" + +from unittest.mock import patch + +import numpy as np + +from bandscope_analysis.api import build_demo_rehearsal_song + + +def _section(section_id: str, label: str, index: int) -> dict[str, object]: + """Build a deterministic structural section fixture for the pipeline boundary.""" + return { + "id": section_id, + "form_label": label, + "sequence_index": index, + "groove": "standard", + "confidence_level": "high", + "confidence_source": "model", + "confidence_notes": "Detected from decoded audio.", + "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"}, + } + + +def test_real_stem_stop_reaches_rehearsal_song_as_rehearsal_cue_section() -> None: + """A decoded full-band cut must survive analysis as an actionable stop section.""" + sr = 1_000 + duration_seconds = 10.0 + sample_count = int(sr * duration_seconds) + time = np.arange(sample_count, dtype=np.float64) / sr + base = np.sin(2 * np.pi * 20 * time).astype(np.float32) + + bass = base.copy() + drums = (0.8 * base).astype(np.float32) + bass[4_000:4_500] = 0.0 + drums[4_000:4_500] = 0.0 + stems = {"bass": bass, "drums": drums} + + detected_sections = [ + _section("verse-1", "verse", 1), + _section("chorus-1", "chorus", 1), + ] + boundaries = [(0.0, 5.0), (5.0, 10.0)] + + with ( + patch( + "bandscope_analysis.api.segment_with_boundaries", + return_value=(detected_sections, boundaries), + ), + patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", return_value=None), + patch( + "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize", + return_value=[], + ), + ): + song = build_demo_rehearsal_song( + { + "stems": stems, + "sr": sr, + "separation": { + "duration_seconds": duration_seconds, + "chunk_count": 1, + "notes": "Rights-safe integration fixture", + }, + } + ) + + assert song["id"] == "analyzed-song" + assert [section["id"] for section in song["sections"]] == [ + "verse-1", + "detected-stop-1", + "chorus-1", + ] + stop = song["sections"][1] + assert stop["label"] == "stop" + assert stop["timeRange"] == {"start": 4, "end": 5} + assert stop["confidence"]["source"] == "model" + assert "full-band quiet interval" in stop["confidence"]["notes"]