HarmonyOS趣味相机实战第29篇:AudioRenderer合成快门声、并发门闩与资源释放

HarmonyOS趣味相机实战第29篇:AudioRenderer合成快门声、并发门闩与资源释放

摘要

快门声只有一百多毫秒,却涉及音频流格式、PCM 字节序、播放器生命周期、重复点击、静音策略和相机回调时序。直接播放一个资源文件看似简单,但会增加资源管理和首次解码延迟;每次拍照新建 AudioRenderer 又必须保证任何异常路径都能停止并释放,否则连续拍摄后可能出现声音叠加、音频对象泄漏或拍照按钮被播放任务拖慢。

本文基于 D:/APP/1quweixiangji 新增的 ShutterSoundService.ets,复盘 44.1kHz 单声道 S16LE PCM 的程序化生成、双频点击音、衰减包络、playing 并发门闩以及 try/finally 资源闭环。同时分析它与 CameraKit 拍照结果、ArkUI 闪屏反馈和用户开关之间的边界,给出真机测试与后续优化方案。

工程背景与源码定位

文件 作用
entry/src/main/ets/service/ShutterSoundService.ets 创建 AudioRenderer、合成并播放 PCM
entry/src/main/ets/pages/Index.ets 拍照成功后触发声音和视觉反馈
entry/src/main/ets/service/CameraPreviewService.ets 执行真实 PhotoOutput 拍照
entry/src/main/ets/model/DecorationModels.ets 相机结果与页面领域模型
entry/src/main/module.json5 模块配置与权限边界

环境与音频参数

参数 当前值 含义
SDK HarmonyOS 6.0.2(22) 工程目标版本
API @ohos.multimedia.audio 音频渲染能力
采样率 44100 Hz 每秒采样数
声道 单声道 快门提示无需立体声
样本格式 S16LE 16位有符号小端 PCM
编码 RAW 直接写入原始采样
时长 0.16 秒 短促点击反馈
用途 STREAM_USAGE_MUSIC 当前音频流用途

一、快门声应与真实拍照结果绑定

页面调用相机后再触发反馈:

ts 复制代码
const captureState: CameraCaptureState =
  await CameraPreviewService.capturePhoto(
    this.captureQualityPreference(), false);

if (this.shutterSoundEnabled) {
  this.triggerShutterFeedback();
}

这一位置意味着反馈发生在拍照调用返回之后,而不是用户按下按钮的瞬间。产品需要明确声音代表什么:

  • 若代表"已接收点击",应在进入 captureNow() 时播放。
  • 若代表"系统已接受拍照请求",应在 PhotoOutput.capture() 成功后播放。
  • 若代表"真实照片已可用",应在 photoAvailable 确认后播放。

趣味相机更适合绑定真实结果,避免相机不可用时仍发出成功快门声。失败场景可以只更新状态文案,不制造"已经拍到"的错觉。

二、服务层隔离音频实现

页面只调用:

ts 复制代码
ShutterSoundService.play();

AudioRenderer 的创建、格式、缓冲区和释放都留在服务层。这样页面不需要知道 PCM 格式,也不会在多个按钮里复制音频生命周期代码。

服务接口返回 Promise<void>,页面当前不等待它:

ts 复制代码
private triggerShutterFeedback(): void {
  this.shutterFeedbackVisible = true;
  ShutterSoundService.play();
  setTimeout(() => {
    this.shutterFeedbackVisible = false;
  }, 650);
}

这是有意的非阻塞设计:声音播放不能延迟结果预览。服务内部必须自行捕获异常,否则未处理的 Promise 拒绝会污染页面运行日志。

三、AudioRendererOptions必须与缓冲区一致

项目配置:

ts 复制代码
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_MUSIC,
    rendererFlags: 0
  }
};

四个流参数必须与 ArrayBuffer 的真实布局一致。S16LE 表示每个采样占 2 字节、低位字节在前;若用浮点值直接写入或按大端保存,播放结果会变成噪声。

