(null);
const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running";
@@ -415,18 +421,66 @@ export function App() {
/** Documented. */
const handleChooseLocalAudio = async () => {
+ if (
+ analysisInFlight ||
+ isStarting ||
+ isImporting ||
+ isSelectingDemo ||
+ localSelectionInFlightRef.current
+ ) {
+ return;
+ }
+
+ localSelectionInFlightRef.current = true;
+ setIsSelectingLocal(true);
setSelectionError(null);
setSelectionErrorSource(null);
- const selection = await selectLocalAudioSource();
- if (selection.ok) {
- setSelectedBootstrap(selection.bootstrap);
+ try {
+ const selection = await selectLocalAudioSource();
+ if (selection.ok) {
+ setSelectedBootstrap(selection.bootstrap);
+ setSelectedSourceKind("local");
+ return;
+ }
+
+ setSelectedBootstrap(null);
+ setSelectedSourceKind(null);
+ setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
+ setSelectionErrorSource("local");
+ setJobStatus(null);
+ } finally {
+ localSelectionInFlightRef.current = false;
+ setIsSelectingLocal(false);
+ }
+ };
+
+ /** Validate the bundled licensed demo through the same local-audio bootstrap. */
+ const handleTryDemo = async () => {
+ if (analysisInFlight || isStarting || isImporting || demoSelectionInFlightRef.current) {
return;
}
- setSelectedBootstrap(null);
- setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
- setSelectionErrorSource("local");
- setJobStatus(null);
+ demoSelectionInFlightRef.current = true;
+ setIsSelectingDemo(true);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
+ try {
+ const selection = await selectDemoAudioSource();
+ if (selection.ok) {
+ setSelectedBootstrap(selection.bootstrap);
+ setSelectedSourceKind("demo");
+ return;
+ }
+
+ setSelectedBootstrap(null);
+ setSelectedSourceKind(null);
+ setSelectionError(safeErrorDetail(selection.error.message, t("demoUnavailable")));
+ setSelectionErrorSource("local");
+ setJobStatus(null);
+ } finally {
+ demoSelectionInFlightRef.current = false;
+ setIsSelectingDemo(false);
+ }
};
/** Documented. */
@@ -451,6 +505,7 @@ export function App() {
const selection = await importYoutubeUrl(normalizedUrl);
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
+ setSelectedSourceKind("youtube");
setYoutubeUrl("");
} else {
setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed")));
@@ -478,6 +533,9 @@ export function App() {
setJobResultBootstrap(null);
setJobError(null);
setSelectedBootstrap(null);
+ setSelectedSourceKind(null);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
@@ -514,7 +572,15 @@ export function App() {
if (jobResult) {
return ;
}
- return ;
+ return (
+
+ );
};
const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
@@ -682,7 +748,7 @@ export function App() {
setYoutubeUrl(e.target.value)}
- disabled={analysisInFlight || isStarting || isImporting}
+ disabled={analysisInFlight || isStarting || isImporting || isSelectingDemo || isSelectingLocal}
className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300"
aria-label={t("youtubeUrlAriaLabel")}
aria-invalid={selectionError && selectionErrorSource === "youtube" ? true : undefined}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..a8164c164 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -304,7 +304,7 @@ describe("Workspace", () => {
render( );
render( );
- expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "오늘 합주를 시작하세요" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy();
});
diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx
new file mode 100644
index 000000000..5f4227987
--- /dev/null
+++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates";
+
+describe("workspace first-run states", () => {
+ it("names try-the-demo and use-my-own-song as the next actions", () => {
+ const onTryDemo = vi.fn();
+ const onUseOwnSong = vi.fn();
+ render( );
+
+ expect(screen.getByRole("heading", { name: /start tonight's rehearsal/i })).toBeTruthy();
+ expect(screen.getByText(/your audio stays on this device/i)).toBeTruthy();
+ expect(screen.getByText(/original BandScope audio for evaluation/i)).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /try the demo/i }));
+ fireEvent.click(screen.getByRole("button", { name: /use my own song/i }));
+ expect(onTryDemo).toHaveBeenCalledOnce();
+ expect(onUseOwnSong).toHaveBeenCalledOnce();
+ });
+
+ it("tells the musician to start analysis after a song is selected", () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/start analysis to open tonight's first cue/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /choose a different song/i })).toBeTruthy();
+ });
+
+ it("uses general next-step copy for a locally selected song", () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/start analysis to open your first cue/i)).toBeTruthy();
+ expect(screen.queryByText(/tonight's first cue/i)).toBeNull();
+ });
+
+ it("does not fire empty-card actions while intake is disabled", () => {
+ const onTryDemo = vi.fn();
+ const onUseOwnSong = vi.fn();
+ render( );
+
+ expect(screen.getByRole("button", { name: /try the demo/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /use my own song/i })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: /try the demo/i }));
+ fireEvent.click(screen.getByRole("button", { name: /use my own song/i }));
+ expect(onTryDemo).not.toHaveBeenCalled();
+ expect(onUseOwnSong).not.toHaveBeenCalled();
+ });
+
+ it("keeps loading and error copy action-oriented", () => {
+ const { rerender } = render( );
+ expect(screen.getByRole("status")).toHaveTextContent(/analyzing audio/i);
+
+ rerender( );
+ expect(screen.getByRole("alert")).toHaveTextContent(/choose another file/i);
+
+ rerender( );
+ expect(screen.getByRole("alert")).toHaveTextContent(/an error occurred during analysis/i);
+ });
+
+ it("renders without first-run actions when the parent does not pass them", () => {
+ render( );
+ expect(screen.queryByRole("button", { name: /try the demo/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /use my own song/i })).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx
index 8f9aba1b1..796e9ac24 100644
--- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx
+++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx
@@ -1,10 +1,27 @@
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
import { Loader2, Music, AlertCircle } from "lucide-react";
-/** Documented. */
-export function EmptyState() {
+interface EmptyStateProps {
+ selectedLabel?: string | null;
+ selectedKind?: "demo" | "local" | "youtube" | null;
+ disabled?: boolean;
+ onTryDemo?: () => void;
+ onUseOwnSong?: () => void;
+}
+
+/** First-run empty workspace: try the licensed demo or use a local song. */
+export function EmptyState({
+ selectedLabel = null,
+ selectedKind = null,
+ disabled = false,
+ onTryDemo,
+ onUseOwnSong
+}: EmptyStateProps) {
const t = createTranslator(detectPreferredLocale());
+ const hasSelection = Boolean(selectedLabel);
+
return (
@@ -12,7 +29,43 @@ export function EmptyState() {
{t("workspaceReadyToAnalyzeTitle")}
- {t("workspaceEmptyState")}
+
+ {hasSelection
+ ? selectedKind === "local" || selectedKind === "youtube"
+ ? t("localSelectedNextAction")
+ : t("demoSelectedNextAction")
+ : t("workspaceEmptyState")}
+
+ {hasSelection ? null : (
+ {t("demoLimitation")}
+ )}
+ {onTryDemo || onUseOwnSong ? (
+
+ {onTryDemo ? (
+
+ {t("tryTheDemo")}
+
+ ) : null}
+ {onUseOwnSong ? (
+
+ {hasSelection ? t("chooseDifferentSong") : t("useMyOwnSong")}
+
+ ) : null}
+
+ ) : null}
);
diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts
index e3347d1f5..b9b7a8c74 100644
--- a/apps/desktop/src/lib/analysis.test.ts
+++ b/apps/desktop/src/lib/analysis.test.ts
@@ -4,6 +4,7 @@ import {
MAX_YOUTUBE_URL_LENGTH,
getAnalysisJobStatus,
importYoutubeUrl,
+ selectDemoAudioSource,
startAnalysisJob
} from "./analysis";
@@ -20,6 +21,30 @@ describe("analysis bridge", () => {
delete tauriWindow.__TAURI_INVOKE__;
});
+ it("fails closed when the licensed demo is requested outside Tauri", async () => {
+ const selection = await selectDemoAudioSource();
+
+ expect(selection).toEqual({
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message: "The licensed demo song could not be loaded. Use your own song to start tonight."
+ }
+ });
+ });
+
+ it("does not invent a browser demo bootstrap when Tauri internals lack invoke", async () => {
+ tauriWindow.__TAURI_INTERNALS__ = {};
+
+ const selection = await selectDemoAudioSource();
+
+ expect(selection.ok).toBe(false);
+ if (selection.ok) {
+ throw new Error("browser demo intake must fail closed");
+ }
+ expect(selection.error.message).toMatch(/use your own song/i);
+ });
+
it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => {
const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34");
@@ -117,50 +142,17 @@ describe("analysis bridge", () => {
expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 });
});
- it("reports staged browser fallback progress before returning the demo result", async () => {
- const queued = await startAnalysisJob(createDemoAnalysisJobRequest());
-
- expect(queued).toMatchObject({
- state: "queued",
- progressLabel: "Queued for analysis",
- progressStage: "queued",
- progressPercent: 0
- });
-
- const running = await getAnalysisJobStatus(queued.jobId);
- expect(running).toMatchObject({
- state: "running",
- progressLabel: "Decoding audio",
- progressStage: "decode",
- progressPercent: 20
- });
-
- expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({
- state: "running",
- progressLabel: "Separating stems... (45%)",
- progressStage: "separate",
- progressPercent: 45
- });
- expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({
- state: "running",
- progressLabel: "Building rehearsal cues",
- progressStage: "analyze",
- progressPercent: 70
- });
- expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({
- state: "running",
- progressLabel: "Saving reusable features",
- progressStage: "persist",
- progressPercent: 90
- });
+ it("fails browser analysis closed instead of synthesizing a rehearsal result", async () => {
+ const status = await startAnalysisJob(createDemoAnalysisJobRequest());
- const ready = await getAnalysisJobStatus(queued.jobId);
- expect(ready).toMatchObject({
- state: "succeeded",
- progressLabel: "Analysis ready",
- progressStage: "ready",
- progressPercent: 100
+ expect(status).toMatchObject({
+ state: "failed",
+ error: {
+ code: "engine_unavailable",
+ message: "Analysis engine is unavailable."
+ }
});
+ expect(status.result).toBeUndefined();
});
it("ignores a non-function Tauri v1 invoke shim", async () => {
diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts
index bb750b34b..c87010fd7 100644
--- a/apps/desktop/src/lib/analysis.ts
+++ b/apps/desktop/src/lib/analysis.ts
@@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core";
import {
createAnalysisJobStatus,
createDemoAnalysisJobRequest,
- createDemoRehearsalSong,
createProjectBootstrapSummary,
parseAnalysisJobStatus,
parseAnalysisJobRequest,
@@ -27,20 +26,16 @@ declare global {
}
}
-const browserJobStore = new Map();
-const BROWSER_PROGRESS_STEPS = [
- { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 },
- { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 },
- { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 },
- { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 }
-] as const;
const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis.";
+const DEMO_UNAVAILABLE_MESSAGE =
+ "The licensed demo song could not be loaded. Use your own song to start tonight.";
const SAFE_LOCAL_AUDIO_MESSAGES = new Set([
UNSUPPORTED_LOCAL_AUDIO_MESSAGE,
"Could not read the selected audio file.",
"Could not prepare the local project workspace.",
"Could not prepare the local cache workspace.",
- "Could not prepare the local temp workspace."
+ "Could not prepare the local temp workspace.",
+ DEMO_UNAVAILABLE_MESSAGE
]);
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
const MAX_YOUTUBE_URL_LENGTH = 2000;
@@ -115,65 +110,34 @@ function browserJobId(prefix: string): string {
async function browserFallback(command: string, args?: Record): Promise {
if (command === "start_analysis_job") {
parseAnalysisJobRequest(args?.request);
- const jobId = browserJobId("browser-job");
- const queued = createAnalysisJobStatus({
- jobId,
- state: "queued",
- progressLabel: "Queued for analysis",
- progressStage: "queued",
- progressPercent: 0,
- cacheStatus: "disabled"
+ return createAnalysisJobStatus({
+ jobId: browserJobId("browser-job"),
+ state: "failed",
+ error: {
+ code: "engine_unavailable",
+ message: "Analysis engine is unavailable."
+ }
});
- browserJobStore.set(jobId, queued);
- return queued;
}
if (command === "select_local_audio_source") {
throw new Error(UNSUPPORTED_LOCAL_AUDIO_MESSAGE);
}
+ if (command === "select_demo_audio_source") {
+ throw new Error(DEMO_UNAVAILABLE_MESSAGE);
+ }
+
if (command === "get_analysis_job_status") {
const jobId = String(args?.jobId ?? "");
- const existing = browserJobStore.get(jobId);
- if (!existing) {
- return createAnalysisJobStatus({
- jobId,
- state: "failed",
- error: {
- code: "not_found",
- message: "Analysis job was not found."
- }
- });
- }
- if (existing.state === "queued" || existing.state === "running") {
- const currentPercent = existing.progressPercent ?? 0;
- const nextStep = BROWSER_PROGRESS_STEPS.find((step) => step.progressPercent > currentPercent);
- if (nextStep) {
- const running = createAnalysisJobStatus({
- jobId,
- state: "running",
- requestedAt: existing.requestedAt,
- progressLabel: nextStep.progressLabel,
- progressStage: nextStep.progressStage,
- progressPercent: nextStep.progressPercent,
- cacheStatus: "disabled"
- });
- browserJobStore.set(jobId, running);
- return running;
- }
- }
- const succeeded = createAnalysisJobStatus({
+ return createAnalysisJobStatus({
jobId,
- state: "succeeded",
- progressLabel: "Analysis ready",
- progressStage: "ready",
- progressPercent: 100,
- cacheStatus: "disabled",
- requestedAt: existing.requestedAt,
- result: createDemoRehearsalSong()
+ state: "failed",
+ error: {
+ code: "not_found",
+ message: "Analysis job was not found."
+ }
});
- browserJobStore.set(jobId, succeeded);
- return succeeded;
}
if (command === "save_project") {
@@ -244,6 +208,28 @@ export async function selectLocalAudioSource(): Promise {
+ try {
+ const response = await invokeAnalysis("select_demo_audio_source");
+ return {
+ ok: true,
+ bootstrap: parseProjectBootstrapSummary(response)
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message:
+ error instanceof Error && SAFE_LOCAL_AUDIO_MESSAGES.has(error.message)
+ ? error.message
+ : DEMO_UNAVAILABLE_MESSAGE
+ }
+ };
+ }
+}
+
/** Documented. */
export async function startAnalysisJob(request: AnalysisJobRequest): Promise {
let parsedRequest: AnalysisJobRequest;
@@ -331,7 +317,12 @@ export async function importYoutubeUrl(url: string): Promise {
+ it("accepts the bundled CC0 package and verifies every recorded hash", () => {
+ const manifest = bundledManifest();
+ expect(manifest.artifactKind).toBe(DEMO_PROVENANCE_KIND);
+ expect(manifest.song.license).toBe("CC0-1.0");
+ expect(manifest.song.title).toBe("Late Night Set");
+ expect(manifest.song.permittedUses).toEqual([
+ "evaluation",
+ "redistribution",
+ "rehearsal-demo"
+ ]);
+ for (const asset of manifest.assets) {
+ const bytes = readFileSync(path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, asset.path));
+ expect(bytes.byteLength).toBe(asset.bytes);
+ expect(createHash("sha256").update(bytes).digest("hex")).toBe(asset.sha256);
+ }
+ const audio = manifest.assets.find((asset) => asset.role === "audio");
+ expect(audio?.path).toBe("late-night-set.wav");
+ const wav = readFileSync(path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "late-night-set.wav"));
+ expect(wav.subarray(0, 4).toString("ascii")).toBe("RIFF");
+ expect(wav.subarray(8, 12).toString("ascii")).toBe("WAVE");
+ });
+
+ it("rejects unknown fields, the wrong kind, and a missing asset role", () => {
+ const manifest = bundledManifest();
+ expect(() => parseDemoProvenanceManifest(null)).toThrow(/root/);
+ expect(() => parseDemoProvenanceManifest([])).toThrow(/root/);
+ expect(() => parseDemoProvenanceManifest({ ...manifest, extra: true })).toThrow(
+ /Invalid demo provenance field 'extra'/
+ );
+ expect(() => parseDemoProvenanceManifest({ ...manifest, artifactKind: "other" })).toThrow(
+ /artifactKind/
+ );
+ expect(() => parseDemoProvenanceManifest({ ...manifest, manifestVersion: 2 })).toThrow(
+ /manifestVersion/
+ );
+ expect(() => parseDemoProvenanceManifest({ ...manifest, song: null })).toThrow(/song/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ song: { ...manifest.song, extra: "nope" }
+ })
+ ).toThrow(/song\.extra/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ song: { ...manifest.song, license: "MIT" }
+ })
+ ).toThrow(/license/);
+ const withoutAudio = {
+ ...manifest,
+ assets: manifest.assets.filter((asset) => asset.role !== "audio")
+ };
+ expect(() => parseDemoProvenanceManifest(withoutAudio)).toThrow(/assets/);
+ });
+
+ it("rejects traversal paths, dot segments, non-hex hashes, and malformed assets", () => {
+ const manifest = bundledManifest();
+ const [audio, license, annotations] = manifest.assets;
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, path: "../secret.wav" }, license, annotations]
+ })
+ ).toThrow(/assets\[0\]\.path/);
+ for (const dotSegment of [".", ".."]) {
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, path: dotSegment }, license, annotations]
+ })
+ ).toThrow(/assets\[0\]\.path/);
+ }
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, sha256: "not-a-hash" }, license, annotations]
+ })
+ ).toThrow(/sha256/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, bytes: 1.5 }, license, annotations]
+ })
+ ).toThrow(/bytes/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, extra: true }, license, annotations]
+ })
+ ).toThrow(/assets\[0\]\.extra/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [null, license, annotations]
+ })
+ ).toThrow(/assets\[0\]/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ assets: [{ ...audio, role: "stems" }, license, annotations]
+ })
+ ).toThrow(/assets\.role/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...manifest,
+ song: { ...manifest.song, permittedUses: [] }
+ })
+ ).toThrow(/permittedUses/);
+ });
+});
diff --git a/apps/desktop/src/lib/demo.ts b/apps/desktop/src/lib/demo.ts
new file mode 100644
index 000000000..5f3ee2417
--- /dev/null
+++ b/apps/desktop/src/lib/demo.ts
@@ -0,0 +1,159 @@
+/** Kind discriminator for the public licensed-demo provenance manifest. */
+const DEMO_PROVENANCE_KIND = "bandscope.licensed-demo" as const;
+
+/** Relative directory that Tauri bundles as the licensed demo package. */
+const DEMO_RESOURCE_DIRECTORY = "apps/desktop/src-tauri/resources/demo";
+
+export { DEMO_PROVENANCE_KIND, DEMO_RESOURCE_DIRECTORY };
+
+/** Permitted asset roles inside one licensed demo package. */
+export type DemoAssetRole = "audio" | "license" | "annotations";
+
+/** One hashed file in the licensed demo package. */
+export type DemoProvenanceAsset = {
+ path: string;
+ role: DemoAssetRole;
+ sha256: string;
+ bytes: number;
+ mediaType: string;
+};
+
+/** Provenance contract for the redistributable BandScope demo song. */
+export type DemoProvenanceManifest = {
+ manifestVersion: 1;
+ artifactKind: typeof DEMO_PROVENANCE_KIND;
+ song: {
+ id: string;
+ title: string;
+ performer: string;
+ license: "CC0-1.0";
+ licenseUrl: string;
+ permittedUses: string[];
+ };
+ assets: DemoProvenanceAsset[];
+};
+
+const SHA256_PATTERN = /^[a-f0-9]{64}$/;
+const RELATIVE_FILE_PATTERN = /^[A-Za-z0-9._-]+$/;
+const MAX_MANIFEST_BYTES = 16_384;
+const MAX_ASSET_BYTES = 2_000_000;
+const REQUIRED_ROLES: DemoAssetRole[] = ["audio", "license", "annotations"];
+
+/** Read one bounded non-empty provenance string. */
+function asNonEmptyString(value: unknown, field: string): string {
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > 200) {
+ throw new Error(`Invalid demo provenance field '${field}'`);
+ }
+ return value.trim();
+}
+
+/** Narrow one untrusted asset role to the licensed-demo allowlist. */
+function asAssetRole(value: unknown): DemoAssetRole {
+ if (value === "audio" || value === "license" || value === "annotations") {
+ return value;
+ }
+ throw new Error("Invalid demo provenance field 'assets.role'");
+}
+
+/** Parse one licensed-demo provenance manifest and reject unknown fields. */
+export function parseDemoProvenanceManifest(payload: unknown): DemoProvenanceManifest {
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
+ throw new Error("Invalid demo provenance field 'root'");
+ }
+ const record = payload as Record;
+ const allowed = new Set(["manifestVersion", "artifactKind", "song", "assets"]);
+ for (const key of Object.keys(record)) {
+ if (!allowed.has(key)) {
+ throw new Error(`Invalid demo provenance field '${key}'`);
+ }
+ }
+ if (record.manifestVersion !== 1) {
+ throw new Error("Invalid demo provenance field 'manifestVersion'");
+ }
+ if (record.artifactKind !== DEMO_PROVENANCE_KIND) {
+ throw new Error("Invalid demo provenance field 'artifactKind'");
+ }
+ if (record.song === null || typeof record.song !== "object" || Array.isArray(record.song)) {
+ throw new Error("Invalid demo provenance field 'song'");
+ }
+ const songRecord = record.song as Record;
+ for (const key of Object.keys(songRecord)) {
+ if (
+ key !== "id" &&
+ key !== "title" &&
+ key !== "performer" &&
+ key !== "license" &&
+ key !== "licenseUrl" &&
+ key !== "permittedUses"
+ ) {
+ throw new Error(`Invalid demo provenance field 'song.${key}'`);
+ }
+ }
+ const permittedUses = songRecord.permittedUses;
+ if (!Array.isArray(permittedUses) || permittedUses.length === 0 || permittedUses.length > 8) {
+ throw new Error("Invalid demo provenance field 'song.permittedUses'");
+ }
+ const uses = permittedUses.map((entry, index) => asNonEmptyString(entry, `song.permittedUses[${index}]`));
+ if (songRecord.license !== "CC0-1.0") {
+ throw new Error("Invalid demo provenance field 'song.license'");
+ }
+ if (!Array.isArray(record.assets) || record.assets.length !== 3) {
+ throw new Error("Invalid demo provenance field 'assets'");
+ }
+ const assets: DemoProvenanceAsset[] = record.assets.map((entry, index) => {
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
+ throw new Error(`Invalid demo provenance field 'assets[${index}]'`);
+ }
+ const asset = entry as Record;
+ for (const key of Object.keys(asset)) {
+ if (key !== "path" && key !== "role" && key !== "sha256" && key !== "bytes" && key !== "mediaType") {
+ throw new Error(`Invalid demo provenance field 'assets[${index}].${key}'`);
+ }
+ }
+ const assetPath = asNonEmptyString(asset.path, `assets[${index}].path`);
+ if (assetPath === "." || assetPath === ".." || !RELATIVE_FILE_PATTERN.test(assetPath)) {
+ throw new Error(`Invalid demo provenance field 'assets[${index}].path'`);
+ }
+ const sha256 = asNonEmptyString(asset.sha256, `assets[${index}].sha256`);
+ if (!SHA256_PATTERN.test(sha256)) {
+ throw new Error(`Invalid demo provenance field 'assets[${index}].sha256'`);
+ }
+ if (
+ !Number.isSafeInteger(asset.bytes) ||
+ (asset.bytes as number) <= 0 ||
+ (asset.bytes as number) > MAX_ASSET_BYTES
+ ) {
+ throw new Error(`Invalid demo provenance field 'assets[${index}].bytes'`);
+ }
+ return {
+ path: assetPath,
+ role: asAssetRole(asset.role),
+ sha256,
+ bytes: asset.bytes as number,
+ mediaType: asNonEmptyString(asset.mediaType, `assets[${index}].mediaType`)
+ };
+ });
+ const roles = new Set(assets.map((asset) => asset.role));
+ for (const role of REQUIRED_ROLES) {
+ if (!roles.has(role)) {
+ throw new Error("Invalid demo provenance field 'assets.role'");
+ }
+ }
+ const encoded = JSON.stringify(payload);
+ if (encoded.length > MAX_MANIFEST_BYTES) {
+ throw new Error("Invalid demo provenance field 'root'");
+ }
+ return {
+ manifestVersion: 1,
+ artifactKind: DEMO_PROVENANCE_KIND,
+ song: {
+ id: asNonEmptyString(songRecord.id, "song.id"),
+ title: asNonEmptyString(songRecord.title, "song.title"),
+ performer: asNonEmptyString(songRecord.performer, "song.performer"),
+ license: "CC0-1.0",
+ licenseUrl: asNonEmptyString(songRecord.licenseUrl, "song.licenseUrl"),
+ permittedUses: uses
+ },
+ assets
+ };
+}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..7904f422d 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -27,9 +27,9 @@
"confidenceLevelHigh": "Ready to trust",
"provenanceSourceModel": "Auto-detected",
"provenanceSourceUser": "User-confirmed",
- "workspaceReadyToAnalyzeTitle": "Ready to Analyze",
+ "workspaceReadyToAnalyzeTitle": "Start tonight's rehearsal",
"workspaceAnalyzingAudioTitle": "Analyzing Audio",
- "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.",
+ "workspaceEmptyState": "Try the licensed demo or use your own song. Your audio stays on this device.",
"workspaceLoadingState": "Analyzing the song's form and instrument roles...",
"workspaceErrorState": "An error occurred during analysis. Please try again.",
"workspaceRehearsalMapLabel": "Tonight's rehearsal map",
@@ -149,6 +149,13 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
+ "tryTheDemo": "Try the demo",
+ "useMyOwnSong": "Use my own song",
+ "demoSelectedNextAction": "Start analysis to open tonight's first cue.",
+ "localSelectedNextAction": "Start analysis to open your first cue.",
+ "demoUnavailable": "The licensed demo song could not be loaded. Use your own song to start tonight.",
+ "demoLimitation": "The demo is original BandScope audio for evaluation, not a commercial track.",
+ "chooseDifferentSong": "Choose a different song",
"workspaceFirstRangeTitle": "Tonight's first range",
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..50e3029a2 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -27,9 +27,9 @@
"confidenceLevelHigh": "믿고 가져가도 됨",
"provenanceSourceModel": "자동 추정",
"provenanceSourceUser": "사용자 확인",
- "workspaceReadyToAnalyzeTitle": "분석 준비 완료",
+ "workspaceReadyToAnalyzeTitle": "오늘 합주를 시작하세요",
"workspaceAnalyzingAudioTitle": "오디오 분석 중",
- "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.",
+ "workspaceEmptyState": "라이선스가 있는 데모를 써 보거나 내 곡을 사용하세요. 오디오는 이 기기에 남습니다.",
"workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...",
"workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
"workspaceRehearsalMapLabel": "오늘의 합주 지도",
@@ -149,6 +149,13 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "tryTheDemo": "데모 써 보기",
+ "useMyOwnSong": "내 곡 사용",
+ "demoSelectedNextAction": "분석을 시작해 오늘 첫 큐를 여세요.",
+ "localSelectedNextAction": "분석을 시작해 첫 큐를 여세요.",
+ "demoUnavailable": "라이선스 데모 곡을 불러올 수 없습니다. 내 곡으로 오늘 합주를 시작하세요.",
+ "demoLimitation": "데모는 평가용 오리지널 BandScope 오디오이며 상업 음원이 아닙니다.",
+ "chooseDifferentSong": "다른 곡 선택",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index f1db6f2b8..8454df2f7 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -22,7 +22,9 @@ export default defineConfig({
include: [
"src/App.tsx",
"src/lib/export.ts",
+ "src/lib/demo.ts",
"src/i18n/index.ts",
+ "src/features/workspace/WorkspaceStates.tsx",
"src/features/score/ScoreViewer.tsx",
"src/features/score/ScoreView.tsx",
"src/features/score/scoreStorage.ts"
diff --git a/docs/activation/licensed-demo.md b/docs/activation/licensed-demo.md
new file mode 100644
index 000000000..72796421e
--- /dev/null
+++ b/docs/activation/licensed-demo.md
@@ -0,0 +1,50 @@
+# Licensed demo and first-run rehearsal
+
+A buyer who launches BandScope with no song loaded must be able to start tonight's rehearsal without locating a file, inventing MIR terminology, or leaving the device.
+
+This package is the licensed demo slice of #964. It does **not** close the full first-run measurement program, and it does **not** invent a parallel MIR product (#828 still owns #770).
+
+## What the buyer sees
+
+1. Open BandScope.
+2. The empty workspace names **Try the demo** and **Use my own song**.
+3. Privacy copy: your audio stays on this device.
+4. **Try the demo** validates the bundled original recording through the same local-audio intake as a user file, then enables **Start analysis**.
+5. **Use my own song** opens the existing local-file picker.
+6. Analysis is never started automatically.
+
+Korean and English keep the same choices, limitation, and next action.
+
+## Licensed package
+
+Canonical files live in `apps/desktop/src-tauri/resources/demo/`:
+
+| File | Role |
+| --- | --- |
+| `late-night-set.wav` | Original two-section evaluation audio (CC0 1.0) |
+| `LICENSE` | CC0 1.0 waiver |
+| `annotations.json` | Ground-truth verse/chorus times for later #770 evidence |
+| `provenance.json` | Exact hashes, byte sizes, performer, and permitted uses |
+
+`Late Night Set` is original Contextual Wisdom Lab audio. It is not a commercial recording. Private or copyrighted benchmark assets stay out of this public package.
+
+After changing the source audio, regenerate the WAV with `scripts/generate_licensed_demo_wav.py`, then refresh `provenance.json` with the final file's byte size and SHA-256 before packaging or committing it.
+
+## Production boundary
+
+`select_demo_audio_source` resolves the bundled WAV from the Tauri resource directory, rejects missing/symlink/non-WAV/wrong-size/non-RIFF files, then reuses the same project/cache/temp bootstrap as `select_local_audio_source`. Browser fallback fails closed and tells the musician to use their own song. No mocked analysis success is presented as a production pass.
+
+## Security Notes
+
+- Untrusted input: bundled resource bytes plus the same local-audio bootstrap as a user-selected file.
+- Trust boundary: empty-card action → allowlisted Tauri command → resource-dir lookup → size/magic/symlink checks → app-owned project roots. The provenance manifest is not a filesystem authority document and never dereferences user paths or URLs.
+- Safe failure: missing or altered demo assets surface payload-free copy that names **Use my own song**. Rejected paths are not rendered.
+- Privacy: no telemetry, no demo download, no network path for the bundled audio.
+- Test points: provenance hash/size contract, browser fail-closed demo intake, empty-card actions, Rust size/magic/symlink rejection.
+
+## Out of scope
+
+- No account, cloud upload, or telemetry consent.
+- No copyrighted commercial song.
+- No role/goal onboarding form in this slice.
+- No dependency, lockfile, or vulnerability-suppression delta. Canonical npm HIGH findings remain #783-owned.
diff --git a/scripts/generate_licensed_demo_wav.py b/scripts/generate_licensed_demo_wav.py
new file mode 100644
index 000000000..4199a7de7
--- /dev/null
+++ b/scripts/generate_licensed_demo_wav.py
@@ -0,0 +1,51 @@
+"""Generate the licensed BandScope demo WAV fixture.
+
+The output is original Contextual Wisdom Lab audio released under CC0 1.0.
+"""
+
+from __future__ import annotations
+
+import argparse
+import math
+import struct
+import wave
+from pathlib import Path
+
+SAMPLE_RATE = 22050
+DURATION_SECONDS = 2
+AMPLITUDE = 0.2
+
+
+def write_demo_wav(path: Path) -> None:
+ """Write the two-section sine WAV used by the licensed demo package."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ n_frames = SAMPLE_RATE * DURATION_SECONDS
+ with wave.open(str(path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(SAMPLE_RATE)
+ frames = bytearray()
+ for index in range(n_frames):
+ moment = index / SAMPLE_RATE
+ frequency = 220.0 if moment < 1.0 else 330.0
+ sample = int(AMPLITUDE * 32767.0 * math.sin(2.0 * math.pi * frequency * moment))
+ frames.extend(struct.pack(" int:
+ """Write ``late-night-set.wav`` to the bundled demo resource directory."""
+ parser = argparse.ArgumentParser(description="Generate the licensed BandScope demo WAV.")
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=Path("apps/desktop/src-tauri/resources/demo/late-night-set.wav"),
+ help="Destination WAV path.",
+ )
+ args = parser.parse_args()
+ write_demo_wav(args.output)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())