(null);
const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running";
@@ -387,6 +393,10 @@ export function App() {
/** Documented. */
const handleStartAnalysis = async () => {
+ if (analysisInFlight || isStarting || workspaceIntakeInFlightRef.current) {
+ return;
+ }
+
const submittedBootstrap = selectedBootstrap;
setJobError(null);
setJobResult(null);
@@ -415,22 +425,64 @@ export function App() {
/** Documented. */
const handleChooseLocalAudio = async () => {
+ if (analysisInFlight || isStarting || workspaceIntakeInFlightRef.current) {
+ return;
+ }
+
+ workspaceIntakeInFlightRef.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;
+ }
+
+ setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
+ setSelectionErrorSource("local");
+ setJobStatus(null);
+ } finally {
+ workspaceIntakeInFlightRef.current = false;
+ setIsSelectingLocal(false);
+ }
+ };
+
+ /** Validate the bundled licensed demo through the same local-audio bootstrap. */
+ const handleTryDemo = async () => {
+ if (analysisInFlight || isStarting || workspaceIntakeInFlightRef.current) {
return;
}
- setSelectedBootstrap(null);
- setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
- setSelectionErrorSource("local");
- setJobStatus(null);
+ workspaceIntakeInFlightRef.current = true;
+ setIsSelectingDemo(true);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
+ try {
+ const selection = await selectDemoAudioSource();
+ if (selection.ok) {
+ setSelectedBootstrap(selection.bootstrap);
+ setSelectedSourceKind("demo");
+ return;
+ }
+
+ setSelectionError(safeErrorDetail(selection.error.message, t("demoUnavailable")));
+ setSelectionErrorSource("local");
+ setJobStatus(null);
+ } finally {
+ workspaceIntakeInFlightRef.current = false;
+ setIsSelectingDemo(false);
+ }
};
/** Documented. */
const handleImportYoutube = async () => {
+ if (analysisInFlight || isStarting || workspaceIntakeInFlightRef.current) {
+ return;
+ }
+
setSelectionError(null);
setSelectionErrorSource(null);
const normalizedUrl = youtubeUrl.trim();
@@ -446,11 +498,13 @@ export function App() {
return;
}
+ workspaceIntakeInFlightRef.current = true;
setIsImporting(true);
try {
const selection = await importYoutubeUrl(normalizedUrl);
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
+ setSelectedSourceKind("youtube");
setYoutubeUrl("");
} else {
setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed")));
@@ -460,6 +514,7 @@ export function App() {
setSelectionError(t("youtubeImportFailed"));
setSelectionErrorSource("youtube");
} finally {
+ workspaceIntakeInFlightRef.current = false;
setIsImporting(false);
}
};
@@ -472,18 +527,30 @@ export function App() {
/** Documented. */
const handleLoadProject = async () => {
+ if (analysisInFlight || isStarting || workspaceIntakeInFlightRef.current) {
+ return;
+ }
+
+ workspaceIntakeInFlightRef.current = true;
+ setIsOpeningProject(true);
try {
const song = await loadProject();
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
setSelectedBootstrap(null);
+ setSelectedSourceKind(null);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
if (!isUserCancellation(e)) {
setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`);
}
+ } finally {
+ workspaceIntakeInFlightRef.current = false;
+ setIsOpeningProject(false);
}
};
@@ -514,7 +581,15 @@ export function App() {
if (jobResult) {
return ;
}
- return ;
+ return (
+
+ );
};
const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
@@ -682,7 +757,7 @@ export function App() {
setYoutubeUrl(e.target.value)}
- disabled={analysisInFlight || isStarting || isImporting}
+ disabled={analysisInFlight || isStarting || isImporting || isSelectingDemo || isSelectingLocal || isOpeningProject}
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}
aria-describedby={selectionError && selectionErrorSource === "youtube" ? "selection-error" : undefined}
/>
- {youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? (
+ {youtubeUrl &&
+ !analysisInFlight &&
+ !isStarting &&
+ !isImporting &&
+ !isSelectingDemo &&
+ !isSelectingLocal &&
+ !isOpeningProject ? (
{
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..11db1d2a7 100644
--- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx
+++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx
@@ -1,10 +1,33 @@
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;
+}
+
+/**
+ * Render the first-run empty workspace and its current source-selection guidance.
+ *
+ * `selectedKind` identifies the selected source: `demo` uses demo-specific next
+ * steps, `local` and `youtube` use the general selected-song guidance, and
+ * `null` or an omitted value means no source kind has been established.
+ */
+export function EmptyState({
+ selectedLabel = null,
+ selectedKind = null,
+ disabled = false,
+ onTryDemo,
+ onUseOwnSong
+}: EmptyStateProps) {
const t = createTranslator(detectPreferredLocale());
+ const hasSelection = Boolean(selectedLabel);
+
return (
@@ -12,7 +35,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.reload.test.ts b/apps/desktop/src/lib/analysis.reload.test.ts
new file mode 100644
index 000000000..10520e573
--- /dev/null
+++ b/apps/desktop/src/lib/analysis.reload.test.ts
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+
+type TauriWindow = Window & {
+ __TAURI_INTERNALS__?: unknown;
+ __TAURI_INVOKE__?: unknown;
+};
+
+const tauriWindow = window as TauriWindow;
+
+const licensedDemoBootstrap = {
+ projectId: "licensed-demo-project",
+ sourceMode: "reference" as const,
+ projectRoot: "/tmp/bandscope/projects/licensed-demo-project",
+ cacheRoot: "/tmp/bandscope/cache/licensed-demo-project",
+ tempRoot: "/tmp/bandscope/temp/licensed-demo-project",
+ source: {
+ sourcePath: "/tmp/bandscope/resources/demo/late-night-set.wav",
+ fileName: "late-night-set.wav",
+ extension: "wav" as const,
+ fileSizeBytes: 441044
+ }
+};
+
+function createLicensedDemoInvoke() {
+ const engineResult = { ...createDemoRehearsalSong(), title: "Analyzed Track" };
+ return vi.fn(async (command: string) => {
+ if (command === "select_demo_audio_source") {
+ return licensedDemoBootstrap;
+ }
+ if (command === "start_analysis_job" || command === "get_analysis_job_status") {
+ return {
+ jobId: "job-licensed-demo",
+ state: "succeeded",
+ requestedAt: "2026-09-02T00:00:00.000Z",
+ updatedAt: "2026-09-02T00:00:01.000Z",
+ result: engineResult
+ };
+ }
+ throw new Error(`Unexpected command: ${command}`);
+ });
+}
+
+describe("licensed demo renderer reload", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ window.sessionStorage.clear();
+ delete tauriWindow.__TAURI_INTERNALS__;
+ delete tauriWindow.__TAURI_INVOKE__;
+ });
+
+ it("keeps the canonical demo title when a running job is polled after module reload", async () => {
+ const nativeInvoke = createLicensedDemoInvoke();
+ tauriWindow.__TAURI_INVOKE__ = nativeInvoke;
+
+ const analysisBeforeReload = await import("./analysis");
+ const selection = await analysisBeforeReload.selectDemoAudioSource();
+ expect(selection.ok).toBe(true);
+ if (!selection.ok) {
+ throw new Error("licensed demo selection must succeed through the native bridge");
+ }
+
+ const startedStatus = await analysisBeforeReload.startAnalysisJob({
+ sourceKind: "local_audio",
+ projectId: selection.bootstrap.projectId,
+ sourceLabel: selection.bootstrap.source.fileName,
+ roleFocus: ["bass-guitar"]
+ });
+ expect(startedStatus.result?.title).toBe("Late Night Set");
+
+ vi.resetModules();
+ const analysisAfterReload = await import("./analysis");
+ const polledStatus = await analysisAfterReload.getAnalysisJobStatus("job-licensed-demo");
+
+ expect(polledStatus.result?.title).toBe("Late Night Set");
+ });
+
+ it("keeps the canonical demo title when session storage is unavailable", async () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("session storage unavailable");
+ });
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("session storage unavailable");
+ });
+ const nativeInvoke = createLicensedDemoInvoke();
+ tauriWindow.__TAURI_INVOKE__ = nativeInvoke;
+
+ const analysis = await import("./analysis");
+ const selection = await analysis.selectDemoAudioSource();
+ expect(selection.ok).toBe(true);
+ if (!selection.ok) {
+ throw new Error("licensed demo selection must succeed through the native bridge");
+ }
+
+ const startedStatus = await analysis.startAnalysisJob({
+ sourceKind: "local_audio",
+ projectId: selection.bootstrap.projectId,
+ sourceLabel: selection.bootstrap.source.fileName,
+ roleFocus: ["bass-guitar"]
+ });
+
+ expect(nativeInvoke).toHaveBeenCalledWith("start_analysis_job", {
+ request: expect.objectContaining({ sourceLabel: "Late Night Set" })
+ });
+ expect(startedStatus.result?.title).toBe("Late Night Set");
+ });
+});
diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts
index e3347d1f5..0e2247e8a 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");
@@ -99,6 +124,68 @@ describe("analysis bridge", () => {
expect(selection.ok).toBe(true);
});
+ it("preserves the canonical licensed-demo title through start and polling", async () => {
+ const engineResult = {
+ ...createDemoRehearsalSong(),
+ title: "Analyzed Track"
+ };
+ const nativeInvoke = vi.fn(async (command: string) => {
+ if (command === "select_demo_audio_source") {
+ return {
+ projectId: "licensed-demo-project",
+ sourceMode: "reference",
+ projectRoot: "/tmp/bandscope/projects/licensed-demo-project",
+ cacheRoot: "/tmp/bandscope/cache/licensed-demo-project",
+ tempRoot: "/tmp/bandscope/temp/licensed-demo-project",
+ source: {
+ sourcePath: "/tmp/bandscope/resources/demo/late-night-set.wav",
+ fileName: "late-night-set.wav",
+ extension: "wav",
+ fileSizeBytes: 441044
+ }
+ };
+ }
+ if (command === "start_analysis_job" || command === "get_analysis_job_status") {
+ return {
+ jobId: "job-licensed-demo",
+ state: "succeeded",
+ requestedAt: "2026-03-12T00:00:00.000Z",
+ updatedAt: "2026-03-12T00:00:01.000Z",
+ progressLabel: "Analysis ready for late-night-set.wav",
+ progressStage: "ready",
+ progressPercent: 100,
+ cacheStatus: "stored",
+ result: engineResult
+ };
+ }
+ throw new Error(`Unexpected command: ${command}`);
+ });
+ tauriWindow.__TAURI_INVOKE__ = nativeInvoke;
+
+ const selection = await selectDemoAudioSource();
+ expect(selection.ok).toBe(true);
+ if (!selection.ok) {
+ throw new Error("licensed demo selection must succeed through the native bridge");
+ }
+
+ const status = await startAnalysisJob({
+ sourceKind: "local_audio",
+ projectId: selection.bootstrap.projectId,
+ sourceLabel: selection.bootstrap.source.fileName,
+ roleFocus: ["bass-guitar"]
+ });
+
+ expect(nativeInvoke).toHaveBeenCalledWith("start_analysis_job", {
+ request: expect.objectContaining({ sourceLabel: "Late Night Set" })
+ });
+ expect(status.progressLabel).toBe("Analysis ready for Late Night Set");
+ expect(status.result?.title).toBe("Late Night Set");
+
+ const polledStatus = await getAnalysisJobStatus("job-licensed-demo");
+ expect(polledStatus.progressLabel).toBe("Analysis ready for Late Night Set");
+ expect(polledStatus.result?.title).toBe("Late Night Set");
+ });
+
it("normalizes legacy analysis job status responses before returning them", async () => {
const legacyResult = createDemoRehearsalSong() as unknown as {
sections: Array>;
@@ -117,50 +204,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
- });
+ it("fails browser analysis closed instead of synthesizing a rehearsal result", async () => {
+ const status = await startAnalysisJob(createDemoAnalysisJobRequest());
- 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
- });
-
- 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..ed3813a22 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,
@@ -15,6 +14,7 @@ import {
type RehearsalSong
} from "@bandscope/shared-types";
import { listen } from "@tauri-apps/api/event";
+import { DEMO_SONG_TITLE } from "./demo";
type TauriInvoke = (command: string, args?: Record) => Promise;
@@ -27,23 +27,23 @@ 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;
+const LICENSED_DEMO_PROJECT_STORAGE_KEY = "bandscope.licensedDemoProjectId";
+const LICENSED_DEMO_JOB_STORAGE_KEY = "bandscope.licensedDemoJobId";
+let currentLicensedDemoProjectId: string | null = null;
+let currentLicensedDemoJobId: string | null = null;
export { MAX_YOUTUBE_URL_LENGTH };
@@ -52,6 +52,76 @@ export type LocalAudioSelectionResult =
| { ok: true; bootstrap: ProjectBootstrapSummary }
| { ok: false; error: AnalysisJobError };
+/** Read a bounded renderer-session marker without making analysis depend on storage availability. */
+function readSessionMarker(storageKey: string): string | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ return window.sessionStorage.getItem(storageKey);
+ } catch {
+ return null;
+ }
+}
+
+/** Persist one renderer-session marker without failing the native analysis path. */
+function writeSessionMarker(storageKey: string, storageValue: string): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ window.sessionStorage.setItem(storageKey, storageValue);
+ } catch {
+ // Storage is a reload-continuity mirror only; the current renderer keeps identity in memory.
+ }
+}
+
+/** Remember the active licensed-demo project in memory and mirror it for renderer reload continuity. */
+function rememberLicensedDemoProjectId(projectId: string): void {
+ currentLicensedDemoProjectId = projectId;
+ writeSessionMarker(LICENSED_DEMO_PROJECT_STORAGE_KEY, projectId);
+}
+
+/** Resolve the active licensed-demo project, preferring current renderer authority over the reload mirror. */
+function readLicensedDemoProjectId(): string | null {
+ return currentLicensedDemoProjectId ?? readSessionMarker(LICENSED_DEMO_PROJECT_STORAGE_KEY);
+}
+
+/** Remember the active licensed-demo job in memory and mirror it for renderer reload continuity. */
+function rememberLicensedDemoJobId(jobId: string): void {
+ currentLicensedDemoJobId = jobId;
+ writeSessionMarker(LICENSED_DEMO_JOB_STORAGE_KEY, jobId);
+}
+
+/** Resolve the active licensed-demo job, preferring current renderer authority over the reload mirror. */
+function readLicensedDemoJobId(): string | null {
+ return currentLicensedDemoJobId ?? readSessionMarker(LICENSED_DEMO_JOB_STORAGE_KEY);
+}
+
+/** Preserve the trusted demo title across the current renderer and ordinary WebView module reloads. */
+function withLicensedDemoTitle(analysisStatus: AnalysisJobStatus): AnalysisJobStatus {
+ if (readLicensedDemoJobId() !== analysisStatus.jobId) {
+ return analysisStatus;
+ }
+ if (!analysisStatus.result && analysisStatus.progressStage !== "ready") {
+ return analysisStatus;
+ }
+ return {
+ ...analysisStatus,
+ ...(analysisStatus.progressStage === "ready"
+ ? { progressLabel: `Analysis ready for ${DEMO_SONG_TITLE}` }
+ : {}),
+ ...(analysisStatus.result
+ ? {
+ result: {
+ ...analysisStatus.result,
+ title: DEMO_SONG_TITLE
+ }
+ }
+ : {})
+ };
+}
+
/** Documented. */
function getInvoke(): TauriInvoke | null {
if (typeof window === "undefined") {
@@ -115,65 +185,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 +283,30 @@ export async function selectLocalAudioSource(): Promise {
+ try {
+ const response = await invokeAnalysis("select_demo_audio_source");
+ const bootstrap = parseProjectBootstrapSummary(response);
+ rememberLicensedDemoProjectId(bootstrap.projectId);
+ return {
+ ok: true,
+ bootstrap
+ };
+ } 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;
@@ -260,11 +323,22 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise {
const response = await invokeAnalysis("get_analysis_job_status", { jobId });
try {
- return parseAnalysisJobStatus(response);
+ return withLicensedDemoTitle(parseAnalysisJobStatus(response));
} catch {
throw new Error("Invalid analysis job status response");
}
@@ -296,7 +370,7 @@ export async function subscribeToAnalysisJobUpdates(
try {
const unlisten = await listen("analysis-job-updated", (event) => {
try {
- const status = parseAnalysisJobStatus(event.payload);
+ const status = withLicensedDemoTitle(parseAnalysisJobStatus(event.payload));
if (status.jobId === jobId) {
onUpdate(status);
}
@@ -331,7 +405,12 @@ export async function importYoutubeUrl(url: string): Promise {
+ it("translates legacy public wire keys into semantic internal names", () => {
+ const parsedManifest = parseDemoProvenanceManifest(legacyWireManifest) as unknown as {
+ demoSong: {
+ songId: string;
+ songTitle: string;
+ performerName: string;
+ licenseExpression: string;
+ };
+ demoAssets: Array<{
+ assetPath: string;
+ assetRole: string;
+ assetSha256: string;
+ assetByteCount: number;
+ assetMediaType: string;
+ }>;
+ };
+
+ expect(parsedManifest.demoSong).toMatchObject({
+ songId: "late-night-set",
+ songTitle: "Late Night Set",
+ performerName: "Contextual Wisdom Lab",
+ licenseExpression: "CC0-1.0"
+ });
+ expect(parsedManifest.demoAssets[0]).toEqual({
+ assetPath: "late-night-set.wav",
+ assetRole: "audio",
+ assetSha256: HASH,
+ assetByteCount: 441044,
+ assetMediaType: "audio/wav"
+ });
+ });
+});
diff --git a/apps/desktop/src/lib/demo.test.ts b/apps/desktop/src/lib/demo.test.ts
new file mode 100644
index 000000000..598b5b9ff
--- /dev/null
+++ b/apps/desktop/src/lib/demo.test.ts
@@ -0,0 +1,173 @@
+import { createHash } from "node:crypto";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import {
+ DEMO_PROVENANCE_KIND,
+ DEMO_RESOURCE_DIRECTORY,
+ parseDemoProvenanceManifest,
+ type DemoProvenanceManifest
+} from "./demo";
+
+const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../");
+
+/** Legacy public wire shape retained only at the provenance anti-corruption boundary. */
+type DemoProvenanceWireFixture = {
+ manifestVersion: number;
+ artifactKind: string;
+ song: {
+ id: string;
+ title: string;
+ performer: string;
+ license: string;
+ licenseUrl: string;
+ permittedUses: string[];
+ };
+ assets: Array<{
+ path: string;
+ role: string;
+ sha256: string;
+ bytes: number;
+ mediaType: string;
+ }>;
+};
+
+function bundledWireManifest(): DemoProvenanceWireFixture {
+ const rawManifest = readFileSync(
+ path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "provenance.json"),
+ "utf8"
+ );
+ return JSON.parse(rawManifest) as DemoProvenanceWireFixture;
+}
+
+function bundledManifest(): DemoProvenanceManifest {
+ return parseDemoProvenanceManifest(bundledWireManifest());
+}
+
+describe("licensed demo provenance", () => {
+ it("accepts the bundled CC0 package and verifies every recorded hash", () => {
+ const manifest = bundledManifest();
+ expect(manifest.artifactKind).toBe(DEMO_PROVENANCE_KIND);
+ expect(manifest.demoSong.licenseExpression).toBe("CC0-1.0");
+ expect(manifest.demoSong.songTitle).toBe("Late Night Set");
+ expect(manifest.demoSong.permittedUses).toEqual([
+ "evaluation",
+ "redistribution",
+ "rehearsal-demo"
+ ]);
+ for (const demoAsset of manifest.demoAssets) {
+ const assetBytes = readFileSync(
+ path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, demoAsset.assetPath)
+ );
+ expect(assetBytes.byteLength).toBe(demoAsset.assetByteCount);
+ expect(createHash("sha256").update(assetBytes).digest("hex")).toBe(demoAsset.assetSha256);
+ }
+ const audioAsset = manifest.demoAssets.find((demoAsset) => demoAsset.assetRole === "audio");
+ expect(audioAsset?.assetPath).toBe("late-night-set.wav");
+ const demoWav = readFileSync(
+ path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "late-night-set.wav")
+ );
+ expect(demoWav.subarray(0, 4).toString("ascii")).toBe("RIFF");
+ expect(demoWav.subarray(8, 12).toString("ascii")).toBe("WAVE");
+ });
+
+ it("rejects unknown fields, the wrong kind, and a missing asset role", () => {
+ const wireManifest = bundledWireManifest();
+ expect(() => parseDemoProvenanceManifest(null)).toThrow(/root/);
+ expect(() => parseDemoProvenanceManifest([])).toThrow(/root/);
+ expect(() => parseDemoProvenanceManifest({ ...wireManifest, extra: true })).toThrow(
+ /Invalid demo provenance field 'extra'/
+ );
+ expect(() =>
+ parseDemoProvenanceManifest({ ...wireManifest, artifactKind: "other" })
+ ).toThrow(/artifactKind/);
+ expect(() => parseDemoProvenanceManifest({ ...wireManifest, manifestVersion: 2 })).toThrow(
+ /manifestVersion/
+ );
+ expect(() => parseDemoProvenanceManifest({ ...wireManifest, song: null })).toThrow(/song/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ song: { ...wireManifest.song, extra: "nope" }
+ })
+ ).toThrow(/song\.extra/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ song: { ...wireManifest.song, license: "MIT" }
+ })
+ ).toThrow(/license/);
+ const withoutAudio = {
+ ...wireManifest,
+ assets: wireManifest.assets.filter((demoAsset) => demoAsset.role !== "audio")
+ };
+ expect(() => parseDemoProvenanceManifest(withoutAudio)).toThrow(/assets/);
+ });
+
+ it("rejects manifests whose UTF-8 serialization exceeds the byte ceiling", () => {
+ const wireManifest = bundledWireManifest();
+ const oversizedUtf8Manifest = { ...wireManifest };
+ Object.defineProperty(oversizedUtf8Manifest, "toJSON", {
+ enumerable: false,
+ value: () => "가".repeat(6000)
+ });
+
+ expect(() => parseDemoProvenanceManifest(oversizedUtf8Manifest)).toThrow(/too large/);
+ });
+
+ it("rejects traversal paths, dot segments, non-hex hashes, and malformed assets", () => {
+ const wireManifest = bundledWireManifest();
+ const [audioAsset, licenseAsset, annotationAsset] = wireManifest.assets;
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, path: "../secret.wav" }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/assets\[0\]\.path/);
+ for (const dotSegment of [".", ".."]) {
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, path: dotSegment }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/assets\[0\]\.path/);
+ }
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, sha256: "not-a-hash" }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/sha256/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, bytes: 1.5 }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/bytes/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, extra: true }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/assets\[0\]\.extra/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [null, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/assets\[0\]/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ assets: [{ ...audioAsset, role: "stems" }, licenseAsset, annotationAsset]
+ })
+ ).toThrow(/assets\.role/);
+ expect(() =>
+ parseDemoProvenanceManifest({
+ ...wireManifest,
+ song: { ...wireManifest.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..c6c86fef9
--- /dev/null
+++ b/apps/desktop/src/lib/demo.ts
@@ -0,0 +1,178 @@
+/** Kind discriminator for the public licensed-demo provenance manifest. */
+const DEMO_PROVENANCE_KIND = "bandscope.licensed-demo" as const;
+
+/** Canonical buyer-facing title for the bundled licensed demo. */
+const DEMO_SONG_TITLE = "Late Night Set" 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, DEMO_SONG_TITLE };
+
+/** Permitted asset roles inside one licensed demo package. */
+export type DemoAssetRole = "audio" | "license" | "annotations";
+
+/** One hashed file in the licensed demo package after wire translation. */
+export type DemoProvenanceAsset = {
+ assetPath: string;
+ assetRole: DemoAssetRole;
+ assetSha256: string;
+ assetByteCount: number;
+ assetMediaType: string;
+};
+
+/** Provenance contract used internally after translating the public wire manifest. */
+export type DemoProvenanceManifest = {
+ manifestVersion: 1;
+ artifactKind: typeof DEMO_PROVENANCE_KIND;
+ demoSong: {
+ songId: string;
+ songTitle: string;
+ performerName: string;
+ licenseExpression: "CC0-1.0";
+ licenseUrl: string;
+ permittedUses: string[];
+ };
+ demoAssets: 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"];
+const UTF8_TEXT_ENCODER = new TextEncoder();
+
+/** 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 the stable public wire manifest into semantically specific internal names. */
+export function parseDemoProvenanceManifest(payload: unknown): DemoProvenanceManifest {
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
+ throw new Error("Invalid demo provenance field 'root'");
+ }
+ const manifestRecord = payload as Record;
+ const allowedManifestFields = new Set(["manifestVersion", "artifactKind", "song", "assets"]);
+ for (const manifestField of Object.keys(manifestRecord)) {
+ if (!allowedManifestFields.has(manifestField)) {
+ throw new Error(`Invalid demo provenance field '${manifestField}'`);
+ }
+ }
+ if (manifestRecord.manifestVersion !== 1) {
+ throw new Error("Invalid demo provenance field 'manifestVersion'");
+ }
+ if (manifestRecord.artifactKind !== DEMO_PROVENANCE_KIND) {
+ throw new Error("Invalid demo provenance field 'artifactKind'");
+ }
+ if (
+ manifestRecord.song === null ||
+ typeof manifestRecord.song !== "object" ||
+ Array.isArray(manifestRecord.song)
+ ) {
+ throw new Error("Invalid demo provenance field 'song'");
+ }
+ const songRecord = manifestRecord.song as Record;
+ for (const songField of Object.keys(songRecord)) {
+ if (
+ songField !== "id" &&
+ songField !== "title" &&
+ songField !== "performer" &&
+ songField !== "license" &&
+ songField !== "licenseUrl" &&
+ songField !== "permittedUses"
+ ) {
+ throw new Error(`Invalid demo provenance field 'song.${songField}'`);
+ }
+ }
+ const permittedUses = songRecord.permittedUses;
+ if (!Array.isArray(permittedUses) || permittedUses.length === 0 || permittedUses.length > 8) {
+ throw new Error("Invalid demo provenance field 'song.permittedUses'");
+ }
+ const permittedUseNames = permittedUses.map((permittedUse, permittedUseIndex) =>
+ asNonEmptyString(permittedUse, `song.permittedUses[${permittedUseIndex}]`)
+ );
+ if (songRecord.license !== "CC0-1.0") {
+ throw new Error("Invalid demo provenance field 'song.license'");
+ }
+ if (!Array.isArray(manifestRecord.assets) || manifestRecord.assets.length !== 3) {
+ throw new Error("Invalid demo provenance field 'assets'");
+ }
+ const demoAssets: DemoProvenanceAsset[] = manifestRecord.assets.map(
+ (assetEntry, assetIndex) => {
+ if (assetEntry === null || typeof assetEntry !== "object" || Array.isArray(assetEntry)) {
+ throw new Error(`Invalid demo provenance field 'assets[${assetIndex}]'`);
+ }
+ const assetRecord = assetEntry as Record;
+ for (const assetField of Object.keys(assetRecord)) {
+ if (
+ assetField !== "path" &&
+ assetField !== "role" &&
+ assetField !== "sha256" &&
+ assetField !== "bytes" &&
+ assetField !== "mediaType"
+ ) {
+ throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].${assetField}'`);
+ }
+ }
+ const assetPath = asNonEmptyString(assetRecord.path, `assets[${assetIndex}].path`);
+ if (assetPath === "." || assetPath === ".." || !RELATIVE_FILE_PATTERN.test(assetPath)) {
+ throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].path'`);
+ }
+ const assetSha256 = asNonEmptyString(assetRecord.sha256, `assets[${assetIndex}].sha256`);
+ if (!SHA256_PATTERN.test(assetSha256)) {
+ throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].sha256'`);
+ }
+ if (
+ !Number.isSafeInteger(assetRecord.bytes) ||
+ (assetRecord.bytes as number) <= 0 ||
+ (assetRecord.bytes as number) > MAX_ASSET_BYTES
+ ) {
+ throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].bytes'`);
+ }
+ return {
+ assetPath,
+ assetRole: asAssetRole(assetRecord.role),
+ assetSha256,
+ assetByteCount: assetRecord.bytes as number,
+ assetMediaType: asNonEmptyString(assetRecord.mediaType, `assets[${assetIndex}].mediaType`)
+ };
+ }
+ );
+ const assetRoles = new Set(demoAssets.map((demoAsset) => demoAsset.assetRole));
+ for (const requiredRole of REQUIRED_ROLES) {
+ if (!assetRoles.has(requiredRole)) {
+ throw new Error("Invalid demo provenance field 'assets.role'");
+ }
+ }
+ const serializedManifest = JSON.stringify(payload);
+ const serializedManifestBytes = UTF8_TEXT_ENCODER.encode(serializedManifest).byteLength;
+ if (serializedManifestBytes > MAX_MANIFEST_BYTES) {
+ throw new Error("Invalid demo provenance field 'root': manifest is too large");
+ }
+ return {
+ manifestVersion: 1,
+ artifactKind: DEMO_PROVENANCE_KIND,
+ demoSong: {
+ songId: asNonEmptyString(songRecord.id, "song.id"),
+ songTitle: asNonEmptyString(songRecord.title, "song.title"),
+ performerName: asNonEmptyString(songRecord.performer, "song.performer"),
+ licenseExpression: "CC0-1.0",
+ licenseUrl: asNonEmptyString(songRecord.licenseUrl, "song.licenseUrl"),
+ permittedUses: permittedUseNames
+ },
+ demoAssets
+ };
+}
diff --git a/apps/desktop/src/lib/job_runner.test.ts b/apps/desktop/src/lib/job_runner.test.ts
new file mode 100644
index 000000000..b4cb7b692
--- /dev/null
+++ b/apps/desktop/src/lib/job_runner.test.ts
@@ -0,0 +1,14 @@
+import { createDemoAnalysisJobRequest } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { enqueueSong, retrySong } from "./job_runner";
+
+describe("legacy analysis runner browser boundary", () => {
+ it("fails closed instead of synthesizing browser analysis", async () => {
+ await expect(enqueueSong(createDemoAnalysisJobRequest())).rejects.toThrow(
+ "Analysis engine is unavailable outside the desktop runtime."
+ );
+ await expect(retrySong("browser-job")).rejects.toThrow(
+ "Analysis engine is unavailable outside the desktop runtime."
+ );
+ });
+});
diff --git a/apps/desktop/src/lib/job_runner.ts b/apps/desktop/src/lib/job_runner.ts
index b024ad5a4..bb32d78a1 100644
--- a/apps/desktop/src/lib/job_runner.ts
+++ b/apps/desktop/src/lib/job_runner.ts
@@ -2,18 +2,19 @@ import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
type RehearsalWorkspace,
- type SongRehearsalPack,
type AnalysisJobRequest,
parseRehearsalWorkspace,
- isRehearsalWorkspace,
- createDemoRehearsalSong
+ isRehearsalWorkspace
} from "@bandscope/shared-types";
-/** Documented. */
-export type WorkspaceUpdateCallback = (workspace: RehearsalWorkspace) => void;
+/** Receives validated rehearsal-workspace updates from the native bridge. */
+export type WorkspaceUpdateCallback = (rehearsalWorkspace: RehearsalWorkspace) => void;
-/** Documented. */
-type TauriInvoke = (command: string, args?: Record) => Promise;
+/** Invokes one typed Tauri command with its bridge-owned argument envelope. */
+type TauriInvoke = (
+ bridgeCommand: string,
+ commandArguments?: Record
+) => Promise;
declare global {
interface Window {
@@ -21,181 +22,125 @@ declare global {
}
}
-/** Documented. */
-function getInvoke(): TauriInvoke | null {
+/** Returns the native Tauri invoke boundary when desktop runtime authority is available. */
+function getTauriInvoke(): TauriInvoke | null {
if (typeof window === "undefined" || !isTauri()) {
return null;
}
return window.__TAURI_INVOKE__ ?? invoke;
}
-const mockWorkspace: RehearsalWorkspace = {
+const browserFallbackWorkspace: RehearsalWorkspace = {
id: "mock-ws",
title: "Browser Mock Workspace",
songs: [],
workspaceVersion: 1
};
-const mockSongsById = new Map(
- mockWorkspace.songs.map(song => [song.id, song])
-);
+type BrowserWorkspaceListener = (workspaceEvent: { payload: unknown }) => void;
+const browserWorkspaceListeners = new Set();
-type MockListener = (event: { payload: unknown }) => void;
-const mockListeners = new Set();
-
-/** Documented. */
-function getMockSong(jobId: string): SongRehearsalPack | undefined {
- return mockSongsById.get(jobId);
+/** Broadcasts the read-only browser fallback workspace to local listeners. */
+function triggerBrowserWorkspaceUpdate(): void {
+ const workspacePayload = structuredClone(browserFallbackWorkspace);
+ browserWorkspaceListeners.forEach((workspaceListener) =>
+ workspaceListener({ payload: workspacePayload })
+ );
}
/**
- * Triggers a mock workspace update to all listeners.
+ * Serves only the read-only browser workspace boundary.
+ *
+ * Analysis mutations fail closed so browser-only code can never manufacture a
+ * successful rehearsal result that could be mistaken for native analysis.
*/
-function triggerMockUpdate() {
- const payload = structuredClone(mockWorkspace);
- mockListeners.forEach(listener => listener({ payload }));
-}
-
-/** Documented. */
-async function browserFallback(command: string, args?: Record): Promise {
- if (command === "get_workspace_state") {
- return structuredClone(mockWorkspace);
- }
-
- if (command === "enqueue_song") {
- const request = args?.request as AnalysisJobRequest;
- const packId = `pack-${Date.now()}`;
- const pack: SongRehearsalPack = {
- id: packId,
- packState: "queued",
- sourceLabel: request.sourceKind === "local_audio" ? request.sourceLabel : "Demo Song",
- engineState: "queued"
- };
- mockWorkspace.songs.push(pack);
- mockSongsById.set(pack.id, pack);
- triggerMockUpdate();
-
- // Simulate processing
- setTimeout(() => {
- pack.packState = "analyzing";
- pack.engineState = "running";
- triggerMockUpdate();
-
- setTimeout(() => {
- // We use Object.assign to mutate the cached pack reference, avoiding an O(N) lookup.
- Object.assign(pack, {
- packState: "ready",
- engineState: "succeeded",
- song: createDemoRehearsalSong()
- });
- triggerMockUpdate();
- }, 2000);
- }, 1000);
-
- return;
- }
-
- if (command === "retry_song") {
- const jobId = args?.jobId as string;
- const pack = getMockSong(jobId);
- if (pack) {
- pack.packState = "queued";
- pack.engineState = "queued";
-
- triggerMockUpdate();
-
- // Simulate processing
- setTimeout(() => {
- pack.packState = "analyzing";
- pack.engineState = "running";
- triggerMockUpdate();
- setTimeout(() => {
- Object.assign(pack, {
- packState: "ready",
- engineState: "succeeded",
- song: createDemoRehearsalSong()
- });
- triggerMockUpdate();
- }, 2000);
- }, 1000);
- }
- return;
+async function browserFallback(
+ bridgeCommand: string,
+ _commandArguments?: Record
+): Promise {
+ if (bridgeCommand === "get_workspace_state") {
+ return structuredClone(browserFallbackWorkspace);
}
- if (command === "cancel_song") {
- const jobId = args?.jobId as string;
- mockWorkspace.songs = mockWorkspace.songs.filter(p => p.id !== jobId);
- mockSongsById.delete(jobId);
- triggerMockUpdate();
- return;
+ if (
+ bridgeCommand === "enqueue_song" ||
+ bridgeCommand === "retry_song" ||
+ bridgeCommand === "cancel_song"
+ ) {
+ throw new Error("Analysis engine is unavailable outside the desktop runtime.");
}
- throw new Error(`Unknown analysis bridge command: ${command}`);
+ throw new Error(`Unknown analysis bridge command: ${bridgeCommand}`);
}
-/** Documented. */
-async function invokeRunner(command: string, args?: Record): Promise {
- const invokeCommand = getInvoke();
- if (invokeCommand) {
- return invokeCommand(command, args);
+/** Routes a bridge command to native Tauri or the fail-closed browser boundary. */
+async function invokeRunner(
+ bridgeCommand: string,
+ commandArguments?: Record
+): Promise {
+ const tauriInvoke = getTauriInvoke();
+ if (tauriInvoke) {
+ return tauriInvoke(bridgeCommand, commandArguments);
}
- return browserFallback(command, args);
+ return browserFallback(bridgeCommand, commandArguments);
}
-/** Documented. */
-export async function enqueueSong(request: AnalysisJobRequest): Promise {
- await invokeRunner("enqueue_song", { request });
+/** Enqueues one analysis request through the native desktop bridge. */
+export async function enqueueSong(analysisRequest: AnalysisJobRequest): Promise {
+ await invokeRunner("enqueue_song", { request: analysisRequest });
}
-/** Documented. */
-export async function retrySong(jobId: string): Promise {
- await invokeRunner("retry_song", { jobId });
+/** Retries one existing analysis job through the native desktop bridge. */
+export async function retrySong(analysisJobId: string): Promise {
+ await invokeRunner("retry_song", { jobId: analysisJobId });
}
-/** Documented. */
-export async function cancelSong(jobId: string): Promise {
- await invokeRunner("cancel_song", { jobId });
+/** Cancels one existing analysis job through the native desktop bridge. */
+export async function cancelSong(analysisJobId: string): Promise {
+ await invokeRunner("cancel_song", { jobId: analysisJobId });
}
-/** Documented. */
-export async function subscribeToWorkspaceUpdates(callback: WorkspaceUpdateCallback): Promise {
- const invokeCommand = getInvoke();
-
- if (invokeCommand) {
- return listen("workspace-updated", (event) => {
- if (isRehearsalWorkspace(event.payload)) {
- callback(parseRehearsalWorkspace(event.payload));
+/** Subscribes to validated workspace updates from native Tauri or the browser placeholder. */
+export async function subscribeToWorkspaceUpdates(
+ workspaceCallback: WorkspaceUpdateCallback
+): Promise {
+ const tauriInvoke = getTauriInvoke();
+
+ if (tauriInvoke) {
+ return listen("workspace-updated", (workspaceEvent) => {
+ if (isRehearsalWorkspace(workspaceEvent.payload)) {
+ workspaceCallback(parseRehearsalWorkspace(workspaceEvent.payload));
} else {
// eslint-disable-next-line no-console -- Warn about invalid payload structure
console.warn("Received invalid workspace update from Tauri");
}
});
- } else {
- // Browser fallback
- /**
- * Internal listener for fallback mock updates.
- */
- const listener: MockListener = (event) => {
- if (isRehearsalWorkspace(event.payload)) {
- callback(parseRehearsalWorkspace(event.payload));
- }
- };
- mockListeners.add(listener);
- return () => {
- mockListeners.delete(listener);
- };
}
+
+ const workspaceListener: BrowserWorkspaceListener = (workspaceEvent) => {
+ if (isRehearsalWorkspace(workspaceEvent.payload)) {
+ workspaceCallback(parseRehearsalWorkspace(workspaceEvent.payload));
+ }
+ };
+ browserWorkspaceListeners.add(workspaceListener);
+ triggerBrowserWorkspaceUpdate();
+ return () => {
+ browserWorkspaceListeners.delete(workspaceListener);
+ };
}
-/** Documented. */
+/** Reads the current rehearsal workspace without surfacing bridge exceptions to the UI. */
export async function getWorkspaceState(): Promise {
try {
- const response = await invokeRunner("get_workspace_state");
- if (!response) return null;
- return parseRehearsalWorkspace(response);
- } catch (error) {
+ const workspaceResponse = await invokeRunner("get_workspace_state");
+ if (!workspaceResponse) return null;
+ return parseRehearsalWorkspace(workspaceResponse);
+ } catch (workspaceError) {
// eslint-disable-next-line no-console -- Error logging for workspace state fetch failure
- console.error("Failed to get workspace state:", error instanceof Error ? error.message : "Unknown error");
+ console.error(
+ "Failed to get workspace state:",
+ workspaceError instanceof Error ? workspaceError.message : "Unknown error"
+ );
return null;
}
}
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..10329d3ba
--- /dev/null
+++ b/docs/activation/licensed-demo.md
@@ -0,0 +1,52 @@
+# 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 `late-night-set.wav` with `scripts/generate_licensed_demo_wav.py`. Then refresh `provenance.json` with the final WAV's byte size and SHA-256, and finally update the matching SHA-256 entries in `supply-chain/supplemental-component-inventory.json` for the changed packaged assets before packaging or committing them. The inventory and provenance values must describe the final bytes that will ship.
+
+The checked-in provenance JSON is a stable public wire contract and therefore keeps its established `song.id`, `song.title`, `song.performer`, `song.license`, and asset `path`, `role`, `sha256`, `bytes`, and `mediaType` keys. `parseDemoProvenanceManifest` is the anti-corruption boundary: after validating those wire keys, organization-owned code uses the semantic internal vocabulary `demoSong.songId`, `songTitle`, `performerName`, `licenseExpression`, and `demoAssets[].assetPath`, `assetRole`, `assetSha256`, `assetByteCount`, and `assetMediaType`. New internal consumers must not propagate the legacy generic wire names beyond that boundary.
+
+## 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..ffd3fa805
--- /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 = 10
+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 < DURATION_SECONDS / 2 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())
diff --git a/services/analysis-engine/tests/test_demo_supply_chain_inventory.py b/services/analysis-engine/tests/test_demo_supply_chain_inventory.py
new file mode 100644
index 000000000..bf7f70807
--- /dev/null
+++ b/services/analysis-engine/tests/test_demo_supply_chain_inventory.py
@@ -0,0 +1,74 @@
+"""Supply-chain regressions for the packaged licensed demo assets."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+INVENTORY_PATH = REPO_ROOT / "supply-chain" / "supplemental-component-inventory.json"
+DEMO_RESOURCE_ROOT = REPO_ROOT / "apps" / "desktop" / "src-tauri" / "resources" / "demo"
+DESKTOP_CORE_SOURCE_PATH = REPO_ROOT / "apps" / "desktop" / "core" / "src" / "lib.rs"
+RUNTIME_DEMO_AUDIO_BYTES_PATTERN = re.compile(
+ r"pub const DEMO_AUDIO_BYTES: u64 = (?P\d+);"
+)
+EXPECTED_DEMO_ASSETS = {
+ "late-night-set.wav": "9e4d5598a8e0f2836b4e7637ec19adfb48ce93eb8d18a2984d20ae597d05a8fb",
+ "LICENSE": "1657b89949ca8bfb2920e26dceb4c1012d6212b5d77eda7d7f3921da29adde5e",
+ "annotations.json": "6ed9253d81168f9cb6d1d3fa905849c5baafc6d079e91e287b03cba4190ca7f7",
+ "provenance.json": "4d980735aa37d3fb0d4fc5e2e1c4013181f51346d26d75b207e20f9154cdf338",
+}
+
+
+def test_demo_package_is_listed_in_supplemental_inventory() -> None:
+ """Require every packaged demo asset to be inventory-traceable by checksum."""
+ actual_demo_assets = {
+ asset_path.name
+ for asset_path in DEMO_RESOURCE_ROOT.iterdir()
+ if not asset_path.is_symlink() and asset_path.is_file()
+ }
+ assert actual_demo_assets == set(EXPECTED_DEMO_ASSETS)
+
+ inventory_document = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
+ bundled_assets = inventory_document.get("bundledAssets")
+
+ assert isinstance(bundled_assets, list)
+ demo_asset_records = {
+ asset_record["storagePath"]: asset_record
+ for asset_record in bundled_assets
+ if isinstance(asset_record, dict)
+ and isinstance(asset_record.get("storagePath"), str)
+ and asset_record["storagePath"].startswith("apps/desktop/src-tauri/resources/demo/")
+ }
+
+ expected_storage_paths = {
+ f"apps/desktop/src-tauri/resources/demo/{asset_name}"
+ for asset_name in EXPECTED_DEMO_ASSETS
+ }
+ assert set(demo_asset_records) == expected_storage_paths
+
+ for asset_name, expected_sha256 in EXPECTED_DEMO_ASSETS.items():
+ storage_path = f"apps/desktop/src-tauri/resources/demo/{asset_name}"
+ asset_record = demo_asset_records[storage_path]
+ asset_bytes = (DEMO_RESOURCE_ROOT / asset_name).read_bytes()
+
+ assert asset_record["assetName"] == asset_name
+ assert asset_record["licenseExpression"] == "CC0-1.0"
+ assert asset_record["assetChecksum"] == f"sha256:{expected_sha256}"
+ assert hashlib.sha256(asset_bytes).hexdigest() == expected_sha256
+
+
+def test_runtime_demo_audio_size_matches_packaged_artifact() -> None:
+ """Keep the Rust runtime size guard synchronized with the bundled demo WAV."""
+ desktop_core_source = DESKTOP_CORE_SOURCE_PATH.read_text(encoding="utf-8")
+ runtime_size_match = RUNTIME_DEMO_AUDIO_BYTES_PATTERN.search(desktop_core_source)
+
+ assert runtime_size_match is not None
+ runtime_demo_audio_bytes = int(runtime_size_match.group("demo_audio_bytes"))
+ packaged_demo_audio_bytes = (
+ DEMO_RESOURCE_ROOT / "late-night-set.wav"
+ ).stat().st_size
+
+ assert runtime_demo_audio_bytes == packaged_demo_audio_bytes
diff --git a/services/analysis-engine/tests/test_licensed_demo_duration_contract.py b/services/analysis-engine/tests/test_licensed_demo_duration_contract.py
new file mode 100644
index 000000000..6ffd634b6
--- /dev/null
+++ b/services/analysis-engine/tests/test_licensed_demo_duration_contract.py
@@ -0,0 +1,71 @@
+"""Regressions for the licensed demo structural-analysis contract."""
+
+from __future__ import annotations
+
+import json
+import wave
+from pathlib import Path
+
+import numpy as np
+
+from bandscope_analysis.sections.segmenter import MIN_SEGMENT_DURATION_SECONDS, segment_audio
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+DEMO_RESOURCE_ROOT = REPO_ROOT / "apps" / "desktop" / "src-tauri" / "resources" / "demo"
+DEMO_AUDIO_PATH = DEMO_RESOURCE_ROOT / "late-night-set.wav"
+DEMO_ANNOTATION_PATH = DEMO_RESOURCE_ROOT / "annotations.json"
+
+
+def _read_demo_audio(audio_path: Path = DEMO_AUDIO_PATH) -> tuple[np.ndarray, int, float]:
+ """Decode one mono PCM demo fixture through the stdlib WAV reader."""
+ with wave.open(str(audio_path), "rb") as wav_file:
+ sample_rate = wav_file.getframerate()
+ declared_frame_count = wav_file.getnframes()
+ assert wav_file.getnchannels() == 1
+ assert wav_file.getsampwidth() == 2
+ decoded_frame_bytes = wav_file.readframes(declared_frame_count)
+ audio_samples = np.frombuffer(decoded_frame_bytes, dtype=" None:
+ """Reject duration authority from a WAV header when the payload is truncated."""
+ truncated_audio_path = tmp_path / "truncated-demo.wav"
+ with wave.open(str(truncated_audio_path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(10)
+ wav_file.writeframes(b"\x00\x00" * 100)
+
+ complete_audio_bytes = truncated_audio_path.read_bytes()
+ truncated_audio_path.write_bytes(complete_audio_bytes[:-20])
+
+ audio_samples, sample_rate, duration_seconds = _read_demo_audio(truncated_audio_path)
+ assert audio_samples.size == 90
+ assert duration_seconds == audio_samples.size / sample_rate
+
+
+def test_licensed_demo_spans_two_structural_windows() -> None:
+ """Keep the bundled demo long enough to avoid the short-audio fallback."""
+ _audio_samples, _sample_rate, duration_seconds = _read_demo_audio()
+ assert duration_seconds >= MIN_SEGMENT_DURATION_SECONDS * 2
+
+ annotation_document = json.loads(DEMO_ANNOTATION_PATH.read_text(encoding="utf-8"))
+ section_ranges = [section["timeRange"] for section in annotation_document["sections"]]
+ assert section_ranges == [{"start": 0, "end": 5}, {"start": 5, "end": 10}]
+ assert all(
+ section_range["end"] - section_range["start"] >= MIN_SEGMENT_DURATION_SECONDS
+ for section_range in section_ranges
+ )
+
+
+def test_licensed_demo_reaches_structural_segmentation() -> None:
+ """Prove the source fixture itself yields multiple structural candidates."""
+ audio_samples, sample_rate, duration_seconds = _read_demo_audio()
+ detected_sections = segment_audio(audio_samples, sample_rate, duration_seconds)
+ assert len(detected_sections) >= 2
+ assert all(
+ "Audio too short for structural analysis" not in section["confidence_notes"]
+ for section in detected_sections
+ )
diff --git a/supply-chain/supplemental-component-inventory.json b/supply-chain/supplemental-component-inventory.json
index 784d90d57..8bd42dd17 100644
--- a/supply-chain/supplemental-component-inventory.json
+++ b/supply-chain/supplemental-component-inventory.json
@@ -23,6 +23,48 @@
"verification": "SHA256 verified in bandscope_analysis.separation.audio_separator.AudioStemSeparator._load_model_profile"
}
],
+ "bundledAssets": [
+ {
+ "assetName": "late-night-set.wav",
+ "assetVersion": "1.0.0",
+ "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/late-night-set.wav",
+ "licenseExpression": "CC0-1.0",
+ "assetChecksum": "sha256:9e4d5598a8e0f2836b4e7637ec19adfb48ce93eb8d18a2984d20ae597d05a8fb",
+ "storagePath": "apps/desktop/src-tauri/resources/demo/late-night-set.wav",
+ "mediaType": "audio/wav",
+ "releaseUsage": "Bundled original rehearsal demo audio for the first-run Try the demo flow."
+ },
+ {
+ "assetName": "LICENSE",
+ "assetVersion": "1.0.0",
+ "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/LICENSE",
+ "licenseExpression": "CC0-1.0",
+ "assetChecksum": "sha256:1657b89949ca8bfb2920e26dceb4c1012d6212b5d77eda7d7f3921da29adde5e",
+ "storagePath": "apps/desktop/src-tauri/resources/demo/LICENSE",
+ "mediaType": "text/plain",
+ "releaseUsage": "Bundled CC0 1.0 license notice accompanying the rehearsal demo package."
+ },
+ {
+ "assetName": "annotations.json",
+ "assetVersion": "1.0.0",
+ "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/annotations.json",
+ "licenseExpression": "CC0-1.0",
+ "assetChecksum": "sha256:6ed9253d81168f9cb6d1d3fa905849c5baafc6d079e91e287b03cba4190ca7f7",
+ "storagePath": "apps/desktop/src-tauri/resources/demo/annotations.json",
+ "mediaType": "application/json",
+ "releaseUsage": "Bundled ground-truth section annotations for the original rehearsal demo package."
+ },
+ {
+ "assetName": "provenance.json",
+ "assetVersion": "1.0.0",
+ "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/provenance.json",
+ "licenseExpression": "CC0-1.0",
+ "assetChecksum": "sha256:4d980735aa37d3fb0d4fc5e2e1c4013181f51346d26d75b207e20f9154cdf338",
+ "storagePath": "apps/desktop/src-tauri/resources/demo/provenance.json",
+ "mediaType": "application/json",
+ "releaseUsage": "Bundled provenance manifest binding the rehearsal demo package to its hashes, sizes, performer, and permitted uses."
+ }
+ ],
"notes": [
"Add ffmpeg, yt-dlp, model weights, or sidecar assets here before they ship.",
"Track source URL, version, checksum, license, storage path, and release usage for every item."