From f5c9bee1369d37b2636d4766188f1ec7f4bd2a62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 05:05:47 +0000 Subject: [PATCH 01/25] feat(workspace): name tonight's first fade plan on the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the earliest corroborated intensity fall so the staying part can land the next downbeat quieter. Engine emission requires the same source set, already-audible named vocals or bass, current still audible, and previous RMS ≥1.8× current. Heuristic topology, accompaniment other, swells, drops, cutoffs, and breakdowns stay unnamed. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/core/src/lib.rs | 37 +- apps/desktop/core/tests/fade_plan_contract.rs | 127 +++++ ...stFadePlanCallout.custom-guidance.test.tsx | 82 ++++ .../FirstFadePlanCallout.identity.test.tsx | 21 + ...adePlanCallout.navigation-failure.test.tsx | 30 ++ .../FirstFadePlanCallout.particle.test.tsx | 82 ++++ .../FirstFadePlanCallout.provenance.test.tsx | 87 ++++ ...rstFadePlanCallout.reduced-motion.test.tsx | 52 +++ .../workspace/FirstFadePlanCallout.test.tsx | 161 +++++++ .../workspace/FirstFadePlanCallout.tsx | 221 +++++++++ ...tFadePlanCallout.unavailable-copy.test.tsx | 32 ++ .../workspace/Workspace.fade-state.test.tsx | 72 +++ .../src/features/workspace/Workspace.tsx | 34 +- ...tFadePlan.accompaniment-provenance.test.ts | 48 ++ .../workspace/firstFadePlan.demo.test.ts | 9 + .../firstFadePlan.model-guidance.test.ts | 39 ++ .../firstFadePlan.proxy-authority.test.ts | 28 ++ .../firstFadePlan.source-continuity.test.ts | 56 +++ .../features/workspace/firstFadePlan.test.ts | 369 +++++++++++++++ .../src/features/workspace/firstFadePlan.ts | 432 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 31 +- apps/desktop/src/i18n/index.ts | 34 ++ apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- packages/shared-types/src/index.ts | 27 +- .../test/fadePlanProvenance.test.ts | 39 ++ packages/shared-types/test/index.test.ts | 6 + .../src/bandscope_analysis/roles/activity.py | 85 +++- .../src/bandscope_analysis/roles/extractor.py | 144 +++++- .../src/bandscope_analysis/roles/model.py | 4 +- .../analysis-engine/tests/test_activity.py | 39 ++ .../analysis-engine/tests/test_fade_plan.py | 368 +++++++++++++++ 36 files changed, 2793 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/core/tests/fade_plan_contract.rs create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstFadePlan.ts create mode 100644 packages/shared-types/test/fadePlanProvenance.test.ts create mode 100644 services/analysis-engine/tests/test_fade_plan.py diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..64fedf6ec 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 fade plan with the owning part when a staying named role is corroborated, the owned `fadePlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes. - 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..83c0fb9b2 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 fade plan on the mounted map when section-level stem energy shows a corroborated intensity fall (same distinct source set stays, named vocals or bass previous RMS ≥1.8× current after already-audible previous, current still audible), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-swell, first-drop, first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. Accompaniment other never owns. - 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..6d9a60c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first fade plan in the mounted rehearsal workspace so the part that quiets in place can land the next downbeat on the map; real analyzed songs now receive this guidance only when section-level stem energy shows the same distinct source set staying while named vocals or bass RMS falls by at least 1.8× after an already-audible previous section and the current section stays audible, 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..90a5fb2cc 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 fade 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, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-swell, first-drop, first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. `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..425472709 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 FadePlanSourcePayload { + 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")] + fade_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fade_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_fade_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role.fade_plan.as_ref().is_some_and(|fade_plan| { + fade_plan.trim().is_empty() + || fade_plan.contains('\n') + || fade_plan.contains('\r') + }) { + return Err("Invalid project file format".to_string()); + } + if role.fade_plan.is_none() && role.fade_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_fade_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": 30, "end": 46 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Stem energy corroborates the fade." + }, + "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 quieter." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays while the level comes down." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "fadePlan": "Fade this part; let the next downbeat land quieter.", + "fadePlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Let the chorus fade together.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_fade_plan_provenance() { + let payload = song_with_fade_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 fade-plan fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["fadePlan"], + payload["sections"][0]["roles"][0]["fadePlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["fadePlanSource"], + json!("model") + ); +} + +#[test] +fn project_contract_rejects_fade_plan_source_without_fade_plan() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fadePlan"); + 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_fade_plan_copy_with_source() { + for fade_plan in ["", " ", "fade here\nthen hold", "fade here\rthen hold"] { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlan"] = json!(fade_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 fade-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_fade_plan_source() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlanSource"] = 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/FirstFadePlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..2f054ee16 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const appendedSongStructureTargets = new Set(); + +function songWithCustomFadePlan(source: "model" | "user" | undefined, text: string) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = text; + if (source) { + vocal.fadePlanSource = source; + } + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); +} + +describe("FirstFadePlanCallout custom guidance", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + }); + + it("preserves user-authored fade guidance verbatim", () => { + render( + + ); + expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy(); + }); + + it("keeps user-authored guidance in the plain body after opening the fade", () => { + appendSongStructureTarget(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("preserves custom copy without model provenance instead of rewriting it", () => { + render( + + ); + expect(screen.getByText("Stack the last bar and grow together.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx new file mode 100644 index 000000000..f494f0eca --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +it("gives co-mounted fade-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first fade plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..d9f3efeec --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = "Fade this part; let the next downbeat land quieter."; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout navigation failure", () => { + it("names the next action when the rendered map target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect( + screen.getByRole("status").textContent + ).toBe("Could not open this fade on the song map. Use the map below to find the section."); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx new file mode 100644 index 000000000..a50e6316a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithKoreanFade( + fadePlan: string, + fadePlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + fadePlan, + ...(fadePlanSource ? { fadePlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the fade action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanFade( + "Fade this part; let the next downbeat land quieter.", + "model" + ); + + 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 = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:30 코러스에서 피아노 파트가 페이드합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 페이드 열기" })); + + expect( + screen.getByText("0:30에서 피아노 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx new file mode 100644 index 000000000..b4d0bed41 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithKoreanFade( + fadePlan: string, + fadePlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + fadePlan, + ...(fadePlanSource ? { fadePlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout fade-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user fade guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Fade this part; let the next downbeat land quieter."; + const song = songWithKoreanFade(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); + }); + + it("does not infer model authority when persisted fade guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Fade this part; let the next downbeat land quieter."; + const song = songWithKoreanFade(legacyPlan); + + render(); + + expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); + }); + + it("localizes model guidance from structured landing topology instead of display sentence wording", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanFade( + "Fade this part with Keyboard 1 Right Hand; let the next downbeat land quieter.", + "model" + ); + + render(); + + expect( + screen.getByText( + "Keyboard 1 Right Hand 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ) + ).toBeTruthy(); + expect( + screen.queryByText( + "Fade this part with Keyboard 1 Right Hand; let the next downbeat land quieter." + ) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..9e9080d6d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = DEMO_FADE_PLAN; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout reduced motion", () => { + afterEach(() => { + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => { + node.parentElement?.remove(); + }); + vi.unstubAllGlobals(); + }); + + it("uses immediate scrolling when the operating system requests reduced motion", () => { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { configurable: true, value: scrollIntoView }); + grid.appendChild(target); + document.body.appendChild(grid); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion: reduce"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx new file mode 100644 index 000000000..448f43b09 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx @@ -0,0 +1,161 @@ +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 { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; +const appendedSongStructureTargets = new Set(); + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = DEMO_FADE_PLAN; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + 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("FirstFadePlanCallout", () => { + 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 fade 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 = songWithFadePlan(); + 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 fade at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithFadePlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No fade plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same fade signature", () => { + const firstSong = songWithFadePlan(); + const nextSong = songWithFadePlan(); + 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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithFadePlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Singer fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Singer together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("resets armed guidance when the section label changes in the same workspace", () => { + const firstSong = songWithFadePlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.label = "bridge"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the bridge at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("opens the named fade on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + expect(screen.getByText(DEMO_FADE_PLAN)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx new file mode 100644 index 000000000..031e7d00d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx @@ -0,0 +1,221 @@ +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 { + formatFadePlanTime, + resolveFirstFadePlan, + type FadePlanGuidance +} from "./firstFadePlan"; + +/** Props for the first fade-plan rehearsal callout. */ +export interface FirstFadePlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type FadePlanCopyValues = Readonly>; +type FadePlanSource = "model" | "user"; + +type OpenedFadePlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + fadePlan: string; + fadePlanSource: FadePlanSource | null; + fadePlanGuidanceKind: FadePlanGuidance["kind"] | null; + fadePlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableFadePlanSongIdentity(song: RehearsalSong, workspaceInstanceKey: unknown): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate fade-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatFadePlanCopy(template: string, values: FadePlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof FadePlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model fade guidance from structured landing topology, never from display-copy grammar. */ +function localizedFadePlan( + fadePlan: string, + fadePlanSource: FadePlanSource | null, + guidance: FadePlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (fadePlanSource !== "model" || guidance === null) { + return fadePlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredFadePlanScrollBehavior(): 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 resolveFadePlanRenderer(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 fade plan and open the matching rendered map section. */ +export function FirstFadePlanCallout({ song, workspaceInstanceKey }: FirstFadePlanCalloutProps) { + const calloutId = `workspace-surface-fade-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableFadePlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstFadePlan(song), [song]); + const [openedFadePlan, setOpenedFadePlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.fadePlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.fadePlanGuidance?.kind === "role" ? named.fadePlanGuidance.targetRoleName : null; + + useEffect(() => { + setOpenedFadePlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.fadePlan, + named?.fadePlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedFadePlan !== null && + openedFadePlan.songIdentity === songIdentity && + openedFadePlan.sectionId === named.sectionId && + openedFadePlan.sectionIndex === named.sectionIndex && + openedFadePlan.sectionLabel === named.sectionLabel && + openedFadePlan.landingRoleId === named.landingRoleId && + openedFadePlan.landingRoleName === named.landingRoleName && + openedFadePlan.fadePlan === named.fadePlan && + openedFadePlan.fadePlanSource === named.fadePlanSource && + openedFadePlan.fadePlanGuidanceKind === guidanceKind && + openedFadePlan.fadePlanTargetRoleName === guidanceTargetRoleName && + openedFadePlan.atSeconds === named.atSeconds; + const at = formatFadePlanTime(named.atSeconds); + const copyValues: FadePlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatFadePlanCopy(t("firstFadePlanOpenAction"), copyValues); + const body = formatFadePlanCopy(t("firstFadePlanBody"), copyValues); + const armed = formatFadePlanCopy(t("firstFadePlanArmed"), copyValues); + const fadePlan = localizedFadePlan( + named.fadePlan, + named.fadePlanSource, + named.fadePlanGuidance, + t("firstFadePlanGeneratedGuidance"), + t("firstFadePlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..baacf754c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +describe("FirstFadePlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English fade plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No fade plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean fade plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx new file mode 100644 index 000000000..dd79e9cb7 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -0,0 +1,72 @@ +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 analyzedSongWithFadePlan(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + verse.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: true, handoff_to: [], handoff_from: [] } + ]; + + const chorus = structuredClone(verse); + chorus.id = "chorus-fade-state"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.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: true, handoff_to: [], handoff_from: [] } + ]; + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = "Fade this part; let the next downbeat land quieter."; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace fade state authority", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + }); + + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + }); + + it("keeps an opened fade armed after an immutable practice-progress update", () => { + const song = analyzedSongWithFadePlan(); + 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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter 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(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..e6a482b78 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 { FirstFadePlanCallout } from "./FirstFadePlanCallout"; 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 : []), @@ -310,6 +334,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ +

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts new file mode 100644 index 000000000..b7e915e02 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +const MODEL_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +describe("resolveFirstFadePlan accompaniment provenance", () => { + it("does not name a shared accompaniment role from persisted fade metadata", () => { + const song = createDemoRehearsalSong(); + const template = structuredClone(song.sections[0]!); + const bass = structuredClone(template.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(template.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(template.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, vocal]) { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + } + keys.fadePlan = MODEL_FADE_PLAN; + keys.fadePlanSource = "model"; + + const previous = structuredClone(template); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + previous.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: true, handoff_to: [], handoff_from: [] } + ]; + + const fade = structuredClone(template); + fade.id = "chorus-fade"; + fade.label = "chorus"; + fade.timeRange = { start: 10, end: 30 }; + fade.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + fade.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: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, fade]; + + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts new file mode 100644 index 000000000..053078812 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +describe("resolveFirstFadePlan demo topology", () => { + it("keeps heuristic demo topology unnamed until real stem energy corroborates a fade", () => { + expect(resolveFirstFadePlan(createDemoRehearsalSong())).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts new file mode 100644 index 000000000..c62a091c1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +it("rejects non-template model fade guidance instead of rendering untranslated copy", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + const previous = structuredClone(seed); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys, vocal]; + previous.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: true, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-fade"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + vocal.fadePlan = "Model says: grow the chorus hard."; + vocal.fadePlanSource = "model"; + current.roles = [vocal, 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: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, current]; + + expect(resolveFirstFadePlan(song)).toBeNull(); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts new file mode 100644 index 000000000..626b29d38 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +describe("resolveFirstFadePlan proxy authority", () => { + it("does not read inherited or Proxy-substituted fadePlan as rehearsal copy", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + const hostile = new Proxy(vocal, { + get(_target, property) { + if (property === "fadePlan") { + return "Fade this part; let the next downbeat land quieter."; + } + return Reflect.get(_target, property); + } + }); + chorus.roles = chorus.roles.map((role) => (role.id === "lead-vocal" ? hostile : role)); + song.sections = [verse, chorus]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts new file mode 100644 index 000000000..d0427f248 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts @@ -0,0 +1,56 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +const FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +describe("resolveFirstFadePlan source continuity", () => { + it("keeps the shared accompaniment source across a keys-to-guitar role swap", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const guitar = structuredClone(keys); + guitar.id = "acoustic-guitar"; + guitar.name = "Acoustic Guitar"; + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, guitar, vocal]) { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + } + vocal.fadePlan = FADE_PLAN; + vocal.fadePlanSource = "model"; + + const previous = structuredClone(seed); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys, vocal]; + previous.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: "acoustic-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-fade"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + current.roles = [bass, guitar, vocal]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "acoustic-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, current]; + + const resolved = resolveFirstFadePlan(song); + expect(resolved?.sectionId).toBe("chorus-fade"); + expect(resolved?.landingRoleId).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe(FADE_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.test.ts new file mode 100644 index 000000000..8e795e5b0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatFadePlanTime, resolveFirstFadePlan } from "./firstFadePlan"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +function withFadeSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + fadePlan?: string; + label?: + | "intro" + | "verse" + | "pre-chorus" + | "chorus" + | "bridge" + | "outro" + | "tag" + | "pickup" + | "stop" + | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + wasActive?: boolean; + previousVocalActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 10; + const previousStart = overrides.previousStart ?? 0; + const roleId = overrides.roleId ?? "lead-vocal"; + const keys = structuredClone(verse.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(verse.roles.find((role) => role.id === "lead-vocal")!); + const bass = structuredClone(verse.roles.find((role) => role.id === "bass-guitar")!); + delete (keys as { fadePlan?: string }).fadePlan; + delete (keys as { fadePlanSource?: string }).fadePlanSource; + delete (vocal as { fadePlan?: string }).fadePlan; + delete (vocal as { fadePlanSource?: string }).fadePlanSource; + delete (bass as { fadePlan?: string }).fadePlan; + delete (bass as { fadePlanSource?: string }).fadePlanSource; + + const landing = { + ...(roleId === "keys-right" ? keys : roleId === "bass-guitar" ? bass : vocal), + id: roleId, + name: + overrides.roleName ?? + (roleId === "keys-right" + ? "Keyboard 1 Right Hand" + : roleId === "bass-guitar" + ? "Bass Guitar" + : "Lead Vocal"), + rehearsalPriority: overrides.priority ?? "high", + fadePlan: overrides.fadePlan ?? DEMO_FADE_PLAN + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-fade"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 20 }; + 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 === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.roles = [landing, bass, vocal]; + } + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.roles = [landing, keys, vocal]; + } + + const previous = structuredClone(current); + previous.id = `${current.id}-hold`; + previous.label = "verse"; + previous.timeRange = { start: previousStart, end: landingStart }; + previous.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + previous.roles.forEach((role) => { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + }); + const previousVocalActive = overrides.previousVocalActive ?? true; + previous.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: previousVocalActive, + handoff_to: [], + handoff_from: [] + } + ]; + if (overrides.wasActive === false) { + previous.partGraph = previous.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId ? false : node.is_active + })); + } + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstFadePlan", () => { + it("picks the earliest fade plan and the part that quiets in place", () => { + const resolved = resolveFirstFadePlan(withFadeSection()); + expect(resolved?.section.id).toBe("chorus-fade"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe(DEMO_FADE_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatFadePlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatFadePlanTime(Number.NaN)).toBe("0:00"); + expect(formatFadePlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a fade plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withFadeSection(); + delete song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.fadePlan; + const landing = song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!; + song.sections[1]!.groove = "Straight eighths with a late snare feel"; + landing.simplification = "Stay on roots if the chorus entrance gets muddy."; + landing.setupNote = DEMO_FADE_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + (landing as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (landing as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (landing as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (landing as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (landing as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the pickup."; + (landing as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (landing as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (landing as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (landing as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + (landing as { cutoffPlan?: string }).cutoffPlan = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + (landing as { turnaroundPlan?: string }).turnaroundPlan = + "Turn these last bars with Lead Vocal; land the downbeat together."; + (landing as { pickupPlan?: string }).pickupPlan = + "Play this pickup with Lead Vocal; land the downbeat together."; + (landing as { breakdownPlan?: string }).breakdownPlan = + "Hold this breakdown; keep it sparse until the drop."; + (landing as { dropPlan?: string }).dropPlan = + "Hit this drop; come in together when the texture fills."; + landing.cue = { kind: "lyric", value: "city lights" }; + landing.range = { lowestNote: "G#3", highestNote: "C#5" }; + landing.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + landing.harmony = { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "user" + }; + landing.harmonicExplanation = "The vocal lands the chorus center."; + landing.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + landing.confidence = { + level: "high", + source: "user", + notes: DEMO_FADE_PLAN + }; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("skips a blank fade plan", () => { + expect(resolveFirstFadePlan(withFadeSection({ fadePlan: " " }))).toBeNull(); + }); + + it("skips a multi-line fade plan", () => { + expect( + resolveFirstFadePlan(withFadeSection({ fadePlan: "Grow together.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two fade plans", () => { + const song = withFadeSection({ + id: "chorus-late-fade", + start: 40, + end: 56, + previousStart: 24, + roleId: "lead-vocal", + fadePlan: "Late fade." + }); + const earlier = structuredClone(song.sections[1]!); + earlier.id = "chorus-early"; + earlier.roles = [ + { + ...earlier.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + fadePlan: "Earlier fade." + }, + ...earlier.roles.filter((role) => role.id !== "lead-vocal") + ]; + earlier.timeRange = { start: 8, end: 24 }; + const earlierHold = structuredClone(song.sections[0]!); + earlierHold.id = "verse-before-early"; + earlierHold.timeRange = { start: 0, end: 8 }; + song.sections[0]!.timeRange = { start: 24, end: 40 }; + song.sections = [earlierHold, earlier, song.sections[0]!, song.sections[1]!]; + + const resolved = resolveFirstFadePlan(song); + expect(resolved?.section.id).toBe("chorus-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe("Earlier fade."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time fade-plan ties with locale-independent id ordering", () => { + const song = withFadeSection({ id: "ä-fade", start: 10, end: 26 }); + const umlautHold = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiHold = structuredClone(umlautHold); + asciiHold.id = "z-fade-hold"; + const ascii = structuredClone(umlaut); + ascii.id = "z-fade"; + song.sections = [umlautHold, umlaut, asciiHold, ascii]; + + expect(resolveFirstFadePlan(song)?.section.id).toBe("z-fade"); + }); + + it("prefers a high-priority landing part over a low-priority part in the same section", () => { + const song = withFadeSection({ + roleId: "bass-guitar", + roleName: "Bass Guitar", + priority: "low", + fadePlan: "Low-priority fade." + }); + const section = song.sections[1]!; + const highRole = { + ...section.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + fadePlan: "High-priority fade." + }; + section.roles = [...section.roles.filter((role) => role.id !== "lead-vocal"), highRole]; + section.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: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstFadePlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstFadePlan(song)?.fadePlan).toBe("High-priority fade."); + }); + + it("skips a fade plan whose graph node is inactive", () => { + expect(resolveFirstFadePlan(withFadeSection({ isActive: false }))).toBeNull(); + }); + + it("skips a fade plan whose previous graph node was inactive", () => { + expect(resolveFirstFadePlan(withFadeSection({ wasActive: false }))).toBeNull(); + }); + + it("skips a fade whose previous graph did not already include the landing", () => { + expect(resolveFirstFadePlan(withFadeSection({ previousVocalActive: false }))).toBeNull(); + }); + + it("skips a fade plan whose rest and landing windows do not abut", () => { + const song = withFadeSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("skips a fade plan whose rehearsal window is unbounded", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a fade plan whose end precedes its start", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length fade-plan window", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a fade plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstFadePlan( + withFadeSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstFadePlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withFadeSection(); + song.sections[1]!.roles = [null as never, ...song.sections[1]!.roles]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstFadePlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withFadeSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("keeps the fade plan unnamed when role identities are duplicated", () => { + const song = withFadeSection(); + const role = song.sections[1]!.roles.find((item) => item.id === "lead-vocal")!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("does not name a density fill as a fade", () => { + const song = withFadeSection({ previousVocalActive: false }); + song.sections[1]!.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: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("does not name a density drop as a fade", () => { + const song = withFadeSection(); + song.sections[1]!.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.ts b/apps/desktop/src/features/workspace/firstFadePlan.ts new file mode 100644 index 000000000..5b373b222 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.ts @@ -0,0 +1,432 @@ +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_FADE_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const ACCOMPANIMENT_SOURCE_ROLE_IDS = new Set([ + "keys-left", + "keys-right", + "acoustic-guitar" +]); +const ACCOMPANIMENT_SOURCE_ID = "other"; +const FADE_PLAN_SOLO = "Fade this part; let the next downbeat land quieter."; +const FADE_PLAN_PREFIX = "Fade this part with "; +const FADE_PLAN_SUFFIX = "; let the next downbeat land quieter."; + +type FadePlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated fade-plan copy. */ +export type FadePlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedFadePlan = Readonly<{ + text: string; + source: FadePlanSource | null; + guidance: FadePlanGuidance | null; +}>; + +/** Tonight's first fade plan: the earliest labeled intensity fall on staying sources. */ +export type FirstFadePlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + fadePlan: string; + fadePlanSource: FadePlanSource | null; + fadePlanGuidance: FadePlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative fade-plan time as m:ss for rehearsal copy. */ +export function formatFadePlanTime(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 fade template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedFadePlan(value: string): OwnedFadePlan | null { + if (value === FADE_PLAN_SOLO) { + return { text: FADE_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(FADE_PLAN_PREFIX) || !value.endsWith(FADE_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(FADE_PLAN_PREFIX.length, -FADE_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(FADE_PLAN_PREFIX + FADE_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_FADE_PLAN_CHARACTERS - fixedLength); + return { + text: `${FADE_PLAN_PREFIX}${boundedTarget}${FADE_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own fade plan and its explicit provenance, or null when malformed. */ +function ownedFadePlan(role: unknown): OwnedFadePlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const fadePlan = ownDataValue(role, "fadePlan"); + const fadePlanSource = ownDataValue(role, "fadePlanSource"); + if (typeof fadePlan !== "string") { + return null; + } + if (fadePlanSource !== undefined && fadePlanSource !== "model" && fadePlanSource !== "user") { + return null; + } + const trimmed = fadePlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (fadePlanSource === "model") { + return boundedGeneratedFadePlan(trimmed); + } + return { + text: truncateCodePoints(trimmed, MAX_FADE_PLAN_CHARACTERS), + source: fadePlanSource ?? 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 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; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** 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; +} + +/** Map canonical accompaniment roles back to their shared source-separation stem. */ +function fadeSourceId(roleId: string): string { + return ACCOMPANIMENT_SOURCE_ROLE_IDS.has(roleId) ? ACCOMPANIMENT_SOURCE_ID : roleId; +} + +/** Prefer rehearsal priority, 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; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return unique graph role ids whose node is explicitly active or inactive. */ +function rankedGraphRoleIds(section: RehearsalSection, isActive: boolean): 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") !== isActive) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + if (!roles) { + return []; + } + const activeIds = rankedGraphRoleIds(section, true); + 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); + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && !repeatedRoleIds.has(metadata.id) && activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Return distinct source-separation stems that are explicitly active. */ +function activeSourceIds(activeIds: Set): Set { + return new Set([...activeIds].map((roleId) => fadeSourceId(roleId))); +} + +/** Resolve a fade plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstFadePlan(song: RehearsalSong): FirstFadePlan | 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) || sectionIndex === 0) { + return []; + } + const previousSection = sections[sectionIndex - 1]; + if (!isRuntimeObject(previousSection)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + const previousTimeRange = ownedBoundedTimeRange(previousSection as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null || + previousTimeRange === null || + previousTimeRange.end !== timeRange.start + ) { + return []; + } + + const previousActiveIds = rankedGraphRoleIds(previousSection as RehearsalSection, true); + const currentActiveIds = rankedGraphRoleIds(section as RehearsalSection, true); + const previousSourceIds = activeSourceIds(previousActiveIds); + const currentSourceIds = activeSourceIds(currentActiveIds); + if (previousSourceIds.size < 1 || currentSourceIds.size !== previousSourceIds.size) { + return []; + } + for (const sourceId of previousSourceIds) { + if (!currentSourceIds.has(sourceId)) { + return []; + } + } + + const landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if ( + !previousActiveIds.has(metadata.id) || + ACCOMPANIMENT_SOURCE_ROLE_IDS.has(metadata.id) + ) { + return []; + } + const fadePlan = ownedFadePlan(metadata.role); + return fadePlan === null + ? [] + : [ + { + ...metadata, + fadePlan: fadePlan.text, + fadePlanSource: fadePlan.source, + fadePlanGuidance: fadePlan.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, + fadePlan: landingRole.fadePlan, + fadePlanSource: landingRole.fadePlanSource, + fadePlanGuidance: landingRole.fadePlanGuidance, + 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 fade plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstFadePlan(song: RehearsalSong): FirstFadePlan | null { + try { + return resolveSafeFirstFadePlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..13671dee7 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,33 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes supported section form labels", () => { + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-fade-plan next-action copy particle-safe and tonally consistent", () => { + const t = createTranslator("ko"); + expect(t("firstFadePlanOpenAction")).toBe("{at} {role} 페이드 열기"); + expect(t("firstFadePlanBody")).toBe("{at} {section}에서 {role} 파트가 페이드합니다."); + expect(t("firstFadePlanArmed")).toBe( + "{at}에서 {role} 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요." + ); + expect(t("firstFadePlanGeneratedGuidance")).toBe( + "{target} 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ); + expect(t("firstFadePlanGeneratedSoloGuidance")).toBe( + "이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ); + }); + }); + }); 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..36aa1d594 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,13 @@ "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}.", + "firstFadePlanLabel": "Tonight's first fade plan", + "firstFadePlanOpenAction": "Open {role} fade at {at}", + "firstFadePlanBody": "{role} fades the {section} at {at}.", + "firstFadePlanArmed": "Fade {role} together at {at} so the quieter landing is audible.", + "firstFadePlanGeneratedGuidance": "Fade this part with {target}; let the next downbeat land quieter.", + "firstFadePlanGeneratedSoloGuidance": "Fade this part; let the next downbeat land quieter.", + "firstFadePlanUnavailable": "No fade plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstFadePlanNavigationFailed": "Could not open this fade 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..8d3981361 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,13 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstFadePlanLabel": "오늘 첫 페이드 계획", + "firstFadePlanOpenAction": "{at} {role} 페이드 열기", + "firstFadePlanBody": "{at} {section}에서 {role} 파트가 페이드합니다.", + "firstFadePlanArmed": "{at}에서 {role} 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요.", + "firstFadePlanGeneratedGuidance": "{target} 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.", + "firstFadePlanGeneratedSoloGuidance": "이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.", + "firstFadePlanUnavailable": "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstFadePlanNavigationFailed": "곡 맵에서 이 페이드를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..e73821a3d 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; + fadePlan?: string; + fadePlanSource?: ProvenanceSource; }; /** Documented. */ @@ -1500,7 +1502,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "fadePlan", + "fadePlanSource" ], path ); @@ -1588,6 +1592,27 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.fadePlan !== undefined && + ( + typeof value.fadePlan !== "string" || + value.fadePlan.trim().length === 0 || + value.fadePlan.includes("\n") || + value.fadePlan.includes("\r") + ) + ) { + return invalidField(`${path}.fadePlan`); + } + if ( + value.fadePlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.fadePlanSource) + ) { + return invalidField(`${path}.fadePlanSource`); + } + if (value.fadePlanSource !== undefined && value.fadePlan === undefined) { + return invalidField(`${path}.fadePlanSource`); + } + return null; } diff --git a/packages/shared-types/test/fadePlanProvenance.test.ts b/packages/shared-types/test/fadePlanProvenance.test.ts new file mode 100644 index 000000000..19f6d6371 --- /dev/null +++ b/packages/shared-types/test/fadePlanProvenance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("fadePlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s fade plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + role.fadePlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.fadePlanSource).toBe(source); + }); + + it("rejects an unknown fade plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + role.fadePlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + + it("rejects a fade plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.fadePlan; + role.fadePlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + + it.each(["", " ", "fade here\nthen hold", "fade here\rthen hold"])( + "rejects a fade plan source with blank or multiline copy %j", + (fadePlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = fadePlan; + role.fadePlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlan/); + } + ); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..befdcfef2 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].fadePlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.fadePlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/activity.py b/services/analysis-engine/src/bandscope_analysis/roles/activity.py index 623e24e77..be570e8d9 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/activity.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/activity.py @@ -26,6 +26,22 @@ STEM_NAMES = ("vocals", "bass", "drums", "other") +def _segment_rms( + audio: NDArray[np.floating[Any]] | object, + start_sample: int, + end_sample: int, +) -> float | None: + """Return RMS for one bounded stem slice, or None when the slice is unusable.""" + if not isinstance(audio, np.ndarray) or audio.size == 0: + return None + seg_start = min(start_sample, audio.size) + seg_end = min(end_sample, audio.size) + if seg_end <= seg_start: + return None + segment = audio[seg_start:seg_end].astype(np.float64) + return float(np.sqrt(np.mean(segment**2))) + + def detect_stem_activity( stems: dict[str, NDArray[np.floating[Any]]], boundaries: list[tuple[float, float]], @@ -61,22 +77,11 @@ def detect_stem_activity( segment_activity: dict[str, bool] = {} for stem_name, audio in stems.items(): - if not isinstance(audio, np.ndarray) or audio.size == 0: + segment_rms = _segment_rms(audio, start_sample, end_sample) + if segment_rms is None: segment_activity[stem_name] = False continue - # Extract the segment region - seg_start = min(start_sample, audio.size) - seg_end = min(end_sample, audio.size) - - if seg_end <= seg_start: - segment_activity[stem_name] = False - continue - - segment = audio[seg_start:seg_end].astype(np.float64) - segment_rms = float(np.sqrt(np.mean(segment**2))) - - # A stem is active if its segment energy exceeds threshold relative to global g_rms = global_rms.get(stem_name, 0.0) if g_rms > 0: is_active = (segment_rms / g_rms) > ACTIVITY_THRESHOLD @@ -118,6 +123,60 @@ def map_stems_to_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: } +def detect_stem_energy( + stems: dict[str, NDArray[np.floating[Any]]], + boundaries: list[tuple[float, float]], + sr: int, +) -> list[dict[str, float]]: + """Return per-segment RMS energy for each stem. + + Args: + stems: Dict mapping stem names to audio arrays. + boundaries: List of (start_seconds, end_seconds) tuples. + sr: Sample rate. + + Returns: + List of dicts mapping stem name -> RMS, one per boundary. Missing or + unusable slices fail closed as 0.0 so a fade cannot be invented from + empty audio. + """ + if not boundaries or not stems: + return [] + + energy_per_segment: list[dict[str, float]] = [] + for start_sec, end_sec in boundaries: + start_sample = int(start_sec * sr) + end_sample = int(end_sec * sr) + segment_energy: dict[str, float] = {} + for stem_name, audio in stems.items(): + rms = _segment_rms(audio, start_sample, end_sample) + segment_energy[stem_name] = 0.0 if rms is None else rms + energy_per_segment.append(segment_energy) + return energy_per_segment + + +def map_stems_to_role_energy(stem_energy: dict[str, float]) -> dict[str, float]: + """Map stem RMS onto rehearsal roles without inventing a drums landing. + + Args: + stem_energy: Dict mapping stem names to RMS energy. + + Returns: + Dict mapping role IDs to RMS. Shared accompaniment roles receive the + ``other`` stem energy but never own a fade. + """ + vocals = float(stem_energy.get("vocals", 0.0) or 0.0) + bass = float(stem_energy.get("bass", 0.0) or 0.0) + other = float(stem_energy.get("other", 0.0) or 0.0) + return { + "bass-guitar": bass, + "keys-left": other, + "keys-right": other, + "lead-vocal": vocals, + "acoustic-guitar": other, + } + + def compute_handoffs( current_roles: dict[str, bool], next_roles: dict[str, bool] | None, diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..e92454b97 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -6,7 +6,13 @@ from typing import Any from ..sections.utils import validate_section -from .activity import compute_handoffs, detect_stem_activity, map_stems_to_roles +from .activity import ( + compute_handoffs, + detect_stem_activity, + detect_stem_energy, + map_stems_to_role_energy, + map_stems_to_roles, +) from .model import ( CueAnchorKind, PartGraphNode, @@ -22,6 +28,15 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_NAMED_FADE_ROLE_IDS = frozenset({"lead-vocal", "bass-guitar"}) +_FADE_PLAN_SOLO = "Fade this part; let the next downbeat land quieter." +_FADE_PLAN_PREFIX = "Fade this part with " +_FADE_PLAN_SUFFIX = "; let the next downbeat land quieter." +_FADE_RATIO = 1.8 +_FADE_PREVIOUS_FLOOR = 1e-4 +_FADE_CURRENT_FLOOR = 1e-4 + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -56,6 +71,7 @@ def extract( # Use real stem activity detection when we have stems and boundaries activity_maps: list[dict[str, bool]] | None = None + energy_maps: list[dict[str, float]] | None = None if stems and boundaries and len(boundaries) == len(sections): try: stem_activity = detect_stem_activity(stems, boundaries, sr) @@ -63,6 +79,12 @@ def extract( except Exception as e: logger.warning("Stem activity detection failed, using fallback: %s", e) activity_maps = None + try: + stem_energy = detect_stem_energy(stems, boundaries, sr) + energy_maps = [map_stems_to_role_energy(se) for se in stem_energy] + except Exception as e: + logger.warning("Stem energy detection failed, leaving fade unnamed: %s", e) + energy_maps = None for i, section in enumerate(sections): section_id = validate_section(section, i, logger) @@ -71,8 +93,19 @@ def extract( # Real activity-based topology current_activity = activity_maps[i] next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None + previous_activity = activity_maps[i - 1] if i > 0 else None + current_energy = energy_maps[i] if energy_maps is not None else None + previous_energy = ( + energy_maps[i - 1] if energy_maps is not None and i > 0 else None + ) topology = self._build_activity_topology( - section_id, roles, current_activity, next_activity + section_id, + roles, + current_activity, + next_activity, + previous_activity, + current_energy, + previous_energy, ) else: # Fallback to heuristic-based topology @@ -330,12 +363,104 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _source_id(role_id: str) -> str: + """Collapse accompaniment stems onto one rehearsal source.""" + return "other" if role_id in _OTHER_STEM_ROLE_IDS else role_id + + @staticmethod + def _active_role_ids(role_activity: dict[str, bool]) -> set[str]: + """Return role ids whose activity flag is explicitly true.""" + return {role_id for role_id, is_active in role_activity.items() if is_active} + + @classmethod + def _source_ids(cls, role_ids: set[str]) -> set[str]: + """Return distinct source-separation stems among the given roles.""" + return {cls._source_id(role_id) for role_id in role_ids} + + def _named_fade_ids( + self, + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool], + role_energy: dict[str, float] | None, + previous_role_energy: dict[str, float] | None, + ) -> set[str]: + """Return named staying roles whose RMS fell by the fade ratio.""" + if role_energy is None or previous_role_energy is None: + return set() + previous_active = self._active_role_ids(previous_role_activity) + current_active = self._active_role_ids(role_activity) + if self._source_ids(previous_active) != self._source_ids(current_active): + return set() + faded: set[str] = set() + for role_id in _NAMED_FADE_ROLE_IDS: + if role_id not in current_active or role_id not in previous_active: + continue + previous_rms = float(previous_role_energy.get(role_id, 0.0) or 0.0) + current_rms = float(role_energy.get(role_id, 0.0) or 0.0) + if previous_rms < _FADE_PREVIOUS_FLOOR: + continue + if current_rms < _FADE_CURRENT_FLOOR: + continue + if previous_rms < current_rms * _FADE_RATIO: + continue + faded.add(role_id) + return faded + + def _activity_fade_plan( + self, + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool] | None, + role_energy: dict[str, float] | None, + previous_role_energy: dict[str, float] | None, + ) -> str | None: + """Return bounded fade guidance only for a corroborated intensity fall. + + A fade plan is emitted only when real stem activity shows this named + part staying while its RMS falls by at least 1.8× after an already + audible previous section, the current section stays audible, and the + distinct source set does not change. A density fill (drop), a thinning + hold (breakdown), a leaving part (dropout), a cutoff to silence, a + first-section, heuristic topology, or an accompaniment ``other`` + landing stay unnamed. + """ + if previous_role_activity is None: + return None + if role_id in _OTHER_STEM_ROLE_IDS: + return None + faded = self._named_fade_ids( + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + ) + if role_id not in faded: + return None + partners = sorted(faded - {role_id}) + if not partners: + return _FADE_PLAN_SOLO + if len(partners) != 1: + return None + partner_id = partners[0] + other_name = next( + (role["name"] for role in roles.values() if role["id"] == partner_id), + None, + ) + if other_name is None: + return None + return f"{_FADE_PLAN_PREFIX}{other_name}{_FADE_PLAN_SUFFIX}" + def _build_activity_topology( self, section_id: str, roles: dict[str, RehearsalRole], role_activity: dict[str, bool], next_role_activity: dict[str, bool] | None, + previous_role_activity: dict[str, bool] | None = None, + role_energy: dict[str, float] | None = None, + previous_role_energy: dict[str, float] | None = None, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -357,7 +482,20 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + fade_plan = self._activity_fade_plan( + role_id, + roles, + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + ) + if fade_plan is not None: + role = role.copy() + role["fadePlan"] = fade_plan + role["fadePlanSource"] = "model" + active_roles.append(role) part_graph.append( { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..5d0ac16b8 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] + fadePlan: NotRequired[str] + fadePlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_activity.py b/services/analysis-engine/tests/test_activity.py index 9c3024c28..8eeb042ec 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -5,6 +5,8 @@ from bandscope_analysis.roles.activity import ( compute_handoffs, detect_stem_activity, + detect_stem_energy, + map_stems_to_role_energy, map_stems_to_roles, ) @@ -116,3 +118,40 @@ def test_compute_handoffs_no_changes_means_no_handoffs() -> None: for role_id in current: assert handoffs[role_id] == ([], []) + + +def test_detect_stem_energy_returns_segment_rms() -> None: + """Energy maps expose per-section RMS so a fade can be corroborated.""" + sr = 8 + vocals = np.concatenate( + [np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)] + ) + bass = np.full(sr * 2, 0.4, dtype=np.float32) + energy = detect_stem_energy( + {"vocals": vocals, "bass": bass}, + [(0.0, 1.0), (1.0, 2.0)], + sr, + ) + assert energy[0]["vocals"] > energy[1]["vocals"] * 1.8 + assert abs(energy[0]["bass"] - energy[1]["bass"]) < 1e-6 + + +def test_detect_stem_energy_fails_closed_for_empty_slices() -> None: + """Unusable slices must not invent fade energy.""" + stems = { + "vocals": np.array([], dtype=np.float32), + "bass": np.ones(10, dtype=np.float32), + } + energy = detect_stem_energy(stems, [(1.0, 2.0)], 10) + assert energy == [{"vocals": 0.0, "bass": 0.0}] + + +def test_map_stems_to_role_energy_shares_other_without_drums() -> None: + """Accompaniment roles share other energy; drums never land a fade.""" + mapped = map_stems_to_role_energy({"vocals": 0.2, "bass": 0.4, "other": 0.9, "drums": 1.0}) + assert mapped["lead-vocal"] == 0.2 + assert mapped["bass-guitar"] == 0.4 + assert mapped["keys-left"] == 0.9 + assert mapped["keys-right"] == 0.9 + assert mapped["acoustic-guitar"] == 0.9 + assert "drums" not in mapped diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py new file mode 100644 index 000000000..b2ec71010 --- /dev/null +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -0,0 +1,368 @@ +"""Tests for corroborated fade-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.roles.extractor import RoleExtractor +from bandscope_analysis.roles.model import RehearsalRole + +_SOLO_PLAN = "Fade this part; let the next downbeat land quieter." +_PREFIX = "Fade this part with " +_SUFFIX = "; let the next downbeat land quieter." + + +def _activity( + *, + bass: bool, + keys_right: bool, + vocal: bool, + keys_left: bool = False, + guitar: bool = False, + extra: dict[str, bool] | None = None, +) -> dict[str, bool]: + """Return a complete role-activity map for one section.""" + activity = { + "bass-guitar": bass, + "keys-left": keys_left, + "keys-right": keys_right, + "lead-vocal": vocal, + "acoustic-guitar": guitar, + } + if extra: + activity.update(extra) + return activity + + +def _energy( + *, + bass: float, + vocal: float, + other: float = 0.2, +) -> dict[str, float]: + """Return RMS energy mapped onto rehearsal roles.""" + return { + "bass-guitar": bass, + "keys-left": other, + "keys-right": other, + "lead-vocal": vocal, + "acoustic-guitar": other, + } + + +def _roles(extractor: RoleExtractor) -> dict[str, RehearsalRole]: + """Return canonical bass and vocal role fixtures for topology tests.""" + return extractor._build_roles( + "C#m7", + {"lowestNote": "C#2", "highestNote": "E3"}, + "C#m7", + {"lowestNote": "G#3", "highestNote": "C#5"}, + ) + + +def test_activity_fade_emits_solo_plan_for_a_staying_vocal_fall() -> None: + """A staying vocal that falls 1.8× names the fade without inventing a cutoff.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["fadePlan"] == _SOLO_PLAN + assert vocal["fadePlanSource"] == "model" + assert all("fadePlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"]) + + +def test_activity_fade_names_two_named_falls_as_partners() -> None: + """Vocal and bass falling together point at each other.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["fadePlan"] == f"{_PREFIX}Bass Guitar{_SUFFIX}" + assert roles_by_id["bass-guitar"]["fadePlan"] == f"{_PREFIX}Lead Vocal{_SUFFIX}" + assert "fadePlan" not in roles_by_id["keys-right"] + + +def test_activity_fade_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a fade.""" + extractor = RoleExtractor() + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "verse-1", + _roles(extractor), + current, + None, + None, + _energy(bass=0.2, vocal=0.2), + None, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a fade plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_density_fill() -> None: + """A new entrance is a drop, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "drop-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.0), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_density_drop() -> None: + """A thinning hold is a breakdown, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=False) + topology = extractor._build_activity_topology( + "breakdown-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.0, other=0.0), + _energy(bass=0.5, vocal=0.5, other=0.5), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_ratio_is_too_small() -> None: + """A small mix dip is not a corroborated fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "mix-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.25), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_previous_energy_is_silent() -> None: + """Silence-to-quiet is not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "from-silence-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.0), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_current_energy_is_silent() -> None: + """Loud-to-silence is a cutoff, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "cutoff-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.0), + _energy(bass=0.2, vocal=0.5), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_swell() -> None: + """An intensity rise is a swell, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "swell-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.5), + _energy(bass=0.2, vocal=0.2), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_does_not_assign_an_other_stem_landing() -> None: + """The shared other stem may stay in the texture but never owns the fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True, keys_left=True, guitar=True) + current = _activity(bass=True, keys_right=True, vocal=True, keys_left=True, guitar=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2, other=0.2), + _energy(bass=0.2, vocal=0.5, other=0.9), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["fadePlan"] == _SOLO_PLAN + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "fadePlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_fade_keeps_shared_accompaniment_source_across_role_swap() -> None: + """A role swap inside the shared other stem does not invent a source change.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["fadePlan"] == _SOLO_PLAN + + +def test_extract_emits_fade_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section energy into fade emission.""" + extractor = RoleExtractor() + sr = 8 + bass = np.full(sr * 2, 0.4, dtype=np.float32) + other = np.full(sr * 2, 0.3, dtype=np.float32) + vocal = np.concatenate( + [np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)] + ) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": bass, "other": other, "vocals": vocal}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + chorus: dict[str, Any] = result["topologies"][1] + vocal_role = next(role for role in chorus["active_roles"] if role["id"] == "lead-vocal") + assert vocal_role.get("fadePlan") == _SOLO_PLAN + assert all("fadePlan" not in role for role in result["topologies"][0]["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_energy_maps_are_missing() -> None: + """Activity without RMS evidence cannot name a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + None, + None, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-named fade without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_fade_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=True, vocal=True), + _activity(bass=True, keys_right=True, vocal=True), + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + ) + assert plan is None + + +def test_activity_fade_stays_unnamed_when_role_is_inactive() -> None: + """An inactive role cannot own a fade even if RMS looks quieter.""" + extractor = RoleExtractor() + plan = extractor._activity_fade_plan( + "lead-vocal", + _roles(extractor), + _activity(bass=True, keys_right=True, vocal=False), + _activity(bass=True, keys_right=True, vocal=True), + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + assert plan is None + + +def test_extract_leaves_fade_unnamed_when_energy_detection_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Energy failures fail closed without dropping activity topology.""" + extractor = RoleExtractor() + + def _boom(*_args: object, **_kwargs: object) -> list[dict[str, float]]: + """Force energy detection to fail closed.""" + raise RuntimeError("energy unavailable") + + monkeypatch.setattr( + "bandscope_analysis.roles.extractor.detect_stem_energy", + _boom, + ) + sr = 8 + audio = np.ones(sr * 2, dtype=np.float32) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": audio, "other": audio, "vocals": audio}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + assert result["topologies"] + assert all( + "fadePlan" not in role + for topology in result["topologies"] + for role in topology["active_roles"] + ) From 4c6dd701643e4605d9624b949e4b243713f3f61f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 22:18:57 -0700 Subject: [PATCH 02/25] fix(ci): wrap fade regression assertion --- services/analysis-engine/tests/test_fade_plan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index b2ec71010..5d39a040d 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -80,7 +80,10 @@ def test_activity_fade_emits_solo_plan_for_a_staying_vocal_fall() -> None: vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") assert vocal["fadePlan"] == _SOLO_PLAN assert vocal["fadePlanSource"] == "model" - assert all("fadePlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"]) + assert all( + "fadePlan" not in role or role["id"] == "lead-vocal" + for role in topology["active_roles"] + ) def test_activity_fade_names_two_named_falls_as_partners() -> None: From 4ac3d81f4d4be6d6a9fc861bd2e5ee327c3b9cfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 22:19:32 -0700 Subject: [PATCH 03/25] test(review): reproduce fade state reset on chord edit --- .../workspace/Workspace.fade-state.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx index dd79e9cb7..e74ee0e73 100644 --- a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -43,6 +43,7 @@ describe("Workspace fade state authority", () => { }); afterEach(() => { + vi.restoreAllMocks(); if (originalScrollIntoView) { Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); } else { @@ -69,4 +70,26 @@ describe("Workspace fade state authority", () => { expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); }); + + it("keeps an opened fade armed after an immutable chord edit", () => { + const song = analyzedSongWithFadePlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + vi.spyOn(window, "prompt").mockReturnValue("Dm7"); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) + ); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + }); }); From ad921eefc1060734d215e77f15334568bbf025b5 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 14:24:54 +0900 Subject: [PATCH 04/25] style(analysis): apply pinned ruff formatting --- services/analysis-engine/tests/test_fade_plan.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index 5d39a040d..98f413e84 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -81,8 +81,7 @@ def test_activity_fade_emits_solo_plan_for_a_staying_vocal_fall() -> None: assert vocal["fadePlan"] == _SOLO_PLAN assert vocal["fadePlanSource"] == "model" assert all( - "fadePlan" not in role or role["id"] == "lead-vocal" - for role in topology["active_roles"] + "fadePlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"] ) @@ -276,9 +275,7 @@ def test_extract_emits_fade_across_real_stem_boundaries() -> None: sr = 8 bass = np.full(sr * 2, 0.4, dtype=np.float32) other = np.full(sr * 2, 0.3, dtype=np.float32) - vocal = np.concatenate( - [np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)] - ) + vocal = np.concatenate([np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)]) result = extractor.extract( [{"id": "verse-1"}, {"id": "chorus-1"}], { From c5193a3fde0c2496f089c8e17d81ba2d350db123 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 14:36:04 +0900 Subject: [PATCH 05/25] style(analysis): format extractor source --- .../analysis-engine/src/bandscope_analysis/roles/extractor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index e92454b97..65b24922c 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -95,9 +95,7 @@ def extract( next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None previous_activity = activity_maps[i - 1] if i > 0 else None current_energy = energy_maps[i] if energy_maps is not None else None - previous_energy = ( - energy_maps[i - 1] if energy_maps is not None and i > 0 else None - ) + previous_energy = energy_maps[i - 1] if energy_maps is not None and i > 0 else None topology = self._build_activity_topology( section_id, roles, From 83997d140f43b56f22de946379b14b01fb917b67 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 14:42:58 +0900 Subject: [PATCH 06/25] fix(workspace): preserve fade state across chord edits --- apps/desktop/src/features/workspace/Workspace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index e6a482b78..ab9ed6cf4 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -531,7 +531,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
From 2cba674794c3a043b0d264ba9a8d82809c444f34 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 14:55:24 +0900 Subject: [PATCH 07/25] fix(coverage): cover fade energy edge cases --- .../src/bandscope_analysis/roles/extractor.py | 6 +----- services/analysis-engine/tests/test_activity.py | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 65b24922c..bd41eb17e 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -391,9 +391,7 @@ def _named_fade_ids( if self._source_ids(previous_active) != self._source_ids(current_active): return set() faded: set[str] = set() - for role_id in _NAMED_FADE_ROLE_IDS: - if role_id not in current_active or role_id not in previous_active: - continue + for role_id in _NAMED_FADE_ROLE_IDS & current_active & previous_active: previous_rms = float(previous_role_energy.get(role_id, 0.0) or 0.0) current_rms = float(role_energy.get(role_id, 0.0) or 0.0) if previous_rms < _FADE_PREVIOUS_FLOOR: @@ -439,8 +437,6 @@ def _activity_fade_plan( partners = sorted(faded - {role_id}) if not partners: return _FADE_PLAN_SOLO - if len(partners) != 1: - return None partner_id = partners[0] other_name = next( (role["name"] for role in roles.values() if role["id"] == partner_id), diff --git a/services/analysis-engine/tests/test_activity.py b/services/analysis-engine/tests/test_activity.py index 8eeb042ec..9ee0e61d4 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -146,6 +146,12 @@ def test_detect_stem_energy_fails_closed_for_empty_slices() -> None: assert energy == [{"vocals": 0.0, "bass": 0.0}] +def test_detect_stem_energy_returns_empty_without_boundaries_or_stems() -> None: + """No input segments must produce no energy maps.""" + assert detect_stem_energy({}, [(0.0, 1.0)], 10) == [] + assert detect_stem_energy({"vocals": np.ones(10, dtype=np.float32)}, [], 10) == [] + + def test_map_stems_to_role_energy_shares_other_without_drums() -> None: """Accompaniment roles share other energy; drums never land a fade.""" mapped = map_stems_to_role_energy({"vocals": 0.2, "bass": 0.4, "other": 0.9, "drums": 1.0}) From 030521b9946a2e67e78a35f62331ceb18c1fa985 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:06:49 -0700 Subject: [PATCH 08/25] test(review): reject negative fade RMS ranges --- services/analysis-engine/tests/test_activity.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/analysis-engine/tests/test_activity.py b/services/analysis-engine/tests/test_activity.py index 9ee0e61d4..42a2cccfd 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -146,6 +146,16 @@ def test_detect_stem_energy_fails_closed_for_empty_slices() -> None: assert energy == [{"vocals": 0.0, "bass": 0.0}] +def test_detect_stem_energy_fails_closed_for_negative_boundaries() -> None: + """Negative section metadata must not read wrapped samples from the stem.""" + audio = np.zeros(20, dtype=np.float32) + audio[5:15] = 1.0 + + energy = detect_stem_energy({"vocals": audio}, [(-1.5, -0.5)], 10) + + assert energy == [{"vocals": 0.0}] + + def test_detect_stem_energy_returns_empty_without_boundaries_or_stems() -> None: """No input segments must produce no energy maps.""" assert detect_stem_energy({}, [(0.0, 1.0)], 10) == [] From 5bbe3e7fe2da803a1d2a9aebdf37132cdbd22ac9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:07:55 -0700 Subject: [PATCH 09/25] fix(review): clamp negative stem RMS boundaries --- .../analysis-engine/src/bandscope_analysis/roles/activity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/activity.py b/services/analysis-engine/src/bandscope_analysis/roles/activity.py index be570e8d9..399aa5af7 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/activity.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/activity.py @@ -34,8 +34,8 @@ def _segment_rms( """Return RMS for one bounded stem slice, or None when the slice is unusable.""" if not isinstance(audio, np.ndarray) or audio.size == 0: return None - seg_start = min(start_sample, audio.size) - seg_end = min(end_sample, audio.size) + seg_start = max(0, min(start_sample, audio.size)) + seg_end = max(0, min(end_sample, audio.size)) if seg_end <= seg_start: return None segment = audio[seg_start:seg_end].astype(np.float64) From 53c51633cc88c935fcba8d62c54ee0fc6d21a706 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:08:58 -0700 Subject: [PATCH 10/25] test(review): keep read-only workspace chord editor disabled --- .../src/features/workspace/Workspace.fade-state.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx index e74ee0e73..8dd23be99 100644 --- a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -92,4 +92,12 @@ describe("Workspace fade state authority", () => { expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); }); + + it("keeps chord editing unavailable when no song update handler exists", () => { + render(); + + expect( + screen.queryByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) + ).toBeNull(); + }); }); From a7b74588f9fac2db3e0b01bade831931648aade3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:10:48 -0700 Subject: [PATCH 11/25] fix(review): keep read-only chord editor unavailable --- apps/desktop/src/features/workspace/Workspace.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index ab9ed6cf4..57a6bcba9 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -531,11 +531,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file From f135109b9e89882b17ad754a7214e5fdb27e375d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:11:50 -0700 Subject: [PATCH 12/25] style(review): apply Ruff formatting to fade regression --- services/analysis-engine/tests/test_fade_plan.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index 98f413e84..35850dc8e 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -310,7 +310,11 @@ def test_activity_fade_stays_unnamed_when_energy_maps_are_missing() -> None: def test_activity_fade_stays_unnamed_when_partner_has_no_display_name() -> None: """A two-named fade without a named partner stays unnamed.""" extractor = RoleExtractor() - incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + incomplete = { + key: value + for key, value in _roles(extractor).items() + if key != "vocal" + } plan = extractor._activity_fade_plan( "bass-guitar", incomplete, @@ -365,4 +369,4 @@ def _boom(*_args: object, **_kwargs: object) -> list[dict[str, float]]: "fadePlan" not in role for topology in result["topologies"] for role in topology["active_roles"] - ) + ) \ No newline at end of file From ecfe905cac83bb7f4afd1053763b59bcac5f7840 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:15:31 -0700 Subject: [PATCH 13/25] style(ci): apply exact Ruff comprehension layout --- services/analysis-engine/tests/test_fade_plan.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index 35850dc8e..404b09a7e 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -311,9 +311,7 @@ def test_activity_fade_stays_unnamed_when_partner_has_no_display_name() -> None: """A two-named fade without a named partner stays unnamed.""" extractor = RoleExtractor() incomplete = { - key: value - for key, value in _roles(extractor).items() - if key != "vocal" + key: value for key, value in _roles(extractor).items() if key != "vocal" } plan = extractor._activity_fade_plan( "bass-guitar", From 4d06285a780d4a17b78f0826e79ba3f64595cfc0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 15:15:48 +0900 Subject: [PATCH 14/25] test(workspace): assert read-only chord editor is disabled --- .../src/features/workspace/Workspace.fade-state.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx index 8dd23be99..b505013ee 100644 --- a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -97,7 +97,7 @@ describe("Workspace fade state authority", () => { render(); expect( - screen.queryByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) - ).toBeNull(); + screen.getByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) + ).toBeDisabled(); }); }); From 008248327f2f3e3636d9d234221810f3db56829f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 15:24:46 +0900 Subject: [PATCH 15/25] style(analysis): format fade plan tests --- services/analysis-engine/tests/test_fade_plan.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index 404b09a7e..98f413e84 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -310,9 +310,7 @@ def test_activity_fade_stays_unnamed_when_energy_maps_are_missing() -> None: def test_activity_fade_stays_unnamed_when_partner_has_no_display_name() -> None: """A two-named fade without a named partner stays unnamed.""" extractor = RoleExtractor() - incomplete = { - key: value for key, value in _roles(extractor).items() if key != "vocal" - } + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} plan = extractor._activity_fade_plan( "bass-guitar", incomplete, @@ -367,4 +365,4 @@ def _boom(*_args: object, **_kwargs: object) -> list[dict[str, float]]: "fadePlan" not in role for topology in result["topologies"] for role in topology["active_roles"] - ) \ No newline at end of file + ) From 7224c45191741bb9b1910319631fda9dc59aecc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:15:10 -0700 Subject: [PATCH 16/25] test(review): reject fade copy without provenance --- packages/shared-types/test/fadePlanProvenance.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/shared-types/test/fadePlanProvenance.test.ts b/packages/shared-types/test/fadePlanProvenance.test.ts index 19f6d6371..eb136584f 100644 --- a/packages/shared-types/test/fadePlanProvenance.test.ts +++ b/packages/shared-types/test/fadePlanProvenance.test.ts @@ -26,6 +26,14 @@ describe("fadePlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); }); + it("rejects fade plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + delete role.fadePlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + it.each(["", " ", "fade here\nthen hold", "fade here\rthen hold"])( "rejects a fade plan source with blank or multiline copy %j", (fadePlan) => { From c42407daf6d68c42a87a24b2e1503592391185cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:15:40 -0700 Subject: [PATCH 17/25] test(review): reject native fade copy without provenance --- apps/desktop/core/tests/fade_plan_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/core/tests/fade_plan_contract.rs b/apps/desktop/core/tests/fade_plan_contract.rs index 57af2fce9..49852731d 100644 --- a/apps/desktop/core/tests/fade_plan_contract.rs +++ b/apps/desktop/core/tests/fade_plan_contract.rs @@ -100,6 +100,21 @@ fn project_contract_rejects_fade_plan_source_without_fade_plan() { ); } +#[test] +fn project_contract_rejects_fade_plan_without_source() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fadePlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject fade-plan copy without provenance" + ); +} + #[test] fn project_contract_rejects_invalid_fade_plan_copy_with_source() { for fade_plan in ["", " ", "fade here\nthen hold", "fade here\rthen hold"] { From c8b5e391cec704e599ff75fe00f5a0b2016a6eca Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 16:49:24 +0900 Subject: [PATCH 18/25] fix(contract): require provenance for fade plan copy --- apps/desktop/core/src/lib.rs | 3 +++ packages/shared-types/src/index.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 425472709..4b8b09636 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -553,6 +553,9 @@ fn validate_fade_plan_provenance( if role.fade_plan.is_none() && role.fade_plan_source.is_some() { return Err("Invalid project file format".to_string()); } + if role.fade_plan.is_some() && role.fade_plan_source.is_none() { + return Err("Invalid project file format".to_string()); + } } } Ok(payload) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index e73821a3d..3630960e1 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1612,6 +1612,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.fadePlanSource !== undefined && value.fadePlan === undefined) { return invalidField(`${path}.fadePlanSource`); } + if (value.fadePlan !== undefined && value.fadePlanSource === undefined) { + return invalidField(`${path}.fadePlanSource`); + } return null; } From 43331fb0e577a3777077ba01cf3287b8077e9b56 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 00:16:22 +0900 Subject: [PATCH 19/25] fix(workspace): preserve fade plan contract --- apps/desktop/core/src/lib.rs | 21 ++++++++++++++++--- apps/desktop/core/tests/fade_plan_contract.rs | 12 ++++++++++- ...stFadePlanCallout.custom-guidance.test.tsx | 9 ++++++-- .../FirstFadePlanCallout.provenance.test.tsx | 9 ++++++-- .../features/workspace/firstFadePlan.test.ts | 13 ++++++++---- .../src/features/workspace/firstFadePlan.ts | 5 ++++- .../src/bandscope_analysis/api.py | 4 ++-- services/analysis-engine/tests/test_api.py | 4 ++-- 8 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 4b8b09636..d2460e04b 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,15 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] enum FadePlanSourcePayload { @@ -190,15 +199,23 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + practice_progress: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] fade_plan: Option, #[serde(default, skip_serializing_if = "Option::is_none")] fade_plan_source: Option, @@ -544,9 +561,7 @@ fn validate_fade_plan_provenance( for section in &payload.sections { for role in §ion.roles { if role.fade_plan.as_ref().is_some_and(|fade_plan| { - fade_plan.trim().is_empty() - || fade_plan.contains('\n') - || fade_plan.contains('\r') + fade_plan.trim().is_empty() || fade_plan.contains('\n') || fade_plan.contains('\r') }) { return Err("Invalid project file format".to_string()); } diff --git a/apps/desktop/core/tests/fade_plan_contract.rs b/apps/desktop/core/tests/fade_plan_contract.rs index 49852731d..512b4cbe7 100644 --- a/apps/desktop/core/tests/fade_plan_contract.rs +++ b/apps/desktop/core/tests/fade_plan_contract.rs @@ -26,6 +26,7 @@ fn song_with_fade_plan() -> Value { "functionLabel": "vi landing", "source": "model" }, + "harmonicExplanation": "The landing keeps the tonal floor clear.", "cue": { "kind": "transition", "value": "Let the next downbeat land quieter." @@ -42,8 +43,16 @@ fn song_with_fade_plan() -> Value { "rehearsalPriority": "high", "simplification": "Hold the landing syllable.", "setupNote": "Keep the attack short.", + "transpositionPlan": "Keep the landing shape a whole step lower if needed.", "manualOverrides": [], "overlapWarnings": [], + "transcription": [{ + "pitch": "C#4", + "onset": 1.0, + "offset": 1.5, + "velocity": 0.8 + }], + "practiceProgress": 50, "fadePlan": "Fade this part; let the next downbeat land quieter.", "fadePlanSource": "model" } @@ -73,7 +82,8 @@ fn project_contract_round_trips_fade_plan_provenance() { let parsed = project_payload_from_content(&content) .expect("native project contract must accept shared fade-plan fields"); - let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); assert_eq!( serialized["sections"][0]["roles"][0]["fadePlan"], diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx index 2f054ee16..b32eec45f 100644 --- a/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx @@ -71,12 +71,17 @@ describe("FirstFadePlanCallout custom guidance", () => { expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); }); - it("preserves custom copy without model provenance instead of rewriting it", () => { + it("does not render custom copy without provenance", () => { render( ); - expect(screen.getByText("Stack the last bar and grow together.")).toBeTruthy(); + expect(screen.queryByText("Stack the last bar and grow together.")).toBeNull(); + expect( + screen.getByText( + "No fade plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); }); }); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx index b4d0bed41..b50906449 100644 --- a/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx @@ -53,14 +53,19 @@ describe("FirstFadePlanCallout fade-plan provenance", () => { expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); }); - it("does not infer model authority when persisted fade guidance has no source", () => { + it("does not render persisted fade guidance when it has no source", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); const legacyPlan = "Fade this part; let the next downbeat land quieter."; const song = songWithKoreanFade(legacyPlan); render(); - expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect(screen.queryByText(legacyPlan)).toBeNull(); + expect( + screen.getByText( + "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); }); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.test.ts index 8e795e5b0..d743463c6 100644 --- a/apps/desktop/src/features/workspace/firstFadePlan.test.ts +++ b/apps/desktop/src/features/workspace/firstFadePlan.test.ts @@ -25,6 +25,7 @@ function withFadeSection( roleId?: string; roleName?: string; priority?: "low" | "medium" | "high"; + source?: "model" | "user"; isActive?: boolean; wasActive?: boolean; previousVocalActive?: boolean; @@ -56,7 +57,8 @@ function withFadeSection( ? "Bass Guitar" : "Lead Vocal"), rehearsalPriority: overrides.priority ?? "high", - fadePlan: overrides.fadePlan ?? DEMO_FADE_PLAN + fadePlan: overrides.fadePlan ?? DEMO_FADE_PLAN, + fadePlanSource: overrides.source ?? "model" }; const current = structuredClone(verse); @@ -209,7 +211,8 @@ describe("resolveFirstFadePlan", () => { end: 56, previousStart: 24, roleId: "lead-vocal", - fadePlan: "Late fade." + fadePlan: "Late fade.", + source: "user" }); const earlier = structuredClone(song.sections[1]!); earlier.id = "chorus-early"; @@ -255,7 +258,8 @@ describe("resolveFirstFadePlan", () => { roleId: "bass-guitar", roleName: "Bass Guitar", priority: "low", - fadePlan: "Low-priority fade." + fadePlan: "Low-priority fade.", + source: "user" }); const section = song.sections[1]!; const highRole = { @@ -263,7 +267,8 @@ describe("resolveFirstFadePlan", () => { id: "lead-vocal", name: "Lead Vocal", rehearsalPriority: "high" as const, - fadePlan: "High-priority fade." + fadePlan: "High-priority fade.", + fadePlanSource: "user" as const }; section.roles = [...section.roles.filter((role) => role.id !== "lead-vocal"), highRole]; section.partGraph = [ diff --git a/apps/desktop/src/features/workspace/firstFadePlan.ts b/apps/desktop/src/features/workspace/firstFadePlan.ts index 5b373b222..9a275310a 100644 --- a/apps/desktop/src/features/workspace/firstFadePlan.ts +++ b/apps/desktop/src/features/workspace/firstFadePlan.ts @@ -166,6 +166,9 @@ function ownedFadePlan(role: unknown): OwnedFadePlan | null { if (fadePlanSource !== undefined && fadePlanSource !== "model" && fadePlanSource !== "user") { return null; } + if (fadePlanSource === undefined) { + return null; + } const trimmed = fadePlan.trim(); if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { return null; @@ -175,7 +178,7 @@ function ownedFadePlan(role: unknown): OwnedFadePlan | null { } return { text: truncateCodePoints(trimmed, MAX_FADE_PLAN_CHARACTERS), - source: fadePlanSource ?? null, + source: fadePlanSource, guidance: null }; } diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..0773e9edc 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) MAX_SECTION_TIME_SECONDS = 4_294_967_295 -ANALYSIS_CACHE_SCHEMA_VERSION = 1 +ANALYSIS_CACHE_SCHEMA_VERSION = 2 FEATURE_CACHE_SCHEMA_VERSION = 1 STEM_SEPARATION_TIMEOUT_SECONDS = 20.0 @@ -613,7 +613,7 @@ def _analysis_cache_path(request: AnalysisJobRequest) -> Path | None: digest = hashlib.sha256( json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() - return Path(cache_root) / "analysis-cache-v1" / f"{digest}.json" + return Path(cache_root) / "analysis-cache-v2" / f"{digest}.json" def _feature_cache_paths(request: AnalysisJobRequest) -> tuple[Path, Path] | None: diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 18273791d..93f3ee962 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -582,7 +582,7 @@ def test_run_analysis_job_updates_report_progress_and_cache(tmp_path) -> None: ("succeeded", "ready", 100), ] assert updates[-1]["cacheStatus"] == "stored" - cache_files = list((tmp_path / "cache" / "analysis-cache-v1").glob("*.json")) + cache_files = list((tmp_path / "cache" / "analysis-cache-v2").glob("*.json")) assert len([path for path in cache_files if not path.name.endswith(".features.json")]) == 1 assert len([path for path in cache_files if path.name.endswith(".features.json")]) == 1 @@ -648,7 +648,7 @@ def test_cached_analysis_helpers_treat_invalid_cache_as_miss(tmp_path) -> None: for content in ( "[]", '{"schemaVersion": 999, "result": {}}', - '{"schemaVersion": 1, "result": []}', + '{"schemaVersion": 2, "result": []}', ): cache_path.write_text(content, encoding="utf-8") assert _load_cached_analysis(cache_path) is None From f46c1770c75995dc42e83140aca7c166a105e8c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 08:47:46 -0700 Subject: [PATCH 20/25] test(core): reject out-of-range practice progress --- apps/desktop/core/tests/fade_plan_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/core/tests/fade_plan_contract.rs b/apps/desktop/core/tests/fade_plan_contract.rs index 512b4cbe7..ffb1ac5b6 100644 --- a/apps/desktop/core/tests/fade_plan_contract.rs +++ b/apps/desktop/core/tests/fade_plan_contract.rs @@ -150,3 +150,15 @@ fn project_contract_rejects_unknown_fade_plan_source() { "native persisted contract must reject provenance outside model/user" ); } + +#[test] +fn project_contract_rejects_practice_progress_above_shared_bound() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["practiceProgress"] = json!(101); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject practiceProgress above the shared 0..=100 bound" + ); +} From 4064405bf5392a8a0749bf5b81048f966291419e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 05:48:30 +0900 Subject: [PATCH 21/25] fix(contract): bound practice progress --- apps/desktop/core/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index d2460e04b..09199b5e9 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -192,6 +192,21 @@ enum FadePlanSourcePayload { User, } +fn deserialize_practice_progress<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let progress = Option::::deserialize(deserializer)?; + if let Some(value) = progress { + if value > 100 { + return Err(serde::de::Error::custom( + "practiceProgress must be between 0 and 100", + )); + } + } + Ok(progress) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -213,7 +228,11 @@ pub struct RehearsalRolePayload { overlap_warnings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] transcription: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_practice_progress", + skip_serializing_if = "Option::is_none" + )] practice_progress: Option, #[serde(default, skip_serializing_if = "Option::is_none")] fade_plan: Option, From d50977e49b9a1ad6adb64722fd0797d541097601 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 07:18:58 +0900 Subject: [PATCH 22/25] fix(fade): preserve source continuity and project provenance --- apps/desktop/core/src/lib.rs | 2 +- apps/desktop/src-tauri/src/main.rs | 7 +++- .../src/features/workspace/Workspace.tsx | 13 +++--- .../src/features/workspace/firstFadePlan.ts | 4 +- .../src/bandscope_analysis/roles/extractor.py | 40 ++++++++++++++++++- .../analysis-engine/tests/test_fade_plan.py | 40 +++++++++++++++++++ 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 09199b5e9..2085656a9 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -574,7 +574,7 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } -fn validate_fade_plan_provenance( +pub fn validate_fade_plan_provenance( payload: RehearsalSongPayload, ) -> Result { for section in &payload.sections { diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..ea3312080 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -740,8 +740,11 @@ async fn import_youtube_url( #[tauri::command] fn save_project(payload: Value) -> Result<(), String> { - let parsed = serde_json::from_value::(payload) - .map_err(|_| "Invalid project payload".to_string())?; + let parsed = validate_fade_plan_provenance( + serde_json::from_value::(payload) + .map_err(|_| "Invalid project payload".to_string())?, + ) + .map_err(|_| "Invalid project payload".to_string())?; let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 57a6bcba9..58ff5504b 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, memo, type MouseEvent } from "react"; +import { useState, useMemo, useRef, useEffect, 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"; @@ -129,16 +129,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp 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) { + useEffect(() => { + if (song !== localSongUpdateRef.current) { workspaceInstanceRef.current = song; } localSongUpdateRef.current = null; - previousSongRef.current = song; - } + }, [song]); // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -538,4 +535,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/features/workspace/firstFadePlan.ts b/apps/desktop/src/features/workspace/firstFadePlan.ts index 9a275310a..d88976613 100644 --- a/apps/desktop/src/features/workspace/firstFadePlan.ts +++ b/apps/desktop/src/features/workspace/firstFadePlan.ts @@ -35,7 +35,7 @@ type RankedRoleMetadata = Readonly<{ type OwnedFadePlan = Readonly<{ text: string; - source: FadePlanSource | null; + source: FadePlanSource; guidance: FadePlanGuidance | null; }>; @@ -49,7 +49,7 @@ export type FirstFadePlan = { landingRoleId: string; landingRoleName: string; fadePlan: string; - fadePlanSource: FadePlanSource | null; + fadePlanSource: FadePlanSource; fadePlanGuidance: FadePlanGuidance | null; atSeconds: number; }; diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index bd41eb17e..ede5c6931 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -71,14 +71,17 @@ def extract( # Use real stem activity detection when we have stems and boundaries activity_maps: list[dict[str, bool]] | None = None + source_activity_maps: list[dict[str, bool]] | None = None energy_maps: list[dict[str, float]] | None = None if stems and boundaries and len(boundaries) == len(sections): try: stem_activity = detect_stem_activity(stems, boundaries, sr) + source_activity_maps = stem_activity activity_maps = [map_stems_to_roles(sa) for sa in stem_activity] except Exception as e: logger.warning("Stem activity detection failed, using fallback: %s", e) activity_maps = None + source_activity_maps = None try: stem_energy = detect_stem_energy(stems, boundaries, sr) energy_maps = [map_stems_to_role_energy(se) for se in stem_energy] @@ -104,6 +107,16 @@ def extract( previous_activity, current_energy, previous_energy, + current_source_activity=( + source_activity_maps[i] + if source_activity_maps is not None and i < len(source_activity_maps) + else None + ), + previous_source_activity=( + source_activity_maps[i - 1] + if source_activity_maps is not None and i > 0 + else None + ), ) else: # Fallback to heuristic-based topology @@ -376,19 +389,36 @@ def _source_ids(cls, role_ids: set[str]) -> set[str]: """Return distinct source-separation stems among the given roles.""" return {cls._source_id(role_id) for role_id in role_ids} + @staticmethod + def _active_source_ids(source_activity: dict[str, bool]) -> set[str]: + """Return raw active source names before role mapping can discard a stem.""" + return {source_id for source_id, is_active in source_activity.items() if is_active} + def _named_fade_ids( self, role_activity: dict[str, bool], previous_role_activity: dict[str, bool], role_energy: dict[str, float] | None, previous_role_energy: dict[str, float] | None, + source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, ) -> set[str]: """Return named staying roles whose RMS fell by the fade ratio.""" if role_energy is None or previous_role_energy is None: return set() previous_active = self._active_role_ids(previous_role_activity) current_active = self._active_role_ids(role_activity) - if self._source_ids(previous_active) != self._source_ids(current_active): + previous_source_ids = ( + self._active_source_ids(previous_source_activity) + if previous_source_activity is not None + else self._source_ids(previous_active) + ) + current_source_ids = ( + self._active_source_ids(source_activity) + if source_activity is not None + else self._source_ids(current_active) + ) + if previous_source_ids != current_source_ids: return set() faded: set[str] = set() for role_id in _NAMED_FADE_ROLE_IDS & current_active & previous_active: @@ -411,6 +441,8 @@ def _activity_fade_plan( previous_role_activity: dict[str, bool] | None, role_energy: dict[str, float] | None, previous_role_energy: dict[str, float] | None, + source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, ) -> str | None: """Return bounded fade guidance only for a corroborated intensity fall. @@ -431,6 +463,8 @@ def _activity_fade_plan( previous_role_activity, role_energy, previous_role_energy, + source_activity, + previous_source_activity, ) if role_id not in faded: return None @@ -455,6 +489,8 @@ def _build_activity_topology( previous_role_activity: dict[str, bool] | None = None, role_energy: dict[str, float] | None = None, previous_role_energy: dict[str, float] | None = None, + current_source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -484,6 +520,8 @@ def _build_activity_topology( previous_role_activity, role_energy, previous_role_energy, + current_source_activity, + previous_source_activity, ) if fade_plan is not None: role = role.copy() diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py index 98f413e84..6ea60c77e 100644 --- a/services/analysis-engine/tests/test_fade_plan.py +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -269,6 +269,46 @@ def test_activity_fade_keeps_shared_accompaniment_source_across_role_swap() -> N assert vocal["fadePlan"] == _SOLO_PLAN +def test_activity_fade_stays_unnamed_when_a_drum_source_enters() -> None: + """A drum entry changes the raw source set even when rendered roles stay active.""" + extractor = RoleExtractor() + role_activity = _activity(bass=True, keys_right=True, vocal=True) + previous_sources = {"bass": True, "other": True, "vocals": True, "drums": False} + current_sources = {"bass": True, "other": True, "vocals": True, "drums": True} + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + role_activity, + None, + role_activity, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + current_source_activity=current_sources, + previous_source_activity=previous_sources, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_a_drum_source_exits() -> None: + """A drum exit changes the raw source set even when rendered roles stay active.""" + extractor = RoleExtractor() + role_activity = _activity(bass=True, keys_right=True, vocal=True) + previous_sources = {"bass": True, "other": True, "vocals": True, "drums": True} + current_sources = {"bass": True, "other": True, "vocals": True, "drums": False} + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + role_activity, + None, + role_activity, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + current_source_activity=current_sources, + previous_source_activity=previous_sources, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + def test_extract_emits_fade_across_real_stem_boundaries() -> None: """Live activity maps pass previous-section energy into fade emission.""" extractor = RoleExtractor() From 5c655995664c9d1faba60c0ae9ff896ea11c0c57 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 07:46:42 +0900 Subject: [PATCH 23/25] fix(fade): reset identity on external song replacement --- .../workspace/Workspace.fade-state.test.tsx | 14 ++++++++++++++ apps/desktop/src/features/workspace/Workspace.tsx | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx index b505013ee..022e5f0b6 100644 --- a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -93,6 +93,20 @@ describe("Workspace fade state authority", () => { expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); }); + it("resets opened fade guidance when an external song reuses the same fade metadata", () => { + const firstSong = analyzedSongWithFadePlan(); + const replacementSong = structuredClone(firstSong); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeNull(); + }); + it("keeps chord editing unavailable when no song update handler exists", () => { render(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 58ff5504b..8b8d4af8f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -136,6 +136,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } localSongUpdateRef.current = null; }, [song]); + const workspaceInstanceKey = + song === localSongUpdateRef.current ? workspaceInstanceRef.current : song; // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -331,7 +333,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

- +
From 08adac5bf019e2060b38c23766e5a65b9e54f589 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 14:06:11 +0900 Subject: [PATCH 24/25] fix(contract): align fade plan validation --- apps/desktop/core/src/lib.rs | 43 +++++++++++++++++-- apps/desktop/core/tests/fade_plan_contract.rs | 29 ++++++++++++- .../features/workspace/firstFadePlan.test.ts | 7 +++ .../src/features/workspace/firstFadePlan.ts | 7 +-- packages/shared-types/src/index.ts | 42 ++++++++++++++++-- .../test/fadePlanProvenance.test.ts | 20 ++++++++- 6 files changed, 136 insertions(+), 12 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 2085656a9..5ea9cab17 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -574,14 +574,51 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn is_plan_whitespace(value: char) -> bool { + matches!( + value, + '\u{0009}'..='\u{000D}' + | '\u{0020}' + | '\u{0085}' + | '\u{00A0}' + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) +} + +/// Mirrors shared-types plan validation without normalizing persisted text. +fn is_valid_fade_plan(value: &str) -> bool { + let mut has_non_whitespace = false; + for character in value.chars() { + if matches!( + character, + '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) { + return false; + } + if !is_plan_whitespace(character) { + has_non_whitespace = true; + } + } + has_non_whitespace +} + pub fn validate_fade_plan_provenance( payload: RehearsalSongPayload, ) -> Result { for section in &payload.sections { for role in §ion.roles { - if role.fade_plan.as_ref().is_some_and(|fade_plan| { - fade_plan.trim().is_empty() || fade_plan.contains('\n') || fade_plan.contains('\r') - }) { + if role + .fade_plan + .as_deref() + .is_some_and(|fade_plan| !is_valid_fade_plan(fade_plan)) + { return Err("Invalid project file format".to_string()); } if role.fade_plan.is_none() && role.fade_plan_source.is_some() { diff --git a/apps/desktop/core/tests/fade_plan_contract.rs b/apps/desktop/core/tests/fade_plan_contract.rs index ffb1ac5b6..27a79a1f3 100644 --- a/apps/desktop/core/tests/fade_plan_contract.rs +++ b/apps/desktop/core/tests/fade_plan_contract.rs @@ -127,7 +127,16 @@ fn project_contract_rejects_fade_plan_without_source() { #[test] fn project_contract_rejects_invalid_fade_plan_copy_with_source() { - for fade_plan in ["", " ", "fade here\nthen hold", "fade here\rthen hold"] { + for fade_plan in [ + "", + " ", + "\u{00A0}\u{2003}\u{3000}", + "fade here\nthen hold", + "fade here\rthen hold", + "fade here\u{0085}then hold", + "fade here\u{2028}then hold", + "fade here\u{2029}then hold", + ] { let mut payload = song_with_fade_plan(); payload["sections"][0]["roles"][0]["fadePlan"] = json!(fade_plan); let content = serde_json::to_string(&payload).expect("fixture should serialize"); @@ -151,6 +160,24 @@ fn project_contract_rejects_unknown_fade_plan_source() { ); } +#[test] +fn project_contract_preserves_padded_single_line_fade_copy() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlan"] = json!(" Fade together. \u{00A0}"); + payload["sections"][0]["roles"][0]["fadePlanSource"] = json!("user"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native persisted contract must preserve padded single-line copy"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["fadePlan"], + payload["sections"][0]["roles"][0]["fadePlan"] + ); +} + #[test] fn project_contract_rejects_practice_progress_above_shared_bound() { let mut payload = song_with_fade_plan(); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.test.ts index d743463c6..0ca8852da 100644 --- a/apps/desktop/src/features/workspace/firstFadePlan.test.ts +++ b/apps/desktop/src/features/workspace/firstFadePlan.test.ts @@ -204,6 +204,13 @@ describe("resolveFirstFadePlan", () => { ).toBeNull(); }); + it("preserves long user-authored fade copy verbatim", () => { + const fadePlan = `${"Fade together. ".repeat(20)}Keep the landing clear.`; + expect( + resolveFirstFadePlan(withFadeSection({ fadePlan, source: "user" }))?.fadePlan + ).toBe(fadePlan); + }); + it("prefers the earlier of two fade plans", () => { const song = withFadeSection({ id: "chorus-late-fade", diff --git a/apps/desktop/src/features/workspace/firstFadePlan.ts b/apps/desktop/src/features/workspace/firstFadePlan.ts index d88976613..a2310f48c 100644 --- a/apps/desktop/src/features/workspace/firstFadePlan.ts +++ b/apps/desktop/src/features/workspace/firstFadePlan.ts @@ -1,6 +1,7 @@ import { MAX_SECTION_TIME_SECONDS, SECTION_FORM_LABELS, + isNonEmptySingleLineText, type RehearsalRole, type RehearsalSection, type RehearsalSong @@ -169,15 +170,15 @@ function ownedFadePlan(role: unknown): OwnedFadePlan | null { if (fadePlanSource === undefined) { return null; } - const trimmed = fadePlan.trim(); - if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + if (!isNonEmptySingleLineText(fadePlan)) { return null; } if (fadePlanSource === "model") { + const trimmed = fadePlan.trim(); return boundedGeneratedFadePlan(trimmed); } return { - text: truncateCodePoints(trimmed, MAX_FADE_PLAN_CHARACTERS), + text: fadePlan, source: fadePlanSource, guidance: null }; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 3630960e1..02adc3b89 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -409,6 +409,43 @@ function isOneOf(options: readonly T[], value: unknown): value return typeof value === "string" && options.includes(value as T); } +/** Return whether a plan is non-empty and contains no Unicode line separator. */ +export function isNonEmptySingleLineText(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + let hasNonWhitespace = false; + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint === 0x000a || + codePoint === 0x000d || + codePoint === 0x0085 || + codePoint === 0x2028 || + codePoint === 0x2029 + ) { + return false; + } + if (!( + (codePoint >= 0x0009 && codePoint <= 0x000d) || + codePoint === 0x0020 || + codePoint === 0x0085 || + codePoint === 0x00a0 || + codePoint === 0x1680 || + (codePoint >= 0x2000 && codePoint <= 0x200a) || + codePoint === 0x2028 || + codePoint === 0x2029 || + codePoint === 0x202f || + codePoint === 0x205f || + codePoint === 0x3000 || + codePoint === 0xfeff + )) { + hasNonWhitespace = true; + } + } + return hasNonWhitespace; +} + /** Documented. */ function invalidField(path: string): string { return `Invalid rehearsal song contract: invalid field '${path}'`; @@ -1595,10 +1632,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if ( value.fadePlan !== undefined && ( - typeof value.fadePlan !== "string" || - value.fadePlan.trim().length === 0 || - value.fadePlan.includes("\n") || - value.fadePlan.includes("\r") + !isNonEmptySingleLineText(value.fadePlan) ) ) { return invalidField(`${path}.fadePlan`); diff --git a/packages/shared-types/test/fadePlanProvenance.test.ts b/packages/shared-types/test/fadePlanProvenance.test.ts index eb136584f..27612408a 100644 --- a/packages/shared-types/test/fadePlanProvenance.test.ts +++ b/packages/shared-types/test/fadePlanProvenance.test.ts @@ -34,7 +34,16 @@ describe("fadePlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); }); - it.each(["", " ", "fade here\nthen hold", "fade here\rthen hold"])( + it.each([ + "", + " ", + "\u00a0\u2003\u3000", + "fade here\nthen hold", + "fade here\rthen hold", + "fade here\u0085then hold", + "fade here\u2028then hold", + "fade here\u2029then hold" + ])( "rejects a fade plan source with blank or multiline copy %j", (fadePlan) => { const song = createDemoRehearsalSong(); @@ -44,4 +53,13 @@ describe("fadePlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/fadePlan/); } ); + + it("accepts padded single-line fade copy without normalizing it", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = " Fade together. \u00a0"; + role.fadePlanSource = "user"; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.fadePlan).toBe(role.fadePlan); + }); }); From c13314677adb0d1ae60a7d2786e858fa6b0f9306 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 14:29:13 +0900 Subject: [PATCH 25/25] fix(contract): reject control line breaks --- apps/desktop/core/src/lib.rs | 2 +- apps/desktop/core/tests/fade_plan_contract.rs | 2 ++ packages/shared-types/src/index.ts | 2 ++ packages/shared-types/test/fadePlanProvenance.test.ts | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 5ea9cab17..ec4a952c4 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -598,7 +598,7 @@ fn is_valid_fade_plan(value: &str) -> bool { for character in value.chars() { if matches!( character, - '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' + '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}' ) { return false; } diff --git a/apps/desktop/core/tests/fade_plan_contract.rs b/apps/desktop/core/tests/fade_plan_contract.rs index 27a79a1f3..37fe70f1e 100644 --- a/apps/desktop/core/tests/fade_plan_contract.rs +++ b/apps/desktop/core/tests/fade_plan_contract.rs @@ -133,6 +133,8 @@ fn project_contract_rejects_invalid_fade_plan_copy_with_source() { "\u{00A0}\u{2003}\u{3000}", "fade here\nthen hold", "fade here\rthen hold", + "fade here\u{000B}then hold", + "fade here\u{000C}then hold", "fade here\u{0085}then hold", "fade here\u{2028}then hold", "fade here\u{2029}then hold", diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 02adc3b89..48ef2d83d 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -420,6 +420,8 @@ export function isNonEmptySingleLineText(value: unknown): value is string { if ( codePoint === 0x000a || codePoint === 0x000d || + codePoint === 0x000b || + codePoint === 0x000c || codePoint === 0x0085 || codePoint === 0x2028 || codePoint === 0x2029 diff --git a/packages/shared-types/test/fadePlanProvenance.test.ts b/packages/shared-types/test/fadePlanProvenance.test.ts index 27612408a..b72af8cca 100644 --- a/packages/shared-types/test/fadePlanProvenance.test.ts +++ b/packages/shared-types/test/fadePlanProvenance.test.ts @@ -40,6 +40,8 @@ describe("fadePlan provenance", () => { "\u00a0\u2003\u3000", "fade here\nthen hold", "fade here\rthen hold", + "fade here\u000bthen hold", + "fade here\u000cthen hold", "fade here\u0085then hold", "fade here\u2028then hold", "fade here\u2029then hold"