Skip to content

Architecture.zh CN

Saco Song edited this page Aug 14, 2026 · 11 revisions

架构

English · 首页

进程模型

标准桌面部署包含两个长期运行的进程:

  1. voice-input daemon 负责音频采集、会话控制、ASR、状态、文本整理和输出。
  2. /usr/bin/qs --no-duplicate --path …/quickshell 让 HUD 保持常驻。

Settings 不会常驻。voice-input settings 会先通过不包含 secret 的 Quickshell IPC 调用尝试激活已有窗口。如果没有可激活的实例,它会启动 /usr/bin/qs --daemonize --no-duplicate --path …/quickshell-settings。这套独立的 Quickshell 配置会创建普通 FloatingWindow,并启动专用 child process:voice-input settings-backend --stdio

Dictation client 通过 $XDG_RUNTIME_DIR/voice-input/control.sock 发送简短命令。daemon 通过原子替换的方式更新 $XDG_RUNTIME_DIR/voice-input/state.json。波形数据采用 NDJSON 格式,通过单独的 waveform.sock 广播。

flowchart LR
  Key["Hyprland 快捷键"] -->|control.sock| D["Daemon"]
  PW["pw-record<br/>单声道 PCM16"] --> D
  D --> RT["Qwen Realtime WebSocket"]
  D --> A3S["Audio3 Streaming WebSocket"]
  D --> Local["/usr/bin/voxtype"]
  D --> Final["Qwen 全音频 HTTP"]
  D --> A3N["Audio3 Native HTTP"]
  StartContext["开始时聚焦的 Pi / Codex"] -->|不可变术语快照| A3S
  StartContext -->|同一快照| LLM["OpenAI-compatible Refine"]
  D --> LLM
  D -->|state.json| HUD["Quickshell HUD"]
  D -->|waveform.sock| HUD
  D --> Out["wl-copy + hyprctl<br/>xclip + xdotool"]
  SettingsCmd["voice-input settings"] -->|激活或启动| Settings["Quickshell FloatingWindow"]
  Settings <-->|带版本号的 NDJSON| SB["Rust settings backend"]
  SB -->|验证并以原子方式写入| Config["config.toml"]
  SB -->|通过 stdin 传递 secret| Creds["systemd-creds"]
Loading

会话阶段

1. 采集与准备

pw-record 按照 audio.sample_rate 生成单声道、有符号 16 位 PCM;默认采样率为 16 kHz。关闭 pre-roll 时,每个会话会启动独立的 recorder。启用 pre-roll 后,capture service 会保持一个 recorder 运行,维护环形缓冲区,并在录音期间把当前会话接入共享音频流。环形缓冲区至少覆盖配置的 pre-roll 时长和 320 ms 的采集预热时长。两条路径都会应用 audio.max_duration_secs;默认值为五分钟。达到上限后,程序会自动执行正常的停止和最终处理流程。

开始录音时,daemon 会记录 Wayland/XWayland 输出目标提示。启用术语上下文后,它还会捕获当前聚焦的 Pi/Codex 来源,并开始从该 session 最近一条已完成的 assistant message 中进行本地提取。生成的不可变术语快照由 Audio3 Streaming 和 Refine 共用;后续焦点变化无法替换它。只有 capture 和 ASR 都准备完成,并且 session 从 arming 进入 recording 后,有效录音计时才会开始;接受停止请求时会在 finalization 开始前冻结该时长。Runtime snapshot 会在录音期间携带开始时间戳,并在停止后携带冻结时长,从而让每个 HUD surface 显示一致的计时。

2. 实时 ASR 与波形

使用 alibaba-qwen-realtime 时,PCM 会被分成每包 2,048 个 sample,并以非阻塞方式提交到容量受限的实时 queue。WebSocket worker 每轮只处理有限数量的待发送 packet,然后读取服务端事件,避免任何一个方向长期得不到处理。实时事件会更新已确认和不稳定的 transcript。server-vad 提供语音开始和停止事件;只有 manual 模式会在停止录音时提交音频。

在 Server VAD 模式下,Qwen Realtime worker 最多重建一次 session。Watchdog 会在两种情况下触发:服务端确认的语音段持续处于 active 状态且八秒没有 transcript event;或者已经出现文本,持续检测到具有音高相关性的本地语音,同时八秒没有收到任何服务端事件。本地路径不使用单纯的 RMS 阈值,因此普通静音和稳定的宽带麦克风底噪不会消耗唯一一次重建机会。结束前发生符合条件的传输故障也可以使用同一份重建预算。Worker 会建立新连接,并从头重放保留的全部原始 PCM packet;录音采集仍通过容量受限的音频 control queue 提交音频。Replacement 使用新的 transcript assembler,因此重放文本会替换中断前的 preview,不会追加到旧文本后面。如果服务端始终没有发出第一条 speech event,且本地路径也不满足条件,Voice Input 会继续录音,并把空音频判断延后到停止时的 finalization 或完整音频恢复阶段。

