diff --git a/src/stemforge/io_utils.py b/src/stemforge/io_utils.py index 2be9581..9c7647b 100644 --- a/src/stemforge/io_utils.py +++ b/src/stemforge/io_utils.py @@ -184,7 +184,11 @@ def read_json(path: PathLike) -> Any: def _json_default(o: Any) -> Any: if isinstance(o, np.ndarray): return o.tolist() - if isinstance(o, (np.floating, np.integer)): + # np.generic covers EVERY numpy scalar — bool_, floating, integer — across + # numpy 1.x and 2.x (2.0 renamed np.bool_'s repr to numpy.bool). The A1 cymbal + # evidence was the first path to put an np.bool_ into a manifest, which the old + # (np.floating, np.integer) branch missed → write_json crashed on drum teardown. + if isinstance(o, np.generic): return o.item() if isinstance(o, Path): return str(o) diff --git a/src/stemforge/web/assets/app.js b/src/stemforge/web/assets/app.js index 1af154f..25506d9 100644 --- a/src/stemforge/web/assets/app.js +++ b/src/stemforge/web/assets/app.js @@ -186,7 +186,13 @@ function showProgress(frac, msg) { async function poll(jobId) { for (;;) { await new Promise((r) => setTimeout(r, 400)); - const job = await (await fetch(`/api/job/${jobId}`)).json(); + const res = await fetch(`/api/job/${jobId}`); + // A dead job id (server restarted -> in-memory job store cleared) returns 404. + // Stop polling instead of looping forever on the missing job. + if (res.status === 404) { + return finishError("Job not found (the server was restarted). Please re-run."); + } + const job = await res.json(); showProgress(job.progress || 0.05, job.message); if (job.status === "done") return renderResults(job.result); if (job.status === "error") return finishError(job.error || job.message); diff --git a/src/stemforge/webapp.py b/src/stemforge/webapp.py index d908dc4..e9421ec 100644 --- a/src/stemforge/webapp.py +++ b/src/stemforge/webapp.py @@ -295,7 +295,7 @@ def _safe(path_str: str) -> Path | None: # --------------------------------------------------------------------------- # def create_app(): from fastapi import FastAPI, File, Form, HTTPException, UploadFile - from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse + from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles app = FastAPI(title="StemForge", docs_url=None, redoc_url=None) @@ -319,6 +319,12 @@ async def _require_token(request, call_next): def index() -> str: return (WEB_DIR / "index.html").read_text(encoding="utf-8") + @app.get("/favicon.ico") + def favicon() -> Response: + # No icon asset is shipped; return 204 so the browser stops logging a 404 + # on every page load (console noise, not an error). + return Response(status_code=204) + @app.get("/api/health") def health() -> dict[str, Any]: return health_status() diff --git a/tests/test_io.py b/tests/test_io.py index b5e2570..d1e18ad 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,6 +1,8 @@ +from pathlib import Path + import numpy as np -from stemforge.io_utils import AudioTensor, load_audio, receipt, save_audio, slugify +from stemforge.io_utils import AudioTensor, load_audio, read_json, receipt, save_audio, slugify, write_json def test_audiotensor_shapes(sine): @@ -40,3 +42,22 @@ def test_slugify(): assert slugify("My Song") == "My_Song" assert slugify("a/b\\c") == "a_b_c" assert slugify("") == "song" + + +def test_write_json_serializes_numpy_scalars(tmp_path): + """A manifest can carry any numpy scalar. np.bool_ (the A1 cymbal evidence hit + it first) previously crashed write_json — _json_default now covers np.generic.""" + obj = { + "flag": np.bool_(True), # the case that broke drum teardown on Windows + "f": np.float64(1.5), + "i": np.int64(7), + "arr": np.arange(3), # ndarray -> list + "path": tmp_path / "out", # Path -> str + } + p = write_json(tmp_path / "m.json", obj) # must not raise + back = read_json(p) + assert back["flag"] is True + assert back["f"] == 1.5 and isinstance(back["f"], float) + assert back["i"] == 7 and isinstance(back["i"], int) + assert back["arr"] == [0, 1, 2] + assert back["path"] == str(Path(tmp_path / "out")) diff --git a/tests/test_separation_backends.py b/tests/test_separation_backends.py index 8529625..56b6bda 100644 --- a/tests/test_separation_backends.py +++ b/tests/test_separation_backends.py @@ -180,7 +180,8 @@ def test_separate_uvr_maps_and_caches(fake_uvr_cli, sine): assert cmd[0].endswith("audio-separator") assert cmd[cmd.index("--model_filename") + 1] == BS_ROFORMER_MODEL assert cmd[cmd.index("--output_format") + 1] == "WAV" - assert cmd[cmd.index("--model_file_dir") + 1].endswith("models/uvr") + # compare path components, not a "/"-joined suffix — portable across Windows backslashes + assert Path(cmd[cmd.index("--model_file_dir") + 1]).parts[-2:] == ("models", "uvr") assert "--use_autocast" in cmd # scratch files are cleaned up after each call diff --git a/tests/test_tempo_evidence.py b/tests/test_tempo_evidence.py index 1de45f5..385aee9 100644 --- a/tests/test_tempo_evidence.py +++ b/tests/test_tempo_evidence.py @@ -8,7 +8,9 @@ the librosa meter is flagged assumed, and both analysis and stretch record a shared, sanitized EngineAttempt chain. -GPU-free and deterministic (beat_this is absent, so analyze uses librosa). +GPU-free and deterministic — analyze is driven to librosa either explicitly +(engine="librosa") or by forcing the beat_this hop to fail, so the tests do not +depend on whether beat_this happens to be installed. """ from __future__ import annotations @@ -108,10 +110,15 @@ def test_beatgrid_to_dict_carries_evidence(): assert d["bpm_candidates"] == [60.0, 120.0, 240.0] -def test_analyze_records_fallback_chain(): - """beat_this is absent -> analyze records a fell_through hop with a sanitized - reason, then librosa used.""" - g = analyze(_two_clicks()) # default engine="beat_this" -> falls to librosa +def test_analyze_records_fallback_chain(monkeypatch): + """A beat_this failure -> analyze records a fell_through hop with a sanitized + reason, then librosa used. Force the failure so the test is isolated from the + environment (beat_this may or may not be installed — it is on Windows).""" + def _boom(*a, **k): + raise ImportError("No module named 'beat_this'") + + monkeypatch.setattr(analysis, "_analyze_beat_this", _boom) + g = analyze(_two_clicks()) # default engine="beat_this" -> forced fallback to librosa assert g.engine == "librosa" chain = g.fallback_chain assert [c["engine"] for c in chain] == ["beat_this", "librosa"] diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 0959278..7c239c3 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -192,9 +192,15 @@ def test_file_route_rejects_paths_outside_output_root(client, tmp_path): def test_unknown_job_is_404(client): + # the SPA poller relies on this 404 to stop polling a dead job after a restart assert client.get("/api/job/deadbeef").status_code == 404 +def test_favicon_returns_204_not_404(client): + # a favicon route silences the per-load 404 in the local server console + assert client.get("/favicon.ico").status_code == 204 + + # --------------------------------------------------------------------------- # # Match BPM (whole-file) — stretch.match_bpm_file mocked # --------------------------------------------------------------------------- #