Skip to content
Open
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Do not add network-dependent runtime paths for local analysis.
- Treat YouTube import as policy-constrained and fallback-friendly.
- Treat files, URLs, metadata, model artifacts, and project files as untrusted input.
- Project load/save failures stay fail-closed and redacted, name the next local file action, and must not replace an open rehearsal map with the analysis error card.
- Do not add generic exec/read/write APIs.
- Use `shell=False`-style subprocess invocation with argument arrays only.
- Keep local backend access on allowlisted IPC or `127.0.0.1` only, with strict schema validation.
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ Last updated: 2026-03-11
- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase.
- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace.
- Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy.
- Project load and save failures keep the current rehearsal map when one exists and name **Choose another project** or **Try saving again** instead of replacing the workspace with the analysis error card.
- Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs.

## Verification model
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
- Name **Choose another project** after a project file fails to open, and **Try saving again** after tonight's rehearsal map fails to save, without hiding a song that is already open.

### 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 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. Project load/save failures keep an open rehearsal map visible and name **Choose another project** / **Try saving again**.
- `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
118 changes: 118 additions & 0 deletions apps/desktop/src/App.project-persistence.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createDemoRehearsalSong } from "@bandscope/shared-types";

import { App } from "./App";

const mocks = vi.hoisted(() => ({
getAnalysisJobStatus: vi.fn(),
importYoutubeUrl: vi.fn(),
loadProject: vi.fn(),
saveProject: vi.fn(),
selectLocalAudioSource: vi.fn(),
startAnalysisJob: vi.fn(),
subscribeToAnalysisJobUpdates: vi.fn()
}));

vi.mock("./features/score/ScoreView", () => ({
ScoreView: () => null
}));

vi.mock("./lib/analysis", () => ({
MAX_YOUTUBE_URL_LENGTH: 2000,
createDefaultAnalysisRequest: () => ({
sourceKind: "demo",
sourceLabel: "Late Night Set",
roleFocus: ["bass-guitar", "keys-right", "lead-vocal"]
}),
getAnalysisJobStatus: mocks.getAnalysisJobStatus,
importYoutubeUrl: mocks.importYoutubeUrl,
isSupportedYoutubeUrl: () => true,
loadProject: mocks.loadProject,
saveProject: mocks.saveProject,
selectLocalAudioSource: mocks.selectLocalAudioSource,
startAnalysisJob: mocks.startAnalysisJob,
subscribeToAnalysisJobUpdates: mocks.subscribeToAnalysisJobUpdates
}));

const localBootstrap = {
projectId: "project-local-1",
sourceMode: "reference",
projectRoot: "/tmp/bandscope/projects/project-local-1",
cacheRoot: "/tmp/bandscope/cache/project-local-1",
tempRoot: "/tmp/bandscope/temp/project-local-1",
source: {
sourcePath: "/tmp/bandscope/project-local-1/rehearsal.wav",
fileName: "rehearsal.wav",
extension: "wav",
fileSizeBytes: 1024
}
};

describe("project persistence recovery", () => {
beforeEach(() => {
mocks.getAnalysisJobStatus.mockReset();
mocks.importYoutubeUrl.mockReset();
mocks.loadProject.mockReset();
mocks.saveProject.mockReset();
mocks.selectLocalAudioSource.mockReset();
mocks.startAnalysisJob.mockReset();
mocks.subscribeToAnalysisJobUpdates.mockReset();
mocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
});

it("replaces a stale analysis failure with failed project-load recovery", async () => {
mocks.selectLocalAudioSource.mockResolvedValue({ ok: true, bootstrap: localBootstrap });
mocks.startAnalysisJob.mockRejectedValue(new Error("engine unavailable"));
mocks.loadProject.mockRejectedValue(new Error("Corrupt file"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => {
expect(screen.getByText(/rehearsal\.wav/i)).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/analysis could not start/i);
});

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /Choose another project/i })).toBeTruthy();
});

expect(screen.queryByText(/An error occurred during analysis/i)).toBeNull();
expect(screen.getByRole("alert")).toHaveTextContent(/Failed to load project: Corrupt file/i);
});

it("keeps a failed save visible in the Score view where the Save control stays reachable", async () => {
mocks.selectLocalAudioSource.mockResolvedValue({ ok: true, bootstrap: localBootstrap });
mocks.startAnalysisJob.mockResolvedValue({
jobId: "job-1",
state: "succeeded",
result: createDemoRehearsalSong()
});
mocks.saveProject.mockRejectedValue(new Error("disk full"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => {
expect(screen.getByText(/rehearsal\.wav/i)).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});

fireEvent.click(screen.getAllByRole("button", { name: /^Score$/i })[0]);
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Failed to save project: disk full/i);
});
});
});
Loading
Loading