Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
912b93f
feat(workspace): guide tonight's first labeled handoff
seonghobae Aug 19, 2026
151feab
test(workspace): reject sparse handoff collections
seonghobae Aug 19, 2026
658f5c0
fix(workspace): reject sparse handoff evidence
seonghobae Aug 19, 2026
048dbfb
test(player): fail closed on malformed handoff sections
seonghobae Aug 19, 2026
9cfb68f
fix(player): contain malformed handoff sections
seonghobae Aug 19, 2026
fa558f2
test(i18n): localize handoff form copy
seonghobae Aug 19, 2026
cda8be6
feat(i18n): localize handoff form labels
seonghobae Aug 19, 2026
b093e9f
fix(i18n): keep handoff form copy localized
seonghobae Aug 19, 2026
fb9e9d9
test(workspace): reject malformed handoff song roots
seonghobae Aug 19, 2026
197edd3
fix(workspace): reject malformed handoff song roots
seonghobae Aug 19, 2026
b80c2b8
test(workspace): contain malformed handoff song roots
seonghobae Aug 19, 2026
5a1ae88
fix(workspace): contain malformed handoff callout roots
seonghobae Aug 19, 2026
1e1bb46
test(workspace): reject mixed malformed handoff holder evidence
seonghobae Aug 19, 2026
712ac35
test(workspace): preserve per-candidate malformed holder isolation
seonghobae Aug 19, 2026
516574b
test(player): reject object-valued runtime song title copy
seonghobae Aug 19, 2026
3a68b9a
fix(player): contain malformed runtime song title copy
seonghobae Aug 19, 2026
0f19f4f
test(workspace): lock particle-safe Korean handoff copy
seonghobae Aug 21, 2026
c368517
fix(workspace): keep Korean handoff role copy particle-safe
seonghobae Aug 21, 2026
50cae0c
test(workspace): scope handoff navigation to owning workspace
seonghobae Aug 25, 2026
2643c0c
fix(workspace): scope handoff navigation to owning workspace
seonghobae Aug 25, 2026
ae18668
Merge branch 'develop' into feat/workspace-first-handoff-cut
seonghobae Aug 25, 2026
90f29ba
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 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 handoff with the holding part when an active role is corroborated, the labeled pass, 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 handoff must name the holding part when corroborated, the labeled pass, 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 @@ -4,6 +4,7 @@

### Added

- Name tonight's first labeled handoff on the workspace and player so the room can catch the pass; 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.
- 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.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
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 handoff so the room can catch the pass; 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
97 changes: 97 additions & 0 deletions apps/desktop/src/features/player/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 songWithHandoff() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const handoff = structuredClone(verse);
handoff.id = "handoff-1";
handoff.label = "handoff";
handoff.timeRange = { start: 22, end: 24 };
handoff.roles = [
{
...verse.roles[2]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
handoff.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, handoff];
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 handoff from this player.")
).toBeTruthy();
});

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

expect(screen.queryByRole("button", { name: "Hear Lead Vocal pass at 0:22" })).toBeNull();
expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy();
});

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

fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pass at 0:22" }));

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

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

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

expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy();
expect(screen.getByText("0 sections")).toBeTruthy();
});

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

it("omits malformed runtime section elements without crashing the player summary", () => {
const song = songWithHandoff();
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 = songWithHandoff();
(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();
});
});
70 changes: 60 additions & 10 deletions apps/desktop/src/features/player/index.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,96 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import {
SECTION_FORM_LABELS,
type RehearsalSection,
type RehearsalSong,
type SectionFormLabel
} from "@bandscope/shared-types";
import { FirstHandoffCallout } from "../workspace/FirstHandoffCallout";
import { createTranslator, detectPreferredLocale } 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;
};

/** 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);
}
Comment thread
seonghobae marked this conversation as resolved.

/** Player surface that names tonight's first labeled handoff and delegates playback to the owning player. */
export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) {
const t = createTranslator(detectPreferredLocale());

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("firstHandoffNeedsSong")}</p>
</section>
);
}

const sections = playerSummarySections(song);
const songTitle = typeof song.title === "string" ? song.title : "";

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<FirstHandoffCallout song={song} actionMode="callback-only" onHearHandoff={onPlayFromSeconds} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Player Hear action has no production caller

PlayerFeature forwards onPlayFromSeconds to the callout, but grep shows no non-test caller renders PlayerFeature at all, let alone with that prop. The Hear action stays hidden in the running app until some parent wires a seek callback.

Open in Devin Review

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

<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"}
{sections.length} {sections.length === 1 ? "section" : "sections"}
</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}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { render, screen } from "@testing-library/react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstHandoffCallout } from "./FirstHandoffCallout";

/** Cast runtime input through the static song contract to exercise the renderer trust boundary. */
function runtimeSong(value: unknown): RehearsalSong {
return value as RehearsalSong;
}

describe("FirstHandoffCallout malformed song root", () => {
it("renders unavailable guidance instead of crashing when the runtime song root is null", () => {
expect(() => render(<FirstHandoffCallout song={runtimeSong(null)} />)).not.toThrow();
expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstHandoffCallout } from "./FirstHandoffCallout";

function songWithHandoff() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const handoff = structuredClone(verse);
handoff.id = "handoff-1";
handoff.label = "handoff";
handoff.timeRange = { start: 22, end: 24 };
handoff.roles = [
{
...verse.roles[2]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
handoff.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, handoff];
return song;
}

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

it("scrolls immediately when the operating system requests reduced motion", () => {
const matchMedia = vi.fn().mockReturnValue({ matches: true });
vi.stubGlobal("matchMedia", matchMedia);

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

render(<FirstHandoffCallout song={songWithHandoff()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" }));

expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });
grid.remove();
});
});
Loading
Loading