From 8a37c798f46b30ec3bdddfaff589f53ed6f4f107 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 07:40:28 +0000 Subject: [PATCH 01/25] feat(workspace): name tonight's first ritardando plan on the map Stamp one owned ritardandoPlan from existing tempo-stability slowing onto the highest-priority active named vocal or bass, and open that landing on the rehearsal map. Half-time and double-time feel flips stay unnamed. Heuristic demo topology stays unnamed. This is not a new MIR product. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/core/src/lib.rs | 37 +- .../core/tests/ritardando_plan_contract.rs | 127 ++++++ .../FirstRitardandoCallout.particle.test.tsx | 66 +++ .../workspace/FirstRitardandoCallout.test.tsx | 178 ++++++++ .../workspace/FirstRitardandoCallout.tsx | 226 ++++++++++ .../Workspace.ritardando-state.test.tsx | 63 +++ .../src/features/workspace/Workspace.tsx | 35 +- .../workspace/firstRitardando.test.ts | 179 ++++++++ .../src/features/workspace/firstRitardando.ts | 414 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 10 +- apps/desktop/src/i18n/index.ts | 34 ++ apps/desktop/src/locales/en/common.json | 9 +- apps/desktop/src/locales/ko/common.json | 9 +- packages/shared-types/src/index.ts | 25 +- packages/shared-types/test/index.test.ts | 6 + .../test/ritardandoPlanProvenance.test.ts | 42 ++ .../src/bandscope_analysis/api.py | 35 ++ .../src/bandscope_analysis/roles/model.py | 4 +- .../bandscope_analysis/temporal/__init__.py | 4 + .../bandscope_analysis/temporal/ritardando.py | 265 +++++++++++ .../tests/test_ritardando_plan.py | 384 ++++++++++++++++ 25 files changed, 2144 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/core/tests/ritardando_plan_contract.rs create mode 100644 apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx create mode 100644 apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstRitardando.test.ts create mode 100644 apps/desktop/src/features/workspace/firstRitardando.ts create mode 100644 packages/shared-types/test/ritardandoPlanProvenance.test.ts create mode 100644 services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py create mode 100644 services/analysis-engine/tests/test_ritardando_plan.py diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..357cc326c 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 ritardando plan with the owning vocal or bass when existing tempo-stability reports a corroborated slowing, the owned `ritardandoPlan` 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, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-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..bb30a1792 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 ritardando plan on the mounted map when existing tempo-stability reports a sustained slowing (`to_bpm < from_bpm`) that is not a half-time (~0.45–0.55) or double-time (~2.0) feel flip, landing on the highest-priority active named vocal or bass in the section that contains the change. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from 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..8f438f397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first ritardando plan in the mounted rehearsal workspace so the vocal or bass that eases into a slower tempo can open that landing on the map; real analyzed songs now receive this guidance only when existing tempo-stability reports a sustained slowing that is not a half-time or double-time feel flip, 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..2cfeeaf7d 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 ritardando 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, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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..2c62f2b3e 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 RitardandoPlanSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -191,6 +198,10 @@ pub struct RehearsalRolePayload { setup_note: String, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + ritardando_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ritardando_plan_source: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +538,29 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn validate_ritardando_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role.ritardando_plan.as_ref().is_some_and(|ritardando_plan| { + ritardando_plan.trim().is_empty() + || ritardando_plan.contains('\n') + || ritardando_plan.contains('\r') + }) { + return Err("Invalid project file format".to_string()); + } + if role.ritardando_plan.is_none() && role.ritardando_plan_source.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_ritardando_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +578,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": "Tempo stability corroborates the ritardando." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Let the next downbeat land later." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays while the tempo eases." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "ritardandoPlan": "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later.", + "ritardandoPlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Ease into the slower chorus landing.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_ritardando_plan_provenance() { + let payload = song_with_ritardando_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 ritardando-plan fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlan"], + payload["sections"][0]["roles"][0]["ritardandoPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlanSource"], + json!("model") + ); +} + +#[test] +fn project_contract_rejects_ritardando_plan_source_without_ritardando_plan() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("ritardandoPlan"); + 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_invalid_ritardando_plan_copy_with_source() { + for ritardando_plan in ["", " ", "ease here\nthen hold", "ease here\rthen hold"] { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlan"] = json!(ritardando_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 ritardando-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_ritardando_plan_source() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlanSource"] = 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" + ); +} diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx new file mode 100644 index 000000000..934807bf5 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstRitardandoCallout } from "./FirstRitardandoCallout"; + +function songWithKoreanRit() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.roles = [ + { + ...verse.roles[0]!, + id: "piano-vocal", + name: "피아노", + roleType: "vocal", + rehearsalPriority: "high", + ritardandoPlan: + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later.", + ritardandoPlanSource: "model" + } + ]; + verse.partGraph = [ + { role_id: "piano-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + return song; +} + +describe("FirstRitardandoCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the rit action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanRit(); + + 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:10 벌스에서 피아노 파트가 리타르단도합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 리타르단도 열기" })); + + expect( + screen.getByText("0:10에서 피아노 파트로 함께 늦추세요. 더 느린 착지가 들리도록 당기세요.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx new file mode 100644 index 000000000..c604f7b6f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx @@ -0,0 +1,178 @@ +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 { FirstRitardandoCallout } from "./FirstRitardandoCallout"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; +const appendedSongStructureTargets = new Set(); + +function songWithRitardandoPlan() { + 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.ritardandoPlan = DEMO_RITARDANDO_PLAN; + vocal.ritardandoPlanSource = "model"; + 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("FirstRitardandoCallout", () => { + 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 ritardando 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 = songWithRitardandoPlan(); + 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 rit at 0:10" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithRitardandoPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText( + "No ritardando plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("opens the named rit on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + expect(screen.getByText(DEMO_RITARDANDO_PLAN)).toBeTruthy(); + }); + + it("reports when the map section cannot be opened", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect( + screen.getByText("Could not open this rit 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 rit at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); + + it("resets armed guidance when accessor-id songs change with the same rit signature", () => { + const firstSong = songWithRitardandoPlan(); + const nextSong = songWithRitardandoPlan(); + 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 rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal eases the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithRitardandoPlan(); + 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 rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + + rerender( + + ); + + expect(screen.getByText("Lead Singer eases the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Ease Lead Singer together at 0:10 so the slower landing is audible./) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx new file mode 100644 index 000000000..751774535 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx @@ -0,0 +1,226 @@ +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 { + formatRitardandoPlanTime, + resolveFirstRitardandoPlan, + type RitardandoPlanGuidance +} from "./firstRitardando"; + +/** Props for the first ritardando-plan rehearsal callout. */ +export interface FirstRitardandoCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type RitardandoPlanCopyValues = Readonly>; +type RitardandoPlanSource = "model" | "user"; + +type OpenedRitardandoPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + ritardandoPlan: string; + ritardandoPlanSource: RitardandoPlanSource | null; + fromBpm: string | null; + toBpm: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableRitardandoPlanSongIdentity( + song: RehearsalSong, + workspaceInstanceKey: unknown +): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate ritardando-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatRitardandoPlanCopy(template: string, values: RitardandoPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof RitardandoPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model ritardando guidance from structured tempo tokens, never from display-copy grammar. */ +function localizedRitardandoPlan( + ritardandoPlan: string, + ritardandoPlanSource: RitardandoPlanSource | null, + guidance: RitardandoPlanGuidance | null, + generatedTemplate: string +): string { + if (ritardandoPlanSource !== "model" || guidance === null) { + return ritardandoPlan; + } + return generatedTemplate + .replace("{from}", () => guidance.fromBpm) + .replace("{to}", () => guidance.toBpm); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredRitardandoPlanScrollBehavior(): 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 resolveRitardandoPlanRenderer(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 ritardando plan and open the matching rendered map section. */ +export function FirstRitardandoCallout({ + song, + workspaceInstanceKey +}: FirstRitardandoCalloutProps) { + const calloutId = `workspace-surface-ritardando-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableRitardandoPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstRitardandoPlan(song), [song]); + const [openedRitardandoPlan, setOpenedRitardandoPlan] = useState( + null + ); + const [navigationFailed, setNavigationFailed] = useState(false); + const fromBpm = named?.ritardandoPlanGuidance?.fromBpm ?? null; + const toBpm = named?.ritardandoPlanGuidance?.toBpm ?? null; + + useEffect(() => { + setOpenedRitardandoPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.ritardandoPlan, + named?.ritardandoPlanSource, + fromBpm, + toBpm, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedRitardandoPlan !== null && + openedRitardandoPlan.songIdentity === songIdentity && + openedRitardandoPlan.sectionId === named.sectionId && + openedRitardandoPlan.sectionIndex === named.sectionIndex && + openedRitardandoPlan.sectionLabel === named.sectionLabel && + openedRitardandoPlan.landingRoleId === named.landingRoleId && + openedRitardandoPlan.landingRoleName === named.landingRoleName && + openedRitardandoPlan.ritardandoPlan === named.ritardandoPlan && + openedRitardandoPlan.ritardandoPlanSource === named.ritardandoPlanSource && + openedRitardandoPlan.fromBpm === fromBpm && + openedRitardandoPlan.toBpm === toBpm && + openedRitardandoPlan.atSeconds === named.atSeconds; + const at = formatRitardandoPlanTime(named.atSeconds); + const copyValues: RitardandoPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatRitardandoPlanCopy(t("firstRitardandoPlanOpenAction"), copyValues); + const body = formatRitardandoPlanCopy(t("firstRitardandoPlanBody"), copyValues); + const armed = formatRitardandoPlanCopy(t("firstRitardandoPlanArmed"), copyValues); + const ritardandoPlan = localizedRitardandoPlan( + named.ritardandoPlan, + named.ritardandoPlanSource, + named.ritardandoPlanGuidance, + t("firstRitardandoPlanGeneratedGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx new file mode 100644 index 000000000..255e159e3 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx @@ -0,0 +1,63 @@ +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 analyzedSongWithRitardandoPlan(): 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.ritardandoPlan = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + vocal.ritardandoPlanSource = "model"; + return song; +} + +describe("Workspace ritardando 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 rit armed after an immutable practice-progress update", () => { + const song = analyzedSongWithRitardandoPlan(); + 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 rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).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(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..5261bd67d 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 { FirstRitardandoCallout } from "./FirstRitardandoCallout"; 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/firstRitardando.test.ts b/apps/desktop/src/features/workspace/firstRitardando.test.ts new file mode 100644 index 000000000..f39a23557 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRitardando.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatRitardandoPlanTime, resolveFirstRitardandoPlan } from "./firstRitardando"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + +function withRitardandoSection( + overrides: { + id?: string; + start?: number; + end?: number; + ritardandoPlan?: string; + source?: "model" | "user"; + 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", + ritardandoPlan: overrides.ritardandoPlan ?? DEMO_RITARDANDO_PLAN, + ...(overrides.source ? { ritardandoPlanSource: overrides.source } : { ritardandoPlanSource: "model" as const }) + }; + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-rit"; + 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("resolveFirstRitardandoPlan", () => { + it("picks the earliest ritardando plan and the named vocal that owns it", () => { + const resolved = resolveFirstRitardandoPlan(withRitardandoSection()); + expect(resolved?.section.id).toBe("chorus-rit"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.ritardandoPlan).toBe(DEMO_RITARDANDO_PLAN); + expect(resolved?.atSeconds).toBe(0); + expect(formatRitardandoPlanTime(resolved?.atSeconds ?? -1)).toBe("0:00"); + expect(formatRitardandoPlanTime(Number.NaN)).toBe("0:00"); + expect(formatRitardandoPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a ritardando plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, or confidence notes", () => { + const song = withRitardandoSection(); + delete song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.ritardandoPlan; + 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_RITARDANDO_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + landing.cue = { kind: "transition", value: DEMO_RITARDANDO_PLAN }; + landing.confidence.notes = DEMO_RITARDANDO_PLAN; + expect(resolveFirstRitardandoPlan(song)).toBeNull(); + }); + + it("leaves the heuristic demo unnamed", () => { + expect(resolveFirstRitardandoPlan(createDemoRehearsalSong())).toBeNull(); + }); + + it("does not let accompaniment own the ritardando", () => { + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ roleId: "keys-right", roleType: "hand" }) + ) + ).toBeNull(); + }); + + it("ignores inactive named parts", () => { + expect(resolveFirstRitardandoPlan(withRitardandoSection({ isActive: false }))).toBeNull(); + }); + + it("prefers a named vocal over bass at the same priority", () => { + const song = withRitardandoSection(); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.ritardandoPlan = DEMO_RITARDANDO_PLAN; + bass.ritardandoPlanSource = "model"; + bass.rehearsalPriority = "high"; + expect(resolveFirstRitardandoPlan(song)?.landingRoleId).toBe("lead-vocal"); + }); + + it("prefers the higher-priority named part", () => { + const song = withRitardandoSection({ priority: "low" }); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.ritardandoPlan = DEMO_RITARDANDO_PLAN; + bass.ritardandoPlanSource = "model"; + bass.rehearsalPriority = "high"; + expect(resolveFirstRitardandoPlan(song)?.landingRoleId).toBe("bass-guitar"); + }); + + it("picks the earlier section when two rits are named", () => { + const earlier = withRitardandoSection({ id: "earlier-rit", start: 0 }); + const later = withRitardandoSection({ id: "later-rit", start: 16 }); + const song = earlier; + song.sections = [...earlier.sections, ...later.sections]; + expect(resolveFirstRitardandoPlan(song)?.sectionId).toBe("earlier-rit"); + }); + + it("rejects blank, multiline, or non-template model copy", () => { + expect(resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlan: " " }))).toBeNull(); + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ ritardandoPlan: "Ease together.\nHold the count." }) + ) + ).toBeNull(); + expect( + resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlan: "slow down here" })) + ).toBeNull(); + }); + + it("admits bounded user copy without requiring the engine template", () => { + const resolved = resolveFirstRitardandoPlan( + withRitardandoSection({ + ritardandoPlan: "Pull the phrase late into the downbeat.", + source: "user" + }) + ); + expect(resolved?.ritardandoPlan).toBe("Pull the phrase late into the downbeat."); + expect(resolved?.ritardandoPlanSource).toBe("user"); + }); + + it("fails closed on a malformed runtime song root", () => { + expect(resolveFirstRitardandoPlan(null as never)).toBeNull(); + }); + + it("fails closed on inherited or accessor-backed plan copy", () => { + const song = withRitardandoSection(); + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + delete vocal.ritardandoPlan; + Object.defineProperty(vocal, "ritardandoPlan", { + configurable: true, + enumerable: true, + get() { + return DEMO_RITARDANDO_PLAN; + } + }); + expect(resolveFirstRitardandoPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRitardando.ts b/apps/desktop/src/features/workspace/firstRitardando.ts new file mode 100644 index 000000000..9b238aa72 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRitardando.ts @@ -0,0 +1,414 @@ +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_RITARDANDO_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const NAMED_RITARDANDO_ROLE_IDS = new Set(["bass-guitar", "lead-vocal"]); +const RITARDANDO_PLAN_PREFIX = "Ease this part from "; +const RITARDANDO_PLAN_MIDDLE = " BPM into "; +const RITARDANDO_PLAN_SUFFIX = " BPM; let the next downbeat land later."; + +type RitardandoPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated ritardando-plan copy. */ +export type RitardandoPlanGuidance = Readonly<{ + kind: "tempo"; + fromBpm: string; + toBpm: string; +}>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; + isVocal: boolean; +}>; + +type OwnedRitardandoPlan = Readonly<{ + text: string; + source: RitardandoPlanSource | null; + guidance: RitardandoPlanGuidance | null; +}>; + +/** Tonight's first ritardando plan: the earliest corroborated slowing on a named vocal or bass. */ +export type FirstRitardandoPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + ritardandoPlan: string; + ritardandoPlanSource: RitardandoPlanSource | null; + ritardandoPlanGuidance: RitardandoPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative ritardando-plan time as m:ss for rehearsal copy. */ +export function formatRitardandoPlanTime(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 ritardando template while bounding its model-owned tempo tokens. */ +function boundedGeneratedRitardandoPlan(value: string): OwnedRitardandoPlan | null { + if ( + !value.startsWith(RITARDANDO_PLAN_PREFIX) || + !value.endsWith(RITARDANDO_PLAN_SUFFIX) || + !value.includes(RITARDANDO_PLAN_MIDDLE) + ) { + return null; + } + const inner = value.slice(RITARDANDO_PLAN_PREFIX.length, -RITARDANDO_PLAN_SUFFIX.length); + const middleIndex = inner.indexOf(RITARDANDO_PLAN_MIDDLE); + if (middleIndex <= 0) { + return null; + } + const fromBpm = inner.slice(0, middleIndex); + const toBpm = inner.slice(middleIndex + RITARDANDO_PLAN_MIDDLE.length); + if (!/^\d+(?:\.\d+)?$/u.test(fromBpm) || !/^\d+(?:\.\d+)?$/u.test(toBpm)) { + return null; + } + return { + text: `${RITARDANDO_PLAN_PREFIX}${fromBpm}${RITARDANDO_PLAN_MIDDLE}${toBpm}${RITARDANDO_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "tempo", fromBpm, toBpm } + }; +} + +/** Return a bounded snapshotted own ritardando plan and its explicit provenance, or null when malformed. */ +function ownedRitardandoPlan(role: unknown): OwnedRitardandoPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const ritardandoPlan = ownDataValue(role, "ritardandoPlan"); + const ritardandoPlanSource = ownDataValue(role, "ritardandoPlanSource"); + if (typeof ritardandoPlan !== "string") { + return null; + } + if ( + ritardandoPlanSource !== undefined && + ritardandoPlanSource !== "model" && + ritardandoPlanSource !== "user" + ) { + return null; + } + const trimmed = ritardandoPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (ritardandoPlanSource === "model") { + return boundedGeneratedRitardandoPlan(trimmed); + } + return { + text: truncateCodePoints(trimmed, MAX_RITARDANDO_PLAN_CHARACTERS), + source: ritardandoPlanSource ?? null, + guidance: null + }; +} + +/** 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_RITARDANDO_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; +} + +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 a ritardando plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstRitardandoPlan(song: RehearsalSong): FirstRitardandoPlan | 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 ritardandoPlan = ownedRitardandoPlan(metadata.role); + return ritardandoPlan === null + ? [] + : [ + { + ...metadata, + ritardandoPlan: ritardandoPlan.text, + ritardandoPlanSource: ritardandoPlan.source, + ritardandoPlanGuidance: ritardandoPlan.guidance + } + ]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + ritardandoPlan: landingRole.ritardandoPlan, + ritardandoPlanSource: landingRole.ritardandoPlanSource, + ritardandoPlanGuidance: landingRole.ritardandoPlanGuidance, + atSeconds: timeRange.start + } + ]; + }) + .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 ritardando plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstRitardandoPlan(song: RehearsalSong): FirstRitardandoPlan | null { + try { + return resolveSafeFirstRitardandoPlan(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..1ec65678e 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}.", + "firstRitardandoPlanLabel": "Tonight's first ritardando plan", + "firstRitardandoPlanOpenAction": "Open {role} rit at {at}", + "firstRitardandoPlanBody": "{role} eases the {section} at {at}.", + "firstRitardandoPlanArmed": "Ease {role} together at {at} so the slower landing is audible.", + "firstRitardandoPlanGeneratedGuidance": "Ease this part from {from} BPM into {to} BPM; let the next downbeat land later.", + "firstRitardandoPlanUnavailable": "No ritardando plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstRitardandoPlanNavigationFailed": "Could not open this rit 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..990e7261d 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} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstRitardandoPlanLabel": "오늘 첫 리타르단도 계획", + "firstRitardandoPlanOpenAction": "{at} {role} 리타르단도 열기", + "firstRitardandoPlanBody": "{at} {section}에서 {role} 파트가 리타르단도합니다.", + "firstRitardandoPlanArmed": "{at}에서 {role} 파트로 함께 늦추세요. 더 느린 착지가 들리도록 당기세요.", + "firstRitardandoPlanGeneratedGuidance": "이 파트를 {from} BPM에서 {to} BPM으로 늦추세요. 다음 다운비트까지 당기세요.", + "firstRitardandoPlanUnavailable": "사용 가능한 리타르단도 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstRitardandoPlanNavigationFailed": "곡 맵에서 이 리타르단도를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..f9483a16d 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -143,6 +143,8 @@ export type RehearsalRole = { overlapWarnings: string[]; transcription?: TranscriptionNote[]; practiceProgress?: number; + ritardandoPlan?: string; + ritardandoPlanSource?: ProvenanceSource; }; /** Documented. */ @@ -1500,7 +1502,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "ritardandoPlan", + "ritardandoPlanSource" ], path ); @@ -1588,6 +1592,25 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.ritardandoPlan !== undefined && + (typeof value.ritardandoPlan !== "string" || + value.ritardandoPlan.trim().length === 0 || + value.ritardandoPlan.includes("\n") || + value.ritardandoPlan.includes("\r")) + ) { + return invalidField(`${path}.ritardandoPlan`); + } + if ( + value.ritardandoPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.ritardandoPlanSource) + ) { + return invalidField(`${path}.ritardandoPlanSource`); + } + if (value.ritardandoPlanSource !== undefined && value.ritardandoPlan === undefined) { + return invalidField(`${path}.ritardandoPlanSource`); + } + return null; } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..02a037897 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].ritardandoPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.ritardandoPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/packages/shared-types/test/ritardandoPlanProvenance.test.ts b/packages/shared-types/test/ritardandoPlanProvenance.test.ts new file mode 100644 index 000000000..af17e36a3 --- /dev/null +++ b/packages/shared-types/test/ritardandoPlanProvenance.test.ts @@ -0,0 +1,42 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + +describe("ritardandoPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s ritardando plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.ritardandoPlanSource).toBe(source); + }); + + it("rejects an unknown ritardando plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + + it("rejects a ritardando plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.ritardandoPlan; + role.ritardandoPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + + it.each(["", " ", "ease here\nthen hold", "ease here\rthen hold"])( + "rejects a ritardando plan source with blank or multiline copy %j", + (ritardandoPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = ritardandoPlan; + role.ritardandoPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlan/); + } + ); +}); diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..25e03f3c9 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -19,6 +19,7 @@ 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.ritardando import apply_ritardando_plan, derive_beat_times logger = logging.getLogger(__name__) @@ -116,6 +117,8 @@ class RehearsalRolePayload(TypedDict): setupNote: str manualOverrides: list[ManualOverridePayload] overlapWarnings: list[str] + ritardandoPlan: NotRequired[str] + ritardandoPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNodePayload(TypedDict): @@ -456,6 +459,7 @@ def _build_from_pipeline( }, } _apply_tempo(song, features) + _apply_ritardando(song, mix, sr, features) return song @@ -518,6 +522,37 @@ 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_ritardando( + song: RehearsalSong, + mix: Any, + sr: int, + audio_features: dict[str, Any] | None, +) -> None: + """Stamp tonight's first ritardando from existing tempo-stability changes.""" + beat_times = _coerce_beat_times(audio_features) + if beat_times is None: + beat_times = derive_beat_times(mix, sr) + apply_ritardando_plan(song, beat_times) + + def _reconstruct_mix(stems: dict[str, Any]) -> Any: """Reconstruct a mono mix from separated stems for segmentation.""" arrays = [] diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..a3a83e96e 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,8 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + ritardandoPlan: NotRequired[str] + ritardandoPlanSource: NotRequired[Literal["model", "user"]] 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..64f66c464 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py @@ -3,6 +3,7 @@ from .analyzer import TemporalAnalyzer from .groove import GrooveResult, detect_groove from .model import TemporalFeatures +from .ritardando import apply_ritardando_plan, first_ritardando, ritardando_plan_copy from .stability import TempoChange, TempoStability, analyze_tempo_stability __all__ = [ @@ -12,5 +13,8 @@ "TemporalAnalyzer", "TemporalFeatures", "analyze_tempo_stability", + "apply_ritardando_plan", "detect_groove", + "first_ritardando", + "ritardando_plan_copy", ] diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py b/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py new file mode 100644 index 000000000..2bb1cdb38 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py @@ -0,0 +1,265 @@ +"""Stamp tonight's first ritardando plan from existing tempo-stability changes. + +A ritardando is the earliest sustained slowing (``to_bpm < from_bpm``) that is +not a half-time feel (~0.45–0.55) or a double-time feel (~2.0). The owned +``ritardandoPlan`` copy lands on the highest-priority active named vocal or +bass in the section that contains that change. Heuristic/demo topology stays +unnamed. This is not a new MIR product: it only reads +``analyze_tempo_stability`` output. + +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, Sequence +from math import isfinite +from typing import Any + +from bandscope_analysis.temporal.stability import TempoChange, analyze_tempo_stability + +HALF_TIME_RATIO_MIN = 0.45 +HALF_TIME_RATIO_MAX = 0.55 +NAMED_RITARDANDO_ROLE_IDS = frozenset({"bass-guitar", "lead-vocal"}) +PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2} +RITARDANDO_PLAN_PREFIX = "Ease this part from " +RITARDANDO_PLAN_MIDDLE = " BPM into " +RITARDANDO_PLAN_SUFFIX = " BPM; let the next downbeat land later." + + +def format_ritardando_bpm(value: float) -> str | None: + """Return a buyer-facing BPM token, or None when the value is unusable.""" + if not isfinite(value) or value <= 0: + return None + rounded = round(float(value), 1) + if abs(rounded - round(rounded)) < 1e-9: + return str(int(round(rounded))) + return f"{rounded:.1f}" + + +def ritardando_plan_copy(from_bpm: float, to_bpm: float) -> str | None: + """Return the owned model ritardando copy, or None when BPM tokens are unusable.""" + from_token = format_ritardando_bpm(from_bpm) + to_token = format_ritardando_bpm(to_bpm) + if from_token is None or to_token is None: + return None + return ( + f"{RITARDANDO_PLAN_PREFIX}{from_token}" + f"{RITARDANDO_PLAN_MIDDLE}{to_token}{RITARDANDO_PLAN_SUFFIX}" + ) + + +def is_ritardando_change(change: Mapping[str, Any]) -> bool: + """Return whether a tempo change is a slowing that is not a feel flip.""" + from_bpm = change.get("from_bpm") + to_bpm = change.get("to_bpm") + if not isinstance(from_bpm, (int, float)) or isinstance(from_bpm, bool): + return False + if not isinstance(to_bpm, (int, float)) or isinstance(to_bpm, bool): + return False + if not isfinite(from_bpm) or not isfinite(to_bpm) or from_bpm <= 0 or to_bpm <= 0: + return False + if to_bpm >= from_bpm: + return False + ratio = float(to_bpm) / float(from_bpm) + if HALF_TIME_RATIO_MIN <= ratio <= HALF_TIME_RATIO_MAX: + return False + return True + + +def first_ritardando(tempo_changes: Sequence[Mapping[str, Any]] | None) -> TempoChange | None: + """Return the earliest ritardando change, or None when none is corroborated.""" + if not isinstance(tempo_changes, Sequence) or isinstance(tempo_changes, (str, bytes)): + return None + for change in tempo_changes: + if not isinstance(change, Mapping): + continue + if not is_ritardando_change(change): + continue + time = change.get("time") + from_bpm = change.get("from_bpm") + to_bpm = change.get("to_bpm") + if ( + not isinstance(time, (int, float)) + or isinstance(time, bool) + or not isfinite(time) + or time < 0 + ): + continue + return { + "time": float(time), + "from_bpm": float(from_bpm), + "to_bpm": float(to_bpm), + } + 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 rit.""" + role_id = role.get("id") + if not isinstance(role_id, str) or role_id.strip() == "": + return False + if role_id in NAMED_RITARDANDO_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) -> bool: + """Return whether a section window contains a tempo-change time.""" + 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]) -> dict[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, dict[str, Any]]] = [] + for role in roles: + if not isinstance(role, Mapping): + 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_ritardando_plan(song: Mapping[str, Any], beat_times: Sequence[float] | None) -> None: + """Attach the first corroborated ritardando 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. + """ + if ( + beat_times is None + or not isinstance(beat_times, Sequence) + or isinstance(beat_times, (str, bytes)) + ): + return + try: + stability = analyze_tempo_stability(beat_times) + change = first_ritardando(stability.get("tempo_changes")) + if change is None: + return + copy = ritardando_plan_copy(change["from_bpm"], change["to_bpm"]) + if copy is None: + return + sections = song.get("sections") + if not isinstance(sections, list): + return + for section in sections: + if not isinstance(section, Mapping) or not _section_contains(section, change["time"]): + continue + landing = _pick_landing_role(section) + if landing is None: + return + landing["ritardandoPlan"] = copy + landing["ritardandoPlanSource"] = "model" + return + except (TypeError, ValueError, KeyError, AttributeError): + return diff --git a/services/analysis-engine/tests/test_ritardando_plan.py b/services/analysis-engine/tests/test_ritardando_plan.py new file mode 100644 index 000000000..f618df0a3 --- /dev/null +++ b/services/analysis-engine/tests/test_ritardando_plan.py @@ -0,0 +1,384 @@ +"""Tests for corroborated ritardando-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.api import ( + _apply_ritardando, + _coerce_beat_times, + build_demo_rehearsal_song, +) +from bandscope_analysis.temporal.ritardando import ( + _is_named_vocal_or_bass, + apply_ritardando_plan, + derive_beat_times, + first_ritardando, + format_ritardando_bpm, + is_ritardando_change, + ritardando_plan_copy, +) +from bandscope_analysis.temporal.stability import analyze_tempo_stability + +_RIT_PLAN = "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later." + + +def _beats_120_to_80() -> list[float]: + """Return beat times that slow from 120 BPM to 80 BPM around 7.5s.""" + beats = [i * 0.5 for i in range(16)] + for _ in range(16): + beats.append(beats[-1] + 0.75) + return beats + + +def _beats_120_to_60() -> list[float]: + """Return beat times that drop from 120 BPM to half-time 60 BPM.""" + beats = [i * 0.5 for i in range(16)] + for _ in range(16): + beats.append(beats[-1] + 1.0) + return beats + + +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 ritardando 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_ritardando_bpm_tokens() -> None: + """Whole BPM values drop the decimal; unusable values stay unnamed.""" + assert format_ritardando_bpm(120.0) == "120" + assert format_ritardando_bpm(96.5) == "96.5" + assert format_ritardando_bpm(0) is None + assert format_ritardando_bpm(-12) is None + assert format_ritardando_bpm(float("nan")) is None + assert format_ritardando_bpm(float("inf")) is None + + +def test_ritardando_plan_copy_uses_owned_template() -> None: + """Model copy names the slowing without inventing other rehearsal plans.""" + assert ritardando_plan_copy(120, 80) == _RIT_PLAN + assert ritardando_plan_copy(0, 80) is None + + +def test_first_ritardando_picks_the_earliest_slowing() -> None: + """120 to 80 is a ritardando; later accelerando is ignored.""" + result = analyze_tempo_stability(_beats_120_to_80()) + change = first_ritardando(result["tempo_changes"]) + assert change is not None + assert abs(change["from_bpm"] - 120.0) < 1.0 + assert abs(change["to_bpm"] - 80.0) < 1.0 + assert 7.0 <= change["time"] <= 8.5 + + +def test_first_ritardando_excludes_half_time() -> None: + """A 120 to 60 feel flip is half-time, not a ritardando.""" + result = analyze_tempo_stability(_beats_120_to_60()) + assert first_ritardando(result["tempo_changes"]) is None + assert is_ritardando_change({"time": 8.0, "from_bpm": 120.0, "to_bpm": 60.0}) is False + + +def test_first_ritardando_excludes_accelerando_and_double_time() -> None: + """Speeding up, including double-time, is not a ritardando.""" + assert is_ritardando_change({"time": 8.0, "from_bpm": 80.0, "to_bpm": 120.0}) is False + assert is_ritardando_change({"time": 8.0, "from_bpm": 60.0, "to_bpm": 120.0}) is False + assert first_ritardando([{"time": 8.0, "from_bpm": 60.0, "to_bpm": 120.0}]) is None + + +def test_first_ritardando_fails_closed_on_malformed_changes() -> None: + """Malformed tempo-change collections never invent a rit.""" + assert first_ritardando(None) is None + assert first_ritardando("tempo") is None + assert first_ritardando([None, "x", {"from_bpm": True, "to_bpm": 80}]) is None + assert is_ritardando_change({"from_bpm": True, "to_bpm": 80}) is False + assert is_ritardando_change({"from_bpm": 120, "to_bpm": True}) is False + assert is_ritardando_change({"from_bpm": 120, "to_bpm": float("nan")}) is False + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80, "time": True}]) is None + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80, "time": -1}]) is None + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80}]) is None + + +def test_apply_stamps_highest_priority_named_vocal() -> None: + """The named vocal owns the rit when it outranks bass in the same section.""" + song = _song_with_section() + apply_ritardando_plan(song, _beats_120_to_80()) + 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["ritardandoPlan"] == _RIT_PLAN + assert vocal["ritardandoPlanSource"] == "model" + assert "ritardandoPlan" not in bass + assert "ritardandoPlan" not in keys + + +def test_apply_stamps_bass_when_vocal_is_inactive() -> None: + """Bass owns the rit 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_ritardando_plan(song, _beats_120_to_80()) + 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["ritardandoPlan"] == _RIT_PLAN + assert "ritardandoPlan" not in vocal + + +def test_apply_stays_unnamed_without_named_vocal_or_bass() -> None: + """Accompaniment hands never own a ritardando 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_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_when_section_does_not_contain_the_change() -> None: + """A rit outside every section window stays unnamed.""" + song = _song_with_section(start=40, end=56) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_on_half_time_and_missing_beats() -> None: + """Half-time, missing beats, and demo topology stay unnamed.""" + song = _song_with_section() + apply_ritardando_plan(song, _beats_120_to_60()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + apply_ritardando_plan(song, None) + apply_ritardando_plan(song, "beats") # type: ignore[arg-type] + demo = build_demo_rehearsal_song({"beat_times": _beats_120_to_80(), "bpm": 120}) + assert demo["id"] == "demo-song" + assert all( + "ritardandoPlan" 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_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" 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 rit.""" + apply_ritardando_plan({"sections": "nope"}, _beats_120_to_80()) + apply_ritardando_plan({"sections": [{"timeRange": "nope", "roles": []}]}, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": True, "end": 16} + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": 10, "end": True} + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["roles"] = None + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["partGraph"] = "graph" + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["roles"] = [ + "not-a-role", + _role("bass-guitar", name="Bass Guitar", priority="urgent"), + ] + apply_ritardando_plan(song, _beats_120_to_80()) + 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 change without owned copy stays unnamed.""" + song = _song_with_section() + monkeypatch.setattr( + "bandscope_analysis.temporal.ritardando.ritardando_plan_copy", + lambda *_args, **_kwargs: None, + ) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" 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_ritardando_plan must fail closed.""" + if key == "sections": + raise TypeError("hostile sections") + return super().get(key, default) + + apply_ritardando_plan(HostileSong(), _beats_120_to_80()) + + +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 120.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 rit; 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_120_to_80()})[0] == 0.0 + + song = _song_with_section() + mix = np.ones(8, dtype=np.float32) + _apply_ritardando(song, mix, 22050, {"beat_times": _beats_120_to_80()}) + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + assert vocal["ritardandoPlan"] == _RIT_PLAN + + unnamed = _song_with_section() + _apply_ritardando(unnamed, np.zeros(0, dtype=np.float32), 22050, {"beat_times": "nope"}) + assert all("ritardandoPlan" not in role for role in unnamed["sections"][0]["roles"]) + + +def test_pipeline_stamps_ritardando_from_provided_beat_times() -> None: + """Real stem pipeline receives beat times and names the rit 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_120_to_80(), + } + ) + 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("ritardandoPlan") + ] + assert len(stamped) <= 1 + if stamped: + assert stamped[0]["ritardandoPlanSource"] == "model" + assert stamped[0]["id"] in {"lead-vocal", "bass-guitar"} From 02b6287005dbd9ee6e770a8aba044db6b925d6ea Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 17:03:43 +0900 Subject: [PATCH 02/25] fix(contract): require provenance for ritardando plan copy --- apps/desktop/core/src/lib.rs | 3 +++ .../core/tests/ritardando_plan_contract.rs | 15 +++++++++++++++ .../src/features/workspace/firstRitardando.ts | 1 + packages/shared-types/src/index.ts | 3 +++ .../test/ritardandoPlanProvenance.test.ts | 8 ++++++++ 5 files changed, 30 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 2c62f2b3e..5e6d866aa 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -553,6 +553,9 @@ fn validate_ritardando_plan_provenance( if role.ritardando_plan.is_none() && role.ritardando_plan_source.is_some() { return Err("Invalid project file format".to_string()); } + if role.ritardando_plan.is_some() && role.ritardando_plan_source.is_none() { + return Err("Invalid project file format".to_string()); + } } } Ok(payload) diff --git a/apps/desktop/core/tests/ritardando_plan_contract.rs b/apps/desktop/core/tests/ritardando_plan_contract.rs index 2724b9733..0c0e2fd26 100644 --- a/apps/desktop/core/tests/ritardando_plan_contract.rs +++ b/apps/desktop/core/tests/ritardando_plan_contract.rs @@ -100,6 +100,21 @@ fn project_contract_rejects_ritardando_plan_source_without_ritardando_plan() { ); } +#[test] +fn project_contract_rejects_ritardando_plan_without_source() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("ritardandoPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject ritardando-plan copy without provenance" + ); +} + #[test] fn project_contract_rejects_invalid_ritardando_plan_copy_with_source() { for ritardando_plan in ["", " ", "ease here\nthen hold", "ease here\rthen hold"] { diff --git a/apps/desktop/src/features/workspace/firstRitardando.ts b/apps/desktop/src/features/workspace/firstRitardando.ts index 9b238aa72..7e00ef118 100644 --- a/apps/desktop/src/features/workspace/firstRitardando.ts +++ b/apps/desktop/src/features/workspace/firstRitardando.ts @@ -257,6 +257,7 @@ function repeatedIds(ids: string[]): Set { return repeated; } +/** Rank vocal roles before instrumental roles. */ function vocalRank(role: RankedRoleMetadata): number { return role.isVocal ? 0 : 1; } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index f9483a16d..758702b67 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1610,6 +1610,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.ritardandoPlanSource !== undefined && value.ritardandoPlan === undefined) { return invalidField(`${path}.ritardandoPlanSource`); } + if (value.ritardandoPlan !== undefined && value.ritardandoPlanSource === undefined) { + return invalidField(`${path}.ritardandoPlanSource`); + } return null; } diff --git a/packages/shared-types/test/ritardandoPlanProvenance.test.ts b/packages/shared-types/test/ritardandoPlanProvenance.test.ts index af17e36a3..2e33fa588 100644 --- a/packages/shared-types/test/ritardandoPlanProvenance.test.ts +++ b/packages/shared-types/test/ritardandoPlanProvenance.test.ts @@ -29,6 +29,14 @@ describe("ritardandoPlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); }); + it("rejects ritardando plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + delete role.ritardandoPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + it.each(["", " ", "ease here\nthen hold", "ease here\rthen hold"])( "rejects a ritardando plan source with blank or multiline copy %j", (ritardandoPlan) => { From de41cbaf2e9168b9fb62a0229c7da83d5fa9e6d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 01:10:51 -0700 Subject: [PATCH 03/25] test(workspace): require rit open confirmation for user plans --- .../workspace/FirstRitardandoCallout.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx index c604f7b6f..52de12edb 100644 --- a/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx @@ -98,6 +98,23 @@ describe("FirstRitardandoCallout", () => { expect(screen.getByText(DEMO_RITARDANDO_PLAN)).toBeTruthy(); }); + it("shows armed confirmation for user-sourced plans without rewriting user copy", () => { + const song = songWithRitardandoPlan(); + const userPlan = "Ease here exactly as our band agreed."; + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + vocal.ritardandoPlan = userPlan; + vocal.ritardandoPlanSource = "user"; + appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + expect(screen.getByText(userPlan)).toBeTruthy(); + }); + it("reports when the map section cannot be opened", () => { render(); From 0dc37b934f395abaa3a8d7ac6c3e032572e35ac0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 01:11:29 -0700 Subject: [PATCH 04/25] fix(workspace): confirm opened user rit plans --- .../desktop/src/features/workspace/FirstRitardandoCallout.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx index 751774535..0f7b41d88 100644 --- a/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx @@ -177,9 +177,7 @@ export function FirstRitardandoCallout({

{t("firstRitardandoPlanLabel")}

-

- {opened && named.ritardandoPlanSource === "model" ? armed : body} -

+

{opened ? armed : body}

{ritardandoPlan}