Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a398bd7
feat(workspace): guide tonight's first outro on map and player
seonghobae Aug 22, 2026
ee819fb
test(player): require consistent Korean section terminology
seonghobae Aug 23, 2026
3a06ec4
fix(i18n): use consistent Korean section terminology
seonghobae Aug 23, 2026
384404b
test(i18n): require complete Korean section-form localization
seonghobae Aug 23, 2026
939f0b2
fix(i18n): localize all supported section-form labels
seonghobae Aug 23, 2026
59e3691
test(workspace): decouple outro navigation from child order
seonghobae Aug 23, 2026
ba405db
fix(workspace): target outro by renderer-owned section marker
seonghobae Aug 23, 2026
093de2e
fix(workspace): mark rendered sections for stable outro navigation
seonghobae Aug 23, 2026
9399b70
test(workspace): require production-owned song structure hook
seonghobae Aug 23, 2026
6facbe2
fix(workspace): use production song structure anchor
seonghobae Aug 23, 2026
c087b3c
fix(workspace): navigate via renderer-owned section marker
seonghobae Aug 23, 2026
8f57bc8
test(player): require localized playback guidance
seonghobae Aug 23, 2026
f80eaed
feat(i18n): add player playback guidance copy
seonghobae Aug 23, 2026
7da2268
feat(i18n): localize player playback guidance
seonghobae Aug 23, 2026
e4526bd
fix(player): localize playback guidance
seonghobae Aug 23, 2026
f7fe779
test(workspace): align reduced-motion fixture with section marker
seonghobae Aug 23, 2026
4eea95a
test(player): reject unreachable outro Hear action
seonghobae Aug 23, 2026
ae63713
fix(player): remove unreachable outro playback authority
seonghobae Aug 23, 2026
6a1de2d
test(player): pin placeholder playback authority boundary
seonghobae Aug 23, 2026
0495f92
docs(changelog): keep first-outro playback claims truthful
seonghobae Aug 23, 2026
e060f52
docs(architecture): bound player playback authority to mounted owners
seonghobae Aug 23, 2026
4222d99
docs(claude): keep player seek authority code-current
seonghobae Aug 23, 2026
52c07ea
Merge branch 'develop' into feat/workspace-first-outro
seonghobae Aug 25, 2026
3ac65d9
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
525cb83
fix(desktop): fail closed on hostile outro metadata
seonghobae Aug 28, 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Name tonight's first labeled outro with the holding part when an active role is corroborated, the labeled ending, and the time so the next action is obvious.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- The mounted workspace copy for tonight's first labeled outro must name the holding part when corroborated, the labeled ending, and the time so the next action is obvious. The current `PlayerFeature` placeholder is not mounted by `App.tsx` and must remain guidance-only until a real playback owner provides seek authority.

## Security source

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- Name tonight's first labeled outro in the mounted rehearsal workspace so the room can finish together; the Open action moves to the matching rendered map section. The unmounted player placeholder remains guidance-only and does not claim playback authority.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into

Three layers, decoupled through shared contracts:

- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first labeled outro and opens the matching rendered map section. The current `PlayerFeature` placeholder is not mounted by `App.tsx`; it is guidance-only and must not accept seek authority or expose a buyer-facing Hear action until a real playback owner is wired into the product. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.

Expand Down
115 changes: 115 additions & 0 deletions apps/desktop/src/features/player/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it, vi } from "vitest";
import { PlayerFeature } from "./index";

function songWithOutro() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const outro = structuredClone(verse);
outro.id = "outro-1";
outro.label = "outro";
outro.timeRange = { start: 180, end: 196 };
outro.roles = [
{
...verse.roles[0]!,
id: "drums",
name: "Drums",
rehearsalPriority: "high"
}
];
outro.partGraph = [
{
role_id: "drums",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, outro];
return song;
}

