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..ee30563a2 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -14,6 +15,9 @@ ### Fixed +- Kept path-backed temporal analysis and stem separation compatible with the + documented MP3/M4A intake formats by using the existing local audioread + fallback when libsndfile cannot inspect a compressed container. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ## [0.1.3] - 2026-04-29 @@ -74,4 +78,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/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..0ba908a2f 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -40,6 +40,12 @@ pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; +/// Canonical encoded-audio ceiling shared by desktop intake and the engine. +pub const MAX_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; + +pub const AUDIO_FILE_TOO_LARGE_MESSAGE: &str = + "Choose a shorter or smaller song file to start analysis."; + pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); @@ -296,6 +302,14 @@ pub struct ProjectBootstrapSummaryPayload { pub source: LocalAudioSourcePayload, } +/// Reject an encoded audio file that exceeds the canonical resource ceiling. +pub fn validate_audio_file_size(file_size_bytes: u64) -> Result<(), String> { + if file_size_bytes > MAX_AUDIO_FILE_BYTES { + return Err(AUDIO_FILE_TOO_LARGE_MESSAGE.to_string()); + } + Ok(()) +} + pub fn next_project_id(state: &AppState) -> String { format!( "project-{}-{}", @@ -344,6 +358,7 @@ pub fn youtube_source_from_metadata( if !file_metadata.is_file() || file_metadata.len() == 0 { return Err("YouTube import returned an invalid audio file.".to_string()); } + validate_audio_file_size(file_metadata.len())?; let extension = canonical .extension() @@ -843,6 +858,15 @@ mod tests { assert!(serde_json::from_value::(payload).is_err()); } + #[test] + fn audio_file_size_uses_the_canonical_resource_ceiling() { + assert!(validate_audio_file_size(MAX_AUDIO_FILE_BYTES).is_ok()); + assert_eq!( + validate_audio_file_size(MAX_AUDIO_FILE_BYTES + 1), + Err(AUDIO_FILE_TOO_LARGE_MESSAGE.to_string()) + ); + } + #[test] fn project_payload_from_content_rejects_legacy_missing_time_range() { let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..001ac98f7 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -158,6 +158,7 @@ fn normalize_local_audio_source(path: &Path) -> Result=4.0.1 ; sys_platform != 'darwin' or platform_machine == 'arm64'", "librosa>=0.11.0", "numba<0.67.0", diff --git a/services/analysis-engine/src/bandscope_analysis/audio_decode.py b/services/analysis-engine/src/bandscope_analysis/audio_decode.py new file mode 100644 index 000000000..9d8b36866 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_decode.py @@ -0,0 +1,103 @@ +"""Bounded PCM decode port for the Resource Admission & Decode context. + +This module is the single analysis-engine boundary that may turn an admitted +container into PCM. The current adapter still delegates to ``librosa`` and is +therefore a transitional owner while #1129 removes the libsndfile-backed +runtime graph. Consumers must call this port rather than selecting decoder +fallbacks themselves. + +Security Notes: +- Untrusted input: caller-authorized local audio handles/paths, container + metadata, decoder output, and third-party decoder exceptions. +- Trust boundary: metadata admission happens before PCM decode and decoded PCM + is revalidated before it can enter MIR/DSP consumers. +- Privacy: third-party decoder details are retained only as exception causes; + the surfaced resource-policy error is stable and payload-free. +- Dependency boundary: this adapter deliberately centralizes the legacy + librosa decoder so the commercial #1129 replacement has one owned seam. It + does not claim that libsndfile has already been removed. +""" + +from __future__ import annotations + +import warnings +from typing import cast + +import librosa +import numpy as np +from numpy.typing import NDArray + +from bandscope_analysis.audio_metadata import AudioSource, preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, + policy_rejection_message, + validate_decoded_audio, +) + +KNOWN_LIBROSA_NUMBA_WARNING_FILTERS = ( + (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), + (FutureWarning, r".*Numba.*", r".*numba.*"), +) + +AudioMonoArray = NDArray[np.float32] + + +def _malformed_decode_error() -> AudioResourcePolicyError: + """Build the stable payload-free decoder failure.""" + return AudioResourcePolicyError( + "malformed_header", policy_rejection_message("malformed_header") + ) + + +def decode_mono_audio( + audio_source: AudioSource, + *, + target_sample_rate_hz: int, + max_duration_seconds: float, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> tuple[AudioMonoArray, int]: + """Admit and decode one source to bounded mono float32 PCM. + + The audio source is first measured against the same resource policy that + later revalidates the decoded buffer. Decoder/provider detail never becomes + the surfaced error message. + """ + preflight_audio_metadata(audio_source, audio_resource_policy) + + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=DeprecationWarning, module=r"^audioread" + ) + warnings.filterwarnings( + "ignore", category=FutureWarning, module=r"^audioread" + ) + for ( + warning_category, + warning_message, + warning_module, + ) in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: + warnings.filterwarnings( + "ignore", + category=warning_category, + message=warning_message, + module=warning_module, + ) + decoded_audio, sample_rate_hz = librosa.load( # type: ignore[arg-type] + audio_source, + sr=target_sample_rate_hz, + mono=True, + duration=max_duration_seconds, + ) + except Exception as decode_error: + raise _malformed_decode_error() from decode_error + + try: + pcm_audio = np.ravel(np.asarray(decoded_audio, dtype=np.float32)) + except (TypeError, ValueError) as decode_error: + raise _malformed_decode_error() from decode_error + + validate_decoded_audio(pcm_audio, sample_rate_hz, audio_resource_policy) + return cast(AudioMonoArray, pcm_audio), int(sample_rate_hz) 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..d9ea4cc58 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -0,0 +1,105 @@ +"""Bounded metadata preflight for caller-owned local audio sources. + +Security Notes: +- Untrusted input: container headers and decoder metadata from caller-owned + binary handles or already-selected local paths. +- Trust boundary: this module inspects metadata only; path-backed sources use + the local libsndfile/audioread decoders and never open 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; path-backed fallback metadata uses + audioread's fixed local decoder invocation, while file-like handles are + rewound for the downstream decoder. +""" + +from __future__ import annotations + +import os +from typing import BinaryIO, TypeAlias + +import audioread # type: ignore[import-untyped] # audioread has no py.typed marker. +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, +) + +AudioSource: TypeAlias = BinaryIO | str | os.PathLike[str] + + +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( + audio_source: AudioSource, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Validate source metadata without decoding PCM into the analysis process. + + Path-backed sources use audioread's existing local decoder fallback when + libsndfile cannot inspect a compressed container such as M4A. File-like + sources remain on the libsndfile-only path because audioread requires a + filesystem path for its fixed decoder invocation. + """ + if isinstance(audio_source, (str, os.PathLike)): + try: + audio_metadata = soundfile.info(audio_source) + except Exception: + try: + with audioread.audio_open(str(audio_source)) as decoder_descriptor: + validate_source_sampling_rate( + decoder_descriptor.samplerate, audio_resource_policy + ) + validate_channel_count( + decoder_descriptor.channels, audio_resource_policy + ) + validate_duration_seconds( + decoder_descriptor.duration, audio_resource_policy + ) + return + except AudioResourcePolicyError: + raise + except Exception as metadata_error: + raise _malformed_header_error() from metadata_error + + validate_source_sampling_rate(audio_metadata.samplerate, audio_resource_policy) + validate_channel_count(audio_metadata.channels, audio_resource_policy) + sampling_rate_hz = int(audio_metadata.samplerate) + validate_duration_seconds( + float(audio_metadata.frames) / float(sampling_rate_hz), + audio_resource_policy, + ) + return + + audio_file_object = audio_source + try: + audio_file_object.seek(0) + audio_metadata = soundfile.info(audio_file_object) + except Exception as metadata_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 metadata_error + + try: + audio_file_object.seek(0) + except Exception as rewind_error: + raise _malformed_header_error() from rewind_error + + validate_source_sampling_rate(audio_metadata.samplerate, audio_resource_policy) + validate_channel_count(audio_metadata.channels, audio_resource_policy) + sampling_rate_hz = int(audio_metadata.samplerate) + duration_seconds = float(audio_metadata.frames) / float(sampling_rate_hz) + validate_duration_seconds(duration_seconds, audio_resource_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..ec1f80343 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -0,0 +1,259 @@ +"""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, rejection_reason: str, safe_message: str) -> None: + """Record the stable rejection reason together with operator-safe copy.""" + super().__init__(safe_message) + self.rejection_reason = rejection_reason + self.safe_message = safe_message + self.policy_version = AUDIO_RESOURCE_POLICY_VERSION + + +@dataclass(frozen=True) +class AudioResourcePolicy: + """Versioned bounds shared by desktop intake, IPC, orchestration, and analyzers.""" + + policy_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(rejection_reason: str) -> str: + """Return payload-free copy that names the next rehearsal action.""" + try: + return POLICY_MESSAGES[rejection_reason] + except KeyError as catalog_error: + raise AudioResourcePolicyError( + "malformed_header", POLICY_MESSAGES["malformed_header"] + ) from catalog_error + + +def _raise_policy_rejection(rejection_reason: str) -> NoReturn: + """Fail closed with the stable rejection reason and payload-free copy.""" + raise AudioResourcePolicyError( + rejection_reason, policy_rejection_message(rejection_reason) + ) + + +def _checked_int_product(left_operand: int, right_operand: int) -> int: + """Multiply two non-negative integers or fail closed on overflow.""" + if left_operand < 0 or right_operand < 0: + _raise_policy_rejection("integer_overflow") + if ( + left_operand != 0 + and right_operand > _MAX_SAFE_PRODUCT // left_operand + ): + _raise_policy_rejection("integer_overflow") + return left_operand * right_operand + + +_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( + policy_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(numeric_value: object) -> float: + """Return a finite float or fail closed on malformed metadata.""" + if isinstance(numeric_value, bool) or not isinstance(numeric_value, (int, float)): + _raise_policy_rejection("non_finite_metadata") + try: + finite_number = float(numeric_value) + except (OverflowError, TypeError, ValueError): + _raise_policy_rejection("non_finite_metadata") + if not np.isfinite(finite_number): + _raise_policy_rejection("non_finite_metadata") + return finite_number + + +def estimate_decoded_memory_bytes( + sample_count: int, + channel_count: int, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> int: + """Return the float32 memory estimate for one decoded buffer, checked for overflow.""" + bytes_per_audio_frame = _checked_int_product( + channel_count, audio_resource_policy.bytes_per_decoded_sample + ) + return _checked_int_product(sample_count, bytes_per_audio_frame) + + +def validate_encoded_file_bytes( + file_size_bytes: object, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject encoded sizes that are missing, non-finite, empty, or over budget.""" + normalized_file_size = _require_finite_number(file_size_bytes) + if normalized_file_size != int(normalized_file_size) or normalized_file_size < 0: + _raise_policy_rejection("non_finite_metadata") + if int(normalized_file_size) == 0: + _raise_policy_rejection("malformed_header") + if int(normalized_file_size) > audio_resource_policy.max_encoded_file_bytes: + _raise_policy_rejection("encoded_file_too_large") + + +def validate_duration_seconds( + duration_seconds: object, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject durations that are missing, non-finite, too short, or too long.""" + normalized_duration_seconds = _require_finite_number(duration_seconds) + if normalized_duration_seconds < audio_resource_policy.min_duration_seconds: + _raise_policy_rejection("duration_too_short") + if normalized_duration_seconds > audio_resource_policy.max_duration_seconds: + _raise_policy_rejection("duration_exceeded") + + +def validate_source_sampling_rate( + sampling_rate_hz: object, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject sampling rates outside the supported rehearsal recording range.""" + normalized_sampling_rate = _require_finite_number(sampling_rate_hz) + if ( + normalized_sampling_rate != int(normalized_sampling_rate) + or normalized_sampling_rate <= 0 + ): + _raise_policy_rejection("sampling_rate_unsupported") + sampling_rate_integer_hz = int(normalized_sampling_rate) + if ( + sampling_rate_integer_hz < audio_resource_policy.min_source_sampling_rate_hz + or sampling_rate_integer_hz > audio_resource_policy.max_source_sampling_rate_hz + ): + _raise_policy_rejection("sampling_rate_unsupported") + + +def validate_channel_count( + channel_count: object, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Reject channel counts outside the mono/stereo rehearsal policy.""" + normalized_channel_count = _require_finite_number(channel_count) + if normalized_channel_count != int(normalized_channel_count): + _raise_policy_rejection("channel_count_unsupported") + channel_count_integer = int(normalized_channel_count) + if ( + channel_count_integer < audio_resource_policy.min_channel_count + or channel_count_integer > audio_resource_policy.max_channel_count + ): + _raise_policy_rejection("channel_count_unsupported") + + +def _decoded_audio_layout(decoded_audio: np.ndarray) -> tuple[int, int]: + """Return ``(channel_count, sample_count)`` for a 1-D or 2-D decoded buffer.""" + if decoded_audio.ndim == 1: + return 1, int(decoded_audio.size) + if decoded_audio.ndim == 2: + first_axis_size = int(decoded_audio.shape[0]) + second_axis_size = int(decoded_audio.shape[1]) + if first_axis_size <= 4 and second_axis_size >= first_axis_size: + return first_axis_size, second_axis_size + return second_axis_size, first_axis_size + raise AudioResourcePolicyError( + "malformed_header", POLICY_MESSAGES["malformed_header"] + ) + + +def validate_decoded_audio( + decoded_audio: object, + sampling_rate_hz: object, + audio_resource_policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Revalidate decoded samples because container metadata is untrusted.""" + if not isinstance(decoded_audio, np.ndarray) or decoded_audio.dtype.kind not in "fiu": + _raise_policy_rejection("malformed_header") + if decoded_audio.size == 0: + _raise_policy_rejection("duration_too_short") + if not np.isfinite(decoded_audio).all(): + _raise_policy_rejection("malformed_header") + validate_source_sampling_rate(sampling_rate_hz, audio_resource_policy) + channel_count, sample_count = _decoded_audio_layout(decoded_audio) + validate_channel_count(channel_count, audio_resource_policy) + if sample_count > audio_resource_policy.max_decoded_sample_count: + _raise_policy_rejection("decoded_sample_count_exceeded") + sample_rate_integer_hz = int(_require_finite_number(sampling_rate_hz)) + decoded_duration_seconds = float(sample_count) / float(sample_rate_integer_hz) + validate_duration_seconds(decoded_duration_seconds, audio_resource_policy) + decoded_memory_bytes = estimate_decoded_memory_bytes( + sample_count, channel_count, audio_resource_policy + ) + if decoded_memory_bytes > audio_resource_policy.max_decoded_memory_bytes: + _raise_policy_rejection("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..40604a698 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_decode import KNOWN_LIBROSA_NUMBA_WARNING_FILTERS +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 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(path) + with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=DeprecationWarning, module=r"^audioread" @@ -213,7 +221,7 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: module=module, ) y, sr = librosa.load( - fileobj, + path, sr=self.config.target_sample_rate, mono=True, duration=self.config.max_duration_seconds, @@ -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..a2130475b 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -4,7 +4,6 @@ import logging import os -import warnings from pathlib import Path from typing import Any @@ -12,18 +11,23 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_decode import decode_mono_audio +from bandscope_analysis.audio_resource_policy import ( + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + TARGET_SAMPLING_RATE_HZ, + AudioResourcePolicyError, + policy_rejection_message, +) + 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 -KNOWN_LIBROSA_NUMBA_WARNING_FILTERS = ( - (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), - (FutureWarning, r".*Numba.*", r".*numba.*"), -) +MAX_ANALYSIS_DURATION_SECONDS = MAX_DURATION_SECONDS +MAX_AUDIO_FILE_BYTES = MAX_ENCODED_FILE_BYTES +TARGET_SR = TARGET_SAMPLING_RATE_HZ + # ponytail: assumes 4/4; upgrade to meter estimation or a madmom DBN if other meters matter. BEATS_PER_BAR = 4 @@ -78,40 +82,18 @@ 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(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"^audioread" - ) - warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") - - # Keep the loader's known third-party churn quiet without hiding - # unrelated decoder warnings that tests and callers should see. - for category, message, module in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: - warnings.filterwarnings( - "ignore", - category=category, - message=message, - module=module, - ) - # Load audio, converting to mono and standardizing sample rate - y, sr = librosa.load( - fileobj, - sr=TARGET_SR, - mono=True, - duration=MAX_ANALYSIS_DURATION_SECONDS, - ) - - # Ensure it's a 1D float array for librosa - if not isinstance(y, np.ndarray): - raise ValueError("Expected numpy array from librosa.load") - - y_array: NDArray[np.floating[Any]] = y + y_array, sr = decode_mono_audio( + path, + target_sample_rate_hz=TARGET_SR, + max_duration_seconds=MAX_ANALYSIS_DURATION_SECONDS, + ) duration = float(librosa.get_duration(y=y_array, sr=sr)) logger.info("Extracting tempo and beat tracking...") @@ -139,6 +121,15 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: "audio_path": path_str, } - except Exception as e: - logger.error(f"Failed to analyze audio {path_str}: {e}") - raise ValueError(f"Temporal analysis failed: {e}") from e + except AudioResourcePolicyError as policy_error: + logger.info( + "Rejected audio against resource policy version %s (%s)", + policy_error.policy_version, + policy_error.rejection_reason, + ) + raise + except ValueError: + raise + except Exception as analysis_error: + logger.error(f"Failed to analyze audio {path_str}: {analysis_error}") + raise ValueError(f"Temporal analysis failed: {analysis_error}") from analysis_error diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index f2a732d31..f3d15fa5c 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -3,16 +3,23 @@ from __future__ import annotations import io -import warnings from dataclasses import dataclass import librosa 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_decode import decode_mono_audio +from bandscope_analysis.audio_resource_policy import ( + MAX_DURATION_SECONDS, + MAX_ENCODED_FILE_BYTES, + AudioResourcePolicyError, + policy_rejection_message, +) + +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,18 +47,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.") - - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") - y, sr = librosa.load( - io.BytesIO(stem_data), - sr=TARGET_SR, - mono=True, - duration=MAX_TRANSCRIPTION_DURATION_SECONDS, + raise AudioResourcePolicyError( + "encoded_file_too_large", + policy_rejection_message("encoded_file_too_large"), ) - y_array = np.asarray(y, dtype=np.float32) + y_array, sr = decode_mono_audio( + io.BytesIO(stem_data), + target_sample_rate_hz=TARGET_SR, + max_duration_seconds=MAX_TRANSCRIPTION_DURATION_SECONDS, + ) + if y_array.size == 0 or float(np.max(np.abs(y_array))) < MIN_SIGNAL_PEAK: return [] @@ -154,7 +160,7 @@ def _contiguous_regions(mask: NDArray[np.bool_]) -> list[tuple[int, int]]: def _merge_adjacent_equal_pitches(events: list[NoteEvent]) -> list[NoteEvent]: - """Merge short pitch-equivalent fragments split by frame-level voicing gaps.""" + """Merge note fragments when pYIN briefly drops voicing.""" merged: list[NoteEvent] = [] for event in events: if not merged: diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index c98f4e513..5fc6e9f82 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 policy_error: + return { + "ok": False, + "error": { + "code": policy_error.rejection_reason, + "message": policy_error.safe_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 policy_error: os.remove(actual_filepath) return { "ok": False, "error": { - "code": "size_exceeded", - "message": "Downloaded file exceeds the 50MB limit.", + "code": policy_error.rejection_reason, + "message": policy_error.safe_message, }, } return { diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py new file mode 100644 index 000000000..d42bdb47f --- /dev/null +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import inspect +import io + +import numpy as np +import pytest + +from bandscope_analysis import audio_decode +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicyError, + policy_rejection_message, +) + + +def test_decode_mono_audio_uses_semantic_public_parameter_names() -> None: + parameter_names = tuple(inspect.signature(audio_decode.decode_mono_audio).parameters) + + assert parameter_names == ( + "audio_source", + "target_sample_rate_hz", + "max_duration_seconds", + "audio_resource_policy", + ) + + +def test_decode_mono_audio_preflights_then_validates_one_owned_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + audio_source = io.BytesIO(b"container") + decode_calls: list[tuple[str, object]] = [] + decoder_output = np.array([[0.25, -0.5]], dtype=np.float64) + + def preflight_audio_source( + audio_candidate: object, audio_resource_policy: object + ) -> None: + decode_calls.append(("preflight", audio_candidate)) + assert audio_resource_policy is DEFAULT_AUDIO_RESOURCE_POLICY + + def load_audio_container( + audio_candidate: object, **loader_options: object + ) -> tuple[np.ndarray, int]: + decode_calls.append(("decode", audio_candidate)) + assert audio_candidate is audio_source + assert loader_options == {"sr": 44_100, "mono": True, "duration": 12.5} + return decoder_output, 44_100 + + def validate_audio_buffer( + decoded_audio: object, sample_rate_hz: object, audio_resource_policy: object + ) -> None: + decode_calls.append(("validate", decoded_audio)) + assert isinstance(decoded_audio, np.ndarray) + assert decoded_audio.dtype == np.float32 + assert decoded_audio.shape == (2,) + assert sample_rate_hz == 44_100 + assert audio_resource_policy is DEFAULT_AUDIO_RESOURCE_POLICY + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", preflight_audio_source) + monkeypatch.setattr(audio_decode.librosa, "load", load_audio_container) + monkeypatch.setattr(audio_decode, "validate_decoded_audio", validate_audio_buffer) + + decoded_audio, sample_rate_hz = audio_decode.decode_mono_audio( + audio_source, + target_sample_rate_hz=44_100, + max_duration_seconds=12.5, + ) + + assert decode_calls[0] == ("preflight", audio_source) + assert decode_calls[1] == ("decode", audio_source) + assert decode_calls[2][0] == "validate" + np.testing.assert_array_equal( + decoded_audio, np.array([0.25, -0.5], dtype=np.float32) + ) + assert sample_rate_hz == 44_100 + + +def test_decode_mono_audio_preserves_resource_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resource_rejection = AudioResourcePolicyError( + "duration_exceeded", policy_rejection_message("duration_exceeded") + ) + + def reject_audio_source(_audio_source: object, _audio_resource_policy: object) -> None: + raise resource_rejection + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", reject_audio_source) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: pytest.fail("decoder must not run after rejected preflight"), + ) + + with pytest.raises(AudioResourcePolicyError) as caught_error: + audio_decode.decode_mono_audio( + io.BytesIO(b"container"), + target_sample_rate_hz=44_100, + max_duration_seconds=10.0, + ) + + assert caught_error.value is resource_rejection + + +def test_decode_mono_audio_redacts_third_party_decoder_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret_decoder_detail = "/Users/alice/Music/private.m4a token=secret" + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError(secret_decoder_detail) + ), + ) + + with pytest.raises(AudioResourcePolicyError) as caught_error: + audio_decode.decode_mono_audio( + io.BytesIO(b"container"), + target_sample_rate_hz=44_100, + max_duration_seconds=10.0, + ) + + assert caught_error.value.rejection_reason == "malformed_header" + assert str(caught_error.value) == policy_rejection_message("malformed_header") + assert secret_decoder_detail not in str(caught_error.value) + assert isinstance(caught_error.value.__cause__, RuntimeError) + + +def test_decode_mono_audio_redacts_malformed_decoder_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: ([object()], 44_100), + ) + + with pytest.raises(AudioResourcePolicyError) as caught_error: + audio_decode.decode_mono_audio( + io.BytesIO(b"container"), + target_sample_rate_hz=44_100, + max_duration_seconds=10.0, + ) + + assert caught_error.value.rejection_reason == "malformed_header" + assert str(caught_error.value) == policy_rejection_message("malformed_header") + + +def test_decode_mono_audio_preserves_decoded_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resource_rejection = AudioResourcePolicyError( + "duration_too_short", policy_rejection_message("duration_too_short") + ) + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: (np.array([0.1], dtype=np.float32), 44_100), + ) + monkeypatch.setattr( + audio_decode, + "validate_decoded_audio", + lambda *_args: (_ for _ in ()).throw(resource_rejection), + ) + + with pytest.raises(AudioResourcePolicyError) as caught_error: + audio_decode.decode_mono_audio( + io.BytesIO(b"container"), + target_sample_rate_hz=44_100, + max_duration_seconds=10.0, + ) + + assert caught_error.value is resource_rejection 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..3c502bd96 --- /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.rejection_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.rejection_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.rejection_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..a7cf19bdb --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata_preflight.py @@ -0,0 +1,189 @@ +"""Bounded container-metadata preflight regressions.""" + +import inspect +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 _audio_metadata_fixture( + *, 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) + + +def test_preflight_audio_metadata_uses_semantic_public_parameter_names() -> None: + """The organization-owned metadata port should expose bounded-context names.""" + parameter_names = tuple(inspect.signature(preflight_audio_metadata).parameters) + + assert parameter_names == ("audio_source", "audio_resource_policy") + + +@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.""" + audio_source = io.BytesIO(b"header-bytes") + + def inspect_audio_handle(audio_handle: io.BytesIO) -> SimpleNamespace: + audio_handle.read(3) + return _audio_metadata_fixture() + + mock_info.side_effect = inspect_audio_handle # type: ignore[attr-defined] + + preflight_audio_metadata(audio_source) + + assert audio_source.tell() == 0 + + +@pytest.mark.parametrize( + ("audio_metadata", "policy_reason"), + [ + (_audio_metadata_fixture(frames=44_100 * 901), "duration_exceeded"), + (_audio_metadata_fixture(channels=3), "channel_count_unsupported"), + (_audio_metadata_fixture(samplerate=7_999), "sampling_rate_unsupported"), + (_audio_metadata_fixture(frames=0), "duration_too_short"), + ], +) +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_rejects_untrusted_container_metadata( + mock_info: object, + audio_metadata: SimpleNamespace, + policy_reason: str, +) -> None: + """Source metadata must fail closed before resampling, downmixing, or truncation.""" + mock_info.return_value = audio_metadata # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError) as caught_error: + preflight_audio_metadata(io.BytesIO(b"header")) + + assert caught_error.value.rejection_reason == policy_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 caught_error: + preflight_audio_metadata(io.BytesIO(b"bad-header")) + + assert caught_error.value.rejection_reason == "malformed_header" + assert "decoder detail" not in caught_error.value.safe_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 = _audio_metadata_fixture() # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError) as caught_error: + preflight_audio_metadata(SeekFailsAfterProbe()) + + assert caught_error.value.rejection_reason == "malformed_header" + assert "rewind failed" not in caught_error.value.safe_message + + +def test_path_preflight_uses_local_decoder_metadata_for_compressed_containers( + tmp_path, +) -> None: + """Path-backed M4A metadata should use the existing local decoder fallback.""" + audio_source = tmp_path / "rehearsal.m4a" + audio_source.write_bytes(b"container") + decoder_descriptor = SimpleNamespace(duration=1.0, samplerate=44_100, channels=2) + + with ( + patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=RuntimeError("container unsupported by libsndfile"), + ), + patch("bandscope_analysis.audio_metadata.audioread.audio_open") as audio_open, + ): + audio_open.return_value.__enter__.return_value = decoder_descriptor + preflight_audio_metadata(audio_source) + + audio_open.assert_called_once_with(str(audio_source)) + + +def test_path_preflight_rejects_when_compressed_metadata_fallback_fails(tmp_path) -> None: + """Unavailable compressed-container metadata must fail closed without decoder detail.""" + audio_source = tmp_path / "unreadable.m4a" + audio_source.write_bytes(b"container") + + with ( + patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=RuntimeError("container unsupported by libsndfile"), + ), + patch( + "bandscope_analysis.audio_metadata.audioread.audio_open", + side_effect=RuntimeError("decoder detail"), + ), + ): + with pytest.raises(AudioResourcePolicyError) as caught_error: + preflight_audio_metadata(audio_source) + + assert caught_error.value.rejection_reason == "malformed_header" + assert "decoder detail" not in caught_error.value.safe_message + + +def test_path_preflight_uses_libsndfile_metadata_when_available(tmp_path) -> None: + """Supported path containers should retain the bounded libsndfile probe.""" + audio_source = tmp_path / "rehearsal.wav" + audio_source.write_bytes(b"container") + + with ( + patch( + "bandscope_analysis.audio_metadata.soundfile.info", + return_value=_audio_metadata_fixture(), + ) as soundfile_info, + patch("bandscope_analysis.audio_metadata.audioread.audio_open") as audio_open, + ): + preflight_audio_metadata(audio_source) + + soundfile_info.assert_called_once_with(audio_source) + audio_open.assert_not_called() + + +def test_path_preflight_preserves_policy_errors_from_decoder_metadata(tmp_path) -> None: + """Compressed metadata that violates policy must keep its stable reason code.""" + audio_source = tmp_path / "surround.m4a" + audio_source.write_bytes(b"container") + decoder_descriptor = SimpleNamespace(duration=1.0, samplerate=44_100, channels=3) + + with ( + patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=RuntimeError("container unsupported by libsndfile"), + ), + patch("bandscope_analysis.audio_metadata.audioread.audio_open") as audio_open, + ): + audio_open.return_value.__enter__.return_value = decoder_descriptor + with pytest.raises(AudioResourcePolicyError) as caught_error: + preflight_audio_metadata(audio_source) + + assert caught_error.value.rejection_reason == "channel_count_unsupported" 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..0eaacaaa4 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -0,0 +1,298 @@ +"""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_rejection( + rejection_reason: str, +) -> pytest.RaisesExc[AudioResourcePolicyError]: + """Expect a payload-free rejection for one stable reason code.""" + return pytest.raises( + AudioResourcePolicyError, + match=policy_rejection_message(rejection_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.""" + audio_resource_policy = DEFAULT_AUDIO_RESOURCE_POLICY + assert audio_resource_policy.policy_version == AUDIO_RESOURCE_POLICY_VERSION == 1 + assert ( + audio_resource_policy.max_encoded_file_bytes + == MAX_ENCODED_FILE_BYTES + == 100 * 1024 * 1024 + ) + assert audio_resource_policy.max_duration_seconds == MAX_DURATION_SECONDS == 15 * 60 + assert ( + audio_resource_policy.target_sampling_rate_hz + == TARGET_SAMPLING_RATE_HZ + == 44100 + ) + assert ( + audio_resource_policy.max_decoded_sample_count + == MAX_DECODED_SAMPLE_COUNT + == 15 * 60 * 44100 + ) + assert audio_resource_policy.max_decoded_memory_bytes == 15 * 60 * 44100 * 2 * 4 + + +@pytest.mark.parametrize("file_size_bytes", [1, MAX_ENCODED_FILE_BYTES]) +def test_encoded_file_bytes_accept_the_inclusive_ceiling(file_size_bytes: int) -> None: + """Sizes at and below the encoded-byte ceiling are admitted.""" + validate_encoded_file_bytes(file_size_bytes) + + +@pytest.mark.parametrize( + "file_size_bytes", + [MAX_ENCODED_FILE_BYTES + 1, float(MAX_ENCODED_FILE_BYTES) + 1.0], +) +def test_encoded_file_bytes_reject_the_next_byte(file_size_bytes: float) -> None: + """One byte above the ceiling fails before decode.""" + with _policy_rejection("encoded_file_too_large"): + validate_encoded_file_bytes(file_size_bytes) + + +def test_encoded_file_bytes_reject_empty_payloads() -> None: + """Zero-byte files cannot skip decode-time measurement.""" + with _policy_rejection("malformed_header"): + validate_encoded_file_bytes(0) + + +@pytest.mark.parametrize( + "file_size_bytes", + [-1, 1.5, 10**1000, math.nan, math.inf, -math.inf, True, "12", None], +) +def test_encoded_file_bytes_reject_malformed_sizes(file_size_bytes: object) -> None: + """Boolean, fractional, non-finite, and non-numeric sizes fail closed.""" + with _policy_rejection("non_finite_metadata"): + validate_encoded_file_bytes(file_size_bytes) + + +@pytest.mark.parametrize( + "duration_seconds", + [DEFAULT_AUDIO_RESOURCE_POLICY.min_duration_seconds, 1.0, MAX_DURATION_SECONDS], +) +def test_duration_accepts_the_inclusive_window(duration_seconds: float) -> None: + """Durations on both published bounds remain valid rehearsal recordings.""" + validate_duration_seconds(duration_seconds) + + +@pytest.mark.parametrize( + ("duration_seconds", "rejection_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_seconds: float, + rejection_reason: str, +) -> None: + """Too-short and too-long recordings name the next file-selection action.""" + with _policy_rejection(rejection_reason): + validate_duration_seconds(duration_seconds) + + +@pytest.mark.parametrize("sampling_rate_hz", [8_000, 44_100, 48_000, 192_000]) +def test_source_sampling_rate_accepts_supported_hosts(sampling_rate_hz: int) -> None: + """Common rehearsal capture rates stay inside the policy.""" + validate_source_sampling_rate(sampling_rate_hz) + + +@pytest.mark.parametrize("sampling_rate_hz", [0, -1, 7_999, 192_001, 44_100.5]) +def test_source_sampling_rate_rejects_unsupported_hosts(sampling_rate_hz: object) -> None: + """Extreme and fractional rates fail before allocation.""" + with _policy_rejection("sampling_rate_unsupported"): + validate_source_sampling_rate(sampling_rate_hz) + + +@pytest.mark.parametrize("sampling_rate_hz", [math.nan, True, None]) +def test_source_sampling_rate_rejects_malformed_metadata(sampling_rate_hz: object) -> None: + """Non-numeric sampling-rate metadata cannot skip the finite-number check.""" + with _policy_rejection("non_finite_metadata"): + validate_source_sampling_rate(sampling_rate_hz) + + +@pytest.mark.parametrize("channel_count", [1, 2]) +def test_channel_count_accepts_mono_and_stereo(channel_count: int) -> None: + """Mono and stereo remain the only admitted layouts.""" + validate_channel_count(channel_count) + + +@pytest.mark.parametrize("channel_count", [0, 3, 8, 1.5]) +def test_channel_count_rejects_unsupported_layouts(channel_count: object) -> None: + """Multichannel and fractional layouts fail closed.""" + with _policy_rejection("channel_count_unsupported"): + validate_channel_count(channel_count) + + +@pytest.mark.parametrize("channel_count", [math.nan, True, None]) +def test_channel_count_rejects_malformed_metadata(channel_count: object) -> None: + """Non-numeric channel metadata cannot skip the finite-number check.""" + with _policy_rejection("non_finite_metadata"): + validate_channel_count(channel_count) + + +def test_decoded_mono_audio_at_the_sample_ceiling_is_admitted() -> None: + """A decoded buffer exactly at the sample ceiling still validates.""" + audio_resource_policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 8, + "max_duration_seconds": 8 / 8_000, + "min_duration_seconds": 8 / 8_000, + } + ) + decoded_audio = np.zeros(8, dtype=np.float32) + validate_decoded_audio(decoded_audio, 8_000, audio_resource_policy) + + +def test_decoded_audio_rejects_empty_or_non_array_payloads() -> None: + """Missing samples cannot skip the decoded-size revalidation.""" + with _policy_rejection("duration_too_short"): + validate_decoded_audio(np.zeros(0, dtype=np.float32), 44_100) + with _policy_rejection("malformed_header"): + validate_decoded_audio([0.0], 44_100) + with _policy_rejection("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.""" + decoded_audio = np.array([0.0, math.nan], dtype=np.float32) + with _policy_rejection("malformed_header"): + validate_decoded_audio(decoded_audio, 44_100) + + +@pytest.mark.parametrize( + "decoded_audio", + [ + np.array(["x"]), + np.array([b"x"]), + np.array(["2020-01-01"], dtype="datetime64[D]"), + ], +) +def test_decoded_audio_rejects_non_numeric_dtypes(decoded_audio: np.ndarray) -> None: + """String, byte-string, and datetime arrays stay outside PCM authority.""" + with _policy_rejection("malformed_header"): + validate_decoded_audio(decoded_audio, 44_100) + + +def test_decoded_audio_rejects_sample_count_above_the_ceiling() -> None: + """Decoded growth after metadata inspection still fails closed.""" + audio_resource_policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 4, + "max_duration_seconds": 1.0, + } + ) + with _policy_rejection("decoded_sample_count_exceeded"): + validate_decoded_audio(np.zeros(5, dtype=np.float32), 8_000, audio_resource_policy) + + +def test_decoded_audio_rejects_duration_after_sample_count_passes() -> None: + """Wall-clock duration is rechecked even when the sample ceiling still fits.""" + audio_resource_policy = AudioResourcePolicy( + **{ + **DEFAULT_AUDIO_RESOURCE_POLICY.__dict__, + "max_decoded_sample_count": 20_000, + "max_duration_seconds": 1.0, + } + ) + with _policy_rejection("duration_exceeded"): + validate_decoded_audio( + np.zeros(9_000, dtype=np.float32), 8_000, audio_resource_policy + ) + + +def test_decoded_audio_rejects_memory_budget_after_layout_classification() -> None: + """Stereo expansion can exceed the float32 memory budget without exceeding samples.""" + audio_resource_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_rejection("memory_budget_exceeded"): + validate_decoded_audio( + np.zeros((2, 400), dtype=np.float32), 8_000, audio_resource_policy + ) + + +def test_decoded_stereo_uses_channel_first_layout() -> None: + """Librosa-style ``(channels, samples)`` arrays are classified as stereo.""" + decoded_audio = np.zeros((2, 8_000), dtype=np.float32) + validate_decoded_audio(decoded_audio, 8_000) + + +def test_decoded_sample_first_layout_is_still_classified() -> None: + """A ``(samples, channels)`` buffer with more frames than channels remains stereo.""" + decoded_audio = np.zeros((8_000, 2), dtype=np.float32) + validate_decoded_audio(decoded_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_rejection("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_rejection("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_rejection("integer_overflow"): + estimate_decoded_memory_bytes(-1, 2) + with _policy_rejection("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_rejection("malformed_header"): + policy_rejection_message("not-a-real-reason") + + +def test_policy_error_carries_versioned_provenance() -> None: + """Audit metadata records policy version and semantic rejection fields.""" + with pytest.raises(AudioResourcePolicyError) as caught_error: + validate_duration_seconds(16 * 60) + assert caught_error.value.rejection_reason == "duration_exceeded" + assert caught_error.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + assert "16" not in caught_error.value.safe_message + assert "960" not in caught_error.value.safe_message diff --git a/services/analysis-engine/tests/test_audio_resource_policy_naming.py b/services/analysis-engine/tests/test_audio_resource_policy_naming.py new file mode 100644 index 000000000..37ac08a09 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy_naming.py @@ -0,0 +1,78 @@ +"""Organization naming-contract regressions for the audio resource policy.""" + +from __future__ import annotations + +import inspect +from dataclasses import fields + +import pytest + +from bandscope_analysis.audio_resource_policy import ( + AudioResourcePolicy, + AudioResourcePolicyError, + 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 test_audio_resource_policy_uses_semantic_owned_field_names() -> None: + """Persist no generic one-word field in the new policy value object.""" + policy_field_names = tuple(policy_field.name for policy_field in fields(AudioResourcePolicy)) + + assert policy_field_names[0] == "policy_version" + assert "version" not in policy_field_names + + +def test_audio_resource_policy_error_uses_semantic_owned_attributes() -> None: + """Expose semantic rejection provenance rather than generic reason/message fields.""" + policy_error = AudioResourcePolicyError("malformed_header", "safe copy") + + assert policy_error.rejection_reason == "malformed_header" + assert policy_error.safe_message == "safe copy" + assert not hasattr(policy_error, "reason") + assert not hasattr(policy_error, "message") + + +@pytest.mark.parametrize( + ("policy_function", "expected_parameters"), + [ + (policy_rejection_message, ("rejection_reason",)), + ( + estimate_decoded_memory_bytes, + ("sample_count", "channel_count", "audio_resource_policy"), + ), + ( + validate_encoded_file_bytes, + ("file_size_bytes", "audio_resource_policy"), + ), + ( + validate_duration_seconds, + ("duration_seconds", "audio_resource_policy"), + ), + ( + validate_source_sampling_rate, + ("sampling_rate_hz", "audio_resource_policy"), + ), + ( + validate_channel_count, + ("channel_count", "audio_resource_policy"), + ), + ( + validate_decoded_audio, + ("decoded_audio", "sampling_rate_hz", "audio_resource_policy"), + ), + ], +) +def test_audio_resource_policy_public_functions_use_semantic_parameter_names( + policy_function: object, + expected_parameters: tuple[str, ...], +) -> None: + """Keep organization-owned public function parameters bounded-context specific.""" + parameter_names = tuple(inspect.signature(policy_function).parameters) + + assert parameter_names == expected_parameters 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..325ae38b0 --- /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.rejection_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..605630476 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -83,7 +83,7 @@ def test_temporal_analyzer_directory_does_not_call_decoder( def test_temporal_analyzer_invalid_y_type(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Ensure temporal analyzer raises ValueError if librosa returns non-ndarray.""" + """Malformed decoder output is contained behind the resource-policy boundary.""" import librosa from bandscope_analysis.temporal.analyzer import TemporalAnalyzer @@ -91,12 +91,15 @@ 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.audio_decode.preflight_audio_metadata", lambda _fileobj, _policy: None + ) monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" test_wav.write_bytes(b"dummy") - with pytest.raises(ValueError, match="Expected numpy array"): + with pytest.raises(ValueError, match="could not be read as audio"): TemporalAnalyzer().analyze(test_wav) @@ -104,7 +107,7 @@ def test_temporal_analyzer_exception_handling( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Ensure temporal analyzer catches general exceptions and raises ValueError.""" + """Third-party decoder details are contained behind payload-free policy copy.""" import librosa from bandscope_analysis.temporal.analyzer import TemporalAnalyzer @@ -112,12 +115,15 @@ 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.audio_decode.preflight_audio_metadata", lambda _fileobj, _policy: None + ) monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" test_wav.write_bytes(b"dummy") - with pytest.raises(ValueError, match="Temporal analysis failed: Mocked general error"): + with pytest.raises(ValueError, match="could not be read as audio"): TemporalAnalyzer().analyze(test_wav) @@ -138,12 +144,12 @@ 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) def test_temporal_analyzer_uses_duration_limit(monkeypatch, tmp_path: Path) -> None: - """Ensure librosa.load receives bounded duration for safer decode behavior.""" + """Ensure the owned decode port receives the bounded analysis duration.""" import librosa test_wav = tmp_path / "bounded.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.audio_decode.preflight_audio_metadata", lambda _fileobj, _policy: 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.audio_decode.preflight_audio_metadata", lambda _fileobj, _policy: 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..a3d67ed00 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -62,6 +62,28 @@ 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 pass the canonical limit to the owned decode port.""" + captured_kwargs: dict[str, object] = {} + + def fake_decode( + source: object, *, target_sample_rate_hz: int, max_duration_seconds: float + ) -> tuple[np.ndarray, int]: + assert isinstance(source, io.BytesIO) + captured_kwargs["target_sample_rate_hz"] = target_sample_rate_hz + captured_kwargs["max_duration_seconds"] = max_duration_seconds + return np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32), SAMPLE_RATE + + monkeypatch.setattr(transcription_api, "decode_mono_audio", fake_decode) + transcribe_bass_stem(b"wav-bytes") + assert captured_kwargs["target_sample_rate_hz"] == SAMPLE_RATE + assert ( + captured_kwargs["max_duration_seconds"] + == transcription_api.MAX_TRANSCRIPTION_DURATION_SECONDS + ) + assert captured_kwargs["max_duration_seconds"] == 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") diff --git a/services/analysis-engine/uv.lock b/services/analysis-engine/uv.lock index 47f7be6ef..1e17cefcc 100644 --- a/services/analysis-engine/uv.lock +++ b/services/analysis-engine/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -101,6 +101,7 @@ name = "bandscope-analysis" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "audioread" }, { name = "demucs", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }, { name = "librosa" }, { name = "numba" }, @@ -121,6 +122,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "audioread", specifier = "==3.1.0" }, { name = "demucs", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'", specifier = ">=4.0.1" }, { name = "librosa", specifier = ">=0.11.0" }, { name = "numba", specifier = "<0.67.0" }, @@ -749,7 +751,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, @@ -760,7 +761,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, @@ -771,7 +771,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, @@ -782,7 +781,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, @@ -793,7 +791,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },