声音明信片实验室 02|HarmonyOS 7.0 音频空间化:让雨声和铃声各就各位【鸿蒙心迹】

1. 前言
上一篇,我们把雨声、翻书声和街角铃声整理到了同一条20秒时间轴上。三路素材都使用48 kHz、双声道、S16LE格式,从项目零点开始;短素材补静音,长素材保留前20秒,不自动循环。它们拥有相同的时间尺度,也保留着各自的身份。
现在可以开始摆放声音了。
用户把铃声图标拖到右边,期待铃声出现在右边。雨声应该仍在窗边,翻书声应该仍在面前。这个动作看起来只是一次参数更新,却对底层结构提出了要求:程序必须知道,哪一段声音属于这个图标,以及哪一个处理节点只影响它。
假如三路声音已经混成一路,再给这一路设置位置,移动的就是整个组合。界面仍然可以画出三个图标,但音频图已经失去了分别摆位的入口。
因此,本篇先解决一个结构问题:如何让每段声音拥有自己的空间参数。随后再解决一个体验问题:这些参数造成的差异,听者能否辨认。两者相关,却需要不同的证据。

图02-D:雨声、翻书声和铃声分别保留素材身份。拖动铃声时,更新应沿着它自己的绑定关系到达空间节点。
2. 功能定义
OHAudioSuite提供的空间渲染节点是EFFECT_NODE_TYPE_SPACE_RENDER,从API 23开始支持。我们在HarmonyOS 7.0工程中使用它,但不能把这个节点本身算作API 26新增能力。
理解空间参数,首先要确定观察者。这里以听者为原点,使用左手坐标系:X正向右,Y正向上,Z正向前。三个轴的范围都是[-5,5]米。这里的米是渲染参数单位,不是设备测量出的房间尺寸。
编辑界面采用固定朝向的俯视图。向右拖动增加X,向上拖动增加Z,Y保持为0。屏幕纵坐标通常向下增长,因此它与Z的方向相反。把屏幕上的纵坐标直接填进空间Y轴,会把“移到前面”变成“移到上面”。

图02-A:主图展示听者周围的X/Z平面,侧图补充Y轴。UI坐标经过应用映射后才能成为空间参数,两套坐标不能直接互换。
可以把这项约定收进一个不依赖音频引擎的小函数。以下C++代码接收编辑区域内的局部坐标;宽高必须大于零,区域之外的拖动被限制在边界上。
#include <algorithm>
#include <cmath>
#include <stdexcept>
struct Position {
float x;
float y;
float z;
};
Position mapToSpace(float px, float py, float width, float height)
{
if (!std::isfinite(px) || !std::isfinite(py) ||
!std::isfinite(width) || !std::isfinite(height) ||
width <= 0 || height <= 0) {
throw std::invalid_argument("invalid editor coordinates");
}
const float u = std::clamp(px, 0.0f, width) / width;
const float v = std::clamp(py, 0.0f, height) / height;
return {10.0f * u - 5.0f, 0.0f, 5.0f - 10.0f * v};
}
这个函数规定编辑区域覆盖完整参数范围。区域中心对应原点,左上角对应(-5,0,5)。它不负责决定某个位置是否好听,也不需要知道用户拖动的是哪种声音。
空间节点还支持旋转和扩展。固定摆位描述一个位置;旋转增加环绕周期与方向;扩展描述半径和角度。它们分别有对应的参数设置接口。本文只用固定摆位建立比较基线,旋转的时间基准留到第06篇处理。
职责也应保持清楚:ArkUI接收操作,工程模型保存配置,C/C++侧的OHAudioSuite处理音频图,OHAudio负责播放。声源身份和坐标映射由应用维护,音频引擎不会从一个铃铛图标推断出这些关系。
3. 方案与实现
对于三段声音,有两种直接的组网方式。
| 组网方案 | 处理顺序 | 空间参数的作用对象 |
|---|---|---|
| 方案A | 三路输入→混音→空间渲染→输出 | 混合后的整体声音 |
| 方案B | 各路输入→各自空间渲染→混音→输出 | 对应输入的声音 |
方案A适合把一段组合声音放在某个位置。例如,把整段广播当作一个声源时,这种结构就很自然。声音明信片要求分别摆放雨声和铃声,因此选择方案B。
这个选择来自编辑需求。我们并不需要证明方案B在所有方面都更好,只需要确认它保留了应用要操作的边界。增加独立空间节点也会增加处理工作,实时成本仍需要测量。

图02-B:左侧只有一个空间参数入口,右侧为每路声音保留独立入口。高亮路径表示修改铃声参数时,参数实际作用于哪一个节点。
在代码中,这个边界可以先表现为一个稳定的声源ID。素材位置、空间位置和播放游标虽然都与声源有关,却不应该放在同一种状态里。
#include <cstdint>
#include <string>
enum class SourceId { Rain, Page, Bell };
enum class SpatialMode { Fixed };
struct SourceSpec {
SourceId id;
std::string assetKey;
uint64_t beginFrame;
uint64_t endFrame;
float gain;
SpatialMode mode;
Position position;
};
struct InputState {
uint64_t nextFrame = 0;
};
SourceSpec描述“要播放什么”,InputState描述“这次读到了哪里”。素材键关联上一章保存的格式说明、文件和哈希;每路样本范围使用[0,960000),末端不包含在内。每次准备播放时,为每路输入建立自己的读取状态。
这一区分会在下一篇变得更重要。两套配置可以引用相同素材,但不能通过共享一个可变游标轮流消耗数据。
我们为两套内容配置保留固定名字:试听版本A叫“窗边阅读”,试听版本B叫“街角散步”。它们都使用组网方案B。组网方案描述处理结构,试听版本描述空间安排,不能把这两组A/B混在一起。
| 声源ID | 素材 | 窗边阅读(X,Y,Z) | 街角散步(X,Y,Z) |
|---|---|---|---|
| rain | 雨声 | (-2,0,1) | (0,0,-2) |
| page | 翻书声 | (0,0,1) | (-1,0,1) |
| bell | 街角铃声 | (2,0,2) | (2,0,1) |
这些位置是应用选择的起始配置。两版使用相同素材、时间范围和逐声源增益,只改变位置。“街角散步”是场景名称,不意味着已经加入脚步素材或运动轨迹。调整坐标后,配置版本、图示和听辨记录也要一起更新。

图02-E:两张俯视图共用听者朝向与声源编号,分别对应参数表中的窗边阅读和街角散步。变化的是位置,素材和时间范围保持一致。
接入引擎时,先使用OH_AudioSuiteEngine_IsNodeTypeSupported检查所需节点类型,再创建管线及节点。方案B需要三个输入节点、三个空间节点、一个混音节点和一个输出节点。每个输入只连接自己的空间节点,三路空间结果汇入混音,最后进入唯一的输出。
固定摆位通过OH_AudioSuiteEngine_SetSpaceRenderPositionParams提交。输入和输出设置明确的PCM格式,其他效果节点不设置格式。下面用应用层伪代码表达准备流程,具体SDK调用集中在适配层中。
prepare(config):
validateAssetsAndPositions(config)
requireSupported(Input, SpaceRender, Mixer, Output)
graph = createPipeline()
try:
mixer = graph.createMixer()
output = graph.createOutput(projectFormat)
for source in config.sources:
input = graph.createInput(source, new InputState())
space = graph.createSpaceRender()
graph.setFixedPosition(space, source.position)
graph.connect(input, space)
graph.connect(space, mixer)
graph.bind(source.id, space)
graph.connect(mixer, output)
graph.start()
return graph
catch error:
graph.releaseCreatedResources()
throw error
其中每一步都要检查返回结果。创建成功不代表连接成功,连接成功也不代表参数已经设置成功。支持查询、建图、设置参数或启动失败时,应停止准备并记录失败阶段,不能把一个只完成部分配置的图交给用户试听。
输入回调根据各自游标提供数据。音频编创采用反向驱动:输出侧请求渲染,需求沿图向上游传递,最终触发输入读取。三路素材不需要各自启动一个播放器。
修改位置时,通过声源ID找到对应空间节点。位置必须是有限数且处于合法范围。UI拖动可以限制到边界,读取外部配置则应明确拒绝非法值,避免悄悄改变已保存的配方。参数提交成功后再更新已应用状态;失败时保留旧状态及错误信息,界面也应反映这次操作未生效。
实时预览允许修改效果参数,但不支持重新创建效果节点。需要改变组网结构时,应结束当前播放流程再重新准备。提交的线程约束和资源释放顺序要遵守所用SDK接口契约。
本篇先让两个版本分别从零播放,同一时刻只输出一个版本。连续拖动的参数提交策略留给第04篇。这样,独立摆位的问题不会被切换同步和实时负载掩盖。
4. 测试
测试从最便宜的检查开始。坐标映射不需要耳机,声源绑定也不需要听辨。把这些问题先排除,才能让听觉实验回答它真正擅长的问题。
| 检查 | 操作 | 观察点 |
|---|---|---|
| 坐标映射 | 输入中心、四角及越界坐标 | X/Z符号正确,Y为0,边界处理一致 |
| 参数校验 | 输入越界数值、无穷或非数值 | 非法配置被拒绝,旧配置不被覆盖 |
| 独立绑定 | 只修改bell的位置 | rain和page的参数保持原值 |
| 能力与生命周期 | 节点不支持或准备过程失败 | 记录失败阶段,不进入可听播放 |
独立绑定测试使用窗边阅读的副本,只把铃声从(2,0,2)改为(-2,0,2)。这使左右变化更容易比较,同时保留相同距离。它不是正式两版之间的切换,不能覆盖原来的窗边配方。
接下来先做单声源判断,再做三路混音判断。诊断素材使用分离的提示音、旁白和环境声,单独建立素材组,沿用相同格式和20秒规则。正式场景验收仍使用雨声、翻书声和街角铃声。诊断素材帮助定位问题,不会成为明信片的第四条轨道。
左右和前后应分开比较,每次只改变目标方向,保持素材、增益和距离一致。随后加入其他声音,观察混音后是否更难辨认。单路铃声可以被定位,并不能推出混合后的铃声仍然同样清楚。
试听需要固定设备、系统、SDK、输出路由、耳机或扬声器、音量以及额外音效开关。更换输出设备时另建测试组,否则设备变化会混入配置差异。
相同输入增益也不保证输出响度完全相同。空间处理后应检查峰值和响度;若需要制作响度匹配的听辨副本,保存原始输出,记录测量方法与整段缩放量。不要单独提高铃声增益,让“更响”代替“方向更清楚”。若发生削波,统一修订增益基线后重新比较两版。
匿名听辨时,听者只看到样本ID,不看到场景名称、坐标和图标。播放顺序随机,人数、重复次数在开始前确定。回答提供“不确定”选项;喜欢哪个版本另行记录,偏好不能替代方向判断。

