声音明信片实验室 01|HarmonyOS 7.0 音频空间化:三段录音对齐时间轴【鸿蒙心迹】

1. 前言
旅行结束后,照片通常很好整理,声音却没有那么直观。雨声来自窗边的一次录音,翻书声录在室内,街角铃声又用了另一套录制设置。我们希望把它们放进一张20秒的“声音明信片”,以后再制作“窗边阅读”和“街角散步”两种空间安排。
在摆放声源之前,有一个更基础的问题:这三段声音是否使用同一种时间尺度?
界面上都显示“20秒”,并不意味着文件包含完全相同的时间长度。显示可能经过取整,录音格式也可能不同。如果读取端把44.1 kHz的数据按48 kHz解释,即使字节一个不少,播放时长仍会改变。此时调整空间参数,只会让问题更难定位。
我倾向于先建立一个简单约定:三段素材从项目零点开始,统一转换为48 kHz、双声道、S16LE;短素材在末尾补静音,长素材保留前20秒,不自动循环。雨声、翻书声和街角铃声各自保留独立身份。
这个约定把问题分成了两步:先正确理解并转换声音,再决定它占据项目中的哪段时间。两步都影响长度,却不能合并成一次含糊的“归一化”。

图1:格式卡描述原始素材,下面的轨道描述项目范围。文件大小、素材时长与项目时长是三个不同的量。
2. 功能定义
HarmonyOS从API 26开始提供独立的PCM格式转换能力,可转换采样率、声道布局和采样格式。这是本文使用的7.0能力增量。音频编创框架本身已有更早的版本基础,不能把整个处理链都归为此次新增。
转换器接收PCM。MP3或AAC需要先解码,裸PCM则需要随附格式信息。仅凭一个文件名,我们无法知道其中每两个字节代表一个样本,还是每四个字节代表一个浮点数。
理解时间轴的关键单位是样本帧:同一采样时刻,各声道样本组成的一组数据。对于本文采用的交错存储格式:
每样本帧字节数 = 声道数 × 每样本字节数
样本帧数 = 有效PCM字节数 ÷ 每样本帧字节数
时长(秒) = 样本帧数 ÷ 采样率
双声道S16LE的每个样本帧占4字节。48 kHz表示每秒48,000个样本帧,因此20秒项目包含960,000个样本帧,每路占3,840,000字节裸PCM。双声道已经包含在4字节中,计算时长时不能再除一次声道数。
这个数字规定的是项目长度,并不要求转换器天然输出恰好这么多数据。重采样后的实际长度需要先记录,随后才执行裁切或补齐。
下面用一个小函数把单位约束放进代码。它只负责本文使用的、每样本占整数个字节的交错PCM,不尝试推断文件格式。
#include <cstdint>
#include <stdexcept>
struct PcmLayout {
uint32_t sampleRate;
uint32_t channels;
uint32_t bytesPerSample;
};
uint64_t frameCount(uint64_t bytes, const PcmLayout& format)
{
if (!format.sampleRate || !format.channels ||
!format.bytesPerSample) {
throw std::invalid_argument("invalid PCM format");
}
const uint64_t stride =
uint64_t(format.channels) * format.bytesPerSample;
if (bytes % stride != 0) {
throw std::invalid_argument("incomplete sample frame");
}
return bytes / stride;
}
素材清单也应该使用同样的语言。为雨声、翻书声、街角铃声分别保存素材ID、授权来源、源文件哈希、源格式、有效字节数和帧数,再记录转换输出与项目素材的路径。裸PCM的格式说明是读取契约的一部分。

图2:读写使用字节偏移,工程游标使用样本帧,界面显示秒。每次换算都必须带上对应的格式。
3. 方案与实现
格式转换可以发生在导入阶段,也可以发生在预览阶段。两种路线都能成立,差别在于谁承担状态管理和处理成本。
| 路线 | 主要收益 | 承担的代价 |
|---|---|---|
| 导入时归一并缓存 | 后续预览重复使用统一输入 | 导入等待、缓存空间和缓存失效管理 |
| 预览时实时转换 | 减少预先生成完整缓存的需求 | 播放路径需要处理转换、供数与缓存排空 |
声音明信片选择前者。20秒素材的范围明确,而后续需要反复比较两种空间安排,把转换留在导入阶段可以减少试听路径中的变量。这个选择并不证明所有场景都应提前转换;长音频或持续输入会改变取舍。
整个导入过程保留三个层次:原始PCM、完整转换输出、20秒项目素材。后两个文件看起来有些重复,但它们回答不同的问题。完整输出用来检查转换是否保留了尾部;项目素材服从剪辑规则。如果直接把输出补成20秒,就可能用静音掩盖一次提前结束。

