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
@@ -17,6 +17,10 @@ mod ranged_http;
mod reader;
mod track_stream;
pub(crate) use mp4::{
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
};
pub(crate) use local_file::LocalFileSource;
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
@@ -65,5 +69,56 @@ pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::s
);
}
}
/// Held until `RangedHttpSource` has moov metadata for Symphonia probe (tail prefetch
/// or fast-start moov in the linear prefix).
pub(crate) struct RangedMp4ProbeGate {
pub(crate) tail_ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub(crate) buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
pub(crate) downloaded_to: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub(crate) gen_arc: std::sync::Arc<std::sync::atomic::AtomicU64>,
pub(crate) gen: u64,
pub(crate) format_hint: Option<String>,
}
/// Block until moov is reachable: tail prefetch completed or moov already in the
/// downloaded prefix (fast-start). Avoids Symphonia probing moov-at-end M4A before
/// the tail range is filled (format probe failed: end of stream).
pub(crate) async fn wait_for_ranged_mp4_probe_ready(gate: &RangedMp4ProbeGate) -> Result<(), String> {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
const PREFIX_SCAN_MIN: usize = 64 * 1024;
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
if gate.tail_ready.load(Ordering::Relaxed) {
crate::app_deprintln!("[stream] ranged: moov metadata ready (tail prefetch)");
return Ok(());
}
let dl = gate.downloaded_to.load(Ordering::Relaxed);
if dl >= PREFIX_SCAN_MIN {
let guard = gate.buf.lock().unwrap();
let n = dl.min(guard.len());
if !mp4::mp4_needs_tail_prefetch(&guard[..n], gate.format_hint.as_deref()) {
crate::app_deprintln!(
"[stream] ranged: moov metadata ready (fast-start, {} KiB prefix)",
n / 1024
);
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(
"ranged-stream: timed out waiting for moov metadata (tail prefetch)".into(),
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// Sleep interval when ring buffer is empty (prevents CPU spin).
pub(crate) const RADIO_YIELD_MS: u64 = 2;
@@ -78,6 +78,92 @@ pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool
saw_mdat && !saw_moov
}
/// Scan `[scan_start, scan_end)` for a top-level atom fourcc (e.g. `moov`).
fn find_atom_fourcc(data: &[u8], atom: &[u8; 4], scan_start: usize, scan_end: usize) -> Option<usize> {
let end = scan_end.min(data.len());
let start = scan_start.min(end);
for i in start..end.saturating_sub(8) {
if data[i + 4..i + 8] == *atom {
return Some(i);
}
}
None
}
/// `moov` in the last 8 MiB (moov-at-end) or anywhere in the first 32 MiB (fast-start).
pub(crate) fn mp4_has_moov_atom(data: &[u8]) -> bool {
if data.len() < 16 {
return false;
}
const TAIL_SCAN: usize = 8 * 1024 * 1024;
const PREFIX_SCAN: usize = 32 * 1024 * 1024;
if find_atom_fourcc(data, b"moov", data.len().saturating_sub(TAIL_SCAN), data.len()).is_some() {
return true;
}
find_atom_fourcc(data, b"moov", 0, PREFIX_SCAN.min(data.len())).is_some()
}
/// `ftyp` in the first few KB — minimal sanity check for ISO-BMFF from ranged assembly.
pub(crate) fn mp4_has_ftyp_atom(data: &[u8]) -> bool {
find_atom_fourcc(data, b"ftyp", 0, data.len().min(8192)).is_some()
}
/// After a full ranged download, the buffer should contain `ftyp` and `moov`.
pub(crate) fn isobmff_buffer_looks_complete(data: &[u8]) -> bool {
mp4_has_ftyp_atom(data) && mp4_has_moov_atom(data)
}
/// Heuristic: large runs of zero bytes between `ftyp` and `moov` suggest a sparse/holey
/// ranged buffer (tail prefetch + incomplete linear fill) rather than real audio.
pub(crate) fn mp4_suspect_zero_holes(data: &[u8]) -> bool {
if data.len() < 256 * 1024 {
return false;
}
let moov_off = find_atom_fourcc(data, b"moov", data.len().saturating_sub(8 * 1024 * 1024), data.len())
.or_else(|| find_atom_fourcc(data, b"moov", 0, data.len().min(32 * 1024 * 1024)));
let Some(moov_off) = moov_off else {
return false;
};
let scan_end = moov_off.min(data.len());
let scan_start = 64 * 1024; // skip tiny header
if scan_end <= scan_start + 64 * 1024 {
return false;
}
const BLOCK: usize = 64 * 1024;
let mut zero_blocks = 0usize;
let mut total_blocks = 0usize;
let mut pos = scan_start;
while pos + BLOCK <= scan_end {
total_blocks += 1;
if data[pos..pos + BLOCK].iter().all(|&b| b == 0) {
zero_blocks += 1;
}
pos += BLOCK;
}
total_blocks > 4 && zero_blocks * 100 / total_blocks >= 10
}
/// Log why Symphonia may reject a buffer (for support / debugging).
pub(crate) fn log_isobmff_buffer_diagnostic(data: &[u8], hint: Option<&str>, label: &str) {
let prefix_hex: String = data
.iter()
.take(16)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ");
let looks_json = data.starts_with(b"{") || data.starts_with(b"[");
crate::app_eprintln!(
"[stream] ISO-BMFF diagnostic ({label}): hint={:?} bytes={} prefix=[{}] json_like={} ftyp={} moov={} zero_holes={}",
hint,
data.len(),
prefix_hex,
looks_json,
mp4_has_ftyp_atom(data),
mp4_has_moov_atom(data),
mp4_suspect_zero_holes(data),
);
}
fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option<u64> {
if pos + 8 > data.len() {
return None;
@@ -136,4 +222,22 @@ mod tests {
assert!(!mp4_needs_tail_prefetch(&[], Some("mp3")));
assert!(!mp4_needs_tail_prefetch(&[], None));
}
#[test]
fn isobmff_complete_detects_ftyp_and_moov() {
let mut buf = Vec::new();
buf.extend(atom(b"ftyp", 4));
buf.extend(atom(b"mdat", 200));
buf.extend(atom(b"moov", 40));
assert!(isobmff_buffer_looks_complete(&buf));
assert!(!mp4_suspect_zero_holes(&buf));
}
#[test]
fn isobmff_incomplete_without_moov() {
let mut buf = Vec::new();
buf.extend(atom(b"ftyp", 4));
buf.extend(atom(b"mdat", 200));
assert!(!isobmff_buffer_looks_complete(&buf));
}
}
@@ -200,13 +200,6 @@ pub(crate) enum RangedHttpLoopOutcome {
Aborted,
}
/// Pure HTTP loop: reads from `initial_response` (and reconnects on transient
/// errors via `Range:` requests against `http_client`) until either `total_size`
/// bytes have been written into `buf`, the generation flips, or the reconnect
/// budget is exhausted. No `tauri::AppHandle` dependency — partial-progress
/// notifications go through `on_partial`, which the caller wires up with its
/// own emitter (or a no-op in tests).
///
/// Returns `(downloaded_bytes, outcome)`. The caller is responsible for setting
/// any `done` flag, promoting the buffer to a cache, or kicking off analysis
/// seeding once the loop returns.
@@ -426,7 +419,13 @@ async fn ranged_prefetch_mp4_tail(
Ok(written) if written > 0 => {
tail_filled_from.store(tail_from, Ordering::Relaxed);
tail_ready.store(true, Ordering::SeqCst);
super::maybe_arm_stream_playback(tail_from + written as u64, &playback_armed);
if !playback_armed.load(Ordering::Relaxed) {
playback_armed.store(true, Ordering::SeqCst);
crate::app_deprintln!(
"[stream] playback armed after moov tail prefetch ({} KiB)",
written / 1024
);
}
crate::app_deprintln!(
"[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})",
written / 1024,
@@ -562,6 +561,11 @@ pub(crate) async fn ranged_download_task(
None
};
let linear_arm = if tail_prefetch {
None
} else {
Some(playback_armed.as_ref())
};
let (downloaded, outcome) = ranged_http_download_loop(
http_client,
&url,
@@ -571,7 +575,7 @@ pub(crate) async fn ranged_download_task(
gen,
&gen_arc,
on_partial,
Some(&playback_armed),
linear_arm,
)
.await;
@@ -605,6 +609,22 @@ pub(crate) async fn ranged_download_task(
if downloaded == total_size && total_size > 0 {
if total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
if super::container_hint_is_mp4(format_hint.as_deref()) {
let guard = buf.lock().unwrap();
if !super::isobmff_buffer_looks_complete(&guard) {
super::log_isobmff_buffer_diagnostic(
&guard,
format_hint.as_deref(),
"ranged-dl-complete-incomplete",
);
} else if super::mp4_suspect_zero_holes(&guard) {
super::log_isobmff_buffer_diagnostic(
&guard,
format_hint.as_deref(),
"ranged-dl-complete-zero-holes",
);
}
}
if let Some(ref tid) = cache_track_id {
crate::app_deprintln!(
"[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)",