如果 replacement 停滞或断线,Voice Input 会停止信任 Qwen Realtime 文本,并在停止时使用完整音频进行恢复。程序会分别诊断音频 control queue 已满和 worker 断开连接。发生任何降级情况时,本地采集、HUD 波形和 daemon 保存的完整 PCM buffer 都会继续工作。客户端不会在 Server VAD 模式下发送手动 recovery commit,因为服务端可能拒绝该请求并关闭连接。

对于实验性 Audio3,Streaming 通过 WebSocket 提供实时和最终文本,Native 则通过 HTTP 处理完整音频。发送 finish-task 前发生一次可恢复的断线时,程序会创建 ID 不同的 replacement task,重置权威 transcript 以废弃旧 task 的文本,并在继续采集的同时以 4 倍实时速度重放保留的完整 PCM 前缀。Replacement 会接收开始时冻结的同一份术语快照。前缀保留上限取 audio.max_duration_secs、300 秒和 10 MiB PCM 三者中的最小值;超过上限后会停用重连,不会改为保留或重放不完整的前缀。第二次断线、发送 finish-task 后的任何断线或 replacement 失败都不会创建第三个 task,而是进入 Native 或本地完整音频恢复。

Audio3 Native 有三种模式。streaming-only 从不调用 Native。adaptive 会在音频传输过载、worker 中断、Streaming 结果为空/失败/降级、缺少明确的 Finished 事件,或者录音达到 30 秒时调用 Native;不过,如果 Streaming 结果可用、明确完成并且确实发送了 Session Context,程序不会仅因为达到 30 秒而替换它。always 会对每段未取消且非空的录音调用 Native。Native 最多接受 10 MiB 的原始 WAV 音频;Native 请求失败时,程序仍可使用有效的 Streaming 文本。

波形分析器直接处理 PCM。它采用 512 sample 的分析窗口和 256 sample 的 hop;在 16 kHz 下会生成每秒 62.5 帧,并发布 30 根对称波形条、12 个频段和聚合语音指标。在 recording 阶段,HUD 会把实时频段传入 cubic B-spline,并将结果映射到 capsule 顶部边缘。检测到人声之前,Listening 会显示宽阔且平缓的虚拟频谱,其最大几何延伸距离与 processing 相同;检测到人声后会以约 150 ms 的 attack 过渡到实时频谱,暂停说话时则以约 800 ms 衰减回静默效果。该 release 会把测量频谱直接混合到全高度静默包络,不会在中间回退到另一套 procedural perimeter wave。Arming 使用虚拟频段来表示准备过程。Finalizing、Refining 和 Sending 共用一个宽阔的虚拟频段轮廓、一组持续累积的动画 phase、相同的节奏和亮度范围。daemon 关闭 waveform session 时,HUD 会保留最后一个实时频谱,然后执行约 360 ms 的呼吸式交接:光晕会在几何轮廓和颜色切换到 processing 的过程中降至约 5% 可见度,随后恢复到完整可见度。只有 runtime snapshot 进入 idle 后,HUD 才会清空保留的帧;之后切换 processing phase 时会保持几何动画不变。后续 phase 变化只会触发约 650 ms 的整体光晕颜色 crossfade,因此 processing 动画不会表现为重新开始播放,频谱上也不会出现移动的颜色边界。HUD 会在每次 phase 变化时同步初始化几何和颜色的过渡进度,从而避免 crossfade 开始前先渲染一帧目标状态。波形帧频率与 ASR packet 频率互不依赖。StateStore 统一负责状态刷新:活动阶段每 50 ms 轮询一次以原子方式替换的 snapshot,idle 时每 100 ms 轮询一次;状态刷新不依赖各个 surface 的动画提供备用路径。它会严格验证 snapshot 的 updated_at_ms/revision 版本,再替换 UI snapshot。波形消息必须包含有效的 session ID 和递增的 sequence,并且包含正好 30 根波形条、12 个频段以及取值位于 [0,1] 的有限标量和数组元素;通过验证的帧会以原子方式替换完整 waveform state。光晕 shader 输出 premultiplied alpha,并且对完全位于 capsule 内部的 fragment 立即返回透明值。

