Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

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() }
};
68 changes: 68 additions & 0 deletions apps/desktop/src/features/stems/StemLab.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<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("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 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(<StemLab song={null} />);

expect(screen.getByRole("heading", { name: "스템 랩" })).toBeTruthy();
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();
});
});
117 changes: 117 additions & 0 deletions apps/desktop/src/features/stems/StemLab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
className="rounded-3xl border border-[color:var(--bandscope-stem-lane-border)] bg-[var(--bandscope-stem-lane-surface)] p-5 shadow-[0_20px_80px_rgba(0,0,0,0.24)]"
aria-labelledby="stem-lab-title"
>
<div className="mb-4 flex items-start gap-3">
<span className="rounded-2xl border border-violet-300/30 bg-violet-300/10 p-3 text-violet-100">
<AudioWaveform className="size-5" aria-hidden="true" />
</span>
<div>
<h2 id="stem-lab-title" className="text-xl font-black tracking-tight text-white">
{t("stemLabTitle")}
</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-300">{t("stemLabSubtitle")}</p>
</div>
</div>

{lanes.length === 0 ? (
<p className="rounded-2xl border border-white/10 bg-white/[0.04] p-4 text-sm leading-6 text-slate-200">
{t("stemLabEmptyNextAction")}
</p>
) : (
<ul className="grid gap-3" aria-label={t("stemLabLaneListLabel")}>
{lanes.map((lane) => (
<StemLaneCard key={lane.roleId} lane={lane} t={t} />
))}
</ul>
)}
</section>
);
}

/**
* One role lane with the next rehearsal action, not a fake play control.
*/
function StemLaneCard({
lane,
t
}: {
lane: StemLane;
t: (key: TranslationKey) => string;
}) {
return (
<li className="rounded-2xl border border-[color:var(--bandscope-stem-lane-border)] bg-[var(--bandscope-stem-lane-fill)] p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-black text-white">{lane.roleName}</h3>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-violet-200">
{stemRoleTypeLabel(lane.roleType, t)}
</p>
</div>
<p className="mt-2 text-sm leading-6 text-slate-200">
{t("stemLabRangeLabel")} {lane.lowestNote}–{lane.highestNote}
</p>
<p className="mt-1 text-sm leading-6 text-slate-300">
{lane.sectionLabels.length > 0
? `${t("stemLabSectionsLabel")} ${lane.sectionLabels.join(", ")}`
: t("stemLabSectionsUnknown")}
</p>
{lane.overlapWarnings.length > 0 ? (
<p className="mt-2 text-sm leading-6 text-amber-100">
{t("stemLabOverlapLabel")} {lane.overlapWarnings.join(" ")}
</p>
) : null}
<p className="mt-3 text-sm font-semibold leading-6 text-cyan-100">{t("stemLabLaneNextAction")}</p>
</li>
);
}
Loading
Loading