Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/stemforge/io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion src/stemforge/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion src/stemforge/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
23 changes: 22 additions & 1 deletion tests/test_io.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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"))
3 changes: 2 additions & 1 deletion tests/test_separation_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions tests/test_tempo_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 6 additions & 0 deletions tests/test_webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------------------------- #
Expand Down
Loading