四、采样数量决定时长

常量:

ts 复制代码
const SAMPLE_RATE: number = 44100;
const DURATION_SECONDS: number = 0.16;

采样数:

ts 复制代码
const sampleCount: number =
  Math.floor(SAMPLE_RATE * DURATION_SECONDS);

本例得到 7056 个采样。单声道 S16LE 每个采样 2 字节,因此缓冲区大小是 14112 字节:

ts 复制代码
const buffer: ArrayBuffer =
  new ArrayBuffer(sampleCount * 2);

如果改成双声道,缓冲区布局和写入步长都必须同时变化,不能只改 channels

五、用DataView写入小端PCM

ts 复制代码
const view: DataView = new DataView(buffer);

for (let index: number = 0;
     index < sampleCount;
     index++) {
  const value: number = createSample(index, sampleCount);
  view.setInt16(
    index * 2,
    Math.floor(value * 32767),
    true
  );
}

第三个参数 true 表示小端,与 S16LE 对应。归一化波形先限制到 [-1, 1],再乘以 32767 转成 16 位整数,可避免溢出绕回造成爆音。

六、衰减包络避免突然截断

项目使用二次幂衰减:

ts 复制代码
const progress: number = index / sampleCount;
const envelope: number =
  Math.pow(1 - progress, 2.2);

没有包络时,正弦波在任意相位突然停止会形成不连续边缘,听起来像尖锐爆点。envelope 让振幅从强到弱逐渐归零,适合短促机械点击。

为了让起点也更平滑,可加入极短 attack:

ts 复制代码
const attackSamples: number = Math.floor(SAMPLE_RATE * 0.003);
const attack: number = Math.min(1, index / attackSamples);
const envelope: number = attack * Math.pow(1 - progress, 2.2);

3ms 渐入足以降低硬切,又不会让快门声显得迟钝。

七、多频叠加构造机械质感

ts 复制代码
const time: number = index / SAMPLE_RATE;
const secondTap: number =
  index > sampleCount * 0.42 ? 0.55 : 0;

const tone: number =
  Math.sin(2 * Math.PI * 1750 * time) * envelope +
  Math.sin(2 * Math.PI * 920 * time) * envelope * 0.45 +
  Math.sin(2 * Math.PI * 2300 * time) * envelope * secondTap;

1750Hz 提供清晰点击,920Hz 增加厚度,后半段 2300Hz 模拟第二次机械触点。它不是录音,而是运行时生成的确定性波形,避免携带外部音频文件。

最后限制振幅:

ts 复制代码
const value: number = Math.max(
  -1, Math.min(1, tone * 0.72));

系数 0.72 留出混合余量。多个正弦波直接相加可能超过 1,不裁剪会转换溢出。

八、完整播放生命周期必须顺序执行

ts 复制代码
renderer = await audio.createAudioRenderer(options);
await renderer.start();
await renderer.write(ShutterSoundService.createClickBuffer());
await renderer.drain();
await renderer.stop();

各步骤职责:

步骤 作用
create 申请音频渲染资源
start 进入可写播放状态
write 提交 PCM 缓冲区
drain 等待已写数据播放完
stop 停止当前流
release 释放系统资源

若不等待 drain() 就 stop,短音效可能被截断;若只 stop 不 release,连续拍照会不断创建未释放对象。

九、finally保证异常路径释放

ts 复制代码
let renderer: audio.AudioRenderer | null = null;
try {
  renderer = await audio.createAudioRenderer(options);
  await renderer.start();
  await renderer.write(buffer);
  await renderer.drain();
  await renderer.stop();
} catch (error) {
  hilog.warn(DOMAIN, TAG,
    'play shutter sound failed: %{public}s',
    JSON.stringify(error));
} finally {
  if (renderer !== null) {
    try {
      await renderer.release();
    } catch (releaseError) {
      hilog.warn(DOMAIN, TAG,
        'release renderer failed: %{public}s',
        JSON.stringify(releaseError));
    }
  }
}

