From edab7e3bab8a6f2a6174a35131a68a4e5047fe3d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 17:25:36 +0000 Subject: [PATCH] feat(workspace): name tonight's first D.C. al Fine on the map Show a trusted D.C. al Fine on the ready rehearsal map so the room can return to the beginning and end at Fine, then check tonight's first range. Fail closed on sibling navigation, inherited labels, and unverified beginning or Fine destinations. Do not invent D.C. al Fine from MIR. --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/core/src/lib.rs | 134 ++++++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 38 +++++ .../src/features/workspace/Workspace.tsx | 21 ++- .../features/workspace/firstDcAlFine.test.ts | 87 ++++++++++++ .../src/features/workspace/firstDcAlFine.ts | 83 +++++++++++ apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + apps/desktop/vite.config.ts | 3 +- docs/doctoring/workspace-first-dc-al-fine.md | 32 +++++ packages/shared-types/src/index.ts | 36 ++++- packages/shared-types/test/index.test.ts | 43 ++++++ .../src/bandscope_analysis/api.py | 9 ++ .../tests/test_dc_al_fine_contract.py | 50 +++++++ 17 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstDcAlFine.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDcAlFine.ts create mode 100644 docs/doctoring/workspace-first-dc-al-fine.md create mode 100644 services/analysis-engine/tests/test_dc_al_fine_contract.py diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..345127ff2 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, tonight's first D.C. al Fine to return to the beginning and end at Fine, 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..9deef3e1c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-31 ## Brand source @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, tonight's first D.C. al Fine to return to the beginning and end at Fine, and the next instrument check - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..ee78f8c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first D.C. al Fine on the ready rehearsal map and tell the room to return to the beginning and end at Fine before the first range check. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..fa0c43376 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, tonight's first D.C. al Fine to return to the beginning and end at Fine, 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/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..ee4d9d15a 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,73 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde( + default, + deserialize_with = "deserialize_optional_dc_al_fine", + skip_serializing_if = "Option::is_none" + )] + dc_al_fine: Option, sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DcAlFinePayload { + label: String, +} + +impl<'de> Deserialize<'de> for DcAlFinePayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct RawDcAlFinePayload { + label: String, + } + + let raw = RawDcAlFinePayload::deserialize(deserializer)?; + if !is_trusted_dc_al_fine_label(&raw.label) { + return Err(serde::de::Error::custom( + "dcAlFine label must be D.C. al Fine or D.C. al Fine 1–9", + )); + } + + Ok(Self { label: raw.label }) + } +} + +fn is_trusted_dc_al_fine_label(label: &str) -> bool { + matches!( + label, + "D.C. al Fine" + | "D.C. al Fine 1" + | "D.C. al Fine 2" + | "D.C. al Fine 3" + | "D.C. al Fine 4" + | "D.C. al Fine 5" + | "D.C. al Fine 6" + | "D.C. al Fine 7" + | "D.C. al Fine 8" + | "D.C. al Fine 9" + ) +} + +fn deserialize_optional_dc_al_fine<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(Some) + .ok_or_else(|| serde::de::Error::custom("dcAlFine must be an object when present")) +} + /// Score attachment metadata persisted inside the song payload. Only the /// locally minted score id and the display file name cross the IPC boundary; /// the PDF bytes stay in the app-owned scores directory keyed by that id. @@ -786,6 +847,77 @@ mod tests { assert_eq!(parsed.sections[0].id, "verse-1"); } + #[test] + fn rehearsal_song_payload_round_trips_optional_dc_al_fine() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["dcAlFine"] = json!({ "label": "D.C. al Fine" }); + + let parsed = serde_json::from_value::(payload) + .expect("song payload with a dcAlFine should deserialize"); + let dc_al_fine = parsed + .dc_al_fine + .as_ref() + .expect("dcAlFine should survive deserialization"); + assert_eq!(dc_al_fine.label, "D.C. al Fine"); + + let serialized = + serde_json::to_value(&parsed).expect("marked song payload should serialize back"); + assert_eq!(serialized["dcAlFine"], json!({ "label": "D.C. al Fine" })); + + let loaded = project_payload_from_content( + &serde_json::to_string(&serialized).expect("marked payload should encode"), + ) + .expect("marked project should load"); + assert_eq!( + loaded.dc_al_fine.as_ref().map(|value| value.label.as_str()), + Some("D.C. al Fine") + ); + } + + #[test] + fn rehearsal_song_payload_round_trips_numbered_dc_al_fine_labels() { + for label in ["D.C. al Fine 1", "D.C. al Fine 5", "D.C. al Fine 9"] { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["dcAlFine"] = json!({ "label": label }); + + let parsed = serde_json::from_value::(payload) + .unwrap_or_else(|_| panic!("trusted dcAlFine {label} should deserialize")); + assert_eq!( + parsed.dc_al_fine.as_ref().map(|value| value.label.as_str()), + Some(label) + ); + } + } + + #[test] + fn rehearsal_song_payload_rejects_invalid_dc_al_fine() { + for dc_al_fine in [ + json!({ "label": "d.c. al fine" }), + json!({ "label": "D.C. Al Fine" }), + json!({ "label": "Da Capo" }), + json!({ "label": "Dal Segno" }), + json!({ "label": "Fine" }), + json!({ "label": "To Coda" }), + json!({ "label": "Coda" }), + json!({ "label": "D.S. al Coda" }), + json!({ "label": "D.C. al Coda" }), + json!({ "label": "D.S. al Fine" }), + json!({ "label": "al Fine" }), + json!({ "label": "D.S." }), + json!({ "label": "D.C." }), + json!({ "label": "D.C. al Fine 0" }), + json!({ "label": "D.C. al Fine 10" }), + json!({ "label": "" }), + json!({ "label": "D.C. al Fine", "confidence": "high" }), + json!({ "text": "D.C. al Fine" }), + Value::Null, + ] { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["dcAlFine"] = dc_al_fine; + assert!(serde_json::from_value::(payload).is_err()); + } + } + #[test] fn rehearsal_song_payload_round_trips_score_attachments() { let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); @@ -817,9 +949,11 @@ mod tests { .expect("legacy payload without score attachments should deserialize"); assert!(parsed.score_attachments.is_none()); + assert!(parsed.dc_al_fine.is_none()); let serialized = serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); assert!(serialized.get("scoreAttachments").is_none()); + assert!(serialized.get("dcAlFine").is_none()); } #[test] diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..6526278d1 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,44 @@ describe("Workspace", () => { ); }); + it("checks the first range after a D.C. al Fine when sections are already named", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-dc-al-fine"); + expect(callout).toHaveTextContent("Tonight's first D.C. al Fine"); + expect(callout).toHaveTextContent( + "Tonight's first D.C. al Fine is D.C. al Fine: at D.C. al Fine, return to the beginning and end at Fine, then check tonight's first range." + ); + expect(callout).not.toHaveTextContent("start the first verse"); + }); + + it("asks the room to stay on the map when the D.C. al Fine is missing", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + delete song.dcAlFine; + + render(); + + expect(screen.getByTestId("first-dc-al-fine")).toHaveTextContent( + "Tonight's first D.C. al Fine still needs a label. Stay on tonight's map until the first D.C. al Fine is marked, then check tonight's first range." + ); + }); + + it("keeps D.C. al Fine copy target-agnostic even when the first section is unnamed", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = song.sections.map((section) => ({ ...section, label: "none" })); + + render(); + + expect(screen.getByTestId("first-dc-al-fine")).toHaveTextContent( + "Tonight's first D.C. al Fine is D.C. al Fine: at D.C. al Fine, return to the beginning and end at Fine, then name the first section so the room knows where it starts." + ); + }); + it("asks for an ear check when the selected part has no named span", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..dd1e087c6 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,7 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText } from "./firstRangeSqueeze"; +import { fillDcAlFineCopy, firstDcAlFinePlan } from "./firstDcAlFine"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstDcAlFine = useMemo(() => firstDcAlFinePlan(song), [song]); + const firstSectionIsNamed = meaningfulRangeText(song.sections[0]?.label) !== undefined; + const firstDcAlFineCopy = firstDcAlFine + ? fillDcAlFineCopy( + t(firstSectionIsNamed ? "workspaceFirstDcAlFineReady" : "workspaceFirstDcAlFineReadyNoSection"), + { + label: firstDcAlFine.label + } + ) + : t("workspaceFirstDcAlFineMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +320,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstDcAlFineTitle")}

