如何让HTMLAudioElement分块下载音频文件,避免全量加载?
问题背景
我用Rust开发自定义音乐服务器,遇到一个问题:浏览器加载音频时会发送range: bytes=0-请求头,试图下载整个50MB+的大文件。我尝试返回部分片段(比如带上content-range: bytes=0-100000响应头),但浏览器会立刻请求下一个范围,循环直到下载完整个文件。我希望浏览器能像Brave(Chromium内核)那样,先请求一段内容,播放到快结束时再请求下一段;但Firefox目前会直接拉取所有分块,完全不符合预期。
当前我的Rust实现会处理范围请求,但无结束范围时,除Firefox返回全文件外,其他浏览器返回起始位置后5MB数据,代码如下:
#[derive(Clone, Debug)] pub struct RangeMiddleware { pub start: u64, pub end: Option<u64> } /** * 5 MB buffer */ const BUFFER_AMOUNT: usize = 5242880; #[handler] pub fn stream_file( Data(range): Data<&RangeMiddleware>, Data(track): Data<&TrackMiddleware>, header_map: &HeaderMap ) -> Result<impl IntoResponse, Error> { // Open the audio file if let Ok(mut file) = File::open(track.0.location.to_owned()) { let (mut buffer, content_range) = buffer_for_track_range(range, header_map, track); // Seek to the requested range if let Err(_) = file.seek(SeekFrom::Start(range.start)) { return Err(poem::Error::from_string("Can't seek", StatusCode::RANGE_NOT_SATISFIABLE)); } if let Err(_) = file.read_exact(&mut buffer) { return Err(poem::Error::from_string("End of file.", StatusCode::RANGE_NOT_SATISFIABLE)); }; return Ok(Response::builder() .header(header::CONTENT_TYPE, guess_mime_type(&track.0.location)) .header(header::CONTENT_RANGE, content_range) .status(StatusCode::PARTIAL_CONTENT) .body(buffer)); } else { return Err(poem::Error::from_string("Failed to read file", StatusCode::NOT_FOUND)); } } pub fn buffer_for_track_range(range: &RangeMiddleware, header_map: &HeaderMap, track: &TrackMiddleware) -> (Vec<u8>, String) { // If they have a range ending point, read to that point if let Some(end) = range.end { let buffer = vec![0; (end - range.start) as usize]; let content_range = format!("bytes {}-{}/{}", range.start, end - 1, track.0.size); (buffer, content_range) } else { // No ending point... if let Some(agent) = header_map.get(USER_AGENT) { if agent.to_str().unwrap().contains("Firefox") { // Return the whole fire since firefox doesn't buffer let buffer = vec![0; track.0.size as usize - range.start as usize]; let content_range = format!( "bytes {}-{}/{}", range.start, track.0.size - 1, track.0.size ); return (buffer, content_range); } } // If the buffer is larger than the range, use the buffer if track.0.size as usize - range.start as usize > BUFFER_AMOUNT { (vec![0; BUFFER_AMOUNT], format!( "bytes {}-{}/{}", range.start, range.start as usize + BUFFER_AMOUNT, track.0.size) ) } else { // The buffer is smaller than the remaining data, use remaining data (vec![0; track.0.size as usize - range.start as usize], format!( "bytes {}-{}/{}", range.start, track.0.size - 1, track.0.size ) } } }
解决方案
核心原因分析
浏览器的预加载行为由三个关键因素决定:服务器是否明确支持范围请求、音频元数据的位置、响应头的完整性。Firefox的激进预加载是因为它没收到明确的按需加载信号,而Brave的行为是符合流媒体逻辑的正常表现。
具体修复步骤
1. 补充关键响应头,明确支持范围请求
浏览器需要确认服务器支持范围请求,同时要准确知道当前返回片段的字节长度,避免自行推断。修改响应构建逻辑:
// 在Response builder中添加以下两个头 .header(header::ACCEPT_RANGES, "bytes") .header(header::CONTENT_LENGTH, buffer.len().to_string())
2. 基于音频时长动态计算片段大小,替代固定5MB
固定5MB的片段对不同码率的音频时长差异极大,应该根据音频比特率计算30秒对应的字节数,让浏览器能精准控制预加载时机。可以用symphonia库解析音频元数据获取比特率:
// 添加symphonia依赖后,实现获取比特率的函数 use std::path::Path; use symphonia::core::io::MediaSourceStream; fn get_audio_bitrate(path: &Path) -> Option<u32> { let file = std::fs::File::open(path).ok()?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let probe = symphonia::default::get_probe().format(&mss, &Default::default()).ok()?; probe.format.metadata().bitrate.map(|br| br / 8) // 转换为字节/秒 }
然后在buffer_for_track_range中替换固定的BUFFER_AMOUNT:
// 计算30秒音频对应的字节数 let bitrate = get_audio_bitrate(&track.0.location).unwrap_or(16_000); // 默认128kbps转为16KB/s let target_duration_secs = 30; let target_buffer_size = bitrate * target_duration_secs as u32;
3. 移除Firefox的特殊处理逻辑
当前对Firefox返回全文件的逻辑会强制它预加载全部内容,应该改为和其他浏览器一致的片段返回:
// 移除buffer_for_track_range中的Firefox判断分支,统一处理无结束范围的请求 let remaining = track.0.size - range.start; let buffer_size = std::cmp::min(remaining, target_buffer_size as u64); let end = range.start + buffer_size - 1; (vec![0; buffer_size as usize], format!("bytes {}-{}/{}", range.start, end, track.0.size))
4. 可选:支持分块传输编码(HTTP/2)
对于HTTP/2,使用分块传输(Transfer-Encoding: chunked)能让服务器更灵活控制数据发送,浏览器也会更倾向于按需请求。注意分块传输和Content-Length不能同时使用,需根据HTTP版本判断:
// 替换Content-Length头 .header(header::TRANSFER_ENCODING, "chunked")
修改后的核心代码片段
// 新增获取音频比特率的函数 use std::path::Path; use symphonia::core::io::MediaSourceStream; fn get_audio_bitrate(path: &Path) -> Option<u32> { let file = std::fs::File::open(path).ok()?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let probe = symphonia::default::get_probe().format(&mss, &Default::default()).ok()?; probe.format.metadata().bitrate.map(|br| br / 8) } pub fn buffer_for_track_range(range: &RangeMiddleware, track: &TrackMiddleware) -> (Vec<u8>, String) { if let Some(end) = range.end { let buffer_size = end - range.start; let content_range = format!("bytes {}-{}/{}", range.start, end - 1, track.0.size); (vec![0; buffer_size as usize], content_range) } else { let bitrate = get_audio_bitrate(&track.0.location).unwrap_or(16_000); let target_duration_secs = 30; let target_buffer_size = bitrate * target_duration_secs as u32; let remaining = track.0.size - range.start; let buffer_size = std::cmp::min(remaining, target_buffer_size as u64); let end = range.start + buffer_size - 1; (vec![0; buffer_size as usize], format!("bytes {}-{}/{}", range.start, end, track.0.size)) } } #[handler] pub fn stream_file( Data(range): Data<&RangeMiddleware>, Data(track): Data<&TrackMiddleware>, ) -> Result<impl IntoResponse, Error> { let mut file = std::fs::File::open(&track.0.location) .map_err(|_| poem::Error::from_string("Failed to read file", StatusCode::NOT_FOUND))?; let (mut buffer, content_range) = buffer_for_track_range(range, track); file.seek(std::io::SeekFrom::Start(range.start)) .map_err(|_| poem::Error::from_string("Can't seek", StatusCode::RANGE_NOT_SATISFIABLE))?; file.read_exact(&mut buffer) .map_err(|_| poem::Error::from_string("End of file.", StatusCode::RANGE_NOT_SATISFIABLE))?; Ok(poem::Response::builder() .header(poem::header::CONTENT_TYPE, guess_mime_type(&track.0.location)) .header(poem::header::CONTENT_RANGE, content_range) .header(poem::header::ACCEPT_RANGES, "bytes") .header(poem::header::CONTENT_LENGTH, buffer.len().to_string()) .status(poem::StatusCode::PARTIAL_CONTENT) .body(buffer)) }
内容的提问来源于stack exchange,提问作者Mytho

