Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
832109a
feat(workspace): guide tonight's first intro on map and player
seonghobae Aug 19, 2026
ff219f5
test(workspace): lock intro tie-breaking across locales
seonghobae Aug 19, 2026
ebfd15b
fix(workspace): make intro tie-breaking locale independent
seonghobae Aug 19, 2026
990f6f5
test(i18n): reject inherited section-label lookups
seonghobae Aug 19, 2026
1e1a937
test(player): require localized section-count copy
seonghobae Aug 19, 2026
a41090c
fix(i18n): keep inherited keys out of section labels
seonghobae Aug 19, 2026
85c425d
fix(player): localize section-count labels
seonghobae Aug 19, 2026
9f4dd36
fix(player): localize section count copy
seonghobae Aug 19, 2026
862cae4
test(player): require localized intro badge
seonghobae Aug 19, 2026
9a7a465
fix(player): localize intro section badge
seonghobae Aug 19, 2026
8b48181
test(workspace): lock particle-safe Korean intro copy
seonghobae Aug 21, 2026
d3d3c40
fix(workspace): keep Korean intro role copy particle-safe
seonghobae Aug 21, 2026
0cb60b5
test(workspace): cover Korean intro armed particle safety
seonghobae Aug 22, 2026
93f49dc
fix(workspace): keep Korean intro armed copy particle-safe
seonghobae Aug 22, 2026
b35424c
test(workspace): align Korean intro copy oracle
seonghobae Aug 22, 2026
c9180f7
docs(changelog): preserve tempo badge entry
seonghobae Aug 22, 2026
26b7f10
test(workspace): reject inherited intro metadata
seonghobae Aug 22, 2026
cbea560
fix(workspace): reject inherited intro metadata
seonghobae Aug 22, 2026
f06234b
docs(workspace): record intro metadata trust boundary
seonghobae Aug 22, 2026
37f6de8
test(i18n): require Korean section badge labels
seonghobae Aug 23, 2026
36a93c9
fix(i18n): localize all section form badges
seonghobae Aug 23, 2026
41063fe
test(workspace): reject exceptional intro metadata
seonghobae Aug 23, 2026
785dbef
fix(workspace): contain exceptional intro metadata
seonghobae Aug 23, 2026
3f6217a
test(runtime): bound synthetic section collection scans
seonghobae Aug 23, 2026
bd5b1ea
fix(runtime): bound intro section density scans
seonghobae Aug 23, 2026
8b1e402
fix(player): bound runtime section density scans
seonghobae Aug 23, 2026
b473ab5
refactor(i18n): remove unreachable label fallback branch
seonghobae Aug 23, 2026
0f62b37
test(player): localize playback availability hint
seonghobae Aug 23, 2026
45a877b
feat(i18n): add localized player playback hint
seonghobae Aug 23, 2026
8cbe4b3
feat(i18n): add Korean player playback hint
seonghobae Aug 23, 2026
ff9ab44
fix(player): localize playback availability hint
seonghobae Aug 23, 2026
9b7a1c6
test(workspace): require localized timeline summary
seonghobae Aug 23, 2026
3984ba0
test(workspace): use shared localized timeline labels
seonghobae Aug 23, 2026
3e81d49
fix(workspace): localize timeline summary
seonghobae Aug 23, 2026
96985e1
test(workspace): cover summary term capitalization
seonghobae Aug 23, 2026
6d38625
fix(workspace): capitalize confidence summary label
seonghobae Aug 23, 2026
14db3d7
test(workspace): isolate malformed first-intro candidates
seonghobae Aug 23, 2026
7ad0956
fix(workspace): isolate malformed first-intro metadata
seonghobae Aug 23, 2026
b627d31
test(player): reject accessor-backed section collections
seonghobae Aug 23, 2026
4855357
fix(player): contain section collection accessors
seonghobae Aug 23, 2026
396d003
Merge branch 'develop' into feat/workspace-first-intro-count-in
seonghobae Aug 25, 2026
d22b6ca
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
39bad56
fix(workspace): read intro sections through trap-safe own-data path
seonghobae Aug 26, 2026
ff5e47d
fix(workspace): fail closed on throwing runtime 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 intro with the holding part when an active role is corroborated, the labeled start, 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.
- Workspace and player copy for tonight's first labeled intro must name the holding part when corroborated, the labeled start, and the time so the next action is obvious.

## 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 intro on the workspace and player so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback.
Comment thread
seonghobae marked this conversation as resolved.
- 각 합주 역할(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). Workspace and player name tonight's first labeled intro so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. 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
143 changes: 143 additions & 0 deletions apps/desktop/src/features/player/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it, vi } from "vitest";
import { PlayerFeature } from "./index";

function songWithIntro() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const intro = structuredClone(verse);
intro.id = "intro-1";
intro.label = "intro";
intro.timeRange = { start: 0, end: 8 };
intro.roles = [
{
...verse.roles[0]!,
id: "drums",
name: "Drums",
rehearsalPriority: "high"
}
];
intro.partGraph = [
{
role_id: "drums",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [intro, verse];
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 intro from this player.")
).toBeTruthy();
});

