Skip to content

Feat/ffmpeg audio migration#21

Merged
imsyy merged 5 commits into
devfrom
feat/ffmpeg-audio-migration
May 25, 2026
Merged

Feat/ffmpeg audio migration#21
imsyy merged 5 commits into
devfrom
feat/ffmpeg-audio-migration

Conversation

@imsyy
Copy link
Copy Markdown
Member

@imsyy imsyy commented May 25, 2026

No description provided.

imsyy added 5 commits May 25, 2026 11:24
…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.
@imsyy imsyy marked this pull request as ready for review May 25, 2026 07:37
Copilot AI review requested due to automatic review settings May 25, 2026 07:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 主要完成 native/audio-engineffmpeg-next 迁移到自研 ffmpeg_audio(静态 FFmpeg、零环境依赖)的改造,并补齐网络 URL 播放所需的 Read + Seek HTTP Range 数据源,以便在 Electron 主进程侧跨平台稳定解码/播放。

Changes:

  • 将扫描/解码/元数据提取从 ffmpeg-next 全量迁移到 ffmpeg_audio API。
  • 新增 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"] }
@imsyy imsyy merged commit c8b64f6 into dev May 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants