全部文章

构建 SignalMetric:四个视图,一条实时信号

拆解 SignalMetric 如何让实时仪表、频谱、Timeline 与波形保持同一信号语义,同时避免实时路径形成 UI 积压。

iOS音频工程AVAudioEngineDSP架构
构建 SignalMetric:四个视图,一条实时信号

音频分析器最难的部分,不是画一条会动的曲线,而是让每一条曲线在正确的时间描述同一条信号,同时不让音频回调变成 UI 调度器,也不让屏幕暂时变慢后积累越来越长的队列。

SignalMetric 的一个核心决定贯穿了整个实现:Monitor、Spectrum、Timeline 和 Scope 应当读取同一份分析帧,而不是四个各自追赶的独立工具。

流程图 正在生成图表

左右滑动查看完整图表

查看图表源代码
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 并调度一次分析;若已经有一个待处理窗口,就不再往后追加。宁可跳过一次调度机会,也不制造延迟债务。

时序图 正在生成图表

左右滑动查看完整图表

查看图表源代码
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 也不能直接被解释为中心频率上存在完美正弦波。

强分量展示的流程是:

  1. 找到高于绝对和相对阈值的局部极大值。
  2. 抑制同一可分辨 Hanning 主瓣附近的候选,避免一个分量变成多个旁瓣卡片。
  3. 用对数幅度抛物线插值细化频率。
  4. 对展示电平补偿 Hanning coherent gain。
  5. 仅当至少两个峰支持同一候选基频时,才添加谐波标签。
  6. 以更低的展示频率匹配并平滑分量,避免排序闪烁。

因此 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 被保留,显示成本也始终可预测。

流程图 正在生成图表

左右滑动查看完整图表

查看图表源代码
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;主线程不会为此等待。

状态图 正在生成图表

左右滑动查看完整图表

查看图表源代码
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 产品指南从使用者角度介绍了这四个工作区。

来自 MonoWare

SignalMetric 背后还有更多上下文。

打开产品网站,或继续阅读按 SignalMetric 筛选的笔记。

通讯

隐私与产品工程笔记,偶尔发送。