Skip to content

fix(Tauri): Linux 输出请求显式周期,消除 pipewire-pulse 约 2s 缓冲延迟 - #17

Open
Burial0268 wants to merge 4 commits into
masterfrom
fix/linux-pulse-output-latency
Open

Burial0268 wants to merge 4 commits into
masterfrom
fix/linux-pulse-output-latency

Conversation

@Burial0268

@Burial0268 Burial0268 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #16
Generated by Claude

感谢 @gozhuimeng 的详细排查——根因定位是对的,这个 PR 在他的方案基础上做了三处收敛。

根因

CPAL 的 PulseAudio 后端把 BufferSize::Default 映射为 BufferAttr::default(),即五个字段全是 u32::MAX——PA 线协议里"服务端自选"的哨兵值(cpal-0.18.1/src/host/pulseaudio/mod.rs:611pulseaudio-0.3.1impl Default for BufferAttr,上游文档原文写着 "this value will default to something like 2s")。同一处代码里 adjust_latency = matches!(buffer_size, Fixed(_)),所以 Default 下我们连"请把延迟控制在某个目标"都没说。真正的 pulseaudio 守护进程恰好会选一个小缓冲,pipewire-pulse 选约 2s。

本仓库 Cargo.toml 只开了 pulseaudio + realtime feature,cpal 的 default_host() 顺序是 PipeWire → PulseAudio → ALSA,所以现代 Linux 桌面上必然落到受影响的 PulseAudio host。

顺带确认:cpal#1190 已被 #1258 关闭,但那个 PR 修的是"短音频被 drop 截断",#1190 原文即说明持续播放的应用不受截断影响;BufferAttr::default() 的行为在最新的 0.18.1(也就是我们锁的版本)里原样保留。升级 cpal 解决不了这个问题。

一个缓冲,四个症状

issue 里报了前两个,另外两个是同源的:

  1. 按下播放约 1.5s 才出声。
  2. 歌词/进度同量领先——output/render.rs:176rendered_samples交给服务端时自增,player/clock.rs:54 直接按它外推,全程不扣设备延迟,领先量 == 服务端缓冲深度。
  3. 每次恢复播放都要重等一次output/render.rs:89paused 时是往回调填静音而非停流,2s 的 memblockq 会被静音填满。这解释了为什么现象是"每次按播放"而不只是首次起播。
  4. seek 后仍会播最多约 1.5s 的旧音频OutputWriter::flush() 只作废我们自己 ring 里的块,已交给服务端的 PCM 收不回来。

改动

stable_buffer_size 改为请求显式周期,另加三点收敛:

  • 按毫秒推导,不用固定帧数。 CPAL 会把周期翻倍成 Pulse 的 max_length/target_length,固定 1024 帧在 48kHz 是 21ms,到 96kHz sink 就变成 10.7ms、192kHz 只剩 5.3ms——在不保证 RT 优先级的 Linux 上对音乐播放器偏激进。20ms 与 desktop.rs 的 Windows/macOS 策略一致,48kHz 下得到 960 帧,和实测有效的 1024 基本等价。
  • SupportedBufferSize::Unknown 保持 BufferSize::Default 这是 CPAL 的 ALSA host 读不到 hw params 时的返回值(host/alsa/mod.rs:1456-1477)。ALSA 直连用户本就没有 Pulse 缓冲问题,对一个连范围都读不到的设备强推固定周期,是唯一可能把"延迟高"变成"建流失败/彻底没声"的路径。CPAL 的 pulse host 永远返回 Range,所以对本 issue 的受影响用户零成本。
  • 收敛不用 clamp u32::clampmin > max 时会 panic;后端给出的区间不该反向,但输出线程不值得为此冒 panic 的风险。

