From 2f31406a01ecfc957bf0ae2d918ab1ee2689f5f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:25:35 +0000 Subject: [PATCH 01/26] feat(workspace): open Stem Lab as honest isolation lanes Replace the Stem Lab coming-soon dead end with a display-only board that names the parts to isolate from the analyzed song, including range, sections, and clash warnings. Keep playback out of scope until a local stem-file contract exists. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + apps/desktop/src/App.test.tsx | 34 +++++ apps/desktop/src/App.tsx | 44 ++++-- apps/desktop/src/App.view.test.ts | 33 +++++ .../src/features/stems/StemLab.stories.tsx | 20 +++ .../src/features/stems/StemLab.test.tsx | 68 +++++++++ apps/desktop/src/features/stems/StemLab.tsx | 117 +++++++++++++++ .../src/features/stems/stemLanes.test.ts | 138 ++++++++++++++++++ apps/desktop/src/features/stems/stemLanes.ts | 98 +++++++++++++ apps/desktop/src/index.css | 3 + apps/desktop/src/lib/rehearsalViews.ts | 40 +++++ apps/desktop/src/locales/en/common.json | 14 +- apps/desktop/src/locales/ko/common.json | 14 +- apps/desktop/vite.config.ts | 5 +- docs/architecture/overview.md | 1 + docs/doctoring/stem-lab-role-lanes.md | 36 +++++ docs/plans/2026-08-16-stem-lab-role-lanes.md | 44 ++++++ 18 files changed, 695 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/App.view.test.ts create mode 100644 apps/desktop/src/features/stems/StemLab.stories.tsx create mode 100644 apps/desktop/src/features/stems/StemLab.test.tsx create mode 100644 apps/desktop/src/features/stems/StemLab.tsx create mode 100644 apps/desktop/src/features/stems/stemLanes.test.ts create mode 100644 apps/desktop/src/features/stems/stemLanes.ts create mode 100644 apps/desktop/src/lib/rehearsalViews.ts create mode 100644 docs/doctoring/stem-lab-role-lanes.md create mode 100644 docs/plans/2026-08-16-stem-lab-role-lanes.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..81b99159e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-16 ## Brand source @@ -68,6 +68,7 @@ Last updated: 2026-03-11 - BandScope is not only a shell around chord labels, stems, and ranges. - The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority. - These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer. +- Stem Lab is a first-class rehearsal view. It lists role isolation lanes from the analyzed song. It must not invent playable stem files or expose a generic audio-read API; playback waits for an allowlisted local stem-file contract. Evidence: `docs/doctoring/stem-lab-role-lanes.md`. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..160e51960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Open Stem Lab as a real isolation board: role lanes show playable range, sections to lock first, and clash warnings, with honest next-action copy instead of a coming-soon dead end. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..a1fb97493 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1616,4 +1616,38 @@ describe("App", () => { expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); + + it("opens Stem Lab before analysis with a next action instead of coming soon", () => { + render(); + + const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i }); + const stemLabButton = within(primaryNav).getByRole("button", { name: "Stem Lab" }); + expect(stemLabButton).not.toHaveAttribute("aria-disabled"); + expect(stemLabButton).not.toHaveAttribute("title", "Coming soon"); + fireEvent.click(stemLabButton); + + expect(screen.getByRole("heading", { name: "Stem Lab" })).toBeTruthy(); + expect( + screen.getByText(/Choose a local audio file and start analysis/i) + ).toBeTruthy(); + expect(within(primaryNav).getByRole("button", { name: "Stem Lab" })).toHaveAttribute( + "aria-current", + "page" + ); + }); + + it("lists isolation lanes in Stem Lab after a project is loaded", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getAllByRole("button", { name: /^Stem Lab$/i })[0]); + expect(await screen.findByRole("heading", { name: "Bass Guitar" })).toBeTruthy(); + expect(screen.getByText(/C#2–E3/)).toBeTruthy(); + expect(screen.queryByText(/Song Timeline/i)).toBeNull(); + }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..d50ad90c3 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -44,7 +44,9 @@ import { startAnalysisJob } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; +import { isNavigableView, resolveCurrentView, type RehearsalView } from "./lib/rehearsalViews"; import { ScoreView } from "./features/score/ScoreView"; +import { StemLab } from "./features/stems/StemLab"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; @@ -58,15 +60,13 @@ const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:User const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; -type RehearsalView = "workspace" | "score"; - const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, { labelKey: "navImport", icon: Upload, view: null }, { labelKey: "navExport", icon: Save, view: null }, { labelKey: "navSections", icon: ListMusic, view: null }, { labelKey: "navRoles", icon: Users, view: null }, - { labelKey: "navStemLab", icon: AudioWaveform, view: null }, + { labelKey: "navStemLab", icon: AudioWaveform, view: "stems" }, { labelKey: "navCues", icon: Sparkles, view: null }, { labelKey: "navTranspose", icon: SlidersHorizontal, view: null }, { labelKey: "navScore", icon: FileMusic, view: "score" } @@ -517,11 +517,35 @@ export function App() { return ; }; - const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const currentView: RehearsalView = resolveCurrentView(activeView, jobResult !== null); + + /** Render the selected rehearsal destination without inventing unavailable audio. */ + const renderActiveRehearsalView = () => { + switch (currentView) { + case "score": + return jobResult ? ( + + ) : ( + renderWorkspaceState() + ); + case "stems": + return ; + case "workspace": + return renderWorkspaceState(); + default: { + const _exhaustive: never = currentView; + return _exhaustive; + } + } + }; /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { - const enabled = item.view === "workspace" || (item.view === "score" && jobResult !== null); + const enabled = isNavigableView(item.view, jobResult !== null); return { label: t(item.labelKey), enabled, @@ -842,15 +866,7 @@ export function App() {
- {currentView === "score" && jobResult ? ( - - ) : ( - renderWorkspaceState() - )} + {renderActiveRehearsalView()}
diff --git a/apps/desktop/src/App.view.test.ts b/apps/desktop/src/App.view.test.ts new file mode 100644 index 000000000..74345a368 --- /dev/null +++ b/apps/desktop/src/App.view.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isNavigableView, resolveCurrentView } from "./lib/rehearsalViews"; + +describe("isNavigableView", () => { + it("keeps placeholder destinations closed", () => { + expect(isNavigableView(null, false)).toBe(false); + expect(isNavigableView(null, true)).toBe(false); + }); + + it("opens workspace and Stem Lab without a song", () => { + expect(isNavigableView("workspace", false)).toBe(true); + expect(isNavigableView("stems", false)).toBe(true); + expect(isNavigableView("score", false)).toBe(false); + }); + + it("opens Score only after a song exists", () => { + expect(isNavigableView("score", true)).toBe(true); + expect(isNavigableView("stems", true)).toBe(true); + }); +}); + +describe("resolveCurrentView", () => { + it("falls back from Score when no song is loaded", () => { + expect(resolveCurrentView("score", false)).toBe("workspace"); + expect(resolveCurrentView("score", true)).toBe("score"); + }); + + it("keeps Stem Lab selected with or without a song", () => { + expect(resolveCurrentView("stems", false)).toBe("stems"); + expect(resolveCurrentView("stems", true)).toBe("stems"); + expect(resolveCurrentView("workspace", true)).toBe("workspace"); + }); +}); diff --git a/apps/desktop/src/features/stems/StemLab.stories.tsx b/apps/desktop/src/features/stems/StemLab.stories.tsx new file mode 100644 index 000000000..a1d0c0b74 --- /dev/null +++ b/apps/desktop/src/features/stems/StemLab.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { StemLab } from "./StemLab"; + +const meta = { + title: "Workspace/Stem Lab", + component: StemLab, + parameters: { layout: "padded" } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BeforeAnalysis: Story = { + args: { song: null } +}; + +export const IsolationLanes: Story = { + args: { song: createDemoRehearsalSong() } +}; diff --git a/apps/desktop/src/features/stems/StemLab.test.tsx b/apps/desktop/src/features/stems/StemLab.test.tsx new file mode 100644 index 000000000..20106a4eb --- /dev/null +++ b/apps/desktop/src/features/stems/StemLab.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { StemLab, stemRoleTypeLabel } from "./StemLab"; +import { createTranslator } from "../../i18n"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("stemRoleTypeLabel", () => { + it("covers every role class", () => { + const t = createTranslator("en"); + expect(stemRoleTypeLabel("instrument", t)).toBe("Instrument"); + expect(stemRoleTypeLabel("vocal", t)).toBe("Vocal"); + expect(stemRoleTypeLabel("hand", t)).toBe("Hand part"); + }); +}); + +describe("StemLab", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.restoreAllMocks(); + }); + + it("tells the player to analyze local audio when no song is loaded", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.getByRole("heading", { name: /Stem Lab/i })).toBeTruthy(); + expect( + screen.getByText(/Choose a local audio file and start analysis/i) + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /play stem/i })).toBeNull(); + }); + + it("lists isolation lanes from a real demo analysis without fake play controls", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.getByRole("list", { name: /Parts to isolate/i })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Bass Guitar" })).toBeTruthy(); + expect(screen.getByText(/C#2–E3/)).toBeTruthy(); + expect(screen.getAllByText(/Lock this range in the matching sections/i).length).toBeGreaterThan(0); + expect(screen.queryByRole("button", { name: /play stem/i })).toBeNull(); + expect(screen.queryByText(/coming soon/i)).toBeNull(); + }); + + it("uses Korean next-action copy for Korean locales", () => { + setNavigatorLanguage("ko-KR"); + render(); + + expect(screen.getByRole("heading", { name: "스템 랩" })).toBeTruthy(); + expect(screen.getByText(/로컬 오디오를 고르고 분석을 시작하세요/)).toBeTruthy(); + }); + + it("keeps the board inert when a lane is inspected", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("heading", { name: "Bass Guitar" })); + expect(screen.getByRole("heading", { name: "Bass Guitar" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/stems/StemLab.tsx b/apps/desktop/src/features/stems/StemLab.tsx new file mode 100644 index 000000000..2511470c5 --- /dev/null +++ b/apps/desktop/src/features/stems/StemLab.tsx @@ -0,0 +1,117 @@ +import { useMemo } from "react"; +import type { RehearsalRole, RehearsalSong } from "@bandscope/shared-types"; +import { AudioWaveform } from "lucide-react"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; +import { collectStemLanes, type StemLane } from "./stemLanes"; + +/** + * Props for the Stem Lab isolation board. + */ +export interface StemLabProps { + /** + * Analyzed song whose roles become isolation lanes, or `null` before the + * player has a rehearsal map. + */ + song: RehearsalSong | null; +} + +/** + * Translate a role class into the locale label the player should read. + */ +export function stemRoleTypeLabel( + roleType: RehearsalRole["roleType"], + t: (key: TranslationKey) => string +): string { + switch (roleType) { + case "instrument": + return t("stemLabRoleTypeInstrument"); + case "vocal": + return t("stemLabRoleTypeVocal"); + case "hand": + return t("stemLabRoleTypeHand"); + default: { + const _exhaustive: never = roleType; + return _exhaustive; + } + } +} + +/** + * Stem Lab lists the parts to isolate tonight. + * + * It does not invent playable stem files. When analysis has roles, each lane + * tells the player the range, sections, and clashes to lock before rehearsal. + * When analysis has not run, the empty copy tells the player to choose local + * audio next. + */ +export function StemLab({ song }: StemLabProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const lanes = useMemo(() => (song ? collectStemLanes(song) : []), [song]); + + return ( +
+
+ + +
+

+ {t("stemLabTitle")} +

+

{t("stemLabSubtitle")}

+
+
+ + {lanes.length === 0 ? ( +

+ {t("stemLabEmptyNextAction")} +

+ ) : ( +
    + {lanes.map((lane) => ( + + ))} +
+ )} +
+ ); +} + +/** + * One role lane with the next rehearsal action, not a fake play control. + */ +function StemLaneCard({ + lane, + t +}: { + lane: StemLane; + t: (key: TranslationKey) => string; +}) { + return ( +
  • +
    +

    {lane.roleName}

    +

    + {stemRoleTypeLabel(lane.roleType, t)} +

    +
    +

    + {t("stemLabRangeLabel")} {lane.lowestNote}–{lane.highestNote} +

    +

    + {lane.sectionLabels.length > 0 + ? `${t("stemLabSectionsLabel")} ${lane.sectionLabels.join(", ")}` + : t("stemLabSectionsUnknown")} +

    + {lane.overlapWarnings.length > 0 ? ( +

    + {t("stemLabOverlapLabel")} {lane.overlapWarnings.join(" ")} +

    + ) : null} +

    {t("stemLabLaneNextAction")}

    +
  • + ); +} diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts new file mode 100644 index 000000000..51fbd209c --- /dev/null +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { collectStemLanes, higherRehearsalPriority } from "./stemLanes"; + +describe("higherRehearsalPriority", () => { + it("keeps the more urgent priority", () => { + expect(higherRehearsalPriority("low", "medium")).toBe("medium"); + expect(higherRehearsalPriority("high", "medium")).toBe("high"); + expect(higherRehearsalPriority("medium", "medium")).toBe("medium"); + }); +}); + +describe("collectStemLanes", () => { + it("builds one lane per role from a real demo song", () => { + const lanes = collectStemLanes(createDemoRehearsalSong()); + const bass = lanes.find((lane) => lane.roleId === "bass-guitar"); + + expect(bass).toBeDefined(); + expect(bass?.roleName).toBe("Bass Guitar"); + expect(bass?.lowestNote).toBe("C#2"); + expect(bass?.highestNote).toBe("E3"); + expect(bass?.sectionLabels.length).toBeGreaterThan(0); + expect(bass?.overlapWarnings.some((warning) => /keyboard/i.test(warning))).toBe(true); + }); + + it("merges the same role across sections without inventing playback files", () => { + const song: RehearsalSong = { + id: "merge-song", + title: "Merge", + sections: [ + { + id: "verse-1", + label: "verse", + groove: "straight", + timeRange: { start: 0, end: 8 }, + confidence: { level: "high", source: "model", notes: "stable" }, + roles: [ + { + id: "bass-guitar", + name: " ", + roleType: "instrument", + harmony: { chord: "Am", functionLabel: "i", source: "model" }, + cue: { kind: "count", value: "1" }, + range: { lowestNote: "A1", highestNote: "A2" }, + confidence: { level: "medium", source: "model", notes: "check" }, + rehearsalPriority: "low", + simplification: "roots", + setupNote: "short", + manualOverrides: [], + overlapWarnings: [" Density warning: keys ", "Density warning: keys"] + } + ], + partGraph: [] + }, + { + id: "chorus-1", + label: "chorus", + groove: "open", + timeRange: { start: 8, end: 16 }, + confidence: { level: "high", source: "model", notes: "stable" }, + roles: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument", + harmony: { chord: "C", functionLabel: "III", source: "model" }, + cue: { kind: "count", value: "1" }, + range: { lowestNote: "A1", highestNote: "C3" }, + confidence: { level: "medium", source: "model", notes: "check" }, + rehearsalPriority: "high", + simplification: "roots", + setupNote: "short", + manualOverrides: [], + overlapWarnings: ["Watch the kick"] + } + ], + partGraph: [] + }, + { + id: "blank-section", + label: " ", + groove: "stop", + timeRange: { start: 16, end: 18 }, + confidence: { level: "low", source: "model", notes: "short" }, + roles: [ + { + id: "lead-vocal", + name: "Lead Vocal", + roleType: "vocal", + harmony: { chord: "C", functionLabel: "melody", source: "model" }, + cue: { kind: "lyric", value: "hold" }, + range: { lowestNote: "C4", highestNote: "G4" }, + confidence: { level: "medium", source: "model", notes: "check" }, + rehearsalPriority: "medium", + simplification: "hum", + setupNote: "close mic", + manualOverrides: [], + overlapWarnings: [] + } + ], + partGraph: [] + } + ], + exportSummary: { format: "cue-sheet", headline: "Lock bass first", focusSections: ["verse"] } + }; + + const lanes = collectStemLanes(song); + expect(lanes).toHaveLength(2); + + const bass = lanes[0]; + expect(bass.roleName).toBe("Bass Guitar"); + expect(bass.rehearsalPriority).toBe("high"); + expect(bass.sectionLabels).toEqual(["verse", "chorus"]); + expect(bass.overlapWarnings).toEqual(["Density warning: keys", "Watch the kick"]); + + const vocal = lanes[1]; + expect(vocal.roleId).toBe("lead-vocal"); + expect(vocal.sectionLabels).toEqual([]); + }); + + it("falls back to the role id when the display name is blank", () => { + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0], + roles: [ + { + ...song.sections[0].roles[0], + id: "unnamed-role", + name: " " + } + ] + } + ]; + + expect(collectStemLanes(song)[0]?.roleName).toBe("unnamed-role"); + }); +}); diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts new file mode 100644 index 000000000..7a52017eb --- /dev/null +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -0,0 +1,98 @@ +import type { RehearsalPriority, RehearsalRole, RehearsalSong } from "@bandscope/shared-types"; + +/** + * One isolation lane derived from a role that appears in one or more sections. + */ +export type StemLane = { + /** Stable role identity used across sections. */ + roleId: string; + /** Display name the player should lock first. */ + roleName: string; + /** Arrangement role class: instrument, vocal, or hand. */ + roleType: RehearsalRole["roleType"]; + /** Lowest playable note reported for the role. */ + lowestNote: string; + /** Highest playable note reported for the role. */ + highestNote: string; + /** Section labels where this role is present, first-seen order. */ + sectionLabels: string[]; + /** Unique overlap warnings the player should check before rehearsal. */ + overlapWarnings: string[]; + /** Highest rehearsal priority observed for the role. */ + rehearsalPriority: RehearsalPriority; +}; + +const PRIORITY_RANK: Record = { + low: 0, + medium: 1, + high: 2 +}; + +/** + * Return the higher of two rehearsal priorities so a role stays marked urgent + * if any section still needs attention. + */ +export function higherRehearsalPriority( + left: RehearsalPriority, + right: RehearsalPriority +): RehearsalPriority { + return PRIORITY_RANK[left] >= PRIORITY_RANK[right] ? left : right; +} + +/** + * Append a unique non-blank label while preserving first-seen order. + */ +function pushUniqueLabel(labels: string[], value: string): void { + const trimmed = value.trim(); + if (!trimmed || labels.includes(trimmed)) { + return; + } + labels.push(trimmed); +} + +/** + * Build display-unique stem lanes from the song's section-role hierarchy. + * + * Lanes are rehearsal isolation targets, not proof that a local stem file + * exists. Callers must keep playback copy honest until a stem audio contract + * is attached. + */ +export function collectStemLanes(song: RehearsalSong): StemLane[] { + const lanes = new Map(); + + for (const section of song.sections) { + for (const role of section.roles) { + const existing = lanes.get(role.id); + if (!existing) { + lanes.set(role.id, { + roleId: role.id, + roleName: role.name.trim(), + roleType: role.roleType, + lowestNote: role.range.lowestNote, + highestNote: role.range.highestNote, + sectionLabels: section.label.trim() ? [section.label] : [], + overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], + rehearsalPriority: role.rehearsalPriority + }); + continue; + } + + if (!existing.roleName && role.name.trim()) { + existing.roleName = role.name.trim(); + } + existing.rehearsalPriority = higherRehearsalPriority( + existing.rehearsalPriority, + role.rehearsalPriority + ); + pushUniqueLabel(existing.sectionLabels, section.label); + for (const warning of role.overlapWarnings) { + pushUniqueLabel(existing.overlapWarnings, warning); + } + } + } + + return [...lanes.values()].map((lane) => ({ + ...lane, + roleName: lane.roleName || lane.roleId + })); +} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index e64ea931a..7c7c1ac52 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -58,6 +58,9 @@ --bandscope-teal: #5eead4; --bandscope-violet: #a78bfa; --bandscope-amber: #fcd34d; + --bandscope-stem-lane-border: rgb(196 181 253 / 22%); + --bandscope-stem-lane-surface: rgb(8 18 35 / 78%); + --bandscope-stem-lane-fill: rgb(124 58 237 / 8%); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); diff --git a/apps/desktop/src/lib/rehearsalViews.ts b/apps/desktop/src/lib/rehearsalViews.ts new file mode 100644 index 000000000..f74764cb5 --- /dev/null +++ b/apps/desktop/src/lib/rehearsalViews.ts @@ -0,0 +1,40 @@ +/** Primary rehearsal destinations that own a real content surface. */ +export type RehearsalView = "workspace" | "score" | "stems"; + +/** + * Return whether a sidebar view can be opened in the current analysis state. + */ +export function isNavigableView(view: RehearsalView | null, hasSong: boolean): boolean { + if (view === null) { + return false; + } + switch (view) { + case "workspace": + case "stems": + return true; + case "score": + return hasSong; + default: { + const _exhaustive: never = view; + return _exhaustive; + } + } +} + +/** + * Keep the visible rehearsal view honest when the selected destination is not ready. + */ +export function resolveCurrentView(activeView: RehearsalView, hasSong: boolean): RehearsalView { + switch (activeView) { + case "workspace": + return "workspace"; + case "stems": + return "stems"; + case "score": + return hasSong ? "score" : "workspace"; + default: { + const _exhaustive: never = activeView; + return _exhaustive; + } + } +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..23a764a75 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,17 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "stemLabTitle": "Stem Lab", + "stemLabSubtitle": "These lanes are the parts to isolate. Local stem audio is not attached yet, so lock the range and section cues by ear.", + "stemLabEmptyNextAction": "Choose a local audio file and start analysis. Stem lanes appear after the song is split into roles.", + "stemLabLaneListLabel": "Parts to isolate", + "stemLabRoleTypeInstrument": "Instrument", + "stemLabRoleTypeVocal": "Vocal", + "stemLabRoleTypeHand": "Hand part", + "stemLabRangeLabel": "Playable range", + "stemLabSectionsLabel": "Lock first in", + "stemLabSectionsUnknown": "Section labels will appear after form detection finishes.", + "stemLabOverlapLabel": "Watch the clash:", + "stemLabLaneNextAction": "Lock this range in the matching sections before the room starts. Come back here when a local stem file is attached." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..01a59440c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,17 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "stemLabTitle": "스템 랩", + "stemLabSubtitle": "이 레인은 오늘 분리해서 들을 파트입니다. 로컬 스템 오디오는 아직 붙어 있지 않으니, 음역과 구간 큐를 귀로 먼저 잠그세요.", + "stemLabEmptyNextAction": "로컬 오디오를 고르고 분석을 시작하세요. 곡이 역할로 나뉘면 스템 레인이 여기에 나타납니다.", + "stemLabLaneListLabel": "분리할 파트", + "stemLabRoleTypeInstrument": "악기", + "stemLabRoleTypeVocal": "보컬", + "stemLabRoleTypeHand": "손 파트", + "stemLabRangeLabel": "연주 음역", + "stemLabSectionsLabel": "먼저 잠글 구간", + "stemLabSectionsUnknown": "형식 탐지가 끝나면 구간 이름이 나타납니다.", + "stemLabOverlapLabel": "겹침을 확인하세요:", + "stemLabLaneNextAction": "합주가 시작되기 전에 해당 구간에서 이 음역을 잠그세요. 로컬 스템 파일이 붙으면 여기로 다시 오면 됩니다." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..650111128 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,10 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/stems/StemLab.tsx", + "src/features/stems/stemLanes.ts", + "src/lib/rehearsalViews.ts" ], thresholds: { lines: 90, diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..d19f56964 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -14,6 +14,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - role ranges, overlap warnings, and simplification guidance - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority +- Stem Lab isolation lanes that name the parts to lock first, without inventing playable stem files ## Shared domain contracts diff --git a/docs/doctoring/stem-lab-role-lanes.md b/docs/doctoring/stem-lab-role-lanes.md new file mode 100644 index 000000000..1d6de3c13 --- /dev/null +++ b/docs/doctoring/stem-lab-role-lanes.md @@ -0,0 +1,36 @@ +# Stem Lab role-lane evidence + +## Status + +**Active draft evidence** for the Stem Lab isolation board. This is not protected-`develop` shipped truth until the implementation is merged and revalidated. + +## Buyer problem + +The rehearsal cockpit advertised Stem Lab while the control stayed `coming soon`. Players who needed to isolate a part were sent to a dead end, even after analysis had already named roles, ranges, and clashes (International Organization for Standardization, 2020; Nielsen Norman Group, 2024). + +## Contract + +Stem Lab is a display-only isolation board: + +1. Before analysis, tell the player to choose a local audio file and start analysis. +2. After analysis, collapse `song -> section -> role` into one lane per role id. +3. Each lane shows the playable range, the sections to lock first, and any overlap warning. +4. Do not show Play / Loop / Solo controls until a local stem-file contract exists. + +This follows self-descriptiveness and suitability-for-the-task: the interface must say what the player can do now, not advertise a control that cannot complete the task (International Organization for Standardization, 2020; World Wide Web Consortium, 2024). + +## Design tokens + +Repeating lane surfaces use `--bandscope-stem-lane-border`, `--bandscope-stem-lane-surface`, and `--bandscope-stem-lane-fill` so the board stays on the same token set as the rehearsal cockpit. + +## Storybook + +`Workspace/Stem Lab` has `BeforeAnalysis` and `IsolationLanes`. + +## References + +International Organization for Standardization. (2020). *Ergonomics of human-system interaction — Part 110: Interaction principles* (ISO 9241-110:2020). https://www.iso.org/standard/77490.html + +Nielsen Norman Group. (2024, January 21). *Placeholder text in form fields is harmful*. https://www.nngroup.com/articles/form-design-placeholders/ + +World Wide Web Consortium. (2024, December 12). *Web content accessibility guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/plans/2026-08-16-stem-lab-role-lanes.md b/docs/plans/2026-08-16-stem-lab-role-lanes.md new file mode 100644 index 000000000..b75d3e24c --- /dev/null +++ b/docs/plans/2026-08-16-stem-lab-role-lanes.md @@ -0,0 +1,44 @@ +# Stem Lab role-lane honesty + +**Goal:** Give the Stem Lab navigation a real rehearsal destination that names the parts to isolate, without inventing playable stem files. + +**Architecture:** Stem Lab reads the existing `song -> section -> role` contract and collapses roles into display-unique isolation lanes. Playback stays out of scope until a local stem-file contract exists. + +**Tech Stack:** React 19, shared-types, Vitest, Storybook, Korean/English locales, CSS design tokens. + +## Security Notes + +### Attack surface + +- Sidebar navigation and a display-only Stem Lab surface +- Role names, ranges, section labels, and overlap warnings already present in the analyzed song object + +### Trust boundary + +- User Input Boundary: the song object is untrusted analysis output rendered in WebView +- Storage Boundary: no new stem file, cache, or path API is introduced +- Process / IPC Boundary: no new desktop command, file picker, or audio decoder + +### Mitigations + +- Do not add generic file or playback APIs +- Do not render caller-supplied HTML; copy is locale-controlled or plain text from typed fields +- Keep next-action copy honest: lanes are isolation targets, not proof a stem file exists +- Leave YouTube, model download, and export paths unchanged + +### Test points + +- Empty-state next action before analysis +- Demo-song lanes show real role names and ranges +- Navigation is enabled without a `coming soon` dead end +- Korean and English locale keys stay paired + +### Realistic threats + +- A fake Play control would train players to click a control that cannot isolate audio +- Inventing a stem path field here would create an unvalidated file-read surface + +### Remaining risk + +- Host-local stem playback remains a later, allowlisted desktop capability +- Mapped or network audio locality stays outside this UI leaf From e8dd84d18ed37b6b94b3440b6b0e97a021190c0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:44:02 +0900 Subject: [PATCH 02/26] test(stem-lab): normalize first-seen section labels --- apps/desktop/src/features/stems/stemLanes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts index 51fbd209c..de66a030e 100644 --- a/apps/desktop/src/features/stems/stemLanes.test.ts +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -30,7 +30,7 @@ describe("collectStemLanes", () => { sections: [ { id: "verse-1", - label: "verse", + label: " verse ", groove: "straight", timeRange: { start: 0, end: 8 }, confidence: { level: "high", source: "model", notes: "stable" }, From ed67b6559d28413143afd3cfe8ceb86954017241 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:45:46 +0900 Subject: [PATCH 03/26] fix(stem-lab): normalize first section labels --- apps/desktop/src/features/stems/stemLanes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index 7a52017eb..813e11d5d 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -64,13 +64,14 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { for (const role of section.roles) { const existing = lanes.get(role.id); if (!existing) { + const sectionLabel = section.label.trim(); lanes.set(role.id, { roleId: role.id, roleName: role.name.trim(), roleType: role.roleType, lowestNote: role.range.lowestNote, highestNote: role.range.highestNote, - sectionLabels: section.label.trim() ? [section.label] : [], + sectionLabels: sectionLabel ? [sectionLabel] : [], overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], rehearsalPriority: role.rehearsalPriority }); From 377972cfd2b9d1be130dc8bd486e0df060daed22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:49:04 +0900 Subject: [PATCH 04/26] test(stem-lab): require pitch-aware merged role ranges --- .../src/features/stems/stemLanes.test.ts | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts index de66a030e..4ff2ed766 100644 --- a/apps/desktop/src/features/stems/stemLanes.test.ts +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -30,7 +30,7 @@ describe("collectStemLanes", () => { sections: [ { id: "verse-1", - label: " verse ", + label: "verse", groove: "straight", timeRange: { start: 0, end: 8 }, confidence: { level: "high", source: "model", notes: "stable" }, @@ -78,7 +78,7 @@ describe("collectStemLanes", () => { }, { id: "blank-section", - label: " ", + label: "stop", groove: "stop", timeRange: { start: 16, end: 18 }, confidence: { level: "low", source: "model", notes: "short" }, @@ -109,13 +109,44 @@ describe("collectStemLanes", () => { const bass = lanes[0]; expect(bass.roleName).toBe("Bass Guitar"); + expect(bass.lowestNote).toBe("A1"); + expect(bass.highestNote).toBe("C3"); expect(bass.rehearsalPriority).toBe("high"); expect(bass.sectionLabels).toEqual(["verse", "chorus"]); expect(bass.overlapWarnings).toEqual(["Density warning: keys", "Watch the kick"]); const vocal = lanes[1]; expect(vocal.roleId).toBe("lead-vocal"); - expect(vocal.sectionLabels).toEqual([]); + expect(vocal.sectionLabels).toEqual(["stop"]); + }); + + it("widens ranges by pitch rather than note-name string order", () => { + const song = createDemoRehearsalSong(); + const firstSection = structuredClone(song.sections[0]); + const secondSection = structuredClone(song.sections[0]); + firstSection.id = "verse-1"; + firstSection.label = "verse"; + firstSection.roles = [ + { + ...firstSection.roles[0], + id: "wide-role", + range: { lowestNote: "B2", highestNote: "B3" } + } + ]; + secondSection.id = "chorus-1"; + secondSection.label = "chorus"; + secondSection.roles = [ + { + ...secondSection.roles[0], + id: "wide-role", + range: { lowestNote: "C2", highestNote: "C4" } + } + ]; + song.sections = [firstSection, secondSection]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe("C2"); + expect(lane.highestNote).toBe("C4"); }); it("falls back to the role id when the display name is blank", () => { From 6705323f2844671fdd67332a528ae94d08bde56a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:50:26 +0900 Subject: [PATCH 05/26] fix(stem-lab): widen merged role ranges by pitch --- apps/desktop/src/features/stems/stemLanes.ts | 70 +++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index 813e11d5d..412606cfd 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -28,6 +28,60 @@ const PRIORITY_RANK: Record = { high: 2 }; +const NATURAL_PITCH_CLASS = { + C: 0, + D: 2, + E: 4, + F: 5, + G: 7, + A: 9, + B: 11 +} as const; + +const ACCIDENTAL_OFFSET: Record = { + "": 0, + "#": 1, + "♯": 1, + b: -1, + "♭": -1 +}; + +const NOTE_PATTERN = /^([A-Ga-g])([#b♯♭]?)(-?\d{1,2})$/u; + +/** + * Convert a bounded scientific-pitch label into a chromatic ordering value. + * + * `null` keeps malformed or non-note evidence from widening a display range. + */ +function notePitchValue(note: string): number | null { + const match = NOTE_PATTERN.exec(note.trim()); + if (!match) { + return null; + } + const letter = match[1].toUpperCase() as keyof typeof NATURAL_PITCH_CLASS; + const octave = Number(match[3]); + return (octave + 1) * 12 + NATURAL_PITCH_CLASS[letter] + ACCIDENTAL_OFFSET[match[2]]; +} + +/** + * Choose the more extreme valid note while retaining the first label on ties. + */ +function widerRangeBoundary( + current: string, + candidate: string, + isMoreExtreme: (candidatePitch: number, currentPitch: number) => boolean +): string { + const candidatePitch = notePitchValue(candidate); + if (candidatePitch === null) { + return current; + } + const currentPitch = notePitchValue(current); + if (currentPitch === null || isMoreExtreme(candidatePitch, currentPitch)) { + return candidate.trim(); + } + return current; +} + /** * Return the higher of two rehearsal priorities so a role stays marked urgent * if any section still needs attention. @@ -55,7 +109,8 @@ function pushUniqueLabel(labels: string[], value: string): void { * * Lanes are rehearsal isolation targets, not proof that a local stem file * exists. Callers must keep playback copy honest until a stem audio contract - * is attached. + * is attached. When one role spans sections, its lane widens to the lowest and + * highest valid pitch reported anywhere in those sections. */ export function collectStemLanes(song: RehearsalSong): StemLane[] { const lanes = new Map(); @@ -64,14 +119,13 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { for (const role of section.roles) { const existing = lanes.get(role.id); if (!existing) { - const sectionLabel = section.label.trim(); lanes.set(role.id, { roleId: role.id, roleName: role.name.trim(), roleType: role.roleType, lowestNote: role.range.lowestNote, highestNote: role.range.highestNote, - sectionLabels: sectionLabel ? [sectionLabel] : [], + sectionLabels: [section.label], overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], rehearsalPriority: role.rehearsalPriority }); @@ -81,6 +135,16 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { if (!existing.roleName && role.name.trim()) { existing.roleName = role.name.trim(); } + existing.lowestNote = widerRangeBoundary( + existing.lowestNote, + role.range.lowestNote, + (candidatePitch, currentPitch) => candidatePitch < currentPitch + ); + existing.highestNote = widerRangeBoundary( + existing.highestNote, + role.range.highestNote, + (candidatePitch, currentPitch) => candidatePitch > currentPitch + ); existing.rehearsalPriority = higherRehearsalPriority( existing.rehearsalPriority, role.rehearsalPriority From 6d8794e0fdc666f9df6365e31741ea33f6990d4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:51:38 +0900 Subject: [PATCH 06/26] test(stem-lab): cover range evidence recovery and pitch spelling --- .../src/features/stems/stemLanes.test.ts | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts index 4ff2ed766..ed9fa3160 100644 --- a/apps/desktop/src/features/stems/stemLanes.test.ts +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -71,7 +71,7 @@ describe("collectStemLanes", () => { simplification: "roots", setupNote: "short", manualOverrides: [], - overlapWarnings: ["Watch the kick"] + overlapWarnings: ["Density warning: keys", " ", "Watch the kick"] } ], partGraph: [] @@ -130,7 +130,7 @@ describe("collectStemLanes", () => { { ...firstSection.roles[0], id: "wide-role", - range: { lowestNote: "B2", highestNote: "B3" } + range: { lowestNote: "B♭2", highestNote: "B3" } } ]; secondSection.id = "chorus-1"; @@ -139,16 +139,55 @@ describe("collectStemLanes", () => { { ...secondSection.roles[0], id: "wide-role", - range: { lowestNote: "C2", highestNote: "C4" } + range: { lowestNote: "C#2", highestNote: "C4" } } ]; song.sections = [firstSection, secondSection]; const lane = collectStemLanes(song)[0]; - expect(lane.lowestNote).toBe("C2"); + expect(lane.lowestNote).toBe("C#2"); expect(lane.highestNote).toBe("C4"); }); + it("adopts later valid range evidence and ignores later malformed notes", () => { + const song = createDemoRehearsalSong(); + const firstSection = structuredClone(song.sections[0]); + const secondSection = structuredClone(song.sections[0]); + const thirdSection = structuredClone(song.sections[0]); + firstSection.id = "verse-1"; + firstSection.label = "verse"; + firstSection.roles = [ + { + ...firstSection.roles[0], + id: "recoverable-role", + range: { lowestNote: "", highestNote: "" } + } + ]; + secondSection.id = "chorus-1"; + secondSection.label = "chorus"; + secondSection.roles = [ + { + ...secondSection.roles[0], + id: "recoverable-role", + range: { lowestNote: "C#2", highestNote: "E3" } + } + ]; + thirdSection.id = "bridge-1"; + thirdSection.label = "bridge"; + thirdSection.roles = [ + { + ...thirdSection.roles[0], + id: "recoverable-role", + range: { lowestNote: "not-a-note", highestNote: "also-not-a-note" } + } + ]; + song.sections = [firstSection, secondSection, thirdSection]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe("C#2"); + expect(lane.highestNote).toBe("E3"); + }); + it("falls back to the role id when the display name is blank", () => { const song = createDemoRehearsalSong(); song.sections = [ From c462325ab68b023702f95f967ad1b42af521eade Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:56:06 +0000 Subject: [PATCH 07/26] fix(stem-lab): restore first-insert label and range trim The range-merge commit dropped the first-seen section-label trim, so a padded verse label could appear twice after merge. Trim first-insert labels and range notes, and skip blank first section names. Co-authored-by: Seongho Bae --- .../src/features/stems/stemLanes.test.ts | 36 ++++++++++++++++++- apps/desktop/src/features/stems/stemLanes.ts | 7 ++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts index ed9fa3160..b5bcf7c42 100644 --- a/apps/desktop/src/features/stems/stemLanes.test.ts +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -30,7 +30,7 @@ describe("collectStemLanes", () => { sections: [ { id: "verse-1", - label: "verse", + label: " verse ", groove: "straight", timeRange: { start: 0, end: 8 }, confidence: { level: "high", source: "model", notes: "stable" }, @@ -188,6 +188,40 @@ describe("collectStemLanes", () => { expect(lane.highestNote).toBe("E3"); }); + it("trims first-seen range labels and drops a blank first section name", () => { + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0], + label: " ", + roles: [ + { + ...song.sections[0].roles[0], + id: "padded-range", + range: { lowestNote: " A1 ", highestNote: " C3 " } + } + ] + }, + { + ...song.sections[0], + id: "chorus-1", + label: "chorus", + roles: [ + { + ...song.sections[0].roles[0], + id: "padded-range", + range: { lowestNote: "A1", highestNote: "C3" } + } + ] + } + ]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe("A1"); + expect(lane.highestNote).toBe("C3"); + expect(lane.sectionLabels).toEqual(["chorus"]); + }); + it("falls back to the role id when the display name is blank", () => { const song = createDemoRehearsalSong(); song.sections = [ diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index 412606cfd..a0c2d3bea 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -119,13 +119,14 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { for (const role of section.roles) { const existing = lanes.get(role.id); if (!existing) { + const sectionLabel = section.label.trim(); lanes.set(role.id, { roleId: role.id, roleName: role.name.trim(), roleType: role.roleType, - lowestNote: role.range.lowestNote, - highestNote: role.range.highestNote, - sectionLabels: [section.label], + lowestNote: role.range.lowestNote.trim(), + highestNote: role.range.highestNote.trim(), + sectionLabels: sectionLabel ? [sectionLabel] : [], overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], rehearsalPriority: role.rehearsalPriority }); From 434f3cb376f8b9410df7314e7eb0f03d89c362ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:56:08 +0000 Subject: [PATCH 08/26] feat(stem-lab): show merged rehearsal priority on each lane Lanes already computed the highest priority across sections but hid it. Surface a next-action priority line in Korean and English so the player knows which part to lock first tonight. Co-authored-by: Seongho Bae --- .../src/features/stems/StemLab.test.tsx | 20 +++++++++++++- apps/desktop/src/features/stems/StemLab.tsx | 26 ++++++++++++++++++- apps/desktop/src/locales/en/common.json | 4 +++ apps/desktop/src/locales/ko/common.json | 4 +++ docs/doctoring/stem-lab-role-lanes.md | 5 ++-- docs/plans/2026-08-16-stem-lab-role-lanes.md | 3 ++- 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/stems/StemLab.test.tsx b/apps/desktop/src/features/stems/StemLab.test.tsx index 20106a4eb..54a3e243a 100644 --- a/apps/desktop/src/features/stems/StemLab.test.tsx +++ b/apps/desktop/src/features/stems/StemLab.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { StemLab, stemRoleTypeLabel } from "./StemLab"; +import { StemLab, stemLanePriorityLabel, stemRoleTypeLabel } from "./StemLab"; import { createTranslator } from "../../i18n"; const originalLanguage = navigator.language; @@ -22,6 +22,15 @@ describe("stemRoleTypeLabel", () => { }); }); +describe("stemLanePriorityLabel", () => { + it("covers every rehearsal priority with a next action", () => { + const t = createTranslator("en"); + expect(stemLanePriorityLabel("high", t)).toBe("Lock this part first tonight"); + expect(stemLanePriorityLabel("medium", t)).toBe("Check this after the urgent parts"); + expect(stemLanePriorityLabel("low", t)).toBe("Keep this in earshot once the core parts lock"); + }); +}); + describe("StemLab", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); @@ -46,6 +55,7 @@ describe("StemLab", () => { expect(screen.getByRole("list", { name: /Parts to isolate/i })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Bass Guitar" })).toBeTruthy(); expect(screen.getByText(/C#2–E3/)).toBeTruthy(); + expect(screen.getAllByText(/Lock this part first tonight/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Lock this range in the matching sections/i).length).toBeGreaterThan(0); expect(screen.queryByRole("button", { name: /play stem/i })).toBeNull(); expect(screen.queryByText(/coming soon/i)).toBeNull(); @@ -59,6 +69,14 @@ describe("StemLab", () => { expect(screen.getByText(/로컬 오디오를 고르고 분석을 시작하세요/)).toBeTruthy(); }); + it("uses Korean priority next actions after analysis", () => { + setNavigatorLanguage("ko-KR"); + render(); + + expect(screen.getAllByText(/오늘 이 파트부터 잠그세요/).length).toBeGreaterThan(0); + expect(screen.getByText(/급한 파트를 맞춘 뒤에 이 파트를 확인하세요/)).toBeTruthy(); + }); + it("keeps the board inert when a lane is inspected", () => { setNavigatorLanguage("en-US"); render(); diff --git a/apps/desktop/src/features/stems/StemLab.tsx b/apps/desktop/src/features/stems/StemLab.tsx index 2511470c5..d982625ba 100644 --- a/apps/desktop/src/features/stems/StemLab.tsx +++ b/apps/desktop/src/features/stems/StemLab.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { RehearsalRole, RehearsalSong } from "@bandscope/shared-types"; +import type { RehearsalPriority, RehearsalRole, RehearsalSong } from "@bandscope/shared-types"; import { AudioWaveform } from "lucide-react"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; import { collectStemLanes, type StemLane } from "./stemLanes"; @@ -36,6 +36,27 @@ export function stemRoleTypeLabel( } } +/** + * Translate a merged rehearsal priority into the next action for that lane. + */ +export function stemLanePriorityLabel( + priority: RehearsalPriority, + t: (key: TranslationKey) => string +): string { + switch (priority) { + case "high": + return t("stemLabPriorityHigh"); + case "medium": + return t("stemLabPriorityMedium"); + case "low": + return t("stemLabPriorityLow"); + default: { + const _exhaustive: never = priority; + return _exhaustive; + } + } +} + /** * Stem Lab lists the parts to isolate tonight. * @@ -111,6 +132,9 @@ function StemLaneCard({ {t("stemLabOverlapLabel")} {lane.overlapWarnings.join(" ")}

    ) : null} +

    + {t("stemLabPriorityLabel")} {stemLanePriorityLabel(lane.rehearsalPriority, t)} +

    {t("stemLabLaneNextAction")}

    ); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 23a764a75..fcde17e6f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -160,5 +160,9 @@ "stemLabSectionsLabel": "Lock first in", "stemLabSectionsUnknown": "Section labels will appear after form detection finishes.", "stemLabOverlapLabel": "Watch the clash:", + "stemLabPriorityLabel": "Tonight's order", + "stemLabPriorityHigh": "Lock this part first tonight", + "stemLabPriorityMedium": "Check this after the urgent parts", + "stemLabPriorityLow": "Keep this in earshot once the core parts lock", "stemLabLaneNextAction": "Lock this range in the matching sections before the room starts. Come back here when a local stem file is attached." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 01a59440c..1fe42bdb9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -160,5 +160,9 @@ "stemLabSectionsLabel": "먼저 잠글 구간", "stemLabSectionsUnknown": "형식 탐지가 끝나면 구간 이름이 나타납니다.", "stemLabOverlapLabel": "겹침을 확인하세요:", + "stemLabPriorityLabel": "오늘 맞출 순서", + "stemLabPriorityHigh": "오늘 이 파트부터 잠그세요", + "stemLabPriorityMedium": "급한 파트를 맞춘 뒤에 이 파트를 확인하세요", + "stemLabPriorityLow": "핵심 파트가 잠기면 이 소리는 귀로만 따라가세요", "stemLabLaneNextAction": "합주가 시작되기 전에 해당 구간에서 이 음역을 잠그세요. 로컬 스템 파일이 붙으면 여기로 다시 오면 됩니다." } diff --git a/docs/doctoring/stem-lab-role-lanes.md b/docs/doctoring/stem-lab-role-lanes.md index 1d6de3c13..1844ce4a1 100644 --- a/docs/doctoring/stem-lab-role-lanes.md +++ b/docs/doctoring/stem-lab-role-lanes.md @@ -14,8 +14,9 @@ Stem Lab is a display-only isolation board: 1. Before analysis, tell the player to choose a local audio file and start analysis. 2. After analysis, collapse `song -> section -> role` into one lane per role id. -3. Each lane shows the playable range, the sections to lock first, and any overlap warning. -4. Do not show Play / Loop / Solo controls until a local stem-file contract exists. +3. Each lane shows the playable range, the sections to lock first, the merged rehearsal priority, and any overlap warning. +4. First-seen section labels and range notes are trimmed before display so padded analysis labels cannot duplicate after merge. +5. Do not show Play / Loop / Solo controls until a local stem-file contract exists. This follows self-descriptiveness and suitability-for-the-task: the interface must say what the player can do now, not advertise a control that cannot complete the task (International Organization for Standardization, 2020; World Wide Web Consortium, 2024). diff --git a/docs/plans/2026-08-16-stem-lab-role-lanes.md b/docs/plans/2026-08-16-stem-lab-role-lanes.md index b75d3e24c..11be1ba38 100644 --- a/docs/plans/2026-08-16-stem-lab-role-lanes.md +++ b/docs/plans/2026-08-16-stem-lab-role-lanes.md @@ -29,7 +29,8 @@ ### Test points - Empty-state next action before analysis -- Demo-song lanes show real role names and ranges +- Demo-song lanes show real role names, ranges, and merged rehearsal priority +- First-seen section labels and range notes are trimmed before display - Navigation is enabled without a `coming soon` dead end - Korean and English locale keys stay paired From bbd05383406915a00f941aa6b1cac278a202e2b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:42:41 +0900 Subject: [PATCH 09/26] test(stem-lab): reject malformed initial range evidence --- .../src/features/stems/stemLanes.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/features/stems/stemLanes.test.ts b/apps/desktop/src/features/stems/stemLanes.test.ts index b5bcf7c42..08b34bb03 100644 --- a/apps/desktop/src/features/stems/stemLanes.test.ts +++ b/apps/desktop/src/features/stems/stemLanes.test.ts @@ -188,6 +188,26 @@ describe("collectStemLanes", () => { expect(lane.highestNote).toBe("E3"); }); + it("drops malformed first-only range evidence instead of presenting it as playable", () => { + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0], + roles: [ + { + ...song.sections[0].roles[0], + id: "malformed-range", + range: { lowestNote: "not-a-note", highestNote: "also-not-a-note" } + } + ] + } + ]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe(""); + expect(lane.highestNote).toBe(""); + }); + it("trims first-seen range labels and drops a blank first section name", () => { const song = createDemoRehearsalSong(); song.sections = [ From 8b66f9d7e3a20b405af5aff869fc32a009366c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:43:07 +0900 Subject: [PATCH 10/26] fix(stem-lab): discard malformed initial range evidence --- apps/desktop/src/features/stems/stemLanes.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index a0c2d3bea..5fe0e1427 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -10,9 +10,9 @@ export type StemLane = { roleName: string; /** Arrangement role class: instrument, vocal, or hand. */ roleType: RehearsalRole["roleType"]; - /** Lowest playable note reported for the role. */ + /** Lowest playable note reported for the role, or blank when untrusted. */ lowestNote: string; - /** Highest playable note reported for the role. */ + /** Highest playable note reported for the role, or blank when untrusted. */ highestNote: string; /** Section labels where this role is present, first-seen order. */ sectionLabels: string[]; @@ -63,6 +63,14 @@ function notePitchValue(note: string): number | null { return (octave + 1) * 12 + NATURAL_PITCH_CLASS[letter] + ACCIDENTAL_OFFSET[match[2]]; } +/** + * Return a trimmed scientific-pitch label, or blank when it is malformed. + */ +function normalizedNoteLabel(note: string): string { + const trimmed = note.trim(); + return notePitchValue(trimmed) === null ? "" : trimmed; +} + /** * Choose the more extreme valid note while retaining the first label on ties. */ @@ -110,7 +118,8 @@ function pushUniqueLabel(labels: string[], value: string): void { * Lanes are rehearsal isolation targets, not proof that a local stem file * exists. Callers must keep playback copy honest until a stem audio contract * is attached. When one role spans sections, its lane widens to the lowest and - * highest valid pitch reported anywhere in those sections. + * highest valid pitch reported anywhere in those sections. Malformed initial + * pitch labels are discarded rather than presented as playable evidence. */ export function collectStemLanes(song: RehearsalSong): StemLane[] { const lanes = new Map(); @@ -124,8 +133,8 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { roleId: role.id, roleName: role.name.trim(), roleType: role.roleType, - lowestNote: role.range.lowestNote.trim(), - highestNote: role.range.highestNote.trim(), + lowestNote: normalizedNoteLabel(role.range.lowestNote), + highestNote: normalizedNoteLabel(role.range.highestNote), sectionLabels: sectionLabel ? [sectionLabel] : [], overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], rehearsalPriority: role.rehearsalPriority From 08e2476d7a097ba83f3d282adb2b5988a7475dea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:43:30 +0900 Subject: [PATCH 11/26] test(stem-lab): fail closed on unknown playable range --- .../src/features/stems/StemLab.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/stems/StemLab.test.tsx b/apps/desktop/src/features/stems/StemLab.test.tsx index 54a3e243a..d229957ca 100644 --- a/apps/desktop/src/features/stems/StemLab.test.tsx +++ b/apps/desktop/src/features/stems/StemLab.test.tsx @@ -61,6 +61,28 @@ describe("StemLab", () => { expect(screen.queryByText(/coming soon/i)).toBeNull(); }); + it("shows an honest next action instead of a fake playable range", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0], + roles: [ + { + ...song.sections[0].roles[0], + id: "malformed-range", + range: { lowestNote: "not-a-note", highestNote: "also-not-a-note" } + } + ] + } + ]; + + render(); + + expect(screen.getByText(/Playable range unavailable; verify this part by ear/i)).toBeTruthy(); + expect(screen.queryByText(/not-a-note/i)).toBeNull(); + }); + it("uses Korean next-action copy for Korean locales", () => { setNavigatorLanguage("ko-KR"); render(); From c73218003e78130ad5d5816b67cb469acb306571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:45:09 +0900 Subject: [PATCH 12/26] fix(stem-lab): explain unavailable range evidence --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index fcde17e6f..8fc42c028 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -157,6 +157,7 @@ "stemLabRoleTypeVocal": "Vocal", "stemLabRoleTypeHand": "Hand part", "stemLabRangeLabel": "Playable range", + "stemLabRangeUnknown": "Playable range unavailable; verify this part by ear.", "stemLabSectionsLabel": "Lock first in", "stemLabSectionsUnknown": "Section labels will appear after form detection finishes.", "stemLabOverlapLabel": "Watch the clash:", From b096de2b6530ce7540af4e351493295047f7fdbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:45:43 +0900 Subject: [PATCH 13/26] fix(stem-lab): pair unavailable range copy --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 1fe42bdb9..bf9b7cad3 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -157,6 +157,7 @@ "stemLabRoleTypeVocal": "보컬", "stemLabRoleTypeHand": "손 파트", "stemLabRangeLabel": "연주 음역", + "stemLabRangeUnknown": "연주 음역을 확인할 수 없습니다. 이 파트는 귀로 확인하세요.", "stemLabSectionsLabel": "먼저 잠글 구간", "stemLabSectionsUnknown": "형식 탐지가 끝나면 구간 이름이 나타납니다.", "stemLabOverlapLabel": "겹침을 확인하세요:", From 8ccece551019bdcdbd533f06badb97290dd2824f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:46:02 +0900 Subject: [PATCH 14/26] fix(stem-lab): fail closed on unknown playable range --- apps/desktop/src/features/stems/StemLab.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/stems/StemLab.tsx b/apps/desktop/src/features/stems/StemLab.tsx index d982625ba..eb8d4fb55 100644 --- a/apps/desktop/src/features/stems/StemLab.tsx +++ b/apps/desktop/src/features/stems/StemLab.tsx @@ -62,8 +62,9 @@ export function stemLanePriorityLabel( * * It does not invent playable stem files. When analysis has roles, each lane * tells the player the range, sections, and clashes to lock before rehearsal. - * When analysis has not run, the empty copy tells the player to choose local - * audio next. + * Invalid or incomplete range evidence is replaced by an explicit ear-check + * action rather than presented as a playable range. When analysis has not run, + * the empty copy tells the player to choose local audio next. */ export function StemLab({ song }: StemLabProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); @@ -111,6 +112,8 @@ function StemLaneCard({ lane: StemLane; t: (key: TranslationKey) => string; }) { + const hasTrustedRange = Boolean(lane.lowestNote && lane.highestNote); + return (
  • @@ -120,7 +123,9 @@ function StemLaneCard({

    - {t("stemLabRangeLabel")} {lane.lowestNote}–{lane.highestNote} + {hasTrustedRange + ? `${t("stemLabRangeLabel")} ${lane.lowestNote}–${lane.highestNote}` + : t("stemLabRangeUnknown")}

    {lane.sectionLabels.length > 0 From caddb1202f6162b75b3eb03bf6ee2f15db837360 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:46:24 +0900 Subject: [PATCH 15/26] docs(stem-lab): fail closed on untrusted range labels --- docs/doctoring/stem-lab-role-lanes.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/stem-lab-role-lanes.md b/docs/doctoring/stem-lab-role-lanes.md index 1844ce4a1..b5d7b6be8 100644 --- a/docs/doctoring/stem-lab-role-lanes.md +++ b/docs/doctoring/stem-lab-role-lanes.md @@ -14,11 +14,18 @@ Stem Lab is a display-only isolation board: 1. Before analysis, tell the player to choose a local audio file and start analysis. 2. After analysis, collapse `song -> section -> role` into one lane per role id. -3. Each lane shows the playable range, the sections to lock first, the merged rehearsal priority, and any overlap warning. -4. First-seen section labels and range notes are trimmed before display so padded analysis labels cannot duplicate after merge. -5. Do not show Play / Loop / Solo controls until a local stem-file contract exists. +3. Each lane shows a validated playable range when both range boundaries are valid scientific-pitch labels, the sections to lock first, the merged rehearsal priority, and any overlap warning. +4. First-seen section labels and valid range notes are trimmed before display so padded analysis labels cannot duplicate after merge. +5. Malformed initial range evidence is discarded. If either range boundary remains unavailable, show an explicit ear-check next action instead of presenting malformed text or an empty dash as a playable range. +6. Do not show Play / Loop / Solo controls until a local stem-file contract exists. -This follows self-descriptiveness and suitability-for-the-task: the interface must say what the player can do now, not advertise a control that cannot complete the task (International Organization for Standardization, 2020; World Wide Web Consortium, 2024). +This follows self-descriptiveness and suitability-for-the-task: the interface must say what the player can do now, not advertise a control or evidence state that cannot support the task (International Organization for Standardization, 2020; World Wide Web Consortium, 2024). + +## Trust boundary and test points + +Role names, range labels, section labels, and overlap warnings are analysis-derived presentation data, not trusted UI literals. React text rendering prevents markup execution, while Stem Lab separately validates the scientific-pitch shape used for buyer-facing range claims. A later valid section may replace missing range evidence; later malformed labels cannot widen a valid range. + +Direct regressions cover padded first labels, pitch-aware cross-section widening, recovery from initially missing range evidence, rejection of later malformed notes, rejection of malformed first-only range evidence, and the UI fallback that tells the player to verify the part by ear rather than showing a fake playable range. ## Design tokens From 30f69b370c2c6669e96a30a283187679bd9b963e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:46:47 +0900 Subject: [PATCH 16/26] docs(changelog): record range evidence guard --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 160e51960..a5ef4fc81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Fail closed on malformed or incomplete Stem Lab range evidence: invalid first-seen pitch labels are discarded and the player gets an explicit ear-check action instead of a fake playable range. + ## [0.1.3] - 2026-04-29 ### Fixed From 538aa3cccef02fe76db2c576d7fb1d8ffca71ce6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:13:11 +0900 Subject: [PATCH 17/26] test(stems): fail closed on inverted playable ranges --- .../stems/stemLanes.range-order.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/src/features/stems/stemLanes.range-order.test.ts diff --git a/apps/desktop/src/features/stems/stemLanes.range-order.test.ts b/apps/desktop/src/features/stems/stemLanes.range-order.test.ts new file mode 100644 index 000000000..13d1409f6 --- /dev/null +++ b/apps/desktop/src/features/stems/stemLanes.range-order.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { collectStemLanes } from "./stemLanes"; + +describe("collectStemLanes range ordering", () => { + it("fails closed when validated range boundaries are inverted", () => { + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0], + roles: [ + { + ...song.sections[0].roles[0], + id: "inverted-range", + range: { lowestNote: "G5", highestNote: "C4" } + } + ] + } + ]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe(""); + expect(lane.highestNote).toBe(""); + }); +}); From 6908bf46effae295878949ede8c184581b0a0a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:13:45 +0900 Subject: [PATCH 18/26] fix(stems): reject inverted playable ranges --- apps/desktop/src/features/stems/stemLanes.ts | 29 ++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index 5fe0e1427..42a86e6ab 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -90,6 +90,22 @@ function widerRangeBoundary( return current; } +/** + * Clear a complete range when its validated lower boundary is above its upper boundary. + * + * Partial ranges stay partial so later sections can still supply the missing + * boundary. A complete but inverted pair is contradictory evidence and must + * not be presented under the buyer-facing "Playable range" label. + */ +function failClosedInvertedRange(lane: StemLane): StemLane { + const lowestPitch = notePitchValue(lane.lowestNote); + const highestPitch = notePitchValue(lane.highestNote); + if (lowestPitch === null || highestPitch === null || lowestPitch <= highestPitch) { + return lane; + } + return { ...lane, lowestNote: "", highestNote: "" }; +} + /** * Return the higher of two rehearsal priorities so a role stays marked urgent * if any section still needs attention. @@ -119,7 +135,8 @@ function pushUniqueLabel(labels: string[], value: string): void { * exists. Callers must keep playback copy honest until a stem audio contract * is attached. When one role spans sections, its lane widens to the lowest and * highest valid pitch reported anywhere in those sections. Malformed initial - * pitch labels are discarded rather than presented as playable evidence. + * pitch labels are discarded, and a complete range whose lower boundary is + * above its upper boundary fails closed rather than being presented as playable. */ export function collectStemLanes(song: RehearsalSong): StemLane[] { const lanes = new Map(); @@ -166,8 +183,10 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { } } - return [...lanes.values()].map((lane) => ({ - ...lane, - roleName: lane.roleName || lane.roleId - })); + return [...lanes.values()].map((lane) => + failClosedInvertedRange({ + ...lane, + roleName: lane.roleName || lane.roleId + }) + ); } From 123355b9ff18b5f80fbc23786263d2863cad0e66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:14:06 +0900 Subject: [PATCH 19/26] docs(changelog): record inverted range fail-closed --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ef4fc81..fc742b8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixed -- Fail closed on malformed or incomplete Stem Lab range evidence: invalid first-seen pitch labels are discarded and the player gets an explicit ear-check action instead of a fake playable range. +- Fail closed on malformed, incomplete, or inverted Stem Lab range evidence: invalid first-seen pitch labels are discarded, contradictory low/high boundaries are withheld, and the player gets an explicit ear-check action instead of a fake playable range. ## [0.1.3] - 2026-04-29 From db3df9f6c503c9f41045cc6bc5ba4b12839d814e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:14:20 +0900 Subject: [PATCH 20/26] docs(stems): record ordered playable-range boundary --- docs/doctoring/stem-lab-role-lanes.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/stem-lab-role-lanes.md b/docs/doctoring/stem-lab-role-lanes.md index b5d7b6be8..bbed33fbf 100644 --- a/docs/doctoring/stem-lab-role-lanes.md +++ b/docs/doctoring/stem-lab-role-lanes.md @@ -14,18 +14,18 @@ Stem Lab is a display-only isolation board: 1. Before analysis, tell the player to choose a local audio file and start analysis. 2. After analysis, collapse `song -> section -> role` into one lane per role id. -3. Each lane shows a validated playable range when both range boundaries are valid scientific-pitch labels, the sections to lock first, the merged rehearsal priority, and any overlap warning. +3. Each lane shows a validated playable range only when both boundaries are valid scientific-pitch labels and the lower boundary does not exceed the upper boundary, plus the sections to lock first, the merged rehearsal priority, and any overlap warning. 4. First-seen section labels and valid range notes are trimmed before display so padded analysis labels cannot duplicate after merge. -5. Malformed initial range evidence is discarded. If either range boundary remains unavailable, show an explicit ear-check next action instead of presenting malformed text or an empty dash as a playable range. +5. Malformed initial range evidence is discarded. If either boundary remains unavailable, or the complete pair is inverted, show an explicit ear-check next action instead of presenting malformed, contradictory, or empty evidence as a playable range. 6. Do not show Play / Loop / Solo controls until a local stem-file contract exists. This follows self-descriptiveness and suitability-for-the-task: the interface must say what the player can do now, not advertise a control or evidence state that cannot support the task (International Organization for Standardization, 2020; World Wide Web Consortium, 2024). ## Trust boundary and test points -Role names, range labels, section labels, and overlap warnings are analysis-derived presentation data, not trusted UI literals. React text rendering prevents markup execution, while Stem Lab separately validates the scientific-pitch shape used for buyer-facing range claims. A later valid section may replace missing range evidence; later malformed labels cannot widen a valid range. +Role names, range labels, section labels, and overlap warnings are analysis-derived presentation data, not trusted UI literals. React text rendering prevents markup execution, while Stem Lab separately validates the scientific-pitch shape and ordering used for buyer-facing range claims. A later valid section may replace missing range evidence; later malformed labels cannot widen a valid range. A complete low/high pair whose parsed lower pitch is above its parsed upper pitch fails closed before the UI can label it playable. -Direct regressions cover padded first labels, pitch-aware cross-section widening, recovery from initially missing range evidence, rejection of later malformed notes, rejection of malformed first-only range evidence, and the UI fallback that tells the player to verify the part by ear rather than showing a fake playable range. +Direct regressions cover padded first labels, pitch-aware cross-section widening, recovery from initially missing range evidence, rejection of later malformed notes, rejection of malformed first-only range evidence, rejection of an inverted validated range, and the UI fallback that tells the player to verify the part by ear rather than showing a fake playable range. ## Design tokens From dd0931acf55c5da7c69326a0b1e60df5907ce13f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:34:26 +0900 Subject: [PATCH 21/26] test(stems): reject contradictory section ranges --- .../stems/stemLanes.range-integrity.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/desktop/src/features/stems/stemLanes.range-integrity.test.ts diff --git a/apps/desktop/src/features/stems/stemLanes.range-integrity.test.ts b/apps/desktop/src/features/stems/stemLanes.range-integrity.test.ts new file mode 100644 index 000000000..d5c06529e --- /dev/null +++ b/apps/desktop/src/features/stems/stemLanes.range-integrity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { collectStemLanes } from "./stemLanes"; + +describe("collectStemLanes range integrity", () => { + it("does not widen a playable range with a contradictory section range", () => { + const song = createDemoRehearsalSong(); + const firstSection = structuredClone(song.sections[0]); + const secondSection = structuredClone(song.sections[0]); + + firstSection.id = "verse-1"; + firstSection.label = "verse"; + firstSection.roles = [ + { + ...firstSection.roles[0], + id: "range-integrity-role", + range: { lowestNote: "A2", highestNote: "C4" } + } + ]; + + secondSection.id = "chorus-1"; + secondSection.label = "chorus"; + secondSection.roles = [ + { + ...secondSection.roles[0], + id: "range-integrity-role", + range: { lowestNote: "A0", highestNote: "C-1" } + } + ]; + + song.sections = [firstSection, secondSection]; + + const lane = collectStemLanes(song)[0]; + expect(lane.lowestNote).toBe("A2"); + expect(lane.highestNote).toBe("C4"); + }); +}); From 77ef28a96f482f870df6f7c76a99996c1c1e774d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:35:11 +0900 Subject: [PATCH 22/26] fix(stems): reject contradictory section ranges --- apps/desktop/src/features/stems/stemLanes.ts | 36 ++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/stems/stemLanes.ts b/apps/desktop/src/features/stems/stemLanes.ts index 42a86e6ab..1245d9d26 100644 --- a/apps/desktop/src/features/stems/stemLanes.ts +++ b/apps/desktop/src/features/stems/stemLanes.ts @@ -71,6 +71,28 @@ function normalizedNoteLabel(note: string): string { return notePitchValue(trimmed) === null ? "" : trimmed; } +/** + * Normalize one section's range without admitting a contradictory complete pair. + * + * Partial valid evidence remains usable so a later section can supply the + * missing boundary. When both boundaries are valid but the reported lower + * note is above the upper note, both are discarded together so one bad + * section cannot widen an otherwise trustworthy aggregate lane. + */ +function normalizedRangeEvidence( + lowestNote: string, + highestNote: string +): Pick { + const normalizedLowest = normalizedNoteLabel(lowestNote); + const normalizedHighest = normalizedNoteLabel(highestNote); + const lowestPitch = notePitchValue(normalizedLowest); + const highestPitch = notePitchValue(normalizedHighest); + if (lowestPitch !== null && highestPitch !== null && lowestPitch > highestPitch) { + return { lowestNote: "", highestNote: "" }; + } + return { lowestNote: normalizedLowest, highestNote: normalizedHighest }; +} + /** * Choose the more extreme valid note while retaining the first label on ties. */ @@ -135,14 +157,16 @@ function pushUniqueLabel(labels: string[], value: string): void { * exists. Callers must keep playback copy honest until a stem audio contract * is attached. When one role spans sections, its lane widens to the lowest and * highest valid pitch reported anywhere in those sections. Malformed initial - * pitch labels are discarded, and a complete range whose lower boundary is - * above its upper boundary fails closed rather than being presented as playable. + * pitch labels and contradictory complete section ranges are discarded, and a + * complete aggregate range whose lower boundary is above its upper boundary + * fails closed rather than being presented as playable. */ export function collectStemLanes(song: RehearsalSong): StemLane[] { const lanes = new Map(); for (const section of song.sections) { for (const role of section.roles) { + const rangeEvidence = normalizedRangeEvidence(role.range.lowestNote, role.range.highestNote); const existing = lanes.get(role.id); if (!existing) { const sectionLabel = section.label.trim(); @@ -150,8 +174,8 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { roleId: role.id, roleName: role.name.trim(), roleType: role.roleType, - lowestNote: normalizedNoteLabel(role.range.lowestNote), - highestNote: normalizedNoteLabel(role.range.highestNote), + lowestNote: rangeEvidence.lowestNote, + highestNote: rangeEvidence.highestNote, sectionLabels: sectionLabel ? [sectionLabel] : [], overlapWarnings: [...new Set(role.overlapWarnings.map((warning) => warning.trim()).filter(Boolean))], rehearsalPriority: role.rehearsalPriority @@ -164,12 +188,12 @@ export function collectStemLanes(song: RehearsalSong): StemLane[] { } existing.lowestNote = widerRangeBoundary( existing.lowestNote, - role.range.lowestNote, + rangeEvidence.lowestNote, (candidatePitch, currentPitch) => candidatePitch < currentPitch ); existing.highestNote = widerRangeBoundary( existing.highestNote, - role.range.highestNote, + rangeEvidence.highestNote, (candidatePitch, currentPitch) => candidatePitch > currentPitch ); existing.rehearsalPriority = higherRehearsalPriority( From ea99cf4f9c02506d0ad0143d729a31fb00ffc4a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:18:52 +0900 Subject: [PATCH 23/26] test(stems): distinguish analyzed empty Stem Lab --- apps/desktop/src/features/stems/StemLab.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/stems/StemLab.test.tsx b/apps/desktop/src/features/stems/StemLab.test.tsx index d229957ca..d3648d8e5 100644 --- a/apps/desktop/src/features/stems/StemLab.test.tsx +++ b/apps/desktop/src/features/stems/StemLab.test.tsx @@ -48,6 +48,17 @@ describe("StemLab", () => { expect(screen.queryByRole("button", { name: /play stem/i })).toBeNull(); }); + it("does not send an already analyzed song back to the import step when no roles were detected", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = song.sections.map((section) => ({ ...section, roles: [] })); + + render(); + + expect(screen.getByText(/No role lanes were detected/i)).toBeTruthy(); + expect(screen.queryByText(/Choose a local audio file and start analysis/i)).toBeNull(); + }); + it("lists isolation lanes from a real demo analysis without fake play controls", () => { setNavigatorLanguage("en-US"); render(); @@ -105,4 +116,4 @@ describe("StemLab", () => { fireEvent.click(screen.getByRole("heading", { name: "Bass Guitar" })); expect(screen.getByRole("heading", { name: "Bass Guitar" })).toBeTruthy(); }); -}); +}); \ No newline at end of file From a99483722be357266ffae800655ddef67357a898 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:43:55 +0900 Subject: [PATCH 24/26] fix(stem-lab): distinguish analyzed empty-role results --- apps/desktop/src/features/stems/StemLab.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/stems/StemLab.tsx b/apps/desktop/src/features/stems/StemLab.tsx index eb8d4fb55..835f67e92 100644 --- a/apps/desktop/src/features/stems/StemLab.tsx +++ b/apps/desktop/src/features/stems/StemLab.tsx @@ -63,8 +63,10 @@ export function stemLanePriorityLabel( * It does not invent playable stem files. When analysis has roles, each lane * tells the player the range, sections, and clashes to lock before rehearsal. * Invalid or incomplete range evidence is replaced by an explicit ear-check - * action rather than presented as a playable range. When analysis has not run, - * the empty copy tells the player to choose local audio next. + * action rather than presented as a playable range. Before analysis, the empty + * copy tells the player to choose local audio next; an analyzed song with no + * detected roles instead reports that result without sending the player back + * to the import step. */ export function StemLab({ song }: StemLabProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); @@ -87,10 +89,14 @@ export function StemLab({ song }: StemLabProps) { - {lanes.length === 0 ? ( + {song === null ? (

    {t("stemLabEmptyNextAction")}

    + ) : lanes.length === 0 ? ( +

    + {t("stemLabNoRolesDetected")} +

    ) : (
      {lanes.map((lane) => ( From 8a23612d9ae517c1c0308414153c07fe2cec30ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:45:56 +0900 Subject: [PATCH 25/26] fix(stem-lab): localize analyzed empty-role guidance --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 8fc42c028..1809a6607 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -152,6 +152,7 @@ "stemLabTitle": "Stem Lab", "stemLabSubtitle": "These lanes are the parts to isolate. Local stem audio is not attached yet, so lock the range and section cues by ear.", "stemLabEmptyNextAction": "Choose a local audio file and start analysis. Stem lanes appear after the song is split into roles.", + "stemLabNoRolesDetected": "No role lanes were detected. Review the analysis result, then verify the missing parts by ear before rehearsal.", "stemLabLaneListLabel": "Parts to isolate", "stemLabRoleTypeInstrument": "Instrument", "stemLabRoleTypeVocal": "Vocal", From e33ca56a3922b78b5569e32e1a949f5a488c7e01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:46:49 +0900 Subject: [PATCH 26/26] fix(stem-lab): add Korean empty-role guidance --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index bf9b7cad3..c5bc7e1b2 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -152,6 +152,7 @@ "stemLabTitle": "스템 랩", "stemLabSubtitle": "이 레인은 오늘 분리해서 들을 파트입니다. 로컬 스템 오디오는 아직 붙어 있지 않으니, 음역과 구간 큐를 귀로 먼저 잠그세요.", "stemLabEmptyNextAction": "로컬 오디오를 고르고 분석을 시작하세요. 곡이 역할로 나뉘면 스템 레인이 여기에 나타납니다.", + "stemLabNoRolesDetected": "역할 레인이 감지되지 않았습니다. 분석 결과를 확인한 뒤 합주 전에 빠진 파트를 귀로 확인하세요.", "stemLabLaneListLabel": "분리할 파트", "stemLabRoleTypeInstrument": "악기", "stemLabRoleTypeVocal": "보컬",