Feat/ffmpeg audio migration#21
Merged
Merged
Conversation
…ng and retry logic - Added configurable timeouts and retry mechanisms to improve resilience against network issues. - Refactored stream handling to ensure proper reconnection and error classification. - Introduced jitter-based backoff strategy for retries to reduce server load. - Updated tests to cover new behaviors and ensure reliability under various scenarios. - Removed unnecessary unsafe implementations for thread safety in FftAnalyzer.
Contributor
There was a problem hiding this comment.
Pull request overview
该 PR 主要完成 native/audio-engine 从 ffmpeg-next 迁移到自研 ffmpeg_audio(静态 FFmpeg、零环境依赖)的改造,并补齐网络 URL 播放所需的 Read + Seek HTTP Range 数据源,以便在 Electron 主进程侧跨平台稳定解码/播放。
Changes:
- 将扫描/解码/元数据提取从
ffmpeg-next全量迁移到ffmpeg_audioAPI。 - 新增
HttpRangeSource:用 ureq(TLS) 实现带 Range 的顺序读 + 失败重连 + cancel 中断传播。 - 调整 FFT 路径:统一使用
TARGET_SAMPLE_RATE,不再单独 44.1k 重采样;同步更新构建/CI 文档与依赖。
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| native/audio-engine/src/scanner.rs | 扫库 probe 改用 AudioReader 读取时长/标签/封面缩略图 |
| native/audio-engine/src/player.rs | FFT analyzer 初始化采样率切到 TARGET_SAMPLE_RATE |
| native/audio-engine/src/metadata.rs | 元数据/tag/封面/歌词提取改为适配 ffmpeg_audio 的 HashMap/cover API |
| native/audio-engine/src/lib.rs | 初始化 logger 时改用 ffmpeg_audio 的日志级别控制;注册新模块 |
| native/audio-engine/src/http_source.rs | 新增 HTTP Range Read+Seek 数据源、重连/退避/cancel 机制及单测 |
| native/audio-engine/src/fft.rs | 移除手写 Send/Sync,实现维持 FFT 分析逻辑不变 |
| native/audio-engine/src/decoder.rs | 解码主循环改为 AudioReader::receive_frame 推 chunk,并接入 HttpRangeSource |
| native/audio-engine/Cargo.toml | 替换 ffmpeg-next 为 git 版 ffmpeg_audio;新增 ureq/httpmock |
| CLAUDE.md | 更新原生构建说明:静态 FFmpeg 由 ffmpeg_audio 内置构建 |
| Cargo.lock | 锁文件更新(引入 ffmpeg_audio/ureq/httpmock 等依赖树) |
| .github/workflows/dev.yml | CI 不再下载外部 FFmpeg 包;精简 Linux 依赖 |
| .github/workflows/claude.yml | 删除 Claude Code workflow |
| .github/workflows/claude-code-review.yml | 删除 Claude Code Review workflow |
| .github/copilot-instructions.md | 更新仓库指引:audio-engine 改用 ffmpeg_audio + HttpRangeSource |
Comment on lines
+80
to
+100
| let status = resp.status(); | ||
| let (total_size, range_supported) = match status { | ||
| 206 => { | ||
| let cr = resp | ||
| .header("Content-Range") | ||
| .ok_or_else(|| anyhow!("206 响应缺少 Content-Range 头: {url}"))?; | ||
| let total = parse_content_range_total(cr) | ||
| .ok_or_else(|| anyhow!("Content-Range 解析失败: {cr}"))?; | ||
| (total, true) | ||
| } | ||
| 200 => { | ||
| let cl = resp | ||
| .header("Content-Length") | ||
| .ok_or_else(|| anyhow!("200 响应缺少 Content-Length,无法估算长度: {url}"))?; | ||
| let total: u64 = cl | ||
| .parse() | ||
| .with_context(|| format!("Content-Length 解析失败: {cl}"))?; | ||
| warn!(url, "服务端不支持 Range(返回 200),seek 将失败"); | ||
| (total, false) | ||
| } | ||
| s => return Err(anyhow!("初始 GET 意外状态: {s}")), |
Comment on lines
+90
to
+99
| 200 => { | ||
| let cl = resp | ||
| .header("Content-Length") | ||
| .ok_or_else(|| anyhow!("200 响应缺少 Content-Length,无法估算长度: {url}"))?; | ||
| let total: u64 = cl | ||
| .parse() | ||
| .with_context(|| format!("Content-Length 解析失败: {cl}"))?; | ||
| warn!(url, "服务端不支持 Range(返回 200),seek 将失败"); | ||
| (total, false) | ||
| } |
Comment on lines
+165
to
+183
| let samples = match data.reader.receive_frame() { | ||
| Ok(Some(s)) => s.to_vec(), | ||
| Ok(None) => return, | ||
| Err(AudioError::Eof) => return, | ||
| Err(e) => { | ||
| warn!(error = %e, "解码失败"); | ||
| shared.mark_decode_failed(); | ||
| return; | ||
| } | ||
| Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::ffi::EAGAIN => { | ||
| if eof_sent { | ||
| // 已发送 EOF,解码器清空完毕,flush resampler 尾巴 | ||
| flush_and_push_tail(data, shared); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // 读取下一个数据包;Packet::read 能区分真正的 EOF 与读取失败 | ||
| let mut packet = ffmpeg::codec::packet::Packet::empty(); | ||
| match packet.read(&mut data.input_ctx) { | ||
| Ok(()) => { | ||
| if packet.stream() == data.audio_stream_index { | ||
| consecutive_read_failures = 0; | ||
| if data.decoder.send_packet(&packet).is_err() { | ||
| return; | ||
| } | ||
| } | ||
| // 非音频包:跳过 | ||
| } | ||
| Err(ffmpeg::Error::Eof) => { | ||
| // 真正的文件结束,刷新解码器 | ||
| let _ = data.decoder.send_eof(); | ||
| eof_sent = true; | ||
| } | ||
| Err(err) => { | ||
| // 读取失败:网络中断 / URL 失效 | ||
| consecutive_read_failures += 1; | ||
| warn!(error = %err, retries = consecutive_read_failures, "音频源读取失败"); | ||
| if consecutive_read_failures <= NETWORK_READ_RETRIES { | ||
| // 网络流可能恢复,等待后重试 | ||
| std::thread::sleep(std::time::Duration::from_millis( | ||
| NETWORK_RETRY_DELAY_MS, | ||
| )); | ||
| continue; | ||
| } | ||
| // 重试耗尽:标记音源失效,仍走 EOF 流程把已缓冲数据放完 | ||
| shared.mark_decode_failed(); | ||
| let _ = data.decoder.send_eof(); | ||
| eof_sent = true; | ||
| } | ||
| } | ||
| } | ||
| Err(err) => { | ||
| // 其他解码错误,重试后放弃 | ||
| consecutive_read_failures += 1; | ||
| warn!(error = %err, retries = consecutive_read_failures, "解码错误"); | ||
| if consecutive_read_failures <= NETWORK_READ_RETRIES { | ||
| std::thread::sleep(std::time::Duration::from_millis(NETWORK_RETRY_DELAY_MS)); | ||
| continue; | ||
| } | ||
| return; | ||
| } | ||
| if samples.is_empty() { | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// 对解码帧进行重采样,分别输出播放数据和 FFT 数据 | ||
| fn resample_frame( | ||
| data: &mut DecoderData, | ||
| decoded: &mut ffmpeg::frame::Audio, | ||
| player_buf: &mut Vec<f32>, | ||
| fft_buf: &mut Vec<f32>, | ||
| ) { | ||
| // 播放重采样 | ||
| if let Some(ref mut resampler) = data.resampler { | ||
| if let Some(frame) = try_resample(resampler, decoded) { | ||
| interleave_planar_frame(player_buf, &frame, frame.samples()); | ||
| } | ||
| } else { | ||
| interleave_planar_frame(player_buf, decoded, decoded.samples()); | ||
| } | ||
|
|
||
| // FFT 重采样(单声道) | ||
| if let Some(ref mut resampler) = data.fft_resampler { | ||
| if let Some(frame) = try_resample(resampler, decoded) { | ||
| let samples = frame.samples(); | ||
| if samples > 0 { | ||
| fft_buf.extend_from_slice(&frame.plane::<f32>(0)[..samples]); | ||
| } | ||
| } | ||
| } else { | ||
| let samples = decoded.samples(); | ||
| if samples > 0 { | ||
| fft_buf.extend_from_slice(&decoded.plane::<f32>(0)[..samples]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// 执行重采样,为每次调用创建正确尺寸的输出帧 | ||
| fn try_resample( | ||
| resampler: &mut ffmpeg::software::resampling::Context, | ||
| decoded: &ffmpeg::frame::Audio, | ||
| ) -> Option<ffmpeg::frame::Audio> { | ||
| let output_samples = (decoded.samples() as f64 * resampler.output().rate as f64 | ||
| / decoded.rate() as f64) | ||
| .ceil() as usize; | ||
|
|
||
| let mut output_frame = ffmpeg::frame::Audio::new( | ||
| resampler.output().format, | ||
| output_samples, | ||
| resampler.output().channel_layout, | ||
| ); | ||
| let mut player_samples = samples; | ||
|
|
||
| if resampler.run(decoded, &mut output_frame).is_ok() { | ||
| Some(output_frame) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
| // FFT 取左声道(48k mono),fft.rs 直接用 TARGET_SAMPLE_RATE 不再单独重采样 | ||
| let fft_samples: Vec<f32> = player_samples.chunks_exact(2).map(|c| c[0]).collect(); |
Comment on lines
11
to
+13
| napi-derive = "3.5" | ||
| ffmpeg-next = { version = "8", default-features = false, features = [ | ||
| "codec", | ||
| "format", | ||
| "software-resampling", | ||
| "static", | ||
| ] } | ||
| ffmpeg_audio = { git = "https://github.com/SPlayer-Dev/ffmpeg-audio", branch = "feat/duration-api", default-features = false } | ||
| ureq = { version = "2", default-features = false, features = ["tls"] } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.