请添加图片描述

1. 前言

上一篇,我们给雨声、翻书声和街角铃声安排了两套位置:A是“窗边阅读”,B是“街角散步”。每路声音先经过自己的空间节点,再汇入混音。两套配置分别从头播放,已经足以讨论摆位;要比较细微差异,从头播放却会成为障碍。

设想雨声播放到第8秒,我们切到街角版,铃声却重新从开头响起。此时很难判断,听到的变化来自空间位置,还是来自不同的素材片段。即使界面的进度条仍显示8秒,问题也可能藏在进度条下面。

我们希望切换改变空间安排,同时保持素材时间连续。这需要先回答一个具体问题:程序里所说的“现在”,究竟是哪一个位置?

本篇沿用原来的20秒工程。三路素材均为48 kHz、双声道、S16LE,各有960,000个样本帧,范围为[0,960000)。短素材在末尾补静音,长素材保留前20秒,不自动循环。两版共用素材和逐声源增益,只改变固定摆位。“街角散步”仍是配置名称,没有增加脚步声或运动轨迹。

我们将从读取状态入手,再处理效果历史和输出选择。这样,每一步都有可以检查的对象。

2. 功能定义

HarmonyOS 7.0对应的API 26取消了实时预览管线最多一条的单独限制。这让两套实时配置能够同时保留,但引擎管线总数仍然不能超过10条。

规则API 26之前API 26及以后
实时预览管线最多1条不再单独限制
引擎管线总数最多10条最多10条
跨管线时间同步由应用处理由应用处理

这里的数量限制描述可创建的管线,并不保证它们能在任何设备上同时流畅渲染。音频编创和实时预览始于API 22,空间渲染节点始于API 23;本篇使用的新增能力是多实时管线共存。

请添加图片描述

图03-A:版本变化允许两套配置并存。时间同步、输出选择和处理预算仍由应用组织与验证;10条管线不是10个声道。

为了讨论同步,我倾向于先把三个位置分开。目标位置表示应用准备在哪里衔接;输入游标表示下一次从素材哪里读取;播放位置表示声音实际输出到哪里。效果缓存和播放器缓冲会让这三个位置产生距离。

48 kHz下,第8秒对应384,000帧。双声道S16LE每帧占4字节,所以素材寻址使用的字节偏移是1,536,000。帧数适合表达时间,字节数适合访问内存。把它们混进同一个变量,会让错误隐藏在看似合理的数字里。

OHAudioSuite由输出请求向上游取数,最终触发输入回调。读取已经走到第8秒,不意味着相应声音已经到达扬声器。把两条输入游标改成同一个值,也不能让效果节点自动拥有相同的处理历史。

还有一个容易遗漏的规则:管线进入stopped状态后,各节点会释放缓存。因此,停止、改游标、重新启动,需要重新考虑效果历史。文档也没有承诺多管线自动同步或无缝切换。

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)

两版都使用三个输入、三个空间节点、一个混音节点和一个输出节点。这里的A/B表示试听版本,与上一篇比较的两种组网方案无关。

最直接的实现是复用一条管线:结束当前播放,停止管线,应用另一套位置,然后重新准备。它容易控制资源,也是低版本的回退路径。不过,停止会清除缓存,恢复到某个素材位置需要重新建立状态。

双管线则分别保存两套节点和参数。它省去了切换时重新应用整套配置的步骤,同时增加了处理和缓冲成本。只有在目标版本准备好了相应位置的输出时,保留配置才转化为切换收益。

先处理读取状态。上一篇用SourceSpec保存素材与位置,用InputState保存游标。本篇将后者放入每个版本自己的运行状态:

#include <array>
#include <cstdint>

enum class Version { Window, Street };

struct InputState {
    uint64_t nextFrame = 0;
};

struct PreviewState {
    Version version;
    uint64_t configRevision;
    std::array<InputState, 3> inputs{};
};

两条管线共有六份InputState。它们可以引用同一份只读PCM,却不能共享可变游标。如果读取来自文件,共享文件句柄的当前位置也会造成串扰;需要独立读取上下文,或明确按偏移读取。

请添加图片描述