即使 start、write、drain 或 stop 任一步失败,finally 都会尝试释放。release 自身也可能失败,因此需要独立 try/catch,不能让清理异常遮盖原始错误。

十、playing门闩阻止声音叠加

ts 复制代码
private static playing: boolean = false;

static async play(): Promise<void> {
  if (ShutterSoundService.playing) {
    return;
  }
  ShutterSoundService.playing = true;
  try {
    // play
  } finally {
    ShutterSoundService.playing = false;
  }
}

门闩检查与赋值之间没有 await,在单线程事件循环中可以阻止并发调用同时进入。无论播放成功还是失败,finally 都重置状态。

这里采用"忽略后续请求"策略,适合快门反馈。若音效必须逐个播放,则应使用队列;但连拍时排队会导致声音落后于画面,不符合快门反馈语义。

十一、音频失败不能让拍照失败

声音是增强反馈,不是拍照必要条件。服务捕获错误后返回,页面仍然显示照片。正确的依赖方向是:

text 复制代码
真实拍照成功 -> 尝试声音 + 视觉反馈 -> 展示结果
声音失败     -> 记录诊断,不回滚照片

不要把 await ShutterSoundService.play() 放在照片保存事务中,也不要因音频设备不可用把 captureState.success 改为 false。

十二、视觉反馈与声音并行

ts 复制代码
this.shutterFeedbackVisible = true;
ShutterSoundService.play();
setTimeout(() => {
  this.shutterFeedbackVisible = false;
}, 650);

视觉反馈持续 650ms,音效约 160ms,两者不必等长。视觉层应避免遮住结果页按钮,并在页面销毁时防止旧定时器写回状态。

可以保存 timer ID 或 generation:

ts 复制代码
private shutterFeedbackGeneration: number = 0;

private triggerShutterFeedback(): void {
  const generation: number =
    ++this.shutterFeedbackGeneration;
  this.shutterFeedbackVisible = true;
  ShutterSoundService.play();
  setTimeout(() => {
    if (generation === this.shutterFeedbackGeneration) {
      this.shutterFeedbackVisible = false;
    }
  }, 650);
}

新反馈不会被旧 timer 提前关闭。

十三、用户开关要与平台规则分开

页面通过 shutterSoundEnabled 控制应用自定义音效。需要注意:某些地区或设备对相机快门提示有系统级规则,应用自定义开关不能用来规避平台要求。上线前应验证目标设备的系统 CameraKit 行为和应用商店政策。

开关只控制应用额外生成的 PCM:

ts 复制代码
if (this.shutterSoundEnabled) {
  this.triggerShutterFeedback();
}

不要修改系统音量、静音模式或其他应用音频状态。

十四、STREAM_USAGE需要按语义选择

当前使用 STREAM_USAGE_MUSIC,兼容性直观,但快门是短提示音。后续应根据目标 SDK 提供的 StreamUsage 枚举评估更符合提示/系统交互的用途。不同 usage 可能影响音量通道、焦点、静音策略和路由。

选择时验证:

  • 静音模式下是否符合产品预期。
  • 蓝牙耳机连接时声音路由。
  • 后台音乐是否被打断或 duck。
  • 通话中是否误播放。
  • 系统音量键控制哪个通道。

不要仅凭模拟器结果决定音频 usage。

十五、频繁创建Renderer的取舍

每次播放都创建并释放,优点是所有权简单、闲置时不占资源;缺点是可能有首次延迟。两种策略:

策略 优点 风险
每次创建 资源闭环清晰 创建延迟、频繁分配
复用单实例 低延迟 状态机复杂、生命周期更长

0.16秒低频拍照可优先选择每次创建。若性能数据证明延迟明显,再在 Ability 前台预热并在后台/销毁时释放,而不是凭感觉持久化 AudioRenderer。

十六、测试PCM生成器

