Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
f56b4e9
feat(workspace): hear tonight's first entrance from the map
seonghobae Aug 18, 2026
fbaa553
test(workspace): keep entrance placeholders literal
seonghobae Aug 18, 2026
5abfcf8
fix(workspace): interpolate entrance copy once
seonghobae Aug 18, 2026
4050a31
test(workspace): cover first entrance song replacement
seonghobae Aug 18, 2026
6141af5
fix(workspace): reset heard state for changed entrance
seonghobae Aug 18, 2026
bd632dd
docs(design): clarify first entrance unavailable action contract
seonghobae Aug 18, 2026
03250e8
fix(changelog): preserve existing unreleased feature notes
seonghobae Aug 18, 2026
e991f4f
test(workspace): lock first entrance state transitions
seonghobae Aug 18, 2026
5b44724
test(workspace): reject malformed entrance metadata
seonghobae Aug 18, 2026
6810002
test(player): require a real entrance playback action
seonghobae Aug 18, 2026
94185c1
fix(workspace): bind first entrance action to its surface
seonghobae Aug 18, 2026
8bc71d8
fix(workspace): reject unsafe first entrance metadata
seonghobae Aug 18, 2026
a71ce94
fix(player): delegate first entrance playback
seonghobae Aug 18, 2026
74cd028
fix(i18n): use musical first-entry terminology
seonghobae Aug 18, 2026
cad86b8
docs(changelog): describe executable first entrance actions
seonghobae Aug 18, 2026
961b846
docs(architecture): align first entrance behavior
seonghobae Aug 18, 2026
71bc5bb
docs(design): record first entrance Figma and anchor contract
seonghobae Aug 18, 2026
e59b974
test(workspace): distinguish map navigation from playback
seonghobae Aug 18, 2026
d496ac6
fix(workspace): label map navigation honestly
seonghobae Aug 18, 2026
6ccdfab
feat(i18n): distinguish entrance navigation from playback
seonghobae Aug 18, 2026
db05549
feat(i18n): localize entrance map navigation
seonghobae Aug 18, 2026
827f0d7
docs(workspace): describe entrance navigation honestly
seonghobae Aug 18, 2026
e03bd5b
fix(docs): preserve release history while clarifying entrance action
seonghobae Aug 18, 2026
ef3358e
docs(workspace): align entrance action architecture copy
seonghobae Aug 18, 2026
03647cd
test(workspace): decouple entrance navigation from analysis ids
seonghobae Aug 18, 2026
a51d2d8
fix(workspace): use renderer-owned entrance navigation targets
seonghobae Aug 18, 2026
aa466dd
fix(workspace): keep entrance analysis ids out of DOM authority
seonghobae Aug 18, 2026
55b73a8
test(workspace): align entrance map contracts with current behavior
seonghobae Aug 18, 2026
0262b8e
test(a11y): require reduced-motion entrance navigation
seonghobae Aug 18, 2026
b754127
fix(a11y): honor reduced motion for entrance navigation
seonghobae Aug 18, 2026
3c2a006
docs(a11y): record reduced-motion navigation contract
seonghobae Aug 18, 2026
dd9434e
docs(changelog): note reduced-motion entrance scroll
seonghobae Aug 18, 2026
a9b22e7
test(workspace): require active first-entrance part
seonghobae Aug 18, 2026
791fa11
fix(workspace): require active first-entrance role
seonghobae Aug 18, 2026
6a749bd
test(workspace): align entrance property graph
seonghobae Aug 18, 2026
960eb49
test: reject non-boolean entrance activity flags
seonghobae Aug 18, 2026
46a6829
fix: require boolean entrance activity evidence
seonghobae Aug 18, 2026
199dbf5
test: reject malformed entrance role ids
seonghobae Aug 18, 2026
5450fb7
fix: reject malformed entrance role ids
seonghobae Aug 18, 2026
1201fae
test(workspace): require executed entrance actions
seonghobae Aug 19, 2026
f98608b
test(i18n): use grammatical Korean role copy
seonghobae Aug 19, 2026
3a418a1
feat(i18n): localize entrance section forms
seonghobae Aug 19, 2026
a571ead
fix(workspace): arm entrance only after action
seonghobae Aug 19, 2026
fd3d88f
fix(workspace): harden first entrance navigation
seonghobae Aug 19, 2026
f9a66f7
fix(workspace): reject malformed entrance metadata
seonghobae Aug 19, 2026
f27ce38
fix(workspace): keep entrance section lookup typed
seonghobae Aug 19, 2026
62ba822
test(workspace): lock particle-safe Korean entrance copy
seonghobae Aug 21, 2026
b8f588a
fix(workspace): keep Korean entrance role copy particle-safe
seonghobae Aug 21, 2026
5466880
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 25, 2026
f6ada09
docs(changelog): remove duplicate 0.1.4 test entry
seonghobae Aug 26, 2026
e5d3d0e
test(workspace): require production song-structure anchor
seonghobae Aug 26, 2026
ec1cc3b
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
88b5400
fix(workspace): remove test-only entrance selector
seonghobae Aug 26, 2026
8fc1f21
test(workspace): fail closed on ambiguous entrance grids
seonghobae Aug 26, 2026
9473742
fix(workspace): fail closed on ambiguous entrance grids
seonghobae Aug 26, 2026
a54ffbd
fix(workspace): stabilize entrance navigation anchor
seonghobae Aug 26, 2026
cead41a
test(a11y): keep first-entrance region name stable
seonghobae Aug 27, 2026
697ef10
fix(a11y): keep first-entrance region name concise
seonghobae Aug 27, 2026
b63219f
test(player): require shipped first-entrance surface
seonghobae Aug 27, 2026
6e20c4c
fix(player): localize shipped player navigation
seonghobae Aug 27, 2026
fcca36c
fix(player): localize shipped player navigation
seonghobae Aug 27, 2026
0c39a81
fix(player): expose first-entrance guidance in shipped app
seonghobae Aug 27, 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 entrance with the part, section, and start time so the next hearable 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 entrance must name the part, section, and start time so the next hearable action is obvious.

