From 47a2e7cc2ec5e060b5b1f8ab544e4f5ad1f32132 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:24:35 +0000 Subject: [PATCH 01/23] feat(analysis): enforce one canonical audio resource policy (#781) Admit local and YouTube audio through one versioned 15-minute / 100 MiB / mono-stereo budget before decode or feature DSP. Rejection copy names the next song to choose and stays payload-free. --- AGENTS.md | 2 + ARCHITECTURE.md | 11 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- docs/architecture/overview.md | 1 + docs/doctoring/audio-resource-policy.md | 90 ++++++ docs/security/app-security.md | 1 + .../audio_resource_policy.py | 233 ++++++++++++++++ .../chords/chord_recognizer.py | 7 +- .../separation/audio_separator.py | 31 ++- .../bandscope_analysis/temporal/analyzer.py | 34 ++- .../bandscope_analysis/transcription/api.py | 20 +- .../src/bandscope_analysis/youtube.py | 49 ++-- .../tests/test_audio_resource_policy.py | 260 ++++++++++++++++++ .../tests/test_chord_recognizer.py | 10 + .../analysis-engine/tests/test_separation.py | 2 +- .../analysis-engine/tests/test_temporal.py | 4 +- .../tests/test_transcription.py | 14 + .../analysis-engine/tests/test_youtube.py | 37 ++- 19 files changed, 759 insertions(+), 50 deletions(-) create mode 100644 docs/doctoring/audio-resource-policy.md create mode 100644 services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py create mode 100644 services/analysis-engine/tests/test_audio_resource_policy.py diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..f9512aad7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - `docs/operations/deploy-runbook.md` - `docs/brand-story.md` - `docs/security/app-security.md` +- `docs/doctoring/audio-resource-policy.md` - `docs/security/dependency-policy.md` - `docs/security/cross-platform-build-policy.md` - `docs/workflow/github-bootstrap-execution-policy.md` @@ -90,6 +91,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Do not add network-dependent runtime paths for local analysis. - Treat YouTube import as policy-constrained and fallback-friendly. - Treat files, URLs, metadata, model artifacts, and project files as untrusted input. +- Apply the canonical audio resource policy in `bandscope_analysis.audio_resource_policy` (15 minutes / 100 MiB / mono-stereo / 44.1 kHz target) before decode or feature DSP. Do not invent a five-minute cap or a silent 120-second transcription window. Rejection copy must name the next rehearsal action and stay payload-free. - Do not add generic exec/read/write APIs. - Use `shell=False`-style subprocess invocation with argument arrays only. - Keep local backend access on allowlisted IPC or `127.0.0.1` only, with strict schema validation. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..67ac495d8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-23 ## Brand source @@ -55,6 +55,15 @@ Last updated: 2026-03-11 - Split privilege where feasible across UI, analysis workers, subprocesses, model delivery, and updater behavior. - Fail safely when a link, file, artifact, or boundary cannot be validated. +## Canonical audio resource policy + +- Local audio admission is versioned in `services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py` and documented in `docs/doctoring/audio-resource-policy.md`. +- Policy version 1 admits one rehearsal recording up to 15 minutes, 100 MiB encoded, mono or stereo, with source rates from 8 kHz through 192 kHz and a 44.1 kHz analysis target. +- Feature analyzers must not invent a shorter silent cap (including the former 120-second transcription window or a five-minute chord guard). Feature DSP resampling happens after canonical validation. +- Encoded size is checked before decode; decoded layout, sample count, duration, and memory are revalidated because container metadata is untrusted. +- Size conversions use checked arithmetic and fail closed on overflow or non-finite metadata. User-facing copy is payload-free and names the next file-selection action. +- YouTube import uses the same 15-minute / 100 MiB ceiling as local files. + ## Repository map - `apps/desktop` - desktop shell and user-facing React UI diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..aa44ba720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Canonical local-audio resource policy (15 minutes / 100 MiB / mono-stereo / 44.1 kHz target) shared by temporal analysis, stem separation, YouTube import, bass transcription, and chord recognition. Oversized or malformed songs now name the next file to choose instead of echoing sizes. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..5ca97b992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Three layers, decoupled through shared contracts: - `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. -- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. +- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. `audio_resource_policy.py` is the versioned 15-minute / 100 MiB local-audio budget shared by those modules; feature DSP rates (for example bass pYIN at 22050 Hz) run only after that admission check. Data flow: React UI → Tauri IPC command → Rust validation + Python subprocess over stdin/stdout → job status and progress events emitted back to the UI. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..7232ea191 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -35,6 +35,7 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code - prefer local processing for audio and analysis - keep risky capabilities narrow, allowlisted, and explicit - treat files, URLs, models, caches, and release artifacts as untrusted inputs +- admit local and YouTube audio through one canonical resource policy (15 minutes / 100 MiB / mono-stereo) before decode or feature DSP - route orchestration through typed Tauri IPC and a narrow Python subprocess bridge before considering any loopback HTTP surface - bootstrap local audio projects by validating the selected file in Rust, then passing only typed source metadata through the orchestration boundary - keep project and temp/cache bootstrap roots under Tauri-resolved app-owned directories rather than the shared OS temp namespace diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md new file mode 100644 index 000000000..f5f4c49e8 --- /dev/null +++ b/docs/doctoring/audio-resource-policy.md @@ -0,0 +1,90 @@ +# Canonical audio resource policy + +BandScope admits one rehearsal recording at a time. Every intake path, decoder, +and feature analyzer must apply the same versioned resource budget before +expensive work starts. + +## Published policy (version 1) + +| Bound | Value | Why this number | +| --- | --- | --- | +| Encoded file bytes | 100 MiB inclusive | Existing temporal intake ceiling; long enough for a 15-minute stereo rehearsal capture without inviting decompression bombs. | +| Duration | 0.05 s through 15 minutes inclusive | Existing temporal and YouTube intake evidence. Not an invented five-minute cap. | +| Source sampling rate | 8 kHz through 192 kHz | Covers phone voice notes through high-rate interface captures. Feature DSP may resample after admission (bass pYIN at 22 050 Hz is allowed). | +| Target sampling rate | 44 100 Hz | Compact-disc PCM rate used by temporal analysis and stem separation. | +| Channel count | Mono or stereo | Rehearsal recordings are not multichannel session stems. | +| Decoded sample count | `15 × 60 × 44100` | Checked product of duration and target rate. | +| Decoded memory | sample count × 2 channels × 4 bytes | Float32 stereo estimate; overflow fails closed. | + +## Next action copy + +Rejection copy is payload-free. It names the next rehearsal action and never +echoes paths, sizes, durations, or header bytes: + +- Choose a shorter or smaller song file to start analysis. +- Choose a song shorter than 15 minutes to start analysis. +- Choose a longer song file to start analysis. +- Choose a WAV, MP3, FLAC, or M4A file recorded at a standard sample rate. +- Choose a mono or stereo song file to start analysis. +- Choose another song file. This one could not be measured safely. +- Choose another song file. This one could not be read as audio. + +Audit metadata records `policy_version` and `reason` on +`AudioResourcePolicyError`. Those fields stay off the user-facing string. + +## Validation order + +1. Encoded byte size, before open/decode, where the filesystem size is + trustworthy as an upper bound. +2. YouTube metadata duration ceiling, before download. +3. Decode with the canonical duration bound as a loader safety cap, not as a + silent shorter feature policy. +4. Revalidate decoded arrays because container metadata is untrusted: layout, + sampling rate, sample count, wall-clock duration, and memory estimate. +5. Feature DSP (chromagram hop, pYIN 22 050 Hz, Demucs split) runs only on an + admitted buffer. + +## Consumers + +- `bandscope_analysis.audio_resource_policy` — versioned policy and validators +- `temporal.analyzer` — local file preflight and decoded revalidation +- `separation.audio_separator` — stem decode preflight and decoded revalidation +- `youtube.download_youtube_audio` — duration ceiling and 100 MiB encoded budget +- `transcription.api` — stem byte budget and 15-minute loader cap (no 120 s silent cap) +- `chords.chord_recognizer` — decoded revalidation at `recognize()` + +Desktop Rust currently records `file_size_bytes` at intake but does not yet +enforce this ceiling. The Python engine remains fail-closed if a larger file +reaches analysis. + +## Rollback + +Revert this slice to restore feature-local limits (YouTube 50 MiB, bass +transcription 120 s, payload-bearing size errors). Do not leave a mix of +canonical validators and the old silent caps on the same branch. + +## Security Notes + +- Attack surface: untrusted local files, YouTube containers, decoder output, + and caller-supplied NumPy arrays. +- Trust boundary: this policy classifies resources only. It does not open + files, follow paths, or talk to the network. +- Mitigations: checked integer products, fail-closed non-finite metadata, + payload-free copy, decoded revalidation after untrusted headers. +- Test points: inclusive ceilings, next-byte/next-millisecond rejections, + empty and malformed metadata, decoded expansion, overflow, provenance. +- Realistic threats: decompression bombs, huge channel counts, extreme + sampling rates, integer overflow in size conversions, inconsistent + feature-local caps that fail only after expensive work. +- Remaining risk: desktop encoded-byte preflight still records size without + rejecting; duration still requires a decoder; GPU/VRAM budgets are not + part of policy version 1. + +## References + +International Electrotechnical Commission. (1999). *Compact disc digital audio +system* (IEC 60908). Geneva, Switzerland: IEC. + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/docs/security/app-security.md b/docs/security/app-security.md index a9983fb97..34239a661 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -137,6 +137,7 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Cross-check extension, MIME, and actual decode behavior. - Prefer isolated worker processing for decode and analysis. - Guard against very large files, abnormal duration, and hostile metadata. +- Apply the canonical audio resource policy (`docs/doctoring/audio-resource-policy.md`): 15 minutes, 100 MiB encoded, mono or stereo, 8–192 kHz source, 44.1 kHz analysis target. Check encoded size before decode and revalidate decoded output. Return payload-free copy that names the next song to choose. - Do not add arbitrary filesystem scanning just to find media files. - When bootstrapping a project around local audio, prefer referencing the validated original file plus app-owned temp/cache/project roots over copying the file until persistence requirements justify the extra storage boundary. diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py new file mode 100644 index 000000000..941982b95 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -0,0 +1,233 @@ +"""Canonical local-audio resource policy for BandScope analysis. + +Security Notes: +- Untrusted input: encoded file size, container metadata, decoded arrays, + sampling rate, and channel count supplied by callers or decoders. +- Trust boundary: this module classifies resources only. It does not open + files, decode audio, follow paths, or talk to the network. +- Safe failure: overflow, non-finite values, and policy disagreement fail + closed with payload-free copy that names the next rehearsal action. +- Privacy: rejection messages never include paths, sizes, durations, or + header bytes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, NoReturn + +import numpy as np + +AUDIO_RESOURCE_POLICY_VERSION: Final[int] = 1 +_MAX_SAFE_PRODUCT: Final[int] = 2**63 - 1 + +# 15 minutes is the existing rehearsal-intake evidence (temporal analysis and +# YouTube import), not an invented five-minute cap. +_MAX_DURATION_SECONDS: Final[int] = 15 * 60 +_TARGET_SAMPLING_RATE_HZ: Final[int] = 44100 +_BYTES_PER_DECODED_SAMPLE: Final[int] = 4 +_MAX_CHANNEL_COUNT: Final[int] = 2 + +POLICY_MESSAGES: Final[dict[str, str]] = { + "encoded_file_too_large": "Choose a shorter or smaller song file to start analysis.", + "duration_exceeded": "Choose a song shorter than 15 minutes to start analysis.", + "duration_too_short": "Choose a longer song file to start analysis.", + "sampling_rate_unsupported": ( + "Choose a WAV, MP3, FLAC, or M4A file recorded at a standard sample rate." + ), + "channel_count_unsupported": "Choose a mono or stereo song file to start analysis.", + "decoded_sample_count_exceeded": "Choose a shorter song file to start analysis.", + "memory_budget_exceeded": "Choose a shorter or smaller song file to start analysis.", + "non_finite_metadata": "Choose another song file. This one could not be measured safely.", + "integer_overflow": "Choose another song file. This one could not be measured safely.", + "malformed_header": "Choose another song file. This one could not be read as audio.", +} + + +class AudioResourcePolicyError(ValueError): + """Payload-free rejection of one audio resource against the canonical policy.""" + + def __init__(self, reason: str, message: str) -> None: + """Record the stable reason code together with operator-safe copy.""" + super().__init__(message) + self.reason = reason + self.message = message + self.policy_version = AUDIO_RESOURCE_POLICY_VERSION + + +@dataclass(frozen=True) +class AudioResourcePolicy: + """Versioned bounds shared by desktop intake, IPC, orchestration, and analyzers.""" + + version: int + max_encoded_file_bytes: int + max_duration_seconds: float + min_duration_seconds: float + min_source_sampling_rate_hz: int + max_source_sampling_rate_hz: int + target_sampling_rate_hz: int + min_channel_count: int + max_channel_count: int + max_decoded_sample_count: int + bytes_per_decoded_sample: int + max_decoded_memory_bytes: int + + +def policy_rejection_message(reason: str) -> str: + """Return payload-free copy that names the next rehearsal action.""" + try: + return POLICY_MESSAGES[reason] + except KeyError as error: + raise AudioResourcePolicyError( + "malformed_header", POLICY_MESSAGES["malformed_header"] + ) from error + + +def _raise(reason: str) -> NoReturn: + """Fail closed with the stable reason and payload-free copy.""" + raise AudioResourcePolicyError(reason, policy_rejection_message(reason)) + + +def _checked_int_product(left: int, right: int) -> int: + """Multiply two non-negative integers or fail closed on overflow.""" + if left < 0 or right < 0: + _raise("integer_overflow") + if left != 0 and right > _MAX_SAFE_PRODUCT // left: + _raise("integer_overflow") + return left * right + + +_MAX_DECODED_SAMPLE_COUNT = _checked_int_product(_MAX_DURATION_SECONDS, _TARGET_SAMPLING_RATE_HZ) +_MAX_DECODED_MEMORY_BYTES = _checked_int_product( + _checked_int_product(_MAX_DECODED_SAMPLE_COUNT, _MAX_CHANNEL_COUNT), + _BYTES_PER_DECODED_SAMPLE, +) + +DEFAULT_AUDIO_RESOURCE_POLICY = AudioResourcePolicy( + version=AUDIO_RESOURCE_POLICY_VERSION, + max_encoded_file_bytes=100 * 1024 * 1024, + max_duration_seconds=float(_MAX_DURATION_SECONDS), + min_duration_seconds=0.05, + min_source_sampling_rate_hz=8_000, + max_source_sampling_rate_hz=192_000, + target_sampling_rate_hz=_TARGET_SAMPLING_RATE_HZ, + min_channel_count=1, + max_channel_count=_MAX_CHANNEL_COUNT, + max_decoded_sample_count=_MAX_DECODED_SAMPLE_COUNT, + bytes_per_decoded_sample=_BYTES_PER_DECODED_SAMPLE, + max_decoded_memory_bytes=_MAX_DECODED_MEMORY_BYTES, +) + +MAX_ENCODED_FILE_BYTES = DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes +MAX_DURATION_SECONDS = DEFAULT_AUDIO_RESOURCE_POLICY.max_duration_seconds +TARGET_SAMPLING_RATE_HZ = DEFAULT_AUDIO_RESOURCE_POLICY.target_sampling_rate_hz +MAX_DECODED_SAMPLE_COUNT = DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_sample_count + + +def _require_finite_number(value: object) -> float: + """Return a finite float or fail closed on malformed metadata.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + _raise("non_finite_metadata") + number = float(value) + if not np.isfinite(number): + _raise("non_finite_metadata") + return number + + +def estimate_decoded_memory_bytes( + sample_count: int, + channel_count: int, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> int: + """Return the float32 memory estimate for one decoded buffer, checked for overflow.""" + per_frame = _checked_int_product(channel_count, policy.bytes_per_decoded_sample) + return _checked_int_product(sample_count, per_frame) + + +def validate_encoded_file_bytes( + file_size: object, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject encoded sizes that are missing, non-finite, empty, or over budget.""" + size = _require_finite_number(file_size) + if size != int(size) or size < 0: + _raise("non_finite_metadata") + if int(size) == 0: + _raise("malformed_header") + if int(size) > policy.max_encoded_file_bytes: + _raise("encoded_file_too_large") + + +def validate_duration_seconds( + duration_seconds: object, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject durations that are missing, non-finite, too short, or too long.""" + duration = _require_finite_number(duration_seconds) + if duration < policy.min_duration_seconds: + _raise("duration_too_short") + if duration > policy.max_duration_seconds: + _raise("duration_exceeded") + + +def validate_source_sampling_rate( + sampling_rate_hz: object, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject sampling rates outside the supported rehearsal recording range.""" + rate = _require_finite_number(sampling_rate_hz) + if rate != int(rate) or rate <= 0: + _raise("sampling_rate_unsupported") + hz = int(rate) + if hz < policy.min_source_sampling_rate_hz or hz > policy.max_source_sampling_rate_hz: + _raise("sampling_rate_unsupported") + + +def validate_channel_count( + channel_count: object, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject channel counts outside the mono/stereo rehearsal policy.""" + count = _require_finite_number(channel_count) + if count != int(count): + _raise("channel_count_unsupported") + channels = int(count) + if channels < policy.min_channel_count or channels > policy.max_channel_count: + _raise("channel_count_unsupported") + + +def _array_layout(audio: np.ndarray) -> tuple[int, int]: + """Return ``(channel_count, sample_count)`` for a 1-D or 2-D decoded buffer.""" + if audio.ndim == 1: + return 1, int(audio.size) + if audio.ndim == 2: + first, second = int(audio.shape[0]), int(audio.shape[1]) + if first <= 4 and second >= first: + return first, second + return second, first + _raise("malformed_header") + + +def validate_decoded_audio( + audio: object, + sampling_rate_hz: object, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Revalidate decoded samples because container metadata is untrusted.""" + if not isinstance(audio, np.ndarray) or audio.dtype == object: + _raise("malformed_header") + if audio.size == 0: + _raise("duration_too_short") + if not np.isfinite(audio).all(): + _raise("malformed_header") + validate_source_sampling_rate(sampling_rate_hz, policy) + channel_count, sample_count = _array_layout(audio) + validate_channel_count(channel_count, policy) + if sample_count > policy.max_decoded_sample_count: + _raise("decoded_sample_count_exceeded") + rate = int(_require_finite_number(sampling_rate_hz)) + duration = float(sample_count) / float(rate) + validate_duration_seconds(duration, policy) + memory_bytes = estimate_decoded_memory_bytes(sample_count, channel_count, policy) + if memory_bytes > policy.max_decoded_memory_bytes: + _raise("memory_budget_exceeded") diff --git a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py index 8f6466924..458889de2 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -7,6 +7,7 @@ import numpy as np from .._native import HAVE_RUST, _viterbi_decode_rust +from ..audio_resource_policy import validate_decoded_audio logger = logging.getLogger(__name__) @@ -30,7 +31,9 @@ class ChordRecognizer: Security Notes: - Processes untrusted audio arrays from stem separation. - No file I/O, network access, or shell execution. - - Bounded computation: frame count capped by input duration. + - Revalidates decoded layout, sampling rate, duration, and memory against + the canonical audio resource policy before DSP allocation. + - Bounded computation: frame count capped by the admitted input duration. - Safe failure: exceptions in DSP steps return empty results. """ @@ -399,6 +402,8 @@ def recognize(self, y: np.ndarray, sr: int = 22050) -> list[TrackedChord]: if len(y) == 0: return [] + validate_decoded_audio(y, sr) + y_harmonic = self._separate_harmonic(y) chromagram = self._extract_chromagram(y_harmonic, sr) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c36e0f1fc..681b0f200 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -31,17 +31,24 @@ import librosa import numpy as np -from bandscope_analysis.temporal.analyzer import ( - KNOWN_LIBROSA_NUMBA_WARNING_FILTERS, - MAX_ANALYSIS_DURATION_SECONDS, - MAX_AUDIO_FILE_BYTES, - TARGET_SR, +from bandscope_analysis.audio_resource_policy import ( + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + TARGET_SAMPLING_RATE_HZ, + AudioResourcePolicyError, + policy_rejection_message, + validate_decoded_audio, ) +from bandscope_analysis.temporal.analyzer import KNOWN_LIBROSA_NUMBA_WARNING_FILTERS from .model import AudioSeparationResult, AudioStemArray, AudioStemName, AudioStemPayload logger = logging.getLogger(__name__) +MAX_ANALYSIS_DURATION_SECONDS = MAX_DURATION_SECONDS +MAX_AUDIO_FILE_BYTES = MAX_ENCODED_FILE_BYTES +TARGET_SR = TARGET_SAMPLING_RATE_HZ + # Demucs htdemucs emits these four sources; this is the canonical stem set. _STEM_ORDER: tuple[AudioStemName, ...] = ("vocals", "bass", "drums", "other") _EMPTY_RANGE_EPS = 1e-9 @@ -83,8 +90,6 @@ def separate(self, audio_path: str | Path) -> AudioSeparationResult: """Separate local audio into vocals, bass, drums, and other stems.""" path = self._resolve_audio_file(audio_path) audio, sample_rate = self._load_audio(path) - if audio.size == 0: - raise ValueError(f"Stem separation decode failed for {path.name}") stem_arrays = self._separate_signal(audio, sample_rate) stems: AudioStemPayload = { @@ -195,9 +200,9 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size if file_size > self.config.max_file_bytes: - raise ValueError( - "Audio file is too large for stem separation: " - f"{file_size} bytes (max {self.config.max_file_bytes} bytes)" + raise AudioResourcePolicyError( + "encoded_file_too_large", + policy_rejection_message("encoded_file_too_large"), ) with warnings.catch_warnings(): @@ -223,7 +228,11 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: except Exception as error: raise ValueError(f"Stem separation decode failed for {path.name}") from error - return _as_float_array(y), int(sr) + decoded = _as_float_array(y) + if decoded.size == 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + validate_decoded_audio(decoded, int(sr)) + return decoded, int(sr) def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: """Trim or pad a stem to match the source length exactly.""" diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 7fe5ae6f7..e57505974 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -12,14 +12,23 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_resource_policy import ( + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + TARGET_SAMPLING_RATE_HZ, + AudioResourcePolicyError, + policy_rejection_message, + validate_decoded_audio, +) + from .model import TemporalFeatures logger = logging.getLogger(__name__) -# Standard sample rate for BandScope analysis -TARGET_SR = 44100 -MAX_AUDIO_FILE_BYTES = 100 * 1024 * 1024 # 100 MiB -MAX_ANALYSIS_DURATION_SECONDS = 15 * 60 # 15 minutes +MAX_ANALYSIS_DURATION_SECONDS = MAX_DURATION_SECONDS +MAX_AUDIO_FILE_BYTES = MAX_ENCODED_FILE_BYTES +TARGET_SR = TARGET_SAMPLING_RATE_HZ + KNOWN_LIBROSA_NUMBA_WARNING_FILTERS = ( (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), (FutureWarning, r".*Numba.*", r".*numba.*"), @@ -78,10 +87,11 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size + # MAX_AUDIO_FILE_BYTES remains monkeypatchable for tests. if file_size > MAX_AUDIO_FILE_BYTES: - raise ValueError( - f"Audio file is too large for temporal analysis: {file_size} bytes " - f"(max {MAX_AUDIO_FILE_BYTES} bytes)" + raise AudioResourcePolicyError( + "encoded_file_too_large", + policy_rejection_message("encoded_file_too_large"), ) with warnings.catch_warnings(): @@ -112,6 +122,7 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: raise ValueError("Expected numpy array from librosa.load") y_array: NDArray[np.floating[Any]] = y + validate_decoded_audio(y_array, sr) duration = float(librosa.get_duration(y=y_array, sr=sr)) logger.info("Extracting tempo and beat tracking...") @@ -139,6 +150,15 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: "audio_path": path_str, } + except AudioResourcePolicyError as error: + logger.info( + "Rejected audio against resource policy version %s (%s)", + error.policy_version, + error.reason, + ) + raise + except ValueError: + raise except Exception as e: logger.error(f"Failed to analyze audio {path_str}: {e}") raise ValueError(f"Temporal analysis failed: {e}") from e diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index f2a732d31..c42882f40 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -10,9 +10,17 @@ import numpy as np from numpy.typing import NDArray -TARGET_SR = 22050 -MAX_STEM_BYTES = 50 * 1024 * 1024 -MAX_TRANSCRIPTION_DURATION_SECONDS = 120 +from bandscope_analysis.audio_resource_policy import ( + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + AudioResourcePolicyError, + policy_rejection_message, + validate_decoded_audio, +) + +TARGET_SR = 22050 # pYIN feature DSP rate after canonical resource validation +MAX_STEM_BYTES = MAX_ENCODED_FILE_BYTES +MAX_TRANSCRIPTION_DURATION_SECONDS = MAX_DURATION_SECONDS FRAME_LENGTH = 2048 HOP_LENGTH = 512 MIN_NOTE_DURATION_SECONDS = 0.05 @@ -40,7 +48,10 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: if not stem_data: return [] if len(stem_data) > MAX_STEM_BYTES: - raise ValueError("Stem data is too large for transcription.") + raise AudioResourcePolicyError( + "encoded_file_too_large", + policy_rejection_message("encoded_file_too_large"), + ) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") @@ -54,6 +65,7 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: y_array = np.asarray(y, dtype=np.float32) if y_array.size == 0 or float(np.max(np.abs(y_array))) < MIN_SIGNAL_PEAK: return [] + validate_decoded_audio(y_array, sr) fmin = float(librosa.note_to_hz("C1")) fmax = float(librosa.note_to_hz("C5")) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index c98f4e513..332d8b983 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -14,6 +14,12 @@ import yt_dlp # type: ignore +from bandscope_analysis.audio_resource_policy import ( + AudioResourcePolicyError, + validate_duration_seconds, + validate_encoded_file_bytes, +) + YOUTUBE_VIDEO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$") MAX_YOUTUBE_URL_LENGTH = 2000 SUPPORTED_AUDIO_EXTENSIONS = (".opus", ".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg") @@ -138,14 +144,17 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: if info is None: raise Exception("Failed to extract info") duration = info.get("duration") - if duration is not None and duration > 15 * 60: - return { - "ok": False, - "error": { - "code": "duration_exceeded", - "message": "Video exceeds the 15-minute limit.", - }, - } + if duration: + try: + validate_duration_seconds(duration) + except AudioResourcePolicyError as error: + return { + "ok": False, + "error": { + "code": "duration_exceeded", + "message": error.message, + }, + } info = ydl.extract_info(url, download=True) if info is None: @@ -163,18 +172,18 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } - if ( - os.path.exists(actual_filepath) - and os.path.getsize(actual_filepath) > 50 * 1024 * 1024 - ): - os.remove(actual_filepath) - return { - "ok": False, - "error": { - "code": "size_exceeded", - "message": "Downloaded file exceeds the 50MB limit.", - }, - } + if os.path.exists(actual_filepath): + try: + validate_encoded_file_bytes(os.path.getsize(actual_filepath)) + except AudioResourcePolicyError as error: + os.remove(actual_filepath) + return { + "ok": False, + "error": { + "code": "size_exceeded", + "message": error.message, + }, + } return { "ok": True, "metadata": { diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py new file mode 100644 index 000000000..d23e35be2 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -0,0 +1,260 @@ +"""Canonical audio-resource policy regressions.""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import ( + AUDIO_RESOURCE_POLICY_VERSION, + DEFAULT_AUDIO_RESOURCE_POLICY, + MAX_DECODED_SAMPLE_COUNT, + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + TARGET_SAMPLING_RATE_HZ, + AudioResourcePolicy, + AudioResourcePolicyError, + _checked_int_product, + estimate_decoded_memory_bytes, + policy_rejection_message, + validate_channel_count, + validate_decoded_audio, + validate_duration_seconds, + validate_encoded_file_bytes, + validate_source_sampling_rate, +) + + +def _policy_error(reason: str) -> pytest.RaisesContext[AudioResourcePolicyError]: + """Expect a payload-free rejection for one stable reason code.""" + return pytest.raises(AudioResourcePolicyError, match=policy_rejection_message(reason)) + + +def test_policy_snapshot_is_versioned_and_matches_rehearsal_intake_evidence() -> None: + """The canonical policy is 15 minutes / 100 MiB / 44.1 kHz, not a 5-minute invention.""" + policy = DEFAULT_AUDIO_RESOURCE_POLICY + assert policy.version == AUDIO_RESOURCE_POLICY_VERSION == 1 + assert policy.max_encoded_file_bytes == MAX_ENCODED_FILE_BYTES == 100 * 1024 * 1024 + assert policy.max_duration_seconds == MAX_DURATION_SECONDS == 15 * 60 + assert policy.target_sampling_rate_hz == TARGET_SAMPLING_RATE_HZ == 44100 + assert policy.max_decoded_sample_count == MAX_DECODED_SAMPLE_COUNT == 15 * 60 * 44100 + assert policy.max_decoded_memory_bytes == 15 * 60 * 44100 * 2 * 4 + + +@pytest.mark.parametrize("file_size", [1, MAX_ENCODED_FILE_BYTES]) +def test_encoded_file_bytes_accept_the_inclusive_ceiling(file_size: int) -> None: + """Sizes at and below the encoded-byte ceiling are admitted.""" + validate_encoded_file_bytes(file_size) + + +@pytest.mark.parametrize( + "file_size", + [MAX_ENCODED_FILE_BYTES + 1, float(MAX_ENCODED_FILE_BYTES) + 1.0], +) +def test_encoded_file_bytes_reject_the_next_byte(file_size: float) -> None: + """One byte above the ceiling fails before decode.""" + with _policy_error("encoded_file_too_large"): + validate_encoded_file_bytes(file_size) + + +def test_encoded_file_bytes_reject_empty_payloads() -> None: + """Zero-byte files cannot skip decode-time measurement.""" + with _policy_error("malformed_header"): + validate_encoded_file_bytes(0) + + +@pytest.mark.parametrize( + "file_size", + [-1, 1.5, math.nan, math.inf, -math.inf, True, "12", None], +) +def test_encoded_file_bytes_reject_malformed_sizes(file_size: object) -> None: + """Boolean, fractional, non-finite, and non-numeric sizes fail closed.""" + with _policy_error("non_finite_metadata"): + validate_encoded_file_bytes(file_size) + + +@pytest.mark.parametrize( + "duration", + [DEFAULT_AUDIO_RESOURCE_POLICY.min_duration_seconds, 1.0, MAX_DURATION_SECONDS], +) +def test_duration_accepts_the_inclusive_window(duration: float) -> None: + """Durations on both published bounds remain valid rehearsal recordings.""" + validate_duration_seconds(duration) + + +@pytest.mark.parametrize( + ("duration", "reason"), + [ + (0.0, "duration_too_short"), + (DEFAULT_AUDIO_RESOURCE_POLICY.min_duration_seconds - 1e-9, "duration_too_short"), + (MAX_DURATION_SECONDS + 1e-6, "duration_exceeded"), + (16 * 60, "duration_exceeded"), + ], +) +def test_duration_rejects_values_outside_the_window(duration: float, reason: str) -> None: + """Too-short and too-long recordings name the next file-selection action.""" + with _policy_error(reason): + validate_duration_seconds(duration) + + +@pytest.mark.parametrize("rate", [8_000, 44_100, 48_000, 192_000]) +def test_source_sampling_rate_accepts_supported_hosts(rate: int) -> None: + """Common rehearsal capture rates stay inside the policy.""" + validate_source_sampling_rate(rate) + + +@pytest.mark.parametrize("rate", [0, -1, 7_999, 192_001, 44_100.5]) +def test_source_sampling_rate_rejects_unsupported_hosts(rate: object) -> None: + """Extreme and fractional rates fail before allocation.""" + with _policy_error("sampling_rate_unsupported"): + validate_source_sampling_rate(rate) + + +@pytest.mark.parametrize("rate", [math.nan, True, None]) +def test_source_sampling_rate_rejects_malformed_metadata(rate: object) -> None: + """Non-numeric sampling-rate metadata cannot skip the finite-number check.""" + with _policy_error("non_finite_metadata"): + validate_source_sampling_rate(rate) + + +@pytest.mark.parametrize("channels", [1, 2]) +def test_channel_count_accepts_mono_and_stereo(channels: int) -> None: + """Mono and stereo remain the only admitted layouts.""" + validate_channel_count(channels) + + +@pytest.mark.parametrize("channels", [0, 3, 8, 1.5]) +def test_channel_count_rejects_unsupported_layouts(channels: object) -> None: + """Multichannel and fractional layouts fail closed.""" + with _policy_error("channel_count_unsupported"): + validate_channel_count(channels) + + +@pytest.mark.parametrize("channels", [math.nan, True, None]) +def test_channel_count_rejects_malformed_metadata(channels: object) -> None: + """Non-numeric channel metadata cannot skip the finite-number check.""" + with _policy_error("non_finite_metadata"): + validate_channel_count(channels) + + +def test_decoded_mono_audio_at_the_sample_ceiling_is_admitted() -> None: + """A decoded buffer exactly at the sample ceiling still validates.""" + policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 8, + "max_duration_seconds": 8 / 8_000, + "min_duration_seconds": 8 / 8_000, + } + ) + audio = np.zeros(8, dtype=np.float32) + validate_decoded_audio(audio, 8_000, policy) + + +def test_decoded_audio_rejects_empty_or_non_array_payloads() -> None: + """Missing samples cannot skip the decoded-size revalidation.""" + with _policy_error("duration_too_short"): + validate_decoded_audio(np.zeros(0, dtype=np.float32), 44_100) + with _policy_error("malformed_header"): + validate_decoded_audio([0.0], 44_100) + with _policy_error("malformed_header"): + validate_decoded_audio(np.array(["x"], dtype=object), 44_100) + + +def test_decoded_audio_rejects_non_finite_samples() -> None: + """NaN/Inf PCM cannot proceed into analyzers.""" + audio = np.array([0.0, math.nan], dtype=np.float32) + with _policy_error("malformed_header"): + validate_decoded_audio(audio, 44_100) + + +def test_decoded_audio_rejects_sample_count_above_the_ceiling() -> None: + """Decoded growth after metadata inspection still fails closed.""" + policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 4, + "max_duration_seconds": 1.0, + } + ) + with _policy_error("decoded_sample_count_exceeded"): + validate_decoded_audio(np.zeros(5, dtype=np.float32), 8_000, policy) + + +def test_decoded_audio_rejects_duration_after_sample_count_passes() -> None: + """Wall-clock duration is rechecked even when the sample ceiling still fits.""" + policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 20_000, + "max_duration_seconds": 1.0, + } + ) + with _policy_error("duration_exceeded"): + validate_decoded_audio(np.zeros(9_000, dtype=np.float32), 8_000, policy) + + +def test_decoded_audio_rejects_memory_budget_after_layout_classification() -> None: + """Stereo expansion can exceed the float32 memory budget without exceeding samples.""" + policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 800, + "min_duration_seconds": 0.05, + "max_duration_seconds": 1.0, + "max_decoded_memory_bytes": 400 * 4, + } + ) + with _policy_error("memory_budget_exceeded"): + validate_decoded_audio(np.zeros((2, 400), dtype=np.float32), 8_000, policy) + + +def test_decoded_stereo_uses_channel_first_layout() -> None: + """Librosa-style ``(channels, samples)`` arrays are classified as stereo.""" + audio = np.zeros((2, 8_000), dtype=np.float32) + validate_decoded_audio(audio, 8_000) + + +def test_decoded_sample_first_layout_is_still_classified() -> None: + """A ``(samples, channels)`` buffer with more frames than channels remains stereo.""" + audio = np.zeros((8_000, 2), dtype=np.float32) + validate_decoded_audio(audio, 8_000) + + +def test_decoded_audio_rejects_rank_three_buffers() -> None: + """Unexpected tensor rank is a malformed header, not a new layout.""" + with _policy_error("malformed_header"): + validate_decoded_audio(np.zeros((1, 1, 8), dtype=np.float32), 8_000) + + +def test_decoded_audio_rejects_quad_channel_layout() -> None: + """A four-channel buffer is outside the mono/stereo rehearsal policy.""" + with _policy_error("channel_count_unsupported"): + validate_decoded_audio(np.zeros((4, 8_000), dtype=np.float32), 8_000) + + +def test_memory_estimate_uses_checked_arithmetic() -> None: + """The float32 memory estimate is the checked product of samples, channels, and width.""" + assert estimate_decoded_memory_bytes(10, 2) == 10 * 2 * 4 + with _policy_error("integer_overflow"): + estimate_decoded_memory_bytes(-1, 2) + with _policy_error("integer_overflow"): + _checked_int_product(2**62, 4) + + +def test_unknown_reason_codes_fail_closed() -> None: + """Callers cannot invent a reason that skips the payload-free catalog.""" + with _policy_error("malformed_header"): + policy_rejection_message("not-a-real-reason") + + +def test_policy_error_carries_versioned_provenance() -> None: + """Audit metadata records the policy version and reason without payload details.""" + with pytest.raises(AudioResourcePolicyError) as error: + validate_duration_seconds(16 * 60) + assert error.value.reason == "duration_exceeded" + assert error.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + assert "16" not in error.value.message + assert "960" not in error.value.message diff --git a/services/analysis-engine/tests/test_chord_recognizer.py b/services/analysis-engine/tests/test_chord_recognizer.py index 20a6dcf78..be34142ea 100644 --- a/services/analysis-engine/tests/test_chord_recognizer.py +++ b/services/analysis-engine/tests/test_chord_recognizer.py @@ -3,7 +3,9 @@ from unittest.mock import patch import numpy as np +import pytest +from bandscope_analysis.audio_resource_policy import policy_rejection_message from bandscope_analysis.chords.chord_recognizer import ( ChordRecognizer, _confidence_rank, @@ -20,6 +22,14 @@ def test_chord_recognizer_empty_audio() -> None: assert result == [] +def test_chord_recognizer_rejects_unsupported_channel_layout() -> None: + """Downstream chord DSP must not invent a layout outside the canonical policy.""" + recognizer = ChordRecognizer() + audio = np.zeros((8, SAMPLE_RATE), dtype=np.float32) + with pytest.raises(ValueError, match=policy_rejection_message("channel_count_unsupported")): + recognizer.recognize(audio, sr=SAMPLE_RATE) + + def test_chord_recognizer_unvoiced_audio() -> None: """Test chord recognition with noise.""" recognizer = ChordRecognizer() diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index f8e098521..c4812457a 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -454,7 +454,7 @@ def test_audio_stem_separator_rejects_oversized_audio_file(tmp_path) -> None: separator = AudioStemSeparator( AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=8) ) - with pytest.raises(ValueError, match="Audio file is too large for stem separation"): + with pytest.raises(ValueError, match="Choose a shorter or smaller song file to start analysis"): separator.separate(audio_path) diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index 6ce90ae1c..d059ce574 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -138,7 +138,7 @@ def fake_load(*args, **kwargs): monkeypatch.setattr(librosa, "load", fake_load) analyzer = TemporalAnalyzer() - with pytest.raises(ValueError, match="too large"): + with pytest.raises(ValueError, match="Choose a shorter or smaller song file to start analysis"): analyzer.analyze(test_wav) @@ -182,7 +182,7 @@ def test_temporal_analyzer_does_not_suppress_unrelated_loader_warnings( def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: warnings.warn("unrelated downstream warning", FutureWarning, stacklevel=2) - return np.zeros(1024, dtype=float), 44100 + return np.zeros(44100, dtype=float), 44100 monkeypatch.setattr(librosa, "load", fake_load) monkeypatch.setattr(librosa, "get_duration", lambda *, y, sr: 1.0) diff --git a/services/analysis-engine/tests/test_transcription.py b/services/analysis-engine/tests/test_transcription.py index f9b55af93..4359a4b73 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -62,6 +62,20 @@ def test_transcribe_bass_stem_rejects_oversized_input(monkeypatch) -> None: transcribe_bass_stem(b"abc") +def test_transcribe_bass_stem_uses_canonical_duration_limit(monkeypatch) -> None: + """Bass transcription must not silently cap rehearsal stems at two minutes.""" + captured_kwargs: dict[str, object] = {} + + def fake_load(*_args: object, **kwargs: object) -> tuple[np.ndarray, int]: + captured_kwargs.update(kwargs) + return np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32), SAMPLE_RATE + + monkeypatch.setattr(transcription_api.librosa, "load", fake_load) + transcribe_bass_stem(b"wav-bytes") + assert captured_kwargs["duration"] == transcription_api.MAX_TRANSCRIPTION_DURATION_SECONDS + assert captured_kwargs["duration"] == 15 * 60 + + def test_transcribe_bass_stem_wraps_pitch_tracking_parameter_errors(monkeypatch) -> None: """Return a stable ValueError when pYIN rejects decoded audio parameters.""" stem_data = _render_bass_sequence([ExpectedNote("E2", 0.0, 0.45)]) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 5531ac9d5..22b449c15 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -271,6 +271,10 @@ def test_download_youtube_audio_duration_exceeded(mock_ydl_class: MagicMock) -> result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") assert result["ok"] is False assert result["error"]["code"] == "duration_exceeded" + assert result["error"]["message"] == ( + "Choose a song shorter than 15 minutes to start analysis." + ) + assert "960" not in result["error"]["message"] @patch("bandscope_analysis.youtube.os.path.getsize") @@ -283,20 +287,49 @@ def test_download_youtube_audio_size_exceeded( mock_exists: MagicMock, mock_getsize: MagicMock, ) -> None: - """Test download fails if size exceeds 50MB.""" + """Test download fails if size exceeds the canonical 100 MiB policy.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" mock_exists.return_value = True - mock_getsize.return_value = 51 * 1024 * 1024 + mock_getsize.return_value = 101 * 1024 * 1024 result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") assert result["ok"] is False assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == ( + "Choose a shorter or smaller song file to start analysis." + ) + assert "101" not in result["error"]["message"] mock_remove.assert_called_with("/tmp/abc123DEF45.m4a") +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_accepts_canonical_encoded_budget( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """A 100 MiB download stays inside the canonical encoded-byte ceiling.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "title": "Test Video", + "duration": 15 * 60, + } + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = 100 * 1024 * 1024 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + assert result["ok"] is True + assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.m4a" + + def test_main_block(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: """Test the CLI entry point.""" test_args = [ From 80c43f3f100e27b41ec3197619450820d38abc75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:18:22 -0700 Subject: [PATCH 02/23] test: reject non-numeric decoded audio dtypes --- .../tests/test_audio_resource_policy.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index d23e35be2..8de02eddc 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -27,7 +27,7 @@ ) -def _policy_error(reason: str) -> pytest.RaisesContext[AudioResourcePolicyError]: +def _policy_error(reason: str) -> pytest.RaisesExc[AudioResourcePolicyError]: """Expect a payload-free rejection for one stable reason code.""" return pytest.raises(AudioResourcePolicyError, match=policy_rejection_message(reason)) @@ -170,6 +170,20 @@ def test_decoded_audio_rejects_non_finite_samples() -> None: validate_decoded_audio(audio, 44_100) +@pytest.mark.parametrize( + "audio", + [ + np.array(["x"]), + np.array([b"x"]), + np.array(["2020-01-01"], dtype="datetime64[D]"), + ], +) +def test_decoded_audio_rejects_non_numeric_dtypes(audio: np.ndarray) -> None: + """String, byte-string, and datetime arrays stay outside PCM authority.""" + with _policy_error("malformed_header"): + validate_decoded_audio(audio, 44_100) + + def test_decoded_audio_rejects_sample_count_above_the_ceiling() -> None: """Decoded growth after metadata inspection still fails closed.""" policy = AudioResourcePolicy( From 706a80d5095504201ba63a8e7111f65f72eace64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:19:09 -0700 Subject: [PATCH 03/23] fix: fail closed on non-numeric decoded audio --- .../src/bandscope_analysis/audio_resource_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 941982b95..329e2b886 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -205,7 +205,7 @@ def _array_layout(audio: np.ndarray) -> tuple[int, int]: if first <= 4 and second >= first: return first, second return second, first - _raise("malformed_header") + raise AudioResourcePolicyError("malformed_header", POLICY_MESSAGES["malformed_header"]) def validate_decoded_audio( @@ -214,7 +214,7 @@ def validate_decoded_audio( policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, ) -> None: """Revalidate decoded samples because container metadata is untrusted.""" - if not isinstance(audio, np.ndarray) or audio.dtype == object: + if not isinstance(audio, np.ndarray) or audio.dtype.kind not in "fiu": _raise("malformed_header") if audio.size == 0: _raise("duration_too_short") From d104a40db139c8dbc631884604660b0461bc1fa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:19:40 -0700 Subject: [PATCH 04/23] docs: align audio policy rejection catalog --- docs/doctoring/audio-resource-policy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index f5f4c49e8..ee22c5afb 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -23,6 +23,7 @@ echoes paths, sizes, durations, or header bytes: - Choose a shorter or smaller song file to start analysis. - Choose a song shorter than 15 minutes to start analysis. +- Choose a shorter song file to start analysis. - Choose a longer song file to start analysis. - Choose a WAV, MP3, FLAC, or M4A file recorded at a standard sample rate. - Choose a mono or stereo song file to start analysis. From bb62c8066f283764369370575b8ff62a44a2a44d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:21:44 -0700 Subject: [PATCH 05/23] test: preserve YouTube resource-policy reason codes --- .../test_youtube_resource_policy_contract.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 services/analysis-engine/tests/test_youtube_resource_policy_contract.py diff --git a/services/analysis-engine/tests/test_youtube_resource_policy_contract.py b/services/analysis-engine/tests/test_youtube_resource_policy_contract.py new file mode 100644 index 000000000..466af8b2e --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_resource_policy_contract.py @@ -0,0 +1,54 @@ +"""Focused YouTube resource-policy reason-code regressions.""" + +from unittest.mock import MagicMock, patch + +from bandscope_analysis.youtube import download_youtube_audio + +YOUTUBE_URL = "https://youtube.com/watch?v=abc123DEF45" + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_zero_duration_metadata_fails_before_download(mock_ydl_class: MagicMock) -> None: + """A zero-duration video must retain the canonical duration-too-short reason.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 0} + + result = download_youtube_audio(YOUTUBE_URL, "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "duration_too_short", + "message": "Choose a longer song file to start analysis.", + }, + } + mock_ydl.extract_info.assert_called_once_with(YOUTUBE_URL, download=False) + + +@patch("bandscope_analysis.youtube.os.path.getsize", return_value=0) +@patch("bandscope_analysis.youtube.os.path.exists", return_value=True) +@patch("bandscope_analysis.youtube.os.remove") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_zero_byte_download_retains_malformed_header_reason( + mock_ydl_class: MagicMock, + mock_remove: MagicMock, + _mock_exists: MagicMock, + _mock_getsize: MagicMock, +) -> None: + """An empty downloaded artifact must not be mislabeled as merely oversized.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + + result = download_youtube_audio(YOUTUBE_URL, "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "malformed_header", + "message": "Choose another song file. This one could not be read as audio.", + }, + } + mock_remove.assert_called_once_with("/tmp/abc123DEF45.m4a") From c0b7422ccab4ce0785954abc5918480f04890e2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:22:19 -0700 Subject: [PATCH 06/23] fix: preserve YouTube policy rejection reasons --- services/analysis-engine/src/bandscope_analysis/youtube.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index 332d8b983..fed5e7824 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -144,14 +144,14 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: if info is None: raise Exception("Failed to extract info") duration = info.get("duration") - if duration: + if duration is not None: try: validate_duration_seconds(duration) except AudioResourcePolicyError as error: return { "ok": False, "error": { - "code": "duration_exceeded", + "code": error.reason, "message": error.message, }, } @@ -180,7 +180,7 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: return { "ok": False, "error": { - "code": "size_exceeded", + "code": error.reason, "message": error.message, }, } From 4c3d1783bd6d6895037ebe54addf1173b5af2377 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:23:00 -0700 Subject: [PATCH 07/23] test: reject non-finite separator decoder output --- ...udio_separator_resource_policy_contract.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py diff --git a/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py new file mode 100644 index 000000000..3ec9d4e83 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py @@ -0,0 +1,25 @@ +"""Focused stem-separation resource-policy regressions.""" + +from unittest.mock import patch + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import AudioResourcePolicyError +from bandscope_analysis.separation.audio_separator import AudioStemSeparator + + +@patch("bandscope_analysis.separation.audio_separator.librosa.load") +def test_separator_rejects_non_finite_decoder_output_before_normalization( + mock_load: object, + tmp_path: pytest.TempPathFactory, +) -> None: + """NaN/Inf decoder output must fail closed instead of becoming silent zeros.""" + audio_path = tmp_path / "rehearsal.wav" + audio_path.write_bytes(b"RIFF") + mock_load.return_value = (np.array([0.0, np.nan], dtype=np.float32), 44_100) # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError) as error: + AudioStemSeparator()._load_audio(audio_path) + + assert error.value.reason == "malformed_header" From 713b1f24210dc1c4d091c5ccaa767e6af0d89c39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:23:14 -0700 Subject: [PATCH 08/23] test: keep separator policy regression type-safe --- .../test_audio_separator_resource_policy_contract.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py index 3ec9d4e83..dae17924e 100644 --- a/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py +++ b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py @@ -1,6 +1,7 @@ """Focused stem-separation resource-policy regressions.""" -from unittest.mock import patch +from pathlib import Path +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -11,13 +12,13 @@ @patch("bandscope_analysis.separation.audio_separator.librosa.load") def test_separator_rejects_non_finite_decoder_output_before_normalization( - mock_load: object, - tmp_path: pytest.TempPathFactory, + mock_load: MagicMock, + tmp_path: Path, ) -> None: """NaN/Inf decoder output must fail closed instead of becoming silent zeros.""" audio_path = tmp_path / "rehearsal.wav" audio_path.write_bytes(b"RIFF") - mock_load.return_value = (np.array([0.0, np.nan], dtype=np.float32), 44_100) # type: ignore[attr-defined] + mock_load.return_value = (np.array([0.0, np.nan], dtype=np.float32), 44_100) with pytest.raises(AudioResourcePolicyError) as error: AudioStemSeparator()._load_audio(audio_path) From b1b5bb24d687b7e18ce3aae6479202cee50ce4f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:24:01 -0700 Subject: [PATCH 09/23] fix: validate separator output before finite normalization --- .../src/bandscope_analysis/separation/audio_separator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 681b0f200..c14004f10 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -228,11 +228,11 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: except Exception as error: raise ValueError(f"Stem separation decode failed for {path.name}") from error - decoded = _as_float_array(y) + decoded = np.ravel(np.asarray(y, dtype=np.float32)) if decoded.size == 0: raise ValueError(f"Stem separation decode failed for {path.name}") validate_decoded_audio(decoded, int(sr)) - return decoded, int(sr) + return _as_float_array(decoded), int(sr) def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: """Trim or pad a stem to match the source length exactly.""" From ab2562f0307ccbda34def61bf53151661445a4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:24:50 -0700 Subject: [PATCH 10/23] test: keep empty chord buffers shape-invariant --- .../test_chord_recognizer_empty_layout_contract.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 services/analysis-engine/tests/test_chord_recognizer_empty_layout_contract.py diff --git a/services/analysis-engine/tests/test_chord_recognizer_empty_layout_contract.py b/services/analysis-engine/tests/test_chord_recognizer_empty_layout_contract.py new file mode 100644 index 000000000..e24fc9b6d --- /dev/null +++ b/services/analysis-engine/tests/test_chord_recognizer_empty_layout_contract.py @@ -0,0 +1,13 @@ +"""Focused chord-recognizer empty-layout regression.""" + +import numpy as np + +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer + + +def test_empty_two_dimensional_buffers_return_no_chords() -> None: + """Both channel-first and sample-first empty arrays are equivalent empty audio.""" + recognizer = ChordRecognizer() + + assert recognizer.recognize(np.zeros((0, 2), dtype=np.float32), 44_100) == [] + assert recognizer.recognize(np.zeros((2, 0), dtype=np.float32), 44_100) == [] From 37d0da0109e89bceeb08bd369cd4fb168396e218 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:25:46 -0700 Subject: [PATCH 11/23] test: define bounded audio metadata preflight contract --- .../tests/test_audio_metadata_preflight.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_metadata_preflight.py diff --git a/services/analysis-engine/tests/test_audio_metadata_preflight.py b/services/analysis-engine/tests/test_audio_metadata_preflight.py new file mode 100644 index 000000000..d9987925f --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata_preflight.py @@ -0,0 +1,67 @@ +"""Bounded container-metadata preflight regressions.""" + +import io +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import AudioResourcePolicyError + + +def _info(*, frames: int = 44_100, samplerate: int = 44_100, channels: int = 2) -> SimpleNamespace: + """Build the metadata subset consumed by the preflight boundary.""" + return SimpleNamespace(frames=frames, samplerate=samplerate, channels=channels) + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_accepts_metadata_without_decoding_and_rewinds( + mock_info: object, +) -> None: + """Metadata validation must preserve the caller-owned handle for the decoder.""" + source = io.BytesIO(b"header-bytes") + + def inspect(handle: io.BytesIO) -> SimpleNamespace: + handle.read(3) + return _info() + + mock_info.side_effect = inspect # type: ignore[attr-defined] + + preflight_audio_metadata(source) + + assert source.tell() == 0 + + +@pytest.mark.parametrize( + ("info", "reason"), + [ + (_info(frames=44_100 * 901), "duration_exceeded"), + (_info(channels=3), "channel_count_unsupported"), + (_info(samplerate=7_999), "sampling_rate_unsupported"), + (_info(frames=0), "duration_too_short"), + ], +) +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_rejects_untrusted_container_metadata( + mock_info: object, + info: SimpleNamespace, + reason: str, +) -> None: + """Source metadata must fail closed before resampling, downmixing, or truncation.""" + mock_info.return_value = info # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError) as error: + preflight_audio_metadata(io.BytesIO(b"header")) + + assert error.value.reason == reason + + +@patch("bandscope_analysis.audio_metadata.soundfile.info", side_effect=RuntimeError("decoder detail")) +def test_preflight_maps_probe_failures_to_payload_free_policy_error(_mock_info: object) -> None: + """Container parser failures must not leak decoder detail or bypass policy errors.""" + with pytest.raises(AudioResourcePolicyError) as error: + preflight_audio_metadata(io.BytesIO(b"bad-header")) + + assert error.value.reason == "malformed_header" + assert "decoder detail" not in error.value.message From 2f2fe18548ae281da547f5e31072564d86f7fcb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:26:09 -0700 Subject: [PATCH 12/23] feat: preflight untrusted audio container metadata --- .../src/bandscope_analysis/audio_metadata.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 services/analysis-engine/src/bandscope_analysis/audio_metadata.py diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py new file mode 100644 index 000000000..4f4da86fe --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -0,0 +1,61 @@ +"""Bounded metadata preflight for caller-owned local audio handles. + +Security Notes: +- Untrusted input: container headers parsed from caller-owned binary handles. +- Trust boundary: this module inspects metadata only; it never decodes PCM, + follows paths, or opens network resources. +- Safe failure: parser failures and malformed metadata become payload-free + ``AudioResourcePolicyError`` values before resampling, downmixing, or + duration truncation can hide the original source characteristics. +- Resource behavior: ``soundfile.info`` reads container metadata without + loading the audio contents into memory, and the handle is rewound for the + downstream decoder. +""" + +from __future__ import annotations + +from typing import BinaryIO + +import soundfile + +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, + policy_rejection_message, + validate_channel_count, + validate_duration_seconds, + validate_source_sampling_rate, +) + + +def _malformed_header_error() -> AudioResourcePolicyError: + """Build the stable payload-free container-probe failure.""" + return AudioResourcePolicyError("malformed_header", policy_rejection_message("malformed_header")) + + +def preflight_audio_metadata( + fileobj: BinaryIO, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Validate source duration, sample rate, and channel count without decoding PCM.""" + try: + fileobj.seek(0) + info = soundfile.info(fileobj) + except Exception as error: + try: + fileobj.seek(0) + except Exception: + pass + raise _malformed_header_error() from error + + try: + fileobj.seek(0) + except Exception as error: + raise _malformed_header_error() from error + + validate_source_sampling_rate(info.samplerate, policy) + validate_channel_count(info.channels, policy) + sampling_rate_hz = int(info.samplerate) + duration_seconds = float(info.frames) / float(sampling_rate_hz) + validate_duration_seconds(duration_seconds, policy) From a040f0973cd2d7c5c8d567654a54aed0cc476de0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:04:08 -0700 Subject: [PATCH 13/23] fix(analysis): fail closed cleanly on metadata probe errors --- .../src/bandscope_analysis/audio_metadata.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py index 4f4da86fe..eeaf169cf 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -31,7 +31,9 @@ def _malformed_header_error() -> AudioResourcePolicyError: """Build the stable payload-free container-probe failure.""" - return AudioResourcePolicyError("malformed_header", policy_rejection_message("malformed_header")) + return AudioResourcePolicyError( + "malformed_header", policy_rejection_message("malformed_header") + ) def preflight_audio_metadata( @@ -43,10 +45,9 @@ def preflight_audio_metadata( fileobj.seek(0) info = soundfile.info(fileobj) except Exception as error: - try: - fileobj.seek(0) - except Exception: - pass + # No decoder runs after a failed metadata probe, so there is no consumer + # that needs the rejected handle rewound. Preserve the parser failure as + # the internal cause instead of masking it with a best-effort seek. raise _malformed_header_error() from error try: From d2cf2047af790cddf02b3957856d246638a754b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:04:25 -0700 Subject: [PATCH 14/23] test(analysis): keep metadata preflight lint-clean --- .../analysis-engine/tests/test_audio_metadata_preflight.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_metadata_preflight.py b/services/analysis-engine/tests/test_audio_metadata_preflight.py index d9987925f..a425fb0e4 100644 --- a/services/analysis-engine/tests/test_audio_metadata_preflight.py +++ b/services/analysis-engine/tests/test_audio_metadata_preflight.py @@ -57,7 +57,10 @@ def test_preflight_rejects_untrusted_container_metadata( assert error.value.reason == reason -@patch("bandscope_analysis.audio_metadata.soundfile.info", side_effect=RuntimeError("decoder detail")) +@patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=RuntimeError("decoder detail"), +) def test_preflight_maps_probe_failures_to_payload_free_policy_error(_mock_info: object) -> None: """Container parser failures must not leak decoder detail or bypass policy errors.""" with pytest.raises(AudioResourcePolicyError) as error: From 2ed5383e60d3c6b35a5df87e73deea8c6d11495f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 13:15:55 +0900 Subject: [PATCH 15/23] fix(analysis): close audio resource edge contracts --- .../src/bandscope_analysis/audio_metadata.py | 2 +- .../chords/chord_recognizer.py | 2 +- .../src/bandscope_analysis/youtube.py | 31 ++++++++++++------- .../tests/test_audio_metadata_preflight.py | 28 +++++++++++++++++ .../analysis-engine/tests/test_youtube.py | 25 ++++++++++++++- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py index eeaf169cf..eb9003b44 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -16,7 +16,7 @@ from typing import BinaryIO -import soundfile +import soundfile # type: ignore[import-untyped] # soundfile has no py.typed marker. from bandscope_analysis.audio_resource_policy import ( DEFAULT_AUDIO_RESOURCE_POLICY, diff --git a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py index 458889de2..2159051c6 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -399,7 +399,7 @@ def recognize(self, y: np.ndarray, sr: int = 22050) -> list[TrackedChord]: Returns: List of TrackedChord dicts with start_time, end_time, chord, and confidence. """ - if len(y) == 0: + if y.size == 0: return [] validate_decoded_audio(y, sr) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index fed5e7824..30ad70784 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -172,18 +172,25 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } - if os.path.exists(actual_filepath): - try: - validate_encoded_file_bytes(os.path.getsize(actual_filepath)) - except AudioResourcePolicyError as error: - os.remove(actual_filepath) - return { - "ok": False, - "error": { - "code": error.reason, - "message": error.message, - }, - } + if not os.path.exists(actual_filepath): + return { + "ok": False, + "error": { + "code": "file_not_found", + "message": "Downloaded file could not be found.", + }, + } + try: + validate_encoded_file_bytes(os.path.getsize(actual_filepath)) + except AudioResourcePolicyError as error: + os.remove(actual_filepath) + return { + "ok": False, + "error": { + "code": error.reason, + "message": error.message, + }, + } return { "ok": True, "metadata": { diff --git a/services/analysis-engine/tests/test_audio_metadata_preflight.py b/services/analysis-engine/tests/test_audio_metadata_preflight.py index a425fb0e4..cec580fe4 100644 --- a/services/analysis-engine/tests/test_audio_metadata_preflight.py +++ b/services/analysis-engine/tests/test_audio_metadata_preflight.py @@ -68,3 +68,31 @@ def test_preflight_maps_probe_failures_to_payload_free_policy_error(_mock_info: assert error.value.reason == "malformed_header" assert "decoder detail" not in error.value.message + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_maps_rewind_failures_to_payload_free_policy_error(mock_info: object) -> None: + """A handle that cannot rewind after probing must not reach a decoder.""" + + class SeekFailsAfterProbe(io.BytesIO): + """Fail only when the metadata boundary tries to rewind the handle.""" + + def __init__(self) -> None: + """Initialize the caller-owned byte handle and seek counter.""" + super().__init__(b"header") + self.seek_count = 0 + + def seek(self, *args: object, **kwargs: object) -> int: + """Reject the second seek, which is the post-probe rewind.""" + self.seek_count += 1 + if self.seek_count == 2: + raise OSError("rewind failed") + return super().seek(*args, **kwargs) + + mock_info.return_value = _info() # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError) as error: + preflight_audio_metadata(SeekFailsAfterProbe()) + + assert error.value.reason == "malformed_header" + assert "rewind failed" not in error.value.message diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 22b449c15..7bfb36e7c 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -297,7 +297,7 @@ def test_download_youtube_audio_size_exceeded( result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") assert result["ok"] is False - assert result["error"]["code"] == "size_exceeded" + assert result["error"]["code"] == "encoded_file_too_large" assert result["error"]["message"] == ( "Choose a shorter or smaller song file to start analysis." ) @@ -419,3 +419,26 @@ def test_download_youtube_audio_second_info_none(mock_ydl_class: MagicMock) -> N assert result["error"]["message"] == ( "YouTube import failed. Please use a local audio file instead." ) + + +@patch("bandscope_analysis.youtube.os.path.exists", side_effect=[True, False]) +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_rejects_file_disappearing_before_size_check( + mock_ydl_class: MagicMock, + _mock_exists: MagicMock, +) -> None: + """A downloaded path that vanishes before validation cannot be reported as success.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "file_not_found", + "message": "Downloaded file could not be found.", + }, + } From 5d91c059b38f28a2227e00792ef215d7b54708f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:38:59 -0700 Subject: [PATCH 16/23] test(audio): reproduce pre-decode metadata admission gap --- .../test_audio_metadata_ingestion_contract.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_metadata_ingestion_contract.py diff --git a/services/analysis-engine/tests/test_audio_metadata_ingestion_contract.py b/services/analysis-engine/tests/test_audio_metadata_ingestion_contract.py new file mode 100644 index 000000000..f33f19f94 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata_ingestion_contract.py @@ -0,0 +1,81 @@ +"""Integration regressions for pre-decode local-audio metadata admission.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from bandscope_analysis import audio_metadata +from bandscope_analysis.audio_resource_policy import AudioResourcePolicyError +from bandscope_analysis.separation.audio_separator import AudioStemSeparator +from bandscope_analysis.temporal.analyzer import TemporalAnalyzer +from bandscope_analysis.transcription import api as transcription_api + + +def _overlong_metadata() -> SimpleNamespace: + """Return metadata for a source one second beyond the 15-minute ceiling.""" + sampling_rate = 44_100 + return SimpleNamespace( + samplerate=sampling_rate, + channels=2, + frames=sampling_rate * (15 * 60 + 1), + ) + + +def _install_overlong_probe(monkeypatch: pytest.MonkeyPatch) -> None: + """Make the real metadata preflight observe an overlong source header.""" + monkeypatch.setattr(audio_metadata.soundfile, "info", lambda _fileobj: _overlong_metadata()) + + +def test_temporal_rejects_overlong_metadata_before_decode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Temporal analysis must reject source duration before truncating decode to 15 minutes.""" + audio_path = tmp_path / "overlong.wav" + audio_path.write_bytes(b"RIFF") + _install_overlong_probe(monkeypatch) + load_mock = Mock(side_effect=AssertionError("decoder must not run before admission")) + monkeypatch.setattr("bandscope_analysis.temporal.analyzer.librosa.load", load_mock) + + with pytest.raises(AudioResourcePolicyError) as error: + TemporalAnalyzer().analyze(audio_path) + + assert error.value.reason == "duration_exceeded" + load_mock.assert_not_called() + + +def test_transcription_rejects_overlong_metadata_before_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bass transcription must reject source duration before resampling or truncation.""" + _install_overlong_probe(monkeypatch) + load_mock = Mock(side_effect=AssertionError("decoder must not run before admission")) + monkeypatch.setattr(transcription_api.librosa, "load", load_mock) + + with pytest.raises(AudioResourcePolicyError) as error: + transcription_api.transcribe_bass_stem(b"RIFF") + + assert error.value.reason == "duration_exceeded" + load_mock.assert_not_called() + + +def test_separation_rejects_overlong_metadata_before_decode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Stem separation must reject source duration before mono/resample decode changes it.""" + audio_path = tmp_path / "overlong.wav" + audio_path.write_bytes(b"RIFF") + _install_overlong_probe(monkeypatch) + load_mock = Mock(side_effect=AssertionError("decoder must not run before admission")) + monkeypatch.setattr("bandscope_analysis.separation.audio_separator.librosa.load", load_mock) + + with pytest.raises(AudioResourcePolicyError) as error: + AudioStemSeparator()._load_audio(audio_path) + + assert error.value.reason == "duration_exceeded" + load_mock.assert_not_called() From 90681ef7a48199d3e9d980c922bcdea1133f03b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:40:56 -0700 Subject: [PATCH 17/23] fix(audio): preflight temporal source metadata before decode --- .../src/bandscope_analysis/temporal/analyzer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index e57505974..76bc77dea 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -12,6 +12,7 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_metadata import preflight_audio_metadata from bandscope_analysis.audio_resource_policy import ( MAX_DURATION_SECONDS, MAX_ENCODED_FILE_BYTES, @@ -94,6 +95,8 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: policy_rejection_message("encoded_file_too_large"), ) + preflight_audio_metadata(fileobj) + with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=DeprecationWarning, module=r"^audioread" From ad67cba3ed230036e28eacdb36481951ad27c1c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:41:23 -0700 Subject: [PATCH 18/23] fix(audio): preflight transcription metadata before decode --- .../src/bandscope_analysis/transcription/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index c42882f40..1a3af0696 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -10,6 +10,7 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_metadata import preflight_audio_metadata from bandscope_analysis.audio_resource_policy import ( MAX_DURATION_SECONDS, MAX_ENCODED_FILE_BYTES, @@ -53,10 +54,12 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: policy_rejection_message("encoded_file_too_large"), ) + fileobj = io.BytesIO(stem_data) + preflight_audio_metadata(fileobj) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") y, sr = librosa.load( - io.BytesIO(stem_data), + fileobj, sr=TARGET_SR, mono=True, duration=MAX_TRANSCRIPTION_DURATION_SECONDS, From 18f8a9c44f114ce8354e31ee3cdfa1645cc175ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:41:54 -0700 Subject: [PATCH 19/23] fix(audio): preflight separation source metadata before decode --- .../src/bandscope_analysis/separation/audio_separator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c14004f10..e1023d6b3 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -31,6 +31,7 @@ import librosa import numpy as np +from bandscope_analysis.audio_metadata import preflight_audio_metadata from bandscope_analysis.audio_resource_policy import ( MAX_DURATION_SECONDS, MAX_ENCODED_FILE_BYTES, @@ -205,6 +206,8 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: policy_rejection_message("encoded_file_too_large"), ) + preflight_audio_metadata(fileobj) + with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=DeprecationWarning, module=r"^audioread" From 5ae317e0454479918a1b7e447618ec6dcfdefaa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:42:41 -0700 Subject: [PATCH 20/23] test(audio): isolate transcription decode-duration contract --- services/analysis-engine/tests/test_transcription.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_transcription.py b/services/analysis-engine/tests/test_transcription.py index 4359a4b73..2c91fffd2 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -63,13 +63,14 @@ def test_transcribe_bass_stem_rejects_oversized_input(monkeypatch) -> None: def test_transcribe_bass_stem_uses_canonical_duration_limit(monkeypatch) -> None: - """Bass transcription must not silently cap rehearsal stems at two minutes.""" + """Bass transcription must keep the canonical limit at the decoder boundary.""" captured_kwargs: dict[str, object] = {} def fake_load(*_args: object, **kwargs: object) -> tuple[np.ndarray, int]: captured_kwargs.update(kwargs) return np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32), SAMPLE_RATE + monkeypatch.setattr(transcription_api, "preflight_audio_metadata", lambda _fileobj: None) monkeypatch.setattr(transcription_api.librosa, "load", fake_load) transcribe_bass_stem(b"wav-bytes") assert captured_kwargs["duration"] == transcription_api.MAX_TRANSCRIPTION_DURATION_SECONDS From e68d9a997810b4f93b0fb6bf748e7cfaa67da4c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:44:25 -0700 Subject: [PATCH 21/23] test(audio): isolate temporal decoder contracts from metadata admission --- services/analysis-engine/tests/test_temporal.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index d059ce574..7010e1131 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -91,6 +91,9 @@ def test_temporal_analyzer_invalid_y_type(monkeypatch: pytest.MonkeyPatch, tmp_p def fake_load(*args, **kwargs): return "not-an-array", 22050 + monkeypatch.setattr( + "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", lambda _fileobj: None + ) monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" @@ -112,6 +115,9 @@ def test_temporal_analyzer_exception_handling( def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: raise Exception("Mocked general error") + monkeypatch.setattr( + "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", lambda _fileobj: None + ) monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" @@ -154,6 +160,9 @@ def fake_load(path, **kwargs): captured_kwargs.update(kwargs) return np.zeros(44100, dtype=float), 44100 + monkeypatch.setattr( + "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", lambda _fileobj: None + ) monkeypatch.setattr(librosa, "load", fake_load) def fake_beat_track(y, sr): @@ -184,6 +193,9 @@ def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: warnings.warn("unrelated downstream warning", FutureWarning, stacklevel=2) return np.zeros(44100, dtype=float), 44100 + monkeypatch.setattr( + "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", lambda _fileobj: None + ) monkeypatch.setattr(librosa, "load", fake_load) monkeypatch.setattr(librosa, "get_duration", lambda *, y, sr: 1.0) monkeypatch.setattr( From 4e0d514980b980e21c5782feddd8ca484e375f85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:44:53 -0700 Subject: [PATCH 22/23] test(audio): isolate decoded separation policy contract --- .../tests/test_audio_separator_resource_policy_contract.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py index dae17924e..f8ed85efb 100644 --- a/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py +++ b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py @@ -14,10 +14,15 @@ def test_separator_rejects_non_finite_decoder_output_before_normalization( mock_load: MagicMock, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """NaN/Inf decoder output must fail closed instead of becoming silent zeros.""" audio_path = tmp_path / "rehearsal.wav" audio_path.write_bytes(b"RIFF") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda _fileobj: None, + ) mock_load.return_value = (np.array([0.0, np.nan], dtype=np.float32), 44_100) with pytest.raises(AudioResourcePolicyError) as error: From 86a9719ddeb0bc10741ed9aa5165bcbbbd352f20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:46:13 -0700 Subject: [PATCH 23/23] test(audio): isolate separation decoder failure contracts --- services/analysis-engine/tests/test_separation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index c4812457a..9deeedd84 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -465,6 +465,10 @@ def test_audio_stem_separator_rejects_empty_decoder_output( """Ensure empty decoder output fails safely.""" audio_path = tmp_path / "empty.wav" audio_path.write_bytes(b"placeholder") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda _fileobj: None, + ) monkeypatch.setattr( "bandscope_analysis.separation.audio_separator.librosa.load", lambda *args, **kwargs: (np.array([], dtype=np.float32), 8_000), @@ -485,6 +489,10 @@ def test_audio_stem_separator_redacts_decoder_exceptions( def fail_decode(*args, **kwargs): raise RuntimeError(f"decoder failed under {tmp_path}") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda _fileobj: None, + ) monkeypatch.setattr( "bandscope_analysis.separation.audio_separator.librosa.load", fail_decode,