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 解码、采样变调、保存完成通知或设置消费者时有清晰的落点。

相关推荐
木合塔尔 麦麦提1 小时前
鸿蒙关系数据库代码案例
华为·harmonyos
云_杰1 小时前
鸿蒙截图工具开发实战 02:截屏权限 CUSTOM\_SCREEN\_CAPTURE——"检查"和"申请"为什么必须是两个函数
华为·harmonyos
nullregedit2 小时前
HarmonyOS 弦乐调音器开发实战 02:AudioCapturer 与 NSDF 怎样完成实时音高检测
harmonyos·arkts·数字信号处理·audiocapturer·乐器调音
如此风景2 小时前
HarmonyOS应用开发-Navigation 路由表详解
harmonyos
云端漫步19873 小时前
HarmonyOS NEXT AI 智能生活助手:AI 待办事项生成
人工智能·华为·生活·harmonyos
烛衔溟3 小时前
HarmonyOS 网络连接 —— HTTP 请求、Axios 与 Socket 通信
http·华为·harmonyos
懿路向前4 小时前
【HarmonyOS学习笔记】2026-08-04 | 端插件新装饰器与CreateRecord全链路验证
笔记·学习·harmonyos
云端漫步19874 小时前
HarmonyOS NEXT AI 智能生活助手:AI 翻译助手
人工智能·华为·生活·harmonyos
世人万千丶12 小时前
鸿蒙日志体系高级应用:HiLog分级输出/隐私脱敏/远程日志采集/线上问题精准溯源方案
学习·harmonyos·鸿蒙