图03-B:共享的是素材,独立的是读取与处理状态。两条管线各自输出PCM,由应用选择其中一版送往播放出口,管线之间不直接连接。

接下来需要决定,未被听见的版本怎样推进。

一种办法是让两条管线从零开始,按共同的项目节拍持续处理。A进入播放出口时,B仍然渲染对应范围,只是不被播放。这样,切到B时可以利用它已经积累的效果历史。代价也很明确:未选中版本仍消耗处理时间。

另一种办法是按目标位置预滚。切换前,让目标管线在不可听状态下处理一段历史,直到准备好需要的范围。预滚应先以从零处理到目标作为参照,再研究能否缩短历史区间。固定摆位依然可能涉及缓存,不能随意假定提前处理100毫秒就够了。

对于20秒素材,先用同步推进建立基准比较容易理解;预滚是否值得采用,要看准备时间、位置误差和负载。长时间追赶不应放在播放器回调里,具体调度也必须遵守SDK的线程约束。

无论采用哪一种策略,输出块都需要说明自己属于什么范围。以下应用结构保存块的身份,PCM缓冲由外部管理:

struct OutputBlock {
    Version version;
    uint64_t configRevision;
    uint64_t beginFrame;
    uint32_t validFrames;
};

bool matches(const OutputBlock& block,
             Version version, uint64_t revision,
             uint64_t begin, uint32_t count)
{
    return block.version == version &&
           block.configRevision == revision &&
           block.beginFrame == begin &&
           block.validFrames == count;
}

这里的beginFrame是应用维护的逻辑标签,不能靠赋值证明声学对齐。它的价值在于阻止明显错误:拿旧配置的块、错误版本的块,或不完整的块去完成一次切换。

切换也应该有身份。用户快速点击A、B、A时,较早的B准备结果可能最后才返回。请求序号让应用识别并丢弃这种过期结果,而不是让界面突然跳回B。

在输出边界上,选择逻辑可以表达为下面的应用层流程。就绪检查和提交由同一调度上下文串行处理,候选块在提交期间保持有效:

onOutputBoundary(beginFrame, frameCount):
    request = latestRequest()
    candidate = readyBlock(request.target, beginFrame)

    if request.id == latestRequest().id
       and matches(candidate, request.target,
                   currentRevision, beginFrame, frameCount):
        selected = request.target
        recordCommit(request.id, beginFrame)

    block = readyBlock(selected, beginFrame)
    if block covers the complete requested range:
        submitToPlayer(block)
    else:
        handleUnderrunAndRecord(beginFrame, frameCount)

准备过程还面临一个时间问题。用户在第8秒发出请求,A可能继续播放到8.2秒。如果此时把B的8秒块接上,声音就倒退了。候选位置必须跟随尚未提交给播放器的输出边界向前推进,目标版本也要准备那个范围。已经排进播放缓冲的声音不能靠修改游标撤回。

如果追不上,可以继续保留当前版本,或采用明确的暂停等待策略。两者的体验不同,日志应分别记录。不能一边暂停,一边仍把这次操作统计为连续切换。

请添加图片描述

图03-D:请求、准备和提交分开处理。提交时再次核对请求序号、配置版本及目标范围,避免迟到结果和过时音频块进入播放。

播放出口保持单一。每个输出范围只选择A或B,不启动两个独立播放器来承担同步。硬切换可能造成波形突变,听起来像咔嗒声;交叉淡化可以另行研究,但它会在过渡区混合两版,不宜悄悄加入当前比较基线。

接入SDK时,先查询节点支持情况,再创建实时管线、设置输入输出格式、连接节点,并通过固定摆位接口提交参数。每个阶段都检查返回值。实时预览中不重新创建效果节点,播放回调内也不临时分配大块缓冲或执行长时间准备。

如果第二条管线创建失败,保留第一条可用路径,清理失败分支。低版本或持续负载过高时,回到单管线顺序试听,并显示“重新准备”。回退若从零开始,进度也应回到零。输出不足时按播放器契约处理剩余缓冲并记录,不能把上次留下的字节再次播放。

4. 测试

