请添加图片描述

1. 前言

上一篇,我们让“窗边阅读”和“街角散步”两套配置保留各自的读取状态,再按共同的素材位置准备输出。这样,切换版本时就有了可以核对的时间依据。接下来,把手指放到铃声图标上,连续拖动它。

用户期待的事情很直接:铃声跟着位置变化,雨声和翻书声继续播放。但程序同时面对两种节奏。触摸事件随操作而来,音频输出则需要按播放进度交付。手指可以短暂停住,播放器却不能等程序处理完一长串位置更新再继续。

我习惯先把这里的现象分开。声音出现空白、位置跟随迟缓、移动时听到咔嗒声,可能有不同原因。前者需要检查供数与处理时限,第二种需要检查事件积压和播放缓冲,第三种还可能来自参数突变。把它们都归入“性能不好”,会让优化失去方向。

本篇继续使用20秒工程,素材仍为rain、page、bell,统一为48 kHz、双声道、S16LE,每路960,000个样本帧。短素材补静音,长素材裁切,不自动循环。A是“窗边阅读”,B是“街角散步”,共用素材和增益基线,同一区间只播放一个版本。

我们要研究的是固定摆位参数的连续编辑。松手后保存最终位置,不把拖动过程变成运动轨迹。首先需要回答:一次输出请求究竟给程序留下多少处理时间?

2. 功能定义

OHAudioSuite负责处理音频,OHAudio负责播放。官方实时预览示例在播放回调中调用OH_AudioSuiteEngine_RenderFrame(),取得处理后的数据。文档同时提醒,处理耗时与管线中连接的效果节点数量有关。

实时预览从API 22开始提供,空间渲染节点始于API 23。HarmonyOS 7.0对应的API 26取消了实时管线最多一条的单独限制,引擎总管线数仍最多10条。这个限制描述可创建的数量,设备是否来得及处理,还需要另一套证据。

计算预算时,我会先确认输出格式。双声道S16LE的一个样本帧占4字节。如果某次请求3840字节,对应960帧,在48 kHz下代表20毫秒音频。20毫秒只是这组数字的结果,实际计算必须使用当前请求大小。

#include <cstdint>

bool blockDurationMs(uint32_t bytes, uint32_t channels,
                     uint32_t bytesPerSample, uint32_t rate,
                     double& durationMs)
{
    const uint64_t frameBytes =
        uint64_t(channels) * bytesPerSample;
    if (rate == 0 || frameBytes == 0 || bytes == 0 ||
        bytes % frameBytes != 0) {
        return false;
    }
    const uint64_t frames = bytes / frameBytes;
    durationMs = double(frames) * 1000.0 / rate;
    return true;
}

这里计算的是输出块覆盖的时间。将回调总耗时与它比较,可以检查名义处理预算;播放器已有多少缓冲、回调调度是否及时,仍会影响实际欠载。因此,一次超过块时长的回调不能直接记作一次可听断续。

请添加图片描述

图04-A:按当前输出格式计算块时长,参数处理、渲染、数据搬运与记录开销共同占用预算。时间格用于说明关系,具体耗时由采集记录填入。

如果A、B在同一回调里串行渲染,两次处理共享同一份预算。只观察当前可听版本的RenderFrame耗时,会漏掉后台版本的成本。移到工作线程也不会消除成本,还需检查队列等待和输出是否按时就绪。

另一个边界同样重要:实时预览过程中只支持修改效果节点参数,不支持重新创建新的效果节点。拖动铃声应当更新已有空间节点,节点创建、素材转换和大块缓冲分配放在准备阶段。

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)

每版都是三路输入各自经过空间节点,再汇入混音和单一输出。两版共享只读PCM,保留六份独立输入游标。三路PCM合计约11.52 MB,准备阶段加载后可以避免回调内读盘;效果缓存和输出缓冲另计。

本轮只拖动当前版本的bell。坐标仍以听者为中心,X向右、Y向上、Z向前,每轴范围为[-5,5]米。平面编辑固定Y=0,把屏幕位置映射为X/Z。rain和page的参数保持不变。

