diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..6cbd3f72a 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, stop-time cutoffs, 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..48bf5c511 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,7 +80,7 @@ Last updated: 2026-03-11 - Core rehearsal artifacts should include: - likely harmony by section and by role - - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs + - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs, with the ready workspace naming tonight's first stop and the next entrance - 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 - simplification, transposition, capo, tuning, or setup cues where applicable diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..f4d5f98d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - 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. +- Name tonight's first stop on the ready rehearsal map and tell the band to cut together before the next entrance. - 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..7c9d871ae 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 and the next instrument check, and names tonight's first stop so the room can cut together before the next entrance. `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/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..5cab84b41 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -106,4 +106,40 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("names the stop next action only on the destination card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-1", label: "verse" }, + { + ...verse, + id: "stop-1", + label: "stop", + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` })) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + 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 the stop next action when no stop is named", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByText(/Cut together here/i)).toBeNull(); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..1aad4ae43 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -3,6 +3,7 @@ import { useId, useMemo } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; +import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop"; 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(() => firstStop(song), [song]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -120,6 +122,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove} + {tonightStop && isStopTarget(tonightStop, section.id) ? ( +

+ {fillStopCopy( + t(tonightStop.nextSectionLabel ? "sectionStopNextAction" : "sectionStopNextActionBare"), + stopCopyValues(tonightStop) + )} +

