Implement audio range requests
This commit enables serving audio files via HTTP Range requests. This is crucial for allowing HTML5 audio players to seek to specific positions within an audio file without re-downloading the entire file. The changes include: - Modifying `handle_audio` in `server/src/routes/web.rs` to parse `Range` headers. - Implementing `serve_range` to handle partial content responses. - Adding a `parse_range` helper function. - Updating tests to verify range request functionality. - Adding `ACCEPT_RANGES: bytes` header to indicate support for range requests. - Storing recording duration in a sidecar file for faster UI rendering. - Enhancing the HTML template to support a custom audio player with seeking.
This commit is contained in:
@@ -10,6 +10,7 @@ pub enum FfmpegError {
|
||||
Spawn(std::io::Error),
|
||||
NonZeroExit { code: Option<i32>, stderr: String },
|
||||
TempFile(std::io::Error),
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FfmpegError {
|
||||
@@ -20,6 +21,7 @@ impl std::fmt::Display for FfmpegError {
|
||||
write!(f, "ffmpeg exited with {code:?}: {stderr}")
|
||||
}
|
||||
Self::TempFile(e) => write!(f, "failed to create temp file: {e}"),
|
||||
Self::Parse(raw) => write!(f, "failed to parse ffprobe output: {raw:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,3 +70,42 @@ pub async fn remux_faststart(input: &Path) -> Result<NamedTempFile, FfmpegError>
|
||||
debug!(?output_path, "ffmpeg remux completed");
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Probe the duration (in whole seconds, rounded) of an audio file via `ffprobe`.
|
||||
///
|
||||
/// Cheap — ffprobe only reads container headers, no decoding. On a faststart-remuxed
|
||||
/// file this is single-digit milliseconds. Uses the `format=duration` entry which
|
||||
/// is the canonical container-level duration for m4a/MP4.
|
||||
pub async fn probe_duration_seconds(path: &Path) -> Result<u32, FfmpegError> {
|
||||
let output = Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-show_entries")
|
||||
.arg("format=duration")
|
||||
.arg("-of")
|
||||
.arg("default=noprint_wrappers=1:nokey=1")
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(FfmpegError::Spawn)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(FfmpegError::NonZeroExit {
|
||||
code: output.status.code(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
let trimmed = raw.trim();
|
||||
let secs: f64 = trimmed
|
||||
.parse()
|
||||
.map_err(|_| FfmpegError::Parse(trimmed.to_string()))?;
|
||||
if !secs.is_finite() || secs < 0.0 {
|
||||
return Err(FfmpegError::Parse(trimmed.to_string()));
|
||||
}
|
||||
Ok(secs.round() as u32)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
// Persist the container duration as a sidecar so the UI can render it
|
||||
// without the browser having to HEAD every audio file. Non-fatal — the
|
||||
// recordings scan has a lazy-backfill path, and transcription is the
|
||||
// critical workload here.
|
||||
write_duration_sidecar(&audio_path, remuxed.path()).await;
|
||||
|
||||
let text = match whisper::transcribe(
|
||||
&client,
|
||||
&config.whisper_url,
|
||||
@@ -126,6 +132,23 @@ async fn mark_failed(audio_path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the (remuxed) audio and persist the duration next to the original file
|
||||
/// as `<ts>.duration.txt`. Probes on the remuxed copy because its `moov` atom
|
||||
/// sits at the start — ffprobe returns without seeking to EOF.
|
||||
async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) {
|
||||
let secs = match ffmpeg::probe_duration_seconds(probe_path).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(audio = %audio_path.display(), error = %e, "ffprobe duration failed — UI will lazy-backfill");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let sidecar = audio_path.with_extension("duration.txt");
|
||||
if let Err(e) = tokio::fs::write(&sidecar, secs.to_string()).await {
|
||||
warn!(sidecar = %sidecar.display(), error = %e, "writing duration sidecar failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerate `case_dir/oneliner.txt` from **all** non-empty transcripts in
|
||||
/// the case, joined chronologically. Called after every successful transcript
|
||||
/// write so later recordings can correct earlier ones (mirror of the
|
||||
|
||||
Reference in New Issue
Block a user