图3:先排空转换缓存并记录完整输出,再执行裁切或补齐。三路素材分别经过相同流程,不在此处混音。
接入转换器需要链接libohaudiosuite.so并包含ohaudiosuite/native_audio_converter.h。调用顺序为OH_AudioConverter_Create、OH_AudioConverter_SetInputCallback、反复调用OH_AudioConverter_Process,最后OH_AudioConverter_Destroy。
输入格式来自素材清单,目标格式则在项目中集中定义:
#include <ohaudiosuite/native_audio_converter.h>
OH_AudioConverter_Format targetFormat = {
.encodingType = OH_Audio_EncodingType::AUDIO_ENCODING_TYPE_RAW,
.samplingRate = OH_Audio_SampleRate::SAMPLE_RATE_48000,
.channelLayout = OH_AudioChannelLayout::CH_LAYOUT_STEREO,
.sampleFormat = OH_Audio_SampleFormat::AUDIO_SAMPLE_S16LE
};
不要通过修改源格式中的采样率来“完成转换”。源格式是在陈述已有数据的含义,目标格式才是在提出处理要求。
每段素材持有自己的输入上下文,包括源样本帧游标、供数状态和缓冲区。输入偏移按源格式计算;输出帧数按目标格式计算。两个游标即使数值相等,也未必对应相同时间。
真正容易出错的是结束条件。输入回调有三种不同含义的状态:
| 状态 | 含义 | 调用方需要做什么 |
|---|---|---|
HAVE_DATA | 本次有有效数据 | 提供数据并保持其有效期 |
NO_AVAILABLE_DATA | 暂时无数据 | 保留任务,等待供数恢复 |
DATA_FINISHED | 输入已经结束 | 继续获取尚未输出的转换数据 |
表中省略了共同的AUDIOCONVERTER_INPUT_前缀。单次回调最多返回400KB,分块还应落在完整样本帧边界上。回调返回的数据指针必须保持有效,直到本次Process返回;把临时局部缓冲的地址交出去,会破坏这一约定。
“暂时没有”和“以后没有”是两个业务事实。把它们压成一个布尔值,往往会导致断供后无法恢复。下面的应用层伪代码将SDK调用、文件写入和等待机制分开表达:
step = processOnce()
if step.result != AUDIOCONVERTER_SUCCESS:
failImport(step.result)
return
if step.outputSize > 0:
writeExactly(step.output, step.outputSize)
scheduleNextStep()
return
if input.finishedReported:
finishConversion()
else if input.waitingForData:
resumeWhenDataArrives()
else:
scheduleNextStepWithProgressGuard()
这里的辅助函数由应用实现。写入失败必须终止导入;重新调度需要允许取消,并检测长期无进展,不能在零输出时无界忙循环。finishedReported表示回调已经报告结束,而不只是应用猜测“文件应该读完了”。
完成判断由三个条件共同构成:Process成功、输出长度为零、输入已结束。结束后继续处理是为了排空转换器缓存;写文件时则始终使用实际输出长度,而不是缓冲区容量。

