HarmonyOS 弦乐调音器开发实战 03:参考音、调音历史与 AppStorage 如何形成闭环

📅 2026/8/4 23:09:12
HarmonyOS 弦乐调音器开发实战 03:参考音、调音历史与 AppStorage 如何形成闭环
调音器如果只显示一个频率数字还不足以形成完整的使用链路。用户需要先知道目标弦应该是什么声音调音过程中需要看到当前偏差离开页面后又希望能回看本次结果。对于一个本地优先的 HarmonyOS 应用这三件事分别落在参考音播放、页面会话状态和 Preferences 持久化上。它们之间确实存在闭环但这个闭环不是“一个服务包办全部”而是多个对象通过明确的调用点衔接起来。本文只讨论原生 ArkTS 1.0.7 工程。涉及的主线文件包括entry/src/main/ets/services/ReferenceToneService.ets、entry/src/main/ets/pages/TunerPage.ets、entry/src/main/ets/services/HistoryService.ets、entry/src/main/ets/services/SettingsService.ets、entry/src/main/ets/pages/Index.ets和entry/src/main/ets/entryability/EntryAbility.ets。文中的运行界面图来自项目留存的早期原生截图只能说明当时页面曾经运行不能替代当前源码版本的重新安装与实机音频验收。一、先把“闭环”拆成三个相互独立的职责这条链路可以分成三层。第一层是实时协调EntryAbility先在AppStorage中建立主题、A4 基准、当前乐器、调音模式等键页面通过StorageLink订阅其中一部分。第二层是即时音频TunerPage根据当前乐器、所选弦和 A4 基准计算目标频率再把“频率、时长、乐器名”交给ReferenceToneService。第三层是持久化只有达到门槛的调音会话才被组装成TuningSession并由HistoryService写入 Preferences。因此AppStorage不是数据库HistoryService也不负责播放参考音。一个更准确的数据流是SettingsService / 页面操作 ↓ 写入或联动 AppStoragecurrentInstrument、a4Reference、tunerMode 等 ↓ StorageLink / Watch TunerPage计算目标频率、播放参考音、累计页面轮询样本 ↓ 达到保存门槛并触发生命周期保存点 HistoryServicePreferences 中的 sessions JSON ↓ 页面重新读取 HistoryPage / ProfilePage列表、总次数、平均值、累计时长这里的“闭环”是可追踪的产品数据流不是事务数据库意义上的强一致性。设置写入、参考音播放和历史保存都是异步操作当前页面普遍采用记录错误或忽略回调错误的方式没有提供跨服务事务、失败回滚或云同步。二、参考音入口页面只提交频率、时长和乐器名TunerPage不直接创建AudioRenderer。它先从当前乐器的弦列表中拿到目标音再调用FrequencyUtils.noteToFreq把当前a4Reference带入频率换算。这样 A4 从默认 440 Hz 改为其他值时调音偏差计算和传给参考音服务的目标频率使用同一入口。entry/src/main/ets/pages/TunerPage.ets中的真实计算函数如下private getTargetFrequency(target: StringNote): number { if (target.note.length 0 || target.frequency 0) { return 0; } return FrequencyUtils.noteToFreq(target.note, target.octave, this.a4Reference); }页面点击“播放琴音”后调用参数固定为目标频率、2000 ms 和当前乐器名。切换乐器时onInstrumentNameChange会先尝试保存当前会话再加载新乐器、重置统计若此前参考音正在播放还会停止并按新乐器重启。A4 基准变化时会重置会话并重新启动参考音。手动选择另一根弦时同样会重置页面统计并在必要时重播。这张流程图表达的是当前源码调用关系。它不能证明扬声器音量、频响、底噪或听感已经在某一台设备上通过。AudioRenderer.start()与write()成功属于软件调用结果真实音色仍需设备扬声器和录音对照。三、四个小提琴 PCM 是唯一的样本直读分支参考音服务不是对所有乐器都读取采样文件。getPlaybackBuffer先调用getViolinSampleName只有instrumentName严格等于Violin时后者才会在 G3、D4、A4、E5 四个基准频率中寻找最近项。四个频率分别是 196.00、293.66、440.00 和 659.26 Hz允许的最近距离是 80 cents。满足条件后才返回violin_g3.pcm、violin_d4.pcm、violin_a4.pcm或violin_e5.pcm。entry/src/main/ets/services/ReferenceToneService.ets的分流代码如下private async getPlaybackBuffer(frequency: number, durationMs: number, instrumentName: string): PromiseArrayBuffer { const sampleName this.getViolinSampleName(frequency, instrumentName); if (sampleName.length 0) { const sampleBuffer await this.loadRawPcmSample(sampleName); if (sampleBuffer ! null) { return sampleBuffer; } } return this.getInstrumentBuffer(frequency, durationMs, instrumentName); }四个 PCM 文件当前每个都是 176400 字节。按照服务固定的 44.1 kHz、单声道、S16LE 格式计算44100 × 2 字节 × 2 秒 176400 字节与页面传入的 2000 ms 相符。文件由resourceManager.getRawFileContent读取缓存键采用raw:文件名读取失败会返回null然后退回程序合成分支。这里还有一个容易被忽略的边界A4 基准参与了目标频率计算但 PCM 本身并没有被重采样或变调。若目标频率仍落在固定样本的 80 cents 范围内服务会播放原始 PCM超过该范围则进入合成分支。因此不能把“修改 A4 后目标频率已更新”写成“四个固定 PCM 已按新 A4 实时变调”。资源目录中还存在rawfile/uiowa_strings其中保存了小提琴、中提琴、大提琴和低音提琴的 Iowa AIFF 文件及清单。但当前 ArkTS 业务没有读取这些.aif文件也没有 AIFF 解码路径ReferenceToneService的样本文件名数组只有上述四个 PCM。AIFF 目前是资源储备不是运行时参考音来源。四、拨弦与其他拉弦靠程序合成吉他、尤克里里和班卓琴由isPluckedInstrument识别为拨弦乐器。它们的合成包含十个谐波、轻微失谐、起音噪声以及按乐器区分的衰减时间默认约 1.7 秒尤克里里约 1.0 秒班卓琴约 0.75 秒。包络使用约 8 ms 的起音和约 80 ms 的释放段目的是形成“快速起音、逐步衰减”的拨弦轮廓。中提琴、大提琴和低音提琴走拉弦合成小提琴在没有命中四个 PCM 或资源读取失败时也会回到这一分支。拉弦分支组合多次谐波、轻微颤音、弓噪声、松香脉冲、起音/释放包络和两级琴体滤波不同乐器使用不同的亮度、温暖度、谐波数、颤音深度和输出增益。这些参数能够让波形不再只是单一正弦但它仍然是程序生成的近似参考音不能冒充真实乐器录音。合成缓冲区同样固定为 44.1 kHz、16 位单声道。缓存键由乐器名、四舍五入到百分之一 Hz 的频率和时长组成缓存项超过 16 时当前实现会整体清空缓存再写入新项。这个缓存减少重复生成开销但源码中没有命中率、内存峰值或播放延迟基准因此文章不推导性能结论。五、playToken 有过期检查但仍存在 renderer 竞态参考音播放涉及资源读取、创建 renderer、启动、写入和延迟停止。用户可能在这些异步步骤之间迅速切弦、切乐器、修改 A4 或点击停止。如果旧调用晚于新调用返回旧回调就可能误关掉新的 renderer或者把页面状态重新写成“正在播放”。ReferenceToneService用递增的playToken标记播放代次。每次新播放先取得新 token每次stop()也先递增 token。创建 renderer 后和start()后都会比较 token定时器到期时也只有 token 仍然相等才调用stop()。这些检查表达了“过期流程不应继续完成”的意图但不能直接推导出并发播放已经安全。entry/src/main/ets/services/ReferenceToneService.ets中的核心片段是async playTone(frequency: number, durationMs: number 2000, instrumentName: string ): Promisevoid { if (this._isPlaying || this.renderer ! null) { await this.stop(); } const token this.playToken 1; this.playToken token; try { const options: audio.AudioRendererOptions { streamInfo: { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW }, rendererInfo: { usage: audio.StreamUsage.STREAM_USAGE_MEDIA, rendererFlags: 0 } }; const buffer await this.getPlaybackBuffer(frequency, durationMs, instrumentName); this.renderer await audio.createAudioRenderer(options); if (this.playToken ! token) { await this.releaseRenderer(); this._isPlaying false; return; } this._isPlaying true; await this.renderer.start(); if (this.playToken ! token) { await this.releaseRenderer(); this._isPlaying false; return; } await this.renderer.write(buffer); return new Promisevoid((resolve) { setTimeout(async () { if (this._isPlaying this.playToken token) { await this.stop(); } resolve(); }, durationMs); }); } catch (err) { console.error([ReferenceToneService] playTone error: JSON.stringify(err)); await this.releaseRenderer(); this._isPlaying false; } }停止函数本身很短却是取消语义的关键async stop(): Promisevoid { this.playToken this.playToken 1; await this.releaseRenderer(); this._isPlaying false; }关键问题是this.renderer await createAudioRenderer()在 token 校验之前写入共享成员releaseRenderer()释放的也是调用时共享成员指向的对象而不是某次调用自己的局部 renderer。若两个playTone()在异步创建阶段交错旧流程可能覆盖新 renderer 的引用随后旧 token 校验失败时又可能释放此刻共享成员中的新对象。因此playToken提供了过期检查却没有建立 renderer 的逐调用所有权不能写成“已经挡住旧流程操作当前 renderer”。releaseRenderer()会先把成员保存到局部变量并把共享成员置为null再尝试stop()和release()。其中stop()异常被空catch吞掉只有release()异常会输出日志。它让单次释放路径尽量收尾但不消除上面的交错竞态后续若修复应先把新 renderer 保存在局部变量中token 通过后再发布到共享成员并且只释放本次调用拥有的对象。六、AppStorage 是实时会话总线不是持久化数据库EntryAbility.onCreate使用AppStorage.setOrCreate建立默认值包括currentTheme、themePreference、a4Reference、displayUnit、pointerSensitivity、autoSleep、sampleRate、bitDepth、inputSource、currentInstrument和tunerMode。这一步让页面在 Preferences 尚未加载时也有可用初值。窗口内容加载成功后SettingsService.init打开名为tuner_settings的 Preferences并调用loadToAppStorage。主题偏好、A4、显示单位、灵敏度、自动息屏、采样率、位深、输入源和当前乐器会从持久化值恢复到 AppStorage。页面通过StorageLink观察其中需要的键形成进程内的实时联动。以 A4 为例设置页先更新自己的StorageLink再调用服务。服务同步更新 AppStorage并把相同数值写入 Preferencesasync setA4Reference(value: number): Promisevoid { AppStorage.setOrCreate(AppConstants.KEY_A4_REF, value); await this.writeAndSync(AppConstants.KEY_A4_REF, value); }这条路径使TunerPage的Watch(onA4ReferenceChange)能立即收到变化同时在下次启动时恢复 A4。需要注意当前实现是“先更新内存再异步持久化”若prefs.put或flush失败服务记录错误但不会把 AppStorage 回滚也不会向页面展示保存失败状态。因此它是务实的本地设置同步不是具备事务保证的双写系统。七、SettingsService 的真实边界保存字段不等于全部功能已接通当前设置页真正提供操作入口的项目包括 A4 参考频率、显示单位、指针灵敏度、主题偏好和调音时保持屏幕常亮。A4 会进入FrequencyUtils.noteToFreq主题会影响页面配色autoSleep会被调音页读取并调用setWindowKeepScreenOn。这些可以从消费者代码中找到闭环。但是不能把 Preferences 中出现的每个字段都写成“已生效配置”。displayUnit和pointerSensitivity当前只在设置页与SettingsService中读写没有被调音计算或指针绘制逻辑消费它们目前更接近已保存的 UI 选项。sampleRate虽然有 getter/setter 和恢复逻辑但AudioService的采集参数仍直接固定为 44100 Hz、S16LE、单声道设置页也没有采样率操作入口。bitDepth和inputSource会从 Preferences 恢复到 AppStorage但当前服务没有对应 setter采音配置同样没有动态读取它们。当前乐器也需要单独说明。SettingsService提供setCurrentInstrument但InstrumentSelectPage的实际选择路径直接修改StorageLink(currentInstrument)音乐库也直接写 AppStorage并没有调用该持久化 setter。因此可以确认当前乐器会在同一进程内驱动各页面更新不能据此保证每次选择都已经写回tuner_settings。tunerMode目前也是 AppStorage 会话状态没有出现在 SettingsService 的持久化流程中。所以本文所说的 AppStorage 闭环准确含义是“页面间状态协调与部分设置持久化已经连通”它不等于“设置页所有选项已经影响底层算法”也不等于“每一个 AppStorage 键都会跨重启恢复”。八、调音会话什么时候才算值得保存调音页不会把每次进入页面都立即记成历史。保存函数检查三个条件当前会话尚未保存、accuracyCount至少为 10、elapsedSeconds至少为 2。这里必须保留变量的真实语义accuracyCount是getSmoothedFrequency() 0时增加的页面轮询计数不等同于 10 个独立且当次均通过清晰度门槛的采集帧。原因在于页面会把清晰度不足的结果以 0 写入 5 项窗口而getSmoothedFrequency()又会丢弃 0 并对剩余旧值取中值。只要窗口里还留有先前的非零频率当前原始结果即使没有通过门槛页面仍可能得到非零hz并增加accuracyCount。因此源码能确认的是“至少 10 次非零平滑频率轮询 至少 2 秒”不能把常量名直接翻译成“10 个有效音频帧”。entry/src/main/ets/pages/TunerPage.ets中的门槛与记录组装如下private saveCurrentSession(): void { if (this.sessionSaved || this.accuracyCount this.MIN_SESSION_RECORD_FRAMES || this.elapsedSeconds this.MIN_SESSION_RECORD_SECONDS) { return; } const session new TuningSession(); session.id HistoryService.getInstance().generateSessionId(); session.timestamp Date.now(); session.instrumentName this.currentInstrument.name; session.tuning this.currentInstrument.tuning; session.accuracy this.sessionAccuracy; session.duration this.elapsedSeconds; session.noiseLevel this.getInputLevelText(); const stringStats: StringStat[] []; for (let i 0; i this.currentInstrument.strings.length; i) { if (i this.stringAccuracyCounts.length this.stringAccuracyCounts[i] 0) { const target this.currentInstrument.strings[i]; const stat new StringStat(); stat.note target.note; stat.octave target.octave; stat.accuracy Math.round(this.stringAccuracySums[i] / this.stringAccuracyCounts[i]); stringStats.push(stat); } } session.stringStats stringStats; this.sessionSaved true; HistoryService.getInstance().saveSession(session).catch((_e: Error) {}); }后续循环只为stringAccuracyCounts[i] 0的弦生成StringStat保存音名、八度和该弦平均准确度。会话级准确度是这些非零平滑频率轮询所得准确度的算术平均单次准确度由音分绝对值映射到 0100偏差达到 50 cents 时记为 0。noiseLevel字段来自页面的输入电平文本不是经过校准的声压级测量。保存触发点也不是“每两秒自动保存”。当前代码会在调音页消失、页面失去活动状态、乐器名变化、进入乐器选择页以及切换自动/手动模式前调用saveCurrentSession。sessionSaved在发起异步保存前被置为true避免同一会话重复写入。A4 变化和直接选择另一根弦会重置会话但当前对应处理函数没有先保存这也是不能笼统宣称“所有状态切换都会保留历史”的原因。另一个生命周期边界是aboutToAppear()和重新激活处理只加载乐器或启动音频并没有调用resetSession()。页面失活保存后再回来旧计数和sessionSaved可能继续保留因此流程图从“页面轮询”开始而不能画成“进入调音页必然 resetSession”。这也是当前源码值得后续补测和修正的状态问题。九、HistoryService 用 Preferences JSON 保存最多 200 条HistoryService使用单例持有名为tuner_history的 Preferences全部会话序列化为一个 JSON 字符串键名是sessions。保存时先读取已有数组把新会话插入头部数量超过MAX_SESSIONS 200时弹出末尾最旧的一条然后执行put和flush。真实保存代码如下async saveSession(session: TuningSession): Promisevoid { if (this.prefs null) { return; } try { const sessions await this.getSessions(); sessions.unshift(session); if (sessions.length MAX_SESSIONS) { sessions.pop(); } await this.prefs.put(KEY_SESSIONS, JSON.stringify(sessions)); await this.prefs.flush(); } catch (err) { console.error(HistoryService saveSession failed: JSON.stringify(err)); } }读取时并不是把反序列化结果直接强制转换后交给页面而是逐项重新构造TuningSession和StringStat。统计服务再基于读取结果计算总次数、所有会话准确度的平均值、累计时长、最近最多 10 次的趋势以及按“音名八度”聚合的单弦准确度。这里没有数据库索引、分页或增量聚合200 条上限使一次性 JSON 读写保持在可控范围但源码没有给出容量压测结果。clearAll会把sessions写成字符串[]并 flush。它和“重置设置”是两条分开的操作SettingsPage 明确提示重置设置不影响历史而清空历史需要独立确认。二者分别使用tuner_settings和tuner_history避免一个重置按钮同时抹掉调音记录。十、历史页与“我的”页如何读回同一批记录HistoryPage在aboutToAppear和refreshTick变化时调用loadData分别读取会话列表与统计结果。Index在切换到历史标签时递增historyRefreshTick切换到“我的”标签时递增profileRefreshTick。ProfilePage监听后者并重新调用HistoryService.getStats。因此两个页面展示的是同一份 Preferences 记录派生出的不同视图不依赖各自手工递增计数。历史页会展示总次数、平均值、累计时长和会话卡片“我的”页展示调音次数、平均准确率和累计时长并提供进入历史与设置的入口。下面的截图同样是历史运行证据不是当前 1.0.7 构建的本轮实机复测。这种刷新方式比在多个页面里分别维护计数更可靠但仍有异步时序边界保存会话是 Promise页面调用处没有等待保存完成如果用户极快切到历史页首次读取理论上可能早于 flush 完成。当前源码没有保存完成事件或统一事务队列因此本文只确认“页面会在进入时重新读取”不宣称任何时序下都能零延迟看到最新记录。十一、源码闭环不等于实机音色闭环当前源码能够确认的事实包括参考音使用 44.1 kHz、单声道、S16LE 的AudioRenderer小提琴四个标准弦具备四个 2 秒 PCM 的优先读取分支拨弦和其他拉弦使用程序合成playToken在创建、启动与延迟停止阶段执行过期检查但共享 renderer 仍有交错竞态历史保存门槛是accuracyCount 10且至少 2 秒这个计数来自非零平滑频率轮询而非独立有效采集帧HistoryService 以 Preferences JSON 保存最多 200 条SettingsService 负责部分设置在 Preferences 与 AppStorage 之间同步。当前源码不能支持的说法包括Iowa AIFF 已参与播放、所有七种乐器都使用真实采样、A4 修改会对固定 PCM 做变调、所有设置项都已接入底层音频、历史保存具有事务一致性、参考音达到某个响度或音色评分以及最新版本已在真机完成扬声器和麦克风联合回归。系列验证基线记录了原生工程assembleHap成功但当前 HDC 设备列表为空。构建成功能说明 ArkTS 编译和 HAP 打包链路通过不能替代安装、启动、扬声器播放、快速切弦无残音、应用重启后设置恢复和历史记录重载等验证。本篇两张界面图属于早期运行素材四张技术图依据当前源码绘制两类图片承担的证据职责不同。十二、把这条链路压缩成可复查清单复查参考音时先确认页面是否通过noteToFreq带入 A4再检查getViolinSampleName是否只对小提琴开放四个 PCM最后确认未命中时进入合成。复查并发播放时不能只看playToken比较还要追踪this.renderer在每个await前后的所有权。复查历史时要把accuracyCount还原为页面轮询计数再看秒数门槛、实际保存触发点、Preferences 的 200 条截断逻辑和页面重新读取路径。复查状态边界时要逐个区分“AppStorage 中即时可见”“SettingsService 已写入 Preferences”“业务消费者已经读取生效”三件事。A4、主题和保持常亮已经存在明确消费者显示单位、指针灵敏度、采样率、位深、输入源和当前乐器则各有不同程度的接通状态不能用一个“设置已保存”概括。这个项目真正有价值的地方不是堆了多少设置项而是已经形成了可以继续完善的服务边界参考音服务专注 renderer 与波形来源调音页负责会话统计和生命周期历史服务负责本地记录SettingsService 负责部分持久设置AppStorage 负责页面间实时协调。把尚未接通的字段和尚未完成的实机验证明确写出来反而让后续增加 AIFF 解码、采样变调、保存完成通知或设置消费者时有清晰的落点。