HarmonyOS 弦乐调音器开发实战 02:AudioCapturer 与 NSDF 怎样完成实时音高检测

📅 发布时间:2026/8/4 23:09:13
HarmonyOS 弦乐调音器开发实战 02:AudioCapturer 与 NSDF 怎样完成实时音高检测
调音器看起来只是“听一个声音再显示一个音名”但真正落到 HarmonyOS 应用里链路至少包含隐私同意、麦克风权限、系统音频采集、PCM 解码、静音门限、周期估计、异常跳变抑制、频率平滑、琴弦匹配和音分换算。任何一层没有说明白文章就很容易把界面效果写成算法能力或者把一次编译成功写成实机精度通过。本文只分析项目根目录的原生 ArkTS/ArkUI Stage 实现版本为 1.0.7。Flutter/OHOS 1.0.8 是另一条工程线会在后续文章单独讨论。下面给出的类名、常量和代码均来自当前工程历史截图仅用于说明曾经出现过的运行状态不替代本轮实机验证。一、先确定本文真正讨论的音频链路调音页不是直接调用一个“识别音符”接口而是通过AudioService取得最新频率与清晰度再由TunerPage做页面侧平滑、琴弦选择和音分换算。当前路径可以概括为隐私状态 → 麦克风权限 →AudioCapturer→ 16 位 PCM → 4096 点累积窗 → NSDF → 频率跳变过滤 → 最近 5 次 80 ms 轮询槽位去零后取中值 → 目标弦与音分。这一定义很重要。工程中虽然还能看到独立的PitchDetector.ets但当前调音页实际读取的是单例AudioService的结果活跃算法也实现在AudioService.detectPitch()中。因此分析和文章代码不能因为文件名更“像算法模块”就误把未接入的类当成生产调用路径。二、权限不是装饰采集前有两道状态门模块配置在entry/src/main/module.json5中声明ohos.permission.MICROPHONE使用场景限定为EntryAbility处于使用中。声明权限只是系统知道应用可能申请麦克风并不代表运行时已经得到授权。AudioService.start()先读取privacyReady与privacyAccepted两者都成立后才请求麦克风权限。权限结果还会写入microphonePermissionGranted然后才创建采集器。源码的关键顺序如下// entry/src/main/ets/services/AudioService.ets const privacyReady AppStorage.getboolean(privacyReady) ?? false; const privacyAccepted AppStorage.getboolean(privacyAccepted) ?? false; if (!privacyReady || !privacyAccepted) { throw new Error(Privacy policy not accepted); } const granted await this.requestMicPermission(context); AppStorage.setOrCreateboolean(microphonePermissionGranted, granted); if (!granted) { throw new Error(Microphone permission denied); }页面侧也有一道条件只有页面处于活动状态并且privacyAccepted为真才会启动音频服务。切走调音页或组件消失时会尝试保存满足门槛的会话并释放客户端隐私状态撤销的监听路径只停止音频并不调用saveCurrentSession()。这里可以确认“实现了采集前置门控”但不能仅凭这段代码宣布应用已经通过完整隐私合规审查合规还涉及文案、数据处理披露、平台配置和实际包行为。三、AudioCapturer 的格式必须与样本解释一致当前AudioCapturerOptions固定为 44.1 kHz、单声道、S16LE、RAW PCM输入源为系统麦克风。随后readData回调把ArrayBuffer交给processBuffer()。这几个参数不是可以随意省略的实现细节因为后续周期到频率的换算明确使用SAMPLE_RATE 44100而缓冲区则按Int16Array解释。// entry/src/main/ets/services/AudioService.ets const options: audio.AudioCapturerOptions { streamInfo: { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW }, capturerInfo: { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0 } }; this.capturer await audio.createAudioCapturer(options); this.capturer.on(readData, (buffer: ArrayBuffer) { this.processBuffer(buffer); }); await this.capturer.start();processBuffer()用Int16Array读取样本再除以32768.0归一化到接近[-1, 1)的区间。若采集格式改成 32 位浮点、立体声或其他采样率而这里只改配置不改算法解释频率结果就会失真。因此设置页里即使保存了采样率、位深、输入源等字段也不能反向推断采集器已经动态使用这些设置当前服务代码仍是固定参数。四、4096 点累积窗怎样进入检测采集回调每次送来的缓冲长度未必正好符合音高检测窗口。当前实现把归一化样本追加到accumBuffer不足 4096 点时只更新波形达到或越过 4096 点后取末尾 4096 点进行检测处理完成后只保留累积数组末尾 2048 点。代码能确定窗口长度与保留量但单次readData的块长可变所以实际相邻分析窗的步进与重叠率还取决于回调边界。// entry/src/main/ets/services/AudioService.ets private processBuffer(buffer: ArrayBuffer): void { const int16View new Int16Array(buffer); const count: number int16View.length; for (let i 0; i count; i) { this.accumBuffer.push(int16View[i] / 32768.0); } if (this.accumBuffer.length ACCUM_TARGET) { if (this.accumBuffer.length 128) { this._latestWaveform this.computeWaveform(this.accumBuffer); } return; } const startIdx: number this.accumBuffer.length - ACCUM_TARGET; const samples: number[] this.accumBuffer.slice(startIdx); const pitch this.detectPitch(samples); this._latestFrequency this.filterFrequency(pitch.frequency, pitch.clarity); this._latestClarity pitch.clarity; this._latestWaveform this.computeWaveform(samples); this.spectrumFrameSkip this.spectrumFrameSkip 1; if (this.spectrumFrameSkip 3) { this._latestSpectrum this.computeSpectrum(samples); this.spectrumFrameSkip 0; } this.accumBuffer this.accumBuffer.slice( this.accumBuffer.length - Math.floor(ACCUM_TARGET / 2) ); }在 44.1 kHz 下4096 点对应约 92.9 ms 的声音长度如果回调恰好让下一次分析只新增 2048 点名义步进约为 46.4 ms、重叠约为 50%。这不是代码对每次回调都能保证的固定步进更不等于端到端显示时延。真正时延还会叠加音频系统缓冲、回调调度、NSDF 计算、页面 80 ms 轮询、去零中值和渲染因此本文不会给出未经实测的“低于多少毫秒”结论。五、当前算法是 NSDF不是 YIN 或 FFT音高检测的核心是归一化平方差函数 NSDF。对每一个候选延迟tau代码同时累积自相关项与两段样本能量并计算2 * acf / norm。候选周期范围由 40 Hz 到 1500 Hz 反推得到最小周期约为44100 / 1500最大周期受44100 / 40和半窗口限制共同约束。// entry/src/main/ets/services/AudioService.ets for (let tau minPeriod; tau maxPeriod; tau) { let acf: number 0; let norm: number 0; const limit: number n - tau; for (let j 0; j limit; j) { const sj: number samples[j]; const sjt: number samples[j tau]; acf sj * sjt; norm sj * sj sjt * sjt; } nsdf[tau] norm 0 ? 2.0 * acf / norm : 0; }项目没有在这条活跃路径上执行 FFT也没有实现 YIN 的差分函数、累积均值归一化和绝对阈值搜索。把所有单音高估计统称为 YIN或者看到“频谱”字样就写成 FFT都会改变项目事实。六、静音门、首个关键极大值与亚采样插值进入 NSDF 前代码先计算平均能量低于0.0008时直接返回空结果避免在近似静音中寻找虚假周期。得到 NSDF 后不是选全局最大值而是从低延迟向高延迟扫描找到第一个由上升转下降且峰值不小于0.65的关键极大值。随后利用峰值左右三个点做抛物线插值得到非整数周期refinedTau再通过44100 / refinedTau转成频率。如果频率不在 401500 Hz或峰值清晰度小于0.65结果仍会被丢弃。// entry/src/main/ets/services/AudioService.ets const alpha: number bestTau minPeriod ? nsdf[bestTau - 1] : nsdf[bestTau]; const beta: number nsdf[bestTau]; const gamma: number bestTau maxPeriod ? nsdf[bestTau 1] : nsdf[bestTau]; const denom: number alpha - 2.0 * beta gamma; const refinedTau: number denom ! 0 ? bestTau 0.5 * (alpha - gamma) / denom : bestTau; const frequency refinedTau 0 ? SAMPLE_RATE / refinedTau : 0; if (frequency PITCH_MIN_FREQ || frequency PITCH_MAX_FREQ || beta MIN_CLARITY) { return result; }“从低tau开始选择第一个超过阈值的局部峰”只是当前候选周期策略它优先较短周期并不天然消除谐波误判在某些复合音中还可能选到高八度。项目没有附带标准信号扫描或误判统计因此不能把这一策略写成准确率保证。阈值0.65与能量门0.0008是当前工程参数而不是经过本文重新标定的行业标准。七、服务层和页面层各自做了一次稳定处理服务层定义了MAX_FRAME_JUMP_RATIO 0.18和MAX_REJECTED_JUMP_FRAMES 3从常量命名看像是要连续确认三次跳变但当前控制流并没有做到这一点。第一次超过 18% 的跳变会返回 0processBuffer()随即把_latestFrequency写成 0下一次分析进入filterFrequency()时因为_latestFrequency 0已不成立比较被跳过新频率可以直接被接受。因此当前实际效果是抑制第一个跳变结果而不是“前两帧置 0、第三帧确认”。// entry/src/main/ets/services/AudioService.ets private filterFrequency(frequency: number, clarity: number): number { if (frequency 0 || clarity MIN_CLARITY) { this.rejectedJumpFrames 0; return 0; } if (this._latestFrequency 0) { const diff frequency this._latestFrequency ? frequency - this._latestFrequency : this._latestFrequency - frequency; if (diff / this._latestFrequency MAX_FRAME_JUMP_RATIO) { this.rejectedJumpFrames this.rejectedJumpFrames 1; if (this.rejectedJumpFrames MAX_REJECTED_JUMP_FRAMES) { return 0; } } } this.rejectedJumpFrames 0; return frequency; }页面层每 80 ms 拉取一次服务结果清晰度不足时向 5 项窗口写入 0getSmoothedFrequency()会丢弃窗口里的 0再对剩余值取中值。这能平滑页面但旧的非零值可能在窗口中保留几个轮询周期所以不能把每次非零页面输出都称为一个独立、当次通过门槛的采集帧。// entry/src/main/ets/pages/TunerPage.ets this.pollTimer setInterval(() { this.pollAudioService(); }, 80); this.frequencyHistory.push( clarity this.MIN_PITCH_CLARITY ? rawHz : 0 ); if (this.frequencyHistory.length this.SMOOTH_WINDOW_SIZE) { this.frequencyHistory.shift(); } const hz this.getSmoothedFrequency();因此最终显示频率不是某个音频回调的原始结果而是“服务层首个大跳变置零 页面层清晰度入窗 最近 5 次 80 ms 轮询槽位去零后的中值”的组合。若要真正实现连续三帧确认应另存最后一个已接受频率或待确认候选不能在第一次拒绝后让比较基准被 0 覆盖。调参时必须区分是哪一层造成响应慢或抖动小不能只盯着 NSDF 阈值。八、多个页面为什么不能互相抢麦克风首页、调音页和宽屏页面都可能需要音频摘要。如果每个页面直接调用start()与stop()一个页面退出时就可能把另一个仍在使用的采集器释放。当前服务用字符串数组保存活跃客户端在stopForClient()的常规释放路径中页面只移除自己的 ID数组为空才真正停止采集器。它不是全局不变量Index.ets在隐私被拒绝时会直接调用AudioService.stop()而stop()会清空客户端数组。// entry/src/main/ets/services/AudioService.ets async startForClient(context: common.UIAbilityContext, clientId: string): Promisevoid { const alreadyActive this.hasActiveClient(clientId); if (!alreadyActive) { this.activeClients.push(clientId); } try { await this.start(context); if (this.activeClients.length 0) { await this.stop(); } } catch (err) { if (!alreadyActive) { this.removeActiveClient(clientId); } throw new Error(AudioCapturer start failed: JSON.stringify(err)); } } async stopForClient(clientId: string): Promisevoid { this.removeActiveClient(clientId); if (this.activeClients.length 0) { await this.stop(); } }这是一种轻量所有权管理并不是并发锁或引用计数的完整通用实现。数组去重依赖hasActiveClient()异常启动时会回滚新客户端页面仍需要在生命周期变化时配对调用。文章能确认的是当前三个页面有统一服务入口不能据此宣称所有并发与后台切换场景都已覆盖测试。九、自动模式与手动模式最终都要落到目标频率页面得到稳定频率后自动模式遍历当前乐器的琴弦根据音分绝对值找最近的目标弦手动模式固定使用用户选中的弦。目标频率不是琴弦数据中的常数直接显示而是调用FrequencyUtils.noteToFreq()把音名、八度和当前 A4 参考值转换为目标频率。检测频率与目标频率的偏差使用十二平均律的音分公式// entry/src/main/ets/common/utils/FrequencyUtils.ets static getCentsDeviation(detectedFreq: number, targetFreq: number): number { if (targetFreq 0 || detectedFreq 0) { return 0; } return Math.round( 1200 * Math.log(detectedFreq / targetFreq) / Math.log(2) ); }音分是对数尺度频率翻倍相差 1200 音分一个平均律半音是 100 音分。当前界面常量把 ±5 音分定义为“perfect”范围把 ±50 音分作为准确度线性评分的边界。这是产品判定逻辑不等同于算法经过校准后能稳定达到 ±5 音分。十、截图能证明什么不能证明什么下面两张图来自 2026-05-14 的原生应用运行素材。第一张展示手机形态下出现过频率、音名和音分结果第二张展示宽屏形态下的调音页以及-- Hz、--音名、0音分与“完美”文案并存的默认/空状态。它们能证明早期原生实现曾经渲染这些界面状态但无法单独证明输入声源的标准频率、设备型号、麦克风链路误差也不能证明当前 1.0.7 与截图像素级一致。尤其不能只看到“0 音分”或“完美”就写成“测量误差为 0”。当前 UI 在frequency 0、音名为--的无有效频率状态下也可能显示默认cents 0与对应文案这张宽屏图因此不能作为一次有效测量更谈不上精度证据。十一、名为 Spectrum 的数据目前不是频域谱computeSpectrum()取最后最多 1024 个时域样本均分为 32 段分别计算绝对值平均值和峰值再执行avg * 8 peak * 0.35的归一化。它没有复数变换、窗函数、频率 bin 或采样率到频轴的映射。因此更准确的叫法是“分段幅值摘要”或“动态柱状图数据”。这不妨碍它作为视觉反馈但若文章说它展示“各频段能量”读者会自然理解为 FFT 频谱事实就被扩大了。后续真要实现频谱应单独引入窗函数、FFT、单边谱和频率映射并重新评估计算量不应仅改函数名或图注。十二、本轮验证结果与明确边界2026-08-03 使用工程配置重新执行assembleHap构建成功生成entry-default-signed.hap文件大小为 29,450,429 字节SHA-256 为4FB7DAC5D2F081EEF428FC005B4F4CD5EB349331010391A5FA482AE78C11BB4D。构建日志有 26 条 API 弃用警告但没有编译或打包错误。这个结果能证明当前源码在本地工具链下完成编译与打包。本轮 HDC 设备列表为空所以没有重新安装 HAP也没有完成当前包的麦克风授权、真实乐器输入、标准信号源扫描、音分误差、端到端时延、长时间稳定性、功耗或温升测试。项目现有原生测试还是模板级断言不能当作 NSDF 业务单元测试。构建成功、历史截图和源码审计是三类不同证据必须分别陈述。结语调音结果是整条链路共同产生的这个工程的实时调音并非一个孤立算法函数隐私与权限决定能否采集固定的 44.1 kHz/单声道/S16LE 决定样本解释末尾 4096 点窗口给 NSDF 提供周期信息能量门与清晰度门过滤弱信号首个跳变结果抑制和最近 5 次 80 ms 轮询槽位去零中值让界面更稳定最后再根据 A4 参考频率匹配琴弦并换算音分。从当前源码可以确认实现路径与参数也能确认新 HAP 已成功构建不能确认的是没有设备与标准输入条件时的真实精度和体验指标。把这条边界写清楚才是真实项目文章和“看起来像调音器”的示例代码之间最关键的差别。