Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AGENTS.md

## Project overview
- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, tonight's first D.C. al Fine to return to the beginning and end at Fine, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-31

## Brand source

Expand Down Expand Up @@ -82,7 +82,7 @@ Last updated: 2026-03-11
- likely harmony by section and by role
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, tonight's first D.C. al Fine to return to the beginning and end at Fine, and the next instrument check
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
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 D.C. al Fine on the ready rehearsal map and tell the room to return to the beginning and end at Fine before the first range check.
- 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). The ready workspace names tonight's first playable range, tonight's first D.C. al Fine to return to the beginning and end at Fine, 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
134 changes: 134 additions & 0 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,73 @@ pub enum AnalysisCacheStatus {
pub struct RehearsalSongPayload {
id: String,
title: String,
#[serde(
default,
deserialize_with = "deserialize_optional_dc_al_fine",
skip_serializing_if = "Option::is_none"
)]
dc_al_fine: Option<DcAlFinePayload>,
Comment on lines +125 to +130

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: Absent markers remain backward-compatible

The custom deserializer defaults an absent dcAlFine but rejects explicit null. Legacy projects still load without gaining a synthetic marker.

Devin Review

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

sections: Vec<RehearsalSectionPayload>,
export_summary: ExportSummaryPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
score_attachments: Option<Vec<ScoreAttachmentMetadataPayload>>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DcAlFinePayload {
label: String,
}

impl<'de> Deserialize<'de> for DcAlFinePayload {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawDcAlFinePayload {
label: String,
}

let raw = RawDcAlFinePayload::deserialize(deserializer)?;
if !is_trusted_dc_al_fine_label(&raw.label) {
return Err(serde::de::Error::custom(
"dcAlFine label must be D.C. al Fine or D.C. al Fine 1–9",
));
}

Ok(Self { label: raw.label })
}
}

fn is_trusted_dc_al_fine_label(label: &str) -> bool {
matches!(
label,
"D.C. al Fine"
| "D.C. al Fine 1"
| "D.C. al Fine 2"
| "D.C. al Fine 3"
| "D.C. al Fine 4"
| "D.C. al Fine 5"
| "D.C. al Fine 6"
| "D.C. al Fine 7"
| "D.C. al Fine 8"
| "D.C. al Fine 9"
)
}

fn deserialize_optional_dc_al_fine<'de, D>(
deserializer: D,
) -> Result<Option<DcAlFinePayload>, D::Error>
where
D: Deserializer<'de>,
{
Option::<DcAlFinePayload>::deserialize(deserializer)?
.map(Some)
.ok_or_else(|| serde::de::Error::custom("dcAlFine must be an object when present"))
}

/// Score attachment metadata persisted inside the song payload. Only the
/// locally minted score id and the display file name cross the IPC boundary;
/// the PDF bytes stay in the app-owned scores directory keyed by that id.
Expand Down Expand Up @@ -786,6 +847,77 @@ mod tests {
assert_eq!(parsed.sections[0].id, "verse-1");
}

#[test]
fn rehearsal_song_payload_round_trips_optional_dc_al_fine() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["dcAlFine"] = json!({ "label": "D.C. al Fine" });

let parsed = serde_json::from_value::<RehearsalSongPayload>(payload)
.expect("song payload with a dcAlFine should deserialize");
let dc_al_fine = parsed
.dc_al_fine
.as_ref()
.expect("dcAlFine should survive deserialization");
assert_eq!(dc_al_fine.label, "D.C. al Fine");

let serialized =
serde_json::to_value(&parsed).expect("marked song payload should serialize back");
assert_eq!(serialized["dcAlFine"], json!({ "label": "D.C. al Fine" }));

let loaded = project_payload_from_content(
&serde_json::to_string(&serialized).expect("marked payload should encode"),
)
.expect("marked project should load");
assert_eq!(
loaded.dc_al_fine.as_ref().map(|value| value.label.as_str()),
Some("D.C. al Fine")
);
}

#[test]
fn rehearsal_song_payload_round_trips_numbered_dc_al_fine_labels() {
for label in ["D.C. al Fine 1", "D.C. al Fine 5", "D.C. al Fine 9"] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["dcAlFine"] = json!({ "label": label });

let parsed = serde_json::from_value::<RehearsalSongPayload>(payload)
.unwrap_or_else(|_| panic!("trusted dcAlFine {label} should deserialize"));
assert_eq!(
parsed.dc_al_fine.as_ref().map(|value| value.label.as_str()),
Some(label)
);
}
}