最容易想到的方式,是每收到一个触摸位置就安排一次参数提交。它保留全部中间状态,也可能让音频路径处理用户已经越过的位置。假如手指先后经过五个点,消费端尚未开始处理,用户关心的通常是第五个位置。

于是可以按版本和声源保留最新的待处理位置,在提交边界消费一次。这个选择适合当前的固定摆位编辑;若业务需要记录完整手势,事件历史就应另行保存。

位置更新需要携带身份。只传X/Z,会让版本切换后的迟到事件有机会修改错误的节点。

#include <cstdint>

enum class Version { Window, Street };
enum class Source { Rain, Page, Bell };

struct PositionUpdate {
    uint64_t gestureId;
    uint64_t sequence;
    uint64_t eventTimeNs;
    Version version;
    Source source;
    float x;
    float y;
    float z;
};

手势开始时绑定版本和声源,整个手势沿用这份身份。切换版本前结束或取消手势,迟到事件按原身份校验,不能临时查“当前版本”再写进去。坐标也应作为完整快照发布,避免X来自一次事件、Z来自另一次事件。

还有三个值得分别保存的值:界面正在显示的位置、等待提交的位置、已经成功应用的位置。只有第三个值能够代表已确认的参数状态。

下面用应用层流程表达提交规则。applyPosition封装固定摆位参数调用,返回结果由应用检查;快照交接与SDK调用必须放在满足接口线程约束的执行上下文中。

onPosition(update):
    if gestureIsValid(update) and coordinatesAreValid(update):
        pending[update.version, update.source] = update

atSubmissionBoundary():
    update = takePendingSnapshot()
    if update is absent or requestIsObsolete(update):
        return

    result = applyPosition(update)
    if result succeeded:
        applied[update.version, update.source] = update
        revision[update.version] += 1
        acknowledge(update.sequence)
    else:
        reportFailure(update.sequence, result)

合并策略本身不提供线程安全。控制线程怎样发布快照、谁负责调用参数接口、能否与RenderFrame并发,都需要按SDK约束确定。不能因为用了一个队列,就推断队列两侧可以同时操作同一管线。

松手也需要确认。最后一个位置若仍在待提交区,界面应等待它成功应用,或显示失败;否则用户看到铃声停在右侧,保存的却可能是较早的位置。提交失败时,已应用值保持不变,较早请求的失败处理也不能覆盖更新的待提交值。

请添加图片描述

图04-D:编辑值经过提交成为已应用值,再关联配置修订号。松手确认沿用事件序号,版本切换时检查手势身份,避免迟到结果覆盖新选择。

配置修订号还连接着上一篇的输出块模型。新参数成功应用后,新渲染块携带对应版本、修订号和帧范围。旧块不能重新贴上新修订号,已经进入播放器缓冲的声音也不能撤回。即使参数接口立即返回,用户听到变化仍可能晚一些。

合并也不等于平滑。相邻两次提交的位置距离可能更大,是否产生可感知的跳变,需要听辨。插值可以成为后续对照,但应单独记录,避免把两种改动的作用混在一起。

回调内只留下有界的必要工作。素材归一和读取提前完成,不临时分配大缓冲,不重建节点,不执行长时间预滚。日志先写入预分配记录区,交给非实时路径落盘;记录区满时统计丢失数量,避免为保住日志而阻塞声音。

若负载仍然过高,降级必须说明改变了什么。

触发条件处理选择需要承担的代价
位置更新积压合并中间值、调整提交节奏响应延迟与位置跳变可能增加
双管线持续处理超出预算只渲染当前版本切换前需要重新准备另一版
当前路径仍难以连续供数单管线顺序试听停止与重新准备带来等待

停止管线会释放节点缓存。恢复另一版时,不能只把游标设到当前帧就宣布同步完成,需要重建效果历史并检查候选输出范围。等待时显示“重新准备”;如果从零开始,进度也回到零。恢复双管线同样需要这一步。

当前节点图没有可以随意删除的非关键效果。删掉空间节点会改变文章要比较的声音关系。以后加入可选效果时,可以在允许的停止与编排阶段准备简化配置,再单独比较听感。

请添加图片描述

