Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2f31406
feat(workspace): open Stem Lab as honest isolation lanes
cursoragent Aug 16, 2026
e8dd84d
test(stem-lab): normalize first-seen section labels
seonghobae Aug 16, 2026
ed67b65
fix(stem-lab): normalize first section labels
seonghobae Aug 16, 2026
377972c
test(stem-lab): require pitch-aware merged role ranges
seonghobae Aug 16, 2026
6705323
fix(stem-lab): widen merged role ranges by pitch
seonghobae Aug 16, 2026
6d8794e
test(stem-lab): cover range evidence recovery and pitch spelling
seonghobae Aug 16, 2026
c462325
fix(stem-lab): restore first-insert label and range trim
cursoragent Aug 16, 2026
434f3cb
feat(stem-lab): show merged rehearsal priority on each lane
cursoragent Aug 16, 2026
bbd0538
test(stem-lab): reject malformed initial range evidence
seonghobae Aug 16, 2026
8b66f9d
fix(stem-lab): discard malformed initial range evidence
seonghobae Aug 16, 2026
08e2476
test(stem-lab): fail closed on unknown playable range
seonghobae Aug 16, 2026
c732180
fix(stem-lab): explain unavailable range evidence
seonghobae Aug 16, 2026
b096de2
fix(stem-lab): pair unavailable range copy
seonghobae Aug 16, 2026
8ccece5
fix(stem-lab): fail closed on unknown playable range
seonghobae Aug 16, 2026
caddb12
docs(stem-lab): fail closed on untrusted range labels
seonghobae Aug 16, 2026
30f69b3
docs(changelog): record range evidence guard
seonghobae Aug 16, 2026
538aa3c
test(stems): fail closed on inverted playable ranges
seonghobae Aug 16, 2026
6908bf4
fix(stems): reject inverted playable ranges
seonghobae Aug 16, 2026
123355b
docs(changelog): record inverted range fail-closed
seonghobae Aug 16, 2026
db3df9f
docs(stems): record ordered playable-range boundary
seonghobae Aug 16, 2026
dd0931a
test(stems): reject contradictory section ranges
seonghobae Aug 16, 2026
77ef28a
fix(stems): reject contradictory section ranges
seonghobae Aug 16, 2026
ea99cf4
test(stems): distinguish analyzed empty Stem Lab
seonghobae Aug 17, 2026
a994837
fix(stem-lab): distinguish analyzed empty-role results
seonghobae Aug 17, 2026
8a23612
fix(stem-lab): localize analyzed empty-role guidance
seonghobae Aug 17, 2026
e33ca56
fix(stem-lab): add Korean empty-role guidance
seonghobae Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-16

## Brand source

Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@

### 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Fixed

- 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

### Fixed
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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(<App />);

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();
});
});
44 changes: 30 additions & 14 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" }
Expand Down Expand Up @@ -517,11 +517,35 @@ export function App() {
return <EmptyState />;
};

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 ? (
<ScoreView
song={jobResult}
projectId={jobResultBootstrap?.projectId ?? null}
onSongUpdate={handleSongUpdate}
/>
) : (
renderWorkspaceState()
);
case "stems":
return <StemLab song={jobResult} />;
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,
Expand Down Expand Up @@ -842,15 +866,7 @@ export function App() {
</header>

<section className="animate-in fade-in duration-500 ease-out fill-mode-both">
{currentView === "score" && jobResult ? (
<ScoreView
song={jobResult}
projectId={jobResultBootstrap?.projectId ?? null}
onSongUpdate={handleSongUpdate}
/>
) : (
renderWorkspaceState()
)}
{renderActiveRehearsalView()}
</section>
</main>
</div>
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/App.view.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
20 changes: 20 additions & 0 deletions apps/desktop/src/features/stems/StemLab.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof StemLab>;

export default meta;
type Story = StoryObj<typeof meta>;

export const BeforeAnalysis: Story = {
args: { song: null }
};

export const IsolationLanes: Story = {
args: { song: createDemoRehearsalSong() }
};
119 changes: 119 additions & 0 deletions apps/desktop/src/features/stems/StemLab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { StemLab, stemLanePriorityLabel, 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("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);
vi.restoreAllMocks();
});

it("tells the player to analyze local audio when no song is loaded", () => {
setNavigatorLanguage("en-US");
render(<StemLab song={null} />);

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("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(<StemLab song={song} />);

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(<StemLab song={createDemoRehearsalSong()} />);

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();
});

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(<StemLab song={song} />);

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(<StemLab song={null} />);

expect(screen.getByRole("heading", { name: "스템 랩" })).toBeTruthy();
expect(screen.getByText(/로컬 오디오를 고르고 분석을 시작하세요/)).toBeTruthy();
});

it("uses Korean priority next actions after analysis", () => {
setNavigatorLanguage("ko-KR");
render(<StemLab song={createDemoRehearsalSong()} />);

expect(screen.getAllByText(/오늘 이 파트부터 잠그세요/).length).toBeGreaterThan(0);
expect(screen.getByText(/급한 파트를 맞춘 뒤에 이 파트를 확인하세요/)).toBeTruthy();
});

it("keeps the board inert when a lane is inspected", () => {
setNavigatorLanguage("en-US");
render(<StemLab song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("heading", { name: "Bass Guitar" }));
expect(screen.getByRole("heading", { name: "Bass Guitar" })).toBeTruthy();
});
});
Loading
Loading