声音明信片实验室 05|HarmonyOS 7.0 音频空间化:导出明信片再听一遍【鸿蒙心迹】

1. 前言
松开铃声图标,选择“窗边阅读”,然后保存。对用户来说,这是一件连续的事:把刚才确认的声音留下来。
程序却需要跨过几个边界。手指停下时,最后一次位置更新可能还在队列里;参数提交成功时,播放器可能仍在播放旧缓冲;导出开始后,用户又可能切到“街角散步”。如果保存逻辑随时读取“当前配置”,一个文件就可能混入几个时刻的选择。
上一篇,我们区分了界面编辑值、待提交值和已成功应用值。这一区分在保存时尤其有用。导出需要绑定已经确认的版本与配置修订号,让用户后续的操作不会改变正在生成的文件。
声音明信片仍由rain、page、bell三路素材组成。工程长20秒,统一为48 kHz、双声道、S16LE,每路960,000个样本帧。短素材补静音,长素材裁切,不自动循环。A是“窗边阅读”,B是“街角散步”,两版共用素材与增益基线,只改变空间安排。
本篇保存的是最后确认的固定摆位。拖动过程没有被记录成运动轨迹。因此,要比较预览和导出,应当用最终配置从头听一遍,再检查同一配置的离线产物。边拖边听的历史片段,不能直接充当这次比较的基准。
2. 功能定义
OHAudioSuite的离线编辑通过管线渲染PCM,保存文件由应用负责。离线编辑始于API 22,空间渲染节点始于API 23;它们是这个HarmonyOS 7.0工程所使用的基础能力。
实时管线使用AUDIOSUITE_PIPELINE_REALTIME_MODE,离线管线使用AUDIOSUITE_PIPELINE_EDIT_MODE。两条路径可以根据相同参数构建节点图,但各自维护读取位置与效果状态。实时路径还要向OHAudio交付播放数据,离线路径则主动获取输出并写入文件。
我会把一次导出的验收拆成四项:参数能追溯,音频数据完整,格式能够解释,复听有据可查。这样的拆分有助于定位问题。文件写入成功,回答不了“是不是选中的版本”;长度正确,也回答不了“左右声道是否放反”。
裸PCM还需要格式说明。这里每个样本帧包含左右两个16位样本,占4字节。20秒对应960,000帧,目标有效数据为3,840,000字节。如果输出长度不同,应检查供数、结束处理和效果尾部,保留原始文件分析原因。直接补零或截断,会把问题藏进一个漂亮的长度数字里。
本篇使用能够显式指定PCM格式的工具复听。WAV封装与普通播放器验证放在第07篇;修改扩展名不会补上格式信息,渲染后的双声道PCM也不等于保存了可编辑的空间对象。
还有三个结束时刻需要区分:输入素材供数结束、管线渲染结束、文件保存结束。后面的实现会分别检查它们。
3. 方案与实现
最直接的做法,是在导出代码里再写一套位置和增益。初次运行时,两套参数很容易保持一致。等到编辑器支持拖动、版本切换和恢复工程,维护成本就会出现:每加一个参数,都要记得修改另一条路径。
我更愿意让两条路径读取同一份工程配方。配方描述声音应当如何生成,运行对象保存这一次生成进行到了哪里。这个边界让配置可以序列化,也让每次导出能够从相同起点重建。
初始摆位仍沿用前文,单位为米:
| 声源 | A:窗边阅读(X,Y,Z) | B:街角散步(X,Y,Z) |
|---|---|---|
| rain | (-2,0,1) | (0,0,-2) |
| page | (0,0,1) | (-1,0,1) |
| bell | (2,0,2) | (2,0,1) |
坐标以听者为中心,X向右、Y向上、Z向前,每轴范围为[-5,5]。用户修改bell后,配方采用最后成功确认的坐标,不能在导出时重新套用这张初始表。
下面的C++结构表达应用自己的配方模型。素材哈希指向归一后的只读PCM;起止位置统一使用样本帧,结束位置不包含在范围内。
#include <array>
#include <cstdint>
#include <string>
struct Position {
float x, y, z;
};
struct SourceRecipe {
std::string id;
std::string pcmHash;
uint64_t beginFrame;
uint64_t endFrame;
float gain;
Position position;
};
struct RenderRecipe {
uint32_t schemaVersion;
std::string version; // A或B
uint64_t revision;
uint32_t sampleRate = 48000;
uint32_t channels = 2;
std::string format = "S16LE";
uint64_t projectFrames = 960000;
std::array<SourceRecipe, 3> sources;
};
这个模型专用于固定摆位。序列化文件还应明确固定摆位模式、声道布局、交错顺序及节点图约定。素材清单保存授权来源与补静音、裁切规则。后续加入其他空间模式时,需要扩展配方结构,避免用几个没有明确含义的可选字段容纳所有效果。
不可变性要落实到对象所有权。导出任务取得一份深拷贝快照,此后只读;编辑器继续修改自己的配置。仅给一个共享可变对象套上只读引用,不能阻止别的持有者改动它。