createClickBuffer() 是纯函数,可验证:

ts 复制代码
it('creates 160ms mono s16le buffer', 0, () => {
  const buffer: ArrayBuffer =
    ShutterSoundService.createClickBufferForTest();
  expect(buffer.byteLength).assertEqual(14112);
});

还应测试:

  • 所有采样值在 Int16 范围。
  • 首尾振幅没有异常爆点。
  • 相同输入生成相同字节。
  • 修改时长后大小按公式变化。
  • 双调用时第二次不会创建 renderer。
  • create/start/write/drain/stop 任一步失败都调用 release。

AudioRenderer 可通过接口包装注入 fake,记录方法顺序。

十七、真机验收矩阵

场景 期望
单次真实拍照 一次短促声音,无截断
相机不可用 不发出成功快门声
快速连点 不叠加、不排队延迟
关闭声音开关 不播放自定义声音
音量最小/静音 遵循系统和产品策略
蓝牙耳机连接 路由行为明确
后台音乐播放 不产生不合理打断
页面退出中播放 renderer最终释放
连续拍摄100次 资源数和内存不持续增长

十八、常见问题排查

现象 高概率原因 排查点
播放是噪声 格式或字节序不匹配 S16LE与setInt16小端
声音结尾爆点 波形突然截断 衰减包络与drain
连拍声音重叠 缺少playing门闩 play入口
偶发无声 renderer状态或路由异常 start/write日志
拍照结果显示变慢 页面await了音频播放 非阻塞触发
连续拍照内存上涨 release路径缺失 finally

十九、发布前验收清单

  • PCM 格式、采样率、声道和写入布局一致。
  • 缓冲区长度由采样率与时长计算。
  • 波形有渐入/衰减并限制到 [-1,1]
  • write 后等待 drain,再 stop 和 release。
  • 所有异常路径都进入 finally。
  • 连续调用不会声音重叠或形成滞后队列。
  • 音频失败不影响真实照片结果。
  • 视觉反馈 timer 不会被旧任务误关闭。
  • 用户开关不改变系统静音或音量状态。
  • 真机验证音频路由、后台音乐和连续拍摄。

总结

程序化快门声的价值不只是省去一个音频文件,而是让音频格式、时长和波形都可审查、可测试。AudioRenderer 负责播放 RAW PCM,DataView 按 S16LE 写入采样,包络解决硬截断,playing 门闩阻止重叠,try/finally 确保任何失败都释放系统资源。

把声音定位为真实拍照后的非关键反馈,并与视觉效果并行执行,既能保持相机主链路响应,也能在音频设备异常时安全降级。最后用真机覆盖静音、路由、后台音乐和长时间连拍,才能把一声短点击做成稳定的工程能力。

相关推荐
listening7772 小时前
HarmonyOS 6.1 混沌工程实战:从“故障免疫”到“韧性架构”
华为·架构·harmonyos
特立独行的猫a2 小时前
Python三方库鸿蒙PC移植指南PPT
harmonyos·移植·鸿蒙pc·python三方库
b130538100493 小时前
HarmonyOS应用开发实战:萌宠日记 - 环比增长指示器
harmonyos·鸿蒙
爱写代码的森3 小时前
鸿蒙三方库 | harmony-utils之LocationUtil位置获取与订阅详解
华为·harmonyos·鸿蒙·huawei
Helen_cai3 小时前
OpenHarmony API23 四层模块化脚手架完整实现(含全套工具源码)
华为·harmonyos
qizayaoshuap3 小时前
# [特殊字符] 指南针 — 鸿蒙ArkTS方向计算与方位跟踪系统
华为·harmonyos
达子6664 小时前
第5章_图解harmonyos Ability基础知识
华为·harmonyos
国服第二切图仔4 小时前
02-breakpoint-system
运维·harmonyos
Helen_cai4 小时前
EntryAbility 全局初始化 + 网络请求 HttpUtil 完整源码
网络·华为·harmonyos