调音器看起来只是"听一个声音,再显示一个音名",但真正落到 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.get<boolean>('privacyReady') ?? false;
const privacyAccepted = AppStorage.get<boolean>('privacyAccepted') ?? false;
if (!privacyReady || !privacyAccepted) {
throw new Error('Privacy policy not accepted');
}
const granted = await this.requestMicPermission(context);
AppStorage.setOrCreate<boolean>('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 转成频率。如果频率不在 40~1500 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% 的跳变会返回 0,processBuffer() 随即把 _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 项窗口写入 0;getSmoothedFrequency() 会丢弃窗口里的 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): Promise<void> {
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): Promise<void> {
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 已成功构建;不能确认的,是没有设备与标准输入条件时的真实精度和体验指标。把这条边界写清楚,才是真实项目文章和"看起来像调音器"的示例代码之间最关键的差别。