Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct AppStateInner {
}

const MAX_IN_FLIGHT_JOBS: usize = 2;
const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30);
const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(600);
const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50);
const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"];
const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__";
Expand Down
25 changes: 24 additions & 1 deletion apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));

await waitFor(() => {
expect(screen.getByText(/queued for analysis/i)).toBeTruthy();
expect(screen.getAllByText(/queued for analysis/i).length).toBeGreaterThan(0);
});
expect(screen.getAllByRole("status").some((status) => /queued for analysis/i.test(status.textContent ?? ""))).toBe(true);
await waitFor(() => {
Expand Down Expand Up @@ -407,6 +407,29 @@ describe("App", () => {
);
});

it("falls back to a state-derived message in the status badge when progressLabel is absent", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(jobStatusResponse({
jobId: "job-fallback-1",
state: "running",
progressLabel: undefined
}));

render(<App />);

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

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

await waitFor(() => {
expect(
screen.getAllByRole("status").some((el) => /running analysis/i.test(el.textContent ?? ""))
).toBe(true);
});
});

it("keeps handoff metadata tied to the source that produced the current result", async () => {
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export function App() {
return <ErrorState error={jobError} />;
}
if (analysisInFlight || isStarting) {
return <LoadingState />;
return <LoadingState progressLabel={jobStatus?.progressLabel} progressPercent={jobStatus?.progressPercent} />;
}
if (jobResult) {
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/features/workspace/WorkspaceStates.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Loader2, Music, AlertCircle } from "lucide-react";

/** Documented. */
Expand All @@ -19,7 +20,13 @@ export function EmptyState() {
}

/** Documented. */
export function LoadingState() {
export function LoadingState({
progressLabel,
progressPercent
}: {
progressLabel?: string;
progressPercent?: number;
}) {
const t = createTranslator(detectPreferredLocale());
return (
<Card
Expand All @@ -32,7 +39,19 @@ export function LoadingState() {
<CardContent className="flex flex-col items-center justify-center py-24 text-center">
<Loader2 className="mb-6 size-12 animate-spin text-cyan-300" aria-hidden="true" />
<h3 className="mb-2 text-xl font-black text-white">{t("workspaceAnalyzingAudioTitle")}</h3>
<p className="max-w-sm animate-pulse text-slate-400">{t("workspaceLoadingState")}</p>
{progressLabel ? (
<div className="mt-2 w-full max-w-xs space-y-2">
<p className="text-sm font-medium text-cyan-200">{progressLabel}</p>
{progressPercent !== undefined && (
<Progress
aria-label="Workspace analysis progress"
value={progressPercent}
/>
)}
</div>
) : (
<p className="max-w-sm animate-pulse text-slate-400">{t("workspaceLoadingState")}</p>
)}
</CardContent>
</Card>
);
Expand Down
18 changes: 0 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion services/analysis-engine/src/bandscope_analysis/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import hashlib
import json
import logging
from pathlib import Path
from typing import Any, Literal, NotRequired, TypedDict, cast

Expand All @@ -12,6 +13,8 @@
from bandscope_analysis.sections import extract_sections
from bandscope_analysis.separation import AudioStemSeparator

logger = logging.getLogger(__name__)

MAX_SECTION_TIME_SECONDS = 4_294_967_295
ANALYSIS_CACHE_SCHEMA_VERSION = 1

Expand Down Expand Up @@ -523,7 +526,8 @@ def run_analysis_job_updates(

try:
audio_features = _build_local_audio_features(request)
except (FileNotFoundError, ValueError) as error:
except Exception as error:
logger.exception("Stem separation failed for job %s: %s", job_id, error)
updates.append(
_build_job_status(
job_id=job_id,
Expand Down
36 changes: 36 additions & 0 deletions services/analysis-engine/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,42 @@ def test_run_analysis_job_updates_fail_safely_when_local_separation_fails() -> N
}


def test_run_analysis_job_updates_fail_safely_on_unexpected_exception() -> None:
"""Ensure unexpected runtime errors (not just ValueError) return a typed failure envelope."""
with patch("bandscope_analysis.api.AudioStemSeparator") as separator_class:
separator_class.return_value.separate.side_effect = RuntimeError(
"Unexpected GPU out-of-memory error"
)

updates = list(
run_analysis_job_updates(
"job-unexpected-err",
{
"sourceKind": "local_audio",
"projectId": "project-1",
"sourceLabel": "late-night-set.wav",
"roleFocus": ["bass-guitar"],
"localSource": {
"sourcePath": "/Users/test/Music/late-night-set.wav",
"fileName": "late-night-set.wav",
"extension": "wav",
"fileSizeBytes": 1024000,
},
},
"2026-03-12T00:00:00Z",
)
)

assert [(update["state"], update.get("progressStage")) for update in updates] == [
("running", "decode"),
("running", "separate"),
("failed", "separate"),
]
assert updates[-1]["progressPercent"] == 45
assert updates[-1]["error"]["code"] == "engine_unavailable"
assert "Unexpected GPU out-of-memory error" in updates[-1]["error"]["message"]


def test_cached_analysis_helpers_treat_invalid_cache_as_miss(tmp_path) -> None:
"""Ensure malformed cache files degrade to cache misses without failing analysis."""
cache_path = tmp_path / "analysis-cache.json"
Expand Down