图4:零输出只是一次调用的结果。只有结合输入结束状态和成功返回,才能决定结束转换;暂时断供需要保留恢复路径。
接入时还有两处值得单独核对。官方指南对最后有效块的文字说明与示例存在差异,末块应如何报告状态,需要依据所用SDK的接口契约并用短尾用例核验。不要把这处差异传播到整个业务流程中,应将它收拢在输入回调适配层。
缓冲容量也不能靠术语猜测。“采样率×声道数×每样本字节数”的单位是字节每秒,不是单个样本帧大小。指南中的容量描述和16KB示例不能直接组合成通用分配规则,应按接口要求检查容量与返回值。
完整输出保存后,20秒长度整理可以保持非常简单。下面的函数只接收已经确认格式为目标S16LE的数据:
#include <vector>
#include <cstdint>
#include <stdexcept>
std::vector<uint8_t> fitProject(std::vector<uint8_t> pcm)
{
constexpr size_t stride = 2 * sizeof(int16_t);
constexpr size_t targetFrames = 48000 * 20;
if (pcm.empty() || pcm.size() % stride != 0) {
throw std::invalid_argument("invalid project material");
}
pcm.resize(targetFrames * stride, uint8_t{0});
return pcm;
}
缩短保留前段,扩展以零填充;零值对S16LE表示静音。这里主动拒绝空素材,避免把读取失败变成一段看似成功的静音。调用前保存完整输出帧数,调用后记录裁切或补齐量。
缓存应先写入临时产物,只有写入、关闭及元数据保存都成功后,才发布为可用素材。源文件哈希、源格式、目标格式和项目规则共同参与缓存识别。任一步骤失败,都应释放已创建资源,并保留失败阶段与返回值。
4. 测试
测试应沿着上述职责划分,而不是只检查最终文件是不是20秒。
第一层测试转换:输入是什么,实际输出多少,结束后是否仍有数据。第二层测试项目规则:短素材是否补齐,长素材是否裁切,两者是否都保留正确的起点。
| 用例 | 输入安排 | 需要检查的行为 |
|---|---|---|
| 格式组合 | 44.1/48 kHz与单/双声道组合 | 格式、帧数和时长换算正确 |
| 整块边界 | 长度为分块整数倍,以及多一个样本帧 | 没有重复或遗漏末块 |
| 合法短尾 | 最后一块较短,但含完整样本帧 | 末块交付后仍完成缓存排空 |
| 残缺样本帧 | 末尾多出不足一帧的字节 | 明确报告格式或长度错误 |
| 暂时断供 | 中间暂停供数,再提供剩余数据 | 不提前完成,恢复后不丢失或重复 |
| 空输入 | 零长度并报告结束 | 转换调用能退出,项目层拒绝空素材 |
| 项目长度 | 短于、等于、长于20秒 | 补齐、保持、裁切分别符合规则 |
| 写入失败 | 在落盘阶段注入失败 | 不发布半成品缓存 |
为了看清尾部,可以在测试副本末尾加入可定位的提示音。它是测量用信号,不是声音明信片的第四条轨道。比较输入与完整转换输出的尾部,再观察项目裁切是否移除了超出20秒的内容。

图5:尾部比较使用裁切前的完整输出,记录表同时保留输入状态、输出帧数和时长差。波形与记录应来自同一用例。
每次记录至少包含设备、系统、SDK、素材哈希、输入输出格式、累计帧数、末次供数状态、Process返回值和补齐或裁切量。对断供用例,使用同一素材的连续供数作为对照,保持分块策略及目标格式一致。
重采样后的理论帧数可以用输入帧数×目标采样率÷输入采样率估算,但实际输出是整数帧,还涉及转换器的尾部行为。因此应同时记录帧数差和时长差,不能未经核实便要求逐字节相等。
尤其要区分三种现象:按错误采样率解释数据、没有排空转换缓存、项目主动裁切。它们都可能表现为“声音变短”,修复方式却完全不同。一个960,000帧的文件只能证明项目长度满足约定,不能单独证明铃声的尾音得到了保留。
5. 结论
三段录音共享时间轴,需要的是一套明确的数据契约:源格式描述已有数据,转换器生成目标格式,项目规则决定最终长度。样本帧把这三者连接起来,同时保留各自的职责。
本篇形成的转换流程、素材清单字段、时间换算方法和短尾、断供用例,可以共同约束导入实现。后续读取归一素材时,仍需携带格式说明;裸PCM并不会因为保存成功,就自动成为普通播放器可识别的分享文件。
下一篇将使用同一批雨声、翻书声和街角铃声,为每路声音设置独立位置,建立“窗边阅读”和“街角散步”两套安排。两版保留相同素材、时间范围和增益基线。这样,比较时听到的差异才能更清楚地归因于空间安排。
更多推荐




所有评论(0)