## Security source

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action arms the entrance and opens the matching map section, honors the user's reduced-motion preference for that navigation, and 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 Expand Up @@ -74,4 +75,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
4 changes: 2 additions & 2 deletions 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 entrance with its part, section, and start time; the workspace action arms the entrance and opens the mapped section, while the player renders the 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 All @@ -72,4 +72,4 @@ Supporting packages:
- Documents under `docs/plans/` must include `Security Notes`; `scripts/checks/verify_security_notes.py` enforces this mechanically.
- Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`.
- CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them.
- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`).
- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`).
40 changes: 40 additions & 0 deletions apps/desktop/src/App.player-reachability.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it, vi } from "vitest";
import { App } from "./App";

vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

vi.mock("./lib/analysis", async (importActual) => {
const actual = await importActual<typeof import("./lib/analysis")>();

return {
...actual,
loadProject: vi.fn(async () => createDemoRehearsalSong())
};
});

describe("App player reachability", () => {
it("exposes the analyzed song's first-entrance guidance through the shipped Player view without a dead playback action", async () => {
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

const playerButton = await screen.findByRole("button", { name: /^Player$/i });
fireEvent.click(playerButton);
Comment thread
seonghobae marked this conversation as resolved.

await waitFor(() => {
expect(screen.getByRole("heading", { name: /^Player$/i })).toBeTruthy();
});
expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })
).toBeNull();
});
});
12 changes: 8 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
startAnalysisJob
} from "./lib/analysis";
import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n";
import { PlayerFeature } from "./features/player";
import { ScoreView } from "./features/score/ScoreView";
import { Workspace } from "./features/workspace/Workspace";
import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates";
Expand All @@ -58,10 +59,11 @@ 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";
type RehearsalView = "workspace" | "player" | "score";

const NAV_ITEMS = [
{ labelKey: "navWorkspace", icon: Home, view: "workspace" },
{ labelKey: "navPlayer", icon: Play, view: "player" },
{ labelKey: "navImport", icon: Upload, view: null },
{ labelKey: "navExport", icon: Save, view: null },
{ labelKey: "navSections", icon: ListMusic, view: null },
Expand Down Expand Up @@ -517,16 +519,16 @@ export function App() {
return <EmptyState />;
};

const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
const currentView: RehearsalView = jobResult ? activeView : "workspace";

/** 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 = item.view === "workspace" || ((item.view === "player" || item.view === "score") && jobResult !== null);
return {
label: t(item.labelKey),
enabled,
active: enabled && item.view === currentView,
title: enabled ? undefined : item.view === "score" ? t("scoreNavDisabledHint") : t("comingSoon")
title: enabled ? undefined : item.view === "player" || item.view === "score" ? t("scoreNavDisabledHint") : t("comingSoon")
};
};

Expand Down Expand Up @@ -848,6 +850,8 @@ export function App() {
projectId={jobResultBootstrap?.projectId ?? null}
onSongUpdate={handleSongUpdate}
/>
) : currentView === "player" && jobResult ? (
<PlayerFeature title={t("navPlayer")} song={jobResult} />
Comment thread
seonghobae marked this conversation as resolved.
) : (
renderWorkspaceState()
)}
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/features/player/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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";

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 entrance from this player.")
).toBeTruthy();
});

it("names tonight's first entrance without a dead action when playback is unavailable", () => {
render(<PlayerFeature title="Player" song={createDemoRehearsalSong()} />);

expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeNull();
});

it("uses the player playback callback for the first entrance action", () => {
const onPlayFromSeconds = vi.fn();
render(
<PlayerFeature
title="Player"
song={createDemoRehearsalSong()}
onPlayFromSeconds={onPlayFromSeconds}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" }));
expect(onPlayFromSeconds).toHaveBeenCalledTimes(1);
expect(onPlayFromSeconds).toHaveBeenCalledWith(10);
});
});
64 changes: 21 additions & 43 deletions apps/desktop/src/features/player/index.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,34 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import { FirstEntranceCallout } from "../workspace/FirstEntranceCallout";
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;
};

/** Player surface that names tonight's first entrance 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>
<section className="p-6">
<h2 className="text-xl font-black text-white">{title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{t("firstEntranceNeedsSong")}</p>
</section>
);
}

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<div
style={{
padding: "16px",
backgroundColor: "#fafafa",
borderRadius: "8px",
border: "1px solid #e8e8e8",
}}
>
<div style={{ marginBottom: "12px" }}>
<strong>{song.title}</strong>
<span style={{ color: "#666", marginLeft: "8px" }}>
{song.sections.length} {song.sections.length === 1 ? "section" : "sections"}
</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{song.sections.map((section) => (
<span
key={section.id}
style={{
padding: "4px 12px",
borderRadius: "16px",
backgroundColor: "#fff",
border: "1px solid #d9d9d9",
fontSize: "0.85em",
textTransform: "capitalize",
}}
>
{section.label}
</span>
))}
</div>
<div style={{ marginTop: "16px", color: "#999", fontSize: "0.85em" }}>
Audio playback requires the desktop app with a local audio source.
</div>
</div>
<section className="space-y-4 p-6">
<h2 className="text-xl font-black text-white">{title}</h2>
<FirstEntranceCallout
song={song}
actionMode="callback-only"
onHearEntrance={onPlayFromSeconds}
/>
</section>
);
Comment thread
seonghobae marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstEntranceCallout } from "./FirstEntranceCallout";

describe("FirstEntranceCallout accessible name", () => {
it("keeps the concise first-entrance region name when no entrance exists", () => {
const song = createDemoRehearsalSong();
song.sections = [];

render(<FirstEntranceCallout song={song} />);

expect(
screen.getByRole("complementary", { name: "Tonight's first entrance" })
).toBeTruthy();
expect(
screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.")
).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstEntranceCallout } from "./FirstEntranceCallout";

afterEach(() => {
vi.unstubAllGlobals();
document.querySelector("#workspace-song-structure-grid")?.remove();
});

/** Mount the renderer-owned song-structure target used by workspace navigation. */
function appendSongStructureTarget() {
const grid = document.createElement("div");
grid.id = "workspace-song-structure-grid";
const target = document.createElement("div");
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);
return scrollIntoView;
}

describe("FirstEntranceCallout reduced-motion navigation", () => {
it("avoids smooth scrolling when the user requests reduced motion", () => {
const scrollIntoView = appendSongStructureTarget();
vi.stubGlobal(
"matchMedia",
vi.fn((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()
}))
);

render(<FirstEntranceCallout song={createDemoRehearsalSong()} />);
fireEvent.click(
screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" })
);

expect(scrollIntoView).toHaveBeenCalledWith({
block: "nearest",
behavior: "auto"
});
});
});
Loading
Loading