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 确保任何失败都释放系统资源。
把声音定位为真实拍照后的非关键反馈,并与视觉效果并行执行,既能保持相机主链路响应,也能在音频设备异常时安全降级。最后用真机覆盖静音、路由、后台音乐和长时间连拍,才能把一声短点击做成稳定的工程能力。