fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757)

* fix(stream): defer M4A probe until moov tail or fast-start prefix

Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB
linear data arrived, before the parallel tail prefetch filled the moov
atom — probe hit end of stream and skipped the track. Wait for tail_ready
or detect fast-start moov in the prefix; do not arm playback from linear
bytes alone when tail prefetch is active.

* fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed

Second-pass header scan with moov already loaded still tried to read
through mdat→EOF on RangedHttpSource holes, causing format probe
end of stream. Re-check play generation after moov wait.

* fix(isomp4): fix AtomIterator overread after seek in patched demuxer

`AtomIterator::new_root(reader, len)` treats `len` as bytes available
from the current `reader.pos()`, not the absolute file length. After
`mss.seek(resume_at)` we were passing the absolute `total_len`, so the
iterator thought there were `total_len` bytes left and tried to read
past EOF on the next iteration, returning "end of stream".

Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining
bytes from the new position) in both branches:
- `moov.is_none()` (moov-at-end layout, seek to moov offset)
- `moov.is_some()` (fast-start layout, skip bounded mdat body)

This caused Symphonia to fail probing moov-at-end M4A files read from
local disk (hot-cache) and from in-memory buffers — every decode attempt
returned "end of stream", analysis fell back to `byte_envelope_no_ebu`
(no EBU R128 loudness), and rodio produced distorted audio.

Also in this commit:
- `resolve_playback_format_hint()` helper to resolve hint from URL,
  stream suffix, Content-Disposition, or byte sniff
- ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`,
  `log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`)
- Probe-fallback path for ranged-stream failures now uses these helpers
  to decide whether to refetch or wait for the in-flight download

* chore(audio): remove redundant hint recomputation and add missing blank line

`bytes_hint_for_wait` in the ranged-stream fallback path was an exact
duplicate of `effective_hint` already in scope — reuse the existing binding.
Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs.

* chore(release): CHANGELOG and credits for PR #757

Add Fixed entry for M4A moov-at-end probe fix and credits line in
settingsCredits.ts under existing cucadmuh contributions.

* fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments

`build_playback_source_with_probe_fallback` had 12 parameters, exceeding
the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into
`BuildSourceArgs` so the function signature stays at 4 arguments.
This commit is contained in:
cucadmuh
2026-05-17 19:54:38 +03:00
committed by GitHub
parent 6595c146a3
commit 33ffb94083
9 changed files with 571 additions and 27 deletions
@@ -15,8 +15,9 @@ use super::engine::{audio_http_client, AudioEngine};
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{
build_source_from_play_input, select_play_input, spawn_legacy_stream_start_when_armed,
swap_in_new_sink, url_format_hint, PlayInputContext, SinkSwapInputs,
build_playback_source_with_probe_fallback, select_play_input,
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
PlayInputContext, SinkSwapInputs,
};
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
@@ -232,14 +233,21 @@ pub async fn audio_play(
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let playback_source = build_source_from_play_input(
let playback_source = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &url,
gen,
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
},
&state,
format_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
&app,
)
.await
.map_err(|e| {