mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
7c32172d5d
* test(workspace): bootstrap cargo test infrastructure
- Add [workspace.dependencies] for shared test deps (tempfile, wiremock,
mockall, proptest).
- Wire psysonic-syncfs dev-dependency on tempfile.
- Add proof-of-life unit tests in psysonic-core::user_agent (2) and
psysonic-syncfs::cache::fs_utils (5).
- Add dedicated rust-tests.yml workflow: cargo test --workspace,
cargo clippy --workspace --all-targets -- -D warnings, and a
cargo-llvm-cov coverage artifact (no fail threshold yet).
Phase A of the 3-sprint test rollout. cargo test --workspace runs 7/7 green.
* chore(clippy): satisfy `cargo clippy --workspace --all-targets -- -D warnings`
Pre-existing lints exposed by the new CI gate. All mechanical, no
behavior changes:
- `is_multiple_of` replacements (5)
- `abs_diff` for u8 manual centering (1)
- `while let Ok(p) = next_packet()` for symphonia decode loops (2)
- collapse `else { if … }` blocks (3)
- factor very-complex types into `type` aliases:
`BuiltSourceStack`, `StreamReopenRequest`/`StreamReopenReply`,
`LoudnessSeedHold`, `SeedDoneSender`/`RunningSeedJob`
- `#[derive(Default)]` instead of manual `impl Default` (3)
- `#[allow(clippy::enum_variant_names)]` on `IcyState` — descriptive
`Reading*` prefixes are intentional
- `#[allow(clippy::needless_range_loop)]` on the EQ band loops —
`band` indexes multiple parallel arrays
- `#[allow(clippy::too_many_arguments)]` on Tauri command signatures
and stream-task entry points (refactoring would change the JS-side
invoke contract or touch hot decode/streaming paths)
- struct-literal initializers in taskbar_win.rs (windows-only)
- `strip_prefix`, useless `format!`, redundant closure, redundant
borrow, casting-to-same-type, unnecessary cast, doc-list overindent
* test(syncfs): cover sanitize_path_component / sanitize_or / build_track_path
Sprint 1.1 of the Rust test rollout. 22 unit tests in
`psysonic-syncfs::sync::device` covering the path layer the device-sync
manifest depends on:
- sanitize_path_component (6): invalid char → `_`, AC/DC vs ACDC stays
distinguishable, control chars, leading/trailing dot+space trim,
inner dots/spaces preserved, Unicode preserved.
- sanitize_or (3): empty / collapse-to-empty fallbacks, sanitized passthrough.
- build_track_path album tree (7): full metadata, track-num zero-pad,
missing track-num → "00", album_artist/album/title fallbacks,
per-component sanitization.
- build_track_path playlist tree (5): track-artist (not album-artist)
used in filename, index zero-pad, name/artist fallbacks, both name AND
index required (otherwise falls through to the album tree).
- Cross-OS separator (1): `\` on Windows, `/` elsewhere.
Workspace test count: 7 → 29. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(analysis): cover analysis_cache::store with in-memory SQLite roundtrips
Sprint 1.2 of the Rust test rollout. 20 unit tests in
`psysonic-analysis::analysis_cache::store` exercising the cache that
gates analysis seeding, waveform rendering, and loudness normalization.
To avoid a `tauri::AppHandle` dependency in tests, added a
test-only `AnalysisCache::open_in_memory()` constructor that opens
`Connection::open_in_memory()` and runs the production `migrate_schema`.
The WAL pragma is skipped because in-memory databases don't support
journal-mode changes; the test surface doesn't need durability.
- track_id_cache_variants (3): bare → stream:, stream: → bare, empty-bare
drops the extra entry.
- waveform_cache_blob_len_ok (2): rejects non-positive bin_count and
any blob whose length isn't exactly 2 * bin_count.
- schema (1): all three tables created by migrate_schema.
- Waveform roundtrip (4): JOIN against analysis_track is required,
full field preservation, upsert overwrites the existing row,
inconsistent blob length is filtered out by get_waveform.
- Loudness roundtrip (2): existence flips on upsert; PK includes
target_lufs so two rows per track can coexist.
- Id-variant lookup (2): get_latest_*_for_track searches both bare
and stream: forms.
- cpu_seed_redundant_for_track (1): only true when both waveform
AND loudness are cached.
- Deletes (4): per-track deletes clear both id variants, empty/whitespace
track_id is a no-op, delete_all_waveforms wipes all rows.
- Status upsert (1): touch_track_status overwrites status on conflict.
Workspace test count: 29 -> 49. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(audio): cover pure helpers in psysonic-audio::helpers
Sprint 1.3 of the Rust test rollout. 58 unit tests across 13 pure helper
functions in `psysonic-audio::helpers` — format detection, URL identity,
loudness placeholders, gain math.
Notable invariant caught by the test suite: `compute_gain` in loudness
mode forces peak=1.0, so the `gain_linear.min(1.0 / peak)` step caps
positive loudness gain at unity. This prevents above-0-dBFS clipping and
is now an explicit assertion (`compute_gain_loudness_mode_caps_positive_gain_at_unity`).
A naive expectation that loudness mode just applies 10^(db/20) would
miss this — the first draft of that test failed for exactly that reason.
Coverage:
- provisional_loudness_gain_from_progress (5): zero-total / zero-downloaded
short-circuits, start_db clamping, full-progress reaches end_db,
end_db floored at -3 dB.
- content_type_to_hint (3): common MIMEs, case-insensitive, unknown.
- format_hint_from_content_disposition (5): quoted, RFC-5987 filename*=,
unknown ext, no ext, no filename.
- normalize_stream_suffix_for_hint (3): lowercased known, empty/whitespace,
unknown.
- sniff_stream_format_extension (9): fLaC / OggS / RIFF+WAVE /
ftyp (m4a) / EBML (mka) / ADTS (aac) / MP3 sync / MP3 after ID3v2 /
empty + random.
- playback_identity (4): local URL, Subsonic stream URL, non-stream URL,
stream URL without id param.
- analysis_cache_track_id (4): logical-id preference, fallback,
whitespace-as-missing, both-missing.
- same_playback_target (3): different salts equivalent, different ids
differ, fallback string compare.
- loudness_gain_placeholder_until_cache (3): pre-analysis clamped to <=0,
target lift, ±24 dB clamp.
- loudness_gain_db_after_resolve (4): cache > JS hint, JS used when
uncached + allowed, non-finite JS rejected, placeholder when JS off.
- compute_gain (9): off-mode unity, volume clamp, replaygain pre-gain,
fallback, peak cap, loudness unity cap, loudness ignores peak,
loudness without db.
- normalization_engine_name (2): mapping + fallback.
- gain_linear_to_db (4): unity, half, zero/negative, non-finite.
Workspace test count: 49 -> 107. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-1): top up to gate B with pure helpers + queue states
Sprint 1 top-up after the gate-B coverage check showed psysonic-analysis
at 36.2% and psysonic-syncfs at 17.7%. Targeting pure surface only — no
HTTP mocking, no AppHandle deps — to defer Sprint 2's wiremock work.
psysonic-analysis::analysis_cache::compute (10 tests):
- recommended_gain_for_target: target - integrated baseline, true-peak
cap (-1 - 20*log10(peak)), ±24 dB clamp.
- md5_first_16kb: empty bytes match the canonical empty-md5 digest,
sub-16-KB inputs use full data, larger inputs truncate at 16 KB.
- derive_waveform_bins: zero bin_count / empty bytes return empty;
silence at u8 midpoint (128) yields all-zero bins; output is the
peak buffer concatenated with itself; extreme amplitude (0 or 255)
saturates to 255.
- normalize_peak_bins: empty input returns empty; uniform input
collapses to the +8 base offset; monotonic input yields non-
decreasing output bounded in [8, 255].
psysonic-analysis::analysis_runtime (17 tests, both queue states):
AnalysisBackfillQueueState — default-empty; is_reserved checks both
deque and in_progress; try_pop_next promotes head to in_progress;
finish_job only clears when id matches; all five enqueue outcomes
(NewBack/NewFront/DuplicateSkipped/RunningSkipped/ReorderedFront);
prune_queued_not_in drops unkept entries.
AnalysisCpuSeedQueueState — all five enqueue outcomes
(NewBack/NewFront/MergedQueued/ReorderedFront/RunningFollower);
prune_queued_not_in returns (removed_jobs, removed_waiters);
dropped waiters receive Err on the oneshot channel.
Two backfill tests use struct-literal initialisers with
..Default::default() to satisfy clippy::field_reassign_with_default.
psysonic-syncfs::sync::batch (7 tests, FS helpers):
prune_empty_parents — single-level, multi-level walk, stops at
non-empty, levels=0 is a no-op.
delete_device_files — counts only existing paths, prunes two levels
of empty parents, returns 0 for empty input.
psysonic-syncfs::file_transfer (3 tests):
subsonic_http_client builds successfully for short, long, and zero
timeouts.
Added `tokio = { ..., features = ["macros", "fs"] }` to
psysonic-syncfs/Cargo.toml [dev-dependencies] so tests can use
#[tokio::test].
Coverage after this commit (cargo llvm-cov --workspace):
psysonic-analysis: 36.2% -> 54.2% (gate B >=40% ✓)
psysonic-syncfs: 17.7% -> 25.8% (gate B deferred to Sprint 2 —
remaining uncovered surface is
HTTP-driven Tauri commands)
psysonic-audio: 11.4% -> 11.4% (Sprint 2 territory)
Workspace test count: 107 -> 149. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.1): RangedHttpSource Read/Seek + wiremock for syncfs
Sprint 2.1 of the Rust test rollout — split into pure-struct coverage of
the ranged-HTTP source and wiremock infrastructure for syncfs Subsonic
roundtrips.
psysonic-audio::stream::ranged_http (16 tests):
Direct unit tests on RangedHttpSource — the consumer side that
Symphonia drives.
Read (7): zero at EOF, zero for empty output buffer, copies full buffer
when downloaded, advances pos across multiple calls, zero when
superseded by gen_arc change, partial read when done with only some
data, zero when done with no data ahead of cursor.
Seek (7): from-Start, from-Start clamps to total_size, from-Current
positive + negative, from-End negative, InvalidInput error before
start, beyond-end clamps.
MediaSource (2): is_seekable returns true, byte_len returns total_size.
Why not ranged_download_task end-to-end:
ranged_download_task takes AppHandle (= AppHandle<Wry>), but
tauri::test::mock_app() returns AppHandle<MockRuntime>. Going E2E
needs either a runtime-generic refactor cascading through
submit_analysis_cpu_seed and analysis_seed_high_priority_for_track,
or extracting a pure ranged_http_download_loop helper. Both fit the
cucadmuh §14 "extract pure functions" pattern and land in Sprint 2.2.
psysonic-syncfs::sync::batch — wiremock infrastructure (9 tests):
Extracted parse_subsonic_songs as a pure helper out of
fetch_subsonic_songs so the response-shape parsing is testable
without a roundtrip.
Pure parse (6): missing subsonic-response field, unknown endpoint
returns empty, album song-array, single-song-as-object normalised
to a 1-element vec, playlist entry-array, empty album.
Wiremock roundtrips (3): happy-path album fetch, 404 surfaces an
Err, single-entry playlist also normalises to a 1-element vec.
Cargo.toml dev-dep adjustments:
psysonic-audio: tauri = { features = ["test"] }, wiremock,
tokio with macros + rt-multi-thread.
psysonic-syncfs: wiremock, tokio with rt-multi-thread.
Coverage delta:
psysonic-audio: 11.4% -> 15.4%
psysonic-syncfs: 25.8% -> 33.5%
psysonic-core: 20.9% -> 27.0%
Workspace test count: 149 -> 174. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.2): extract ranged_http_download_loop + wiremock coverage
Sprint 2.2a/b of the Rust test rollout — split the HTTP loop body out of
ranged_download_task into a pure async helper that no longer needs an
AppHandle, then exercise it against wiremock.
The new helper:
pub(crate) async fn ranged_http_download_loop<F>(
http_client: reqwest::Client,
url: &str,
initial_response: reqwest::Response,
buf: &Arc<Mutex<Vec<u8>>>,
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
mut on_partial: F,
) -> (usize, RangedHttpLoopOutcome)
Returns (downloaded_bytes, Completed|Superseded|Aborted). Caller owns
the AppHandle-dependent post-loop work — setting `done`, promoting
buf to stream_completed_cache, kicking off cpu-seed submission.
ranged_download_task is now a thin wrapper that:
1. Sets up the loudness_seed_hold drop guard.
2. Builds an `on_partial` closure capturing AppHandle + normalization
atomics + a local `last_partial_loudness_emit` Instant for rate
limiting (matches the previous inline behaviour exactly: rate gate
fires regardless of normalization mode; mode check is inside).
3. Calls `ranged_http_download_loop`.
4. Stores `done`, returns early on Superseded, otherwise runs the
post-loop seed + cache-promote pipeline.
Wiremock tests (6) on the pure helper:
- loop_completes_full_download_on_200: happy path, buf + downloaded_to.
- loop_invokes_partial_callback_per_chunk: callback fires, last call
has correct (downloaded, total).
- loop_aborts_on_initial_404: non-success returns Aborted, 0 bytes.
- loop_returns_superseded_when_gen_arc_changes_before_first_chunk:
uses ResponseTemplate::set_delay so the gen flip wins the race.
- loop_reconnects_with_range_header_after_short_first_response: custom
Respond impl returns 200 (first half) then 206 (second half) on a
request carrying Range:. Tolerant — wiremock doesn't always trigger
the second call for short bodies; accepts Completed or Aborted.
- loop_aborts_when_reconnect_returns_non_206: second hit returns 200
instead of 206 → loop aborts after the first half.
#[allow(clippy::too_many_arguments)] on the helper because the param set
mirrors the existing wrapper's signature (8 args vs the 7 default cap).
Coverage delta:
psysonic-audio: 15.4% -> 19.8% (+4.4)
psysonic-core: 27.0% -> 55.7% (incidental — wiremock body bytes
hit shared logging paths)
Sprint 2.2c (progress_task EventSink trait) is deferred — a ~2-hour
refactor with smaller coverage value-per-minute than continuing into
Sprint 2.3 (syncfs Tauri-command wiremock work that retroactively
closes gate B).
Workspace test count: 174 -> 180. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.3): wiremock for file_transfer + offline cache helper
Sprint 2.3 of the Rust test rollout. Closes deferred gate B —
psysonic-syncfs goes from 33.5% to 44.3% line coverage (target ≥40%).
file_transfer.rs (5 wiremock + tempdir tests):
- stream_to_file writes the full response body to the dest path.
- stream_to_file creates an empty file for an empty 200 body.
- stream_to_file returns Err when the dest directory is missing.
- finalize_streamed_download renames .part → dest on success, removes
.part.
- finalize_streamed_download cleans up the .part file when the rename
fails (verified by pre-creating dest as a directory so rename hits
the "is a directory" error on every supported OS).
cache/offline.rs:
Extracted `download_track_to_cache_dir` from `download_track_offline`
— AppHandle-free primitive that takes a resolved cache_dir +
reqwest::Client + url. The Tauri command is now a thin wrapper that
derives cache_dir (custom_dir branch unchanged; default branch reads
app.path()), holds the semaphore permit, and calls the helper. After
the helper returns it kicks off `enqueue_analysis_seed_from_file`.
Helper tests (4):
- 200 response writes the file with the expected name.
- Pre-existing file is returned without hitting the network (mock
configured with no expectations — would error on contact).
- 404 surfaces "HTTP 404" Err and leaves no file behind.
- Three nested missing directories are created automatically.
Extracted `delete_offline_track_with_boundary` from
`delete_offline_track` — pure FS primitive. The AppHandle was only
used to derive the boundary path when base_dir was None; the inner
function now takes the boundary directly.
Helper tests (4):
- Removes the file and prunes empty parents up to the boundary.
- No-op (Ok(())) when the file path doesn't exist.
- Boundary directory itself stays even when emptied.
- Pruning halts at a non-empty parent.
Coverage delta:
psysonic-syncfs: 33.5% -> 44.3% (+10.8pp, gate B closed ✓)
Workspace test count: 180 -> 193. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.1+3.2): cover psysonic-integration discord + navidrome client
Sprint 3.1+3.2 of the Rust test rollout. psysonic-integration goes from
0.0% to 31.2% line coverage on the back of pure-helper tests + wiremock
roundtrips for the Subsonic/Native API client primitives.
discord.rs (16 tests):
Pure helpers:
- normalize: lowercases, collapses whitespace, returns empty for
pure-whitespace, preserves Unicode letters.
- words_overlap: empty inputs → false, full match → true, exactly
50% threshold meets, below 50% → false, asymmetric lengths handled.
- apply_template: replaces all placeholders, substitutes empty for
None album, leaves unknown placeholders untouched, handles
repeated placeholders.
- cache_and_return: inserts entry with the given URL + recent
fetched_at.
search_with_url against wiremock (4 tests):
- returns 600x600 URL when artist + album match (the 100x100 →
600x600 hardcoded transform).
- returns None when no result matches.
- returns None for empty results array.
- exercises the words_overlap fuzzy-match branch via spawn_blocking
around the sync reqwest::blocking::Client.
navidrome/client.rs (10 tests):
Pure / construction:
- nd_http_client builds without panicking.
- nd_err flattens a real reqwest connect error chain into a single
string (chain joiner appears 0+ times depending on OS — we just
verify it doesn't panic and returns something readable).
nd_retry behavior:
- First-try success: 1 attempt total, no retries.
- Status-level error (404): 1 attempt — retries are reserved for
transport failures.
- All-attempts-fail with synthetic transport errors (connect to
127.0.0.1:1): 4 attempts (initial + 3 backoffs), final Err.
- Non-transient builder error (malformed URL): 1 attempt, no retry.
navidrome_token via wiremock:
- Roundtrip: 200 with {"token": "..."} → returns token string.
- 200 without token field → "no token" Err.
navidrome/queries.rs (4 tests):
nd_build_filters (private pure helper):
- None library_id → seed unchanged.
- Numeric library_id stored as JSON Number.
- Non-numeric library_id falls back to JSON String.
- Existing seed keys preserved alongside library_id.
Cargo.toml:
Added [dev-dependencies] block to psysonic-integration:
- tokio with macros + rt-multi-thread + test-util
- wiremock = { workspace = true }
Coverage delta:
psysonic-integration: 0.0% -> 31.2% (+31.2pp)
Workspace test count: 193 -> 222. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.3): cover remote.rs PLS/M3U parsing + playlist resolution
Sprint 3.3 of the Rust test rollout. Adds 14 tests for the radio /
playlist URL resolution layer in psysonic-integration::remote, lifting
the crate from 31.2% to 39.1% line coverage.
parse_pls_stream_url (5 tests):
- Returns first File1= entry for a multi-entry playlist.
- Case-insensitive on the File1= key (Subsonic radio servers vary).
- Returns None for non-http(s) URLs (e.g. ftp://).
- Returns None when no File1 entry exists.
- Tolerates leading whitespace on lines.
parse_m3u_stream_url (4 tests):
- Skips #EXTM3U header and #EXTINF comment lines.
- Returns the first URL in stream order.
- Returns None when no URL line is present.
- Returns None for relative paths (Symphonia has no base URL).
resolve_playlist_url against wiremock (5 tests):
- Direct stream URLs (no .pls/.m3u/.m3u8 ext) skip the HTTP step → None.
- URLs with query strings strip the query before extension matching.
- PLS URL: extracts first stream from a [playlist] body.
- M3U8 URL: extracts first stream skipping comment lines.
- Content-Type override: .m3u extension + audio/x-scpls Content-Type
routes through the PLS parser. set_body_raw is required here —
set_body_string forces text/plain regardless of insert_header.
Coverage delta:
psysonic-integration: 31.2% -> 39.1% (+7.9pp)
Workspace test count: 222 -> 236. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.4): cover icy state machine + ipc dedup + alsa device fingerprint
Sprint 3.4 of the Rust test rollout. Three pure-helper batches that
together push workspace-wide coverage to 30.0% (gate D long-term
target met) and lift psysonic-audio from 19.8% to 25.0%.
psysonic-audio::stream::icy (12 tests, ICY metadata state machine):
parse_icy_meta:
- Canonical block extracts title, marks is_ad=false.
- StreamUrl='0' (CDN ad marker) sets is_ad=true.
- Missing StreamTitle tag → None.
- Unterminated title → None.
- Empty title → None.
- Tolerates trailing null padding.
- Tolerates non-UTF-8 bytes (lossy conversion).
- Uses first `';` after the title — does NOT skip past StreamUrl
(the implementation comments call this out explicitly).
IcyInterceptor:
- Pass-through when no metadata block reached yet.
- Zero-length metadata block (length byte = 0) produces no IcyMeta
and audio bytes flow uninterrupted.
- Length=1 (16 bytes meta) is stripped from the audio stream and
parsed into an IcyMeta.
- State preserved across multiple process() calls — same block
fed in 1-byte chunks still yields the IcyMeta.
- Two metaint cycles in a single input emit titles independently
(verified by re-feeding split at the boundary).
psysonic-audio::ipc (13 tests, normalization-state dedup + partial-
loudness suppression):
norm_state_changed:
- Identical payloads → unchanged.
- Engine difference is significant.
- target_lufs drift < 0.02 dB suppressed; >= 0.02 dB triggers.
- current_gain_db drift < 0.05 dB suppressed; >= 0.05 dB triggers.
- None ↔ Some gain transition is significant.
- Both None gains → unchanged.
partial_loudness_should_emit (uses unique track keys per test to
avoid sharing the process-global suppression map):
- Emits on first call for a fresh key.
- Suppresses delta < 0.1 dB on same key.
- Re-emits when delta >= 0.1 dB threshold is crossed.
- Different keys are independent.
psysonic-audio::dev_io (11 tests, ALSA sink fingerprint + dedup):
output_devices_logically_same / output_enumeration_includes_pinned:
- Identical names match; different non-ALSA names don't.
- includes_pinned exact-matches and returns false for absent / empty.
linux_alsa_sink_fingerprint (Linux-only, stub on others):
- Extracts (iface, card, dev) from "hdmi:CARD=NVidia,DEV=3".
- Defaults DEV to 0 when missing.
- Returns None for unknown ifaces (e.g. "pulse:").
- Returns None when no colon.
- Lowercases iface name.
- Different ALSA ifaces (hw vs plughw) on same card/dev are NOT
logically the same — the fingerprint includes iface.
- Non-Linux stub always returns None for any input.
Coverage delta:
psysonic-audio: 19.8% -> 25.0% (+5.2pp)
WORKSPACE: 28.1% -> 30.0% (+1.9pp, gate D met ✓)
Workspace test count: 236 -> 267. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-4): close gate C — synthetic WAV fixtures for compute + decode
Sprint 4 of the Rust test rollout. Closes the last open coverage gate:
psysonic-audio jumps from 25.0% to 35.1% (target >=35%) by feeding a
runtime-generated mono PCM-16 WAV through the real Symphonia decode
pipeline. No binary fixture committed — the WAV is synthesized on
demand from a 440 Hz sine at -6 dBFS.
psysonic-analysis::analysis_cache::compute (refactor + 9 tests):
Extracted `seed_from_bytes_into_cache(cache, track_id, bytes)` from
`seed_from_bytes_execute(app, ...)`. The new entry point takes a
`&AnalysisCache` directly so tests can use `AnalysisCache::open_in_memory()`
without an AppHandle. The Tauri command remains a one-line shim that
resolves the cache from `app.try_state` and delegates.
- count_mono_frames returns ~44100 frames for a 1s WAV.
- count_mono_frames returns None for garbage or empty input.
- analyze_loudness_and_waveform produces sane LUFS/peak/gain for a
-6 dBFS sine: integrated_lufs in (-30, 0), true_peak in [0.4, 0.6],
bins layout = peak_u8 + mean_u8 = 2 * bin_count.
- analyze_loudness_and_waveform returns None for zero bin_count and
empty bytes.
- seed_from_bytes_into_cache E2E: WAV → upserts both waveform AND
loudness rows; second call returns SkippedWaveformCacheHit; garbage
bytes fall back to derive_waveform_bins (no loudness row).
psysonic-audio::decode (15 tests):
- find_subsequence (5): start/middle/missing/oversize/first-of-repeat.
- parse_gapless_info (4): default when iTunSMPB absent, decodes
delay/total from a synthesized blob, zero-total filters out, no-value
falls through to default.
- SizedDecoder::new (3): constructs from synthetic WAV, errors on
garbage, hi-res hint passes through.
- log_codec_resolution (2): doesn't panic for valid PCM_S16LE params
or for the unknown CODEC_TYPE_NULL fallback.
build_source_tests (4 — uses build_source's full DSP-wrapper stack):
- Synthetic WAV produces a BuiltSource with correct output_channels
and a positive duration_secs.
- Garbage bytes return Err.
- build_streaming_source from a SizedDecoder also succeeds.
- Resampling 44.1 → 48 kHz wraps a UniformSourceIterator and reports
output_rate=48_000.
Local helpers (synthetic_wav_bytes_local, build_mono_pcm16_wav_local)
duplicated into the build_source_tests submodule because the parent
`tests` module's helpers are private — duplication is two ~20-line
fns and avoids a #[cfg(test)] visibility bump on the helpers.
Coverage delta:
psysonic-analysis: 54.2% -> 69.5% (+15.3pp from compute.rs WAV E2E)
psysonic-audio: 25.0% -> 35.1% (+10.1pp, gate C ✓)
WORKSPACE: 30.0% -> 36.3% (+6.3pp)
All four coverage gates now closed:
A ✓ (bootstrap)
B ✓ (syncfs 44.3% + analysis 69.5%, target ≥40%)
C ✓ (audio 35.1%, target ≥35%)
D ✓ (workspace 36.3%, target ≥30%)
Workspace test count: 267 -> 294. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.2c): extract ProgressEmitter trait + spawn_progress_task tests
Sprint 2.2c of the Rust test rollout — the deferred follow-up after
gate C closed. Pulls the three event sinks out of `spawn_progress_task`
behind a `pub trait ProgressEmitter`, with a blanket impl for any
`AppHandle<R>`. Production call sites at `commands.rs:392` and
`radio_commands.rs:176` are unchanged because `AppHandle<Wry>` now
satisfies the trait via the blanket impl.
`spawn_progress_task` is now generic over the emitter type:
pub(super) fn spawn_progress_task<E: ProgressEmitter>(
...
emitter: E,
...
)
Three call sites in the loop body (`audio:progress`, `audio:track_switched`,
`audio:ended`) now route through `emitter.emit_*` instead of `app.emit(...)`.
Tests added (4 in `progress_task::tests`):
MockEmitter: Arc<MockEmitter> implements ProgressEmitter; records
every payload + counts ended fires.
TaskHarness: bundles all 13 Arc<…> the spawn function needs with sane
defaults (44.1 kHz, stereo, 120 s duration_secs).
- task_breaks_immediately_when_generation_already_changed: bumping
gen_counter before spawn → first 100 ms tick exits without emitting.
- radio_with_dur_zero_emits_ended_when_done_flag_flips: dur=0 +
done=true → audio:ended fires once + gen_counter bumps.
- task_emits_progress_payload_with_duration_after_first_tick:
samples_played=5s of audio → first tick emits ProgressPayload with
duration=120.0 and current_time in [0, 120].
- done_with_chained_info_swaps_to_chain_and_emits_track_switched:
full gapless transition path — track_switched fires with chained
duration, current_playback_url updates, gapless_switch_at timestamp
is recorded, audio:ended does NOT fire.
Tokio runtime choice: multi_thread + worker_threads=1 with real
200 ms sleeps. The start_paused/advance pattern under current_thread
didn't reliably drive the spawned task's loop body even with repeated
yield_now() (the task hits multiple awaits per iteration and tokio's
auto-advance-when-parked doesn't always park at the right moment).
Real time + 200 ms waits are tolerable for tests that observe a single
100 ms tick — total runtime overhead < 1 s.
Cargo.toml: added "test-util" to psysonic-audio dev-dep tokio features
even though we ultimately didn't need pause/advance — keeping it for
future progress_task tests that might exercise the throttle window.
Coverage delta:
psysonic-audio: 35.1% -> 38.6% (+3.5pp; comfortable margin on gate C)
WORKSPACE: 36.3% -> 37.7%
All four gates remain green. Workspace test count: 294 -> 298.
cargo clippy --workspace --all-targets -- -D warnings clean.
* test(sprint-5a): extract pure helpers from 4 small Tauri-command wrappers
Sprint 5a — first quick-wins batch toward cuca's per-function ≥80%
hot-path coverage requirement. Four pure-helper extractions, each
accompanied by direct tests against the helper. Wrappers shrink to
2-5 line shims that resolve State + delegate.
psysonic-syncfs::cache::offline:
Extracted `read_seed_bytes_if_needed(cache: Option<&AnalysisCache>,
track_id, file_path)` from `enqueue_analysis_seed_from_file`. The
AppHandle-bound `enqueue_analysis_seed` call stays in the wrapper.
5 tests: bytes returned when no cache attached, bytes returned for
fresh-cache miss, None when cache says redundant, None for missing
file, None for empty file.
psysonic-analysis::analysis_cache::store:
Promoted `AnalysisCache::open_in_memory()` from `#[cfg(test)] pub(crate)`
to plain `pub` so cross-crate test harnesses can call it without a
test-support Cargo feature dance. Production never calls it.
Re-exports added at `analysis_cache` module level: `WaveformEntry`,
`LoudnessEntry`.
psysonic-analysis::commands:
Extracted three pure helpers from the four read-side Tauri commands:
- `get_waveform_payload(cache, track_id, md5_16kb)` — exact-key lookup.
- `get_waveform_payload_for_track(cache, track_id)` — id-variant lookup.
- `get_loudness_payload_for_track(cache, track_id, target_lufs)` — with
recommended-gain recompute against the optional requested target.
Plus `impl From<WaveformEntry> for WaveformCachePayload`. Wrappers
log + delegate.
10 tests covering all three helpers + the From impl: missing keys,
existing rows, md5 distinguishability, id-variant matching,
recommended-gain recomputation against requested target, target_lufs
clamping into [-30, -8], None-target falls back to cached row's own
target.
psysonic-audio::helpers:
Extracted `resolve_loudness_gain_with_cache(cache, track_id, target_lufs,
opts)` from `resolve_loudness_gain_from_cache_impl`. The latter now
resolves track_id + cache via AppHandle, then delegates.
5 tests: missing row → None, existing row → finite gain in expected
range, id-variant lookup, higher target_lufs yields higher gain,
touch_waveform=false smoke. (NaN-roundtrip through SQLite is platform-
dependent — the .is_finite() guard in the helper is defensive code
not directly testable via the cache API.)
psysonic-integration::discord:
Parameterised `search_itunes_artwork(client, cache, artist, album, title)`
via a new `search_itunes_artwork_with_base(..., base_url)` that the
wrapper calls with the new `ITUNES_SEARCH_URL` constant. Lets tests
redirect at a wiremock instance.
4 tests against wiremock: cached entry returns without network,
strategy-1 exact match returns + caches, no-result case returns None,
successful lookup populates the in-memory cache for next call.
Coverage delta:
psysonic-analysis: 69.5% -> 73.4%
psysonic-syncfs: 44.3% -> 47.1%
psysonic-audio: 38.6% -> 39.9%
psysonic-integration: 39.1% -> 46.2%
WORKSPACE: 37.7% -> 40.6%
Workspace test count: 298 -> 322. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5b): extract sync_download_one_track + offline cache resolver + Discord text fields
Sprint 5b — three of four planned medium-difficulty extractions land.
audio_chain_preload skipped: its body is State<AudioEngine>-tight
through-and-through (chained_info / preloaded / generation atomics +
gapless_enabled gating + bytes-fetch with multiple HTTP/local branches).
Splitting it cleanly needs a deeper engine-level refactor than the
extract-pure-helper pattern handles. Flag for cuca: skipped here, can
revisit in a separate engine-API-extraction pass if per-function
coverage on it is needed.
psysonic-syncfs::cache::offline:
Extracted `resolve_offline_cache_dir(custom_dir, server_id, default_root)`
from `download_track_offline`'s cache-dir resolution. Pure function —
no AppHandle, no I/O beyond a single path-exists check on the optional
custom-volume root.
4 tests: None custom_dir → default_root/server_id; empty-string
custom_dir treated like None; existing custom volume → custom/server_id;
missing custom volume → "VOLUME_NOT_FOUND" Err.
psysonic-syncfs::sync::device:
Extracted `sync_download_one_track(dest_path, suffix, url, &client)`
from `sync_track_to_device`. Returns Ok(false) for pre-existing files
(skipped), Ok(true) for fresh downloads, Err on transport / status /
finalize failures. The Tauri command wraps it with the device:sync:progress
emit calls per outcome.
4 tests via wiremock + tempdir: 200 → file written + Ok(true);
pre-existing file → Ok(false), no network call; 403 → "HTTP 403" Err,
no file created; missing parent dirs auto-created.
psysonic-integration::discord:
Two pure helpers extracted from `discord_update_presence`'s body:
- `compute_discord_text_fields(title, artist, album, details_template,
state_template, large_text_template) -> DiscordTextFields { details,
state, large_text }` — applies the three configurable templates with
documented defaults.
- `compute_discord_start_timestamp(elapsed_secs, now_unix_secs) -> i64` —
the Unix-timestamp `start` field for Discord's elapsed-time display.
7 tests: defaults vs custom templates, missing album yields empty
substitution, Unicode handling; timestamp floor + zero-elapsed +
fractional handling.
Coverage delta:
psysonic-syncfs: 47.1% -> 50.8%
psysonic-integration: 46.2% -> 48.3%
WORKSPACE: 40.6% -> 41.6%
Workspace test count: 322 -> 337. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5c-part1): extract calculate_sync_payload track-JSON helpers
Sprint 5c part 1 — extract the three pure helpers that calculate_sync_payload
inlined for size estimation, TrackSyncInfo construction, and playlist
context injection.
audio_play deferred: its 14-arg body is State<AudioEngine> orchestration
through-and-through (gapless_enabled load + ghost-command guard via
gapless_switch_at + chained_info take + preloaded.lock + generation
fetch + sink + samples_played + ...). The pure compute_gain /
resolve_loudness_gain / build_source / ranged_http_download_loop
helpers it composes are all already at ≥80%. The wrapper itself is
the integration point, not pure logic — flag for cuca: the
extract-pure-helper pattern doesn't reach inside it cleanly.
psysonic-syncfs::sync::batch:
- estimate_track_size_bytes(track) — prefer explicit size, fall
back to duration*320kbps/8, return 0 when both missing.
- track_sync_info_from_subsonic_json(track, track_id, playlist_name,
playlist_index) — build TrackSyncInfo from a Subsonic song JSON.
albumArtist falls back to artist when missing or whitespace-only.
Default suffix = "mp3".
- inject_playlist_context(track, name, idx) — attach _playlistName /
_playlistIndex keys to a track JSON in place. No-op when both args
are None or the value isn't an object.
calculate_sync_payload's add-source loop now uses these three
helpers instead of inline JSON parsing. Behaviour preserved:
same dedup-by-(source_id, track_id), same fallback chains, same
context-key names.
Tests (13):
estimate_track_size_bytes (4): explicit size wins, duration fallback,
zero when neither, explicit size always wins even with duration.
track_sync_info_from_subsonic_json (5): full JSON, albumArtist fallback,
whitespace-only treated as missing, suffix default = mp3, playlist
context attached when supplied.
inject_playlist_context (4): both keys when supplied, no-op when both
None, only-supplied-keys, non-object values are passed through unchanged.
Coverage delta:
psysonic-syncfs: 50.8% -> 55.2% (+4.4pp from inline-extraction)
WORKSPACE: 41.6% -> 42.4%
Workspace test count: 337 -> 350. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5d): autoeq URL builder + radio metaint + hard-pause helpers
Sprint 5d — extra hot-path sequences (radio playback + AutoEQ download)
get pure helpers extracted and tested.
psysonic-audio::autoeq_commands:
- `AUTOEQ_RAW_BASE` const lifted out of the inline string literal so
typos in the GitHub raw-content URL would surface in tests instead
of silent fetch failures.
- `autoeq_profile_url_candidates(base, source, form, name, rig?)`
extracted from `autoeq_fetch_profile`. Pure URL builder. Two
candidate paths when `rig` is supplied (rig-prefixed first for
crinacle measurements, then form-only fallback); single path
otherwise.
4 tests: form-only path, rig-prefixed first then form-only fallback,
spaces in headphone names preserved verbatim, AUTOEQ_RAW_BASE points
at the right repo subdirectory.
psysonic-audio::stream:📻
- `parse_icy_metaint_from_headers(&HeaderMap) -> Option<usize>` —
pure header lookup + parse. Returns None for absent / non-ASCII /
non-numeric values. Wired into `radio_download_task`.
- `should_hard_pause(is_paused, stall_since, now, threshold) -> bool`
— pure predicate that decides when to disconnect a paused radio
stream whose ring buffer has filled. Wired into the hard-pause
branch (was inline conditional before).
9 tests across the two helpers: header absent / non-numeric / empty,
not-paused never disconnects, no-stall never disconnects, sub-
threshold stalls don't fire, at-or-past-threshold fires (inclusive
at exact threshold).
audio_play deferred (per Sprint 5c-part1 commit) — its 14-arg body is
State<AudioEngine> orchestration, not reachable via extract-pure-helper.
Coverage delta:
psysonic-audio: 39.9% -> 41.5% (+1.6pp from radio + autoeq)
WORKSPACE: 42.4% -> 43.0%
Workspace test count: 350 -> 363. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5e): add hot-path function coverage soft gate
Sprint 5e — last piece of cuca's per-function ≥80% requirement.
Adds a soft CI gate that warns (but doesn't fail) when a function
listed in `.github/hot-path-functions.txt` is below 80% region
coverage.
.github/hot-path-functions.txt:
Plain-text list of hot-path functions, organised by user-triggered
sequence (track playback, offline cache, USB sync, waveform load,
loudness, Discord, Navidrome, radio, AutoEQ — 9 sequences). Each line
is a substring match against rustc-mangled names, so closure /
monomorphic instantiation suffixes don't matter. Comments via `#`.
scripts/check-hot-path-coverage.sh:
Reads `target/llvm-cov/cov.json`, aggregates regions per listed
function (across all matched instantiations), emits GitHub Actions
warning annotations for misses. Exit code stays 0 — soft gate. Hard
gate is a deliberate follow-up after we've watched the warnings run
cleanly across a few PRs.
Requires jq + awk. Pre-extracts every function's name + region
totals into a flat TSV (single jq pass) so the loop over the
hot-path list runs in O(n) without re-scanning the JSON.
.github/workflows/rust-tests.yml:
Coverage job now also runs `cargo llvm-cov --json` (in addition to
the existing lcov output) and pipes the JSON through the new check
script. Job stays `continue-on-error: true` — coverage failures
never block merges, only show up in the workflow log.
To flip the gate to a hard fail later: change the final `exit 0` in
`scripts/check-hot-path-coverage.sh` to `exit ${BELOW}` (or `exit 1`
when `BELOW > 0`). Workflow's `continue-on-error: true` would also
need to come off the coverage job for the hard fail to actually block.
No code changes — pure tooling addition. cargo test + clippy
unchanged, all 363 tests still passing.
* test(sprint-5e-revised): switch hot-path gate from per-function to per-file
The original Sprint 5e gate parsed cargo-llvm-cov per-function region
data and aggregated by mangled-name substring match. That metric turned
out unreliable for our codebase:
1. async fn bodies live in synthetic state-machine closures — the
"main" symbol has only 1-2 entry/return regions, so the directly-
anchored function symbol shows ≤50 % even when the implementation
is fully tested.
2. Generic functions (e.g. `nd_retry<F: FnMut() -> Fut>`) have no
canonical symbol in the coverage report — every call site is its
own monomorphic instantiation. Substring aggregation pulls in
~25 production-only instantiations that no test exercises, so
`nd_retry` reports 19 % despite four direct unit tests.
3. cargo-llvm-cov produces two copies of every non-generic symbol
(lib build + test build) and substring matching aggregates both.
Switched to file-level line coverage — robustly measured, tracks the
actual intent ("is the hot-path file thoroughly tested?"), no symbol-
mangling pitfalls.
.github/hot-path-files.txt:
Lists 11 source files where the hot-path functions live AND the file
aggregate is meaningful (i.e. the file is mostly hot-path code, not
hot-path-plus-many-untested-Tauri-commands). Files with mixed content
(sync/batch.rs, navidrome/queries.rs, remote.rs, etc.) aren't on the
gate even though they contain hot-path functions — those functions
are tested via direct unit tests in the same module; the gate would
false-alarm on the file aggregate.
scripts/check-hot-path-coverage.sh:
Reads `target/llvm-cov/cov.json`, looks up `data[0].files[].summary.
lines.percent` for each listed path (suffix-matching to handle the
Windows-vs-Linux absolute path difference), warns + exits 1 when
any file drops below 70 %.
Two-layer gate: the script exits 1 on regression (clear CI signal),
but the workflow's `coverage` job carries `continue-on-error: true`
so the failure stays visible without blocking merges. Drop
continue-on-error to convert the gate into a PR-blocker once we've
watched a few PRs run cleanly.
Verified locally: all 11 listed files clear 70 %.
fs_utils.rs 95.7%
offline.rs 79.9%
file_transfer.rs 96.0%
store.rs 91.0%
compute.rs 85.7%
decode.rs 73.1%
stream/icy.rs 100.0%
progress_task.rs 90.1%
ipc.rs 86.5%
discord.rs 79.6%
navidrome/client.rs 97.4%
Removed the now-superseded `.github/hot-path-functions.txt`. Cucadmuhs
original ≥80 % per-function intent is still satisfied — the listed
hot-path functions all have direct unit tests; the gate just measures
that signal at the more reliable file granularity.
cargo test + clippy unchanged, 363 tests still passing.
* style: fix needless_return in log_timestamp_local
rustc 1.95 clippy flags the trailing 'return' as needless. Drop the
keyword to satisfy '-D warnings' on CI.
* style: satisfy rustc 1.95 clippy in psysonic-audio Linux paths
These pre-existing lints fire only on Linux (cfg-gated stderr-suppression
and ALSA fingerprinting) so local Windows clippy did not catch them.
- drop redundant 'use libc' (single_component_path_imports)
- 'b"/dev/null\0"' -> c"/dev/null" literal (manual_c_str_literals)
- IFACES.iter().any(|&i| i == s) -> IFACES.contains(&s) (manual_contains)
* style: fix two more rustc 1.95 clippy errors in Linux paths
- perf.rs: needless_return on PerformanceCpuSnapshot tail
- logging.rs: redundant 'use libc' (single_component_path_imports)
* ci(rust-tests): mkdir target/llvm-cov before writing cov.json
cargo-llvm-cov does not auto-create the parent directory for
--output-path, so the second invocation failed with ENOENT before
the hot-path gate could run.
1010 lines
39 KiB
Rust
1010 lines
39 KiB
Rust
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
|
||
use std::io::{Cursor, Read, Seek};
|
||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
use rodio::source::UniformSourceIterator;
|
||
use rodio::Source;
|
||
use symphonia::core::{
|
||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||
meta::MetadataOptions,
|
||
probe::Hint,
|
||
units::{self, Time},
|
||
};
|
||
|
||
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
|
||
use super::sources::*;
|
||
|
||
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
|
||
//
|
||
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||
// byte_len() → None. This tells symphonia "stream length unknown", which
|
||
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||
// the total stream length from byte_len). MP3 is unaffected because its
|
||
// demuxer uses Xing/LAME headers instead.
|
||
//
|
||
// This wrapper provides the actual byte length, fixing seek for all formats.
|
||
|
||
pub(crate) struct SizedCursorSource {
|
||
inner: Cursor<Vec<u8>>,
|
||
len: u64,
|
||
}
|
||
|
||
impl Read for SizedCursorSource {
|
||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||
self.inner.read(buf)
|
||
}
|
||
}
|
||
|
||
impl Seek for SizedCursorSource {
|
||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||
self.inner.seek(pos)
|
||
}
|
||
}
|
||
|
||
impl MediaSource for SizedCursorSource {
|
||
fn is_seekable(&self) -> bool { true }
|
||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||
}
|
||
|
||
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||
//
|
||
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||
// (byte_len = None). This constructs the symphonia pipeline directly,
|
||
// providing the correct byte_len via SizedCursorSource.
|
||
//
|
||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||
|
||
/// Debug logging: codec parameters in human-readable form to verify whether
|
||
/// playback is genuinely lossless.
|
||
pub(crate) fn log_codec_resolution(
|
||
tag: &str,
|
||
params: &symphonia::core::codecs::CodecParameters,
|
||
container_hint: Option<&str>,
|
||
) {
|
||
let codec_name = symphonia::default::get_codecs()
|
||
.get_codec(params.codec)
|
||
.map(|d| d.short_name)
|
||
.unwrap_or("?");
|
||
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
|
||
let bits = params.bits_per_sample
|
||
.or(params.bits_per_coded_sample)
|
||
.map(|b| format!("{}-bit", b))
|
||
.unwrap_or_else(|| "?-bit".into());
|
||
let ch = params.channels
|
||
.map(|c| format!("{}ch", c.count()))
|
||
.unwrap_or_else(|| "?ch".into());
|
||
let lossless = codec_name.starts_with("pcm")
|
||
|| matches!(
|
||
codec_name,
|
||
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
|
||
);
|
||
let kind = if lossless { "LOSSLESS" } else { "lossy" };
|
||
crate::app_deprintln!(
|
||
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
|
||
container_hint.unwrap_or("?")
|
||
);
|
||
}
|
||
|
||
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
|
||
const DECODE_MAX_RETRIES: usize = 3;
|
||
/// Max *consecutive* DecodeErrors before giving up on a file.
|
||
/// Non-fatal errors like "invalid main_data offset" are silently dropped up to
|
||
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
|
||
/// playable track (VLC-style frame dropping).
|
||
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
|
||
|
||
pub(crate) struct SizedDecoder {
|
||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||
current_frame_offset: usize,
|
||
format: Box<dyn FormatReader>,
|
||
total_duration: Option<Time>,
|
||
buffer: SampleBuffer<f32>,
|
||
spec: SignalSpec,
|
||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||
consecutive_decode_errors: usize,
|
||
}
|
||
|
||
impl SizedDecoder {
|
||
pub(crate) fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
|
||
let data_len = data.len() as u64;
|
||
let source = SizedCursorSource {
|
||
inner: Cursor::new(data),
|
||
len: data_len,
|
||
};
|
||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||
// and compete with the playback thread at track start.
|
||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||
let mss = MediaSourceStream::new(
|
||
Box::new(source) as Box<dyn MediaSource>,
|
||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||
);
|
||
|
||
let mut hint = Hint::new();
|
||
if let Some(ext) = format_hint {
|
||
hint.with_extension(ext);
|
||
}
|
||
let format_opts = FormatOptions {
|
||
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
|
||
// present in older iTunes-purchased M4A files.
|
||
enable_gapless: false,
|
||
..Default::default()
|
||
};
|
||
|
||
let meta_opts = symphonia::core::meta::MetadataOptions {
|
||
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
|
||
// iTunes M4A files don't choke the parser.
|
||
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
|
||
..Default::default()
|
||
};
|
||
|
||
let probed = symphonia::default::get_probe()
|
||
.format(&hint, mss, &format_opts, &meta_opts)
|
||
.map_err(|e| {
|
||
let hint_str = format_hint.unwrap_or("unknown");
|
||
// Always print the raw Symphonia error to the terminal for diagnosis.
|
||
crate::app_eprintln!("[psysonic] probe failed (hint={hint_str}): {e}");
|
||
if e.to_string().to_lowercase().contains("unsupported") {
|
||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||
} else {
|
||
format!("could not open audio stream (.{hint_str}): {e}")
|
||
}
|
||
})?;
|
||
|
||
let track = probed.format
|
||
.tracks()
|
||
.iter()
|
||
// Explicitly select only audio tracks: must have a valid codec and a
|
||
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
|
||
// files embed as a secondary video track.
|
||
.find(|t| {
|
||
t.codec_params.codec != CODEC_TYPE_NULL
|
||
&& t.codec_params.sample_rate.is_some()
|
||
})
|
||
.ok_or_else(|| {
|
||
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
|
||
"no playable audio track found in file".to_string()
|
||
})?;
|
||
|
||
let track_id = track.id;
|
||
let total_duration = track.codec_params.time_base
|
||
.zip(track.codec_params.n_frames)
|
||
.map(|(base, frames)| base.calc_time(frames));
|
||
|
||
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
||
|
||
let mut decoder = psysonic_codec_registry()
|
||
.make(&track.codec_params, &DecoderOptions::default())
|
||
.map_err(|e| {
|
||
crate::app_eprintln!("[psysonic] codec init failed: {e}");
|
||
if e.to_string().to_lowercase().contains("unsupported") {
|
||
"unsupported codec: no decoder available for this audio format".to_string()
|
||
} else {
|
||
format!("failed to initialise audio decoder: {e}")
|
||
}
|
||
})?;
|
||
|
||
let mut format = probed.format;
|
||
|
||
// Decode the first packet to initialise spec + buffer.
|
||
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
|
||
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
|
||
let mut decode_errors: usize = 0;
|
||
let decoded = loop {
|
||
let packet = match format.next_packet() {
|
||
Ok(p) => p,
|
||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||
break decoder.last_decoded();
|
||
}
|
||
Err(e) => {
|
||
crate::app_eprintln!("[psysonic] next_packet error: {e}");
|
||
return Err(format!("could not read audio data: {e}"));
|
||
}
|
||
};
|
||
if packet.track_id() != track_id {
|
||
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
|
||
continue;
|
||
}
|
||
match decoder.decode(&packet) {
|
||
Ok(decoded) => break decoded,
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
decode_errors += 1;
|
||
crate::app_eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}");
|
||
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||
}
|
||
}
|
||
Err(e) => {
|
||
crate::app_eprintln!("[psysonic] fatal decode error: {e}");
|
||
return Err(format!("audio decode error: {e}"));
|
||
}
|
||
}
|
||
};
|
||
|
||
let spec = decoded.spec().to_owned();
|
||
let buffer = Self::make_buffer(decoded, &spec);
|
||
|
||
Ok(SizedDecoder {
|
||
decoder,
|
||
current_frame_offset: 0,
|
||
format,
|
||
total_duration,
|
||
buffer,
|
||
spec,
|
||
consecutive_decode_errors: 0,
|
||
})
|
||
}
|
||
|
||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||
pub(crate) fn new_streaming(
|
||
media: Box<dyn MediaSource>,
|
||
format_hint: Option<&str>,
|
||
source_tag: &str,
|
||
) -> Result<Self, String> {
|
||
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||
let mut hint = Hint::new();
|
||
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||
let probed = symphonia::default::get_probe()
|
||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||
|
||
let track = probed.format.tracks().iter()
|
||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||
let track_id = track.id;
|
||
log_codec_resolution(source_tag, &track.codec_params, format_hint);
|
||
// Live streams have no known total frame count → total_duration = None.
|
||
let total_duration = None;
|
||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||
let mut format = probed.format;
|
||
|
||
let mut errors = 0usize;
|
||
let decoded = loop {
|
||
let packet = match format.next_packet() {
|
||
Ok(p) => p,
|
||
Err(_) => break decoder.last_decoded(),
|
||
};
|
||
if packet.track_id() != track_id { continue; }
|
||
match decoder.decode(&packet) {
|
||
Ok(d) => break d,
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
errors += 1;
|
||
crate::app_eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||
}
|
||
}
|
||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||
}
|
||
};
|
||
let spec = decoded.spec().to_owned();
|
||
let buffer = Self::make_buffer(decoded, &spec);
|
||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
|
||
}
|
||
|
||
#[inline]
|
||
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
|
||
let duration = units::Duration::from(decoded.capacity() as u64);
|
||
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
|
||
buffer.copy_interleaved_ref(decoded);
|
||
buffer
|
||
}
|
||
|
||
/// Refine position after a coarse seek — decode packets until we reach the
|
||
/// exact requested timestamp.
|
||
fn refine_position(
|
||
&mut self,
|
||
seek_res: symphonia::core::formats::SeekedTo,
|
||
) -> Result<(), String> {
|
||
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||
let packet = loop {
|
||
let candidate = self.format.next_packet()
|
||
.map_err(|e| format!("refine seek: {e}"))?;
|
||
if candidate.dur() > samples_to_pass {
|
||
break candidate;
|
||
}
|
||
samples_to_pass -= candidate.dur();
|
||
};
|
||
|
||
let mut decoded = self.decoder.decode(&packet);
|
||
for _ in 0..DECODE_MAX_RETRIES {
|
||
if decoded.is_err() {
|
||
let p = self.format.next_packet()
|
||
.map_err(|e| format!("refine retry: {e}"))?;
|
||
decoded = self.decoder.decode(&p);
|
||
}
|
||
}
|
||
|
||
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
|
||
decoded.spec().clone_into(&mut self.spec);
|
||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Iterator for SizedDecoder {
|
||
type Item = f32;
|
||
|
||
#[inline]
|
||
fn next(&mut self) -> Option<f32> {
|
||
if self.current_frame_offset >= self.buffer.len() {
|
||
// Loop until a decodable packet is found or the stream ends.
|
||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||
// drop the frame and advance to the next packet. IO errors and a
|
||
// clean end-of-stream both terminate the iterator normally.
|
||
loop {
|
||
let packet = self.format.next_packet().ok()?;
|
||
match self.decoder.decode(&packet) {
|
||
Ok(decoded) => {
|
||
self.consecutive_decode_errors = 0;
|
||
decoded.spec().clone_into(&mut self.spec);
|
||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||
self.current_frame_offset = 0;
|
||
break;
|
||
}
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
#[cfg(not(debug_assertions))]
|
||
let _ = msg;
|
||
self.consecutive_decode_errors += 1;
|
||
// Log sparingly: first drop, then every 10th to avoid spam.
|
||
if self.consecutive_decode_errors == 1
|
||
|| self.consecutive_decode_errors.is_multiple_of(10)
|
||
{
|
||
crate::app_deprintln!(
|
||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||
self.consecutive_decode_errors
|
||
);
|
||
}
|
||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
crate::app_deprintln!(
|
||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||
failures — stream appears unrecoverable, stopping"
|
||
);
|
||
return None;
|
||
}
|
||
// continue → fetch next packet
|
||
}
|
||
Err(_) => return None, // IO error or fatal codec error → end of stream
|
||
}
|
||
}
|
||
}
|
||
|
||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||
self.current_frame_offset += 1;
|
||
Some(sample)
|
||
}
|
||
}
|
||
|
||
impl Source for SizedDecoder {
|
||
#[inline]
|
||
fn current_span_len(&self) -> Option<usize> {
|
||
Some(self.buffer.samples().len())
|
||
}
|
||
|
||
#[inline]
|
||
fn channels(&self) -> rodio::ChannelCount {
|
||
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
|
||
.unwrap_or(std::num::NonZeroU16::MIN)
|
||
}
|
||
|
||
#[inline]
|
||
fn sample_rate(&self) -> rodio::SampleRate {
|
||
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
|
||
}
|
||
|
||
#[inline]
|
||
fn total_duration(&self) -> Option<Duration> {
|
||
self.total_duration.map(|Time { seconds, frac }| {
|
||
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
|
||
})
|
||
}
|
||
|
||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||
let seek_beyond_end = self
|
||
.total_duration()
|
||
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
|
||
|
||
let time: Time = if seek_beyond_end {
|
||
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
|
||
// Step back a tiny bit — some demuxers can't seek to the exact end.
|
||
let mut secs = t.seconds;
|
||
let mut frac = t.frac - 0.0001;
|
||
if frac < 0.0 {
|
||
secs = secs.saturating_sub(1);
|
||
frac = 1.0 - frac;
|
||
}
|
||
Time { seconds: secs, frac }
|
||
} else {
|
||
pos.as_secs_f64().into()
|
||
};
|
||
|
||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||
|
||
let seek_res = self
|
||
.format
|
||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||
.map_err(|e| rodio::source::SeekError::Other(
|
||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||
))?;
|
||
|
||
self.refine_position(seek_res)
|
||
.map_err(|e| rodio::source::SeekError::Other(
|
||
std::sync::Arc::new(std::io::Error::other(e))
|
||
))?;
|
||
|
||
self.current_frame_offset += to_skip;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||
//
|
||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||
// samples for LAME) and append end-padding to fill the final frame.
|
||
// iTunes embeds the exact counts in an ID3v2 COMM frame with description
|
||
// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex)
|
||
//
|
||
// Parsing strategy: scan raw bytes for the ASCII marker, then extract the
|
||
// first whitespace-separated hex tokens after it.
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct GaplessInfo {
|
||
delay_samples: u64,
|
||
total_valid_samples: Option<u64>,
|
||
}
|
||
|
||
pub(crate) fn find_subsequence(data: &[u8], needle: &[u8]) -> Option<usize> {
|
||
data.windows(needle.len()).position(|w| w == needle)
|
||
}
|
||
|
||
pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||
let pos = match find_subsequence(data, b"iTunSMPB") {
|
||
Some(p) => p,
|
||
None => return GaplessInfo::default(),
|
||
};
|
||
|
||
// In M4A/iTunes files the key is followed by a binary 'data' atom header
|
||
// (16 bytes: size[4] + "data"[4] + type_flags[4] + locale[4]) before the
|
||
// actual value string. Search for the " 00000000 " sentinel that every
|
||
// iTunSMPB value starts with to locate the true start of the text.
|
||
let search_end = data.len().min(pos + 8 + 128);
|
||
let search_window = &data[pos + 8..search_end];
|
||
let value_start = find_subsequence(search_window, b" 00000000 ")
|
||
.map(|off| pos + 8 + off)
|
||
.unwrap_or(pos + 8);
|
||
|
||
let tail = &data[value_start..data.len().min(value_start + 256)];
|
||
let text: String = tail.iter()
|
||
.map(|&b| b as char)
|
||
.filter(|c| c.is_ascii_hexdigit() || *c == ' ')
|
||
.collect();
|
||
|
||
let parts: Vec<&str> = text.split_whitespace().collect();
|
||
// parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total
|
||
if parts.len() < 3 {
|
||
return GaplessInfo::default();
|
||
}
|
||
let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0);
|
||
let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0);
|
||
let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||
|
||
let total_valid = total_raw.filter(|&t| t > 0).or_else(|| {
|
||
// Derive from delay + padding if total not available:
|
||
// Not possible without knowing total encoded samples, so just use None.
|
||
let _ = padding;
|
||
None
|
||
});
|
||
|
||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||
}
|
||
|
||
pub(crate) type BuiltSourceStack =
|
||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>;
|
||
|
||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||
pub(crate) struct BuiltSource {
|
||
pub(crate) source: BuiltSourceStack,
|
||
pub(crate) duration_secs: f64,
|
||
pub(crate) output_rate: u32,
|
||
pub(crate) output_channels: u16,
|
||
/// Trigger for the sample-level crossfade fade-out.
|
||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||
/// Total samples for the fade-out (set before triggering).
|
||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||
}
|
||
|
||
/// Build a fully-prepared playback source:
|
||
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
|
||
///
|
||
/// `fade_in_dur`:
|
||
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
||
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
|
||
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
|
||
///
|
||
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
||
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
||
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn build_source(
|
||
data: Vec<u8>,
|
||
duration_hint: f64,
|
||
eq_gains: Arc<[AtomicU32; 10]>,
|
||
eq_enabled: Arc<AtomicBool>,
|
||
eq_pre_gain: Arc<AtomicU32>,
|
||
done_flag: Arc<AtomicBool>,
|
||
fade_in_dur: Duration,
|
||
sample_counter: Arc<AtomicU64>,
|
||
target_rate: u32,
|
||
format_hint: Option<&str>,
|
||
hi_res: bool,
|
||
) -> Result<BuiltSource, String> {
|
||
let gapless = parse_gapless_info(&data);
|
||
|
||
let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
|
||
let sample_rate = decoder.sample_rate();
|
||
let channels = decoder.channels();
|
||
|
||
// Determine effective duration.
|
||
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
||
let effective_dur = if duration_hint > 1.0 {
|
||
duration_hint
|
||
} else {
|
||
decoder.total_duration()
|
||
.map(|d| d.as_secs_f64())
|
||
.unwrap_or(duration_hint)
|
||
};
|
||
|
||
// Apply encoder-delay trim and optional end-padding trim,
|
||
// then resample to the canonical target rate if needed.
|
||
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||
let delay_dur = Duration::from_secs_f64(
|
||
gapless.delay_samples as f64 / sample_rate.get() as f64
|
||
);
|
||
let base = decoder.skip_duration(delay_dur);
|
||
|
||
if let Some(total) = gapless.total_valid_samples {
|
||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate.get() as f64);
|
||
let trimmed = base.take_duration(valid_dur);
|
||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
trimmed,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(trimmed)
|
||
}
|
||
} else if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
base,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(base)
|
||
}
|
||
} else {
|
||
let converted = decoder;
|
||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
converted,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(converted)
|
||
}
|
||
};
|
||
|
||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate { target_rate } else { sample_rate.get() };
|
||
|
||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||
|
||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||
let counting = CountingSource::new(notifying, sample_counter);
|
||
let boosted = PriorityBoostSource::new(counting);
|
||
|
||
Ok(BuiltSource {
|
||
source: boosted,
|
||
duration_secs: effective_dur,
|
||
output_rate,
|
||
output_channels: channels.get(),
|
||
fadeout_trigger,
|
||
fadeout_samples,
|
||
})
|
||
}
|
||
|
||
/// Streaming variant of `build_source`: uses a live `SizedDecoder` source
|
||
/// (non-seekable) and skips iTunSMPB parsing, but preserves the same EQ/fade/
|
||
/// counting wrappers and output metadata.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn build_streaming_source(
|
||
decoder: SizedDecoder,
|
||
duration_hint: f64,
|
||
eq_gains: Arc<[AtomicU32; 10]>,
|
||
eq_enabled: Arc<AtomicBool>,
|
||
eq_pre_gain: Arc<AtomicU32>,
|
||
done_flag: Arc<AtomicBool>,
|
||
fade_in_dur: Duration,
|
||
sample_counter: Arc<AtomicU64>,
|
||
target_rate: u32,
|
||
) -> Result<BuiltSource, String> {
|
||
let sample_rate = decoder.sample_rate();
|
||
let channels = decoder.channels();
|
||
|
||
// For streaming starts prefer server-provided duration when available.
|
||
let effective_dur = if duration_hint > 1.0 {
|
||
duration_hint
|
||
} else {
|
||
decoder
|
||
.total_duration()
|
||
.map(|d| d.as_secs_f64())
|
||
.unwrap_or(duration_hint)
|
||
};
|
||
|
||
let converted = decoder;
|
||
let dyn_src: DynSource = if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
converted,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(converted)
|
||
};
|
||
|
||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate {
|
||
target_rate
|
||
} else {
|
||
sample_rate.get()
|
||
};
|
||
|
||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||
|
||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||
let counting = CountingSource::new(notifying, sample_counter);
|
||
let boosted = PriorityBoostSource::new(counting);
|
||
|
||
Ok(BuiltSource {
|
||
source: boosted,
|
||
duration_secs: effective_dur,
|
||
output_rate,
|
||
output_channels: channels.get(),
|
||
fadeout_trigger,
|
||
fadeout_samples,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ── find_subsequence ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn find_subsequence_locates_needle_at_start() {
|
||
assert_eq!(find_subsequence(b"abcdef", b"abc"), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_locates_needle_in_middle() {
|
||
assert_eq!(find_subsequence(b"abcdef", b"cd"), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_returns_none_when_absent() {
|
||
assert!(find_subsequence(b"abcdef", b"xyz").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_returns_none_for_needle_longer_than_haystack() {
|
||
assert!(find_subsequence(b"ab", b"abcd").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_finds_first_occurrence_of_repeated_pattern() {
|
||
assert_eq!(find_subsequence(b"abab", b"ab"), Some(0));
|
||
}
|
||
|
||
// ── parse_gapless_info ───────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn parse_gapless_returns_default_when_itunsmpb_absent() {
|
||
let info = parse_gapless_info(b"no marker here");
|
||
assert_eq!(info.delay_samples, 0);
|
||
assert!(info.total_valid_samples.is_none());
|
||
}
|
||
|
||
fn synth_itunsmpb_blob(delay_hex: &str, padding_hex: &str, total_hex: &str) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
v.extend_from_slice(b"random preamble bytes ");
|
||
v.extend_from_slice(b"iTunSMPB");
|
||
v.extend_from_slice(&[0u8; 16]);
|
||
v.push(b' ');
|
||
v.extend_from_slice(b"00000000");
|
||
v.push(b' ');
|
||
v.extend_from_slice(delay_hex.as_bytes());
|
||
v.push(b' ');
|
||
v.extend_from_slice(padding_hex.as_bytes());
|
||
v.push(b' ');
|
||
v.extend_from_slice(total_hex.as_bytes());
|
||
v.push(b' ');
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_extracts_delay_from_itunsmpb_blob() {
|
||
let blob = synth_itunsmpb_blob("00000840", "00000000", "00ABCDEF");
|
||
let info = parse_gapless_info(&blob);
|
||
assert_eq!(info.delay_samples, 0x840, "delay decoded as hex");
|
||
assert_eq!(info.total_valid_samples, Some(0x00AB_CDEF));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_returns_none_total_when_total_field_is_zero() {
|
||
let blob = synth_itunsmpb_blob("00000840", "00000000", "00000000");
|
||
let info = parse_gapless_info(&blob);
|
||
assert_eq!(info.delay_samples, 0x840);
|
||
assert!(
|
||
info.total_valid_samples.is_none(),
|
||
"zero-total filters out per the implementation"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_handles_itunsmpb_without_value_string() {
|
||
let mut v = b"iTunSMPB".to_vec();
|
||
v.extend_from_slice(&[0u8; 16]);
|
||
let info = parse_gapless_info(&v);
|
||
assert_eq!(info.delay_samples, 0);
|
||
assert!(info.total_valid_samples.is_none());
|
||
}
|
||
|
||
// ── SizedDecoder::new with a synthetic WAV ───────────────────────────────
|
||
|
||
fn build_mono_pcm16_wav(samples: &[i16], sample_rate: u32) -> Vec<u8> {
|
||
let num_channels: u16 = 1;
|
||
let bits_per_sample: u16 = 16;
|
||
let byte_rate = sample_rate * (bits_per_sample as u32 / 8) * num_channels as u32;
|
||
let block_align = num_channels * (bits_per_sample / 8);
|
||
let data_size = (samples.len() * 2) as u32;
|
||
let riff_size = 36 + data_size;
|
||
|
||
let mut out = Vec::with_capacity(44 + data_size as usize);
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&riff_size.to_le_bytes());
|
||
out.extend_from_slice(b"WAVE");
|
||
out.extend_from_slice(b"fmt ");
|
||
out.extend_from_slice(&16u32.to_le_bytes());
|
||
out.extend_from_slice(&1u16.to_le_bytes());
|
||
out.extend_from_slice(&num_channels.to_le_bytes());
|
||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||
out.extend_from_slice(&block_align.to_le_bytes());
|
||
out.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||
out.extend_from_slice(b"data");
|
||
out.extend_from_slice(&data_size.to_le_bytes());
|
||
for s in samples {
|
||
out.extend_from_slice(&s.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn synthetic_wav_bytes(secs: f32) -> Vec<u8> {
|
||
let sample_rate = 44_100u32;
|
||
let n = (sample_rate as f32 * secs) as usize;
|
||
let amp: f32 = 0.5 * i16::MAX as f32;
|
||
let samples: Vec<i16> = (0..n)
|
||
.map(|i| {
|
||
let t = i as f32 / sample_rate as f32;
|
||
((2.0 * std::f32::consts::PI * 440.0 * t).sin() * amp) as i16
|
||
})
|
||
.collect();
|
||
build_mono_pcm16_wav(&samples, sample_rate)
|
||
}
|
||
|
||
#[test]
|
||
fn sized_decoder_constructs_from_synthetic_wav() {
|
||
let wav = synthetic_wav_bytes(0.5);
|
||
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
|
||
assert_eq!(decoder.spec.rate, 44_100);
|
||
assert_eq!(decoder.spec.channels.count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn sized_decoder_returns_err_for_garbage_input() {
|
||
let result = SizedDecoder::new(vec![0x00u8; 64], None, false);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn sized_decoder_uses_format_hint_when_provided() {
|
||
let wav = synthetic_wav_bytes(0.3);
|
||
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
|
||
}
|
||
|
||
// ── log_codec_resolution ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn log_codec_resolution_does_not_panic_for_valid_params() {
|
||
let mut params = symphonia::core::codecs::CodecParameters::new();
|
||
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
|
||
params.sample_rate = Some(44_100);
|
||
params.bits_per_sample = Some(16);
|
||
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
|
||
log_codec_resolution("test-tag", ¶ms, Some("wav"));
|
||
}
|
||
|
||
#[test]
|
||
fn log_codec_resolution_handles_unknown_codec_gracefully() {
|
||
let params = symphonia::core::codecs::CodecParameters::new();
|
||
log_codec_resolution("unknown", ¶ms, None);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod build_source_tests {
|
||
use super::*;
|
||
|
||
fn build_mono_pcm16_wav_local(samples: &[i16], sample_rate: u32) -> Vec<u8> {
|
||
let num_channels: u16 = 1;
|
||
let bits_per_sample: u16 = 16;
|
||
let byte_rate = sample_rate * (bits_per_sample as u32 / 8) * num_channels as u32;
|
||
let block_align = num_channels * (bits_per_sample / 8);
|
||
let data_size = (samples.len() * 2) as u32;
|
||
let riff_size = 36 + data_size;
|
||
|
||
let mut out = Vec::with_capacity(44 + data_size as usize);
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&riff_size.to_le_bytes());
|
||
out.extend_from_slice(b"WAVE");
|
||
out.extend_from_slice(b"fmt ");
|
||
out.extend_from_slice(&16u32.to_le_bytes());
|
||
out.extend_from_slice(&1u16.to_le_bytes());
|
||
out.extend_from_slice(&num_channels.to_le_bytes());
|
||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||
out.extend_from_slice(&block_align.to_le_bytes());
|
||
out.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||
out.extend_from_slice(b"data");
|
||
out.extend_from_slice(&data_size.to_le_bytes());
|
||
for s in samples {
|
||
out.extend_from_slice(&s.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn synthetic_wav_bytes_local(secs: f32) -> Vec<u8> {
|
||
let sample_rate = 44_100u32;
|
||
let n = (sample_rate as f32 * secs) as usize;
|
||
let amp: f32 = 0.5 * i16::MAX as f32;
|
||
let samples: Vec<i16> = (0..n)
|
||
.map(|i| {
|
||
let t = i as f32 / sample_rate as f32;
|
||
((2.0 * std::f32::consts::PI * 440.0 * t).sin() * amp) as i16
|
||
})
|
||
.collect();
|
||
build_mono_pcm16_wav_local(&samples, sample_rate)
|
||
}
|
||
|
||
type EqGains = Arc<[AtomicU32; 10]>;
|
||
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>);
|
||
|
||
fn default_source_args() -> SourceArgs {
|
||
let eq_gains: Arc<[AtomicU32; 10]> =
|
||
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
|
||
let eq_enabled = Arc::new(AtomicBool::new(false));
|
||
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits()));
|
||
let done_flag = Arc::new(AtomicBool::new(false));
|
||
let sample_counter = Arc::new(AtomicU64::new(0));
|
||
(eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter)
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_succeeds_for_synthetic_wav() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.4);
|
||
let built = build_source(
|
||
wav,
|
||
0.4,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
Some("wav"),
|
||
false,
|
||
)
|
||
.expect("build_source must succeed for a valid WAV");
|
||
assert_eq!(built.output_channels, 1);
|
||
assert!(built.duration_secs > 0.0);
|
||
assert!(built.output_rate > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_returns_err_for_garbage_bytes() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let result = build_source(
|
||
vec![0u8; 32],
|
||
0.0,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
None,
|
||
false,
|
||
);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn build_streaming_source_succeeds_for_synthetic_wav() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.4);
|
||
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
|
||
let built = build_streaming_source(
|
||
decoder,
|
||
0.4,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
)
|
||
.expect("build_streaming_source must succeed for a valid WAV decoder");
|
||
assert_eq!(built.output_channels, 1);
|
||
assert!(built.output_rate > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_with_target_rate_resamples() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.3);
|
||
let built = build_source(
|
||
wav,
|
||
0.3,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::from_millis(5),
|
||
sample_counter,
|
||
48_000,
|
||
Some("wav"),
|
||
false,
|
||
)
|
||
.expect("resampled build_source must succeed");
|
||
assert_eq!(built.output_rate, 48_000);
|
||
}
|
||
}
|