#[test]
fn rehearsal_song_payload_rejects_invalid_dc_al_fine() {
for dc_al_fine in [
json!({ "label": "d.c. al fine" }),
json!({ "label": "D.C. Al Fine" }),
json!({ "label": "Da Capo" }),
json!({ "label": "Dal Segno" }),
json!({ "label": "Fine" }),
json!({ "label": "To Coda" }),
json!({ "label": "Coda" }),
json!({ "label": "D.S. al Coda" }),
json!({ "label": "D.C. al Coda" }),
json!({ "label": "D.S. al Fine" }),
json!({ "label": "al Fine" }),
json!({ "label": "D.S." }),
json!({ "label": "D.C." }),
json!({ "label": "D.C. al Fine 0" }),
json!({ "label": "D.C. al Fine 10" }),
json!({ "label": "" }),
json!({ "label": "D.C. al Fine", "confidence": "high" }),
json!({ "text": "D.C. al Fine" }),
Value::Null,
] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["dcAlFine"] = dc_al_fine;
assert!(serde_json::from_value::<RehearsalSongPayload>(payload).is_err());
}
}

#[test]
fn rehearsal_song_payload_round_trips_score_attachments() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
Expand Down Expand Up @@ -817,9 +949,11 @@ mod tests {
.expect("legacy payload without score attachments should deserialize");

assert!(parsed.score_attachments.is_none());
assert!(parsed.dc_al_fine.is_none());
let serialized =
serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON");
assert!(serialized.get("scoreAttachments").is_none());
assert!(serialized.get("dcAlFine").is_none());
}

#[test]
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,44 @@ describe("Workspace", () => {
);
});

it("checks the first range after a D.C. al Fine when sections are already named", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

const callout = screen.getByTestId("first-dc-al-fine");
expect(callout).toHaveTextContent("Tonight's first D.C. al Fine");
expect(callout).toHaveTextContent(
"Tonight's first D.C. al Fine is D.C. al Fine: at D.C. al Fine, return to the beginning and end at Fine, then check tonight's first range."
);
expect(callout).not.toHaveTextContent("start the first verse");
});

it("asks the room to stay on the map when the D.C. al Fine is missing", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
delete song.dcAlFine;

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

expect(screen.getByTestId("first-dc-al-fine")).toHaveTextContent(
"Tonight's first D.C. al Fine still needs a label. Stay on tonight's map until the first D.C. al Fine is marked, then check tonight's first range."
);
});

it("keeps D.C. al Fine copy target-agnostic even when the first section is unnamed", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = song.sections.map((section) => ({ ...section, label: "none" }));

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

expect(screen.getByTestId("first-dc-al-fine")).toHaveTextContent(
"Tonight's first D.C. al Fine is D.C. al Fine: at D.C. al Fine, return to the beginning and end at Fine, then name the first section so the room knows where it starts."
);
});

it("asks for an ear check when the selected part has no named span", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText } from "./firstRangeSqueeze";
import { fillDcAlFineCopy, firstDcAlFinePlan } from "./firstDcAlFine";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -163,6 +164,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const firstDcAlFine = useMemo(() => firstDcAlFinePlan(song), [song]);
const firstSectionIsNamed = meaningfulRangeText(song.sections[0]?.label) !== undefined;
const firstDcAlFineCopy = firstDcAlFine
? fillDcAlFineCopy(
t(firstSectionIsNamed ? "workspaceFirstDcAlFineReady" : "workspaceFirstDcAlFineReadyNoSection"),
{
label: firstDcAlFine.label
}
)
: t("workspaceFirstDcAlFineMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +320,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<p className="text-xs font-black uppercase tracking-[0.24em] text-fuchsia-200">{t("workspaceFirstRangeTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstRangeCopy}</p>
</section>
<section
className="rounded-2xl border border-amber-300/20 bg-amber-300/[0.07] p-4"
data-testid="first-dc-al-fine"
aria-label={t("workspaceFirstDcAlFineTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-amber-200">{t("workspaceFirstDcAlFineTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstDcAlFineCopy}</p>
</section>

<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<section className="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4 md:col-span-2">
Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/src/features/workspace/firstDcAlFine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import {
fillDcAlFineCopy,
firstDcAlFinePlan,
isTrustedDcAlFineLabel,
MAX_DC_AL_FINE_LABEL_LENGTH,
trustedDcAlFine
} from "./firstDcAlFine";

describe("trustedDcAlFine", () => {
it("admits only own Gould/MusicXML D.C. al Fine and D.C. al Fine 1–9 labels", () => {
expect(trustedDcAlFine({ label: "D.C. al Fine" })).toEqual({ label: "D.C. al Fine" });
expect(trustedDcAlFine({ label: "D.C. al Fine 1" })).toEqual({ label: "D.C. al Fine 1" });
expect(trustedDcAlFine({ label: "D.C. al Fine 9" })).toEqual({ label: "D.C. al Fine 9" });
const inherited = Object.create({ label: "D.C. al Fine" }) as Record<string, unknown>;
expect(trustedDcAlFine(inherited)).toBeNull();
expect(trustedDcAlFine({ label: "d.c. al fine" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. Al Fine" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. AL FINE" })).toBeNull();
expect(trustedDcAlFine({ label: " D.C. al Fine" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. al Fine " })).toBeNull();
expect(trustedDcAlFine({ label: "Da Capo" })).toBeNull();
expect(trustedDcAlFine({ label: "Dal Segno" })).toBeNull();
expect(trustedDcAlFine({ label: "Fine" })).toBeNull();
expect(trustedDcAlFine({ label: "To Coda" })).toBeNull();
expect(trustedDcAlFine({ label: "Coda" })).toBeNull();
expect(trustedDcAlFine({ label: "D.S. al Coda" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. al Coda" })).toBeNull();
expect(trustedDcAlFine({ label: "D.S. al Fine" })).toBeNull();
expect(trustedDcAlFine({ label: "al Fine" })).toBeNull();
expect(trustedDcAlFine({ label: "D.S." })).toBeNull();
expect(trustedDcAlFine({ label: "D.C." })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. al Fine 0" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. al Fine 10" })).toBeNull();
expect(trustedDcAlFine({ label: "" })).toBeNull();
expect(trustedDcAlFine({ label: "D.C. al Fine", extra: true })).toBeNull();
expect(trustedDcAlFine({ text: "D.C. al Fine" })).toBeNull();
expect(trustedDcAlFine(null)).toBeNull();
expect(trustedDcAlFine("D.C. al Fine")).toBeNull();
expect(MAX_DC_AL_FINE_LABEL_LENGTH).toBe(14);
});
});

describe("isTrustedDcAlFineLabel", () => {
it("rejects lowercase, sibling navigation, padded, and overlong tokens", () => {
expect(isTrustedDcAlFineLabel("D.C. al Fine")).toBe(true);
expect(isTrustedDcAlFineLabel("D.C. al Fine 2")).toBe(true);
expect(isTrustedDcAlFineLabel("d.c. al fine")).toBe(false);
expect(isTrustedDcAlFineLabel("D.C. al Fine 01")).toBe(false);
expect(isTrustedDcAlFineLabel("D.C. al Fine.")).toBe(false);
});
});

describe("firstDcAlFinePlan", () => {
it("builds a D.C. al Fine plan without inventing beginning or Fine destinations", () => {
expect(firstDcAlFinePlan(createDemoRehearsalSong())).toEqual({ label: "D.C. al Fine" });
});

it("ignores section order because the stored compound has no destination authority", () => {
expect(
firstDcAlFinePlan({
dcAlFine: { label: "D.C. al Fine 2" },
sections: [{ label: "intro" }, { label: "bridge" }, { label: "outro" }]
})
).toEqual({ label: "D.C. al Fine 2" });
});

it("fails closed without a trusted D.C. al Fine", () => {
const song = createDemoRehearsalSong();
delete song.dcAlFine;
expect(firstDcAlFinePlan(song)).toBeNull();
expect(firstDcAlFinePlan(undefined)).toBeNull();
expect(firstDcAlFinePlan([])).toBeNull();
});
});

describe("fillDcAlFineCopy", () => {
it("fills own-property tokens once and keeps rehearsal values literal", () => {
expect(
fillDcAlFineCopy("Tonight's first D.C. al Fine is {label}: return at {label}.", {
label: "D.C. al Fine {label}"
})
).toBe("Tonight's first D.C. al Fine is D.C. al Fine {label}: return at D.C. al Fine {label}.");
expect(fillDcAlFineCopy("keep {toString}", {})).toBe("keep {toString}");
});
});
Loading
Loading