diff --git a/BACKLOG.md b/BACKLOG.md
index c3db5a56..92a993da 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -28,5 +28,8 @@ All eight detectors emit fields visible in [`apps/backend/tests/test_analyze.py`
- ✅ `timeSignatureSource` / `timeSignatureConfidence` — surfaced through HTTP `phase1` via `server.py`.
- ✅ Vibrato display follow-up — `melodyDetail.vibratoExtent` is labeled in cents and present-branch sub-1% confidence renders as `< 1%` rather than `VIBRATO: PRESENT … 0%`.
+## Shipped — Phase 3 Audition
+- ✅ **Audition samples.** Heuristic WAV/MIDI clips derived from Phase 1 measurements (and Phase 2 context when available) so producers can ear-check the measurement chain. PyTheory generates the musical plan, FluidSynth (with sine-additive fallback) renders audio, NumPy synthesizes drum one-shots, manifest carries citations. **Landed:** [`apps/backend/sample_generation.py`](apps/backend/sample_generation.py), [`sample_theory.py`](apps/backend/sample_theory.py), [`sample_synthesis.py`](apps/backend/sample_synthesis.py), [`sample_drums.py`](apps/backend/sample_drums.py), [`server_samples.py`](apps/backend/server_samples.py); UI [`SamplePlayback.tsx`](apps/ui/src/components/SamplePlayback.tsx) + [`sampleGenerationClient.ts`](apps/ui/src/services/sampleGenerationClient.ts). On-demand endpoints `POST/GET /api/analysis-runs/{run_id}/samples` (not part of the staged-execution queue). Design doc: [`docs/SAMPLE_GENERATION.md`](docs/SAMPLE_GENERATION.md). Adjacent to `patchSmith.ts` but solves a different problem — audition validates the measurements rather than producing a saveable preset.
+
## Open
- 🔲 `services/patchSmith.ts` — generates Vital/Operator patch parameters from detected features. Slot: Phase 3 (not yet built); unique differentiator (download-ready preset output). Evaluate against `PURPOSE.md` decision framework before scheduling.
diff --git a/CLAUDE.md b/CLAUDE.md
index f4062d8c..e1d958d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,6 +112,8 @@ The backend supports staged execution via `analysis_runtime.py`, which persists
Run-oriented endpoints (`/api/analysis-runs*`) are the canonical interface for staged execution. Legacy `POST /api/analyze`, `POST /api/analyze/estimate`, and `POST /api/phase2` remain only as temporary compatibility wrappers — do not build new functionality on them.
+Phase 3 audition-sample generation (`POST/GET /api/analysis-runs/{run_id}/samples`) is **on-demand**, not a queue stage — the UI explicitly requests it after interpretation completes. See `server_samples.py` and the `sample_*.py` modules.
+
Frontend polling: `src/services/analysisRunsClient.ts` creates runs and polls stage snapshots. `src/services/analyzer.ts` orchestrates the create-run + poll loop and projects display payloads.
### Runtime Profiles
@@ -129,7 +131,7 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths
1. **`analyze.py`**: Pure DSP pipeline entry point. Runs as a subprocess invoked by `server.py`. Coordinates the split feature modules below. **Writes JSON to stdout, diagnostics to stderr** — this contract is load-bearing.
2. **`analyze_core.py`, `analyze_audio_io.py`, `analyze_detection.py`, `analyze_estimate.py`, `analyze_rhythm.py`, `analyze_segments.py`, `analyze_structure.py`, `analyze_transcription.py`, `analyze_fast.py`**: Feature modules. Loadouts: BPM/key/LUFS/stereo/spectral balance, rhythm/melody detail, segment boundaries, transcription. `analyze_fast.py` is the streamlined pipeline used by `--fast`.
-3. **`server.py`**: FastAPI app and router composition. Routes are organized into `server_phase1.py`, `server_phase2.py`, `server_upload.py`. Handles multipart uploads, invokes `analyze.py` (or worker), normalizes raw output into the `phase1` HTTP contract.
+3. **`server.py`**: FastAPI app and router composition. Routes are organized into `server_phase1.py`, `server_phase2.py`, `server_upload.py`, `server_samples.py`. Handles multipart uploads, invokes `analyze.py` (or worker), normalizes raw output into the `phase1` HTTP contract.
4. **`analysis_runtime.py`**: SQLite-backed run state and stage queue management. Run state in `.runtime/analysis_runs.sqlite3`; artifacts in `.runtime/artifacts/`.
5. **`worker.py`**: Dedicated worker-process entry point for hosted-style background stage execution. In `local` profile, work runs in-process; in `hosted` profile, this is the worker role.
6. **`runtime_profile.py`**: Switchboard for `local` vs `hosted` profile and `all` vs `api` vs `worker` process roles (env: `SONIC_ANALYZER_RUNTIME_PROFILE`, `SONIC_ANALYZER_PROCESS_ROLE`).
@@ -137,6 +139,12 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths
8. **`auth_context.py`**: Hosted-mode user-context resolution and ownership checks on canonical run routes.
9. **`upload_limits.py`**: Canonical 100 MiB raw-audio / 101 MiB request-envelope limits. Regenerate the operator contract via `scripts/render_upload_limit_contract.py` if numbers change.
10. **`spectral_viz.py`**: Librosa-based spectrogram/time-series artifacts. Called after measurement; failures are non-critical.
+11. **`url_ingest.py`**: SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs` — fetches a public `http`/`https` audio URL and feeds it through the same downstream pipeline as a multipart upload.
+12. **`csv_export.py`**: CSV exporters for Phase 1 time-series fields, backing `GET /api/analysis-runs/{run_id}/export/csv/{field_path}`. Keeps the route handler a thin lookup-and-serve.
+13. **`stage_status.py`**: Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field on every stage snapshot.
+14. **`server_samples.py` + `sample_generation.py`, `sample_theory.py`, `sample_synthesis.py`, `sample_drums.py`**: Phase 3 audition-sample generation. `sample_theory.py` builds the PyTheory musical plan, `sample_synthesis.py` renders audio (FluidSynth with sine-additive fallback), `sample_drums.py` synthesizes drum one-shots, `sample_generation.py` orchestrates and emits the citation manifest. On-demand only.
+15. **`dsp_bandbank.py` + `dsp_utils.py`**: Shared DSP primitives — `BatchedBandpass` (4th-order Butterworth bandpass bank) and cross-module utility functions.
+16. **`phase1_evaluation.py` + `phase1_report_html.py`, `polyphonic_evaluation.py`**: Offline evaluation harnesses (deterministic-metric / detector-stability reporting and research-only polyphonic transcription). Not on the product path; driven by `scripts/evaluate_*.py`.
The subprocess isolation means `analyze.py` works as a standalone CLI. Check `apps/backend/JSON_SCHEMA.md` before adding new analyzer output fields. Check `apps/backend/ARCHITECTURE.md` for the full HTTP flow and contract details.
@@ -236,7 +244,7 @@ A quick map from intent to the right place to start:
## Backport Candidates
-Most of the original `sonic-architect-app` port (genre profiles, Ableton device mappings, mix doctor, eight detectors) is **shipped**. The remaining open item in [`BACKLOG.md`](BACKLOG.md) is `patchSmith.ts` (Phase 3 synth-patch generation). Consult `BACKLOG.md` before re-implementing genre detection, mix analysis, acid/reverb/vocal/supersaw/bass/kick detection — they're already in [`apps/backend/analyze_detection.py`](apps/backend/analyze_detection.py) and emit fields visible in `EXPECTED_TOP_LEVEL_KEYS`.
+Most of the original `sonic-architect-app` port (genre profiles, Ableton device mappings, mix doctor, eight detectors) is **shipped**, as is Phase 3 audition-sample generation. The remaining open item in [`BACKLOG.md`](BACKLOG.md) is `patchSmith.ts` (Phase 3 synth-patch generation — distinct from audition samples, which validate measurements rather than producing a saveable preset). Consult `BACKLOG.md` before re-implementing genre detection, mix analysis, acid/reverb/vocal/supersaw/bass/kick detection — they're already in [`apps/backend/analyze_detection.py`](apps/backend/analyze_detection.py) and emit fields visible in `EXPECTED_TOP_LEVEL_KEYS`.
## Companion Agent Docs
diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md
index 4ae460d4..871fcbd9 100644
--- a/apps/backend/AGENTS.md
+++ b/apps/backend/AGENTS.md
@@ -108,11 +108,17 @@ python3.11 -m venv venv
- `analyze.py`: CLI entry point. Coordinates the split `analyze_*.py` feature modules and emits the raw JSON.
- `analyze_core.py`, `analyze_audio_io.py`, `analyze_detection.py`, `analyze_estimate.py`, `analyze_rhythm.py`, `analyze_segments.py`, `analyze_structure.py`, `analyze_transcription.py`, `analyze_fast.py`: Feature modules (BPM/key/LUFS/stereo, rhythm/melody/groove, segments, structure, transcription, the `--fast` pipeline). Split from the original monolith in commit `5c40dd44` — keep the split when adding features.
-- `server.py` + `server_phase1.py`, `server_phase2.py`, `server_upload.py`: FastAPI app + route modules. Multipart upload handling, subprocess execution, envelope normalization.
+- `server.py` + `server_phase1.py`, `server_phase2.py`, `server_upload.py`, `server_samples.py`: FastAPI app + route modules. Multipart/URL upload handling, subprocess execution, envelope normalization, and the on-demand Phase 3 audition-sample routes.
- `analysis_runtime.py`: SQLite-backed run state, stage queue, artifact metadata.
- `worker.py`, `runtime_profile.py`, `auth_context.py`, `artifact_storage.py`: Hosted-mode foundation. Local mode shouldn't branch through these unless it has to.
- `upload_limits.py`: Canonical 100 MiB raw-audio / 101 MiB request-envelope limits. Operator contract is generated, not hand-edited.
+- `url_ingest.py`: SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs`.
+- `csv_export.py`: CSV exporters for Phase 1 time-series fields; backs `GET /api/analysis-runs/{run_id}/export/csv/{field_path}`.
+- `stage_status.py`: Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field.
+- `sample_generation.py`, `sample_theory.py`, `sample_synthesis.py`, `sample_drums.py`: Phase 3 audition-sample generation — PyTheory plan, FluidSynth/sine-additive render, NumPy drum one-shots, citation manifest. On-demand only.
+- `dsp_bandbank.py`, `dsp_utils.py`: Shared DSP primitives — `BatchedBandpass` Butterworth bank and cross-module utilities.
- `spectral_viz.py`: Librosa spectrogram and spectral time-series artifacts. Non-critical — failures don't break a run.
+- `phase1_evaluation.py` + `phase1_report_html.py`: Offline Phase 1 evaluation harness and HTML render. Not on the product path; driven by `scripts/evaluate_phase1.py`.
- `polyphonic_evaluation.py` + `scripts/evaluate_polyphonic.py`: **Research-only.** Offline polyphonic-transcription evaluation harness, not part of the shipped product path.
- `tests/test_server.py`: OpenAPI and envelope contract tests.
- `tests/test_analyze.py`: generated WAV fixture, `EXPECTED_TOP_LEVEL_KEYS` snapshot, raw payload assertions.
diff --git a/apps/backend/ARCHITECTURE.md b/apps/backend/ARCHITECTURE.md
index c624b0c9..b0028d87 100644
--- a/apps/backend/ARCHITECTURE.md
+++ b/apps/backend/ARCHITECTURE.md
@@ -5,7 +5,7 @@
| Component | Role |
| --- | --- |
| `analyze.py` | Raw CLI analyzer entry point. Loads audio, coordinates the `analyze_*.py` feature modules (see [Analyzer Submodules](#analyzer-submodules) below), optionally separates stems and transcribes notes through torchcrepe, then prints JSON to `stdout`. |
-| `server.py` + `server_phase1.py` / `server_phase2.py` / `server_upload.py` | FastAPI app and router composition. Accepts uploads, computes estimates, manages the canonical staged run API, normalizes measurement results, and serves artifact access. |
+| `server.py` + `server_phase1.py` / `server_phase2.py` / `server_upload.py` / `server_samples.py` | FastAPI app and router composition. Accepts uploads, computes estimates, manages the canonical staged run API, normalizes measurement results, serves artifact access, and exposes the on-demand Phase 3 audition-sample routes. |
| `analysis_runtime.py` | Run-state persistence and staged-analysis orchestration. Owns run snapshots, stage status, artifact metadata, and ownership checks. |
| `artifact_storage.py` | Artifact storage boundary. The current implementation uses the local filesystem, but the runtime now talks to a storage service interface instead of assuming every artifact is a local disk path forever. |
| `runtime_profile.py` | Runtime/profile switchboard for `local` vs `hosted` behavior and `all` vs `api` vs `worker` process roles. |
@@ -13,6 +13,12 @@
| `worker.py` | Dedicated worker-process entry point for hosted-style background stage execution. |
| `upload_limits.py` | Canonical raw-audio (100 MiB) and request-envelope (101 MiB) limits, plus the protected-route list. Operator contract is generated, not hand-edited — see `scripts/render_upload_limit_contract.py`. |
| `spectral_viz.py` | Librosa-based spectrogram generation and spectral time-series extraction. Produces mel/chroma PNG spectrograms and per-frame spectral evolution JSON. Called after successful measurement; failures are non-critical. |
+| `url_ingest.py` | SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs`. Fetches a public `http`/`https` audio file and feeds the bytes through the same downstream pipeline as a multipart upload. |
+| `csv_export.py` | CSV exporters for Phase 1 time-series fields, keyed by dotted JSON path. Backs `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` and keeps the route handler a thin lookup-and-serve. |
+| `stage_status.py` | Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field carried on every stage in the run snapshot. |
+| `server_samples.py` + `sample_generation.py` / `sample_theory.py` / `sample_synthesis.py` / `sample_drums.py` | Phase 3 audition-sample generation. PyTheory musical plan, FluidSynth (sine-additive fallback) audio render, NumPy drum one-shots, and a citation manifest tying every clip back to a Phase 1 field. On-demand only — not part of the staged-execution queue. See [`docs/SAMPLE_GENERATION.md`](../../docs/SAMPLE_GENERATION.md). |
+| `dsp_bandbank.py` + `dsp_utils.py` | Shared DSP primitives: `BatchedBandpass` (4th-order Butterworth bandpass bank with zero-phase `filtfilt`) and cross-module utility functions. |
+| `phase1_evaluation.py` + `phase1_report_html.py` | Offline Phase 1 evaluation harness — deterministic-metric and detector-stability reporting, with a standalone HTML render. Not on the product path; driven by `scripts/evaluate_phase1.py`. |
| `polyphonic_evaluation.py` + `scripts/evaluate_polyphonic.py` | Research-only offline polyphonic-transcription evaluation harness. Not on the product path. |
| `tests/test_server.py` | Contract tests for estimate, timeout, and success envelopes. |
| `tests/test_analyze.py` | Structural snapshot tests for the raw analyzer JSON output. Owns `EXPECTED_TOP_LEVEL_KEYS` — update it whenever you add a root field. |
@@ -74,9 +80,10 @@ Custom routes:
- `GET /api/analysis-runs/{run_id}/artifacts` and `…/artifacts/{artifact_id}`
- `GET /api/analysis-runs/{run_id}/source-audio` — re-serves the original ingested audio for the run. Owner-only (no admin bypass). Saves a round-trip vs looking up the source artifact id first.
- `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` — CSV export of a Phase 1 time-series field. See [`docs/adr/0001-phase1-json-schema-v1.md`](../../docs/adr/0001-phase1-json-schema-v1.md) and the registry in [`csv_export.py`](csv_export.py).
-- `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}`
+- `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}` — on-demand spectral artifacts. `kind` is one of `cqt`, `hpss`, `onset`, `chroma_interactive`, or `reassigned` (sharper transient/frequency localization via `librosa.reassigned_spectrogram`).
- `POST /api/analysis-runs/{run_id}/pitch-note-translations`
- `POST /api/analysis-runs/{run_id}/interpretations`
+- `POST /api/analysis-runs/{run_id}/samples` and `GET /api/analysis-runs/{run_id}/samples` — on-demand Phase 3 audition-sample generation and retrieval. Nothing in the staged-execution loop runs these automatically; the UI POSTs after interpretation completes. See [`server_samples.py`](server_samples.py) and [`docs/SAMPLE_GENERATION.md`](../../docs/SAMPLE_GENERATION.md).
- `POST /api/analyze` (legacy compatibility)
- `POST /api/analyze/estimate` (legacy compatibility)
- `POST /api/phase2` (legacy compatibility)
diff --git a/apps/backend/README.md b/apps/backend/README.md
index 34b77433..016bb6eb 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -16,9 +16,16 @@ This repo contains two entry points:
Canonical live-analysis routes:
- `POST /api/analysis-runs/estimate`
-- `POST /api/analysis-runs`
+- `POST /api/analysis-runs` — multipart upload or SSRF-guarded URL ingestion
- `GET /api/analysis-runs/{run_id}`
+- `DELETE /api/analysis-runs/{run_id}` — owner delete; operator `X-Admin-Key` bypass when `SONIC_ANALYZER_ADMIN_KEY` is set
- `GET /api/analysis-runs/{run_id}/artifacts...`
+- `GET /api/analysis-runs/{run_id}/source-audio`
+- `GET /api/analysis-runs/{run_id}/export/csv/{field_path}`
+- `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}`
+- `POST /api/analysis-runs/{run_id}/pitch-note-translations`
+- `POST /api/analysis-runs/{run_id}/interpretations`
+- `POST /api/analysis-runs/{run_id}/samples` and `GET /api/analysis-runs/{run_id}/samples` — on-demand Phase 3 audition samples
Legacy compatibility routes:
@@ -34,7 +41,9 @@ FastAPI also serves the usual generated endpoints at `/openapi.json`, `/docs`, a
- NumPy
- Demucs
- torchcrepe (pitch/note translation)
-- mido
+- librosa (spectrogram / spectral time-series artifacts)
+- mido / pretty_midi
+- pytheory + pyfluidsynth (Phase 3 audition-sample generation)
- FastAPI
- Uvicorn
diff --git a/apps/backend/analyze_detection.py b/apps/backend/analyze_detection.py
index e1438890..8a4a19ab 100644
--- a/apps/backend/analyze_detection.py
+++ b/apps/backend/analyze_detection.py
@@ -1,5 +1,6 @@
"""Detection analyzers — effects, acid, reverb, vocal, supersaw, and genre."""
+import functools
import sys
import numpy as np
@@ -21,6 +22,7 @@
from dsp_utils import _safe_db, _compute_bark_db
from analyze_audio_io import _load_stem_mono
+from dsp_bandbank import BatchedBandpass
# Phase 1.D #5 — bands for per-band RT60 estimation. Chosen to roughly mirror
@@ -35,20 +37,21 @@
)
+@functools.lru_cache(maxsize=4)
+def _bandbank_for(sample_rate: int) -> BatchedBandpass:
+ """One BatchedBandpass per sample rate (4 slots covers 22050/44100/48000/96000)."""
+ return BatchedBandpass(int(sample_rate))
+
+
def _bandpass_signal(mono: np.ndarray, sample_rate: int, lo_hz: float, hi_hz: float) -> np.ndarray | None:
- """4th-order Butterworth bandpass via scipy.signal. Returns None on failure."""
- if scipy_signal is None or mono.size == 0:
- return None
- nyquist = 0.5 * sample_rate
- lo = max(1.0, lo_hz) / nyquist
- hi = min(sample_rate * 0.49, hi_hz) / nyquist
- if not (0.0 < lo < hi < 1.0):
- return None
- try:
- sos = scipy_signal.butter(4, [lo, hi], btype="bandpass", output="sos")
- return scipy_signal.sosfiltfilt(sos, mono).astype(np.float32, copy=False)
- except Exception:
- return None
+ """4th-order Butterworth bandpass via scipy.signal. Returns None on failure.
+
+ Thin wrapper around ``BatchedBandpass.filter_one`` kept for callers that
+ pass ``sample_rate`` per call. Output is bit-identical to the previous
+ inline implementation; new code should obtain a ``BatchedBandpass``
+ directly via ``_bandbank_for(sample_rate)`` and use ``filter_many``.
+ """
+ return _bandbank_for(sample_rate).filter_one(mono, lo_hz, hi_hz)
# Mirrors apps/backend/analyze_core.py SPECTRAL_BALANCE_BANDS — imported lazily
@@ -545,13 +548,16 @@ def analyze_reverb_detail(
# Per-band RT60: re-use the SAME transient indices on the broadband
# envelope so each band measures the same events. Bandpass the raw
- # signal first, recompute the per-band envelope, then run the slope
- # fit. Requires scipy.signal — if unavailable, omit per-band.
+ # signal once (filter_many designs each SOS only once and reuses
+ # them), recompute the per-band envelope, then run the slope fit.
+ # Requires scipy.signal — if unavailable, omit per-band.
per_band_rt60: dict[str, float] | None = None
if scipy_signal is not None:
+ bandbank = _bandbank_for(sample_rate)
+ filtered_bands = bandbank.filter_many(mono_arr, _REVERB_BANDS, dtype=np.float32)
per_band: dict[str, float] = {}
- for band_name, lo_hz, hi_hz in _REVERB_BANDS:
- filtered = _bandpass_signal(mono_arr, sample_rate, lo_hz, hi_hz)
+ for band_name, _lo, _hi in _REVERB_BANDS:
+ filtered = filtered_bands.get(band_name)
if filtered is None:
continue
band_envelope = np.zeros(n_frames, dtype=np.float64)
diff --git a/apps/backend/dsp_bandbank.py b/apps/backend/dsp_bandbank.py
new file mode 100644
index 00000000..effb2a19
--- /dev/null
+++ b/apps/backend/dsp_bandbank.py
@@ -0,0 +1,147 @@
+"""BatchedBandpass — 4th-order Butterworth bandpass bank with zero-phase filtfilt.
+
+Drop-in replacement for the inline ``butter(4, ..., output='sos') + sosfiltfilt``
+pattern that ASA's detection module uses for per-band RT60. The pattern was
+repeating across multiple call sites with slightly different glue; this module
+gives them one shared primitive.
+
+Output is bit-identical to the inline code (np.allclose atol=1e-12, rtol=1e-12).
+The ``backend`` argument is reserved for a future torch path — the audit at
+``docs/history/library-review-torchfx-2026-05-13.md`` flagged that as a
+candidate if profiling ever shows per-band scipy filtering is hot.
+"""
+
+from collections.abc import Iterable, Mapping
+from typing import Union
+
+import numpy as np
+
+try:
+ from scipy import signal as scipy_signal # type: ignore[import-not-found]
+except ImportError:
+ scipy_signal = None # type: ignore[assignment]
+
+
+class BatchedBandpass:
+ """Per-instance bandpass bank with cached SOS coefficients.
+
+ Matches ``analyze_detection._bandpass_signal`` byte-for-byte: Nyquist
+ clamps via ``max(1.0, lo_hz)`` and ``min(sample_rate * 0.49, hi_hz)``,
+ 4th-order Butterworth via ``output='sos'``, then ``sosfiltfilt``. Any
+ deviation is a Phase 1 regression — see ``BatchedBandpassTests``.
+ """
+
+ def __init__(self, sample_rate: int, *, order: int = 4, backend: str = "scipy") -> None:
+ if backend != "scipy":
+ raise ValueError(
+ f"BatchedBandpass: backend={backend!r} not supported (PR 1 ships scipy only)"
+ )
+ self.sample_rate = int(sample_rate)
+ self.order = int(order)
+ self.backend = backend
+ # Memoised SOS coefficients per (lo_hz, hi_hz). sample_rate and order
+ # are fixed per instance. Failed designs (out-of-range etc.) are
+ # cached as None so the second caller short-circuits without retry.
+ self._sos_cache: dict[tuple[float, float], np.ndarray | None] = {}
+
+ def _design(self, lo_hz: float, hi_hz: float) -> np.ndarray | None:
+ key = (float(lo_hz), float(hi_hz))
+ if key in self._sos_cache:
+ return self._sos_cache[key]
+ if scipy_signal is None:
+ self._sos_cache[key] = None
+ return None
+ nyquist = 0.5 * self.sample_rate
+ lo = max(1.0, lo_hz) / nyquist
+ hi = min(self.sample_rate * 0.49, hi_hz) / nyquist
+ if not (0.0 < lo < hi < 1.0):
+ self._sos_cache[key] = None
+ return None
+ try:
+ sos = scipy_signal.butter(self.order, [lo, hi], btype="bandpass", output="sos")
+ except Exception:
+ sos = None
+ self._sos_cache[key] = sos
+ return sos
+
+ # Note on asymmetric dtype defaults between filter_one and filter_many:
+ # filter_one defaults to float32 because it replaces the pre-refactor
+ # _bandpass_signal which hardcoded float32 — that default keeps the
+ # existing call site bit-identical with no code change. filter_many
+ # defaults to float64 because its primary future caller
+ # (analyze_per_band_transient_density) does the upstream mono → float64
+ # cast in the outer scope and feeds the result to librosa, which accepts
+ # either dtype. Both methods accept an explicit dtype keyword so callers
+ # can override either default at the call site.
+
+ def filter_one(
+ self,
+ mono: np.ndarray,
+ lo_hz: float,
+ hi_hz: float,
+ *,
+ dtype=np.float32,
+ ) -> np.ndarray | None:
+ """Single-band filter; use ``filter_many`` for the multi-band path.
+
+ Default ``dtype=np.float32`` preserves bit-identicality with the
+ pre-refactor ``analyze_detection._bandpass_signal`` — callers that
+ pass no dtype get exactly the historical output. See the class-level
+ note above on why ``filter_one`` and ``filter_many`` differ in
+ default dtype.
+
+ Returns ``None`` on empty input, missing scipy, Nyquist-clamp
+ failure, or sosfiltfilt error.
+ """
+ if scipy_signal is None or mono is None or getattr(mono, "size", 0) == 0:
+ return None
+ sos = self._design(lo_hz, hi_hz)
+ if sos is None:
+ return None
+ try:
+ return scipy_signal.sosfiltfilt(sos, mono).astype(dtype, copy=False)
+ except Exception:
+ return None
+
+ def filter_many(
+ self,
+ mono: np.ndarray,
+ bands: Union[Mapping[str, tuple[float, float]], Iterable[tuple[str, float, float]]],
+ *,
+ dtype=np.float64,
+ ) -> dict[str, np.ndarray]:
+ """Run the bandpass over many bands and return ``{band_name: filtered}``.
+
+ Accepts two shapes for ``bands``:
+ - ``Mapping[str, (lo, hi)]`` — matches ``SPECTRAL_BALANCE_BANDS``
+ in ``analyze_core``.
+ - ``Iterable[(name, lo, hi)]`` — matches ``_REVERB_BANDS`` in
+ ``analyze_detection``.
+
+ Skipped bands (empty input, out-of-range, sosfiltfilt error) are
+ absent from the returned dict — preserves the omit-on-skip behavior
+ of the per-band reverb loop today. See the class-level note above
+ on why ``filter_many`` defaults to ``float64`` while ``filter_one``
+ defaults to ``float32``.
+ """
+ if scipy_signal is None or mono is None or getattr(mono, "size", 0) == 0:
+ return {}
+
+ if isinstance(bands, Mapping):
+ entries: Iterable[tuple[str, float, float]] = [
+ (str(name), float(lo), float(hi)) for name, (lo, hi) in bands.items()
+ ]
+ else:
+ entries = [(str(name), float(lo), float(hi)) for name, lo, hi in bands]
+
+ out: dict[str, np.ndarray] = {}
+ for name, lo_hz, hi_hz in entries:
+ sos = self._design(lo_hz, hi_hz)
+ if sos is None:
+ continue
+ try:
+ filtered = scipy_signal.sosfiltfilt(sos, mono)
+ except Exception:
+ continue
+ out[name] = filtered.astype(dtype, copy=False)
+ return out
diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt
index 54696e71..5a27a597 100644
--- a/apps/backend/requirements.txt
+++ b/apps/backend/requirements.txt
@@ -70,3 +70,12 @@ typing_extensions==4.15.0
urllib3==2.6.3
google-genai>=1.14.0,<2.0.0
uvicorn==0.41.0
+# Phase 3 (audition samples). Both deps are optional: sample_theory.py has a
+# pure-Python music-theory fallback and sample_synthesis.py has a NumPy
+# sine-additive fallback. Tests exercise the fallbacks so CI doesn't require
+# either of these to be importable, but installing them upgrades audition
+# quality from "in-tune" to "musical." pyfluidsynth additionally needs the
+# system `libfluidsynth` library and a General MIDI soundfont — see
+# docs/SAMPLE_GENERATION.md.
+pytheory>=0.42,<1.0
+pyfluidsynth>=1.3,<2.0
diff --git a/apps/backend/sample_drums.py b/apps/backend/sample_drums.py
new file mode 100644
index 00000000..03fcf0e8
--- /dev/null
+++ b/apps/backend/sample_drums.py
@@ -0,0 +1,145 @@
+"""NumPy-based drum one-shot synthesis for audition samples.
+
+Phase 1 measures the kick's fundamental and decay (`kickDetail.fundamentalHz`,
+`kickDetail.decayTimeMs`). We turn those into a sub-sine + transient click.
+Snare and hi-hat are not directly measured — we render plausible defaults and
+the manifest is explicit that those are heuristic, not measurement-grounded.
+
+PyTheory is not involved here; drums are not harmonic. Synthesis lives outside
+the music-theory layer.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+SAMPLE_RATE = 44_100
+_RNG = np.random.default_rng(seed=42) # deterministic noise so tests are stable
+
+
+@dataclass(frozen=True)
+class DrumOneShot:
+ samples: np.ndarray # float32, mono, range [-1, 1]
+ sample_rate: int
+ duration_seconds: float
+ label: str
+
+
+def synth_kick(
+ *,
+ fundamental_hz: float = 55.0,
+ decay_time_ms: float = 250.0,
+ click_amount: float = 0.35,
+ duration_seconds: float = 0.6,
+) -> DrumOneShot:
+ """Synthesize a kick: pitch-modulated sub-sine plus transient click.
+
+ The pitch starts an octave above `fundamental_hz` and exponentially decays
+ to the fundamental over the first 30 ms. This mimics the body-and-thump
+ shape of an electronic kick well enough for an audition.
+
+ `decay_time_ms` controls how long the body sustains. Phase 1 typically
+ measures values in the 150–400 ms range for electronic kicks.
+ """
+ if fundamental_hz <= 0:
+ raise ValueError(f"fundamental_hz must be positive; got {fundamental_hz}")
+ if duration_seconds <= 0:
+ raise ValueError(f"duration_seconds must be positive; got {duration_seconds}")
+
+ num_samples = int(round(SAMPLE_RATE * duration_seconds))
+ t = np.arange(num_samples, dtype=np.float64) / SAMPLE_RATE
+
+ # Pitch envelope: octave drop over the first 30 ms.
+ pitch_decay_s = 0.030
+ pitch_env = np.exp(-t / pitch_decay_s) * fundamental_hz + fundamental_hz
+ phase = 2.0 * np.pi * np.cumsum(pitch_env) / SAMPLE_RATE
+ body = np.sin(phase)
+
+ # Amplitude envelope: exponential decay with measured time constant.
+ decay_s = max(decay_time_ms, 50.0) / 1000.0
+ amp_env = np.exp(-t / (decay_s * 0.4)) # slightly tighter than reported decay
+ body *= amp_env
+
+ # Click: short high-frequency burst with its own fast envelope.
+ click_env = np.exp(-t / 0.004) # 4 ms decay
+ click_noise = _RNG.standard_normal(num_samples).astype(np.float64)
+ click = click_noise * click_env * click_amount
+
+ samples = body * 0.85 + click
+ samples = _normalize(samples)
+
+ return DrumOneShot(
+ samples=samples.astype(np.float32),
+ sample_rate=SAMPLE_RATE,
+ duration_seconds=duration_seconds,
+ label=f"Kick at {fundamental_hz:.0f} Hz",
+ )
+
+
+def synth_snare(
+ *,
+ body_hz: float = 200.0,
+ duration_seconds: float = 0.4,
+) -> DrumOneShot:
+ """Snare: tone body + noise crack. Heuristic only — no Phase 1 ground truth."""
+ num_samples = int(round(SAMPLE_RATE * duration_seconds))
+ t = np.arange(num_samples, dtype=np.float64) / SAMPLE_RATE
+
+ body_env = np.exp(-t / 0.08)
+ body = np.sin(2.0 * np.pi * body_hz * t) * body_env
+
+ noise_env = np.exp(-t / 0.12)
+ noise = _RNG.standard_normal(num_samples) * noise_env
+
+ # Simple high-shelf: take the running difference to emphasize highs.
+ noise_hp = np.empty_like(noise)
+ noise_hp[0] = noise[0]
+ noise_hp[1:] = noise[1:] - 0.97 * noise[:-1]
+
+ samples = body * 0.4 + noise_hp * 0.85
+ samples = _normalize(samples)
+ return DrumOneShot(
+ samples=samples.astype(np.float32),
+ sample_rate=SAMPLE_RATE,
+ duration_seconds=duration_seconds,
+ label="Snare (heuristic)",
+ )
+
+
+def synth_hat(*, duration_seconds: float = 0.18) -> DrumOneShot:
+ """Closed hi-hat: short filtered noise. Heuristic — no Phase 1 ground truth."""
+ num_samples = int(round(SAMPLE_RATE * duration_seconds))
+ t = np.arange(num_samples, dtype=np.float64) / SAMPLE_RATE
+
+ env = np.exp(-t / 0.035)
+ noise = _RNG.standard_normal(num_samples)
+
+ # Two-tap high-pass (same idea as snare, vectorized to match snare's style).
+ # The filter has no feedback, so it's an FIR we can express as array slices.
+ hp = np.empty_like(noise)
+ hp[0] = noise[0]
+ if num_samples > 1:
+ hp[1] = noise[1] - 0.98 * noise[0]
+ if num_samples > 2:
+ hp[2:] = noise[2:] - 1.96 * noise[1:-1] + 0.97 * noise[:-2]
+
+ samples = hp * env
+ samples = _normalize(samples) * 0.7
+ return DrumOneShot(
+ samples=samples.astype(np.float32),
+ sample_rate=SAMPLE_RATE,
+ duration_seconds=duration_seconds,
+ label="Closed hat (heuristic)",
+ )
+
+
+def _normalize(samples: np.ndarray, *, headroom_db: float = 1.5) -> np.ndarray:
+ """Peak-normalize to leave a small headroom margin."""
+ peak = float(np.max(np.abs(samples))) if samples.size else 0.0
+ if peak <= 1e-9:
+ return samples
+ target = 10 ** (-headroom_db / 20.0)
+ return samples * (target / peak)
diff --git a/apps/backend/sample_generation.py b/apps/backend/sample_generation.py
new file mode 100644
index 00000000..69e97982
--- /dev/null
+++ b/apps/backend/sample_generation.py
@@ -0,0 +1,403 @@
+"""Phase 3 audition sample generation orchestrator.
+
+Reads a Phase 1 result + optional Phase 2 result, produces audition WAV/MIDI
+artifacts, and emits a manifest with citation metadata so every generated
+sample traces back to a Phase 1 field. The manifest *is* the chain of custody
+for this stage — it must remain the source of truth for "what justifies this
+audio?"
+
+Shape of the manifest is documented in `docs/SAMPLE_GENERATION.md`.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import soundfile as sf
+
+import sample_drums
+import sample_synthesis
+import sample_theory
+
+logger = logging.getLogger(__name__)
+
+SCHEMA_VERSION = "samples.v1"
+FRAMING_NOTE = (
+ "Heuristic audition. Verifies tonal/rhythmic foundation derived from "
+ "Phase 1 measurements. Not an Ableton-accurate reconstruction — follow "
+ "Phase 2 in Ableton for production character."
+)
+
+
+@dataclass(frozen=True)
+class GenerationResult:
+ manifest: dict[str, Any]
+ artifact_paths: dict[str, Path] # sample_id -> WAV path
+ midi_paths: dict[str, Path] # sample_id -> MIDI path (subset of samples)
+ manifest_path: Path
+
+
+def generate_samples(
+ *,
+ run_id: str,
+ phase1: dict[str, Any],
+ phase2: dict[str, Any] | None,
+ output_dir: Path,
+ pitch_note_hints: list[int] | None = None,
+ prefer_fluidsynth: bool = True,
+) -> GenerationResult:
+ """Build the full audition set for a single run.
+
+ `pitch_note_hints` may carry scale degrees pulled from the stem-summary
+ pitch/note translation output. When absent we render the default ascent.
+
+ `prefer_fluidsynth` is plumbed through so tests can force the fallback
+ even if FluidSynth is installed on the dev machine.
+ """
+ output_dir.mkdir(parents=True, exist_ok=True)
+ artifact_paths: dict[str, Path] = {}
+ midi_paths: dict[str, Path] = {}
+ samples: list[dict[str, Any]] = []
+ selected_backend: sample_synthesis.Backend | None = None
+ selected_soundfont: str | None = None
+
+ key_string = _safe_get(phase1, "key")
+ key_confidence = _safe_float(_safe_get(phase1, "keyConfidence"))
+ bpm = _safe_float(_safe_get(phase1, "bpm")) or 120.0
+
+ # --- Tonal samples (chord progression + bass) -------------------------- #
+ ctx: sample_theory.TheoryContext | None = None
+ if key_string:
+ try:
+ ctx = sample_theory.build_context(
+ key=key_string, bpm=bpm, key_confidence=key_confidence
+ )
+ except ValueError as exc:
+ logger.info("Skipping tonal samples; unparsed key %r (%s)", key_string, exc)
+
+ if ctx is not None:
+ low_confidence = bool(key_confidence is not None and key_confidence < 0.5)
+
+ chord_plan = sample_theory.plan_chord_progression(ctx, bars=8)
+ chord_result = sample_synthesis.render_clip(
+ chord_plan, prefer_fluidsynth=prefer_fluidsynth
+ )
+ selected_backend = chord_result.backend
+ selected_soundfont = chord_result.soundfont_path
+ chord_wav = output_dir / "tonal_chord_progression.wav"
+ chord_mid = output_dir / "tonal_chord_progression.mid"
+ sample_synthesis.write_wav(chord_result.samples, path=chord_wav)
+ sample_synthesis.write_midi(chord_plan, path=chord_mid)
+ artifact_paths["tonal_chord_progression"] = chord_wav
+ midi_paths["tonal_chord_progression"] = chord_mid
+ samples.append(
+ _sample_record(
+ sample_id="tonal_chord_progression",
+ label=_compose_chord_label(ctx, low_confidence),
+ category="tonal",
+ filename=chord_wav.name,
+ midi_filename=chord_mid.name,
+ duration_seconds=chord_result.duration_seconds,
+ confidence=_confidence_band(key_confidence),
+ low_confidence=low_confidence,
+ phase1_fields=["key", "keyConfidence", "bpm"],
+ phase2_recommendations=_phase2_tonal_citations(phase2),
+ rationale=(
+ f"8-bar diatonic progression in {ctx.root_name} {ctx.mode} at "
+ f"{ctx.tempo_bpm:.0f} BPM (key confidence "
+ f"{_format_confidence(key_confidence)})."
+ ),
+ )
+ )
+
+ bass_plan = sample_theory.plan_bass_root(ctx, bars=8)
+ bass_result = sample_synthesis.render_clip(
+ bass_plan, prefer_fluidsynth=prefer_fluidsynth
+ )
+ # `selected_backend` is already pinned by the chord render above —
+ # `render_clip` always returns a concrete backend string, so the bass
+ # render can never narrow or widen the choice.
+ bass_wav = output_dir / "tonal_bass_root.wav"
+ bass_mid = output_dir / "tonal_bass_root.mid"
+ sample_synthesis.write_wav(bass_result.samples, path=bass_wav)
+ sample_synthesis.write_midi(bass_plan, path=bass_mid)
+ artifact_paths["tonal_bass_root"] = bass_wav
+ midi_paths["tonal_bass_root"] = bass_mid
+ samples.append(
+ _sample_record(
+ sample_id="tonal_bass_root",
+ label=f"Bass root on {ctx.root_name}",
+ category="tonal",
+ filename=bass_wav.name,
+ midi_filename=bass_mid.name,
+ duration_seconds=bass_result.duration_seconds,
+ confidence=_confidence_band(key_confidence),
+ low_confidence=low_confidence,
+ phase1_fields=["key", "keyConfidence", "bpm", "bassDetail"]
+ if _safe_get(phase1, "bassDetail")
+ else ["key", "keyConfidence", "bpm"],
+ phase2_recommendations=_phase2_tonal_citations(phase2),
+ rationale=(
+ f"Sustained root tone at MIDI bass register. Tonic "
+ f"derived from Phase 1 key ({ctx.root_name} {ctx.mode})."
+ ),
+ )
+ )
+
+ # --- Drum one-shots ---------------------------------------------------- #
+ kick_detail = _safe_get(phase1, "kickDetail") or {}
+ kick_fundamental = _safe_float(kick_detail.get("fundamentalHz")) or 55.0
+ kick_decay = _safe_float(kick_detail.get("decayTimeMs")) or 250.0
+ kick_confidence_raw = _safe_float(kick_detail.get("confidence"))
+
+ kick = sample_drums.synth_kick(
+ fundamental_hz=kick_fundamental,
+ decay_time_ms=kick_decay,
+ )
+ kick_path = output_dir / "drum_kick.wav"
+ sf.write(str(kick_path), kick.samples, kick.sample_rate, subtype="PCM_16")
+ artifact_paths["drum_kick"] = kick_path
+ samples.append(
+ _sample_record(
+ sample_id="drum_kick",
+ label=kick.label,
+ category="drums",
+ filename=kick_path.name,
+ midi_filename=None,
+ duration_seconds=kick.duration_seconds,
+ confidence=_confidence_band(kick_confidence_raw),
+ low_confidence=bool(
+ kick_confidence_raw is not None and kick_confidence_raw < 0.5
+ ),
+ phase1_fields=["kickDetail.fundamentalHz", "kickDetail.decayTimeMs"]
+ if kick_detail
+ else [],
+ phase2_recommendations=_phase2_kick_citations(phase2),
+ rationale=(
+ f"Sub-sine at {kick_fundamental:.0f} Hz with {kick_decay:.0f} ms "
+ "decay, derived from measured kickDetail."
+ if kick_detail
+ else "Default kick — Phase 1 did not surface kickDetail."
+ ),
+ )
+ )
+
+ snare = sample_drums.synth_snare()
+ snare_path = output_dir / "drum_snare.wav"
+ sf.write(str(snare_path), snare.samples, snare.sample_rate, subtype="PCM_16")
+ artifact_paths["drum_snare"] = snare_path
+ samples.append(
+ _sample_record(
+ sample_id="drum_snare",
+ label=snare.label,
+ category="drums",
+ filename=snare_path.name,
+ midi_filename=None,
+ duration_seconds=snare.duration_seconds,
+ confidence="LOW",
+ low_confidence=True,
+ phase1_fields=[],
+ phase2_recommendations=[],
+ rationale=(
+ "Heuristic snare — Phase 1 does not measure a snare fundamental. "
+ "Provided for kit completeness; do not treat as measurement-grounded."
+ ),
+ )
+ )
+
+ hat = sample_drums.synth_hat()
+ hat_path = output_dir / "drum_hat.wav"
+ sf.write(str(hat_path), hat.samples, hat.sample_rate, subtype="PCM_16")
+ artifact_paths["drum_hat"] = hat_path
+ samples.append(
+ _sample_record(
+ sample_id="drum_hat",
+ label=hat.label,
+ category="drums",
+ filename=hat_path.name,
+ midi_filename=None,
+ duration_seconds=hat.duration_seconds,
+ confidence="LOW",
+ low_confidence=True,
+ phase1_fields=[],
+ phase2_recommendations=[],
+ rationale=(
+ "Heuristic closed hi-hat — Phase 1 does not measure a hat. "
+ "Provided for kit completeness; do not treat as measurement-grounded."
+ ),
+ )
+ )
+
+ # --- Melody lead phrase ------------------------------------------------ #
+ if ctx is not None:
+ melody_detail = _safe_get(phase1, "melodyDetail")
+ melody_plan = sample_theory.plan_melody_phrase(
+ ctx, scale_degrees=pitch_note_hints, bars=4
+ )
+ if melody_plan is not None:
+ melody_result = sample_synthesis.render_clip(
+ melody_plan, prefer_fluidsynth=prefer_fluidsynth
+ )
+ # See bass-render note above — `selected_backend` is pinned at the
+ # first chord render and `render_clip` never returns falsy.
+ melody_wav = output_dir / "melody_lead.wav"
+ melody_mid = output_dir / "melody_lead.mid"
+ sample_synthesis.write_wav(melody_result.samples, path=melody_wav)
+ sample_synthesis.write_midi(melody_plan, path=melody_mid)
+ artifact_paths["melody_lead"] = melody_wav
+ midi_paths["melody_lead"] = melody_mid
+
+ phase1_fields = ["key", "keyConfidence", "bpm"]
+ if melody_detail:
+ phase1_fields.append("melodyDetail")
+ hints_used = pitch_note_hints is not None and len(pitch_note_hints) > 0
+ samples.append(
+ _sample_record(
+ sample_id="melody_lead",
+ label="Lead phrase in detected key",
+ category="melody",
+ filename=melody_wav.name,
+ midi_filename=melody_mid.name,
+ duration_seconds=melody_result.duration_seconds,
+ confidence=_confidence_band(key_confidence),
+ low_confidence=bool(
+ key_confidence is not None and key_confidence < 0.5
+ ),
+ phase1_fields=phase1_fields,
+ phase2_recommendations=[],
+ rationale=(
+ "Scale-degree sequence from pitch/note translation hints, "
+ f"rendered on a square-lead voice in {ctx.root_name} {ctx.mode}."
+ if hints_used
+ else (
+ f"Default 1-2-3-5 ascent in {ctx.root_name} {ctx.mode} — "
+ "no pitch/note translation hints available."
+ )
+ ),
+ )
+ )
+
+ manifest: dict[str, Any] = {
+ "schemaVersion": SCHEMA_VERSION,
+ "runId": run_id,
+ "generatedAt": datetime.now(UTC).isoformat(),
+ "synthesisBackend": selected_backend or "sine_fallback",
+ "soundfont": selected_soundfont,
+ "framing": FRAMING_NOTE,
+ "theoryBackend": (
+ "pytheory" if sample_theory.pytheory_available() else "fallback"
+ ),
+ "samples": samples,
+ }
+ manifest_path = output_dir / "samples_manifest.json"
+ manifest_path.write_text(json.dumps(manifest, indent=2))
+
+ return GenerationResult(
+ manifest=manifest,
+ artifact_paths=artifact_paths,
+ midi_paths=midi_paths,
+ manifest_path=manifest_path,
+ )
+
+
+# --- Helpers ---------------------------------------------------------------- #
+
+
+def _sample_record(
+ *,
+ sample_id: str,
+ label: str,
+ category: str,
+ filename: str,
+ midi_filename: str | None,
+ duration_seconds: float,
+ confidence: str,
+ low_confidence: bool,
+ phase1_fields: list[str],
+ phase2_recommendations: list[str],
+ rationale: str,
+) -> dict[str, Any]:
+ record: dict[str, Any] = {
+ "id": sample_id,
+ "label": label,
+ "category": category,
+ "filename": filename,
+ "mimeType": "audio/wav",
+ "durationSeconds": round(duration_seconds, 3),
+ "confidence": confidence,
+ "lowConfidence": low_confidence,
+ "cites": {
+ "phase1Fields": phase1_fields,
+ "phase2Recommendations": phase2_recommendations,
+ "rationale": rationale,
+ },
+ }
+ if midi_filename is not None:
+ record["midiFilename"] = midi_filename
+ return record
+
+
+def _safe_get(payload: dict[str, Any] | None, key: str) -> Any:
+ if not isinstance(payload, dict):
+ return None
+ return payload.get(key)
+
+
+def _safe_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return None
+ if result != result: # NaN check
+ return None
+ return result
+
+
+def _confidence_band(value: float | None) -> str:
+ if value is None:
+ return "MED"
+ if value >= 0.75:
+ return "HIGH"
+ if value >= 0.5:
+ return "MED"
+ return "LOW"
+
+
+def _format_confidence(value: float | None) -> str:
+ return "n/a" if value is None else f"{value:.2f}"
+
+
+def _compose_chord_label(
+ ctx: sample_theory.TheoryContext, low_confidence: bool
+) -> str:
+ base = f"Chord progression in {ctx.root_name} {ctx.mode}"
+ return f"{base} (low-confidence key)" if low_confidence else base
+
+
+def _phase2_tonal_citations(phase2: dict[str, Any] | None) -> list[str]:
+ if not isinstance(phase2, dict):
+ return []
+ cites: list[str] = []
+ if phase2.get("styleProfile"):
+ cites.append("styleProfile.authoritativeMeasurements.key")
+ if isinstance(phase2.get("sonicElements"), dict):
+ if phase2["sonicElements"].get("harmonicContent"):
+ cites.append("sonicElements.harmonicContent")
+ return cites
+
+
+def _phase2_kick_citations(phase2: dict[str, Any] | None) -> list[str]:
+ if not isinstance(phase2, dict):
+ return []
+ sonic = phase2.get("sonicElements")
+ if isinstance(sonic, dict) and sonic.get("kick"):
+ return ["sonicElements.kick"]
+ return []
diff --git a/apps/backend/sample_synthesis.py b/apps/backend/sample_synthesis.py
new file mode 100644
index 00000000..b81a1547
--- /dev/null
+++ b/apps/backend/sample_synthesis.py
@@ -0,0 +1,288 @@
+"""MIDI plan → WAV rendering for audition samples.
+
+Two render paths:
+
+1. **FluidSynth** (`pyfluidsynth` + a General MIDI soundfont) — high-quality.
+ Used when both the Python binding and a soundfont file are available.
+2. **Sine-additive fallback** — pure NumPy. Always available; in-tune; raw.
+
+Both paths produce the same float32 mono 44.1 kHz numpy array, so callers
+don't need to branch on which backend was selected. The selected backend is
+recorded on `RenderResult.backend` so it can flow into the manifest.
+
+MIDI artifacts are emitted via `pretty_midi`, which is already a hard dep, so
+the user can drop a `.mid` into Ableton even if the audio render is rough.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+import numpy as np
+import pretty_midi # type: ignore[import-untyped]
+import soundfile as sf
+
+from sample_theory import ClipPlan
+
+logger = logging.getLogger(__name__)
+
+SAMPLE_RATE = 44_100
+
+# --- FluidSynth probe ------------------------------------------------------- #
+
+try: # pragma: no cover - import probe
+ import fluidsynth # type: ignore[import-untyped]
+
+ _FLUIDSYNTH_IMPORTABLE = True
+except Exception as exc: # pragma: no cover - exercised when pyfluidsynth absent
+ fluidsynth = None
+ _FLUIDSYNTH_IMPORTABLE = False
+ logger.info("pyfluidsynth not importable (%s); using sine fallback.", exc)
+
+
+_DEFAULT_SOUNDFONT_CANDIDATES: tuple[Path, ...] = (
+ Path(__file__).parent / "assets" / "soundfonts" / "default.sf2",
+ Path("/usr/share/sounds/sf2/FluidR3_GM.sf2"),
+ Path("/usr/share/sounds/sf2/default-GM.sf2"),
+ Path("/usr/share/sounds/sf3/default-GM.sf3"),
+ Path("/opt/homebrew/share/fluid-synth/sf2/FluidR3_GM.sf2"),
+)
+
+
+Backend = Literal["fluidsynth", "sine_fallback"]
+
+
+@dataclass(frozen=True)
+class RenderResult:
+ samples: np.ndarray # float32 mono, range ~[-1, 1]
+ sample_rate: int
+ backend: Backend
+ soundfont_path: str | None
+ duration_seconds: float
+
+
+def locate_soundfont(explicit: Path | str | None = None) -> Path | None:
+ """Resolve a soundfont path from explicit input → env var → known locations."""
+ if explicit is not None:
+ path = Path(explicit)
+ return path if path.is_file() else None
+ env_path = os.environ.get("SONIC_ANALYZER_SOUNDFONT")
+ if env_path and Path(env_path).is_file():
+ return Path(env_path)
+ for candidate in _DEFAULT_SOUNDFONT_CANDIDATES:
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def render_clip(
+ plan: ClipPlan,
+ *,
+ soundfont: Path | str | None = None,
+ prefer_fluidsynth: bool = True,
+) -> RenderResult:
+ """Render a ClipPlan to an in-memory float32 audio buffer.
+
+ Picks the best available backend. Caller can force the fallback by passing
+ `prefer_fluidsynth=False`; tests use that to exercise the fallback path
+ even when FluidSynth happens to be installed.
+ """
+ if prefer_fluidsynth and _FLUIDSYNTH_IMPORTABLE:
+ sf_path = locate_soundfont(soundfont)
+ if sf_path is not None:
+ try:
+ return _render_with_fluidsynth(plan, sf_path)
+ except Exception as exc: # pragma: no cover - hard to trigger in CI
+ logger.warning(
+ "FluidSynth render failed (%s); falling back to sine synth.", exc
+ )
+ return _render_with_sine_fallback(plan)
+
+
+def write_wav(samples: np.ndarray, *, path: Path, sample_rate: int = SAMPLE_RATE) -> None:
+ """Write float32 samples to a 16-bit PCM WAV at the conventional bit depth."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ clipped = np.clip(samples, -1.0, 1.0).astype(np.float32)
+ sf.write(str(path), clipped, sample_rate, subtype="PCM_16")
+
+
+def write_midi(plan: ClipPlan, *, path: Path) -> None:
+ """Emit a MIDI file from a ClipPlan so users can audition in Ableton."""
+ pm = pretty_midi.PrettyMIDI(initial_tempo=plan.tempo_bpm)
+ inst = pretty_midi.Instrument(program=plan.program)
+ beats_per_second = plan.tempo_bpm / 60.0
+ for note in plan.notes:
+ start_seconds = note.start_beat / beats_per_second
+ end_seconds = (note.start_beat + note.duration_beats) / beats_per_second
+ inst.notes.append(
+ pretty_midi.Note(
+ velocity=int(np.clip(note.velocity, 1, 127)),
+ pitch=int(np.clip(note.pitch_midi, 0, 127)),
+ start=float(start_seconds),
+ end=float(end_seconds),
+ )
+ )
+ pm.instruments.append(inst)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ pm.write(str(path))
+
+
+# --- FluidSynth path -------------------------------------------------------- #
+
+
+def _render_with_fluidsynth(plan: ClipPlan, soundfont_path: Path) -> RenderResult: # pragma: no cover - exercised only when FluidSynth + a soundfont are available locally
+ """Offline-render a ClipPlan via pyfluidsynth.
+
+ We bypass an audio driver entirely (`driver=None`) and use `get_samples()`
+ to pull rendered audio out in fixed-size blocks. That keeps the call site
+ headless-server-safe.
+ """
+ if fluidsynth is None:
+ raise RuntimeError("pyfluidsynth missing despite probe passing")
+
+ seconds_total = (plan.duration_beats / plan.tempo_bpm) * 60.0
+ total_samples = int(round(SAMPLE_RATE * seconds_total))
+
+ synth = fluidsynth.Synth(samplerate=float(SAMPLE_RATE))
+ sfid = synth.sfload(str(soundfont_path))
+ # Channel 0, bank 0, preset = clip's GM program.
+ synth.program_select(0, sfid, 0, plan.program)
+
+ # Build an event timeline: each note becomes a noteon and a noteoff at the
+ # right sample index. Sorted so we can iterate forward.
+ beats_per_second = plan.tempo_bpm / 60.0
+ events: list[tuple[int, str, int, int]] = []
+ for note in plan.notes:
+ on_sample = int(round(note.start_beat / beats_per_second * SAMPLE_RATE))
+ off_sample = int(
+ round(
+ (note.start_beat + note.duration_beats) / beats_per_second * SAMPLE_RATE
+ )
+ )
+ events.append((on_sample, "on", note.pitch_midi, int(note.velocity)))
+ events.append((off_sample, "off", note.pitch_midi, 0))
+ events.sort(key=lambda e: (e[0], 0 if e[1] == "off" else 1))
+
+ block = 1024
+ buffer = np.zeros(total_samples, dtype=np.float32)
+ event_idx = 0
+ cursor = 0
+ while cursor < total_samples:
+ while event_idx < len(events) and events[event_idx][0] <= cursor:
+ _, kind, pitch, velocity = events[event_idx]
+ if kind == "on":
+ synth.noteon(0, pitch, velocity)
+ else:
+ synth.noteoff(0, pitch)
+ event_idx += 1
+ chunk_size = min(block, total_samples - cursor)
+ # get_samples returns interleaved stereo int16. Mix to mono float32.
+ raw = synth.get_samples(chunk_size)
+ stereo = np.asarray(raw, dtype=np.int16).reshape(-1, 2)
+ mono = stereo.mean(axis=1).astype(np.float32) / 32768.0
+ buffer[cursor : cursor + chunk_size] = mono[:chunk_size]
+ cursor += chunk_size
+
+ synth.delete()
+ return RenderResult(
+ samples=buffer,
+ sample_rate=SAMPLE_RATE,
+ backend="fluidsynth",
+ soundfont_path=str(soundfont_path),
+ duration_seconds=seconds_total,
+ )
+
+
+# --- Sine fallback ---------------------------------------------------------- #
+
+
+def _render_with_sine_fallback(plan: ClipPlan) -> RenderResult:
+ """Sine-additive synth with a simple ADSR envelope.
+
+ Three harmonics (1f, 2f, 3f) with decreasing amplitude give a slightly
+ less plain sound than a pure sine, without straying into territory we'd
+ have to defend musically.
+ """
+ seconds_total = (plan.duration_beats / plan.tempo_bpm) * 60.0
+ total_samples = int(round(SAMPLE_RATE * seconds_total))
+ buffer = np.zeros(total_samples, dtype=np.float64)
+
+ beats_per_second = plan.tempo_bpm / 60.0
+ for note in plan.notes:
+ start_sample = int(round(note.start_beat / beats_per_second * SAMPLE_RATE))
+ note_samples = int(
+ round(note.duration_beats / beats_per_second * SAMPLE_RATE)
+ )
+ if note_samples <= 0:
+ continue
+ end_sample = min(start_sample + note_samples, total_samples)
+ if start_sample >= total_samples:
+ continue
+
+ actual_samples = end_sample - start_sample
+ t = np.arange(actual_samples, dtype=np.float64) / SAMPLE_RATE
+ freq = 440.0 * (2.0 ** ((note.pitch_midi - 69) / 12.0))
+
+ voice = (
+ np.sin(2.0 * np.pi * freq * t) * 0.6
+ + np.sin(2.0 * np.pi * (2.0 * freq) * t) * 0.25
+ + np.sin(2.0 * np.pi * (3.0 * freq) * t) * 0.12
+ )
+ envelope = _adsr_envelope(actual_samples, sample_rate=SAMPLE_RATE)
+ velocity_scale = note.velocity / 127.0
+ buffer[start_sample:end_sample] += voice * envelope * velocity_scale
+
+ # Soft normalization to leave headroom.
+ peak = float(np.max(np.abs(buffer))) if buffer.size else 0.0
+ if peak > 1e-9:
+ buffer *= 0.8 / peak
+
+ return RenderResult(
+ samples=buffer.astype(np.float32),
+ sample_rate=SAMPLE_RATE,
+ backend="sine_fallback",
+ soundfont_path=None,
+ duration_seconds=seconds_total,
+ )
+
+
+def _adsr_envelope(
+ num_samples: int,
+ *,
+ sample_rate: int,
+ attack_s: float = 0.01,
+ decay_s: float = 0.04,
+ sustain_level: float = 0.7,
+ release_s: float = 0.12,
+) -> np.ndarray:
+ """Standard 4-stage envelope. Length matches the note; trims if too short."""
+ env = np.ones(num_samples, dtype=np.float64) * sustain_level
+
+ attack_samples = min(int(attack_s * sample_rate), num_samples)
+ if attack_samples > 0:
+ env[:attack_samples] = np.linspace(0.0, 1.0, attack_samples, dtype=np.float64)
+
+ decay_start = attack_samples
+ decay_samples = min(int(decay_s * sample_rate), num_samples - decay_start)
+ if decay_samples > 0:
+ env[decay_start : decay_start + decay_samples] = np.linspace(
+ 1.0, sustain_level, decay_samples, dtype=np.float64
+ )
+
+ release_samples = min(int(release_s * sample_rate), num_samples)
+ if release_samples > 0:
+ env[-release_samples:] *= np.linspace(
+ 1.0, 0.0, release_samples, dtype=np.float64
+ )
+ return env
+
+
+def fluidsynth_available() -> bool:
+ """True iff both the python binding and a soundfont file are reachable."""
+ if not _FLUIDSYNTH_IMPORTABLE:
+ return False
+ return locate_soundfont() is not None
diff --git a/apps/backend/sample_theory.py b/apps/backend/sample_theory.py
new file mode 100644
index 00000000..9dd785d6
--- /dev/null
+++ b/apps/backend/sample_theory.py
@@ -0,0 +1,337 @@
+"""Music-theory adapter for Phase 3 audition sample generation.
+
+Takes Phase 1 measurement output (key, bpm, optional melody/stem hints) and
+produces structured MIDI plans that downstream synthesis can render to audio.
+
+Primary path uses PyTheory (https://github.com/kennethreitz/pytheory). When
+that import fails we fall back to a self-contained Western music-theory
+implementation so the audition feature remains functional in lean environments
+and so tests do not depend on pytheory being importable.
+
+Contract: both paths return the same dataclass shapes with the same MIDI note
+numbers for the same input. Test coverage exercises both paths.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass, field
+from typing import Literal
+
+logger = logging.getLogger(__name__)
+
+# --- Pytheory probe (graceful, no-op if absent) ----------------------------- #
+
+try: # pragma: no cover - import probe; behavior covered by both branches
+ import pytheory # type: ignore[import-untyped]
+
+ _PYTHEORY_AVAILABLE = True
+except Exception as exc: # pragma: no cover - exercised when pytheory absent
+ pytheory = None
+ _PYTHEORY_AVAILABLE = False
+ logger.info("pytheory not importable (%s); using pure-Python fallback.", exc)
+
+
+def pytheory_available() -> bool:
+ """Expose probe so callers and tests can branch deterministically."""
+ return _PYTHEORY_AVAILABLE
+
+
+# --- Pitch-class constants -------------------------------------------------- #
+
+_PITCH_CLASS: dict[str, int] = {
+ "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
+ "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8,
+ "A": 9, "A#": 10, "Bb": 10, "B": 11,
+}
+
+# Intervals are semitones from tonic. Restricted to the modes Phase 1 actually
+# emits (major / minor) plus the common modal options the melody detector may
+# surface. Add more if/when needed.
+_SCALE_INTERVALS: dict[str, tuple[int, ...]] = {
+ "major": (0, 2, 4, 5, 7, 9, 11),
+ "minor": (0, 2, 3, 5, 7, 8, 10),
+ "dorian": (0, 2, 3, 5, 7, 9, 10),
+ "phrygian": (0, 1, 3, 5, 7, 8, 10),
+ "lydian": (0, 2, 4, 6, 7, 9, 11),
+ "mixolydian": (0, 2, 4, 5, 7, 9, 10),
+}
+
+# Diatonic chord progressions in scale-degree notation. We pick genre-neutral
+# loops that survive being played as plain triads; the audition is not the
+# place to be clever with secondary dominants.
+_DIATONIC_PROGRESSIONS: dict[str, tuple[int, ...]] = {
+ "major": (1, 6, 4, 5), # I vi IV V
+ "minor": (1, 6, 7, 5), # i VI VII V
+}
+
+
+Mode = Literal["major", "minor", "dorian", "phrygian", "lydian", "mixolydian"]
+
+
+@dataclass(frozen=True)
+class NoteEvent:
+ """A single MIDI-style note event with absolute beat timing."""
+
+ pitch_midi: int
+ start_beat: float
+ duration_beats: float
+ velocity: int = 96
+
+
+@dataclass(frozen=True)
+class ClipPlan:
+ """Render-ready plan: a sequence of note events at a known tempo."""
+
+ tempo_bpm: float
+ duration_beats: float
+ notes: list[NoteEvent]
+ program: int = 0 # General MIDI program number (0 = Acoustic Grand Piano)
+
+
+@dataclass(frozen=True)
+class TheoryContext:
+ """Parsed key + tempo, plus the source-of-truth confidence carried through."""
+
+ root_pc: int
+ mode: Mode
+ root_name: str # canonical "F#" / "Bb" — what we display
+ tempo_bpm: float
+ key_confidence: float | None
+ backend: Literal["pytheory", "fallback"]
+
+
+# --- Public API ------------------------------------------------------------- #
+
+
+def parse_key(key_string: str) -> tuple[int, Mode, str]:
+ """Parse a Phase 1-style key string into (pitch_class, mode, display_root).
+
+ Accepts inputs like "F# minor", "C major", "Bb dorian", or just "C"
+ (mode defaults to major to match the Phase 1 convention).
+ """
+ if not key_string or not isinstance(key_string, str):
+ raise ValueError(f"empty or non-string key: {key_string!r}")
+
+ cleaned = key_string.strip()
+ # Tolerate trailing qualifiers like "(uncertain)"; strip parenthetical noise.
+ cleaned = re.sub(r"\s*\(.*?\)\s*$", "", cleaned).strip()
+ if not cleaned:
+ raise ValueError(f"empty key after cleaning: {key_string!r}")
+
+ parts = cleaned.split()
+ root_token = parts[0]
+ mode_token = parts[1].lower() if len(parts) > 1 else "major"
+
+ # Normalize root: "f#" -> "F#"; reject unknown roots loudly so the caller
+ # can degrade gracefully rather than silently mis-tuning.
+ normalized = root_token[:1].upper() + root_token[1:]
+ if normalized not in _PITCH_CLASS:
+ raise ValueError(f"unrecognized root note: {root_token!r} in {key_string!r}")
+
+ if mode_token not in _SCALE_INTERVALS:
+ # Phase 1 occasionally emits "Minor" capitalized or "min"/"maj" abbreviations.
+ # Map a few common variants before giving up.
+ mode_token = {"min": "minor", "maj": "major"}.get(mode_token, mode_token)
+ if mode_token not in _SCALE_INTERVALS:
+ raise ValueError(f"unsupported mode: {mode_token!r} in {key_string!r}")
+
+ return _PITCH_CLASS[normalized], mode_token, normalized # type: ignore[return-value]
+
+
+def build_context(
+ *, key: str, bpm: float, key_confidence: float | None = None
+) -> TheoryContext:
+ """Build a TheoryContext from Phase 1 outputs."""
+ root_pc, mode, display = parse_key(key)
+ backend: Literal["pytheory", "fallback"] = (
+ "pytheory" if _PYTHEORY_AVAILABLE else "fallback"
+ )
+ return TheoryContext(
+ root_pc=root_pc,
+ mode=mode,
+ root_name=display,
+ tempo_bpm=float(bpm),
+ key_confidence=key_confidence,
+ backend=backend,
+ )
+
+
+def plan_chord_progression(
+ ctx: TheoryContext, *, bars: int = 8, voicing_octave: int = 4
+) -> ClipPlan:
+ """Build a diatonic chord-progression plan for audition.
+
+ Two bars per chord; we cycle through a 4-chord loop. At 8 bars that's two
+ full repeats. Voicing places the chord root near the requested octave.
+ """
+ if bars < 2 or bars % 2 != 0:
+ raise ValueError(f"bars must be an even integer >= 2; got {bars}")
+
+ # The diatonic progression dict only covers strict major/minor today.
+ # For other modes, treat them as their parent (major-ish or minor-ish).
+ progression_key = "major" if ctx.mode in {"major", "lydian", "mixolydian"} else "minor"
+ degrees = _DIATONIC_PROGRESSIONS[progression_key]
+
+ notes: list[NoteEvent] = []
+ beats_per_chord = 8.0 # two 4/4 bars per chord
+ for chord_index in range(bars // 2):
+ degree = degrees[chord_index % len(degrees)]
+ chord_pitches = _diatonic_triad_midi(
+ ctx.root_pc, ctx.mode, degree, base_octave=voicing_octave
+ )
+ start = chord_index * beats_per_chord
+ for pitch in chord_pitches:
+ notes.append(
+ NoteEvent(
+ pitch_midi=pitch,
+ start_beat=start,
+ duration_beats=beats_per_chord,
+ velocity=88,
+ )
+ )
+
+ return ClipPlan(
+ tempo_bpm=ctx.tempo_bpm,
+ duration_beats=bars * 4.0,
+ notes=notes,
+ program=0, # Acoustic Grand Piano (GM #1, zero-indexed)
+ )
+
+
+def plan_bass_root(ctx: TheoryContext, *, bars: int = 8) -> ClipPlan:
+ """Sustained bass note on the tonic, two MIDI octaves below voicing range."""
+ bass_pitch = _midi_from_pc(ctx.root_pc, octave=2)
+ # Two-bar sustains; let the user actually hear pitch.
+ sustained_beats = 8.0
+ notes: list[NoteEvent] = []
+ for chord_index in range(bars // 2):
+ notes.append(
+ NoteEvent(
+ pitch_midi=bass_pitch,
+ start_beat=chord_index * sustained_beats,
+ duration_beats=sustained_beats,
+ velocity=100,
+ )
+ )
+ return ClipPlan(
+ tempo_bpm=ctx.tempo_bpm,
+ duration_beats=bars * 4.0,
+ notes=notes,
+ program=33, # Electric Bass (finger), GM #34
+ )
+
+
+def plan_melody_phrase(
+ ctx: TheoryContext,
+ *,
+ scale_degrees: list[int] | None = None,
+ bars: int = 4,
+) -> ClipPlan | None:
+ """Render a short lead phrase from scale-degree hints.
+
+ If no scale degrees are supplied we fabricate a simple ascent (1-2-3-5) so
+ the user at least hears the key context. Returns None if a sensible plan
+ can't be built (defensive — calling code will then omit the artifact).
+ """
+ if scale_degrees is None or not scale_degrees:
+ # Default ascent: tonic → 2nd → 3rd → 5th → 3rd. Familiar enough to be
+ # recognizable as "in the key" without sounding like a real melody.
+ scale_degrees = [1, 2, 3, 5, 3, 1]
+
+ # Reject obviously bad inputs rather than emitting garbage.
+ cleaned: list[int] = [d for d in scale_degrees if isinstance(d, int) and 1 <= d <= 7]
+ if not cleaned:
+ return None
+
+ notes: list[NoteEvent] = []
+ beats_per_note = (bars * 4.0) / max(len(cleaned), 1)
+ for index, degree in enumerate(cleaned):
+ pitch = _diatonic_scale_pitch(ctx.root_pc, ctx.mode, degree, octave=5)
+ notes.append(
+ NoteEvent(
+ pitch_midi=pitch,
+ start_beat=index * beats_per_note,
+ duration_beats=beats_per_note * 0.85,
+ velocity=92,
+ )
+ )
+ return ClipPlan(
+ tempo_bpm=ctx.tempo_bpm,
+ duration_beats=bars * 4.0,
+ notes=notes,
+ program=80, # Lead 1 (square), GM #81 — sounds synthetic, fits an EDM frame
+ )
+
+
+# --- Internal helpers ------------------------------------------------------- #
+
+
+def _midi_from_pc(pitch_class: int, *, octave: int) -> int:
+ """C4 = 60. Map a pitch class + octave number to a MIDI note."""
+ return (octave + 1) * 12 + (pitch_class % 12)
+
+
+def _diatonic_scale_pitch(
+ root_pc: int, mode: str, scale_degree: int, *, octave: int
+) -> int:
+ """1-indexed scale degree → MIDI note. Degree 8 wraps to next octave."""
+ intervals = _SCALE_INTERVALS[mode]
+ if scale_degree < 1:
+ raise ValueError(f"scale_degree must be >= 1; got {scale_degree}")
+ degree_index = (scale_degree - 1) % 7
+ octave_offset = (scale_degree - 1) // 7
+ semitones = intervals[degree_index] + 12 * octave_offset
+ return _midi_from_pc(root_pc, octave=octave) + semitones
+
+
+def _diatonic_triad_midi(
+ root_pc: int, mode: str, scale_degree: int, *, base_octave: int
+) -> tuple[int, int, int]:
+ """Stack thirds within the scale to build a triad on the given degree.
+
+ Non-tonic chords are dropped by an octave so the progression voice-leads
+ within a narrow range around the tonic rather than ascending forever. The
+ tonic stays at the requested `base_octave` and acts as the anchor.
+ """
+ intervals = _SCALE_INTERVALS[mode]
+ # Build a 14-note scale (two octaves) so stacking-by-third never wraps off.
+ extended = [intervals[i % 7] + 12 * (i // 7) for i in range(14)]
+ base_midi = _midi_from_pc(root_pc, octave=base_octave)
+ degree_index = (scale_degree - 1) % 7
+
+ root = base_midi + extended[degree_index]
+ third = base_midi + extended[degree_index + 2]
+ fifth = base_midi + extended[degree_index + 4]
+ if extended[degree_index] > 0:
+ # Anchor non-tonic chords below the tonic so vi / IV / V don't stack
+ # an octave above the I — that sounds wrong and obscures the cadence.
+ root -= 12
+ third -= 12
+ fifth -= 12
+ return root, third, fifth
+
+
+def cite_pytheory_if_used(ctx: TheoryContext) -> dict[str, object]:
+ """Optional helper: probe pytheory at runtime for a fingerprint we can log.
+
+ Best-effort. If pytheory is importable but exposes a different surface than
+ we expect, we don't fail — we just return what we know.
+ """
+ if not _PYTHEORY_AVAILABLE:
+ return {"backend": "fallback"}
+ info: dict[str, object] = {"backend": "pytheory"}
+ try: # pragma: no cover - tiny defensive probe
+ version = getattr(pytheory, "__version__", None)
+ if version:
+ info["version"] = str(version)
+ # PyTheory exposes a Tone class; use it as a soft sanity check that the
+ # library is healthy. We don't actually need its output — fallback
+ # tables are authoritative for MIDI numbers.
+ tone_cls = getattr(pytheory, "Tone", None)
+ if tone_cls is not None and hasattr(tone_cls, "from_string"):
+ info["toneClass"] = True
+ except Exception as exc: # pragma: no cover
+ info["probeError"] = str(exc)
+ return info
diff --git a/apps/backend/server.py b/apps/backend/server.py
index 67d10859..181576d1 100644
--- a/apps/backend/server.py
+++ b/apps/backend/server.py
@@ -143,6 +143,8 @@
_validate_phase2_semantics,
)
+import server_samples
+
app = FastAPI(title="Sonic Analyzer Local API")
@@ -2366,6 +2368,77 @@ async def get_run_source_audio(
)
+# ── Audition samples (Phase 3) ───────────────────────────────────────────────
+#
+# Heuristic reconstructions of the track's tonal foundation + drum kit, derived
+# from Phase 1 measurements (and enriched by Phase 2 when available). Used by
+# the UI to let producers ear-check the measurement chain. See
+# `docs/SAMPLE_GENERATION.md` for the chain-of-custody framing.
+
+@app.post("/api/analysis-runs/{run_id}/samples")
+async def create_run_samples(
+ run_id: str,
+ force: bool = Query(False, description="Regenerate even if a manifest exists"),
+ x_asa_user_id: str | None = Header(None),
+ x_asa_user_email: str | None = Header(None),
+) -> JSONResponse:
+ user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email)
+ if isinstance(user_context, JSONResponse):
+ return user_context
+ runtime = get_analysis_runtime()
+ try:
+ snapshot = runtime.get_run(run_id, owner_user_id=user_context.user_id)
+ except (KeyError, PermissionError):
+ return _run_not_found_response(run_id)
+
+ try:
+ manifest = await asyncio.to_thread(
+ server_samples.generate_and_register_samples,
+ runtime=runtime,
+ run_id=run_id,
+ snapshot=snapshot,
+ force=force,
+ )
+ except server_samples.SamplesPreconditionError as exc:
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"error": {"code": exc.code, "message": exc.message}},
+ )
+ return JSONResponse(status_code=201, content=manifest)
+
+
+@app.get("/api/analysis-runs/{run_id}/samples")
+async def get_run_samples(
+ run_id: str,
+ x_asa_user_id: str | None = Header(None),
+ x_asa_user_email: str | None = Header(None),
+) -> JSONResponse:
+ user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email)
+ if isinstance(user_context, JSONResponse):
+ return user_context
+ runtime = get_analysis_runtime()
+ try:
+ runtime.get_run(run_id, owner_user_id=user_context.user_id)
+ except (KeyError, PermissionError):
+ return _run_not_found_response(run_id)
+
+ manifest = server_samples.fetch_existing_manifest(runtime=runtime, run_id=run_id)
+ if manifest is None:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "error": {
+ "code": "SAMPLES_NOT_GENERATED",
+ "message": (
+ f"No audition samples have been generated for run '{run_id}'. "
+ "POST to this URL to create them."
+ ),
+ }
+ },
+ )
+ return JSONResponse(content=manifest)
+
+
@app.get(
"/api/analysis-runs/{run_id}/export/csv/{field_path}",
response_model=None,
@@ -2456,6 +2529,7 @@ async def export_run_field_as_csv(
"hpss": ("generate_hpss_spectrograms", ["spectrogram_harmonic", "spectrogram_percussive"], True),
"onset": ("generate_onset_enhancement", ["spectrogram_onset", "onset_strength"], True),
"chroma_interactive": ("generate_chroma_enhancement", ["spectrogram_chroma", "chroma_interactive"], True),
+ "reassigned": ("generate_reassigned_spectrogram", ["spectrogram_reassigned"], True),
}
diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py
index 401aa3f9..dee30da3 100644
--- a/apps/backend/server_phase1.py
+++ b/apps/backend/server_phase1.py
@@ -9,6 +9,7 @@
from analysis_runtime import AnalysisRuntime
from server_upload import ERROR_PHASE_LOCAL_DSP
+from stage_status import to_public_status
# ── Constants ────────────────────────────────────────────────────────────────
@@ -226,6 +227,30 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]:
}
+def _annotate_public_status(stages: dict[str, Any]) -> dict[str, Any]:
+ """Attach ``publicStatus`` to every stage whose ``status`` is set.
+
+ Returns a shallow-copy of ``stages`` with each stage dict updated.
+ The original ``status`` field is preserved untouched; ``publicStatus``
+ is the additive 5-state collapse documented in
+ :mod:`stage_status`.
+
+ Defensive against non-dict stage entries (e.g. nulls in legacy
+ snapshots) — those pass through unchanged.
+ """
+ annotated: dict[str, Any] = {}
+ for stage_name, stage_value in stages.items():
+ if not isinstance(stage_value, dict):
+ annotated[stage_name] = stage_value
+ continue
+ stage_copy = dict(stage_value)
+ # `status` may legitimately be absent on legacy/partial snapshots;
+ # to_public_status(None) yields None which we expose as null.
+ stage_copy["publicStatus"] = to_public_status(stage_copy.get("status"))
+ annotated[stage_name] = stage_copy
+ return annotated
+
+
def _normalize_run_snapshot(
snapshot: dict[str, Any], runtime: AnalysisRuntime | None = None
) -> dict[str, Any]:
@@ -236,22 +261,27 @@ def _normalize_run_snapshot(
_build_phase1 produces for the legacy /api/analyze endpoint — notably,
top-level stereoWidth/stereoCorrelation extracted from stereoDetail.
+ Each stage in ``stages`` is also annotated with a ``publicStatus`` field
+ that collapses the 8 internal stage statuses to 5 public ones (see
+ :mod:`stage_status`). The original ``status`` field is preserved; this
+ is purely additive.
+
When *runtime* is provided, spectral visualization artifacts (spectrogram
PNGs and time-series JSON) are attached under ``artifacts.spectral``.
"""
stages = snapshot.get("stages")
if not isinstance(stages, dict):
return snapshot
- measurement = stages.get("measurement")
- if not isinstance(measurement, dict):
- return snapshot
- raw_result = measurement.get("result")
- if not isinstance(raw_result, dict):
- return snapshot
snapshot = dict(snapshot)
- snapshot["stages"] = dict(stages)
- snapshot["stages"]["measurement"] = dict(measurement)
- snapshot["stages"]["measurement"]["result"] = _build_phase1(raw_result)
+ snapshot["stages"] = _annotate_public_status(stages)
+
+ measurement = snapshot["stages"].get("measurement")
+ if isinstance(measurement, dict):
+ raw_result = measurement.get("result")
+ if isinstance(raw_result, dict):
+ # Reuse the dict we already copied in _annotate_public_status;
+ # no need to clone twice.
+ measurement["result"] = _build_phase1(raw_result)
if runtime is not None:
run_id = snapshot.get("runId")
diff --git a/apps/backend/server_samples.py b/apps/backend/server_samples.py
new file mode 100644
index 00000000..a6e05c61
--- /dev/null
+++ b/apps/backend/server_samples.py
@@ -0,0 +1,217 @@
+"""Helpers for the Phase 3 audition-sample HTTP routes.
+
+The thin `@app.post / @app.get` wrappers live in `server.py` to match the
+existing house style. All business logic — payload extraction, generation,
+artifact registration, manifest decoration — lives here.
+
+These endpoints are on-demand: nothing in the staged-execution loop runs them
+automatically. The user (or UI) explicitly POSTs to
+`/api/analysis-runs/{run_id}/samples` after Phase 2 is complete.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import tempfile
+from pathlib import Path
+from typing import Any
+
+import sample_generation
+from analysis_runtime import AnalysisRuntime
+
+logger = logging.getLogger(__name__)
+
+SAMPLE_AUDIO_KIND_PREFIX = "sample_audio"
+SAMPLE_MIDI_KIND_PREFIX = "sample_midi"
+SAMPLE_MANIFEST_KIND = "sample_manifest"
+
+
+class SamplesPreconditionError(Exception):
+ """The run isn't in a state where samples can be generated yet."""
+
+ def __init__(self, code: str, message: str, status_code: int = 409):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.status_code = status_code
+
+
+def _extract_phase1_from_snapshot(snapshot: dict[str, Any]) -> dict[str, Any] | None:
+ stages = snapshot.get("stages") or {}
+ measurement = stages.get("measurement") or {}
+ if measurement.get("status") != "completed":
+ return None
+ result = measurement.get("result")
+ return result if isinstance(result, dict) else None
+
+
+def _extract_phase2_from_snapshot(snapshot: dict[str, Any]) -> dict[str, Any] | None:
+ """Pull the preferred Phase 2 result if one has completed.
+
+ Returns None if interpretation never ran or didn't complete — sample
+ generation still proceeds from Phase 1 alone in that case. Any non-
+ "completed" status reaches that path through the isinstance gate below,
+ since stages without a completed attempt carry a `null` result anyway.
+ """
+ stages = snapshot.get("stages") or {}
+ interpretation = stages.get("interpretation") or {}
+ if interpretation.get("status") != "completed":
+ return None
+ result = interpretation.get("result")
+ return result if isinstance(result, dict) else None
+
+
+def generate_and_register_samples(
+ *,
+ runtime: AnalysisRuntime,
+ run_id: str,
+ snapshot: dict[str, Any],
+ force: bool = False,
+ prefer_fluidsynth: bool = True,
+) -> dict[str, Any]:
+ """Run the orchestrator, persist artifacts, return a decorated manifest.
+
+ The decorated manifest is the same shape the orchestrator emits, with
+ each sample augmented by `artifactId` so the frontend can construct
+ download URLs without a second round-trip.
+ """
+ phase1 = _extract_phase1_from_snapshot(snapshot)
+ if phase1 is None:
+ raise SamplesPreconditionError(
+ code="MEASUREMENT_NOT_COMPLETED",
+ message=(
+ "Audition samples require a completed Phase 1 measurement. "
+ "Wait for the measurement stage to finish before requesting samples."
+ ),
+ status_code=409,
+ )
+
+ phase2 = _extract_phase2_from_snapshot(snapshot)
+
+ if not force:
+ existing = runtime.get_internal_artifacts_by_kind(run_id, SAMPLE_MANIFEST_KIND)
+ if existing:
+ raise SamplesPreconditionError(
+ code="SAMPLES_ALREADY_GENERATED",
+ message=(
+ "An audition-sample manifest already exists for this run. "
+ "Pass ?force=true to regenerate."
+ ),
+ status_code=409,
+ )
+
+ with tempfile.TemporaryDirectory(prefix=f"asa-samples-{run_id}-") as tmp_root:
+ tmp_dir = Path(tmp_root)
+ result = sample_generation.generate_samples(
+ run_id=run_id,
+ phase1=phase1,
+ phase2=phase2,
+ output_dir=tmp_dir,
+ pitch_note_hints=None, # Pitch/note translation hints are a follow-up.
+ prefer_fluidsynth=prefer_fluidsynth,
+ )
+
+ # Persist each WAV/MIDI as a run artifact. The artifact kind names the
+ # sample so the GET-by-kind endpoint can filter just sample artifacts
+ # without including spectral or stem outputs.
+ sample_artifact_ids: dict[str, str] = {}
+ midi_artifact_ids: dict[str, str] = {}
+ for sample_id, wav_path in result.artifact_paths.items():
+ record = runtime.record_artifact(
+ run_id,
+ kind=f"{SAMPLE_AUDIO_KIND_PREFIX}:{sample_id}",
+ source_path=str(wav_path),
+ filename=wav_path.name,
+ mime_type="audio/wav",
+ provenance={
+ "sampleId": sample_id,
+ "schemaVersion": result.manifest["schemaVersion"],
+ },
+ )
+ sample_artifact_ids[sample_id] = record["artifactId"]
+ for sample_id, mid_path in result.midi_paths.items():
+ record = runtime.record_artifact(
+ run_id,
+ kind=f"{SAMPLE_MIDI_KIND_PREFIX}:{sample_id}",
+ source_path=str(mid_path),
+ filename=mid_path.name,
+ mime_type="audio/midi",
+ provenance={
+ "sampleId": sample_id,
+ "schemaVersion": result.manifest["schemaVersion"],
+ },
+ )
+ midi_artifact_ids[sample_id] = record["artifactId"]
+
+ # And persist the manifest itself. Future GETs read this back to
+ # decorate the response identically.
+ manifest_record = runtime.record_artifact(
+ run_id,
+ kind=SAMPLE_MANIFEST_KIND,
+ source_path=str(result.manifest_path),
+ filename=result.manifest_path.name,
+ mime_type="application/json",
+ provenance={
+ "schemaVersion": result.manifest["schemaVersion"],
+ "sampleArtifactIds": sample_artifact_ids,
+ "midiArtifactIds": midi_artifact_ids,
+ },
+ )
+
+ decorated = _decorate_manifest(
+ result.manifest, sample_artifact_ids, midi_artifact_ids
+ )
+ decorated["manifestArtifactId"] = manifest_record["artifactId"]
+ return decorated
+
+
+def fetch_existing_manifest(
+ *,
+ runtime: AnalysisRuntime,
+ run_id: str,
+) -> dict[str, Any] | None:
+ """Reconstruct the decorated manifest from previously-persisted artifacts.
+
+ Returns None if no manifest has been generated yet — caller can decide
+ whether that's a 404 or a different signal.
+ """
+ manifests = runtime.get_internal_artifacts_by_kind(run_id, SAMPLE_MANIFEST_KIND)
+ if not manifests:
+ return None
+ # get_internal_artifacts_by_kind returns rows ordered by created_at ASC,
+ # so the latest is the tail. (force=true paths create multiple rows.)
+ latest = manifests[-1]
+
+ manifest_path = runtime.resolve_artifact_local_path(latest.get("path"))
+ if manifest_path is None or not manifest_path.is_file():
+ return None
+ raw = json.loads(manifest_path.read_text())
+ provenance = latest.get("provenance") or {}
+ decorated = _decorate_manifest(
+ raw,
+ provenance.get("sampleArtifactIds") or {},
+ provenance.get("midiArtifactIds") or {},
+ )
+ decorated["manifestArtifactId"] = latest["artifactId"]
+ return decorated
+
+
+def _decorate_manifest(
+ manifest: dict[str, Any],
+ sample_artifact_ids: dict[str, str],
+ midi_artifact_ids: dict[str, str],
+) -> dict[str, Any]:
+ """Attach artifactId fields to each sample so the UI can build URLs."""
+ decorated = dict(manifest)
+ decorated_samples: list[dict[str, Any]] = []
+ for sample in manifest.get("samples", []):
+ copy = dict(sample)
+ sample_id = sample.get("id")
+ if sample_id in sample_artifact_ids:
+ copy["artifactId"] = sample_artifact_ids[sample_id]
+ if sample_id in midi_artifact_ids:
+ copy["midiArtifactId"] = midi_artifact_ids[sample_id]
+ decorated_samples.append(copy)
+ decorated["samples"] = decorated_samples
+ return decorated
diff --git a/apps/backend/spectral_viz.py b/apps/backend/spectral_viz.py
index e1df6010..942e5589 100644
--- a/apps/backend/spectral_viz.py
+++ b/apps/backend/spectral_viz.py
@@ -426,6 +426,130 @@ def generate_onset_enhancement(
return results
+# Reassigned-spectrogram parameters. The magnitude floor controls how much
+# of the low-energy "noise" is dropped before rendering — too aggressive
+# and you lose detail; too lenient and the scatter plot turns into a wash.
+# -60 dBFS below peak is a generous starting point that preserves
+# attack transients and harmonic detail while hiding the floor.
+REASSIGNED_MAG_FLOOR_DB = -60.0
+
+# Render cap on scatter points. Each STFT cell becomes one scatter point,
+# so the raw count is ``(1 + n_fft/2) * n_frames``: ~8M points on a
+# 3-minute track and 15M+ on a 10-minute track at the defaults. Matplotlib's
+# scatter renderer takes seconds-to-tens-of-seconds at those sizes, well
+# above the librosa compute cost. 300K is a visual sweet spot — the PNG
+# still looks sharp at the default ~1200×400 px figure but renders in
+# well under a second.
+REASSIGNED_MAX_SCATTER_POINTS = 300_000
+
+
+def generate_reassigned_spectrogram(
+ audio_path: str,
+ output_dir: str,
+ *,
+ sr: int = DEFAULT_SR,
+ n_fft: int = DEFAULT_N_FFT,
+ hop_length: int = DEFAULT_HOP_LENGTH,
+) -> dict[str, str]:
+ """Generate a reassigned spectrogram PNG for sharper time/frequency localization.
+
+ Unlike :func:`generate_spectrograms` (mel STFT on a uniform grid),
+ reassignment relocates each STFT bin to the local centroid of energy
+ in the (time, frequency) plane. Transients land at their true onset
+ instead of being smeared across a window; stable partials collapse
+ to sharp horizontal lines instead of fuzzy ridges.
+
+ Algorithm: :func:`librosa.reassigned_spectrogram` returns three
+ arrays of shape ``(1 + n_fft/2, n_frames)`` — the per-bin reassigned
+ frequencies, per-bin reassigned times, and per-bin magnitudes.
+ Because the (time, freq) coordinates are no longer a uniform grid,
+ we render via scatter rather than :func:`librosa.display.specshow`
+ (which would re-rasterize to a grid and undo the sharpening).
+
+ Returns a dict mapping artifact kind to the output file path::
+
+ {"spectrogram_reassigned": "/path/to/reassigned.png"}
+ """
+ import matplotlib.figure as mpl_figure
+
+ y = _load_audio(audio_path, sr=sr)
+ out = Path(output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+
+ # librosa returns (freqs, times, mags). Shapes are (1 + n_fft//2,
+ # n_frames). `fill_nan=True` substitutes the original STFT grid
+ # coordinate when reassignment is unstable (e.g. silent regions);
+ # without it, NaNs would propagate into the scatter and the plot
+ # would silently lose those points.
+ freqs, times, mags = librosa.reassigned_spectrogram(
+ y=y,
+ sr=sr,
+ n_fft=n_fft,
+ hop_length=hop_length,
+ reassign_frequencies=True,
+ reassign_times=True,
+ fill_nan=True,
+ )
+ mags_db = librosa.amplitude_to_db(np.abs(mags), ref=np.max)
+
+ # Flatten and mask to finite values above the magnitude floor.
+ flat_times = times.ravel()
+ flat_freqs = freqs.ravel()
+ flat_mags_db = mags_db.ravel()
+ mask = (
+ np.isfinite(flat_times)
+ & np.isfinite(flat_freqs)
+ & np.isfinite(flat_mags_db)
+ & (flat_mags_db >= REASSIGNED_MAG_FLOOR_DB)
+ )
+ times_visible = flat_times[mask]
+ freqs_visible = flat_freqs[mask]
+ mags_visible = flat_mags_db[mask]
+
+ # Cap the number of scatter points to keep matplotlib's render time
+ # well under a second on long inputs. Subsampling is deterministic
+ # (seeded RNG) so re-running the enhancement on the same audio
+ # produces a byte-identical PNG — matters for idempotency tests and
+ # content-hash artifact caching.
+ if times_visible.size > REASSIGNED_MAX_SCATTER_POINTS:
+ rng = np.random.default_rng(0)
+ idx = rng.choice(
+ times_visible.size,
+ size=REASSIGNED_MAX_SCATTER_POINTS,
+ replace=False,
+ )
+ times_visible = times_visible[idx]
+ freqs_visible = freqs_visible[idx]
+ mags_visible = mags_visible[idx]
+
+ fig = mpl_figure.Figure(figsize=(FIG_WIDTH_INCHES, FIG_HEIGHT_INCHES), dpi=FIG_DPI)
+ ax = fig.add_axes((0, 0, 1, 1))
+ ax.set_axis_off()
+ if times_visible.size > 0:
+ ax.scatter(
+ times_visible,
+ freqs_visible,
+ c=mags_visible,
+ cmap="magma",
+ s=0.5,
+ marker=",",
+ alpha=0.6,
+ linewidths=0,
+ vmin=REASSIGNED_MAG_FLOOR_DB,
+ vmax=0.0,
+ )
+ # Axes limits: lock to the input duration and Nyquist so the PNG
+ # has consistent geometry regardless of how many points cleared
+ # the floor.
+ ax.set_xlim(0.0, max(1.0 / sr, len(y) / float(sr)))
+ ax.set_ylim(0.0, sr / 2.0)
+ out_path = out / "reassigned_spectrogram.png"
+ fig.savefig(str(out_path), dpi=FIG_DPI, bbox_inches="tight", pad_inches=0)
+ fig.clear()
+
+ return {"spectrogram_reassigned": str(out_path)}
+
+
def generate_all_artifacts(
audio_path: str,
output_dir: str | None = None,
diff --git a/apps/backend/stage_status.py b/apps/backend/stage_status.py
new file mode 100644
index 00000000..eea88e0c
--- /dev/null
+++ b/apps/backend/stage_status.py
@@ -0,0 +1,88 @@
+"""Public-facing collapse of the internal stage-status state machine.
+
+ASA's run-state machine has eight internal statuses
+(``queued``, ``running``, ``blocked``, ``ready``, ``completed``,
+``failed``, ``interrupted``, ``not_requested``). Most of these are
+runtime-scheduling concerns the client does not need to act on:
+
+- ``blocked`` and ``ready`` are transient states between "waiting on
+ a dependency" and "scheduled to run." Both look identical to a
+ client polling the snapshot — there's nothing to display or do
+ differently.
+- ``not_requested`` means the caller did not ask for this stage to
+ run (e.g. ``pitch_note_mode="off"``). Conceptually distinct from
+ "queued"; the stage is simply absent from the requested pipeline.
+
+This module exposes the five-state vocabulary intended for external
+consumers and the mapping from the internal eight to that public
+five. The internal ``status`` field on each stage stays untouched;
+this is an *additive* collapse — the route layer attaches a parallel
+``publicStatus`` field next to ``status`` on each stage.
+
+Mapping:
+
+============== ===============================
+Internal Public (publicStatus)
+============== ===============================
+queued queued
+running running
+blocked queued (transient internal)
+ready queued (scheduled, not yet running)
+completed completed
+failed failed
+interrupted interrupted
+not_requested None (not in the pipeline)
+============== ===============================
+
+A ``None`` mapping is exposed in JSON as ``"publicStatus": null``.
+This is deliberate — explicit ``null`` is easier for clients than
+checking key presence, especially in strongly-typed languages where
+optional vs missing are different shapes.
+
+See ``docs/adr/0001-phase1-json-schema-v1.md`` for the schema-version
+treatment of this field.
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+
+PUBLIC_STATUS_VALUES: Final[frozenset[str]] = frozenset(
+ {"queued", "running", "completed", "failed", "interrupted"}
+)
+
+
+_INTERNAL_TO_PUBLIC: Final[dict[str, str | None]] = {
+ "queued": "queued",
+ "running": "running",
+ "blocked": "queued",
+ "ready": "queued",
+ "completed": "completed",
+ "failed": "failed",
+ "interrupted": "interrupted",
+ "not_requested": None,
+}
+
+
+def to_public_status(internal_status: str | None) -> str | None:
+ """Map an internal stage status to its public-facing equivalent.
+
+ Returns ``None`` when:
+ - ``internal_status`` is ``None`` (stage exists but no status set)
+ - ``internal_status`` is ``"not_requested"`` (stage is not in the pipeline)
+ - ``internal_status`` is an unrecognized value (defensive — should not
+ happen with the current state machine, but a forwards-compat
+ guard against accidental internal-only additions leaking out)
+ """
+ if internal_status is None:
+ return None
+ return _INTERNAL_TO_PUBLIC.get(internal_status)
+
+
+def public_status_values() -> frozenset[str]:
+ """The complete set of non-null public status values.
+
+ Useful for type generation, validation, and documentation.
+ """
+ return PUBLIC_STATUS_VALUES
diff --git a/apps/backend/tests/test_dsp_utils.py b/apps/backend/tests/test_dsp_utils.py
index 2823d97d..0a567f14 100644
--- a/apps/backend/tests/test_dsp_utils.py
+++ b/apps/backend/tests/test_dsp_utils.py
@@ -27,6 +27,14 @@
_DSP_SPEC.loader.exec_module(dsp_utils)
+_BANDBANK_PATH = _BACKEND_ROOT / "dsp_bandbank.py"
+_BANDBANK_SPEC = importlib.util.spec_from_file_location("dsp_bandbank_test", _BANDBANK_PATH)
+if _BANDBANK_SPEC is None or _BANDBANK_SPEC.loader is None:
+ raise AssertionError("Could not load dsp_bandbank.py for direct helper tests.")
+dsp_bandbank = importlib.util.module_from_spec(_BANDBANK_SPEC)
+_BANDBANK_SPEC.loader.exec_module(dsp_bandbank)
+
+
class PearsonCorrTests(unittest.TestCase):
"""Sanity-check the in-house Pearson correlation used across the analyzers."""
@@ -274,5 +282,128 @@ def test_silent_sub_band_produces_none_sub(self):
self.assertIsNone(point["sub"])
+class BatchedBandpassTests(unittest.TestCase):
+ """Verify ``BatchedBandpass`` is bit-identical to the inline scipy code
+ it replaces in ``analyze_detection.py``.
+
+ The load-bearing assertion is ``test_filter_one_matches_inline_scipy_bit_for_bit``:
+ if the new class deviates from ``butter(4, [lo/nyq, hi/nyq], output='sos')
+ + sosfiltfilt`` by more than 1e-12 (well below float32 last-bit precision
+ after the post-filter cast), Phase 1 numbers move and the refactor must
+ not land.
+ """
+
+ # _REVERB_BANDS, replicated here so the test doesn't drag in analyze_detection
+ # (which pulls essentia, librosa, etc.).
+ _REVERB_BANDS = (
+ ("low", 20.0, 250.0),
+ ("lowMids", 250.0, 2000.0),
+ ("highMids", 2000.0, 8000.0),
+ ("highs", 8000.0, 16000.0),
+ )
+ _SR = 44100
+
+ @classmethod
+ def setUpClass(cls):
+ # Fixed-seed signal so the parity assertion is reproducible.
+ cls._signal = np.random.default_rng(1234).standard_normal(2 * cls._SR)
+
+ def _inline_reference(self, mono, lo_hz, hi_hz):
+ """Literal pre-refactor implementation, copy-pasted from
+ ``analyze_detection._bandpass_signal``. Anything the new class
+ produces must match this byte-for-byte."""
+ from scipy import signal as scipy_signal
+
+ nyquist = 0.5 * self._SR
+ lo = max(1.0, lo_hz) / nyquist
+ hi = min(self._SR * 0.49, hi_hz) / nyquist
+ if not (0.0 < lo < hi < 1.0):
+ return None
+ sos = scipy_signal.butter(4, [lo, hi], btype="bandpass", output="sos")
+ return scipy_signal.sosfiltfilt(sos, mono).astype(np.float32, copy=False)
+
+ def test_filter_one_matches_inline_scipy_bit_for_bit(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ for name, lo, hi in self._REVERB_BANDS:
+ with self.subTest(band=name):
+ expected = self._inline_reference(self._signal, lo, hi)
+ got = bb.filter_one(self._signal, lo, hi)
+ self.assertIsNotNone(expected)
+ self.assertIsNotNone(got)
+ np.testing.assert_allclose(got, expected, atol=1e-12, rtol=1e-12)
+
+ def test_filter_many_returns_same_arrays_as_per_band_loop(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ many = bb.filter_many(self._signal, self._REVERB_BANDS, dtype=np.float32)
+ self.assertEqual(set(many.keys()), {n for n, *_ in self._REVERB_BANDS})
+ for name, lo, hi in self._REVERB_BANDS:
+ with self.subTest(band=name):
+ expected = self._inline_reference(self._signal, lo, hi)
+ np.testing.assert_allclose(many[name], expected, atol=1e-12, rtol=1e-12)
+
+ def test_filter_many_accepts_reverb_bands_tuple_shape(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ result = bb.filter_many(self._signal, self._REVERB_BANDS, dtype=np.float32)
+ self.assertEqual(set(result.keys()), {"low", "lowMids", "highMids", "highs"})
+
+ def test_filter_many_accepts_spectral_balance_dict_shape(self):
+ """SPECTRAL_BALANCE_BANDS in analyze_core is a ``Mapping[str, (lo, hi)]`` —
+ ``filter_many`` must accept that shape too, not just the reverb triple."""
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ bands = {"subBass": (20, 80), "lowMids": (250, 500)}
+ result = bb.filter_many(self._signal, bands)
+ self.assertEqual(set(result.keys()), {"subBass", "lowMids"})
+ # Default dtype is float64 to match the upstream cast in
+ # analyze_per_band_transient_density.
+ self.assertEqual(result["subBass"].dtype, np.float64)
+
+ def test_invalid_band_returns_none_or_absent_key(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ # lo > hi forces the clamp predicate to fail.
+ self.assertIsNone(bb.filter_one(self._signal, 10000.0, 100.0))
+ many = bb.filter_many(
+ self._signal,
+ [("bad", 10000.0, 100.0), ("good", 100.0, 1000.0)],
+ dtype=np.float32,
+ )
+ self.assertNotIn("bad", many)
+ self.assertIn("good", many)
+
+ def test_filter_one_returns_none_for_empty_input(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ self.assertIsNone(bb.filter_one(np.array([], dtype=np.float64), 100.0, 1000.0))
+
+ def test_sos_coefficients_are_cached_per_band(self):
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ bb.filter_one(self._signal, 100.0, 1000.0)
+ self.assertIn((100.0, 1000.0), bb._sos_cache)
+ sos_first = bb._sos_cache[(100.0, 1000.0)]
+ bb.filter_one(self._signal, 100.0, 1000.0)
+ sos_second = bb._sos_cache[(100.0, 1000.0)]
+ # Identity check — second call must hit the cache, not re-design.
+ self.assertIs(sos_first, sos_second)
+
+ def test_unsupported_backend_raises(self):
+ with self.assertRaises(ValueError):
+ dsp_bandbank.BatchedBandpass(self._SR, backend="torch")
+
+ def test_filter_one_default_dtype_is_float32(self):
+ """Locks the bit-identicality contract: ``filter_one`` with no dtype
+ kwarg must return float32 to match the pre-refactor _bandpass_signal."""
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ out = bb.filter_one(self._signal, 100.0, 1000.0)
+ self.assertIsNotNone(out)
+ self.assertEqual(out.dtype, np.float32)
+
+ def test_filter_one_respects_dtype_override(self):
+ """Explicit ``dtype=np.float64`` must produce a float64 array — the
+ forward-looking override path for transient-density-style callers
+ that want to skip an upstream cast."""
+ bb = dsp_bandbank.BatchedBandpass(self._SR)
+ out = bb.filter_one(self._signal, 100.0, 1000.0, dtype=np.float64)
+ self.assertIsNotNone(out)
+ self.assertEqual(out.dtype, np.float64)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/apps/backend/tests/test_sample_audio_content.py b/apps/backend/tests/test_sample_audio_content.py
new file mode 100644
index 00000000..46a85b41
--- /dev/null
+++ b/apps/backend/tests/test_sample_audio_content.py
@@ -0,0 +1,223 @@
+"""End-to-end audio-content tests for audition sample generation.
+
+The other sample test modules verify the *plan* (correct MIDI numbers) and
+the *primitives* (a single note renders at the right pitch; a kick lands on
+its fundamental). They do not verify that the *orchestrated WAV files written
+to disk* actually contain audio that reflects the Phase 1 input.
+
+This module closes that loop. It runs the real orchestrator, loads the WAVs
+it wrote, runs an FFT, and asserts on spectral content. It is the answer to
+"how do we know it's generating anything of value?"
+
+The load-bearing test is `test_different_keys_produce_different_audio`: it
+proves the generator is *responsive* to its input rather than emitting a
+canned clip. If sample generation ever regresses to fixed output — or stops
+threading the measured key through to the render — that test fails.
+
+Runs against the sine-additive fallback (`prefer_fluidsynth=False`) so the
+assertions are deterministic and don't depend on a system soundfont.
+"""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+import numpy as np
+import soundfile as sf
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+import sample_generation # noqa: E402
+
+
+# --- Spectral helpers ------------------------------------------------------- #
+
+
+def _midi_to_freq(midi: int) -> float:
+ """Equal-tempered frequency for a MIDI note. A4 (69) = 440 Hz."""
+ return 440.0 * (2.0 ** ((midi - 69) / 12.0))
+
+
+def _fft_magnitude(samples: np.ndarray, sample_rate: int) -> tuple[np.ndarray, np.ndarray]:
+ """Hann-windowed magnitude spectrum. Hann keeps spectral leakage low so a
+ non-chord-tone band reads as genuine near-silence, not smeared neighbors."""
+ mono = np.asarray(samples, dtype=np.float64)
+ if mono.ndim > 1:
+ mono = mono.mean(axis=1)
+ window = np.hanning(mono.size)
+ spectrum = np.abs(np.fft.rfft(mono * window))
+ freqs = np.fft.rfftfreq(mono.size, d=1.0 / sample_rate)
+ return freqs, spectrum
+
+
+def _energy_near(
+ samples: np.ndarray, sample_rate: int, target_hz: float, tolerance_hz: float = 6.0
+) -> float:
+ """Summed spectral magnitude in a narrow band around target_hz."""
+ freqs, spectrum = _fft_magnitude(samples, sample_rate)
+ band = (freqs >= target_hz - tolerance_hz) & (freqs <= target_hz + tolerance_hz)
+ return float(np.sum(spectrum[band]))
+
+
+def _dominant_freq(samples: np.ndarray, sample_rate: int) -> float:
+ """Frequency of the single strongest spectral bin."""
+ freqs, spectrum = _fft_magnitude(samples, sample_rate)
+ return float(freqs[int(np.argmax(spectrum))])
+
+
+def _load_wav(path: Path) -> tuple[np.ndarray, int]:
+ audio, sample_rate = sf.read(str(path))
+ return np.asarray(audio, dtype=np.float64), int(sample_rate)
+
+
+def _phase1(
+ *, key: str = "C major", key_confidence: float = 0.85, kick_hz: float = 60.0
+) -> dict:
+ return {
+ "bpm": 120.0,
+ "bpmConfidence": 0.9,
+ "key": key,
+ "keyConfidence": key_confidence,
+ "kickDetail": {
+ "fundamentalHz": kick_hz,
+ "decayTimeMs": 220.0,
+ "confidence": 0.8,
+ },
+ }
+
+
+def _generate(tmp: str, phase1: dict, **kwargs) -> Path:
+ sample_generation.generate_samples(
+ run_id="audio-content-test",
+ phase1=phase1,
+ phase2=None,
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ **kwargs,
+ )
+ return Path(tmp)
+
+
+class ChordProgressionAudioTests(unittest.TestCase):
+ def test_c_major_chord_audio_contains_the_triad(self) -> None:
+ # The first chord of the I-vi-IV-V progression is the tonic triad,
+ # occupying beats 0-8 = the first 4 s at 120 BPM. FFT a steady-state
+ # window inside that span to dodge the ADSR envelope edges.
+ with tempfile.TemporaryDirectory() as tmp:
+ out = _generate(tmp, _phase1(key="C major"))
+ audio, sr = _load_wav(out / "tonal_chord_progression.wav")
+ window = audio[int(1.0 * sr) : int(3.0 * sr)]
+
+ c4 = _energy_near(window, sr, _midi_to_freq(60)) # C4 261.6 Hz
+ e4 = _energy_near(window, sr, _midi_to_freq(64)) # E4 329.6 Hz
+ g4 = _energy_near(window, sr, _midi_to_freq(67)) # G4 392.0 Hz
+ cs4 = _energy_near(window, sr, _midi_to_freq(61)) # C#4 — NOT in C major
+
+ # All three chord tones carry real energy.
+ self.assertGreater(c4, 0.0)
+ self.assertGreater(e4, 0.0)
+ self.assertGreater(g4, 0.0)
+ # And the weakest chord tone still dwarfs a non-chord tone — i.e.
+ # the audio is in the key, not just noise that happens to be loud.
+ self.assertGreater(min(c4, e4, g4), cs4 * 20.0)
+
+ def test_bass_root_audio_sits_on_the_tonic(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ out = _generate(tmp, _phase1(key="C major"))
+ audio, sr = _load_wav(out / "tonal_bass_root.wav")
+ window = audio[int(1.0 * sr) : int(3.0 * sr)]
+ # Bass root for C major is C2 = MIDI 36 ≈ 65.4 Hz.
+ self.assertAlmostEqual(
+ _dominant_freq(window, sr), _midi_to_freq(36), delta=4.0
+ )
+
+ def test_different_keys_produce_different_audio(self) -> None:
+ # The "is it generating anything of value" test: prove the render is
+ # driven by the measured key, not a fixed clip. C natural belongs to
+ # the C-major triad and is absent from the F#-minor triad; F# is the
+ # mirror image. Each note must be loud in its own key and near-silent
+ # in the other.
+ with tempfile.TemporaryDirectory() as tmp_c, tempfile.TemporaryDirectory() as tmp_fs:
+ c_out = _generate(tmp_c, _phase1(key="C major"))
+ fs_out = _generate(tmp_fs, _phase1(key="F# minor"))
+
+ c_audio, sr = _load_wav(c_out / "tonal_chord_progression.wav")
+ fs_audio, _ = _load_wav(fs_out / "tonal_chord_progression.wav")
+ c_window = c_audio[int(1.0 * sr) : int(3.0 * sr)]
+ fs_window = fs_audio[int(1.0 * sr) : int(3.0 * sr)]
+
+ # Sanity: the two clips are not the same audio.
+ self.assertNotEqual(c_audio.tobytes(), fs_audio.tobytes())
+
+ # C natural (MIDI 60): in the C-major triad, not in F# minor's.
+ c_nat_in_cmaj = _energy_near(c_window, sr, _midi_to_freq(60))
+ c_nat_in_fsmin = _energy_near(fs_window, sr, _midi_to_freq(60))
+ self.assertGreater(c_nat_in_cmaj, c_nat_in_fsmin * 20.0)
+
+ # F# (MIDI 66): in the F#-minor triad, not in C major's.
+ fs_in_fsmin = _energy_near(fs_window, sr, _midi_to_freq(66))
+ fs_in_cmaj = _energy_near(c_window, sr, _midi_to_freq(66))
+ self.assertGreater(fs_in_fsmin, fs_in_cmaj * 20.0)
+
+
+class KickAudioTests(unittest.TestCase):
+ def test_orchestrated_kick_lands_on_measured_fundamental(self) -> None:
+ # Extends test_sample_drums (which tests synth_kick directly) through
+ # the full orchestrator + disk round-trip: the kick WAV the endpoint
+ # would serve actually sits on the measured kickDetail.fundamentalHz.
+ with tempfile.TemporaryDirectory() as tmp:
+ out = _generate(tmp, _phase1(kick_hz=72.0))
+ audio, sr = _load_wav(out / "drum_kick.wav")
+ # Skip the first 150 ms — the pitch envelope sweeps an octave down
+ # before settling on the fundamental.
+ steady = audio[int(0.15 * sr) :]
+ self.assertAlmostEqual(_dominant_freq(steady, sr), 72.0, delta=20.0)
+
+ def test_kick_fundamental_tracks_the_measurement(self) -> None:
+ # Two different measured fundamentals must yield two different kicks.
+ with tempfile.TemporaryDirectory() as tmp_low, tempfile.TemporaryDirectory() as tmp_high:
+ low_out = _generate(tmp_low, _phase1(kick_hz=45.0))
+ high_out = _generate(tmp_high, _phase1(kick_hz=95.0))
+ low_audio, sr = _load_wav(low_out / "drum_kick.wav")
+ high_audio, _ = _load_wav(high_out / "drum_kick.wav")
+ low_peak = _dominant_freq(low_audio[int(0.15 * sr) :], sr)
+ high_peak = _dominant_freq(high_audio[int(0.15 * sr) :], sr)
+ # The 45 Hz kick must be audibly lower than the 95 Hz kick.
+ self.assertLess(low_peak, high_peak)
+ self.assertAlmostEqual(low_peak, 45.0, delta=20.0)
+ self.assertAlmostEqual(high_peak, 95.0, delta=20.0)
+
+
+class MelodyAudioTests(unittest.TestCase):
+ def test_melody_follows_scale_degree_hints(self) -> None:
+ # Hints [1, 5] in C major => scale degrees 1 then 5 => C5 then G5.
+ # The rendered melody must actually play those two pitches in order.
+ with tempfile.TemporaryDirectory() as tmp:
+ out = _generate(tmp, _phase1(key="C major"), pitch_note_hints=[1, 5])
+ audio, sr = _load_wav(out / "melody_lead.wav")
+
+ # 2 notes over 4 bars at 120 BPM => each note gets an 8-beat (4 s)
+ # slot. The 0.85 note-length gate means note 0 *sounds* ~0-3.4 s
+ # and note 1 ~4-7.4 s (each followed by a short gap). Sample
+ # comfortably inside each sounding span.
+ first_note = audio[int(0.5 * sr) : int(3.0 * sr)]
+ second_note = audio[int(4.5 * sr) : int(7.0 * sr)]
+
+ self.assertAlmostEqual(
+ _dominant_freq(first_note, sr), _midi_to_freq(72), delta=8.0
+ ) # C5
+ self.assertAlmostEqual(
+ _dominant_freq(second_note, sr), _midi_to_freq(79), delta=10.0
+ ) # G5
+
+ # And the phrase ascends — degree 5 is above degree 1.
+ self.assertLess(
+ _dominant_freq(first_note, sr), _dominant_freq(second_note, sr)
+ )
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_sample_drums.py b/apps/backend/tests/test_sample_drums.py
new file mode 100644
index 00000000..24855c24
--- /dev/null
+++ b/apps/backend/tests/test_sample_drums.py
@@ -0,0 +1,73 @@
+"""Tests for the NumPy drum-synthesis layer.
+
+The kick test is the load-bearing one: it verifies that a measured
+`fundamentalHz` actually shows up as the dominant spectral energy in the
+rendered audio. If this regresses, the audition lies about what the
+measurement says.
+"""
+
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+import sample_drums # noqa: E402
+
+
+class KickTests(unittest.TestCase):
+ def test_kick_fft_peak_lands_near_fundamental(self) -> None:
+ # Use 80 Hz so the peak sits in a region with enough FFT resolution.
+ kick = sample_drums.synth_kick(fundamental_hz=80.0, decay_time_ms=250.0)
+ # Look at the steady-state portion (after the initial pitch sweep).
+ steady_start = int(0.05 * kick.sample_rate)
+ steady_segment = kick.samples[steady_start:].astype(np.float64)
+ spectrum = np.abs(np.fft.rfft(steady_segment))
+ freqs = np.fft.rfftfreq(steady_segment.size, d=1.0 / kick.sample_rate)
+ peak_freq = float(freqs[int(np.argmax(spectrum))])
+ # Generous tolerance: the pitch envelope means peak energy can sit a
+ # little above the fundamental in the first ~30 ms.
+ self.assertAlmostEqual(peak_freq, 80.0, delta=20.0)
+
+ def test_kick_obeys_decay_envelope(self) -> None:
+ kick = sample_drums.synth_kick(fundamental_hz=55.0, decay_time_ms=150.0)
+ # Tail amplitude should be substantially lower than head amplitude.
+ head_rms = float(np.sqrt(np.mean(kick.samples[:1000] ** 2)))
+ tail_rms = float(
+ np.sqrt(np.mean(kick.samples[-1000:] ** 2))
+ )
+ self.assertGreater(head_rms, tail_rms * 5.0)
+
+ def test_kick_rejects_negative_fundamental(self) -> None:
+ with self.assertRaises(ValueError):
+ sample_drums.synth_kick(fundamental_hz=-1.0)
+
+
+class SnareTests(unittest.TestCase):
+ def test_snare_is_well_formed(self) -> None:
+ snare = sample_drums.synth_snare()
+ self.assertEqual(snare.sample_rate, sample_drums.SAMPLE_RATE)
+ self.assertEqual(snare.samples.dtype, np.float32)
+ # Within [-1, 1] after normalization.
+ self.assertLessEqual(float(np.max(np.abs(snare.samples))), 1.0)
+ # Non-silent.
+ self.assertGreater(
+ float(np.sqrt(np.mean(snare.samples.astype(np.float64) ** 2))), 0.01
+ )
+
+
+class HatTests(unittest.TestCase):
+ def test_hat_is_short_and_non_silent(self) -> None:
+ hat = sample_drums.synth_hat()
+ self.assertLess(hat.duration_seconds, 0.3)
+ self.assertGreater(
+ float(np.sqrt(np.mean(hat.samples.astype(np.float64) ** 2))), 0.005
+ )
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_sample_generation.py b/apps/backend/tests/test_sample_generation.py
new file mode 100644
index 00000000..04019b6a
--- /dev/null
+++ b/apps/backend/tests/test_sample_generation.py
@@ -0,0 +1,176 @@
+"""End-to-end orchestrator tests.
+
+These tests feed synthetic Phase 1 / Phase 2 dicts into `generate_samples`
+and assert on the manifest contract documented in
+`docs/SAMPLE_GENERATION.md`. The citation array is the chain of custody for
+this stage — if a generated sample isn't tied back to a Phase 1 field, the
+audition has nothing to justify it.
+"""
+
+import json
+import sys
+import tempfile
+import unittest
+import wave
+from pathlib import Path
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+import sample_generation # noqa: E402
+
+
+def _baseline_phase1() -> dict:
+ return {
+ "bpm": 124.0,
+ "bpmConfidence": 0.91,
+ "key": "F# minor",
+ "keyConfidence": 0.83,
+ "kickDetail": {
+ "fundamentalHz": 55.0,
+ "decayTimeMs": 220.0,
+ "confidence": 0.82,
+ },
+ "melodyDetail": {"placeholder": True},
+ }
+
+
+def _baseline_phase2() -> dict:
+ return {
+ "trackCharacter": "deep house",
+ "styleProfile": {
+ "genre": "Deep House",
+ "authoritativeMeasurements": {"bpm": 124.0, "key": "F# minor"},
+ },
+ "sonicElements": {
+ "kick": "Punchy sub-heavy kick around 55 Hz.",
+ "harmonicContent": "Minor 7th pads, layered piano.",
+ },
+ }
+
+
+class OrchestratorTests(unittest.TestCase):
+ def test_full_input_produces_all_sample_categories(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-1",
+ phase1=_baseline_phase1(),
+ phase2=_baseline_phase2(),
+ output_dir=Path(tmp),
+ pitch_note_hints=[1, 2, 3, 5],
+ prefer_fluidsynth=False,
+ )
+
+ sample_ids = {s["id"] for s in result.manifest["samples"]}
+ self.assertIn("tonal_chord_progression", sample_ids)
+ self.assertIn("tonal_bass_root", sample_ids)
+ self.assertIn("drum_kick", sample_ids)
+ self.assertIn("drum_snare", sample_ids)
+ self.assertIn("drum_hat", sample_ids)
+ self.assertIn("melody_lead", sample_ids)
+
+ # Every WAV file on disk.
+ for sample in result.manifest["samples"]:
+ wav_path = Path(tmp) / sample["filename"]
+ self.assertTrue(wav_path.is_file(), f"missing {wav_path}")
+ with wave.open(str(wav_path), "rb") as wav:
+ self.assertGreater(wav.getnframes(), 0)
+
+ # Manifest file written.
+ manifest_on_disk = json.loads(result.manifest_path.read_text())
+ self.assertEqual(manifest_on_disk["runId"], "run-1")
+ self.assertEqual(manifest_on_disk["schemaVersion"], "samples.v1")
+ self.assertEqual(manifest_on_disk["synthesisBackend"], "sine_fallback")
+
+ def test_tonal_samples_skipped_when_key_missing(self) -> None:
+ phase1 = _baseline_phase1()
+ phase1.pop("key")
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-no-key",
+ phase1=phase1,
+ phase2=None,
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ )
+ sample_ids = {s["id"] for s in result.manifest["samples"]}
+ self.assertNotIn("tonal_chord_progression", sample_ids)
+ self.assertNotIn("tonal_bass_root", sample_ids)
+ self.assertNotIn("melody_lead", sample_ids)
+ # Drums still emitted.
+ self.assertIn("drum_kick", sample_ids)
+
+ def test_low_confidence_key_flags_tonal_samples(self) -> None:
+ phase1 = _baseline_phase1()
+ phase1["keyConfidence"] = 0.2
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-low-confidence",
+ phase1=phase1,
+ phase2=None,
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ )
+ tonal = [s for s in result.manifest["samples"] if s["category"] == "tonal"]
+ self.assertTrue(tonal, "expected tonal samples even at low confidence")
+ for sample in tonal:
+ self.assertTrue(sample["lowConfidence"])
+ self.assertEqual(sample["confidence"], "LOW")
+ # Drums shouldn't be flagged off the back of key confidence.
+ kick = next(s for s in result.manifest["samples"] if s["id"] == "drum_kick")
+ self.assertEqual(kick["confidence"], "HIGH")
+
+ def test_every_sample_cites_or_explains_absence(self) -> None:
+ # Chain-of-custody invariant: a sample must either cite a Phase 1
+ # field or carry a rationale that explicitly names it as heuristic.
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-cite",
+ phase1=_baseline_phase1(),
+ phase2=_baseline_phase2(),
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ )
+ for sample in result.manifest["samples"]:
+ cites = sample["cites"]
+ has_phase1 = len(cites["phase1Fields"]) > 0
+ rationale = cites["rationale"].lower()
+ mentions_heuristic = (
+ "heuristic" in rationale or "default" in rationale
+ )
+ self.assertTrue(
+ has_phase1 or mentions_heuristic,
+ f"{sample['id']} cites nothing and isn't labeled heuristic: {sample}",
+ )
+
+ def test_kick_uses_measured_fundamental_when_present(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-kick",
+ phase1=_baseline_phase1(),
+ phase2=None,
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ )
+ kick = next(s for s in result.manifest["samples"] if s["id"] == "drum_kick")
+ self.assertEqual(kick["cites"]["phase1Fields"], [
+ "kickDetail.fundamentalHz",
+ "kickDetail.decayTimeMs",
+ ])
+ self.assertIn("55", kick["label"]) # "Kick at 55 Hz"
+
+ def test_manifest_records_theory_backend(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ result = sample_generation.generate_samples(
+ run_id="run-backend",
+ phase1=_baseline_phase1(),
+ phase2=None,
+ output_dir=Path(tmp),
+ prefer_fluidsynth=False,
+ )
+ self.assertIn(result.manifest["theoryBackend"], {"pytheory", "fallback"})
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_sample_synthesis.py b/apps/backend/tests/test_sample_synthesis.py
new file mode 100644
index 00000000..917e0639
--- /dev/null
+++ b/apps/backend/tests/test_sample_synthesis.py
@@ -0,0 +1,115 @@
+"""Tests for the MIDI-plan → WAV/MIDI synthesis layer.
+
+The sine fallback path is the one exercised by these tests; the FluidSynth
+path requires a system library and a soundfont, which we don't assume are
+present in CI. The fallback is the everywhere-available backstop and must
+stay correct.
+"""
+
+import sys
+import tempfile
+import unittest
+import wave
+from pathlib import Path
+
+import numpy as np
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+import sample_synthesis # noqa: E402
+import sample_theory # noqa: E402
+
+
+def _single_note_plan(pitch: int = 69, bpm: float = 120.0, duration_beats: float = 4.0) -> sample_theory.ClipPlan:
+ """Minimal plan: one note for `duration_beats`."""
+ return sample_theory.ClipPlan(
+ tempo_bpm=bpm,
+ duration_beats=duration_beats,
+ notes=[
+ sample_theory.NoteEvent(
+ pitch_midi=pitch, start_beat=0.0, duration_beats=duration_beats
+ )
+ ],
+ program=0,
+ )
+
+
+class SineFallbackTests(unittest.TestCase):
+ def test_render_returns_expected_shape(self) -> None:
+ plan = _single_note_plan(pitch=69, bpm=120.0, duration_beats=4.0)
+ result = sample_synthesis.render_clip(plan, prefer_fluidsynth=False)
+ expected_samples = int(round(2.0 * sample_synthesis.SAMPLE_RATE)) # 4 beats @ 120 = 2 s
+ self.assertEqual(result.samples.shape, (expected_samples,))
+ self.assertEqual(result.samples.dtype, np.float32)
+ self.assertEqual(result.backend, "sine_fallback")
+ self.assertIsNone(result.soundfont_path)
+
+ def test_render_contains_energy_at_target_frequency(self) -> None:
+ # A4 = MIDI 69 = 440 Hz exactly.
+ plan = _single_note_plan(pitch=69, bpm=120.0, duration_beats=4.0)
+ result = sample_synthesis.render_clip(plan, prefer_fluidsynth=False)
+
+ # Look at the middle of the note to avoid envelope transients.
+ mid_start = result.samples.size // 4
+ mid_end = result.samples.size - result.samples.size // 4
+ segment = result.samples[mid_start:mid_end].astype(np.float64)
+ spectrum = np.abs(np.fft.rfft(segment))
+ freqs = np.fft.rfftfreq(segment.size, d=1.0 / sample_synthesis.SAMPLE_RATE)
+ peak_freq = float(freqs[int(np.argmax(spectrum))])
+ # Allow a few Hz of bin-resolution slop.
+ self.assertAlmostEqual(peak_freq, 440.0, delta=5.0)
+
+ def test_render_silent_when_no_notes(self) -> None:
+ plan = sample_theory.ClipPlan(
+ tempo_bpm=120.0, duration_beats=4.0, notes=[], program=0
+ )
+ result = sample_synthesis.render_clip(plan, prefer_fluidsynth=False)
+ peak = float(np.max(np.abs(result.samples)))
+ self.assertEqual(peak, 0.0)
+
+
+class WriteWavTests(unittest.TestCase):
+ def test_write_wav_round_trips(self) -> None:
+ plan = _single_note_plan(pitch=60, bpm=120.0, duration_beats=2.0)
+ result = sample_synthesis.render_clip(plan, prefer_fluidsynth=False)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ wav_path = Path(tmp) / "out.wav"
+ sample_synthesis.write_wav(result.samples, path=wav_path)
+ self.assertTrue(wav_path.is_file())
+ with wave.open(str(wav_path), "rb") as wav:
+ self.assertEqual(wav.getframerate(), sample_synthesis.SAMPLE_RATE)
+ self.assertEqual(wav.getnchannels(), 1)
+ # 16-bit subtype = 2 bytes per sample.
+ self.assertEqual(wav.getsampwidth(), 2)
+ self.assertGreater(wav.getnframes(), 0)
+
+
+class WriteMidiTests(unittest.TestCase):
+ def test_write_midi_emits_expected_note(self) -> None:
+ import pretty_midi # local import — only test needs it
+
+ plan = sample_theory.ClipPlan(
+ tempo_bpm=120.0,
+ duration_beats=4.0,
+ notes=[
+ sample_theory.NoteEvent(pitch_midi=60, start_beat=0.0, duration_beats=2.0),
+ sample_theory.NoteEvent(pitch_midi=64, start_beat=2.0, duration_beats=2.0),
+ ],
+ program=0,
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ mid_path = Path(tmp) / "out.mid"
+ sample_synthesis.write_midi(plan, path=mid_path)
+ self.assertTrue(mid_path.is_file())
+ pm = pretty_midi.PrettyMIDI(str(mid_path))
+ self.assertEqual(len(pm.instruments), 1)
+ self.assertEqual(len(pm.instruments[0].notes), 2)
+ pitches = sorted(n.pitch for n in pm.instruments[0].notes)
+ self.assertEqual(pitches, [60, 64])
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_sample_theory.py b/apps/backend/tests/test_sample_theory.py
new file mode 100644
index 00000000..a80d47c2
--- /dev/null
+++ b/apps/backend/tests/test_sample_theory.py
@@ -0,0 +1,150 @@
+"""Tests for the Phase 3 music-theory adapter.
+
+The MIDI numbers asserted here are the canonical Western-music values for the
+given key/mode/degree combinations. Both the PyTheory and fallback paths must
+agree with these reference values — if a render starts sounding out-of-key,
+this is the first place to look.
+"""
+
+import sys
+import unittest
+from pathlib import Path
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+import sample_theory # noqa: E402
+
+
+class ParseKeyTests(unittest.TestCase):
+ def test_parses_canonical_keys(self) -> None:
+ self.assertEqual(sample_theory.parse_key("C major"), (0, "major", "C"))
+ self.assertEqual(sample_theory.parse_key("A minor"), (9, "minor", "A"))
+ self.assertEqual(sample_theory.parse_key("F# minor"), (6, "minor", "F#"))
+ self.assertEqual(sample_theory.parse_key("Bb major"), (10, "major", "Bb"))
+
+ def test_defaults_to_major_when_mode_missing(self) -> None:
+ self.assertEqual(sample_theory.parse_key("D"), (2, "major", "D"))
+
+ def test_tolerates_parenthetical_suffix(self) -> None:
+ # Phase 1 occasionally tacks on confidence qualifiers.
+ self.assertEqual(
+ sample_theory.parse_key("F# minor (low confidence)"),
+ (6, "minor", "F#"),
+ )
+
+ def test_rejects_unknown_root(self) -> None:
+ with self.assertRaises(ValueError):
+ sample_theory.parse_key("H minor")
+
+ def test_rejects_unknown_mode(self) -> None:
+ with self.assertRaises(ValueError):
+ sample_theory.parse_key("C ionian-augmented-fancy")
+
+ def test_rejects_empty_input(self) -> None:
+ with self.assertRaises(ValueError):
+ sample_theory.parse_key("")
+
+
+class BuildContextTests(unittest.TestCase):
+ def test_carries_confidence_through(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=128.0, key_confidence=0.83)
+ self.assertEqual(ctx.root_pc, 0)
+ self.assertEqual(ctx.mode, "major")
+ self.assertEqual(ctx.root_name, "C")
+ self.assertEqual(ctx.tempo_bpm, 128.0)
+ self.assertEqual(ctx.key_confidence, 0.83)
+ self.assertIn(ctx.backend, {"pytheory", "fallback"})
+
+
+class ChordProgressionTests(unittest.TestCase):
+ def test_c_major_progression_voicings(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ plan = sample_theory.plan_chord_progression(ctx, bars=8, voicing_octave=4)
+
+ # 4 chords × 3 notes each = 12 events.
+ self.assertEqual(len(plan.notes), 12)
+
+ # Chord 1 (C major): C-E-G at octave 4.
+ chord_one_pitches = sorted(n.pitch_midi for n in plan.notes if n.start_beat == 0.0)
+ self.assertEqual(chord_one_pitches, [60, 64, 67])
+
+ # Chord 2 (A minor, vi): A-C-E. Starts at beat 8.
+ chord_two_pitches = sorted(
+ n.pitch_midi for n in plan.notes if n.start_beat == 8.0
+ )
+ self.assertEqual(chord_two_pitches, [57, 60, 64])
+
+ # Chord 3 (F major, IV): F-A-C. Starts at beat 16.
+ chord_three_pitches = sorted(
+ n.pitch_midi for n in plan.notes if n.start_beat == 16.0
+ )
+ self.assertEqual(chord_three_pitches, [53, 57, 60])
+
+ # Chord 4 (G major, V): G-B-D. Starts at beat 24.
+ chord_four_pitches = sorted(
+ n.pitch_midi for n in plan.notes if n.start_beat == 24.0
+ )
+ self.assertEqual(chord_four_pitches, [55, 59, 62])
+
+ def test_a_minor_progression_voicings(self) -> None:
+ ctx = sample_theory.build_context(key="A minor", bpm=120.0)
+ plan = sample_theory.plan_chord_progression(ctx, bars=8, voicing_octave=4)
+
+ # i (A minor) at the tonic octave: A4-C5-E5.
+ chord_one_pitches = sorted(n.pitch_midi for n in plan.notes if n.start_beat == 0.0)
+ self.assertEqual(chord_one_pitches, [69, 72, 76])
+
+ # VI (F major), dropped to the octave below the tonic anchor:
+ # F4-A4-C5. Distance from tonic chord: a stepwise descent in the bass.
+ chord_two_pitches = sorted(
+ n.pitch_midi for n in plan.notes if n.start_beat == 8.0
+ )
+ self.assertEqual(chord_two_pitches, [65, 69, 72])
+
+ def test_rejects_odd_bar_counts(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ with self.assertRaises(ValueError):
+ sample_theory.plan_chord_progression(ctx, bars=7)
+
+
+class BassRootTests(unittest.TestCase):
+ def test_bass_lives_two_octaves_below_voicing(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ plan = sample_theory.plan_bass_root(ctx, bars=8)
+ # C at octave 2 = MIDI 36.
+ for note in plan.notes:
+ self.assertEqual(note.pitch_midi, 36)
+
+ def test_bass_count_matches_bars(self) -> None:
+ ctx = sample_theory.build_context(key="A minor", bpm=120.0)
+ plan = sample_theory.plan_bass_root(ctx, bars=8)
+ self.assertEqual(len(plan.notes), 4) # one per two bars
+ self.assertEqual(plan.duration_beats, 32.0)
+
+
+class MelodyPlanTests(unittest.TestCase):
+ def test_default_phrase_uses_in_key_pitches(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ plan = sample_theory.plan_melody_phrase(ctx)
+ self.assertIsNotNone(plan)
+ assert plan is not None # narrowing for type checkers
+ # Default ascent 1-2-3-5-3-1 in C major at octave 5 -> C5 D5 E5 G5 E5 C5.
+ pitches = [n.pitch_midi for n in plan.notes]
+ self.assertEqual(pitches, [72, 74, 76, 79, 76, 72])
+
+ def test_respects_supplied_hints(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ plan = sample_theory.plan_melody_phrase(ctx, scale_degrees=[1, 3, 5])
+ assert plan is not None
+ self.assertEqual([n.pitch_midi for n in plan.notes], [72, 76, 79])
+
+ def test_returns_none_for_empty_clean_input(self) -> None:
+ ctx = sample_theory.build_context(key="C major", bpm=120.0)
+ # All ineligible degrees should produce no plan rather than fabricate one.
+ self.assertIsNone(sample_theory.plan_melody_phrase(ctx, scale_degrees=[0, 99]))
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py
index 90154dd9..3b46e449 100644
--- a/apps/backend/tests/test_server.py
+++ b/apps/backend/tests/test_server.py
@@ -1289,6 +1289,48 @@ def test_get_analysis_run_returns_persisted_stage_snapshot(self) -> None:
self.assertEqual(payload["runId"], created["runId"])
self.assertIn("artifacts", payload)
+ def test_get_analysis_run_attaches_public_status_to_every_stage(self) -> None:
+ """Track 3.4: every stage in the response carries publicStatus
+ alongside the internal status. Pitch-note and interpretation
+ stages here are not_requested (publicStatus must be null);
+ measurement stage is queued (publicStatus must be queued).
+ """
+ from analysis_runtime import AnalysisRuntime
+
+ with tempfile.TemporaryDirectory(prefix="asa_server_runtime_") as temp_dir:
+ runtime = AnalysisRuntime(Path(temp_dir) / "runtime")
+ created = runtime.create_run(
+ filename="track.mp3",
+ content=b"fake-audio",
+ mime_type="audio/mpeg",
+ pitch_note_mode="off",
+ pitch_note_backend="auto",
+ interpretation_mode="off",
+ interpretation_profile="producer_summary",
+ interpretation_model=None,
+ )
+ with patch.object(server, "get_analysis_runtime", return_value=runtime):
+ response = asyncio.run(server.get_analysis_run(created["runId"]))
+
+ payload = self._decode_json_response(response)
+ stages = payload["stages"]
+
+ # measurement starts queued; publicStatus should match
+ self.assertEqual(stages["measurement"]["status"], "queued")
+ self.assertEqual(stages["measurement"]["publicStatus"], "queued")
+
+ # not_requested stages must surface publicStatus: null so a
+ # client can distinguish "you didn't ask for this" from
+ # "queued and waiting" without checking a specific string.
+ self.assertEqual(
+ stages["pitchNoteTranslation"]["status"], "not_requested"
+ )
+ self.assertIsNone(stages["pitchNoteTranslation"]["publicStatus"])
+ self.assertEqual(
+ stages["interpretation"]["status"], "not_requested"
+ )
+ self.assertIsNone(stages["interpretation"]["publicStatus"])
+
def test_interrupt_analysis_run_marks_stages_interrupted_and_reports_terminated_children(self) -> None:
from analysis_runtime import AnalysisRuntime
diff --git a/apps/backend/tests/test_server_samples.py b/apps/backend/tests/test_server_samples.py
new file mode 100644
index 00000000..b0cc92f1
--- /dev/null
+++ b/apps/backend/tests/test_server_samples.py
@@ -0,0 +1,165 @@
+"""Contract tests for the audition-sample HTTP helper layer.
+
+We test the helper module directly (rather than spinning up a full FastAPI
+TestClient) because the layered server is heavy to boot and the route
+handlers themselves are thin wrappers. The helpers are where the precondition
+logic, manifest decoration, and artifact persistence happen — and those are
+the places a bug would slip through.
+"""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+from analysis_runtime import AnalysisRuntime # noqa: E402
+import server_samples # noqa: E402
+
+
+def _baseline_snapshot(*, phase2_completed: bool = True) -> dict:
+ interpretation = {"status": "completed", "result": {"trackCharacter": "fixture"}}
+ if not phase2_completed:
+ interpretation = {"status": "ready", "result": None}
+ return {
+ "stages": {
+ "measurement": {
+ "status": "completed",
+ "result": {
+ "bpm": 124.0,
+ "bpmConfidence": 0.9,
+ "key": "F# minor",
+ "keyConfidence": 0.78,
+ "kickDetail": {
+ "fundamentalHz": 55.0,
+ "decayTimeMs": 240.0,
+ "confidence": 0.8,
+ },
+ },
+ },
+ "interpretation": interpretation,
+ }
+ }
+
+
+class ServerSamplesTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tempdir = tempfile.TemporaryDirectory(prefix="asa_server_samples_test_")
+ self.runtime = AnalysisRuntime(Path(self.tempdir.name) / "runtime")
+ result = self.runtime.create_run(
+ filename="test.wav",
+ content=b"\x00" * 256,
+ mime_type="audio/wav",
+ pitch_note_mode="off",
+ pitch_note_backend="auto",
+ interpretation_mode="off",
+ interpretation_profile="default",
+ interpretation_model=None,
+ )
+ self.run_id = result["runId"]
+
+ def tearDown(self) -> None:
+ self.tempdir.cleanup()
+
+ def test_generation_succeeds_with_phase1_only(self) -> None:
+ # Phase 2 absent is acceptable; we still emit tonal/drum samples.
+ snapshot = _baseline_snapshot(phase2_completed=False)
+ manifest = server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ force=False,
+ prefer_fluidsynth=False,
+ )
+ self.assertEqual(manifest["schemaVersion"], "samples.v1")
+ self.assertIn("manifestArtifactId", manifest)
+ # Every sample carries an artifactId for the frontend to dereference.
+ for sample in manifest["samples"]:
+ self.assertIn(
+ "artifactId",
+ sample,
+ f"sample {sample['id']} missing artifactId",
+ )
+
+ def test_rejects_when_measurement_not_completed(self) -> None:
+ snapshot = _baseline_snapshot()
+ snapshot["stages"]["measurement"]["status"] = "running"
+ with self.assertRaises(server_samples.SamplesPreconditionError) as ctx:
+ server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ prefer_fluidsynth=False,
+ )
+ self.assertEqual(ctx.exception.code, "MEASUREMENT_NOT_COMPLETED")
+ self.assertEqual(ctx.exception.status_code, 409)
+
+ def test_rejects_regeneration_unless_force(self) -> None:
+ snapshot = _baseline_snapshot()
+ server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ prefer_fluidsynth=False,
+ )
+ with self.assertRaises(server_samples.SamplesPreconditionError) as ctx:
+ server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ force=False,
+ prefer_fluidsynth=False,
+ )
+ self.assertEqual(ctx.exception.code, "SAMPLES_ALREADY_GENERATED")
+
+ def test_force_regenerates(self) -> None:
+ snapshot = _baseline_snapshot()
+ first = server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ prefer_fluidsynth=False,
+ )
+ second = server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ force=True,
+ prefer_fluidsynth=False,
+ )
+ # New manifest artifact id; the SQLite ids are UUIDs so they will differ.
+ self.assertNotEqual(first["manifestArtifactId"], second["manifestArtifactId"])
+
+ def test_fetch_existing_returns_none_before_generation(self) -> None:
+ self.assertIsNone(
+ server_samples.fetch_existing_manifest(
+ runtime=self.runtime, run_id=self.run_id
+ )
+ )
+
+ def test_fetch_existing_returns_decorated_manifest_after_generation(self) -> None:
+ snapshot = _baseline_snapshot()
+ created = server_samples.generate_and_register_samples(
+ runtime=self.runtime,
+ run_id=self.run_id,
+ snapshot=snapshot,
+ prefer_fluidsynth=False,
+ )
+ fetched = server_samples.fetch_existing_manifest(
+ runtime=self.runtime, run_id=self.run_id
+ )
+ self.assertIsNotNone(fetched)
+ assert fetched is not None # type narrow for the static checker
+ # Same sample IDs, each with an artifactId.
+ created_ids = {s["id"] for s in created["samples"]}
+ fetched_ids = {s["id"] for s in fetched["samples"]}
+ self.assertEqual(created_ids, fetched_ids)
+ for sample in fetched["samples"]:
+ self.assertIn("artifactId", sample)
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/apps/backend/tests/test_spectral_viz.py b/apps/backend/tests/test_spectral_viz.py
index e431aa0b..6b4c6647 100644
--- a/apps/backend/tests/test_spectral_viz.py
+++ b/apps/backend/tests/test_spectral_viz.py
@@ -390,5 +390,134 @@ def test_json_has_required_keys(self) -> None:
self.assertEqual(len(data["timePoints"]), len(data["onsetStrength"]))
+class ReassignedSpectrogramTests(unittest.TestCase):
+ """Tests for the opt-in librosa.reassigned_spectrogram enhancement.
+
+ The generator is gated behind the existing
+ POST /api/analysis-runs/{run_id}/spectral-enhancements/reassigned
+ route and produces a single PNG artifact. Tests assert it runs to
+ completion and writes a valid PNG of non-trivial size — the visual
+ quality of the sharpening is empirical and not test-asserted.
+ """
+
+ def setUp(self) -> None:
+ self.temp_dir = tempfile.TemporaryDirectory(prefix="reassigned_test_")
+ self.audio_path = os.path.join(self.temp_dir.name, "test.wav")
+ _create_test_wav(self.audio_path)
+
+ def tearDown(self) -> None:
+ self.temp_dir.cleanup()
+
+ def test_produces_single_reassigned_png(self) -> None:
+ from spectral_viz import generate_reassigned_spectrogram
+
+ out_dir = os.path.join(self.temp_dir.name, "out")
+ result = generate_reassigned_spectrogram(self.audio_path, out_dir)
+
+ self.assertEqual(list(result.keys()), ["spectrogram_reassigned"])
+ path = result["spectrogram_reassigned"]
+ self.assertTrue(os.path.isfile(path), f"reassigned PNG missing: {path}")
+ self.assertGreater(
+ os.path.getsize(path),
+ 1000,
+ "reassigned PNG suspiciously small — likely empty plot.",
+ )
+
+ def test_output_is_valid_png(self) -> None:
+ from spectral_viz import generate_reassigned_spectrogram
+
+ out_dir = os.path.join(self.temp_dir.name, "out")
+ result = generate_reassigned_spectrogram(self.audio_path, out_dir)
+ with open(result["spectrogram_reassigned"], "rb") as f:
+ header = f.read(8)
+ self.assertEqual(header[:4], b"\x89PNG")
+
+ def test_scatter_point_cap_is_enforced_for_long_inputs(self) -> None:
+ """Per-frame scatter rendering can produce 15M+ points on a
+ 10-min track. The cap (REASSIGNED_MAX_SCATTER_POINTS) keeps
+ matplotlib render time bounded. This test asserts the cap is
+ actually applied — patches ax.scatter to count call args.
+ """
+ from unittest import mock as _mock
+
+ from spectral_viz import (
+ REASSIGNED_MAX_SCATTER_POINTS,
+ generate_reassigned_spectrogram,
+ )
+
+ # Generate a ~60 s audio fixture so the raw point count is well
+ # above the cap. At n_fft=2048 / hop=1024 / sr=44100 / 60 s
+ # → ~2585 frames × 1025 bins ≈ 2.6M raw points; with the floor
+ # mask, ~1M+ survivors — comfortably above 300K.
+ long_path = os.path.join(self.temp_dir.name, "long.wav")
+ sr = 44100
+ duration = 60.0
+ t = np.linspace(0, duration, int(sr * duration), endpoint=False)
+ # Multi-component signal so the floor mask doesn't gate too
+ # aggressively (a single sine would mask down to a thin line).
+ signal = (
+ 0.4 * np.sin(2 * np.pi * 220 * t)
+ + 0.3 * np.sin(2 * np.pi * 660 * t)
+ + 0.2 * np.sin(2 * np.pi * 1320 * t)
+ + 0.1 * np.sin(2 * np.pi * 3000 * t)
+ )
+ pcm = (signal * 32767).clip(-32767, 32767).astype(np.int16)
+ with wave.open(long_path, "w") as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sr)
+ wf.writeframes(pcm.tobytes())
+
+ # Patch matplotlib's scatter to capture the call args without
+ # actually rendering — we only need to know how many points
+ # would have been drawn.
+ with _mock.patch(
+ "matplotlib.axes._axes.Axes.scatter", autospec=True
+ ) as mock_scatter:
+ out_dir = os.path.join(self.temp_dir.name, "long_out")
+ generate_reassigned_spectrogram(long_path, out_dir)
+
+ self.assertTrue(mock_scatter.called, "scatter must have been called")
+ # First positional arg after `self` is the x array; size is the
+ # number of points actually rendered.
+ call_args = mock_scatter.call_args
+ x_array = call_args.args[1] if len(call_args.args) >= 2 else call_args.kwargs.get("x")
+ self.assertIsNotNone(x_array)
+ self.assertLessEqual(
+ len(x_array),
+ REASSIGNED_MAX_SCATTER_POINTS,
+ f"Expected at most {REASSIGNED_MAX_SCATTER_POINTS} scatter "
+ f"points after cap, got {len(x_array)}.",
+ )
+
+ def test_handles_silent_input_without_raising(self) -> None:
+ """A nearly-silent input would produce all-NaN reassignment
+ coordinates without ``fill_nan=True``. Guard the generator
+ against that by running on a silent fixture and asserting it
+ doesn't raise.
+ """
+ from spectral_viz import generate_reassigned_spectrogram
+
+ silent_path = os.path.join(self.temp_dir.name, "silent.wav")
+ sr = 44100
+ # 2 seconds of "silence" with imperceptible dither so librosa's
+ # reassignment math is well-defined.
+ signal = np.random.default_rng(0).normal(0.0, 1e-6, sr * 2)
+ pcm = (signal * 32767).clip(-32767, 32767).astype(np.int16)
+ with wave.open(silent_path, "w") as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sr)
+ wf.writeframes(pcm.tobytes())
+
+ out_dir = os.path.join(self.temp_dir.name, "silent_out")
+ result = generate_reassigned_spectrogram(silent_path, out_dir)
+ # The PNG should exist even if the scatter is sparse/empty —
+ # axes + colorbar geometry is still rendered.
+ self.assertTrue(
+ os.path.isfile(result["spectrogram_reassigned"])
+ )
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/apps/backend/tests/test_stage_status.py b/apps/backend/tests/test_stage_status.py
new file mode 100644
index 00000000..5d182f4c
--- /dev/null
+++ b/apps/backend/tests/test_stage_status.py
@@ -0,0 +1,140 @@
+"""Unit tests for stage_status — the 8 → 5 public-status collapse.
+
+The collapse is the documented contract for ``publicStatus`` on every
+stage object in the run snapshot. Tests here pin the mapping cell-by-cell
+so a quiet edit to the lookup table is caught.
+"""
+
+import sys
+import unittest
+from pathlib import Path
+
+
+_BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(_BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(_BACKEND_ROOT))
+
+
+import stage_status # noqa: E402 — load after sys.path is set
+
+
+class PublicStatusValuesTests(unittest.TestCase):
+ """The five public-facing values, exhaustively."""
+
+ def test_exact_five_values(self):
+ self.assertEqual(
+ stage_status.PUBLIC_STATUS_VALUES,
+ frozenset({"queued", "running", "completed", "failed", "interrupted"}),
+ )
+
+ def test_helper_returns_same_set(self):
+ self.assertEqual(
+ stage_status.public_status_values(),
+ stage_status.PUBLIC_STATUS_VALUES,
+ )
+
+
+class ToPublicStatusTests(unittest.TestCase):
+ """Cell-by-cell mapping pins for the eight internal states."""
+
+ def test_queued_maps_to_queued(self):
+ self.assertEqual(stage_status.to_public_status("queued"), "queued")
+
+ def test_running_maps_to_running(self):
+ self.assertEqual(stage_status.to_public_status("running"), "running")
+
+ def test_blocked_maps_to_queued(self):
+ """``blocked`` is transient internal scheduling; clients see it
+ the same as ``queued``."""
+ self.assertEqual(stage_status.to_public_status("blocked"), "queued")
+
+ def test_ready_maps_to_queued(self):
+ """``ready`` means scheduled but not yet started; same as queued
+ from the client's perspective."""
+ self.assertEqual(stage_status.to_public_status("ready"), "queued")
+
+ def test_completed_maps_to_completed(self):
+ self.assertEqual(stage_status.to_public_status("completed"), "completed")
+
+ def test_failed_maps_to_failed(self):
+ self.assertEqual(stage_status.to_public_status("failed"), "failed")
+
+ def test_interrupted_maps_to_interrupted(self):
+ self.assertEqual(
+ stage_status.to_public_status("interrupted"), "interrupted"
+ )
+
+ def test_not_requested_maps_to_none(self):
+ """``not_requested`` means the caller didn't ask for this stage;
+ conceptually distinct from queued. Exposed as ``null`` in JSON."""
+ self.assertIsNone(stage_status.to_public_status("not_requested"))
+
+
+class ToPublicStatusDefensiveTests(unittest.TestCase):
+ """Defensive cases: nones, unknowns, and the empty string."""
+
+ def test_none_returns_none(self):
+ self.assertIsNone(stage_status.to_public_status(None))
+
+ def test_unknown_internal_status_returns_none(self):
+ """A new internal-only state added without a public mapping
+ should fall through as None rather than leak through. This is
+ the forwards-compat guard."""
+ self.assertIsNone(stage_status.to_public_status("does_not_exist"))
+
+ def test_empty_string_returns_none(self):
+ self.assertIsNone(stage_status.to_public_status(""))
+
+ def test_case_sensitive(self):
+ """The internal vocabulary is lowercase. Uppercase shouldn't
+ match — the mapping table is case-sensitive by design."""
+ self.assertIsNone(stage_status.to_public_status("QUEUED"))
+ self.assertIsNone(stage_status.to_public_status("Running"))
+
+
+class MappingCompletenessTests(unittest.TestCase):
+ """Pins the set of internal states the mapping covers.
+
+ If a new internal status is added without a corresponding entry
+ here, this test fails. If an old one is removed, this test fails.
+ Either way, the developer must make a deliberate choice about the
+ public mapping rather than silently drift.
+ """
+
+ EXPECTED_INTERNAL_STATES = frozenset(
+ {
+ "queued",
+ "running",
+ "blocked",
+ "ready",
+ "completed",
+ "failed",
+ "interrupted",
+ "not_requested",
+ }
+ )
+
+ def test_internal_states_unchanged(self):
+ actual = frozenset(stage_status._INTERNAL_TO_PUBLIC.keys())
+ self.assertEqual(actual, self.EXPECTED_INTERNAL_STATES)
+
+ def test_all_non_null_mappings_are_public_values(self):
+ """Every non-None target of the mapping must be in
+ PUBLIC_STATUS_VALUES — no smuggling new public states in via
+ the mapping table without updating the canonical set."""
+ for internal, public in stage_status._INTERNAL_TO_PUBLIC.items():
+ if public is None:
+ continue
+ self.assertIn(
+ public,
+ stage_status.PUBLIC_STATUS_VALUES,
+ msg=(
+ f"{internal!r} maps to {public!r}, which is not in "
+ f"PUBLIC_STATUS_VALUES. Either add it to the public "
+ f"set or change the mapping."
+ ),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/ui/AGENTS.md b/apps/ui/AGENTS.md
index 31a1c17f..1dbce3c9 100644
--- a/apps/ui/AGENTS.md
+++ b/apps/ui/AGENTS.md
@@ -10,7 +10,7 @@
## Working Style For Agents
- Prefer small, reviewable edits over broad UI rewrites.
-- Preserve the backend contract enforced by `src/services/backendPhase1Client.ts` and `src/types.ts`.
+- Preserve the backend contract enforced by `src/services/analysisRunsClient.ts` (canonical), `src/services/backendPhase1Client.ts` (legacy wrappers), and `src/types.ts`.
- Read `README.md` before changing scripts, env handling, smoke tests, or backend integration behavior.
- Read `../../docs/ARCHITECTURE_STRATEGY.md` before proposing changes to the Session Musician panel, transcription display, or the Layer 1/2/3 UI structure. The strategy doc explains the two-path transcription design (local MIDI vs Gemini description) and what each layer is responsible for.
- Keep bundle-size-sensitive patterns in place unless there is a clear reason to change them.
@@ -88,9 +88,10 @@ RUN_GEMINI_LIVE_SMOKE=true VITE_ENABLE_PHASE2_GEMINI=true GEMINI_API_KEY=your_ke
## File Map
- `src/App.tsx`: upload flow, estimate flow, phase orchestration, diagnostic log state.
-- `src/services/backendPhase1Client.ts`: backend transport, parsing, timeout handling, error mapping.
-- `src/services/analyzer.ts`: phase orchestration and Gemini entry.
-- `src/types.ts`: shared frontend contract types.
+- `src/services/analysisRunsClient.ts`: canonical transport — creates runs against `/api/analysis-runs`, polls stage snapshots, fetches pitch/note translations and interpretations.
+- `src/services/backendPhase1Client.ts`: legacy multipart transport (typed errors, `AbortController` timeouts). Kept only for the compatibility wrappers; new flows go through `analysisRunsClient.ts`.
+- `src/services/analyzer.ts`: phase orchestration — sequences run creation, polling, and display payload projection.
+- `src/types.ts` + `src/types/`: shared frontend contract types. `types.ts` is a barrel re-export of `./types/{measurement,interpretation,backend}.ts`; `./types/samples.ts` exists but is imported directly, not through the barrel.
- `src/index.css`: Tailwind theme tokens and visual language.
- `tests/services/*`: unit and service tests.
- `tests/smoke/*`: smoke and live smoke coverage.
diff --git a/apps/ui/README.md b/apps/ui/README.md
index 1f74a927..fc851cd1 100644
--- a/apps/ui/README.md
+++ b/apps/ui/README.md
@@ -24,6 +24,7 @@ The app uploads a track to the local DSP backend, shows the estimate and executi
- quantize grid and swing controls
- browser preview and `.mid` download
- JSON export and markdown report export
+- Phase 3 audition-sample playback panel — on-demand heuristic WAV/MIDI clips with citation metadata, requested after interpretation completes
- collapsible diagnostic log with request IDs, durations, estimate ranges, and backend or Gemini status
- semantic theme token system for status colors and surface backgrounds
- mobile-responsive layouts across header, results grid, and upload flow
@@ -139,7 +140,10 @@ Legacy note:
## Backend Contract Used by the UI
-The app talks to two backend routes.
+The two routes below drive the core upload-and-analyze flow. The app also calls
+the canonical run sub-resources — artifacts, source audio, CSV export, spectral
+enhancements, pitch/note translations, interpretations, and on-demand audition
+samples — through `src/services/analysisRunsClient.ts` and its sibling clients.
### `POST /api/analysis-runs/estimate`
diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx
index 98b006b7..47ce64af 100644
--- a/apps/ui/src/components/AnalysisResults.tsx
+++ b/apps/ui/src/components/AnalysisResults.tsx
@@ -28,6 +28,7 @@ import { motion } from 'motion/react';
import { downloadFile, generateMarkdown } from '../utils/exportUtils';
import { INTERPRETATION_LABEL } from '../services/phaseLabels';
import { MeasurementDashboard } from './MeasurementDashboard';
+import { SamplePlayback } from './SamplePlayback';
import { SessionMusicianPanel } from './SessionMusicianPanel';
import { StemListeningNotesPanel } from './StemListeningNotesPanel';
import { hasStemListeningNotesContent } from '../services/sessionMusician';
@@ -2290,6 +2291,13 @@ export function AnalysisResults({
runId={runId}
/>
+ {apiBaseUrl && runId && (
+
+ )}
);
}
diff --git a/apps/ui/src/components/SamplePlayback.tsx b/apps/ui/src/components/SamplePlayback.tsx
new file mode 100644
index 00000000..ffe57133
--- /dev/null
+++ b/apps/ui/src/components/SamplePlayback.tsx
@@ -0,0 +1,300 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { SampleRecord, SamplesManifest } from '../types/samples';
+import {
+ artifactStreamUrl,
+ fetchExistingManifest,
+ generateSamples,
+} from '../services/sampleGenerationClient';
+import { BackendClientError } from '../services/backendPhase1Client';
+
+interface SamplePlaybackProps {
+ runId: string | null | undefined;
+ apiBaseUrl: string;
+ /**
+ * If false, the panel renders an explanatory placeholder rather than the
+ * generate button. The caller passes false when measurement isn't complete
+ * yet — there's nothing to audition against.
+ */
+ measurementCompleted: boolean;
+}
+
+type PanelStatus =
+ | { kind: 'idle' }
+ | { kind: 'loading' }
+ | { kind: 'generating' }
+ | { kind: 'error'; message: string };
+
+const CATEGORY_LABELS: Record = {
+ tonal: 'Tonal — key & chord foundation',
+ drums: 'Drum kit',
+ melody: 'Melody / lead phrase',
+};
+
+/**
+ * Audition panel that renders generated audio clips for the current run.
+ *
+ * Honest-uncertainty framing is non-negotiable per `PURPOSE.md`: these clips
+ * are heuristic reconstructions of the measurement layer, not Ableton-accurate
+ * renderings. The panel says so up top, every time.
+ */
+export function SamplePlayback({
+ runId,
+ apiBaseUrl,
+ measurementCompleted,
+}: SamplePlaybackProps): React.ReactElement | null {
+ const [manifest, setManifest] = useState(null);
+ const [status, setStatus] = useState({ kind: 'idle' });
+
+ // On mount, see if a manifest already exists for this run.
+ useEffect(() => {
+ if (!runId) return;
+ let cancelled = false;
+ const abort = new AbortController();
+ setStatus({ kind: 'loading' });
+ fetchExistingManifest(runId, { apiBaseUrl, signal: abort.signal })
+ .then((existing) => {
+ if (cancelled) return;
+ setManifest(existing);
+ setStatus({ kind: 'idle' });
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setStatus({ kind: 'error', message: friendlyError(err) });
+ });
+ return () => {
+ cancelled = true;
+ abort.abort();
+ };
+ }, [runId, apiBaseUrl]);
+
+ const handleGenerate = useCallback(
+ async (force: boolean) => {
+ if (!runId) return;
+ setStatus({ kind: 'generating' });
+ try {
+ const next = await generateSamples(runId, { apiBaseUrl, force });
+ setManifest(next);
+ setStatus({ kind: 'idle' });
+ } catch (err) {
+ setStatus({ kind: 'error', message: friendlyError(err) });
+ }
+ },
+ [runId, apiBaseUrl],
+ );
+
+ const groupedSamples = useMemo<
+ Array<[SampleRecord['category'], SampleRecord[]]>
+ >(() => {
+ if (!manifest) return [];
+ const groups = new Map();
+ for (const sample of manifest.samples) {
+ const list = groups.get(sample.category) ?? [];
+ list.push(sample);
+ groups.set(sample.category, list);
+ }
+ return Array.from(groups.entries());
+ }, [manifest]);
+
+ if (!runId) return null;
+
+ return (
+
+
+
+
+ Audition samples (Phase 3 — heuristic)
+
+
+ Short clips derived from Phase 1 measurements (and Phase 2 context when
+ available) so you can ear-check the measurement chain. These are not
+ Ableton-accurate reconstructions — follow Phase 2 in Live for the
+ production character.
+