+ ) : null} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..6db9e9d83 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,53 @@ describe("Workspace", () => { ); }); + it("asks for an ear check when the map has no named stop", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.getByTestId("first-stop")).toHaveTextContent("Tonight's first stop"); + expect(screen.getByTestId("first-stop")).toHaveTextContent( + "Tonight's first stop still needs an ear check. Listen for the place everyone cuts out together before the next section." + ); + }); + + it("names tonight's first stop and the next entrance", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-1", label: "verse" }, + { + ...verse, + id: "stop-1", + label: "stop", + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` })) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + render(); + + expect(screen.getByTestId("first-stop")).toHaveTextContent( + "Cut together in stop after verse, then come back in on chorus." + ); + }); + + it("localizes the missing stop callout", () => { + setNavigatorLanguage("ko-KR"); + render(); + + expect(screen.getByTestId("first-stop")).toHaveTextContent("오늘 먼저 맞출 스톱"); + expect(screen.getByTestId("first-stop")).toHaveTextContent( + "오늘 먼저 맞출 스톱은 아직 귀로 확인이 필요합니다. 다음 구간 전에 다 같이 끊는 자리를 들어 보세요." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { 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..52feaaf1a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillStopCopy, firstStop, stopCopyValues } from "./firstStop"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const tonightStop = useMemo(() => firstStop(song), [song]); + const firstStopCopy = tonightStop + ? fillStopCopy( + t( + tonightStop.previousSectionLabel && tonightStop.nextSectionLabel + ? "workspaceFirstStopCheck" + : tonightStop.nextSectionLabel + ? "workspaceFirstStopCheckNoPrevious" + : tonightStop.previousSectionLabel + ? "workspaceFirstStopCheckNoNext" + : "workspaceFirstStopCheckBare" + ), + stopCopyValues(tonightStop) + ) + : t("workspaceFirstStopMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +326,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstStopTitle")}

+

{firstStopCopy}

+
+

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/firstStop.test.ts b/apps/desktop/src/features/workspace/firstStop.test.ts new file mode 100644 index 000000000..09d5ae509 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStop.test.ts @@ -0,0 +1,177 @@ +import { createDemoRehearsalSong, type RehearsalSong, type RehearsalSection } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop"; + +function cloneSong(song: RehearsalSong): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ ...role })) + })) + }; +} + +function withSections(song: RehearsalSong, sections: RehearsalSection[]): RehearsalSong { + return { ...song, sections }; +} + +function namedSection(base: RehearsalSection, id: string, label: RehearsalSection["label"]): RehearsalSection { + return { + ...base, + id, + label, + roles: base.roles.map((role) => ({ ...role, id: `${role.id}-${id}` })) + }; +} + +describe("firstStop", () => { + it("returns null on the demo song so the map asks for an ear check", () => { + expect(firstStop(createDemoRehearsalSong())).toBeNull(); + }); + + it("names the first form-labeled stop and the sections around it", () => { + const verse = createDemoRehearsalSong().sections[0]!; + const song = withSections(createDemoRehearsalSong(), [ + namedSection(verse, "verse-1", "verse"), + namedSection(verse, "stop-1", "stop"), + namedSection(verse, "chorus-1", "chorus") + ]); + + expect(firstStop(song)).toEqual({ + sectionId: "stop-1", + sectionLabel: "stop", + previousSectionLabel: "verse", + nextSectionLabel: "chorus" + }); + }); + + it("skips earlier non-stop sections and keeps the first stop only", () => { + const verse = createDemoRehearsalSong().sections[0]!; + const song = withSections(createDemoRehearsalSong(), [ + namedSection(verse, "intro-1", "intro"), + namedSection(verse, "verse-1", "verse"), + namedSection(verse, "stop-1", "stop"), + namedSection(verse, "stop-2", "stop"), + namedSection(verse, "chorus-1", "chorus") + ]); + + expect(firstStop(song)?.sectionId).toBe("stop-1"); + }); + + it("omits previous or next labels when those neighbors are unnamed", () => { + const verse = createDemoRehearsalSong().sections[0]!; + const song = withSections(createDemoRehearsalSong(), [ + { ...namedSection(verse, "verse-1", "verse"), label: "verse" }, + namedSection(verse, "stop-1", "stop") + ]); + song.sections[0] = { ...song.sections[0]!, label: "none" as RehearsalSection["label"] }; + + expect(firstStop(song)).toEqual({ + sectionId: "stop-1", + sectionLabel: "stop", + previousSectionLabel: undefined, + nextSectionLabel: undefined + }); + }); + + it("does not invent a stop from groove or cue wording", () => { + const song = cloneSong(createDemoRehearsalSong()); + song.sections[0] = { + ...song.sections[0]!, + groove: "Stop-time hits on beat 4", + roles: song.sections[0]!.roles.map((role) => ({ + ...role, + cue: { kind: "transition", value: "Cut together on the stop." } + })) + }; + + expect(firstStop(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots and members", () => { + expect(firstStop(null as unknown as RehearsalSong)).toBeNull(); + expect(firstStop({ sections: "nope" } as unknown as RehearsalSong)).toBeNull(); + expect( + firstStop({ + ...createDemoRehearsalSong(), + sections: [null, "skip", { label: "stop" }] + } as unknown as RehearsalSong) + ).toBeNull(); + }); + + it("fails closed when a section identity is missing", () => { + const song = cloneSong(createDemoRehearsalSong()); + song.sections[0] = { ...song.sections[0]!, id: " " }; + + expect(firstStop(song)).toBeNull(); + }); + + it("fails closed when repeated section ids cannot identify one destination card", () => { + const verse = createDemoRehearsalSong().sections[0]!; + const song = withSections(createDemoRehearsalSong(), [ + namedSection(verse, "duplicate", "verse"), + namedSection(verse, "duplicate", "stop") + ]); + + expect(firstStop(song)).toBeNull(); + }); + + it("trims labels before matching the stop form", () => { + const verse = createDemoRehearsalSong().sections[0]!; + const song = withSections(createDemoRehearsalSong(), [ + namedSection(verse, "verse-1", "verse"), + { ...namedSection(verse, "stop-1", "stop"), label: " stop " as RehearsalSection["label"] }, + namedSection(verse, "chorus-1", "chorus") + ]); + + expect(firstStop(song)).toEqual({ + sectionId: "stop-1", + sectionLabel: "stop", + previousSectionLabel: "verse", + nextSectionLabel: "chorus" + }); + }); +}); + +describe("fillStopCopy", () => { + it("fills own-property tokens once and leaves unknown tokens literal", () => { + expect( + fillStopCopy("Cut together in {sectionLabel} after {previousSectionLabel}.", { + sectionLabel: "stop", + previousSectionLabel: "verse" + }) + ).toBe("Cut together in stop after verse."); + expect(fillStopCopy("Keep {toString}", { sectionLabel: "stop" })).toBe("Keep {toString}"); + }); +}); + +describe("isStopTarget", () => { + it("matches only the named stop identity", () => { + const stop = { + sectionId: "stop-1", + sectionLabel: "stop", + previousSectionLabel: "verse", + nextSectionLabel: "chorus" + }; + + expect(isStopTarget(stop, "stop-1")).toBe(true); + expect(isStopTarget(stop, " verse-1 ")).toBe(false); + expect(isStopTarget(stop, " ")).toBe(false); + }); +}); + +describe("stopCopyValues", () => { + it("exposes empty neighbor tokens when those labels are missing", () => { + expect( + stopCopyValues({ + sectionId: "stop-1", + sectionLabel: "stop" + }) + ).toEqual({ + sectionLabel: "stop", + previousSectionLabel: "", + nextSectionLabel: "" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStop.ts b/apps/desktop/src/features/workspace/firstStop.ts new file mode 100644 index 000000000..ee34ca83b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStop.ts @@ -0,0 +1,108 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named stop on the rehearsal map. */ +export type FirstStop = { + sectionId: string; + sectionLabel: string; + previousSectionLabel?: string; + nextSectionLabel?: string; +}; + +const STOP_LABEL = "stop"; +const UNNAMED_SECTION_LABEL = "none"; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(runtimeValue: unknown): runtimeValue is Record { + return typeof runtimeValue === "object" && runtimeValue !== null && !Array.isArray(runtimeValue); +} + +/** + * Pick the first named stop the band should cut together before the next entrance. + * + * Runtime roots and collection members are untrusted. Missing or duplicate + * section identities fail closed so a later stop cannot steal another card. + * Only the canonical form label `stop` becomes rehearsal-map authority; + * groove text and cue wording never invent a stop. + */ +export function firstStop(rehearsalSong: RehearsalSong): FirstStop | null { + const runtimeSong: unknown = rehearsalSong; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const seenSectionIds = new Set(); + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const sectionId = meaningfulRangeText(sectionValue.id); + if (!sectionId) { + return null; + } + if (seenSectionIds.has(sectionId)) { + return null; + } + seenSectionIds.add(sectionId); + } + + type NamedSection = { + sectionId: string; + sectionLabel: string; + }; + + const namedSections: NamedSection[] = []; + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const sectionId = meaningfulRangeText(sectionValue.id); + const runtimeSectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionId) { + return null; + } + namedSections.push({ + sectionId, + sectionLabel: runtimeSectionLabel === UNNAMED_SECTION_LABEL ? "" : runtimeSectionLabel ?? "" + }); + } + + for (let sectionIndex = 0; sectionIndex < namedSections.length; sectionIndex += 1) { + const currentSection = namedSections[sectionIndex]; + if (!currentSection || currentSection.sectionLabel !== STOP_LABEL) { + continue; + } + + const previousSectionLabel = namedSections[sectionIndex - 1]?.sectionLabel; + const nextSectionLabel = namedSections[sectionIndex + 1]?.sectionLabel; + + return { + sectionId: currentSection.sectionId, + sectionLabel: currentSection.sectionLabel, + previousSectionLabel: previousSectionLabel || undefined, + nextSectionLabel: nextSectionLabel || undefined + }; + } + + return null; +} + +/** Fill trusted `{token}` placeholders for stop rehearsal copy. */ +export function fillStopCopy(copyTemplate: string, copyValues: Record): string { + return fillRangeCopy(copyTemplate, copyValues); +} + +/** True when this stable section identity owns tonight's first-stop action. */ +export function isStopTarget(firstStopResult: FirstStop, sectionId: string): boolean { + const normalizedSectionId = meaningfulRangeText(sectionId); + return Boolean(normalizedSectionId) && normalizedSectionId === firstStopResult.sectionId; +} + +/** Tokens for the buyer-visible stop callout and roadmap next action. */ +export function stopCopyValues(firstStopResult: FirstStop): Record { + return { + sectionLabel: firstStopResult.sectionLabel, + previousSectionLabel: firstStopResult.previousSectionLabel ?? "", + nextSectionLabel: firstStopResult.nextSectionLabel ?? "" + }; +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..887bfcd55 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,14 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstStopTitle": "Tonight's first stop", + "workspaceFirstStopCheck": "Cut together in {sectionLabel} after {previousSectionLabel}, then come back in on {nextSectionLabel}.", + "workspaceFirstStopCheckNoPrevious": "Cut together in {sectionLabel}, then come back in on {nextSectionLabel}.", + "workspaceFirstStopCheckNoNext": "Cut together in {sectionLabel} after {previousSectionLabel}, then come back in on the next downbeat.", + "workspaceFirstStopCheckBare": "Cut together in {sectionLabel}, then come back in on the next downbeat.", + "workspaceFirstStopMissing": "Tonight's first stop still needs an ear check. Listen for the place everyone cuts out together before the next 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, then come back in on the next downbeat." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..698d465fb 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,14 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstStopTitle": "오늘 먼저 맞출 스톱", + "workspaceFirstStopCheck": "{previousSectionLabel} 다음 {sectionLabel}에서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.", + "workspaceFirstStopCheckNoPrevious": "{sectionLabel}에서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.", + "workspaceFirstStopCheckNoNext": "{previousSectionLabel} 다음 {sectionLabel}에서 같이 끊고, 다음 다운비트에 다시 들어오세요.", + "workspaceFirstStopCheckBare": "{sectionLabel}에서 같이 끊고, 다음 다운비트에 다시 들어오세요.", + "workspaceFirstStopMissing": "오늘 먼저 맞출 스톱은 아직 귀로 확인이 필요합니다. 다음 구간 전에 다 같이 끊는 자리를 들어 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "sectionStopNextAction": "여기서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.", + "sectionStopNextActionBare": "여기서 같이 끊고, 다음 다운비트에 다시 들어오세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..a284cb268 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 - 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..f61630ca2 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 stop so the room can cut together before the next entrance. - Cue anchors should support lyric phrases, count-based entries, or section-transition markers. ## Groove cues and rhythmic feel diff --git a/services/analysis-engine/src/bandscope_analysis/roles/__init__.py b/services/analysis-engine/src/bandscope_analysis/roles/__init__.py index e432ed3dd..49224362f 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/__init__.py @@ -1,6 +1,6 @@ """Role extraction and part graph models.""" -from .extractor import RoleExtractor +from .coordinated_extractor import CoordinatedRoleExtractor from .model import ( CueAnchorKind, PartGraphNode, @@ -12,8 +12,13 @@ ) from .tuning import get_setup_note +# Preserve the established package-level import while routing the analysis +# pipeline through the cross-role temporal coordination boundary. +RoleExtractor = CoordinatedRoleExtractor + __all__ = [ "RoleExtractor", + "CoordinatedRoleExtractor", "CueAnchorKind", "PartGraphNode", "RehearsalPriority", diff --git a/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py new file mode 100644 index 000000000..bde5a3233 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py @@ -0,0 +1,73 @@ +"""Cross-role rehearsal extraction that binds temporal stop-time to stable section cards.""" + +from __future__ import annotations + +from typing import Any + +from ..temporal import hits as temporal_hits +from .extractor import RoleExtractor as CoreRoleExtractor +from .model import RoleExtractionResult + + +def _apply_stop_time_section_labels( + section_candidates: list[Any], + audio_features: dict[str, Any] | None, +) -> None: + """Map each detected all-stem cutoff onto the stable section immediately before re-entry. + + Stop-time is detected from separated stems, while structural segmentation owns + stable section identities and ranges. The first boundary range containing a + stop's start time is therefore the rehearsal card that owns the cut. Exact + boundary starts deliberately resolve to the preceding range so the next card + remains the re-entry destination. Only the form label changes; stable section + identity, timing, role topology, and persisted wire keys remain unchanged. + """ + analysis_features = audio_features or {} + audio_stems = analysis_features.get("stems") + sample_rate = analysis_features.get("sr") + section_boundaries = analysis_features.get("boundaries") + + if not isinstance(audio_stems, dict) or not audio_stems: + return + if not isinstance(sample_rate, int) or isinstance(sample_rate, bool) or sample_rate <= 0: + return + if not isinstance(section_boundaries, list) or len(section_boundaries) != len(section_candidates): + return + + stop_time_moments = temporal_hits.detect_stop_time(audio_stems, sample_rate) + for stop_time_moment in stop_time_moments: + if not isinstance(stop_time_moment, dict): + continue + stop_start_time = stop_time_moment.get("start_time") + if isinstance(stop_start_time, bool) or not isinstance(stop_start_time, (int, float)): + continue + + for section_index, section_boundary in enumerate(section_boundaries): + if not isinstance(section_boundary, (tuple, list)) or len(section_boundary) != 2: + continue + section_start_time, section_end_time = section_boundary + if ( + isinstance(section_start_time, bool) + or isinstance(section_end_time, bool) + or not isinstance(section_start_time, (int, float)) + or not isinstance(section_end_time, (int, float)) + ): + continue + if section_start_time <= stop_start_time <= section_end_time: + section_candidate = section_candidates[section_index] + if isinstance(section_candidate, dict): + section_candidate["form_label"] = "stop" + break + + +class CoordinatedRoleExtractor(CoreRoleExtractor): + """Role extractor that first binds cross-role stop-time evidence to section form.""" + + def extract( + self, + section_candidates: list[Any], + audio_features: dict[str, Any] | None = None, + ) -> RoleExtractionResult: + """Extract role topology after mapping all-stem cutoffs onto stable section cards.""" + _apply_stop_time_section_labels(section_candidates, audio_features) + return super().extract(section_candidates, audio_features) diff --git a/services/analysis-engine/tests/test_pipeline_integration.py b/services/analysis-engine/tests/test_pipeline_integration.py index fa39d4eaa..0ebea475d 100644 --- a/services/analysis-engine/tests/test_pipeline_integration.py +++ b/services/analysis-engine/tests/test_pipeline_integration.py @@ -143,6 +143,70 @@ def test_pipeline_without_detected_sections_falls_back() -> None: assert song["id"] == "demo-song" +def test_pipeline_maps_detected_stop_time_to_section_before_reentry() -> None: + """Ensure an analyzed all-stem cutoff reaches the stable section consumed by the workspace.""" + sample_rate = 22050 + audio_stems = _make_realistic_stems(sr=sample_rate, duration=20.0) + stop_start_sample = int(sample_rate * 9.4) + stop_end_sample = int(sample_rate * 10.0) + for stem_audio in audio_stems.values(): + stem_audio[stop_start_sample:stop_end_sample] = 0.0 + + detected_sections = [ + { + "id": "verse-1", + "form_label": "verse", + "sequence_index": 0, + "groove": "driving", + "confidence_level": "high", + "confidence_source": "model", + "confidence_notes": "Synthetic verse", + "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"}, + }, + { + "id": "chorus-1", + "form_label": "chorus", + "sequence_index": 1, + "groove": "open", + "confidence_level": "high", + "confidence_source": "model", + "confidence_notes": "Synthetic chorus", + "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"}, + }, + ] + section_boundaries = [(0.0, 10.0), (10.0, 20.0)] + + with ( + patch( + "bandscope_analysis.api.segment_with_boundaries", + return_value=(detected_sections, section_boundaries), + ), + patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", return_value=None), + patch( + "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize", + return_value=[], + ), + ): + rehearsal_song = build_demo_rehearsal_song( + { + "stems": audio_stems, + "sr": sample_rate, + "separation": { + "duration_seconds": 20.0, + "chunk_count": 1, + "notes": "Synthetic stop-time", + }, + } + ) + + assert [section_payload["label"] for section_payload in rehearsal_song["sections"]] == [ + "stop", + "chorus", + ] + assert rehearsal_song["sections"][0]["id"] == "verse-1" + assert rehearsal_song["sections"][1]["id"] == "chorus-1" + + def test_pipeline_missing_boundary_uses_full_duration_range() -> None: """Ensure boundary count mismatches fail closed to the full duration.""" stems = _make_realistic_stems(sr=22050, duration=30.0)