describe("PlayerFeature", () => {
it("asks the room to analyze first when no song is loaded", () => {
render(<PlayerFeature title="Player" />);
expect(
screen.getByText("Analyze tonight's song first, then hear the first outro from this player.")
).toBeTruthy();
});

it("keeps the outro hear action unavailable without a mounted playback owner", () => {
render(<PlayerFeature title="Player" song={songWithOutro()} />);

expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull();
expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy();
});

it("does not grant seek authority to the unmounted player placeholder", () => {
// @ts-expect-error PlayerFeature is not a mounted playback owner and must not accept seek authority.
const unreachablePlayer = <PlayerFeature title="Player" song={songWithOutro()} onPlayFromSeconds={() => undefined} />;

expect(unreachablePlayer).toBeTruthy();
render(<PlayerFeature title="Player" song={songWithOutro()} />);
expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull();
});

it("localizes the section count, labels, and playback guidance instead of mixing English player copy", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
try {
render(<PlayerFeature title="Player" song={songWithOutro()} />);
expect(screen.getByText("2개 구간")).toBeTruthy();
expect(screen.queryByText("2 sections")).toBeNull();
expect(screen.getByText("아웃트로")).toBeTruthy();
expect(screen.queryByText("outro")).toBeNull();
expect(
screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.")
).toBeTruthy();
expect(
screen.queryByText("Audio playback requires the desktop app with a local audio source.")
).toBeNull();
} finally {
vi.unstubAllGlobals();
}
});

it("renders a safe empty summary when the runtime section collection is not an array", () => {
const song = songWithOutro();
(song as unknown as { sections: unknown }).sections = null;

render(<PlayerFeature title="Player" song={song} />);

expect(screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

it("renders a safe empty summary when the runtime section collection is sparse", () => {
const song = songWithOutro();
const sparseSections: typeof song.sections = new Array(2);
sparseSections[1] = song.sections[1]!;
song.sections = sparseSections;

render(<PlayerFeature title="Player" song={song} />);

expect(screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

it("omits malformed runtime section elements without crashing the player summary", () => {
const song = songWithOutro();
song.sections = [null, song.sections[0]!] as unknown as typeof song.sections;

render(<PlayerFeature title="Player" song={song} />);

expect(screen.getByText("1 section")).toBeTruthy();
expect(screen.getByText("verse")).toBeTruthy();
});

it("does not pass an object-valued runtime song title into React copy", () => {
const song = songWithOutro();
(song as unknown as { title: unknown }).title = { unsafe: "not-copy" };

expect(() => render(<PlayerFeature title="Player" song={song} />)).not.toThrow();
expect(screen.queryByText("not-copy")).toBeNull();
});
});
79 changes: 67 additions & 12 deletions apps/desktop/src/features/player/index.tsx
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,54 +1,109 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import {
SECTION_FORM_LABELS,
type RehearsalSection,
type RehearsalSong,
type SectionFormLabel
} from "@bandscope/shared-types";
import { FirstOutroCallout } from "../workspace/FirstOutroCallout";
import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n";

/** Documented. */
export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) {
const { title, song } = props;
type PlayerFeatureProps = {
title: string;
song?: RehearsalSong | null;
};

/** Return whether one runtime section is safe to summarize in the player. */
function isPlayerSummarySection(value: unknown): value is RehearsalSection {
if (value === null || typeof value !== "object") {
return false;
}
const section = value as Partial<RehearsalSection>;
return (
typeof section.id === "string" &&
section.id.trim().length > 0 &&
typeof section.label === "string" &&
SECTION_FORM_LABELS.includes(section.label as SectionFormLabel)
);
}

/** Return dense, individually valid sections without trusting runtime collection metadata. */
function playerSummarySections(song: RehearsalSong): RehearsalSection[] {
const sections = song.sections as unknown;
if (!Array.isArray(sections)) {
return [];
}
const length = Number(sections.length);
if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
return [];
}
for (let index = 0; index < length; index += 1) {
if (!(index in sections)) {
return [];
}
}
return sections.filter(isPlayerSummarySection);
}

/** Player summary remains guidance-only until a mounted playback owner provides real seek authority. */
export function PlayerFeature({ title, song }: PlayerFeatureProps) {
const locale = detectPreferredLocale();
const t = createTranslator(locale);

if (!song) {
return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<p style={{ color: "#999" }}>No song loaded. Start an analysis to use the player.</p>
<p style={{ color: "#999" }}>{t("firstOutroNeedsSong")}</p>
</section>
);
}

const sections = playerSummarySections(song);
const songTitle = typeof song.title === "string" ? song.title : "";
const sectionCountLabel = t(
sections.length === 1
? "metricConfidenceSectionCountSingular"
: "metricConfidenceSectionCountPlural"
).replace("{count}", String(sections.length));
Comment thread
seonghobae marked this conversation as resolved.

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<FirstOutroCallout song={song} actionMode="callback-only" />
<div
style={{
padding: "16px",
backgroundColor: "#fafafa",
borderRadius: "8px",
border: "1px solid #e8e8e8",
marginTop: "16px"
}}
>
<div style={{ marginBottom: "12px" }}>
<strong>{song.title}</strong>
<strong>{songTitle}</strong>
<span style={{ color: "#666", marginLeft: "8px" }}>
{song.sections.length} {song.sections.length === 1 ? "section" : "sections"}
{sectionCountLabel}
</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{song.sections.map((section) => (
{sections.map((section, sectionIndex) => (
<span
key={section.id}
key={`${section.id}-${sectionIndex}`}
style={{
padding: "4px 12px",
borderRadius: "16px",
backgroundColor: "#fff",
border: "1px solid #d9d9d9",
fontSize: "0.85em",
textTransform: "capitalize",
textTransform: "capitalize"
}}
>
{section.label}
{translateSectionFormLabel(locale, section.label)}
Comment thread
seonghobae marked this conversation as resolved.
</span>
))}
</div>
<div style={{ marginTop: "16px", color: "#999", fontSize: "0.85em" }}>
Audio playback requires the desktop app with a local audio source.
{t("playerPlaybackRequiresLocalAudio")}
</div>
</div>
</section>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstOutroCallout } from "./FirstOutroCallout";

describe("FirstOutroCallout Korean role copy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("keeps vowel-ending dynamic role names particle-safe before and after the outro action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
const outro = structuredClone(seed);
outro.id = "outro-particle";
outro.label = "outro";
outro.timeRange = { start: 180, end: 196 };
outro.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }];
outro.partGraph = [
{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }
];
song.sections = [outro];

const onHearOutro = vi.fn();
render(<FirstOutroCallout song={song} actionMode="callback-only" onHearOutro={onHearOutro} />);

expect(screen.getByText("3:00 아웃트로에서 피아노 파트가 끝맺습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "3:00에 피아노 끝맺음 듣기" }));

expect(onHearOutro).toHaveBeenCalledWith(180);
expect(screen.getByText("3:00에서 피아노 파트와 함께 마지막 마디를 잡으세요. 같이 끝내세요.")).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstOutroCallout } from "./FirstOutroCallout";

function songWithOutro() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const outro = structuredClone(verse);
outro.id = "outro-1";
outro.label = "outro";
outro.timeRange = { start: 180, end: 196 };
outro.roles = [
{
...verse.roles[0]!,
id: "drums",
name: "Drums",
rehearsalPriority: "high"
}
];
outro.partGraph = [
{
role_id: "drums",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, outro];
return song;
}

describe("FirstOutroCallout reduced motion", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("scrolls immediately when the operating system requests reduced motion", () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}));

const grid = document.createElement("div");
const first = document.createElement("div");
first.dataset.sectionIndex = "0";
const target = document.createElement("div");
target.dataset.sectionIndex = "1";
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(first);
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstOutroCallout song={songWithOutro()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Drums outro at 3:00" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

grid.remove();
});
});
Loading
Loading