图02-C:听者端仅展示匿名样本,实验者保留配置答案。结果按配置方向与判断方向汇总,并保留“不确定”;人数、次数和设备条件随记录一同保存。
一条记录可以采用下面的结构。期望方向由实验者保管,不传给听者界面。
sampleId, materialGroup, configRevision, graphScheme,
sourceId, expectedDirection, listenerId, playOrder,
device, outputRoute, volume, effectSettings,
parameterResult, judgedDirection, identifiable, notes
这里特意同时保留parameterResult和judgedDirection。前者回答接口是否接受参数,后者回答听者如何判断。如果参数设置成功,但方向判断集中在“不确定”,接下来应检查素材、输出条件和摆位设计,而不是把成功返回值写成体验结论。
两种组网也需要对照。方案A改变整体空间节点,方案B只改变铃声节点,比较时固定素材和增益。记录分别针对铃声、雨声和翻书声提问。即使后两路参数未变,铃声的变化仍可能影响注意力和掩蔽关系,因此不能要求主观描述逐字一致。
最终报告应分别呈现调用失败、左右判断、前后判断、三路可辨识性和不确定反馈。前后定位更困难、某些声音被掩蔽,都是需要保留的结果。没有足够记录时,先保留表头和用例;有了记录,再把结论限定在相应素材与设备条件内。
5. 结论
让雨声和铃声各就各位,需要在混音前保留独立的空间处理入口,并让声源身份贯穿界面、工程配置和音频节点。坐标映射决定操作表达了什么,组网结构决定参数能够影响什么,听辨记录决定用户实际辨认出了什么。
这三层各有自己的检查方式。把它们分开,错误就更容易定位:图标方向反了,检查坐标映射;移动铃声却改了整个组合,检查节点绑定;调用成功但方向难辨,检查素材和播放条件。
本篇留下的坐标约定、两套声源参数、组网对照和匿名记录字段,可以共同约束后续实现。它们描述固定摆位,不依赖头部追踪,也不保证不同耳机提供相同结果。动态运动、离线产物和分享封装仍有各自的问题要解决。
下一篇继续使用同一批20秒素材,以及“窗边阅读”和“街角散步”两套配置。新的问题是:当用户在第8秒切换版本时,怎样确保比较的仍然是素材的同一秒?
更多推荐




所有评论(0)