图04-E:从双管线持续推进转为仅渲染当前版后,目标版本需重新准备并核对范围,再进入单一可听出口。降级收益与切换等待一起记录。

4. 测试

要判断合并是否有用,先让两种策略接收同一组拖动事件。记录每次事件的时间、坐标和序号,再重放慢速移动、快速往返、边界拖动与松手操作。单靠两次手动拖动,很难保证输入负载相同。

基础比较分为四格:

用例运行条件主要观察
单管线、固定位置当前版持续渲染处理基线
单管线、连续拖动只编辑当前版bell逐事件与合并的差异
双管线、固定位置两版持续推进,一版可听第二条渲染管线的成本
双管线、连续拖动两版推进,只编辑当前版bell渲染与参数提交的共同负载

第二条管线只创建而不渲染,不能算入双管线持续处理组。每轮从相同配方开始,A、B分别作为当前版测试,固定设备、系统、SDK、输出路由、音量、素材和节点配置。每轮项目仍为20秒,多次独立运行;冷启动、预热与运行顺序分别记录。

回调记录要能回答“时间花在哪里”。以下结构保留时间分析的主要字段,运行配置与参数事件另表保存,用回调序号和事件序号关联。

struct CallbackTrace {
    uint64_t callbackIndex;
    uint64_t enterNs;
    uint64_t exitNs;
    uint64_t renderANs;
    uint64_t renderBNs;
    uint64_t parameterNs;
    uint64_t copyNs;
    uint32_t requestBytes;
    uint32_t validBytes;
    int32_t resultCode;
};

时间使用同一单调时钟。参数表记录事件产生时间、成功提交时间、序号、版本、修订号、合并数量和失败原因;运行表保存采样率、策略、设备条件及记录丢失数量。

报告同时查看RenderFrame和回调总耗时的中位数、P95、P99、最大值,附样本数、名义超期次数与总回调次数。分位数需要足够样本支撑,还应检查开启采集本身增加的开销。

请添加图片描述

图04-C:四格负载矩阵按参数提交策略分组,分别记录耗时分布、超期次数、输出不足和听辨结果。分布图由对应原始日志生成。

位置响应有两个容易混淆的指标:事件到提交的时间,以及提交之后多久听到变化。前者可由软件日志计算,后者需要可靠的播放时钟映射或回环采集。只有软件记录时,就报告软件提交延迟,不用它代替可听延迟。

连续性也要另查。输出捕获用于定位空白、重复或遗漏,听辨记录用于确认用户感受到的变化。一次参数跳变的咔嗒声不能自动归为供数不足,一次名义超期也不能自动等同一次断续。保留片段和时间对应关系,才能把现象与处理过程联系起来。

异常测试覆盖参数失败、记录缓冲溢出、松手后立即切换、快速A→B→A,以及19秒附近的短输出。RenderFrame返回的有效字节数需要单独检查,剩余缓冲按播放器契约处理,不能把旧字节再次播放。20秒正常结束与中途断供分别统计。

降级后的测试继续沿用1、8、19秒双向切换,记录准备等待、提交边界、素材位置误差和空白时长。必要时另用带标记的诊断素材定位时间问题,最终听辨仍回到原来的三路声音。

5. 结论

连续拖动把编辑器里的两个责任放到了一起:控制路径表达用户最新的选择,音频路径按时交付输出。用完整快照、明确身份和成功确认把两者连接起来,位置更新与播放问题就有了各自可检查的状态。

合并待处理参数提供了一种减少中间提交的办法。它是否适合当前设备,需要同时看回调耗时、位置响应和听感。双管线的取舍也一样:持续推进减少了切换前的准备工作,只渲染当前版则降低持续负载,代价落在下一次切换上。

最终应保存设备、输出格式、请求帧长、节点配置和提交节奏对应的工作区间,并附失败条件。管线数量上限无法替代这份记录。

下一篇,我们把选中版本、最后确认的位置及配置修订号整理为工程配方,继续处理PCM导出与复听。播放时及时交付了声音之后,还要检查保存下来的明信片是否保留了同样的安排。

接口与规则参考:
实时预览
音频编创开发概述
空间音频渲染
音频格式转换。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