同步测试最好从容易定位的信号开始。雨声适合听场景,却不适合辨认几十个样本帧的偏移。

为此,另建一组带已知位置标记的诊断素材,沿用相同格式、20秒范围和组网。A/B使用同一组诊断输入,正式验收仍回到雨声、翻书声和街角铃声。诊断组不替换工程素材,也不增加第四条轨道。

标记需要区分时间位置。完全相同的周期脉冲可能让错开一整个周期的输出仍然看似对齐。先用单路检查,再加入三路混音;为每个版本保留不中途切换的参考输出,避免将空间处理造成的脉冲形状变化误认成时间偏移。

测试位置选1、8、19秒,分别对应48,000、384,000、912,000帧。三个位置分别覆盖早期切换、中段比较和接近结束的边界。

用例主要观察
交替请求A/B输入一版读取不会消耗另一版游标
三处位置双向切换素材位置连续,标记不重复、不遗漏
冷启动与已准备切换分别记录准备成本与提交成本
同步推进与预滚位置误差、额外处理时间和未就绪次数
连续A→B→A迟到请求不会覆盖最新选择
19秒切换及短输出不越界、不循环,结束与断供分开处理
创建失败与负载不足回退路径、提示及资源清理正确

同一组比较固定设备、系统、SDK、输出路由、音量和额外音效设置。保存素材哈希、配置版本、请求块大小和实际增益。更换耳机或设备后另建测试组,否则设备差异会混进策略差异。

测量时需要保留两个独立指标。切换响应时间描述用户等待多久;素材位置误差描述接上来的声音偏离目标多少。误差换算很简单:

positionErrorFrames = observedSourceFrame - targetFrame
positionErrorMs = positionErrorFrames * 1000.0 / 48000

相减时使用有符号数,避免提前接入产生的负误差被无符号运算变成巨大正值。响应时间则应从请求时间计算到目标版本实际开始可听的时刻。

软件提交时间不能直接代替可听时间。软件捕获能够检查送入播放器的数据;要测量扬声器输出,还需要可靠的播放时钟映射或回环采集,并记录采集延迟及校准方式。只有软件记录时,就报告软件提交延迟。

请添加图片描述

图03-C:记录请求、准备完成、软件提交与可听输出的不同边界。标记信号用于核对素材位置,冷切换和已准备切换分别整理。

事件记录应足以重建一次切换,而不只是保存一个“耗时”:

requestId, configRevision, strategy, fromVersion, toVersion,
prepareState, requestTime, requestFrame, targetFrame,
commitTime, commitFrame, firstTargetOutputFrame,
captureTime, observedSourceFrame, validFrames,
errorCode, fallbackReason

不可测字段留空,并保留原因。输出选择日志用于检查同一区间是否只选了一版,捕获数据用于查找空白、重复标记和边界突变。两版使用相同素材且效果可能带有尾部,仅凭某个波形峰值不能判定叠播。

执行前确定重复次数、允许偏移、空白时长阈值和标记检测方法。报告按切换方向、位置、准备状态和推进策略分组,保留失败样本。听辨还应检查响度差异;如制作匹配响度的副本,保留原始输出与缩放记录,计时分析仍使用原始数据。

5. 结论

双版本试听把一个进度条背后的状态暴露了出来。素材可以共享,读取状态必须独立;配置可以同时保留,效果历史需要各自建立;输出已经准备好,还要等到合适的播放边界才能提交。

API 26提供了保留两条实时管线的空间。应用在这个基础上维护六个输入游标、输出块范围和切换请求,再用样本标记检验它们是否对应实际声音。这些结构也让失败更容易定位:错读素材查输入上下文,接入旧声音查块身份,响应过慢查准备和播放缓冲。

同步推进便于建立连续处理的基准,预滚提供另一种资源取舍。最终选择应同时考虑位置误差、等待时间和处理负载;单管线顺序试听则保留为明确的回退路径。

下一篇继续使用这三路素材和两套固定摆位配置。当用户连续拖动声源时,除了准备正确的声音,还必须及时交付每一块输出。我们将进一步讨论回调预算、参数提交频率,以及负载不足时如何降级。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