使用 local-cli 时,后台 partial thread 会根据 audio.partial_interval_ms 定期重新识别当前累计音频。停止录音后,完整音频会再次进行识别。

3. 停止、取消与最终 ASR

用户手动停止录音或录音达到时长上限而自动停止后,如果启用了 refinement,daemon 会捕获当前聚焦目标的类别。停止时的焦点只选择 refinement 风格,不会重新捕获或替换开始时冻结的术语快照。显式执行 record cancel 时会跳过这项查询。

随后,daemon 会断开共享采集会话,或者终止该会话的 pw-record child process;再发送最后一包 ASR 音频,并要求实时 ASR 完成会话。如果在 350 ms 宽限期之后仍未收到语音事件或非空 transcript,daemon 不会立即取消,而是继续执行 realtime Finish 或 manual commit,以及正常的完整音频判定。只有最终选定的 transcript 仍为空时,session 才会直接回到 idle,不执行 LLM 整理或文本注入。

final_pass_enabled = true 时,daemon 会把完整 PCM 写入临时 WAV,再以 base64 data:audio/wav 输入发送给配置的 Qwen final model。成功结果会替换 realtime transcript。失败时,daemon 会先使用已有的 realtime final text;如果仍无可用文本且启用了本地 fallback,则调用 /usr/bin/voxtype。关闭 final pass 时,daemon 会优先使用有效的 realtime final text,并在远程失败或返回空文本时按配置执行本地 fallback。如果一次 realtime 重建仍然失败、queue overflow 或 worker 断开连接,远程 service 就没有处理完整录音;程序会拒绝其 transcript,并通过已启用的 final pass 或本地 fallback 处理完整的缓冲录音。

中文识别结果会在识别完成后通过 OpenCC 执行 t2ss2t 转换。

4. LLM 整理

LLM 整理是可选的保守处理。程序会根据停止录音时捕获的目标选择风格,即使没有启用 Agent 会话上下文也会如此:Pi 和 Codex 使用紧凑的 Markdown,把明确的顺序保留为有序列表,把没有顺序的同级列举保留为无序列表,并把不同部分分成段落;已安装的即时通讯客户端使用自然的聊天标点;其他目标使用轻度书面化文本。Prompt 明确要求 model 不要强行把简单请求转换成列表,也不要编造标题、层级或内容。

llm.timeout_ms 默认为 15000;实现会把总预算限制在 1,000–30,000 ms。prompt 构造、带上下文的请求和符合条件的纯 transcript 重试共用同一个 deadline。预算至少为 10 秒时,带上下文的请求最多使用总预算减去最后五秒的部分,以便纯 transcript 恢复仍有机会执行。Transport error、明确的上下文或 payload 问题、无效响应、响应截断或带上下文请求的预算耗尽都可以触发不带上下文的重试,但剩余预算必须不少于一秒。遇到不可重试的 HTTP 或 provider error 时,程序会保留原始 ASR 文本。

发生错误、超时、响应截断、缺少 model 或 credential、预算耗尽时,daemon 都会保留 ASR transcript。时间日志只记录请求类型、耗时毫秒数、结果类别和最终选择,不会输出 transcript 或上下文。

5. 输出

投递文本时,daemon 会重新探测活动窗口以确定使用 Wayland 还是 XWayland,同时保留开始录音时记录的目标提示。所有 transcript 都通过剪贴板粘贴:Wayland 使用 wl-copy 写入内容,再通过 Hyprland 的 dispatch sendshortcut 把配置的快捷键发送给活动窗口;XWayland 使用 xclipxdotool。输出路径不会创建逐字符合成 keymap。粘贴流程会先备份相关剪贴板,发送粘贴快捷键,等待 220 ms,再恢复原内容。原生 Wayland 会把临时 transcript 和恢复的 payload 都标记为敏感。

如果启用了相应配置,Fcitx5 guard 会在输出前暂时关闭活动输入法,并在输出后恢复。

Settings 边界

Settings QML 会编辑完整的配置草稿,但是它本身不解析、不验证,也不写入 TOML。每个 backend request 和 response 都是单独一行且带版本号的 JSON object。Request 通过 child process 继承的标准输入发送,response 通过标准输出返回。

QML 为每个 request 设置 30 秒 deadline,并拒绝超过 2 MiB 的 response line。只有在精确的 protocol envelope 以及对应 method 的 result 或 error payload 都通过严格的结构、类型、枚举值和大小验证后,QML 才会接受响应。Backend process 或 protocol 失败后,程序会按照 250 ms 到 8 秒的指数退避延迟重启,最多自动尝试六次;只有有效响应才会重置失败计数。