图05-A:实时与离线根据同一应用配方建立节点图,分别持有游标和效果状态。配方标识连接两条路径的输出记录。
保存动作也需要明确的提交边界。以下流程中的函数属于应用层,参数确认和快照创建在同一个受控执行上下文中完成:
requestExport(version, finalSequence):
waitUntilApplied(version, finalSequence)
if submissionFailed:
reportFailure()
return
snapshot = deepCopyConfirmedRecipe(version)
job = createExportJob(snapshot)
enqueue(job)
等待期间不能悄悄改用较早的已应用值。创建快照后,后续手势形成新修订,导出任务继续使用已经绑定的那一份。参数接口成功也不意味着播放器里的旧数据已经播完;复听参考应从冻结配方重新生成。

图05-D:最后一次参数提交确认后,选中版本与修订号进入独立快照。新的编辑留在编辑器中,不回写正在运行的导出任务。
节点图不变:三路输入各自进入空间节点,再汇入混音和单一输出。创建离线管线前查询节点支持,逐项检查创建、连接、设参和启动结果。离线任务新增三份读取游标,从项目零点开始;预览A/B的六份游标不参与导出,效果缓存也不直接复制。
基础比较按阶段执行:先从冻结配方生成实时参考,再离线导出。这样可以先检查内容一致性。边听边导出的资源竞争应另做测试,管线数量没有超过上限,并不能说明实时预算足够。
渲染循环调用OH_AudioSuiteEngine_RenderFrame(),取得返回码、有效长度和完成标志。缓冲容量只是本次能接收多少数据,不能直接当作写入长度。
下面的应用辅助函数负责校验并写出有效区间。它放在离线工作路径,使用调用方已经打开的临时文件:
#include <cstddef>
#include <cstdio>
#include <cstdint>
bool writeBlock(FILE* file, const uint8_t* data,
size_t capacity, int32_t validBytes,
uint64_t& totalBytes)
{
if (!file || !data || validBytes < 0 ||
size_t(validBytes) > capacity || validBytes % 4 != 0) {
return false;
}
size_t written = 0;
const size_t length = size_t(validBytes);
while (written < length) {
const size_t n = std::fwrite(
data + written, 1, length - written, file);
written += n;
totalBytes += n;
if (n == 0 || std::ferror(file)) {
return false;
}
}
return true;
}
渲染控制层先检查接口返回码,再处理有效数据,最后判断完成标志。最后一次调用可能同时带回音频和结束信息;先退出循环,就会丢掉那段音频。零输出且尚未完成不能登记为成功,应按接口契约处理无进展情况,保留日志并限制异常重试,避免无限循环。第01篇转换器的结束协议不能直接套到这里。