软件侧的抖动余量没动:DEFAULT_QUEUE_BLOCKS = 48 × MIX_BLOCK_FRAMES = 512 ≈ 512ms ring,以及 player/platform.rs 的 160ms 起播 / 48ms seek 预缓冲水位,都保持原样。Android、Windows、macOS 走各自独立的 platform/*.rs,不受影响。

未处理(另开)

时钟仍然不扣设备延迟,只是把领先量从约 1.5s 压到约 40ms(与 Windows WASAPI 的 20ms 同量级)。要彻底修,得用上 output/mod.rs:841 现在被丢弃的 _info——OutputCallbackInfo::timestamp() 能给出 callback / playback 两个时间点。那会动到所有平台的时钟,不适合塞进这个 PR。

验证

  • cargo check -p gmplayer-audio-backend 通过(Windows host;仅有 2 条与本改动无关的既存 dead_code warning)。
  • rustfmt --check 干净。
  • 6 个单测覆盖:48kHz 得到 960 帧、跨 44.1/48/96/192kHz 延迟恒定、低速率 sink 的帧数下限、上下界收敛、反向区间不 panic、Unknown 回落 Default注意linux.rs 在 Windows 宿主上不参与编译,这些用例是在一个替换掉 cpal 类型、逻辑逐字相同的 harness 里跑过的,尚未在 Linux 上执行 cargo test

需要 Linux 上复核

@gozhuimeng 如果方便的话,麻烦在这个分支上再确认几项(都是这次改动的真实风险面):

  • cd src-tauri && cargo testoutput::platform::linux 相关用例)
  • 起播 / 歌词对齐延迟(应与你实测的 Fixed(1024) 一致)
  • 暂停 → 恢复的出声延迟(症状 3)
  • seek 后是否还有旧音频(症状 4)
  • 切换输出设备(output_runtime 重建路径)不出问题
  • 44.1kHz 音源、以及蓝牙 sink——BT 链路延迟大,小 tlength + adjust_latency 是最可能出 xrun 的场景
  • 顺带贴一下 output/mod.rs 那条 音频输出:... buffer=... rate=... 日志的前后对比

关于你问的"是否需要做成可配置项":暂不做。多一个设置面、改完还要重建输出流,而合理取值实际只有一个;如果后续真的出现需要按机器调的场景,用环境变量做 debug 开关更划算。

CPAL 的 PulseAudio 后端把 BufferSize::Default 映射为五个字段全是 u32::MAX 的
BufferAttr(协议里"服务端自选"的哨兵值),并且只在 Fixed 时才置 adjust_latency。
真正的 pulseaudio 守护进程恰好会选一个小缓冲,pipewire-pulse 选约 2s,
且没有任何一方去凑延迟目标(RustAudio/cpal#1190,该 issue 被 #1258 关闭,
修的是短音频截断,持续播放的缓冲行为在 0.18.1 里原样保留)。

一个过深的服务端缓冲同时表现为四个问题:按下播放约 1.5s 才出声;
歌词与进度同量领先,因为 render clock 统计的是交给服务端的样本而非已播放的;
每次恢复播放都要重等一次,因为暂停时回调仍在往服务端灌静音而不是停流;
seek 之后还会继续播最多约 1.5s 的旧音频,因为 flush 收不回服务端已持有的 PCM。

改为请求显式周期。周期按毫秒推导而非固定帧数:CPAL 会把它翻倍成 Pulse 的
max/target length,固定帧数会让 96kHz sink 的延迟目标减半、192kHz 减到四分之一。
20ms 与 desktop.rs 的 Windows/macOS 策略一致,且远在 48 块(约 512ms)
mixer ring 之内,抖动余量仍由软件 ring 提供。

Unknown 区间保持 BufferSize::Default:那是 CPAL ALSA 在读不到 hw params 时的
返回值,ALSA 直连用户本就没有 Pulse 缓冲问题,强推固定周期反而可能直接建流失败。
Range 的收敛不用 clamp,避免后端给出反向区间时 panic 掉输出线程。

Closes #16
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
s-player Ready Ready Preview Aug 8, 2026 4:13pm

@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for gbcl-ncmplayer failed. Why did it fail? →

Name Link
🔨 Latest commit 2a4e36d
🔍 Latest deploy log https://app.netlify.com/projects/gbcl-ncmplayer/deploys/6a775524f9b23a00080fdc4b

@gozhuimeng

Copy link
Copy Markdown

复核反馈

已完成 PR #17 的 Linux 复核。三处收敛(毫秒推导 / Unknown 回落 Default / 免 clamp 收敛)均合理,感谢采纳。

一、自动化验证

环境:Arch Linux / Rust 1.97.1 / PipeWire 1.6.8

命令 结果
cargo test -p gmplayer-audio-backend output::platform::linux ✅ 6/6 通过
cargo test --workspace gmplayer_audio_backend 49 passed、audio_analysis 18 passed,无失败
cargo check ✅ 通过(仅既有 warning,与 PR 无关)

6 个用例覆盖:跨采样率延迟恒定、低速率下限、上下界收敛、反向区间不 panic、Unknown 回落 Default。

二、手动实测

方式pnpm tauri dev,有线耳机

# 测试项 结果
1 起播 ✅ 按下播放立刻出声
2 暂停 → 恢复(症状 3) ✅ 果断,无体感延迟
3 seek(症状 4) ✅ 无旧音频残留
4 切换输出设备 ✅ 无延迟、无报错
5 蓝牙 ⚠️ 未测 (设备蓝牙模块存在些许问题)

有线 + 笔记本音频源均正常,无爆音/卡顿。

三、需要关注的观察

歌词对齐:歌词偏移设为 +0.5s(提前) 时视觉对齐优于 0s,提示歌词与声音间仍存在约 0.5s 偏移,比 PR 描述中预期的 ~40ms 大一个数量级。此现象仅作客观记录,未做根因排查,想确认这符合你的预期吗?

日志输出(与 PR 无关):output/mod.rs:663音频输出:... 日志在 Linux 上从未输出——audio-backend 用 tracing,而 tauri_plugin_log 只捕获 log crate,全项目无 tracing_subscriber/LogTracer 桥接。若需该日志用于诊断,建议后续补桥接(可另开 issue)。buffer= 实际值已由单测覆盖(48kHz → Fixed(960))。

四、结论

修复有效,症状 1-4 全部解决,可以合入。蓝牙场景需补充验证。

0ba9748 不是一次编辑,而是把整个模块又插了一遍:文件从第 31 行起出现第二份
use / DEFAULT_QUEUE_BLOCKS / stable_buffer_size / mod tests,
STABLE_OUTPUT_BUFFER_MS 出现三次(120、120、20),并留下了 "Fix test later"
这类未完成的注释。重复定义使该模块无法编译。

Windows 上没被发现,是因为 linux.rs 由 #[cfg(target_os = "linux")] 门控,
本机 cargo check 根本不会编译它。

那次改动想把周期从 20ms 提到 120ms,以回应 PR #17 复核里约 0.5s 的歌词偏移,
但方向是反的:cpal 0.18.1 的 make_playback_buffer_attr 把 target_length 设为
周期的两倍并置 adjust_latency,20ms 对应约 40ms 的延迟目标,120ms 会变成约
240ms;Pulse 上报 Range { min: 1, max: ~262144 },5760 帧不会被收敛。
该注释自身也算错了帧数(写成 576 / 480,实际 120ms@48kHz = 5760)。

歌词偏移的符号同样说明问题不在这里:musicLyric.ts 里 +0.5s 是
displayCurrentTime + offset,即时钟落后于声音,而设备缓冲只会让时钟领先。

本次仅还原到 4b05fb0 的内容,输出周期策略保持不变。
PlayPosition / SyncStatus 只带 position,不带发出时刻;前端 AudioTimelineSync
以到达时刻作为外推锚点(_lastPositionEvent.receivedAt),于是事件在 IPC 队列里
待多久,UI 时钟就永久落后多久:frontend = backend - Δ_ipc。

给 AudioThreadEventMessage 增加 sent_at(epoch 毫秒,与 JS Date.now() 同域),
在 forward_msg 里与 seq 一起打戳——那是进入传输层前的最后一刻,之后的都是可以
减掉的延迟,之前的已经算进 position 里了。前端据此把外推锚点回拨到打戳时刻。

只作用于外推锚点:_expectedAnchorPosition 仍用到达时刻,不改动 seek 后的
接受/拒绝行为。回退保护里 hold 与 reject 两个分支锚定的是当前位置,仍用 now,
避免重复推进。带 2000ms 上限、暂停时跳过,与 playerBridge.ts 的
MAX_ANCHOR_LATENCY_MS 一致——从属窗口一直有这个补偿,原生主窗口此前没有。

wasm.rs 保持 sent_at = 0:进程内后端没有传输跳数需要补偿。

types.rs 补了三个测试:epoch 域、to/to_none 保留时间戳(二者逐字段重建信封,
漏掉新字段不会有编译错误)、缺字段反序列化为 0。

这修的是锚点丢弃传输时间这一确定缺陷。PR #17 复核里约 0.5s 的偏移是否由此解释
仍需在 Linux 上实测——那个 0.5s 来自 LyricOffsetControl 的 500ms 步进按钮,
是上界而非测量值,建议改用设置面板的数值输入在 release 构建上细扫。
@Burial0268

Copy link
Copy Markdown
Owner Author

@gozhuimeng 稍微 push 了一下,works on my machine. 你看看你那边的环境修复没有。

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.

[Linux/Tauri] 音频恒定延迟约 1.5-1.7s(pipewire-pulse 将 cpal BufferSize::Default 放大至 ~2s)

2 participants