diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..f980c5fbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## 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. +- Name tonight's first fermata plan with the owning vocal or bass when existing beat times report an isolated extra-duration hold that tempo-stability ignores as a single IBI outlier, the owned `fermataPlan` copy, the labeled section, and the time so the next action is Open on the map. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, accelerando plans, ritardando plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, double-time feel flips, half-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Heuristic demo topology stays unnamed. - 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..c3e9ee4c8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - 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 + - tonight's first fermata plan on the mounted map when existing beat times report an isolated extra-duration hold (about 1.75–3.5× the local median pulse, extra 0.25–8 s) that tempo-stability ignores as a single IBI outlier, landing on the highest-priority active named vocal or bass in the section that contains the hold. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-accelerando, first-ritardando, first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, first-cutoff, first-pickup, and first-turnaround. This is not a new MIR product. - 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..8da86f85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first fermata plan in the mounted rehearsal workspace so the vocal or bass that holds through an isolated extra beat can open that landing on the map; real analyzed songs now receive this guidance only when existing beat times report a single extra-duration hold that tempo-stability ignores as an IBI outlier, while heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - 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..ed8179b9a 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 mounted workspace names tonight's first fermata plan and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, accelerando plans, ritardando plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, double-time feel flips, half-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-accelerando, first-ritardando, first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, and first-cutoff. `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..7c8e2b99c 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,13 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum FermataPlanSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -191,6 +198,12 @@ pub struct RehearsalRolePayload { setup_note: String, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + fermata_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fermata_plan_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fermata_plan_at_seconds: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +540,41 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn validate_fermata_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role.fermata_plan.as_ref().is_some_and(|fermata_plan| { + fermata_plan.trim().is_empty() + || fermata_plan.contains('\n') + || fermata_plan.contains('\r') + }) { + return Err("Invalid project file format".to_string()); + } + if role.fermata_plan.is_none() && role.fermata_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + if role.fermata_plan.is_some() && role.fermata_plan_source.is_none() { + return Err("Invalid project file format".to_string()); + } + if role + .fermata_plan_at_seconds + .is_some_and(|at_seconds| !at_seconds.is_finite() || at_seconds < 0.0) + { + return Err("Invalid project file format".to_string()); + } + if role.fermata_plan.is_none() && role.fermata_plan_at_seconds.is_some() { + return Err("Invalid project file format".to_string()); + } + } + } + Ok(payload) +} + pub fn project_payload_from_content(content: &str) -> Result { if let Ok(parsed) = serde_json::from_str::(content) { - return Ok(parsed); + return validate_fermata_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +592,9 @@ pub fn project_payload_from_content(content: &str) -> Result Value { + json!({ + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Lifted chorus downbeat", + "timeRange": { "start": 0, "end": 16 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "An isolated beat-gap hold corroborates the fermata." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Wait for the cutoff before the next entrance." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays through the held landing." + }, + "rehearsalPriority": "high", + "simplification": "Lean into the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "fermataPlan": "Hold this part through the extra 1 s; wait for the cutoff before the next entrance.", + "fermataPlanSource": "model", + "fermataPlanAtSeconds": 11.25 + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Hold the chorus fermata until the cutoff.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_fermata_plan_provenance() { + let payload = song_with_fermata_plan(); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project contract must accept shared fermata-plan fields"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["fermataPlan"], + payload["sections"][0]["roles"][0]["fermataPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["fermataPlanSource"], + json!("model") + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["fermataPlanAtSeconds"], + json!(11.25) + ); +} + +#[test] +fn project_contract_rejects_fermata_plan_source_without_fermata_plan() { + let mut payload = song_with_fermata_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fermataPlan"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance without the value it describes" + ); +} + +#[test] +fn project_contract_rejects_fermata_plan_without_source() { + let mut payload = song_with_fermata_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fermataPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject fermata-plan copy without provenance" + ); +} + +#[test] +fn project_contract_rejects_invalid_fermata_plan_copy_with_source() { + for fermata_plan in ["", " ", "hold here\nthen cut", "hold here\rthen cut"] { + let mut payload = song_with_fermata_plan(); + payload["sections"][0]["roles"][0]["fermataPlan"] = json!(fermata_plan); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject blank or multiline sourced fermata-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_fermata_plan_source() { + let mut payload = song_with_fermata_plan(); + payload["sections"][0]["roles"][0]["fermataPlanSource"] = json!("legacy"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance outside model/user" + ); +} + +#[test] +fn project_contract_rejects_invalid_fermata_plan_timestamp() { + let mut payload = song_with_fermata_plan(); + payload["sections"][0]["roles"][0]["fermataPlanAtSeconds"] = json!(-1.0); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject negative fermata-plan timestamps" + ); +} diff --git a/apps/desktop/src/features/workspace/FirstFermataCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstFermataCallout.particle.test.tsx new file mode 100644 index 000000000..7d509ad2b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFermataCallout.particle.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFermataCallout } from "./FirstFermataCallout"; + +function songWithKoreanAccel() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.roles = [ + { + ...verse.roles[0]!, + id: "piano-vocal", + name: "피아노", + roleType: "vocal", + rehearsalPriority: "high", + fermataPlan: + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance.", + fermataPlanSource: "model", + fermataPlanAtSeconds: 11.25 + } + ]; + verse.partGraph = [ + { role_id: "piano-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + return song; +} + +describe("FirstFermataCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the fermata action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanAccel(); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:11 벌스에서 피아노 파트가 페르마타를 붙잡습니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:11 피아노 페르마타 열기" })); + + expect( + screen.getByText("0:11에서 피아노 파트로 함께 붙잡으세요. 끊을 신호까지 기다리세요.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFermataCallout.test.tsx b/apps/desktop/src/features/workspace/FirstFermataCallout.test.tsx new file mode 100644 index 000000000..08466b378 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFermataCallout.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFermataCallout } from "./FirstFermataCallout"; + +const DEMO_FERMATA_PLAN = + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance."; +const appendedSongStructureTargets = new Set(); + +function songWithFermataPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = verse.roles.find((role) => role.id === "lead-vocal")!; + vocal.fermataPlan = DEMO_FERMATA_PLAN; + vocal.fermataPlanSource = "model"; + vocal.fermataPlanAtSeconds = 11.25; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstFermataCallout", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "No fermata plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithFermataPlan(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithFermataPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText( + "No fermata plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("opens the named fermata on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + expect(screen.getByText(DEMO_FERMATA_PLAN)).toBeTruthy(); + }); + + it("shows armed confirmation for user-sourced plans without rewriting user copy", () => { + const song = songWithFermataPlan(); + const userPlan = "Hold here exactly as our band agreed."; + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + vocal.fermataPlan = userPlan; + vocal.fermataPlanSource = "user"; + appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + expect(screen.getByText(userPlan)).toBeTruthy(); + }); + + it("reports when the map section cannot be opened", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + + expect( + screen.getByText("Could not open this fermata on the song map. Use the map below to find the section.") + ).toBeTruthy(); + }); + + it("uses immediate scrolling when reduced motion is requested", () => { + const { scrollIntoView } = appendSongStructureTarget(); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); + + it("resets armed guidance when accessor-id songs change with the same fermata signature", () => { + const firstSong = songWithFermataPlan(); + const nextSong = songWithFermataPlan(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal holds the verse fermata at 0:11.")).toBeTruthy(); + expect( + screen.queryByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithFermataPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.name = "Lead Singer"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + + rerender( + + ); + + expect(screen.getByText("Lead Singer holds the verse fermata at 0:11.")).toBeTruthy(); + expect( + screen.queryByText(/Hold Lead Singer together at 0:11 until the cutoff./) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFermataCallout.tsx b/apps/desktop/src/features/workspace/FirstFermataCallout.tsx new file mode 100644 index 000000000..7be3021d7 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFermataCallout.tsx @@ -0,0 +1,217 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { + formatFermataPlanTime, + resolveFirstFermataPlan, + type FermataPlanGuidance +} from "./firstFermata"; + +/** Props for the first fermata-plan rehearsal callout. */ +export interface FirstFermataCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type FermataPlanCopyValues = Readonly>; +type FermataPlanSource = "model" | "user"; + +type OpenedFermataPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + fermataPlan: string; + fermataPlanSource: FermataPlanSource | null; + holdSeconds: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableFermataPlanSongIdentity( + song: RehearsalSong, + workspaceInstanceKey: unknown +): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate fermata-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatFermataPlanCopy(template: string, values: FermataPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof FermataPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model fermata guidance from structured tempo tokens, never from display-copy grammar. */ +function localizedFermataPlan( + fermataPlan: string, + fermataPlanSource: FermataPlanSource | null, + guidance: FermataPlanGuidance | null, + generatedTemplate: string +): string { + if (fermataPlanSource !== "model" || guidance === null) { + return fermataPlan; + } + return generatedTemplate.replace("{hold}", () => guidance.holdSeconds); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredFermataPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveFermataPlanRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first fermata plan and open the matching rendered map section. */ +export function FirstFermataCallout({ + song, + workspaceInstanceKey +}: FirstFermataCalloutProps) { + const calloutId = `workspace-surface-fermata-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableFermataPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstFermataPlan(song), [song]); + const [openedFermataPlan, setOpenedFermataPlan] = useState( + null + ); + const [navigationFailed, setNavigationFailed] = useState(false); + const holdSeconds = named?.fermataPlanGuidance?.holdSeconds ?? null; + + useEffect(() => { + setOpenedFermataPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.fermataPlan, + named?.fermataPlanSource, + holdSeconds, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedFermataPlan !== null && + openedFermataPlan.songIdentity === songIdentity && + openedFermataPlan.sectionId === named.sectionId && + openedFermataPlan.sectionIndex === named.sectionIndex && + openedFermataPlan.sectionLabel === named.sectionLabel && + openedFermataPlan.landingRoleId === named.landingRoleId && + openedFermataPlan.landingRoleName === named.landingRoleName && + openedFermataPlan.fermataPlan === named.fermataPlan && + openedFermataPlan.fermataPlanSource === named.fermataPlanSource && + openedFermataPlan.holdSeconds === holdSeconds && + openedFermataPlan.atSeconds === named.atSeconds; + const at = formatFermataPlanTime(named.atSeconds); + const copyValues: FermataPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatFermataPlanCopy(t("firstFermataPlanOpenAction"), copyValues); + const body = formatFermataPlanCopy(t("firstFermataPlanBody"), copyValues); + const armed = formatFermataPlanCopy(t("firstFermataPlanArmed"), copyValues); + const fermataPlan = localizedFermataPlan( + named.fermataPlan, + named.fermataPlanSource, + named.fermataPlanGuidance, + t("firstFermataPlanGeneratedGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.fermata-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fermata-state.test.tsx new file mode 100644 index 000000000..f76dd8055 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.fermata-state.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView" +); + +function analyzedSongWithFermataPlan(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = verse.roles.find((role) => role.id === "lead-vocal")!; + vocal.fermataPlan = + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance."; + vocal.fermataPlanSource = "model"; + vocal.fermataPlanAtSeconds = 11.25; + return song; +} + +describe("Workspace fermata state authority", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + }); + + it("keeps an opened fermata armed after an immutable practice-progress update", () => { + const song = analyzedSongWithFermataPlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fermata at 0:11" })); + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect( + screen.getByText(/Hold Lead Vocal together at 0:11 until the cutoff./) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..61015993b 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, useRef, memo, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { FirstFermataCallout } from "./FirstFermataCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -122,6 +127,18 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const localSongUpdateRef = useRef(null); + const workspaceInstanceRef = useRef(song); + const previousSongRef = useRef(song); + + if (song !== previousSongRef.current) { + const isLocalWorkspaceUpdate = song === localSongUpdateRef.current; + if (!isLocalWorkspaceUpdate) { + workspaceInstanceRef.current = song; + } + localSongUpdateRef.current = null; + previousSongRef.current = song; + } // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -164,6 +181,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ) : t("workspaceFirstRangeMissing"); + /** Preserve workspace-instance authority for immutable edits emitted by this workspace. */ + const commitSongUpdate = (nextSong: RehearsalSong) => { + if (!onSongUpdate) return; + localSongUpdateRef.current = nextSong; + onSongUpdate(nextSong); + }; + /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { if (!activeRole || !onSongUpdate) return; @@ -188,7 +212,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) }; - onSongUpdate(nextSong); + commitSongUpdate(nextSong); }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), @@ -309,6 +333,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
@@ -505,7 +530,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/firstFermata.test.ts b/apps/desktop/src/features/workspace/firstFermata.test.ts new file mode 100644 index 000000000..fdb65802d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFermata.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatFermataPlanTime, resolveFirstFermataPlan } from "./firstFermata"; + +const DEMO_FERMATA_PLAN = + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance."; + +function withFermataSection( + overrides: { + id?: string; + start?: number; + end?: number; + fermataPlan?: string; + source?: "model" | "user"; + fermataPlanAtSeconds?: number; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + roleType?: "instrument" | "vocal" | "hand"; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 0; + const roleId = overrides.roleId ?? "lead-vocal"; + const vocal = structuredClone(verse.roles.find((role) => role.id === "lead-vocal")!); + const bass = structuredClone(verse.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(verse.roles.find((role) => role.id === "keys-right")!); + const landing = { + ...(roleId === "bass-guitar" ? bass : roleId === "keys-right" ? keys : vocal), + id: roleId, + name: + overrides.roleName ?? + (roleId === "bass-guitar" + ? "Bass Guitar" + : roleId === "keys-right" + ? "Keyboard 1 Right Hand" + : "Lead Vocal"), + roleType: overrides.roleType ?? (roleId === "lead-vocal" ? "vocal" : "instrument"), + rehearsalPriority: overrides.priority ?? "high", + fermataPlan: overrides.fermataPlan ?? DEMO_FERMATA_PLAN, + ...(overrides.source ? { fermataPlanSource: overrides.source } : { fermataPlanSource: "model" as const }), + fermataPlanAtSeconds: overrides.fermataPlanAtSeconds ?? landingStart + }; + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-fermata"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 16 }; + current.roles = [landing, bass, keys]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { + role_id: "lead-vocal", + is_active: roleId === "lead-vocal" ? (overrides.isActive ?? true) : true, + handoff_to: [], + handoff_from: [] + } + ]; + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.roles = [landing, vocal, keys]; + } + if (roleId === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.roles = [landing, vocal, bass]; + } + song.sections = [current]; + return song; +} + +describe("resolveFirstFermataPlan", () => { + it("picks the earliest fermata plan and the named vocal that owns it", () => { + const resolved = resolveFirstFermataPlan(withFermataSection()); + expect(resolved?.section.id).toBe("chorus-fermata"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.fermataPlan).toBe(DEMO_FERMATA_PLAN); + expect(resolved?.atSeconds).toBe(0); + expect(formatFermataPlanTime(resolved?.atSeconds ?? -1)).toBe("0:00"); + expect(formatFermataPlanTime(Number.NaN)).toBe("0:00"); + expect(formatFermataPlanTime(-4)).toBe("0:00"); + }); + + it("uses the engine hold timestamp instead of the section opening", () => { + const resolved = resolveFirstFermataPlan( + withFermataSection({ fermataPlanAtSeconds: 7.25 }) + ); + + expect(resolved?.atSeconds).toBe(7.25); + }); + + it("does not invent an fermata plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, or confidence notes", () => { + const song = withFermataSection(); + delete song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.fermataPlan; + const landing = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + landing.simplification = "Stay on roots if the chorus entrance gets muddy."; + landing.setupNote = DEMO_FERMATA_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + landing.cue = { kind: "transition", value: DEMO_FERMATA_PLAN }; + landing.confidence.notes = DEMO_FERMATA_PLAN; + expect(resolveFirstFermataPlan(song)).toBeNull(); + }); + + it("leaves the heuristic demo unnamed", () => { + expect(resolveFirstFermataPlan(createDemoRehearsalSong())).toBeNull(); + }); + + it("does not let accompaniment own the fermata", () => { + expect( + resolveFirstFermataPlan( + withFermataSection({ roleId: "keys-right", roleType: "hand" }) + ) + ).toBeNull(); + }); + + it("ignores inactive named parts", () => { + expect(resolveFirstFermataPlan(withFermataSection({ isActive: false }))).toBeNull(); + }); + + it("prefers a named vocal over bass at the same priority", () => { + const song = withFermataSection(); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.fermataPlan = DEMO_FERMATA_PLAN; + bass.fermataPlanSource = "model"; + bass.fermataPlanAtSeconds = 0; + bass.rehearsalPriority = "high"; + expect(resolveFirstFermataPlan(song)?.landingRoleId).toBe("lead-vocal"); + }); + + it("prefers the higher-priority named part", () => { + const song = withFermataSection({ priority: "low" }); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.fermataPlan = DEMO_FERMATA_PLAN; + bass.fermataPlanSource = "model"; + bass.fermataPlanAtSeconds = 0; + bass.rehearsalPriority = "high"; + expect(resolveFirstFermataPlan(song)?.landingRoleId).toBe("bass-guitar"); + }); + + it("picks the earlier section when two fermatas are named", () => { + const earlier = withFermataSection({ id: "earlier-fermata", start: 0 }); + const later = withFermataSection({ id: "later-fermata", start: 16 }); + const song = earlier; + song.sections = [...earlier.sections, ...later.sections]; + expect(resolveFirstFermataPlan(song)?.sectionId).toBe("earlier-fermata"); + }); + + it("rejects blank, multiline, or non-template model copy", () => { + expect(resolveFirstFermataPlan(withFermataSection({ fermataPlan: " " }))).toBeNull(); + expect( + resolveFirstFermataPlan( + withFermataSection({ fermataPlan: "Ease together.\nHold the count." }) + ) + ).toBeNull(); + expect( + resolveFirstFermataPlan(withFermataSection({ fermataPlan: "hold forever" })) + ).toBeNull(); + }); + + it("rejects model copy that is not a genuine isolated extra-hold", () => { + expect( + resolveFirstFermataPlan( + withFermataSection({ + fermataPlan: + "Hold this part through the extra 0 s; wait for the cutoff before the next entrance." + }) + ) + ).toBeNull(); + expect( + resolveFirstFermataPlan( + withFermataSection({ + fermataPlan: + "Hold this part through the extra 12 s; wait for the cutoff before the next entrance." + }) + ) + ).toBeNull(); + expect( + resolveFirstFermataPlan( + withFermataSection({ + fermataPlan: + "Push this part from 80 BPM into 120 BPM; let the next downbeat arrive sooner." + }) + ) + ).toBeNull(); + expect(resolveFirstFermataPlan(withFermataSection())?.fermataPlan).toBe( + DEMO_FERMATA_PLAN + ); + }); + + it("admits bounded user copy without requiring the engine template", () => { + const resolved = resolveFirstFermataPlan( + withFermataSection({ + fermataPlan: "Hold the last chord until the cut.", + source: "user" + }) + ); + expect(resolved?.fermataPlan).toBe("Hold the last chord until the cut."); + expect(resolved?.fermataPlanSource).toBe("user"); + }); + + it("fails closed on a malformed runtime song root", () => { + expect(resolveFirstFermataPlan(null as never)).toBeNull(); + }); + + it("fails closed on inherited or accessor-backed plan copy", () => { + const song = withFermataSection(); + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + delete vocal.fermataPlan; + Object.defineProperty(vocal, "fermataPlan", { + configurable: true, + enumerable: true, + get() { + return DEMO_FERMATA_PLAN; + } + }); + expect(resolveFirstFermataPlan(song)).toBeNull(); + }); + + it("fails closed when model provenance or its timestamp is missing", () => { + const missingSource = withFermataSection(); + delete missingSource.sections[0]!.roles[0]!.fermataPlanSource; + expect(resolveFirstFermataPlan(missingSource)).toBeNull(); + + const missingTimestamp = withFermataSection(); + delete missingTimestamp.sections[0]!.roles[0]!.fermataPlanAtSeconds; + expect(resolveFirstFermataPlan(missingTimestamp)).toBeNull(); + }); + + it("fails closed when the engine hold timestamp is outside its section", () => { + expect( + resolveFirstFermataPlan(withFermataSection({ fermataPlanAtSeconds: 16 })) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFermata.ts b/apps/desktop/src/features/workspace/firstFermata.ts new file mode 100644 index 000000000..43ecf28dd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFermata.ts @@ -0,0 +1,433 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_FERMATA_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const NAMED_FERMATA_ROLE_IDS = new Set(["bass-guitar", "lead-vocal"]); +const FERMATA_PLAN_PREFIX = "Hold this part through the extra "; +const FERMATA_PLAN_SUFFIX = " s; wait for the cutoff before the next entrance."; +const MIN_FERMATA_HOLD_SECONDS = 0.25; +const MAX_FERMATA_HOLD_SECONDS = 8; + +type FermataPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated fermata-plan copy. */ +export type FermataPlanGuidance = Readonly<{ + kind: "hold"; + holdSeconds: string; +}>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; + isVocal: boolean; +}>; + +type OwnedFermataPlan = Readonly<{ + text: string; + source: FermataPlanSource; + guidance: FermataPlanGuidance | null; + atSeconds: number | null; +}>; + +/** Tonight's first fermata plan: the earliest isolated beat-gap hold on a named vocal or bass. */ +export type FirstFermataPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + fermataPlan: string; + fermataPlanSource: FermataPlanSource; + fermataPlanGuidance: FermataPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative fermata-plan time as m:ss for rehearsal copy. */ +export function formatFermataPlanTime(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}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Preserve the engine fermata template while enforcing isolated extra-hold semantics. */ +function boundedGeneratedFermataPlan( + value: string, + atSeconds: number +): OwnedFermataPlan | null { + if (!value.startsWith(FERMATA_PLAN_PREFIX) || !value.endsWith(FERMATA_PLAN_SUFFIX)) { + return null; + } + const holdSeconds = value.slice(FERMATA_PLAN_PREFIX.length, -FERMATA_PLAN_SUFFIX.length); + if (!/^\d+(?:\.\d+)?$/u.test(holdSeconds) || holdSeconds.includes("BPM")) { + return null; + } + const holdValue = Number(holdSeconds); + if ( + !Number.isFinite(holdValue) || + holdValue < MIN_FERMATA_HOLD_SECONDS || + holdValue > MAX_FERMATA_HOLD_SECONDS + ) { + return null; + } + return { + text: `${FERMATA_PLAN_PREFIX}${holdSeconds}${FERMATA_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "hold", holdSeconds }, + atSeconds + }; +} + +/** Return a bounded snapshotted own fermata plan and its explicit provenance, or null when malformed. */ +function ownedFermataPlan(role: unknown): OwnedFermataPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const fermataPlan = ownDataValue(role, "fermataPlan"); + const fermataPlanSource = ownDataValue(role, "fermataPlanSource"); + const fermataPlanAtSeconds = ownDataValue(role, "fermataPlanAtSeconds"); + if (typeof fermataPlan !== "string") { + return null; + } + if ( + fermataPlanSource !== "model" && + fermataPlanSource !== "user" + ) { + return null; + } + if ( + fermataPlanAtSeconds !== undefined && + (typeof fermataPlanAtSeconds !== "number" || + !Number.isFinite(fermataPlanAtSeconds) || + fermataPlanAtSeconds < 0) + ) { + return null; + } + const atSeconds = fermataPlanAtSeconds === undefined ? null : fermataPlanAtSeconds; + const trimmed = fermataPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (fermataPlanSource === "model") { + return atSeconds === null ? null : boundedGeneratedFermataPlan(trimmed, atSeconds); + } + return { + text: truncateCodePoints(trimmed, MAX_FERMATA_PLAN_CHARACTERS), + source: fermataPlanSource, + guidance: null, + atSeconds + }; +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const roleType = ownDataValue(role, "roleType"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + if (!NAMED_FERMATA_ROLE_IDS.has(id) && roleType !== "vocal") { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK, + isVocal: roleType === "vocal" || id === "lead-vocal" + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** 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; +} + +/** Rank vocal roles before instrumental roles. */ +function vocalRank(role: RankedRoleMetadata): number { + return role.isVocal ? 0 : 1; +} + +/** Prefer rehearsal priority, then a named vocal, then a locale-independent stable id. */ +function pickLandingRole(roles: Role[]): Role | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + const vocalDelta = vocalRank(left) - vocalRank(right); + if (vocalDelta !== 0) { + return vocalDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return unique graph role ids whose node is explicitly active. */ +function rankedActiveRoleIds(section: RehearsalSection): Set { + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!partGraph) { + return new Set(); + } + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + return new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); +} + +/** Resolve an fermata plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstFermataPlan(song: RehearsalSong): FirstFermataPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const activeIds = rankedActiveRoleIds(section as RehearsalSection); + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + if (!roles) { + return []; + } + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const landingRole = pickLandingRole( + roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + if ( + metadata === null || + repeatedRoleIds.has(metadata.id) || + !activeIds.has(metadata.id) + ) { + return []; + } + const fermataPlan = ownedFermataPlan(metadata.role); + return fermataPlan === null + ? [] + : [ + { + ...metadata, + fermataPlan: fermataPlan.text, + fermataPlanSource: fermataPlan.source, + fermataPlanGuidance: fermataPlan.guidance, + atSeconds: fermataPlan.atSeconds + } + ]; + }) + ); + if (!landingRole) { + return []; + } + const atSeconds = landingRole.atSeconds ?? timeRange.start; + if (atSeconds < timeRange.start || atSeconds >= timeRange.end) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + fermataPlan: landingRole.fermataPlan, + fermataPlanSource: landingRole.fermataPlanSource, + fermataPlanGuidance: landingRole.fermataPlanGuidance, + atSeconds + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named fermata plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstFermataPlan(song: RehearsalSong): FirstFermataPlan | null { + try { + return resolveSafeFirstFermataPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..e0c767882 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,12 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes supported section labels and fails closed on unknown labels", () => { + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("en", "not-a-section" as never)).toBe("not-a-section"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 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,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(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..b2ec3b636 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,12 @@ "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}.", + "firstFermataPlanLabel": "Tonight's first fermata plan", + "firstFermataPlanOpenAction": "Open {role} fermata at {at}", + "firstFermataPlanBody": "{role} holds the {section} fermata at {at}.", + "firstFermataPlanArmed": "Hold {role} together at {at} until the cutoff.", + "firstFermataPlanGeneratedGuidance": "Hold this part through the extra {hold} s; wait for the cutoff before the next entrance.", + "firstFermataPlanUnavailable": "No fermata plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstFermataPlanNavigationFailed": "Could not open this fermata on the song map. Use the map below to find the section." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..b0ae52e79 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,12 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstFermataPlanLabel": "오늘 첫 페르마타 계획", + "firstFermataPlanOpenAction": "{at} {role} 페르마타 열기", + "firstFermataPlanBody": "{at} {section}에서 {role} 파트가 페르마타를 붙잡습니다.", + "firstFermataPlanArmed": "{at}에서 {role} 파트로 함께 붙잡으세요. 끊을 신호까지 기다리세요.", + "firstFermataPlanGeneratedGuidance": "이 파트를 {hold}초 더 붙잡으세요. 다음 입장 전에 끊을 신호를 기다리세요.", + "firstFermataPlanUnavailable": "사용 가능한 페르마타 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstFermataPlanNavigationFailed": "곡 맵에서 이 페르마타를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..d09c9bc84 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -143,6 +143,9 @@ export type RehearsalRole = { overlapWarnings: string[]; transcription?: TranscriptionNote[]; practiceProgress?: number; + fermataPlan?: string; + fermataPlanSource?: ProvenanceSource; + fermataPlanAtSeconds?: number; }; /** Documented. */ @@ -1500,7 +1503,10 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "fermataPlan", + "fermataPlanSource", + "fermataPlanAtSeconds" ], path ); @@ -1588,6 +1594,37 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.fermataPlan !== undefined && + (typeof value.fermataPlan !== "string" || + value.fermataPlan.trim().length === 0 || + value.fermataPlan.includes("\n") || + value.fermataPlan.includes("\r")) + ) { + return invalidField(`${path}.fermataPlan`); + } + if ( + value.fermataPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.fermataPlanSource) + ) { + return invalidField(`${path}.fermataPlanSource`); + } + if (value.fermataPlanSource !== undefined && value.fermataPlan === undefined) { + return invalidField(`${path}.fermataPlanSource`); + } + if (value.fermataPlan !== undefined && value.fermataPlanSource === undefined) { + return invalidField(`${path}.fermataPlanSource`); + } + if ( + value.fermataPlanAtSeconds !== undefined && + (typeof value.fermataPlanAtSeconds !== "number" || + !Number.isFinite(value.fermataPlanAtSeconds) || + value.fermataPlanAtSeconds < 0 || + value.fermataPlan === undefined) + ) { + return invalidField(`${path}.fermataPlanAtSeconds`); + } + return null; } diff --git a/packages/shared-types/test/fermataPlanProvenance.test.ts b/packages/shared-types/test/fermataPlanProvenance.test.ts new file mode 100644 index 000000000..ee94c30eb --- /dev/null +++ b/packages/shared-types/test/fermataPlanProvenance.test.ts @@ -0,0 +1,66 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +const DEMO_FERMATA_PLAN = + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance."; + +describe("fermataPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s fermata plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fermataPlan = DEMO_FERMATA_PLAN; + role.fermataPlanSource = source; + role.fermataPlanAtSeconds = 11.25; + const parsed = parseRehearsalSong(song).sections[0]!.roles[0]!; + expect(parsed.fermataPlanSource).toBe(source); + expect(parsed.fermataPlanAtSeconds).toBe(11.25); + }); + + it("rejects an unknown fermata plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fermataPlan = DEMO_FERMATA_PLAN; + role.fermataPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/fermataPlanSource/); + }); + + it("rejects an fermata plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.fermataPlan; + role.fermataPlanSource = "model"; + role.fermataPlanAtSeconds = 11.25; + expect(() => parseRehearsalSong(song)).toThrow(/fermataPlanSource/); + }); + + it("rejects fermata plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fermataPlan = DEMO_FERMATA_PLAN; + delete role.fermataPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/fermataPlanSource/); + }); + + it.each(["", " ", "push here\nthen hold", "push here\rthen hold"])( + "rejects an fermata plan source with blank or multiline copy %j", + (fermataPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fermataPlan = fermataPlan; + role.fermataPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/fermataPlan/); + } + ); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "11"])( + "rejects an invalid fermata plan timestamp %j", + (atSeconds) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fermataPlan = DEMO_FERMATA_PLAN; + role.fermataPlanSource = "model"; + role.fermataPlanAtSeconds = atSeconds as never; + expect(() => parseRehearsalSong(song)).toThrow(/fermataPlanAtSeconds/); + } + ); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..70b3d9d36 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -1257,6 +1257,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].fermataPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.fermataPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..6f83fc92d 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -19,11 +19,12 @@ from bandscope_analysis.sections import extract_sections from bandscope_analysis.sections.segmenter import segment_with_boundaries from bandscope_analysis.separation import AudioStemSeparator +from bandscope_analysis.temporal.fermata import apply_fermata_plan, derive_beat_times logger = logging.getLogger(__name__) MAX_SECTION_TIME_SECONDS = 4_294_967_295 -ANALYSIS_CACHE_SCHEMA_VERSION = 1 +ANALYSIS_CACHE_SCHEMA_VERSION = 2 FEATURE_CACHE_SCHEMA_VERSION = 1 STEM_SEPARATION_TIMEOUT_SECONDS = 20.0 @@ -116,6 +117,9 @@ class RehearsalRolePayload(TypedDict): setupNote: str manualOverrides: list[ManualOverridePayload] overlapWarnings: list[str] + fermataPlan: NotRequired[str] + fermataPlanSource: NotRequired[Literal["model", "user"]] + fermataPlanAtSeconds: NotRequired[float] class PartGraphNodePayload(TypedDict): @@ -456,6 +460,7 @@ def _build_from_pipeline( }, } _apply_tempo(song, features) + _apply_fermata(song, mix, sr, features, boundaries) return song @@ -518,6 +523,38 @@ def _apply_tempo(song: RehearsalSong, audio_features: dict[str, Any] | None) -> song["tempo"] = bpm +def _coerce_beat_times(audio_features: dict[str, Any] | None) -> list[float] | None: + """Return finite non-negative beat times from analysis features, or None.""" + if not audio_features: + return None + raw = audio_features.get("beat_times") + if not isinstance(raw, list) or not raw: + return None + times: list[float] = [] + for item in raw: + if isinstance(item, bool) or not isinstance(item, (int, float)): + return None + value = float(item) + if np.isnan(value) or np.isinf(value) or value < 0: + return None + times.append(value) + return times + + +def _apply_fermata( + song: RehearsalSong, + mix: Any, + sr: int, + audio_features: dict[str, Any] | None, + section_boundaries: list[tuple[float, float]] | None = None, +) -> None: + """Stamp tonight's first fermata from an isolated beat-gap hold.""" + beat_times = _coerce_beat_times(audio_features) + if beat_times is None: + beat_times = derive_beat_times(mix, sr) + apply_fermata_plan(song, beat_times, section_boundaries) + + def _reconstruct_mix(stems: dict[str, Any]) -> Any: """Reconstruct a mono mix from separated stems for segmentation.""" arrays = [] @@ -613,7 +650,7 @@ def _analysis_cache_path(request: AnalysisJobRequest) -> Path | None: digest = hashlib.sha256( json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() - return Path(cache_root) / "analysis-cache-v1" / f"{digest}.json" + return Path(cache_root) / "analysis-cache-v2" / f"{digest}.json" def _feature_cache_paths(request: AnalysisJobRequest) -> tuple[Path, Path] | None: diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..714e387fd 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -83,6 +83,9 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + fermataPlan: NotRequired[str] + fermataPlanSource: NotRequired[Literal["model", "user"]] + fermataPlanAtSeconds: NotRequired[float] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py b/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py index 104b82ec9..6ab66ff3a 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py @@ -1,6 +1,7 @@ """Temporal analysis module (audio decoding, tempo, beat tracking).""" from .analyzer import TemporalAnalyzer +from .fermata import apply_fermata_plan, fermata_plan_copy, first_fermata from .groove import GrooveResult, detect_groove from .model import TemporalFeatures from .stability import TempoChange, TempoStability, analyze_tempo_stability @@ -12,5 +13,8 @@ "TemporalAnalyzer", "TemporalFeatures", "analyze_tempo_stability", + "apply_fermata_plan", "detect_groove", + "fermata_plan_copy", + "first_fermata", ] diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/fermata.py b/services/analysis-engine/src/bandscope_analysis/temporal/fermata.py new file mode 100644 index 000000000..38d92c6da --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/temporal/fermata.py @@ -0,0 +1,354 @@ +"""Stamp tonight's first fermata plan from an isolated beat-gap hold. + +Tempo-stability already ignores a single outlier inter-beat interval so that +only sustained tempo changes are reported. A fermata is that residual: one +isolated hold that is longer than the local median pulse, then the pulse +resumes. The owned ``fermataPlan`` copy lands on the highest-priority active +named vocal or bass in the section that contains that hold. Heuristic/demo +topology stays unnamed. This is not a new MIR product: it only reads the +same beat times already owned by ``analyze_tempo_stability``. + +Security Notes: + Pure in-memory mutation of an already-built rehearsal song. Beat times and + song topology are untrusted runtime values: malformed numbers, missing + identity, repeated graph ids, or inactive parts fail closed instead of + inventing a plan. No file, network, or subprocess I/O. +""" + +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping, Sequence +from math import isfinite +from typing import Any, TypedDict + +from bandscope_analysis.temporal.stability import analyze_tempo_stability + +FERMATA_RATIO_MIN = 1.75 +FERMATA_RATIO_MAX = 3.5 +NEIGHBOR_RATIO_MAX = 1.2 +TEMPO_CHANGE_GUARD_SECONDS = 1.5 +MIN_HOLD_SECONDS = 0.25 +MAX_HOLD_SECONDS = 8.0 +NAMED_FERMATA_ROLE_IDS = frozenset({"bass-guitar", "lead-vocal"}) +PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2} +FERMATA_PLAN_PREFIX = "Hold this part through the extra " +FERMATA_PLAN_SUFFIX = " s; wait for the cutoff before the next entrance." + + +class FermataHold(TypedDict): + """An isolated beat-gap hold that is not a sustained tempo change.""" + + time: float + hold_seconds: float + + +def format_fermata_hold(value: float) -> str | None: + """Return a buyer-facing extra-hold token, or None when the value is unusable.""" + if not isfinite(value) or value < MIN_HOLD_SECONDS or value > MAX_HOLD_SECONDS: + return None + rounded = round(float(value), 1) + if rounded < MIN_HOLD_SECONDS or rounded > MAX_HOLD_SECONDS: + return None + if abs(rounded - round(rounded)) < 1e-9: + return str(int(round(rounded))) + return f"{rounded:.1f}" + + +def fermata_plan_copy(hold_seconds: float) -> str | None: + """Return the owned model fermata copy, or None when the hold token is unusable.""" + token = format_fermata_hold(hold_seconds) + if token is None: + return None + return f"{FERMATA_PLAN_PREFIX}{token}{FERMATA_PLAN_SUFFIX}" + + +def is_fermata_hold(hold: Mapping[str, Any]) -> bool: + """Return whether a candidate is an isolated extra-duration hold.""" + time = hold.get("time") + hold_seconds = hold.get("hold_seconds") + if not isinstance(time, (int, float)) or isinstance(time, bool): + return False + if not isinstance(hold_seconds, (int, float)) or isinstance(hold_seconds, bool): + return False + if not isfinite(time) or time < 0: + return False + if ( + not isfinite(hold_seconds) + or hold_seconds < MIN_HOLD_SECONDS + or hold_seconds > MAX_HOLD_SECONDS + ): + return False + return True + + +def _interval_near_median(interval: float, median: float) -> bool: + """Return whether one inter-beat interval sits close to the median pulse.""" + if median <= 0 or not isfinite(interval) or interval <= 0: + return False + ratio = interval / median + return ratio <= NEIGHBOR_RATIO_MAX + + +def first_fermata(beat_times: Sequence[float] | None) -> FermataHold | None: + """Return the earliest isolated hold, or None when none is corroborated.""" + if ( + beat_times is None + or not isinstance(beat_times, Sequence) + or isinstance(beat_times, (str, bytes)) + ): + return None + times: list[float] = [] + for item in beat_times: + if isinstance(item, bool) or not isinstance(item, (int, float)): + return None + value = float(item) + if not isfinite(value) or value < 0: + return None + if times and value <= times[-1]: + return None + times.append(value) + if len(times) < 8: + return None + intervals = [times[index + 1] - times[index] for index in range(len(times) - 1)] + ordered = sorted(intervals) + median = ordered[len(ordered) // 2] + try: + stability = analyze_tempo_stability(times) + change_times = [ + float(change["time"]) + for change in stability.get("tempo_changes", []) + if isinstance(change, Mapping) + and isinstance(change.get("time"), (int, float)) + and not isinstance(change.get("time"), bool) + and isfinite(change["time"]) + ] + except (TypeError, ValueError, KeyError, AttributeError): + change_times = [] + for index, interval in enumerate(intervals): + ratio = interval / median + if ratio < FERMATA_RATIO_MIN or ratio > FERMATA_RATIO_MAX: + continue + extra = interval - median + if extra < MIN_HOLD_SECONDS or extra > MAX_HOLD_SECONDS: + continue + previous_ok = index == 0 or _interval_near_median(intervals[index - 1], median) + next_ok = index + 1 >= len(intervals) or _interval_near_median(intervals[index + 1], median) + if not previous_ok or not next_ok: + continue + time = times[index] + if any( + abs(time - change_time) < TEMPO_CHANGE_GUARD_SECONDS for change_time in change_times + ): + continue + if not is_fermata_hold({"time": time, "hold_seconds": extra}): + continue + return {"time": float(time), "hold_seconds": float(extra)} + return None + + +def _role_type_value(role_type: Any) -> str: + """Normalize enum or string role types to a comparable token.""" + value = getattr(role_type, "value", role_type) + return value if isinstance(value, str) else "" + + +def _priority_value(priority: Any) -> str: + """Normalize enum or string rehearsal priority to a comparable token.""" + value = getattr(priority, "value", priority) + return value if isinstance(value, str) else "" + + +def _is_named_vocal_or_bass(role: Mapping[str, Any]) -> bool: + """Return whether a role is a named vocal or bass that may own a fermata.""" + role_id = role.get("id") + if not isinstance(role_id, str) or role_id.strip() == "": + return False + if role_id in NAMED_FERMATA_ROLE_IDS: + return True + return _role_type_value(role.get("roleType")) == "vocal" + + +def _repeated_ids(ids: list[str]) -> set[str]: + """Return ids that appear more than once in one section-local collection.""" + seen: set[str] = set() + repeated: set[str] = set() + for role_id in ids: + if role_id in seen: + repeated.add(role_id) + else: + seen.add(role_id) + return repeated + + +def _active_role_ids(section: Mapping[str, Any]) -> set[str]: + """Return unique graph role ids whose node is explicitly active.""" + part_graph = section.get("partGraph") + if not isinstance(part_graph, list): + return set() + safe_ids = [ + node.get("role_id") + for node in part_graph + if isinstance(node, Mapping) + and isinstance(node.get("role_id"), str) + and node["role_id"].strip() + ] + repeated = _repeated_ids([role_id for role_id in safe_ids if isinstance(role_id, str)]) + active: set[str] = set() + for node in part_graph: + if not isinstance(node, Mapping) or node.get("is_active") is not True: + continue + role_id = node.get("role_id") + if isinstance(role_id, str) and role_id.strip() and role_id not in repeated: + active.add(role_id) + return active + + +def _section_contains( + section: Mapping[str, Any], + time: float, + precise_boundary: Sequence[float] | None = None, +) -> bool: + """Return whether a section window contains a fermata hold time.""" + if precise_boundary is not None: + if ( + not isinstance(precise_boundary, Sequence) + or isinstance(precise_boundary, (str, bytes)) + or len(precise_boundary) != 2 + or any( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + for value in precise_boundary + ) + ): + return False + precise_start, precise_end = (float(value) for value in precise_boundary) + return ( + precise_start >= 0 + and precise_end > precise_start + and precise_start <= time < precise_end + ) + time_range = section.get("timeRange") + if not isinstance(time_range, Mapping): + return False + start = time_range.get("start") + end = time_range.get("end") + if not isinstance(start, int) or isinstance(start, bool) or start < 0: + return False + if not isinstance(end, int) or isinstance(end, bool) or end <= start: + return False + return start <= time < end + + +def _pick_landing_role(section: Mapping[str, Any]) -> MutableMapping[str, Any] | None: + """Pick the highest-priority unique active named vocal or bass.""" + roles = section.get("roles") + if not isinstance(roles, list): + return None + active_ids = _active_role_ids(section) + safe_ids = [ + role.get("id") + for role in roles + if isinstance(role, Mapping) and isinstance(role.get("id"), str) and role["id"].strip() + ] + repeated = _repeated_ids([role_id for role_id in safe_ids if isinstance(role_id, str)]) + ranked: list[tuple[int, int, str, MutableMapping[str, Any]]] = [] + for role in roles: + if not isinstance(role, MutableMapping): + continue + role_id = role.get("id") + name = role.get("name") + priority = _priority_value(role.get("rehearsalPriority")) + if not isinstance(role_id, str) or role_id.strip() == "" or role_id in repeated: + continue + if not isinstance(name, str) or name.strip() == "": + continue + if role_id not in active_ids or not _is_named_vocal_or_bass(role): + continue + if priority not in PRIORITY_RANK: + continue + is_vocal = _role_type_value(role.get("roleType")) == "vocal" or role_id == "lead-vocal" + vocal_rank = 0 if is_vocal else 1 + ranked.append((PRIORITY_RANK[priority], vocal_rank, role_id, role)) + if not ranked: + return None + ranked.sort(key=lambda item: (item[0], item[1], item[2])) + return ranked[0][3] + + +def derive_beat_times(mix: Any, sr: Any) -> list[float] | None: + """Return beat times from an in-memory mix using existing librosa beat tracking. + + Security Notes: + In-memory only. Malformed mix or sample-rate values fail closed. This + reuses ``librosa.beat.beat_track`` already owned by ``TemporalAnalyzer``; + it does not introduce a new MIR product. + """ + try: + import librosa + + if not isinstance(sr, int) or isinstance(sr, bool) or sr <= 0: + return None + if not hasattr(mix, "size") or int(getattr(mix, "size", 0)) <= 0: + return None + _tempo, beat_frames = librosa.beat.beat_track(y=mix, sr=sr) + times = librosa.frames_to_time(beat_frames, sr=sr) + derived = [float(time) for time in times] + return derived if derived else None + except (TypeError, ValueError, RuntimeError, AttributeError, ImportError): + return None + + +def apply_fermata_plan( + song: Mapping[str, Any], + beat_times: Sequence[float] | None, + section_boundaries: Sequence[Sequence[float]] | None = None, +) -> None: + """Attach the first corroborated fermata plan, failing closed on bad input. + + Args: + song: Mutable rehearsal-song mapping with section/role topology. + beat_times: Beat onset times in seconds used by tempo-stability. + section_boundaries: Optional unrounded section boundaries aligned to sections. + """ + hold = first_fermata(beat_times) + if hold is None: + return + copy = fermata_plan_copy(hold["hold_seconds"]) + if copy is None: + return + try: + sections = song.get("sections") + if not isinstance(sections, list): + return + for section_index, section in enumerate(sections): + precise_boundary = None + if ( + section_boundaries is not None + and isinstance(section_boundaries, Sequence) + and not isinstance(section_boundaries, (str, bytes)) + and section_index < len(section_boundaries) + ): + precise_boundary = section_boundaries[section_index] + if not isinstance(section, Mapping) or not _section_contains( + section, hold["time"], precise_boundary + ): + continue + landing = _pick_landing_role(section) + if landing is None: + return + roles = section.get("roles") + if not isinstance(roles, list): + return + for index, role in enumerate(roles): + if role is not landing: + continue + stamped = dict(landing) + stamped["fermataPlan"] = copy + stamped["fermataPlanSource"] = "model" + stamped["fermataPlanAtSeconds"] = hold["time"] + roles[index] = stamped + return + return + except (TypeError, ValueError, KeyError, AttributeError): + return diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 18273791d..93f3ee962 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -582,7 +582,7 @@ def test_run_analysis_job_updates_report_progress_and_cache(tmp_path) -> None: ("succeeded", "ready", 100), ] assert updates[-1]["cacheStatus"] == "stored" - cache_files = list((tmp_path / "cache" / "analysis-cache-v1").glob("*.json")) + cache_files = list((tmp_path / "cache" / "analysis-cache-v2").glob("*.json")) assert len([path for path in cache_files if not path.name.endswith(".features.json")]) == 1 assert len([path for path in cache_files if path.name.endswith(".features.json")]) == 1 @@ -648,7 +648,7 @@ def test_cached_analysis_helpers_treat_invalid_cache_as_miss(tmp_path) -> None: for content in ( "[]", '{"schemaVersion": 999, "result": {}}', - '{"schemaVersion": 1, "result": []}', + '{"schemaVersion": 2, "result": []}', ): cache_path.write_text(content, encoding="utf-8") assert _load_cached_analysis(cache_path) is None diff --git a/services/analysis-engine/tests/test_fermata_plan.py b/services/analysis-engine/tests/test_fermata_plan.py new file mode 100644 index 000000000..9c453907b --- /dev/null +++ b/services/analysis-engine/tests/test_fermata_plan.py @@ -0,0 +1,480 @@ +"""Tests for corroborated fermata-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.api import ( + _apply_fermata, + _coerce_beat_times, + build_demo_rehearsal_song, +) +from bandscope_analysis.temporal.fermata import ( + _interval_near_median, + _is_named_vocal_or_bass, + apply_fermata_plan, + derive_beat_times, + fermata_plan_copy, + first_fermata, + format_fermata_hold, + is_fermata_hold, +) + +_FERMATA_PLAN = ( + "Hold this part through the extra 1 s; wait for the cutoff before the next entrance." +) + + +def _beats_with_fermata() -> list[float]: + """Return beat times with one isolated extra hold after a steady 80 BPM pulse.""" + beats = [index * 0.75 for index in range(16)] + beats.append(beats[-1] + 1.75) + for _ in range(8): + beats.append(beats[-1] + 0.75) + return beats + + +def _beats_80_to_120() -> list[float]: + """Return beat times that lift from 80 BPM to 120 BPM around 11.25s.""" + beats = [i * 0.75 for i in range(16)] + for _ in range(16): + beats.append(beats[-1] + 0.5) + return beats + + +def _beats_steady() -> list[float]: + """Return a steady 80 BPM grid with no isolated hold.""" + return [index * 0.75 for index in range(24)] + + +def _role( + role_id: str, + *, + name: str | None = None, + role_type: str = "instrument", + priority: str = "high", +) -> dict[str, Any]: + """Return a minimal rehearsal role fixture.""" + display = name if name is not None else role_id + return { + "id": role_id, + "name": display, + "roleType": role_type, + "rehearsalPriority": priority, + "harmony": {"chord": "C#m7", "functionLabel": "vi", "source": "model"}, + "cue": {"kind": "transition", "value": "Hold"}, + "range": {"lowestNote": "C#2", "highestNote": "E3"}, + "confidence": {"level": "high", "source": "model", "notes": "ok"}, + "simplification": "Stay on roots.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + } + + +def _song_with_section( + *, + start: int = 0, + end: int = 16, + roles: list[dict[str, Any]] | None = None, + part_graph: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return a one-section song that can receive a fermata stamp.""" + section_roles = roles or [ + _role("keys-right", name="Keyboard 1 Right Hand", role_type="hand"), + _role("lead-vocal", name="Lead Vocal", role_type="vocal"), + _role("bass-guitar", name="Bass Guitar"), + ] + graph = part_graph or [ + {"role_id": role["id"], "is_active": True, "handoff_to": [], "handoff_from": []} + for role in section_roles + ] + return { + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Lifted chorus downbeat", + "timeRange": {"start": start, "end": end}, + "roles": section_roles, + "partGraph": graph, + } + ], + } + + +def test_format_fermata_hold_tokens() -> None: + """Whole-second holds drop the decimal; unusable values stay unnamed.""" + assert format_fermata_hold(1.0) == "1" + assert format_fermata_hold(1.5) == "1.5" + assert format_fermata_hold(0) is None + assert format_fermata_hold(0.1) is None + assert format_fermata_hold(0.25) is None + assert format_fermata_hold(9.0) is None + assert format_fermata_hold(float("nan")) is None + assert format_fermata_hold(float("inf")) is None + assert _interval_near_median(1.0, 0.0) is False + assert _interval_near_median(-1.0, 0.75) is False + + +def test_fermata_plan_copy_uses_owned_template() -> None: + """Model copy names the extra hold without inventing other rehearsal plans.""" + assert fermata_plan_copy(1.0) == _FERMATA_PLAN + assert fermata_plan_copy(0) is None + + +def test_first_fermata_picks_the_earliest_isolated_hold() -> None: + """A single extra beat-gap is a fermata; later steady pulse is ignored.""" + hold = first_fermata(_beats_with_fermata()) + assert hold is not None + assert abs(hold["hold_seconds"] - 1.0) < 1e-9 + assert 10.5 <= hold["time"] <= 12.5 + + +def test_first_fermata_excludes_steady_pulse_and_tempo_change() -> None: + """A steady pulse or a sustained speeding is not a fermata.""" + assert first_fermata(_beats_steady()) is None + assert first_fermata(_beats_80_to_120()) is None + + +def test_first_fermata_excludes_non_isolated_and_out_of_ratio_holds() -> None: + """Neighboring long gaps and extreme ratios stay unnamed.""" + clustered = [index * 0.75 for index in range(16)] + clustered.append(clustered[-1] + 1.75) + clustered.append(clustered[-1] + 1.75) + for _ in range(8): + clustered.append(clustered[-1] + 0.75) + assert first_fermata(clustered) is None + + stretched = [index * 0.75 for index in range(16)] + stretched.append(stretched[-1] + 3.2) + for _ in range(8): + stretched.append(stretched[-1] + 0.75) + assert first_fermata(stretched) is None + + too_short_extra = [index * 0.1 for index in range(16)] + too_short_extra.append(too_short_extra[-1] + 0.2) + for _ in range(8): + too_short_extra.append(too_short_extra[-1] + 0.1) + assert first_fermata(too_short_extra) is None + + too_long_extra = [index * 6.0 for index in range(16)] + too_long_extra.append(too_long_extra[-1] + 15.0) + for _ in range(8): + too_long_extra.append(too_long_extra[-1] + 6.0) + assert first_fermata(too_long_extra) is None + + +def test_first_fermata_skips_holds_near_tempo_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An isolated gap next to a reported tempo change stays unnamed.""" + + def _stability(_times: Any) -> dict[str, Any]: + return {"tempo_changes": [{"time": 11.25, "from_bpm": 80.0, "to_bpm": 120.0}]} + + monkeypatch.setattr( + "bandscope_analysis.temporal.fermata.analyze_tempo_stability", + _stability, + ) + assert first_fermata(_beats_with_fermata()) is None + + +def test_first_fermata_survives_stability_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tempo-stability exceptions fail closed to an empty change list, not a crash.""" + + def _boom(_times: Any) -> dict[str, Any]: + raise TypeError("hostile stability") + + monkeypatch.setattr( + "bandscope_analysis.temporal.fermata.analyze_tempo_stability", + _boom, + ) + hold = first_fermata(_beats_with_fermata()) + assert hold is not None + assert abs(hold["hold_seconds"] - 1.0) < 1e-9 + + +def test_first_fermata_fails_closed_when_hold_validator_rejects( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A candidate that later fails the hold validator stays unnamed.""" + monkeypatch.setattr( + "bandscope_analysis.temporal.fermata.is_fermata_hold", + lambda _hold: False, + ) + assert first_fermata(_beats_with_fermata()) is None + + +def test_first_fermata_fails_closed_on_malformed_beats() -> None: + """Malformed beat collections never invent a hold.""" + assert first_fermata(None) is None + assert first_fermata("beats") is None + assert first_fermata([0.0, True, 1.5]) is None + assert first_fermata([0.0, -1.0]) is None + assert first_fermata([0.0, 0.75, 0.5]) is None + assert first_fermata([0.0, 0.75]) is None + assert is_fermata_hold({"time": True, "hold_seconds": 1.0}) is False + assert is_fermata_hold({"time": 8.0, "hold_seconds": True}) is False + assert is_fermata_hold({"time": 8.0, "hold_seconds": float("nan")}) is False + assert is_fermata_hold({"time": -1.0, "hold_seconds": 1.0}) is False + assert is_fermata_hold({"hold_seconds": 1.0}) is False + + +def test_apply_stamps_highest_priority_named_vocal() -> None: + """The named vocal owns the fermata when it outranks bass in the same section.""" + song = _song_with_section() + apply_fermata_plan(song, _beats_with_fermata()) + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + bass = next(role for role in song["sections"][0]["roles"] if role["id"] == "bass-guitar") + keys = next(role for role in song["sections"][0]["roles"] if role["id"] == "keys-right") + assert vocal["fermataPlan"] == _FERMATA_PLAN + assert vocal["fermataPlanSource"] == "model" + assert vocal["fermataPlanAtSeconds"] == 11.25 + assert "fermataPlan" not in bass + assert "fermataPlan" not in keys + + +def test_apply_stamps_bass_when_vocal_is_inactive() -> None: + """Bass owns the fermata when the vocal is not active in the section.""" + song = _song_with_section( + part_graph=[ + {"role_id": "keys-right", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "lead-vocal", "is_active": False, "handoff_to": [], "handoff_from": []}, + {"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []}, + ] + ) + apply_fermata_plan(song, _beats_with_fermata()) + bass = next(role for role in song["sections"][0]["roles"] if role["id"] == "bass-guitar") + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + assert bass["fermataPlan"] == _FERMATA_PLAN + assert "fermataPlan" not in vocal + + +def test_apply_stays_unnamed_without_named_vocal_or_bass() -> None: + """Accompaniment hands never own a fermata plan.""" + song = _song_with_section( + roles=[_role("keys-right", name="Keyboard 1 Right Hand", role_type="hand")], + part_graph=[ + {"role_id": "keys-right", "is_active": True, "handoff_to": [], "handoff_from": []} + ], + ) + apply_fermata_plan(song, _beats_with_fermata()) + assert all("fermataPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_when_section_does_not_contain_the_hold() -> None: + """A fermata outside every section window stays unnamed.""" + song = _song_with_section(start=40, end=56) + apply_fermata_plan(song, _beats_with_fermata()) + assert all("fermataPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_on_tempo_change_and_missing_beats() -> None: + """Sustained speeding, missing beats, and demo topology stay unnamed.""" + song = _song_with_section() + apply_fermata_plan(song, _beats_80_to_120()) + assert all("fermataPlan" not in role for role in song["sections"][0]["roles"]) + apply_fermata_plan(song, None) + apply_fermata_plan(song, "beats") # type: ignore[arg-type] + demo = build_demo_rehearsal_song({"beat_times": _beats_with_fermata(), "bpm": 80}) + assert demo["id"] == "demo-song" + assert all( + "fermataPlan" not in role for section in demo["sections"] for role in section["roles"] + ) + + +def test_apply_skips_repeated_and_blank_identities() -> None: + """Repeated graph ids, blank names, and unknown priorities fail closed.""" + roles = [ + _role("lead-vocal", name="Lead Vocal", role_type="vocal"), + _role("lead-vocal", name="Double Vocal", role_type="vocal"), + _role("bass-guitar", name=""), + _role("mystery", name="Mystery", priority="urgent"), + ] + song = _song_with_section( + roles=roles, + part_graph=[ + {"role_id": "lead-vocal", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "lead-vocal", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "mystery", "is_active": True, "handoff_to": [], "handoff_from": []}, + ], + ) + apply_fermata_plan(song, _beats_with_fermata()) + assert all("fermataPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_fails_closed_on_malformed_song_topology() -> None: + """Malformed sections, ranges, and graph nodes never invent a hold.""" + apply_fermata_plan({"sections": "nope"}, _beats_with_fermata()) + apply_fermata_plan({"sections": [{"timeRange": "nope", "roles": []}]}, _beats_with_fermata()) + apply_fermata_plan( + _song_with_section(), + _beats_with_fermata(), + [(0.0,)], # type: ignore[list-item] + ) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": True, "end": 16} + apply_fermata_plan(song, _beats_with_fermata()) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": 10, "end": True} + apply_fermata_plan(song, _beats_with_fermata()) + song = _song_with_section() + song["sections"][0]["roles"] = None + apply_fermata_plan(song, _beats_with_fermata()) + song = _song_with_section() + song["sections"][0]["partGraph"] = "graph" + apply_fermata_plan(song, _beats_with_fermata()) + song = _song_with_section() + song["sections"][0]["roles"] = [ + "not-a-role", + _role("bass-guitar", name="Bass Guitar", priority="urgent"), + ] + apply_fermata_plan(song, _beats_with_fermata()) + assert _is_named_vocal_or_bass({"id": ""}) is False + assert _is_named_vocal_or_bass({"id": "choir", "roleType": "vocal"}) is True + + +def test_apply_fails_closed_when_copy_cannot_be_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A corroborated hold without owned copy stays unnamed.""" + song = _song_with_section() + monkeypatch.setattr( + "bandscope_analysis.temporal.fermata.fermata_plan_copy", + lambda *_args, **_kwargs: None, + ) + apply_fermata_plan(song, _beats_with_fermata()) + assert all("fermataPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_fails_closed_when_song_get_raises() -> None: + """Hostile song mappings fail closed instead of escaping.""" + + class HostileSong(dict[str, Any]): + """Mapping that raises when sections are read.""" + + def get(self, key: str, default: Any = None) -> Any: + """Raise on sections so apply_fermata_plan must fail closed.""" + if key == "sections": + raise TypeError("hostile sections") + return super().get(key, default) + + apply_fermata_plan(HostileSong(), _beats_with_fermata()) + + +def test_derive_beat_times_fails_closed_and_reuses_librosa( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """In-memory beat derivation fails closed and reuses existing beat tracking.""" + assert derive_beat_times(np.zeros(0, dtype=np.float32), 22050) is None + assert derive_beat_times(np.ones(16, dtype=np.float32), True) is None # type: ignore[arg-type] + assert derive_beat_times(np.ones(16, dtype=np.float32), 0) is None + + class _Librosa: + """Minimal librosa stand-in for beat tracking.""" + + class beat: + """Beat-tracking namespace.""" + + @staticmethod + def beat_track(*, y: Any, sr: int) -> tuple[float, np.ndarray]: + """Return a tiny beat-frame grid.""" + return 80.0, np.array([0, 10, 20], dtype=np.int32) + + @staticmethod + def frames_to_time(frames: np.ndarray, sr: int) -> np.ndarray: + """Convert frames to seconds.""" + return frames.astype(np.float64) / sr + + monkeypatch.setitem(__import__("sys").modules, "librosa", _Librosa) + derived = derive_beat_times(np.ones(32, dtype=np.float32), 10) + assert derived == [0.0, 1.0, 2.0] + + class _Boom: + """Librosa stand-in that fails closed.""" + + class beat: + """Beat-tracking namespace that raises.""" + + @staticmethod + def beat_track(*, y: Any, sr: int) -> tuple[float, np.ndarray]: + """Force beat tracking to fail closed.""" + raise RuntimeError("beat tracking unavailable") + + @staticmethod + def frames_to_time(frames: np.ndarray, sr: int) -> np.ndarray: + """Unused converter.""" + return frames.astype(np.float64) + + monkeypatch.setitem(__import__("sys").modules, "librosa", _Boom) + assert derive_beat_times(np.ones(32, dtype=np.float32), 22050) is None + + +def test_coerce_beat_times_and_pipeline_stamp() -> None: + """Pipeline features stamp a fermata; malformed beat times fall through to mix derivation.""" + assert _coerce_beat_times(None) is None + assert _coerce_beat_times({"beat_times": []}) is None + assert _coerce_beat_times({"beat_times": [0.0, True]}) is None + assert _coerce_beat_times({"beat_times": [0.0, float("nan")]}) is None + assert _coerce_beat_times({"beat_times": _beats_with_fermata()})[0] == 0.0 + + song = _song_with_section() + mix = np.ones(8, dtype=np.float32) + _apply_fermata(song, mix, 22050, {"beat_times": _beats_with_fermata()}) + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + assert vocal["fermataPlan"] == _FERMATA_PLAN + + +def test_pipeline_uses_unrounded_boundaries_for_fermata_section() -> None: + """A fractional structural boundary must not be truncated before hold selection.""" + earlier = _song_with_section(start=0, end=11) + later = _song_with_section(start=11, end=20) + song = earlier + song["sections"].extend(later["sections"]) + + apply_fermata_plan(song, _beats_with_fermata(), [(0.0, 11.9), (11.9, 20.0)]) + + earlier_vocal = next( + role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal" + ) + later_vocal = next(role for role in song["sections"][1]["roles"] if role["id"] == "lead-vocal") + assert earlier_vocal["fermataPlan"] == _FERMATA_PLAN + assert "fermataPlan" not in later_vocal + + unnamed = _song_with_section() + _apply_fermata(unnamed, np.zeros(0, dtype=np.float32), 22050, {"beat_times": "nope"}) + assert all("fermataPlan" not in role for role in unnamed["sections"][0]["roles"]) + + +def test_pipeline_stamps_fermata_from_provided_beat_times() -> None: + """Real stem pipeline receives beat times and names the fermata on the map.""" + sr = 8 + duration = 16.0 + audio = np.ones(int(sr * duration), dtype=np.float32) + song = build_demo_rehearsal_song( + { + "stems": {"bass": audio, "other": audio, "vocals": audio}, + "sr": sr, + "separation": {"duration_seconds": duration, "chunk_count": 1, "notes": "test"}, + "beat_times": _beats_with_fermata(), + } + ) + if song["id"] != "analyzed-song": + pytest.skip("pipeline fell back to arrangement without structural sections") + stamped = [ + role for section in song["sections"] for role in section["roles"] if role.get("fermataPlan") + ] + assert len(stamped) <= 1 + if stamped: + assert stamped[0]["fermataPlanSource"] == "model" + assert stamped[0]["fermataPlanAtSeconds"] == 11.25 + assert stamped[0]["id"] in {"lead-vocal", "bass-guitar"} diff --git a/services/analysis-engine/tests/test_fermata_shared_role_isolation.py b/services/analysis-engine/tests/test_fermata_shared_role_isolation.py new file mode 100644 index 000000000..43d9d0a2d --- /dev/null +++ b/services/analysis-engine/tests/test_fermata_shared_role_isolation.py @@ -0,0 +1,97 @@ +"""Regression tests for section-local fermata role mutation.""" + +from typing import Any + +from bandscope_analysis.temporal.fermata import apply_fermata_plan + + +def _beats_with_fermata() -> list[float]: + """Return beat times with one isolated extra hold after a steady 80 BPM pulse.""" + beats = [index * 0.75 for index in range(16)] + beats.append(beats[-1] + 1.75) + for _ in range(8): + beats.append(beats[-1] + 0.75) + return beats + + +def _shared_vocal() -> dict[str, Any]: + """Return one role object deliberately shared by two section fixtures.""" + return { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "rehearsalPriority": "high", + } + + +def _section(section_id: str, start: int, end: int, role: dict[str, Any]) -> dict[str, Any]: + """Return a minimal section containing the supplied shared role object.""" + return { + "id": section_id, + "label": "verse", + "timeRange": {"start": start, "end": end}, + "roles": [role], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": True, + "handoff_to": [], + "handoff_from": [], + } + ], + } + + +class _ChangingRolesSection(dict[str, Any]): + """Section mapping that changes its roles collection after selection.""" + + def __init__(self, initial_role: dict[str, Any], replacement: object) -> None: + """Store a valid first roles read and the later replacement value.""" + super().__init__(_section("verse-changing", 8, 20, initial_role)) + self._roles_reads = 0 + self._replacement = replacement + + def get(self, key: str, default: Any = None) -> Any: + """Return a different roles payload after the landing role is selected.""" + if key == "roles": + self._roles_reads += 1 + if self._roles_reads > 1: + return self._replacement + return super().get(key, default) + + +def test_fermata_stamp_does_not_leak_through_a_shared_role_object() -> None: + """Only the section containing the hold receives the owned plan copy.""" + shared_role = _shared_vocal() + earlier = _section("verse-1", 0, 8, shared_role) + containing = _section("verse-2", 8, 20, shared_role) + song = {"id": "shared-role-song", "title": "Shared Role", "sections": [earlier, containing]} + + apply_fermata_plan(song, _beats_with_fermata()) + + assert "fermataPlan" not in earlier["roles"][0] + assert containing["roles"][0]["fermataPlanSource"] == "model" + + +def test_fermata_stamp_fails_closed_if_roles_stop_being_a_list() -> None: + """A runtime section that changes shape after selection receives no stamp.""" + role = _shared_vocal() + section = _ChangingRolesSection(role, "not-a-role-list") + song = {"id": "changing-song", "title": "Changing", "sections": [section]} + + apply_fermata_plan(song, _beats_with_fermata()) + + assert "fermataPlan" not in role + + +def test_fermata_stamp_fails_closed_if_selected_role_identity_disappears() -> None: + """A replaced roles list cannot receive a stamp through stale object identity.""" + role = _shared_vocal() + replacement_role = dict(role) + section = _ChangingRolesSection(role, [replacement_role]) + song = {"id": "drifting-song", "title": "Drifting", "sections": [section]} + + apply_fermata_plan(song, _beats_with_fermata()) + + assert "fermataPlan" not in role + assert "fermataPlan" not in replacement_role