图05-B:文件只接收缓冲区的有效部分。最后一次有效长度、累计样本帧数和完成标志分别记录,输入结束不直接代表文件保存成功。
写入临时文件后,还要检查刷新和关闭结果。只有渲染完成、写入完成且结构检查通过,才将产物登记为完整输出。取消、短写或渲染失败保留独立状态;重新尝试从零开始,不能向半成品继续追加。退出时按生命周期停止并释放离线资源,错误清理也不能跳过。
输出清单连接配方标识、素材哈希、PCM哈希、有效字节数、格式和任务状态。渲染完成与复听验收使用不同字段,便于判断还剩哪一步。
4. 测试
测试先固定配方,再固定运行条件。记录设备、系统、SDK、素材哈希、节点图和输出格式。实时参考与离线输出都从零开始,完整处理20秒,期间不修改空间参数。
实时参考应在明确的渲染输出边界捕获,通过有界缓冲交给非实时路径写盘,并记录采集丢失。参考文件本身缺了数据,就不能据此判断离线输出多了一段。软件PCM比较与实际播放听辨也应分别保留记录。
我会按下面的矩阵组织用例:
| 操作 | 主要检查 |
|---|---|
| 导出初始A,再导出修改后的A | 修订号与bell位置对应,其他参数不变 |
| 选中B并导出 | 产物绑定B,没有沿用A的位置 |
| 松手后立即保存 | 最后提交得到确认,失败时明确结束任务 |
| 导出期间切换版本或继续编辑 | 冻结配方不变,新编辑属于新修订 |
| 最后一块不足缓冲容量 | 只写有效数据,没有旧字节或重复尾部 |
| 注入写入失败或取消 | 不发布完整产物,重新导出从零开始 |
| 素材缺失或哈希不符 | 准备阶段拒绝错误输入 |
| 同配方重复导出 | 独立初始化,比较重复性并保留差异 |
首先检查文件长度能否被4整除,再用有效字节数除以4得到帧数,除以48,000得到秒数。20秒目标是3,840,000字节。尾帧表另外记录最后请求容量、有效长度、实际写入数和完成标志。只核对总长度,可能漏掉“一段重复、另一段丢失”的错误。
检查末尾时,先听正式素材中的可辨识内容。需要更精确定位时,另建包含末尾提示音的诊断素材组,保持相同格式、时长和节点图,记录被替换素材的哈希。诊断组仍是三路输入,不向正式作品增加第四条轨道。
左右声道可用可区分的诊断输入检查,并核对通道顺序。空间渲染会影响两侧输出,不能用“一侧必须完全静音”作为通用判断。峰值检查记录满幅样本和异常波形,未达到满幅也不能单独证明没有失真。

图05-E:身份、数据完整性和听辨逐层检查。软件输出比较与播放链路记录分开保存,避免把采集问题或设备差异归入离线渲染。
比较两份PCM时,先核对起点、帧范围、增益和通道,再分析差异。需要对齐时保留未处理的原始文件,记录偏移量;响度调整同样生成单独的听辨副本。不能把加工后更相似的结果,回填成原始文件已经通过验收。
匿名复听比较的是“选定A的实时参考”和“同配方离线输出”。用随机样本编号隐藏来源,避免与工程的A/B版本命名混淆。提前约定人数、重复次数和播放顺序,固定输出设备、路由、音量以及额外音效设置,记录响度控制方法与调整量。
记录表分别询问声源安排、末尾完整性、可闻异常与偏好,并保留“不确定”。喜欢某个样本不等于另一个有错误,听不出差别也不证明逐样本相等。任何一致性结论,都应附带对应配置与播放条件。
旋转模式留到下一篇。素材处于同一帧,并不足以证明动态效果处于同一运动位置;这需要独立的时间基准实验。
5. 结论
导出的明信片需要再听一遍,因为保存跨越了配置、渲染、文件和播放几个边界。每个边界都有自己的成功条件,也有自己的失败方式。让它们留下可关联的记录,问题就能沿着数据回到具体环节。
前四篇已经为这件事准备了素材时间基线、声源摆位、版本位置关系和最终参数确认。本篇用不可变配方把这些信息固定下来,让实时参考与离线产物拥有共同的来源。
交付目录可以保持简单:
experiment-05/
recipe.json
assets-manifest.json
output/
selected-version.pcm
format.json
output-manifest.json
reference/
realtime-reference.pcm
records/
render-log.csv
tail-check.csv
listening.csv
environment.json
failures/
README.md

图05-C:工程配方、素材清单、PCM格式说明与验证记录共同组成交付。验收项关联实际样本,容器封装单独记录完成状态。
下一篇将沿用这份固定摆位基线,检查旋转模式在重播、重启和离线导出时能否保留相同的运动顺序。第07篇再将验收后的PCM封装为WAV,并检查普通播放器中的播放结果。工程配置继续独立保留,供后续修改与重建使用。
更多推荐





所有评论(0)