当前位置: 首页 > news >正文

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

调音器如果只显示一个频率数字,还不足以形成完整的使用链路。用户需要先知道目标弦应该是什么声音,调音过程中需要看到当前偏差,离开页面后又希望能回看本次结果。对于一个本地优先的 HarmonyOS 应用,这三件事分别落在参考音播放、页面会话状态和 Preferences 持久化上。它们之间确实存在闭环,但这个闭环不是“一个服务包办全部”,而是多个对象通过明确的调用点衔接起来。

本文只讨论原生 ArkTS 1.0.7 工程。涉及的主线文件包括entry/src/main/ets/services/ReferenceToneService.etsentry/src/main/ets/pages/TunerPage.etsentry/src/main/ets/services/HistoryService.etsentry/src/main/ets/services/SettingsService.etsentry/src/main/ets/pages/Index.etsentry/src/main/ets/entryability/EntryAbility.ets。文中的运行界面图来自项目留存的早期原生截图,只能说明当时页面曾经运行,不能替代当前源码版本的重新安装与实机音频验收。

一、先把“闭环”拆成三个相互独立的职责

这条链路可以分成三层。第一层是实时协调:EntryAbility先在AppStorage中建立主题、A4 基准、当前乐器、调音模式等键,页面通过@StorageLink订阅其中一部分。第二层是即时音频:TunerPage根据当前乐器、所选弦和 A4 基准计算目标频率,再把“频率、时长、乐器名”交给ReferenceToneService。第三层是持久化:只有达到门槛的调音会话才被组装成TuningSession,并由HistoryService写入 Preferences。

因此,AppStorage不是数据库,HistoryService也不负责播放参考音。一个更准确的数据流是:

SettingsService / 页面操作 ↓ 写入或联动 AppStorage:currentInstrument、a4Reference、tunerMode 等 ↓ @StorageLink / @Watch TunerPage:计算目标频率、播放参考音、累计页面轮询样本 ↓ 达到保存门槛并触发生命周期保存点 HistoryService:Preferences 中的 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.pcmviolin_d4.pcmviolin_a4.pcmviolin_e5.pcm

entry/src/main/ets/services/ReferenceToneService.ets的分流代码如下:

private async getPlaybackBuffer(frequency: number, durationMs: number, instrumentName: string): Promise<ArrayBuffer> { 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 = ''): Promise<void> { 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 Promise<void>((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(): Promise<void> { 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建立默认值,包括currentThemethemePreferencea4ReferencedisplayUnitpointerSensitivityautoSleepsampleRatebitDepthinputSourcecurrentInstrumenttunerMode。这一步让页面在 Preferences 尚未加载时也有可用初值。

窗口内容加载成功后,SettingsService.init打开名为tuner_settings的 Preferences,并调用loadToAppStorage。主题偏好、A4、显示单位、灵敏度、自动息屏、采样率、位深、输入源和当前乐器会从持久化值恢复到 AppStorage。页面通过@StorageLink观察其中需要的键,形成进程内的实时联动。

以 A4 为例,设置页先更新自己的@StorageLink,再调用服务。服务同步更新 AppStorage,并把相同数值写入 Preferences:

async setA4Reference(value: number): Promise<void> { AppStorage.setOrCreate(AppConstants.KEY_A4_REF, value); await this.writeAndSync(AppConstants.KEY_A4_REF, value); }

这条路径使TunerPage@Watch('onA4ReferenceChange')能立即收到变化,同时在下次启动时恢复 A4。需要注意,当前实现是“先更新内存,再异步持久化”;若prefs.putflush失败,服务记录错误但不会把 AppStorage 回滚,也不会向页面展示保存失败状态。因此它是务实的本地设置同步,不是具备事务保证的双写系统。

七、SettingsService 的真实边界:保存字段不等于全部功能已接通

当前设置页真正提供操作入口的项目包括 A4 参考频率、显示单位、指针灵敏度、主题偏好和调音时保持屏幕常亮。A4 会进入FrequencyUtils.noteToFreq,主题会影响页面配色,autoSleep会被调音页读取并调用setWindowKeepScreenOn。这些可以从消费者代码中找到闭环。

但是不能把 Preferences 中出现的每个字段都写成“已生效配置”。displayUnitpointerSensitivity当前只在设置页与SettingsService中读写,没有被调音计算或指针绘制逻辑消费;它们目前更接近已保存的 UI 选项。sampleRate虽然有 getter/setter 和恢复逻辑,但AudioService的采集参数仍直接固定为 44100 Hz、S16LE、单声道,设置页也没有采样率操作入口。bitDepthinputSource会从 Preferences 恢复到 AppStorage,但当前服务没有对应 setter,采音配置同样没有动态读取它们。

当前乐器也需要单独说明。SettingsService提供setCurrentInstrument,但InstrumentSelectPage的实际选择路径直接修改@StorageLink('currentInstrument'),音乐库也直接写 AppStorage,并没有调用该持久化 setter。因此可以确认当前乐器会在同一进程内驱动各页面更新,不能据此保证每次选择都已经写回tuner_settingstunerMode目前也是 AppStorage 会话状态,没有出现在 SettingsService 的持久化流程中。

所以本文所说的 AppStorage 闭环,准确含义是“页面间状态协调与部分设置持久化已经连通”;它不等于“设置页所有选项已经影响底层算法”,也不等于“每一个 AppStorage 键都会跨重启恢复”。

八、调音会话什么时候才算值得保存

调音页不会把每次进入页面都立即记成历史。保存函数检查三个条件:当前会话尚未保存、accuracyCount至少为 10、elapsedSeconds至少为 2。这里必须保留变量的真实语义:accuracyCountgetSmoothedFrequency() > 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,保存音名、八度和该弦平均准确度。会话级准确度是这些非零平滑频率轮询所得准确度的算术平均,单次准确度由音分绝对值映射到 0~100;偏差达到 50 cents 时记为 0。noiseLevel字段来自页面的输入电平文本,不是经过校准的声压级测量。

保存触发点也不是“每两秒自动保存”。当前代码会在调音页消失、页面失去活动状态、乐器名变化、进入乐器选择页以及切换自动/手动模式前调用saveCurrentSessionsessionSaved在发起异步保存前被置为true,避免同一会话重复写入。A4 变化和直接选择另一根弦会重置会话,但当前对应处理函数没有先保存;这也是不能笼统宣称“所有状态切换都会保留历史”的原因。

另一个生命周期边界是:aboutToAppear()和重新激活处理只加载乐器或启动音频,并没有调用resetSession()。页面失活保存后再回来,旧计数和sessionSaved可能继续保留;因此流程图从“页面轮询”开始,而不能画成“进入调音页必然 resetSession”。这也是当前源码值得后续补测和修正的状态问题。

九、HistoryService 用 Preferences JSON 保存最多 200 条

HistoryService使用单例持有名为tuner_history的 Preferences,全部会话序列化为一个 JSON 字符串,键名是sessions。保存时先读取已有数组,把新会话插入头部;数量超过MAX_SESSIONS = 200时弹出末尾最旧的一条,然后执行putflush

真实保存代码如下:

async saveSession(session: TuningSession): Promise<void> { 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)); } }

读取时并不是把反序列化结果直接强制转换后交给页面,而是逐项重新构造TuningSessionStringStat。统计服务再基于读取结果计算总次数、所有会话准确度的平均值、累计时长、最近最多 10 次的趋势,以及按“音名+八度”聚合的单弦准确度。这里没有数据库索引、分页或增量聚合;200 条上限使一次性 JSON 读写保持在可控范围,但源码没有给出容量压测结果。

clearAll会把sessions写成字符串[]并 flush。它和“重置设置”是两条分开的操作:SettingsPage 明确提示重置设置不影响历史,而清空历史需要独立确认。二者分别使用tuner_settingstuner_history,避免一个重置按钮同时抹掉调音记录。

十、历史页与“我的”页如何读回同一批记录

HistoryPageaboutToAppearrefreshTick变化时调用loadData,分别读取会话列表与统计结果。Index在切换到历史标签时递增historyRefreshTick,切换到“我的”标签时递增profileRefreshTickProfilePage监听后者并重新调用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 解码、采样变调、保存完成通知或设置消费者时有清晰的落点。

http://www.jsqmd.com/news/1329516/

相关文章:

  • 【GNSS】24年,GAO一直在说同一件事
  • 如何理解熔盐堆的未来挑战?Transatomic Reactor27开源项目中的10大技术难题深度剖析
  • 199、TinyML实战项目:智能娱乐与游戏交互
  • 20款降AI率平台实测:论文降AIGC率靠谱选择指南
  • Go⼆进制瘦⾝编译:容器镜像体积减少70%实操
  • 2026 年昆山市住宅防水修缮行业白皮书 - 速达同城防水
  • 上海管道疏通哪家好?2026年上海本地靠谱疏通师傅电话与价格参考 - 园子一号
  • HarmonyOS 弦乐调音器开发实战 04:Flutter 页面如何通过 ArkTS 插件接入系统音频
  • 江门管道疏通哪家好?2026年江门本地靠谱疏通师傅电话与价格参考 - 园子一号
  • 如何用Python-on-Whales快速上手Docker?5分钟入门教程
  • 2026年Q3制造业装备供应商选型:连云港元丰机械制造有限公司的市场定位与技术纵深分析 - 优企名品
  • Mmock Docker部署教程:3步实现跨平台HTTP模拟服务
  • 2026年重庆小程序App开发必看!这8家本地服务商,精准解决您的定制需求 - 软件测评师
  • 泉州管道疏通哪家好?2026年泉州本地靠谱疏通师傅电话与价格参考 - 园子一号
  • 终极指南:在VS Code中直接绘制专业图表,告别工具切换烦恼
  • AI Agent安全架构设计:基于最小权限原则的三层防御体系实践
  • 德鲁克书籍和作品那么多,真正适合入门的是这一本
  • AI驱动的用户留存分析实战手册(2024企业级SOP全公开)
  • 如何利用Architectural Metapatterns构建可进化的软件架构:从单体到微服务的转型指南
  • 为什么选择K-EXAONE-2.0-750B-A37B?5大核心优势揭秘:推理加速、多语言支持与安全防护
  • HarmonyOS 弦乐调音器开发实战 05:UIAbility 如何串起启动、窗口与配置更新
  • 濮阳管道疏通哪家好?2026年濮阳本地靠谱疏通师傅电话与价格参考 - 园子一号
  • 旧衣服回收平台怎么下单?2026年上门回收避坑指南 - 快递物流资讯
  • 三轮车怎么托运邮寄?2026年完整攻略+避坑指南 - 快递物流资讯
  • 歌词文本挖掘:MSongsDB Lyrics任务中的词袋模型与情感分析
  • 2026年选择约克中央空调的5个关键考量
  • 论文工具不用瞎选!Gradpaper凭实力成为行业天花板
  • Tesla-Menu终极指南:Nintendo Switch最强叠加菜单完整使用教程
  • 2026年液下泵厂家**单,WSY立式玻璃钢液下泵/FY液下泵/FYS氟塑料合金泵,槽内液下泵专业实力深度解析 - 卓企推荐
  • 套壳LogMiner的DBZ与Flink cdc方案,永远绕不开这个死结