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. +

+
+ {manifest && ( + + )} +
+ + {!measurementCompleted && ( +

+ Measurements still running — audition samples become available once Phase 1 + completes. +

+ )} + + {measurementCompleted && !manifest && status.kind !== 'generating' && ( + + )} + + {status.kind === 'generating' && ( +

Rendering audition clips…

+ )} + + {status.kind === 'error' && ( +

+ {status.message} +

+ )} + + {manifest && groupedSamples.length > 0 && ( +
+ + {groupedSamples.map(([category, samples]) => ( + + + + ))} +
+ )} +
+ ); +} + +function ManifestMeta({ manifest }: { manifest: SamplesManifest }) { + const synthesisLabel = + manifest.synthesisBackend === 'fluidsynth' + ? 'FluidSynth + GM SoundFont' + : 'NumPy sine-additive fallback'; + const theoryLabel = + manifest.theoryBackend === 'pytheory' + ? 'PyTheory' + : 'Pure-Python theory fallback'; + return ( +
+ + Music theory: {theoryLabel} + + + Synthesis: {synthesisLabel} + +
+ ); +} + +interface SampleGroupProps { + category: SampleRecord['category']; + samples: SampleRecord[]; + runId: string; + apiBaseUrl: string; +} + +function SampleGroup({ category, samples, runId, apiBaseUrl }: SampleGroupProps) { + return ( +
+

+ {CATEGORY_LABELS[category]} +

+
    + {samples.map((sample) => ( + + + + ))} +
+
+ ); +} + +interface SampleCardProps { + sample: SampleRecord; + runId: string; + apiBaseUrl: string; +} + +function SampleCard({ sample, runId, apiBaseUrl }: SampleCardProps) { + const audioUrl = sample.artifactId + ? artifactStreamUrl(runId, sample.artifactId, apiBaseUrl) + : null; + const midiUrl = sample.midiArtifactId + ? artifactStreamUrl(runId, sample.midiArtifactId, apiBaseUrl) + : null; + + return ( +
  • +
    + {sample.label} + + {sample.lowConfidence ? 'Low confidence' : `${sample.confidence} confidence`} + +
    + {audioUrl ? ( +
  • + ); +} + +function confidenceClassFor( + confidence: SampleRecord['confidence'], + lowConfidence: boolean, +): string { + if (lowConfidence || confidence === 'LOW') return 'bg-amber-900/40 text-amber-300'; + if (confidence === 'MED') return 'bg-zinc-700 text-zinc-200'; + return 'bg-emerald-900/40 text-emerald-300'; +} + +function friendlyError(err: unknown): string { + if (err instanceof BackendClientError) return err.message; + if (err instanceof Error) return err.message; + return 'Unknown error while talking to the sample generation service.'; +} diff --git a/apps/ui/src/services/analysisRunsClient.ts b/apps/ui/src/services/analysisRunsClient.ts index 24f86fae..2ad68371 100644 --- a/apps/ui/src/services/analysisRunsClient.ts +++ b/apps/ui/src/services/analysisRunsClient.ts @@ -18,6 +18,7 @@ import { StemSummaryResult, PitchNoteTranslationAttemptSummary, PitchNoteTranslationStageSnapshot, + PublicStageStatus, } from '../types'; import { parsePhase1Result } from './backendPhase1Client'; import { requestBackendEstimate } from './backendPhase1Client'; @@ -60,6 +61,17 @@ const ANALYSIS_RUN_STATUSES = new Set([ 'not_requested', ]); +// Mirror of stage_status.PUBLIC_STATUS_VALUES on the backend. Five-value +// collapse exposed as the additive `publicStatus` field; the Python +// source of truth is `apps/backend/stage_status.py`. +const PUBLIC_STAGE_STATUSES = new Set([ + 'queued', + 'running', + 'completed', + 'failed', + 'interrupted', +]); + export async function estimateAnalysisRun( file: File, options: EstimateAnalysisRunOptions, @@ -288,6 +300,7 @@ function parseAnalysisRunSnapshot(value: unknown): AnalysisRunSnapshot { stages: { measurement: { status: expectStageStatus(measurement.status), + publicStatus: parsePublicStageStatus(measurement.publicStatus), authoritative: true, result: measurement.result == null ? null : parseCanonicalMeasurementResult(measurement.result), provenance: parseNullableRecord(measurement.provenance), @@ -354,6 +367,7 @@ function parseSpectralArtifacts(value: unknown): SpectralArtifacts { function parsePitchNoteStage(value: Record): PitchNoteTranslationStageSnapshot { return { status: expectStageStatus(value.status), + publicStatus: parsePublicStageStatus(value.publicStatus), authoritative: false, preferredAttemptId: asString(value.preferredAttemptId), attemptsSummary: Array.isArray(value.attemptsSummary) @@ -385,6 +399,7 @@ function parseInterpretationStage(value: Record): Interpretatio : undefined; return { status: expectStageStatus(value.status), + publicStatus: parsePublicStageStatus(value.publicStatus), authoritative: false, preferredAttemptId: asString(value.preferredAttemptId), attemptsSummary, @@ -684,6 +699,21 @@ function expectStageStatus(value: unknown): AnalysisStageStatus { return value as AnalysisStageStatus; } +/** + * Parse the additive `publicStatus` field on a stage. Returns `null` for + * either a JSON null (the documented "not in pipeline" signal) or for + * missing values on legacy snapshots that pre-date the field — callers + * that need the public collapse can use this without crashing on + * older runs. + */ +function parsePublicStageStatus(value: unknown): PublicStageStatus | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return null; + return PUBLIC_STAGE_STATUSES.has(value as PublicStageStatus) + ? (value as PublicStageStatus) + : null; +} + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } diff --git a/apps/ui/src/services/sampleGenerationClient.ts b/apps/ui/src/services/sampleGenerationClient.ts new file mode 100644 index 00000000..40afcc32 --- /dev/null +++ b/apps/ui/src/services/sampleGenerationClient.ts @@ -0,0 +1,159 @@ +/** + * Typed HTTP client for the Phase 3 audition-sample endpoints. + * + * Endpoints: + * - POST /api/analysis-runs/{run_id}/samples — generate (returns manifest) + * - GET /api/analysis-runs/{run_id}/samples — fetch existing manifest + * - GET /api/analysis-runs/{run_id}/artifacts/{id} — stream a WAV/MIDI + * + * The first two return the same manifest shape; the third is a binary stream + * we expose as a URL the consumer can hand to an