diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ecdb0d660 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 ca0df5ac4..3acccc972 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 0b6f7e784..0f7c4f710 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. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -74,4 +75,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..0af3dc349 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). The ready workspace names tonight's first playable range and the next instrument check. `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..ee22c5afb --- /dev/null +++ b/docs/doctoring/audio-resource-policy.md @@ -0,0 +1,91 @@ +# 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 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. +- 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_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py new file mode 100644 index 000000000..eb9003b44 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -0,0 +1,62 @@ +"""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 # type: ignore[import-untyped] # soundfile has no py.typed marker. + +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: + # 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: + 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) 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..329e2b886 --- /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 AudioResourcePolicyError("malformed_header", POLICY_MESSAGES["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.kind not in "fiu": + _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..2159051c6 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. """ @@ -396,9 +399,11 @@ 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) + 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..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,17 +31,25 @@ 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_metadata import preflight_audio_metadata +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 +91,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,11 +201,13 @@ 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"), ) + preflight_audio_metadata(fileobj) + with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=DeprecationWarning, module=r"^audioread" @@ -223,7 +231,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 = 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 _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.""" diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 7fe5ae6f7..76bc77dea 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -12,14 +12,24 @@ 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, + 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,12 +88,15 @@ 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"), ) + preflight_audio_metadata(fileobj) + with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=DeprecationWarning, module=r"^audioread" @@ -112,6 +125,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 +153,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..1a3af0696 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -10,9 +10,18 @@ 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_metadata import preflight_audio_metadata +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,12 +49,17 @@ 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"), + ) + 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, @@ -54,6 +68,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..30ad70784 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 is not None: + try: + validate_duration_seconds(duration) + except AudioResourcePolicyError as error: + return { + "ok": False, + "error": { + "code": error.reason, + "message": error.message, + }, + } info = ydl.extract_info(url, download=True) if info is None: @@ -163,16 +172,23 @@ 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 - ): + 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": "size_exceeded", - "message": "Downloaded file exceeds the 50MB limit.", + "code": error.reason, + "message": error.message, }, } return { 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() 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..cec580fe4 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata_preflight.py @@ -0,0 +1,98 @@ +"""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 + + +@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_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py new file mode 100644 index 000000000..8de02eddc --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -0,0 +1,274 @@ +"""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.RaisesExc[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) + + +@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( + **{ + **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_audio_separator_resource_policy_contract.py b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py new file mode 100644 index 000000000..f8ed85efb --- /dev/null +++ b/services/analysis-engine/tests/test_audio_separator_resource_policy_contract.py @@ -0,0 +1,31 @@ +"""Focused stem-separation resource-policy regressions.""" + +from pathlib import Path +from unittest.mock import MagicMock, 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: 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: + AudioStemSeparator()._load_audio(audio_path) + + assert error.value.reason == "malformed_header" 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_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) == [] diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index f8e098521..9deeedd84 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) @@ -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, diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index 6ce90ae1c..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" @@ -138,7 +144,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) @@ -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): @@ -182,8 +191,11 @@ 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( + "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( diff --git a/services/analysis-engine/tests/test_transcription.py b/services/analysis-engine/tests/test_transcription.py index f9b55af93..2c91fffd2 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -62,6 +62,21 @@ 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 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 + 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..7bfb36e7c 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"]["code"] == "encoded_file_too_large" + 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 = [ @@ -386,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.", + }, + } 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")