音频分析器最难的部分,不是画一条会动的曲线,而是让每一条曲线在正确的时间描述同一条信号,同时不让音频回调变成 UI 调度器,也不让屏幕暂时变慢后积累越来越长的队列。
SignalMetric 的一个核心决定贯穿了整个实现:Monitor、Spectrum、Timeline 和 Scope 应当读取同一份分析帧,而不是四个各自追赶的独立工具。
This diagram could not be rendered. Its source is still available below.
View diagram source
flowchart LR
A[实时麦克风输入] --> D[音频采集]
B[导入文件或本地录音] --> C[AVAudioPlayerNode]
C --> E[主混音器 Tap]
D --> F[2,048 采样窗口]
E --> F
F --> G[串行 ScopeAnalyzer]
G --> H[最新 ScopeFrame]
H --> I[ViewModel 以 30 FPS 发布]
I --> J[Monitor]
I --> K[Spectrum]
I --> L[Timeline]
I --> M[Scope]
D --> N[受限录音写入器]
图本身很常见,真正决定仪表是否可信的是边界的细节。
所有来源最终进入同一分析契约
SignalMetric 有四种来源:无需权限的 Demo、Live Mic、导入音频和显式本地录音。它们进入 AVAudioEngine 的位置并不相同:
- 麦克风会在 input node 安装 tap。
- 导入文件和完成的本地录音经由
AVAudioPlayerNode播放,并在 main mixer tap 分析实际播放路径。 - 录音复用麦克风 input tap,同时把已接收的 buffer 交给文件写入器。
- Demo 不请求权限,直接生成确定性的
ScopeFrame。
关键不在于所有来源都使用同一套播放控制,而在于真实音频最终都会满足同一契约:Float32 单声道窗口、采样率、路由指纹和声道数。
多声道输入会折叠为明确的单声道分析信号。这使频谱和响度都围绕同一边界,但也意味着它不是多声道交付仪表。UI 会显示声道上下文,不会隐瞒折叠操作。路由或采样率变化时,分析器会重置状态并重新配置频段;把旧的整合响度或 FFT 范围带入新输入,会得到一张看似连续、实际不再一致的仪表盘。
让 render thread 只做有边界的工作
AVAudioEngine tap 靠近音频渲染路径。它不应该等待视图更新、发起网络请求、追加无限历史,或分配一块随会话长度增长的缓冲区。
SignalMetric 收集固定的 2,048 采样窗口,并预分配两个 sample slot:一个可以正在串行分析,另一个可以等待处理。窗口完整后,采集路径拷贝到可用 slot 并调度一次分析;若已经有一个待处理窗口,就不再往后追加。宁可跳过一次调度机会,也不制造延迟债务。
This diagram could not be rendered. Its source is still available below.
View diagram source
sequenceDiagram
participant Tap as Audio tap
participant Capture as 固定采集槽
participant Analyzer as 串行分析器
participant Store as 最新帧锁
participant UI as 主线程 30 FPS
Tap->>Capture: 填充 2,048 采样窗口
alt 没有等待帧
Capture->>Analyzer: 调度一个窗口
Analyzer->>Analyzer: FFT、电平、响度、波形
Analyzer->>Store: 替换最新 ScopeFrame
else 已有等待帧
Capture-->>Capture: 不增长积压
end
UI->>Store: 读取最新完成帧
Store-->>UI: 一份一致的快照
UI->>UI: 渲染四个工作区
UI 由主线程以 30 FPS 读取最近完成的帧,音频回调从不直接调用 SwiftUI。这样显示节奏保持稳定,而音频回调仍可随设备路由和 I/O buffer 改变。
最新帧通过锁整体替换。所以 Monitor 的读数不会属于一个 FFT 窗口,而 Scope 的波形却属于另一个。界面可能落后一帧,但它内部是一致的。
一帧里有哪些证据
ScopeFrame 是信号处理和呈现之间的边界,包含:
- 原始 RMS dBFS、平滑显示电平、即时 Sample Peak、峰值保持和 4x True Peak estimate;
- K-weighted Momentary、Short-Term、Integrated loudness 与 LRA;
- 24 个显示频段、64 个对数 dBFS 频谱点、峰值保持和频谱统计;
- 强分量、Centroid、Bandwidth、85% roll-off、Flatness;
- 触发后的 256 bin 波形包络;
- DC offset、过零率、声道数、削波状态、主频和节拍置信度;
- 采样率、来源指纹、时间戳与窗口时长。
格式化、颜色和页面规则不属于这一层。分析器只拥有信号证据;ViewModel 才负责单位文本、当前工作区、无障碍摘要、响应偏好、会话重置和有界的 30 秒历史。这样 UI 重构或格式变化不会悄悄改变测量结果。
FFT 只是工作的一部分
分析器用 Accelerate/vDSP 在 Hanning 窗之后执行 2,048 点 DFT。Hanning 窗可减少泄漏,但一个显眼的 bin 也不能直接被解释为中心频率上存在完美正弦波。
强分量展示的流程是:
- 找到高于绝对和相对阈值的局部极大值。
- 抑制同一可分辨 Hanning 主瓣附近的候选,避免一个分量变成多个旁瓣卡片。
- 用对数幅度抛物线插值细化频率。
- 对展示电平补偿 Hanning coherent gain。
- 仅当至少两个峰支持同一候选基频时,才添加谐波标签。
- 以更低的展示频率匹配并平滑分量,避免排序闪烁。
因此 Spectrum 可以让频率证据易读,但不会声称六条正弦波重建了整个音频。原始频率分辨率仍按 sampleRate / 2048 公开。
电平和响度也遵循同样的克制。RMS 与 Sample Peak 处在实用数字范围内;True Peak 明确写作 estimate。响度使用持久 K-weighting 和按时长加权的窗口:400 ms Momentary、3 s Short-Term、每 100 ms 跳进的 400 ms Integrated block,以及绝对和相对门限。它使用 BS.1770-style 单声道术语,不宣称认证节目交付。
历史也必须有上限
好的 Timeline 应该连续,但仍必须有固定内存预算。SignalMetric 以每秒 8 帧发布频谱历史,30 秒最多 240 列;它不是从会话开头开始无限保留每一帧。
Scope 也遵循同样规则。它不是隐藏的全分辨率录音,而是在上升过零触发点后,把当前窗口缩减为固定 256 bin 的 min/max/average 包络。窄瞬态仍会以每 bin 的 min/max 被保留,显示成本也始终可预测。
This diagram could not be rendered. Its source is still available below.
View diagram source
flowchart TD
A[原始来源帧] --> B[固定 2,048 采样分析窗口]
B --> C[64 个对数频段]
C --> D[每秒 8 帧历史发布]
D --> E[240 列滚动 Timeline]
B --> F[触发后缩减]
F --> G[256 bin 波形包络]
E --> H[恒定会话内存]
G --> H
这不只是性能优化。无限历史会让内存和渲染成本随时间漂移,让长会话和短会话变成两种不同的工具;一台仪表不该在运行一小时后改变自己的性格。
录音是第二项实时责任
录音把磁盘 I/O 加入了时间敏感的输入路径。若把原始 AVAudioPCMBuffer 直接交给慢速写入器,存储压力就会反过来影响采集。SignalMetric 会先复制每个已接受的 buffer,再在独立串行队列上写入。
待写队列最多 64 个 buffer。存储跟不上时,录音会停止,而不是让内存无限增长。用户停止、发生中断、路由改变或应用进入后台时,引擎先拒绝新 buffer,随后等待已经接受的写入完成,最后才关闭本地 M4A;主线程不会为此等待。
This diagram could not be rendered. Its source is still available below.
View diagram source
stateDiagram-v2
[*] --> LiveAnalysis
LiveAnalysis --> Recording: 点击 Record
Recording --> Finalizing: 停止 / 中断 / 后台
Finalizing --> LocalFile: 已接收写入排空并关闭 M4A
Finalizing --> Failed: 空文件或写入失败
LocalFile --> FileAnalysis: 打开本地录音
FileAnalysis --> LiveAnalysis: 切换来源
正常 Live Mic 仍然是内存分析;只有用户点下 Record 才会创建文件,文件也只在用户选择分享目的地时离开应用。
边界本身就是设计的一部分
SignalMetric 不走几条看似方便的捷径:
- 不从音频回调调用 SwiftUI。
- 不让队列或历史随会话时长增长。
- 不上传麦克风或导入音频做处理。
- 不把未校准麦克风读数描述为 SPL。
- 不把 4x 采样间估算描述为认证结果。
- 节拍和音高置信度不足时,不强行给出确定数值。
结果不是四个彼此无关的动画,而是一条来源、一个分析边界,以及四种更准确的提问方式。SignalMetric 产品指南从使用者角度介绍了这四个工作区。