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": "보컬",