it("keeps the intro hear action unavailable without a player playback callback", () => {
render(<PlayerFeature title="Player" song={songWithIntro()} />);

expect(screen.queryByRole("button", { name: "Hear Drums start at 0:00" })).toBeNull();
expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy();
});

it("delegates the intro hear action to the owning player callback", () => {
const onPlayFromSeconds = vi.fn();
render(<PlayerFeature title="Player" song={songWithIntro()} onPlayFromSeconds={onPlayFromSeconds} />);

fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" }));

expect(onPlayFromSeconds).toHaveBeenCalledTimes(1);
expect(onPlayFromSeconds).toHaveBeenCalledWith(0);
});

it("localizes the section count, labels, and playback hint instead of mixing English player copy", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
try {
render(<PlayerFeature title="Player" song={songWithIntro()} />);
expect(screen.getByText("2개 섹션")).toBeTruthy();
expect(screen.queryByText("2 sections")).toBeNull();
expect(screen.getByText("인트로")).toBeTruthy();
expect(screen.queryByText("intro")).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 = songWithIntro();
(song as unknown as { sections: unknown }).sections = null;

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

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

it("renders a safe empty summary when the runtime section collection is sparse", () => {
const song = songWithIntro();
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 intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

it("renders a safe empty summary when sections is a throwing own accessor", () => {
const song = songWithIntro();
Object.defineProperty(song, "sections", {
configurable: true,
enumerable: true,
get() {
throw new Error("sections getter must stay data");
}
});

expect(() => render(<PlayerFeature title="Player" song={song} />)).not.toThrow();
expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

it("renders a safe empty summary when a song Proxy throws on sections access", () => {
const song = songWithIntro();
const proxiedSong = new Proxy(song, {
get(target, key, receiver) {
if (key === "sections") {
throw new Error("sections get trap");
}
return Reflect.get(target, key, receiver);
}
});

expect(() => render(<PlayerFeature title="Player" song={proxiedSong} />)).not.toThrow();
expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

it("omits malformed runtime section elements without crashing the player summary", () => {
const song = songWithIntro();
song.sections = [null, song.sections[1]!] 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 = songWithIntro();
(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();
});
});
101 changes: 89 additions & 12 deletions apps/desktop/src/features/player/index.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,54 +1,131 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import {
SECTION_FORM_LABELS,
type RehearsalSection,
type RehearsalSong,
type SectionFormLabel
} from "@bandscope/shared-types";
import { FirstIntroCallout } from "../workspace/FirstIntroCallout";
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;
onPlayFromSeconds?: (startSeconds: number) => void;
};

/** Read an own data property without invoking accessors or letting descriptor traps escape. */
function readOwnDataProperty(value: object, key: PropertyKey): unknown {
try {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
return undefined;
}
Reflect.get(value, key);
return descriptor.value;
} catch {
return undefined;
}
}

/** 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>;
Comment on lines +24 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Intentional read asymmetry between player and callout

The player's readOwnDataProperty adds a Reflect.get so a throwing sections get-trap fails closed to an empty summary, while readOwnDataProperty omits it and still resolves the intro through the descriptor value. Each path is covered by its own test.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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 invoking an untrusted collection accessor. */
function playerSummarySections(song: RehearsalSong): RehearsalSection[] {
const sections = readOwnDataProperty(song, "sections");
if (!Array.isArray(sections)) {
return [];
}
try {
const length = Number(sections.length);
if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
return [];
}
const keys = Object.keys(sections);
if (keys.length !== length || !keys.every((key, index) => key === String(index))) {
return [];
}
return sections.filter(isPlayerSummarySection);
} catch {
return [];
}
}

/** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */
export function PlayerFeature({ title, song, onPlayFromSeconds }: 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("firstIntroNeedsSong")}</p>
</section>
);
}

const sections = playerSummarySections(song);
const rawSongTitle = readOwnDataProperty(song, "title");
const songTitle = typeof rawSongTitle === "string" ? rawSongTitle : "";
const calloutSong = sections.length === 0
? ({ sections: [] } as unknown as RehearsalSong)
: song;
const sectionCountLabel = t(
sections.length === 1
? "metricConfidenceSectionCountSingular"
: "metricConfidenceSectionCountPlural"
).replace("{count}", String(sections.length));

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<FirstIntroCallout song={calloutSong} actionMode="callback-only" onHearIntro={onPlayFromSeconds} />
<div
style={{
Comment thread
seonghobae marked this conversation as resolved.
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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</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)}
</span>
))}
</div>
<div style={{ marginTop: "16px", color: "#999", fontSize: "0.85em" }}>
Audio playback requires the desktop app with a local audio source.
{t("playerPlaybackRequiresDesktop")}
</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 { FirstIntroCallout } from "./FirstIntroCallout";

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

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

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

expect(screen.getByText("0:00 인트로에서 피아노 파트가 시작합니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "0:00에 피아노 시작 듣기" }));

expect(onHearIntro).toHaveBeenCalledWith(0);
expect(screen.getByText("0:00에서 피아노 파트와 함께 카운트인하세요. 같이 시작하세요.")).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();
});
});
Loading
Loading