+

{firstDcAlFineCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstDcAlFine.test.ts b/apps/desktop/src/features/workspace/firstDcAlFine.test.ts new file mode 100644 index 000000000..9a23b2fcb --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDcAlFine.test.ts @@ -0,0 +1,87 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillDcAlFineCopy, + firstDcAlFinePlan, + isTrustedDcAlFineLabel, + MAX_DC_AL_FINE_LABEL_LENGTH, + trustedDcAlFine +} from "./firstDcAlFine"; + +describe("trustedDcAlFine", () => { + it("admits only own Gould/MusicXML D.C. al Fine and D.C. al Fine 1–9 labels", () => { + expect(trustedDcAlFine({ label: "D.C. al Fine" })).toEqual({ label: "D.C. al Fine" }); + expect(trustedDcAlFine({ label: "D.C. al Fine 1" })).toEqual({ label: "D.C. al Fine 1" }); + expect(trustedDcAlFine({ label: "D.C. al Fine 9" })).toEqual({ label: "D.C. al Fine 9" }); + const inherited = Object.create({ label: "D.C. al Fine" }) as Record; + expect(trustedDcAlFine(inherited)).toBeNull(); + expect(trustedDcAlFine({ label: "d.c. al fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. Al Fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. AL FINE" })).toBeNull(); + expect(trustedDcAlFine({ label: " D.C. al Fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. al Fine " })).toBeNull(); + expect(trustedDcAlFine({ label: "Da Capo" })).toBeNull(); + expect(trustedDcAlFine({ label: "Dal Segno" })).toBeNull(); + expect(trustedDcAlFine({ label: "Fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "To Coda" })).toBeNull(); + expect(trustedDcAlFine({ label: "Coda" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.S. al Coda" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. al Coda" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.S. al Fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "al Fine" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.S." })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C." })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. al Fine 0" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. al Fine 10" })).toBeNull(); + expect(trustedDcAlFine({ label: "" })).toBeNull(); + expect(trustedDcAlFine({ label: "D.C. al Fine", extra: true })).toBeNull(); + expect(trustedDcAlFine({ text: "D.C. al Fine" })).toBeNull(); + expect(trustedDcAlFine(null)).toBeNull(); + expect(trustedDcAlFine("D.C. al Fine")).toBeNull(); + expect(MAX_DC_AL_FINE_LABEL_LENGTH).toBe(14); + }); +}); + +describe("isTrustedDcAlFineLabel", () => { + it("rejects lowercase, sibling navigation, padded, and overlong tokens", () => { + expect(isTrustedDcAlFineLabel("D.C. al Fine")).toBe(true); + expect(isTrustedDcAlFineLabel("D.C. al Fine 2")).toBe(true); + expect(isTrustedDcAlFineLabel("d.c. al fine")).toBe(false); + expect(isTrustedDcAlFineLabel("D.C. al Fine 01")).toBe(false); + expect(isTrustedDcAlFineLabel("D.C. al Fine.")).toBe(false); + }); +}); + +describe("firstDcAlFinePlan", () => { + it("builds a D.C. al Fine plan without inventing beginning or Fine destinations", () => { + expect(firstDcAlFinePlan(createDemoRehearsalSong())).toEqual({ label: "D.C. al Fine" }); + }); + + it("ignores section order because the stored compound has no destination authority", () => { + expect( + firstDcAlFinePlan({ + dcAlFine: { label: "D.C. al Fine 2" }, + sections: [{ label: "intro" }, { label: "bridge" }, { label: "outro" }] + }) + ).toEqual({ label: "D.C. al Fine 2" }); + }); + + it("fails closed without a trusted D.C. al Fine", () => { + const song = createDemoRehearsalSong(); + delete song.dcAlFine; + expect(firstDcAlFinePlan(song)).toBeNull(); + expect(firstDcAlFinePlan(undefined)).toBeNull(); + expect(firstDcAlFinePlan([])).toBeNull(); + }); +}); + +describe("fillDcAlFineCopy", () => { + it("fills own-property tokens once and keeps rehearsal values literal", () => { + expect( + fillDcAlFineCopy("Tonight's first D.C. al Fine is {label}: return at {label}.", { + label: "D.C. al Fine {label}" + }) + ).toBe("Tonight's first D.C. al Fine is D.C. al Fine {label}: return at D.C. al Fine {label}."); + expect(fillDcAlFineCopy("keep {toString}", {})).toBe("keep {toString}"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDcAlFine.ts b/apps/desktop/src/features/workspace/firstDcAlFine.ts new file mode 100644 index 000000000..d3ed7f5b2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDcAlFine.ts @@ -0,0 +1,83 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy } from "./firstRangeSqueeze"; + +export /** Inclusive maximum length for a rehearsal-usable D.C. al Fine label. */ const MAX_DC_AL_FINE_LABEL_LENGTH = 14; + +/** Tonight's first named D.C. al Fine for the ready rehearsal map. */ +export type FirstDcAlFinePlan = { + label: string; +}; + +/** Stored song-level D.C. al Fine after lexical validation. */ +export type TrustedDcAlFine = { + label: string; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Admit only Gould/MusicXML D.C. al Fine labels: `D.C. al Fine` or + * `D.C. al Fine 1`–`D.C. al Fine 9`. + * + * Lowercase, extra keys, inherited labels, `Da Capo`, `Dal Segno`, `Fine`, + * `To Coda`, `Coda`, `D.S. al Coda`, `D.C. al Coda`, `D.S. al Fine`, + * `al Fine`, `D.S.`, `D.C.`, padded, and overlong strings fail closed. This + * is not OCR, MIR beginning/Fine detection, a beginning or Fine destination + * mark, or a form tag. + */ +export function trustedDcAlFine(value: unknown): TrustedDcAlFine | null { + if (!isRuntimeObject(value)) { + return null; + } + for (const key of Object.keys(value)) { + if (key !== "label") { + return null; + } + } + if ( + !Object.prototype.hasOwnProperty.call(value, "label") || + typeof value.label !== "string" || + !isTrustedDcAlFineLabel(value.label) + ) { + return null; + } + + return { label: value.label }; +} + +/** Return whether a D.C. al Fine label is a bounded Gould/MusicXML compound jump. */ +export function isTrustedDcAlFineLabel(label: string): boolean { + if (label.length < 12 || label.length > MAX_DC_AL_FINE_LABEL_LENGTH) { + return false; + } + return /^D\.C\. al Fine(?: [1-9])?$/u.test(label); +} + +/** + * Build tonight's first D.C. al Fine from a trusted stored chart compound. + * + * The song-level D.C. al Fine contract identifies only the return-then-end + * instruction and carries no verified beginning or Fine destination location. + * Customer copy therefore stays target-agnostic until a future contract + * explicitly represents those destinations. + */ +export function firstDcAlFinePlan(song: RehearsalSong | unknown): FirstDcAlFinePlan | null { + if (!isRuntimeObject(song)) { + return null; + } + + const dcAlFine = trustedDcAlFine(song.dcAlFine); + if (dcAlFine === null) { + return null; + } + + return { label: dcAlFine.label }; +} + +/** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ +export function fillDcAlFineCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..cad73e274 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,10 @@ "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.", + "workspaceFirstDcAlFineTitle": "Tonight's first D.C. al Fine", + "workspaceFirstDcAlFineReady": "Tonight's first D.C. al Fine is {label}: at {label}, return to the beginning and end at Fine, then check tonight's first range.", + "workspaceFirstDcAlFineReadyNoSection": "Tonight's first D.C. al Fine is {label}: at {label}, return to the beginning and end at Fine, then name the first section so the room knows where it starts.", + "workspaceFirstDcAlFineMissing": "Tonight's first D.C. al Fine still needs a label. Stay on tonight's map until the first D.C. al Fine is marked, then check tonight's first range.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..e7cc096bb 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstDcAlFineTitle": "오늘 먼저 맞출 D.C. al Fine", + "workspaceFirstDcAlFineReady": "오늘 첫 D.C. al Fine는 {label}입니다. {label}에서 처음으로 돌아간 뒤 Fine에서 끝내고, 오늘 첫 음역을 확인하세요.", + "workspaceFirstDcAlFineReadyNoSection": "오늘 첫 D.C. al Fine는 {label}입니다. {label}에서 처음으로 돌아간 뒤 Fine에서 끝낸 다음, 첫 구간 이름을 정해 시작 위치를 공유하세요.", + "workspaceFirstDcAlFineMissing": "오늘 첫 D.C. al Fine는 아직 표시가 필요합니다. 첫 D.C. al Fine가 표시될 때까지 오늘 지도를 보고, 그다음 오늘 첫 음역을 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..f7f6b6f30 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,8 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstDcAlFine.ts" ], thresholds: { lines: 90, diff --git a/docs/doctoring/workspace-first-dc-al-fine.md b/docs/doctoring/workspace-first-dc-al-fine.md new file mode 100644 index 000000000..274959d40 --- /dev/null +++ b/docs/doctoring/workspace-first-dc-al-fine.md @@ -0,0 +1,32 @@ +# Tonight's first D.C. al Fine + +## Decision + +The ready rehearsal map names tonight's first stored D.C. al Fine so the room can return to the beginning and end at Fine, then checks tonight's first range. This is not MIR beginning or Fine detection, a beginning or Fine destination mark, a form tag, OCR, or a section label. It is distinct from Da Capo, Dal Segno, Fine, To Coda, Coda, D.S. al Coda, D.C. al Coda, and D.S. al Fine. + +## Authority + +- Trusted D.C. al Fine is a MusicXML-shaped `{ label }` on `song.dcAlFine`: the token `D.C. al Fine` or `D.C. al Fine 1` through `D.C. al Fine 9`. +- Missing, inherited, extra-keyed, lowercase, `Da Capo`, `Dal Segno`, `Fine`, `To Coda`, `Coda`, `D.S. al Coda`, `D.C. al Coda`, `D.S. al Fine`, `al Fine`, `D.S.`, `D.C.`, padded, or overlong labels fail closed to a stay-on-the-map next action. Do not invent D.C. al Fine. +- The song-level contract does not identify the physical beginning or Fine destination sections. Customer copy may name the trusted D.C. al Fine instruction, but it must not claim that the first named song section is the return target or the Fine landing. + +## Trust boundary + +- Untrusted input: runtime song roots, `dcAlFine`, and section labels. +- Session display only. No files, URLs, subprocesses, IPC, model artifacts, or persistence are added by the callout. +- This does not scroll a score, detect a beginning or Fine location, or invent a bar/section target. The next action stays target-agnostic until a future validated contract carries that association. + +## Security Notes + +- Attack surface: untrusted song JSON, `dcAlFine.label`, and section labels rendered as React text. +- Trust boundary: lexical admission requires an own `label` property before display; inherited, extra-keyed, or malformed values fail closed. +- Mitigations: bounded ASCII Gould tokens; one-shot copy interpolation so rehearsal values are never rescanned as template syntax; no filesystem, network, subprocess, model, or telemetry path added. +- Tests: D.C. al Fine / D.C. al Fine 1–9 admission, inherited/extra-key/malformed/sibling-navigation rejection, target-agnostic jump planning, particle-safe copy fill, and demo D.C. al Fine plan. +- Dependency / supply-chain: none. Inherited protected-base npm HIGH findings belong to #783 and are not suppressed here. +- i18n: English and Korean copy name the D.C. al Fine action without asserting unverified beginning or Fine destination sections. + +## Primary standard + +MakeMusic. (2021). *MusicXML 4.0* (`words` / `sound` with da capo and fine). W3C Music Notation Community Group. https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/sound/ + +Gould, E. (2011). *Behind bars: The definitive guide to music notation*. Faber Music. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..7bb9006b9 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -218,11 +218,17 @@ export type ScoreAttachment = { fileName: string; }; +/** Documented. */ +export type RehearsalDcAlFine = { + label: string; +}; + /** Documented. */ export type RehearsalSong = { id: string; title: string; tempo?: number; + dcAlFine?: RehearsalDcAlFine; sections: RehearsalSection[]; exportSummary: ExportSummary; collaboration?: RehearsalCollaboration; @@ -432,6 +438,9 @@ const demoRehearsalSongSeed: RehearsalSong = { id: "demo-song", title: "Late Night Set", tempo: 120, + dcAlFine: { + label: "D.C. al Fine" + }, sections: [ { id: "verse-1", @@ -1755,6 +1764,25 @@ function migrateLegacySectionTimeRanges(value: unknown): unknown { return migrated; } +/** Documented. */ +function validateRehearsalDcAlFine(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + const extraKey = unexpectedKey(value, ["label"], path); + if (extraKey) { + return extraKey; + } + if (typeof value.label !== "string") { + return invalidField(`${path}.label`); + } + if (!/^D\.C\. al Fine(?: [1-9])?$/u.test(value.label)) { + return invalidField(`${path}.label`); + } + + return null; +} + /** Documented. */ function validateScoreAttachment(value: unknown, path: string): string | null { if (!isRecord(value)) { @@ -1787,7 +1815,7 @@ function validateRehearsalSong( } const extraKey = unexpectedKey( normalized, - ["id", "title", "tempo", "sections", "exportSummary", "collaboration", "scoreAttachments"], + ["id", "title", "tempo", "dcAlFine", "sections", "exportSummary", "collaboration", "scoreAttachments"], "" ); if (extraKey) { @@ -1805,6 +1833,12 @@ function validateRehearsalSong( ) { return invalidField("tempo"); } + if (normalized.dcAlFine !== undefined) { + const dcAlFineError = validateRehearsalDcAlFine(normalized.dcAlFine, "dcAlFine"); + if (dcAlFineError) { + return dcAlFineError; + } + } if (!isDenseArray(normalized.sections)) { return invalidField("sections"); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..d5f37ed7b 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -1023,6 +1023,49 @@ describe("shared type helpers", () => { expect(() => parseRehearsalSong(invalidTempoInfinity)).toThrow("tempo"); }); + it("validates dcAlFine correctly", () => { + const validSong = createDemoRehearsalSong(); + expect(validSong.dcAlFine).toEqual({ label: "D.C. al Fine" }); + expect(isRehearsalSong(validSong)).toBe(true); + + validSong.dcAlFine = { label: "D.C. al Fine 2" }; + expect(isRehearsalSong(validSong)).toBe(true); + expect(parseRehearsalSong(validSong).dcAlFine).toEqual({ label: "D.C. al Fine 2" }); + + const withoutDcAlFine = createDemoRehearsalSong(); + delete withoutDcAlFine.dcAlFine; + expect(isRehearsalSong(withoutDcAlFine)).toBe(true); + expect(parseRehearsalSong(withoutDcAlFine)).toEqual(withoutDcAlFine); + + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: "D.C. al Fine" })).toThrow( + "dcAlFine" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: 1 } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "d.c. al fine" } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "Da Capo" } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "Fine" } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "D.S. al Fine" } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "D.C. al Coda" } })).toThrow( + "dcAlFine.label" + ); + expect(() => parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "D.C." } })).toThrow( + "dcAlFine.label" + ); + expect(() => + parseRehearsalSong({ ...createDemoRehearsalSong(), dcAlFine: { label: "D.C. al Fine", extra: true } }) + ).toThrow("dcAlFine.extra"); + }); + it("validates practiceProgress successfully when valid", () => { const validPracticeProgressSong = createDemoRehearsalSong(); validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 0; diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..b7b5826f0 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -154,12 +154,19 @@ class ExportSummaryPayload(TypedDict): focusSections: list[str] +class RehearsalDcAlFinePayload(TypedDict): + """Typed trusted D.C. al Fine payload nested inside rehearsal songs.""" + + label: str + + class RehearsalSong(TypedDict): """Typed rehearsal song payload returned by the bootstrap engine.""" id: str title: str tempo: NotRequired[int] + dcAlFine: NotRequired[RehearsalDcAlFinePayload] sections: list[RehearsalSectionPayload] exportSummary: ExportSummaryPayload @@ -1193,6 +1200,8 @@ def run_analysis_job_updates( ) result = build_demo_rehearsal_song(audio_features) + if request["sourceKind"] == "demo": + result["dcAlFine"] = {"label": "D.C. al Fine"} updates.append( _build_job_status( job_id=job_id, diff --git a/services/analysis-engine/tests/test_dc_al_fine_contract.py b/services/analysis-engine/tests/test_dc_al_fine_contract.py new file mode 100644 index 000000000..65a76140d --- /dev/null +++ b/services/analysis-engine/tests/test_dc_al_fine_contract.py @@ -0,0 +1,50 @@ +"""Regression tests for D.C. al Fine propagation through the analysis API.""" + +from unittest.mock import patch + +from bandscope_analysis.api import run_analysis_job + + +def test_demo_analysis_returns_trusted_dc_al_fine() -> None: + """Keep native demo analysis aligned with the desktop demo song contract.""" + status = run_analysis_job( + "job-demo-dc-al-fine", + { + "sourceKind": "demo", + "sourceLabel": "Late Night Set", + "roleFocus": ["bass-guitar", "lead-vocal"], + }, + "2026-08-31T00:00:00Z", + ) + + result = status.get("result") + assert status["state"] == "succeeded" + assert result is not None + assert result.get("dcAlFine") == {"label": "D.C. al Fine"} + + +def test_local_audio_fallback_does_not_invent_dc_al_fine() -> None: + """Never fabricate notation marks when local audio has no trusted score metadata.""" + request = { + "sourceKind": "local_audio", + "projectId": "project-no-score-mark", + "sourceLabel": "plain-song.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/tmp/plain-song.wav", + "fileName": "plain-song.wav", + "extension": "wav", + "fileSizeBytes": 4096, + }, + } + + with patch( + "bandscope_analysis.api._build_local_audio_features", + side_effect=RuntimeError("separator unavailable"), + ): + status = run_analysis_job("job-local-no-dc-al-fine", request, "2026-08-31T00:00:00Z") + + result = status.get("result") + assert status["state"] == "succeeded" + assert result is not None + assert "dcAlFine" not in result