只有 Rust 会加载和验证配置。加载响应包含一个根据实际读取源计算的不透明 revision。保存时,Settings 会发送该 revision 和完整的受支持配置;如果原始配置的精确内容已经变化,Rust 会拒绝保存,避免一个 Settings 窗口覆盖另一个编辑器的修改。保存成功后,程序会保留全部受支持字段,把配置目录权限设为 0700,把配置文件权限设为 0600,并以原子方式替换该文件。

Credential action 只有 keepreplace。密码输入只会通过继承的 stdin 进入 Rust,再通过 stdin 进入 systemd-creds;它们不会进入 TOML、process argument、环境变量、日志或 backend response。用户提交 Save 或 Test LLM 后,QML 会立即清空对应 credential field。由于 QML/JavaScript string 使用托管内存,这项清理只能尽力执行,不能保证完成内存零化。

Test LLM 可以使用刚输入的 LLM credential,也可以使用加密 credential store 中的现有值。Save 可以请求重启 daemon。程序会把重启失败与持久化结果分别报告,因此配置已经成功写入时,不会被错误地描述为写入失败。

只读的 runtime.get 会检查 voice-input.service,并且独立解析标准 runtime snapshot,不会与保存请求共用状态。响应采用允许列表,只包含取值受限的 service state,以及 phase、更新时间、语言、engine 和 model。各种 transcript、tooltip、输出目标信息、runtime error 文本及 credential 都不会返回。检查失败时,Overview 会显示 unknown 或 unavailable;该结果不会阻止编辑,也不会覆盖保存错误。

并发边界

边界 作用
Capture service 或独立 reader thread 持续读取 PipeWire,不等待网络或界面操作。
容量受限的 ASR 音频 control queue 与 backend worker Capture 提交 packet 时不等待 WebSocket I/O。Queue overflow 会把 stream 标记为不完整,并改为使用完整音频缓冲区恢复。ASR event 当前使用标准的无界 Rust mpsc channel;只有音频 control queue 有容量上限。
本地 partial-ASR thread 定期生成预览,不在 control listener 中执行。
开始时的术语 worker Pi、Kitty 与 Hyprland 检查和本地提取可以与采集并行;消费者共用一份不可变结果。
波形 publisher thread 使用有界 queue 和非阻塞发送;queue 满时可以丢弃视觉帧,从而避免阻塞采集。
串行状态更新 lock 保证采集和 ASR 的并发更新按顺序持久化;每次写 JSON 都采用临时文件重命名。
输出 child process runner 并行读取通过 pipe 返回的 stdout 和 stderr,分别限制为 16 MiB;stdin、I/O 和 process 完成共用一个 deadline,超时、超过大小限制或 I/O 失败时会终止 child process group。
常驻 HUD Quickshell process StateStore 在活动 phase 中每 50 ms 读取一次状态,idle 时每 100 ms 读取一次,并独立消费通过验证的波形 socket frame。HUD 故障不会接管或终止识别流程。
按需启动的 Settings 与 Rust child 使用 request ID 和带版本号的 NDJSON;严格的响应验证与精确源 revision 检查共同保护该边界。
Control connection thread 与 daemon mutex 接受数量受限的并发本地 client,同时避免实际命令并行执行。

Control socket 最多允许 32 个活动连接。命令上限为 4 KiB,响应上限为 64 KiB,服务端读写 I/O timeout 为两秒。每个连接都可能等待 daemon mutex;该 mutex 仍会串行执行命令,并覆盖 final ASR、LLM 整理和输出阶段。程序会在接受录音控制命令时记录当前 idle generation;如果命令获得 mutex 前 daemon 已进入更新的 idle generation,该命令会被视为过期并忽略。过期判断基于 idle generation,而不基于 750 ms 时长阈值。

构建时 shader 验证

CI 使用 QSB 编译 HUD shader,并验证打包产物包含六个目标:SPIR-V 100、GLSL ES 100、GLSL 120、GLSL 150、HLSL 50 和 MSL 12。CI 还会解析 QSB reflection metadata,检查 uniform block binding,以及反射得到的 qt_Matrixqt_Opacity layout。

运行时状态机

idle → arming → recording → transcribing → refining → outputting → idle。任何控制或会话操作失败后都可能进入 error。关闭 LLM 时会跳过 refining。取消的 session 或最终为空/no-words 的结果会直接返回 idle,不会输出文本。

另请参阅:配置参考 · Agent 上下文 · 桌面集成

Clone this wiki locally