青铜文明展区的讲解音源已经被拆成独立播放节点。问题随之变成:观众在平面展区中向左移动两米,应用究竟应该把什么数据交给声音控制层?直接把触点坐标传给播放器,看似省事,却会把屏幕像素、展馆距离、声音音量和设备方向混在一起。页面缩放一次,声音关系就可能发生变化;换一张展厅平面图,原先的计算又失去意义。

这篇不急着给声音加特效,而是先建立一条可追踪的参数链:展馆坐标 → 听众朝向 → 相对方位与距离 → 声源目标增益 → 播放节点。定位和声音输出之间插入一份不可变的参数快照,便于检查为什么某件展品此时较近、另一件展品此时被压低。

展馆声源与听众的几何关系

展厅平面位置与声源参数的关系。

给位置约定统一的含义

音源清单仍然沿用前文稳定的 id。新的空间描述只增加 x、z 两个平面坐标和 baseGain:展厅的横向为 x,纵深为 z,单位统一为米。向右为 x 正向,面向展厅内部为 z 正向。听众的 yawDeg 表示相对于 z 正方向的顺时针角度;若导览地图旋转,先在地图层完成逆旋转,再更新听众的展馆坐标。

为什么先做二维?多数固定展柜位置可以用平面表达,展品高度和耳朵高度对几米范围内的导航提示不是首要变量。以后若需要立体位置,可把 y 与三维距离加入几何模块,而不改音源 ID 和播放状态的所有权结构。

参数单位来源进入音频层前的检查
source.x/z米展区布局配置有限数字,匹配当前展厅
listener.x/z米页面选择的观察点越界时保持上一个有效位置
listener.yawDeg度地图朝向或交互方向归一化至 -180°~180°
distance米两点欧氏距离不小于零
relativeAzimuthDeg度位置差减去听众朝向统一角度方向
gain0~1应用层距离曲线夹紧后再交付播放器

首先写一个没有 UI 依赖的纯计算模块。使用 atan2(dx, dz),而非 atan2(dz, dx),因为这里把展厅纵深定义为正前方;每个项目可以选择自己的坐标轴,但必须全链路一致。

// model/SpatialMath.ets
export interface HallPoint {
  x: number;
  z: number;
}
export interface ListenerPose extends HallPoint {
  yawDeg: number;
}
export interface PositionedSource extends HallPoint {
  id: string;
  title: string;
  baseGain: number;
}
export interface SpatialSnapshot {
  id: string;
  distanceM: number;
  relativeAzimuthDeg: number;
  targetGain: number;
}
function normalizeAngle(deg: number): number {
  return ((deg + 180) % 360 + 360) % 360 - 180;
}
function clamp01(v: number): number {
  return Math.max(0, Math.min(1, v));
}
export function spatialOf(
  source: PositionedSource,
  listener: ListenerPose
): SpatialSnapshot {
  const vals: number[] = [source.x, source.z, source.baseGain,
    listener.x, listener.z, listener.yawDeg];
  if (!vals.every((n: number) => Number.isFinite(n))) {
    throw new Error('空间参数必须是有限数字');
  }
  const dx: number = source.x - listener.x;
  const dz: number = source.z - listener.z;
  const distanceM: number = Math.sqrt(dx * dx + dz * dz);
  const absoluteDeg: number = Math.atan2(dx, dz) * 180 / Math.PI;
  const relativeAzimuthDeg: number = normalizeAngle(
    absoluteDeg - listener.yawDeg);
  const attenuation: number = 1 / (1 + 0.15 * distanceM * distanceM);
  return {
    id: source.id,
    distanceM: distanceM,
    relativeAzimuthDeg: relativeAzimuthDeg,
    targetGain: clamp01(source.baseGain * attenuation)
  };
}

这里的 0.15 是展馆业务曲线的形状参数,不是声学标准。它让远处声源逐渐减弱,又避免在零距离出现无穷增益。以 baseGain=1、距离 2.4m 为例,目标增益约 0.54。这一数值描述应用希望交付的流音量比例;实际响度还受到素材响度、系统音量和输出设备影响。方位角主要用于地图提示和后续空间渲染模块的数据准备,不能把它和声音已被左右声道定位画等号。

让参数快照成为可追踪的边界

从展示平面输入的并不一定都是平滑轨迹。用户拖拽观察点时,一次手势可能产生很多中间位置;如果每个位置都直接修改全部播放器,音频层会收到重复值。做法是把计算和提交拆开:计算层每次产出完整快照,提交层只针对变化超过阈值的增益发布更新。角度可以继续用于 UI 更新,不必强迫一次角度变化也触发音量写入。

// model/SpatialStore.ets
import { ListenerPose, PositionedSource,
  SpatialSnapshot, spatialOf } from './SpatialMath';

export interface SnapshotBatch {
  revision: number;
  items: SpatialSnapshot[];
}
export class SpatialStore {
  private revision: number = 0;
  private lastGain: Map<string, number> = new Map();
  private lastPose: ListenerPose = { x: 0, z: 0, yawDeg: 0 };

  update(pose: ListenerPose, sources: PositionedSource[]): SnapshotBatch {
    if (![pose.x, pose.z, pose.yawDeg].every(
      (n: number) => Number.isFinite(n))) {
      return { revision: this.revision, items: [] };
    }
    this.lastPose = { x: pose.x, z: pose.z, yawDeg: pose.yawDeg };
    const items: SpatialSnapshot[] = sources.map(
      (source: PositionedSource) => spatialOf(source, this.lastPose));
    this.revision += 1;
    return { revision: this.revision, items: items };
  }

  needsGainUpdate(item: SpatialSnapshot): boolean {
    const previous: number | undefined = this.lastGain.get(item.id);
    return previous === undefined ||
      Math.abs(previous - item.targetGain) >= 0.015;
  }
  acknowledge(id: string, gain: number): void {
    this.lastGain.set(id, gain);
  }
  clear(): void {
    this.lastGain.clear();
  }
}

revision 是会话内的参数版本,不必与音频播放器内部状态绑定。界面接收到旧版本时可以直接丢弃,防止一次耗时较长的资源切换把新位置覆盖掉。clear() 用在展区切换或播放节点被重建之后:新的播放器可能尚未接收当前增益,不能因为上一实例已经应用过同一数值就略过设置。

needsGainUpdate() 只负责比较,不提前修改应用记录;真正成功提交后,再由连接层调用 acknowledge()。播放对象还在准备资源时,应在音频桥接层保留待应用参数,而不是让界面提前把数值标成已生效。这样参数版本和应用结果分离,错误也有位置可查。

用受控桥接器连接前一篇的播放节点

前文的 AudioRack 管理环境音、讲解音及提示音的实例和生命周期。本篇不把播放对象重新放回页面。音频桥接器保存每个已关联 AVPlayer 的引用,对缺席实例缓存最新目标增益。待播放节点完成资源准备,再调用 flush()。调用 setVolume() 处理的是音频流音量,而不是系统音量;这是华为 Audio Kit 已明确介绍的用途。

// audio/SpatialGainBridge.ets
import { media } from '@kit.MediaKit';

export class SpatialGainBridge {
  private players: Map<string, media.AVPlayer> =
    new Map<string, media.AVPlayer>();
  private pending: Map<string, number> = new Map<string, number>();
  private applied: Map<string, number> = new Map<string, number>();

  setTarget(id: string, targetGain: number): void {
    if (!Number.isFinite(targetGain)) {
      return;
    }
    this.pending.set(id, Math.max(0, Math.min(1, targetGain)));
    this.flush(id);
  }
  bind(id: string, player: media.AVPlayer): void {
    this.players.set(id, player);
    this.applied.delete(id);
    this.flush(id);
  }
  flush(id: string): void {
    const player: media.AVPlayer | undefined = this.players.get(id);
    const target: number | undefined = this.pending.get(id);
    if (player === undefined || target === undefined) {
      return;
    }
    const oldValue: number | undefined = this.applied.get(id);
    if (oldValue !== undefined && Math.abs(oldValue - target) < 0.015) {
      return;
    }
    try {
      player.setVolume(target);
      this.applied.set(id, target);
    } catch (err) {
      // 由播放节点在进入可设置音量的状态后再次调用 flush(id)
      console.error(`音源${id}的流音量设置失败: ${JSON.stringify(err)}`);
    }
  }
  unbind(id: string): void {
    this.players.delete(id);
    this.applied.delete(id);
  }
  clear(): void {
    this.players.clear();
    this.pending.clear();
    this.applied.clear();
  }
}

桥接器的 bind() 应由前文节点在资源进入适合控制的状态后调用,unbind() 则应在 release() 前后按节点的所有权规则配对处理。对于短提示音,定位结果可以决定是否进入可听范围,但不要把极短的提示音与长环境声放在同一个高频增益更新队列里。提示音通常需要明确的触发门槛和冷却时间,以免观众停在边界位置时连续收到提示。

DevEco Studio 中的方位计算、参数快照与页面预览

方位、距离及目标音量分别在应用层计算和记录。

让参数面板反映同一份数据

调试入口最好直接展示 SpatialSnapshot,避免地图上显示一套坐标,播放器收到另一套坐标。例如青铜鼎在听众左前方时,卡片依次显示“相对方位角 -35°”“距离 2.4m”“目标音量 0.54”。这三个值都由同一次 spatialOf() 产出。角度不需要在页面中重新计算,也不应使用控件自身的位置来代替展馆坐标。

// model/HallPreset.ets
import { ListenerPose, PositionedSource,
  SpatialSnapshot, spatialOf } from './SpatialMath';

export const BRONZE_HALL_SOURCES: PositionedSource[] = [
  { id: 'bronze-story', title: '青铜器讲解', x: -1.38,
    z: 1.97, baseGain: 1 },
  { id: 'hall-ambient', title: '展厅环境音', x: 3.4,
    z: 1.8, baseGain: 0.55 },
  { id: 'display-hint', title: '展柜提示音', x: -2.6,
    z: 3.1, baseGain: 0.7 }
];
export const DEFAULT_LISTENER: ListenerPose = {
  x: 0, z: 0, yawDeg: 0
};
export function buildHallRows(): SpatialSnapshot[] {
  return BRONZE_HALL_SOURCES.map(
    (item: PositionedSource) => spatialOf(item, DEFAULT_LISTENER));
}

这组坐标是业务配置数据;在后续引入传感器朝向或用户地图拖动之前,用它来校验不同方向下的角度符号是否符合预期。检查方法很简单:听众原地向右转 90°,相对角度应该整体减去 90°;听众靠近某音源,距离单调下降,目标增益不应突然升高超过 1。还需要覆盖源点与听众重合、极大距离、非法数字以及切换展区时旧音源仍在队列中的情况。

值得给空间配置建立一个小型审查表。展馆新增一件文物,产品同事可能只会提交一张平面图和一句展品名称,音源坐标却需要和此前展厅的原点一致。可以要求每次提交音源配置时同时标出展区尺寸、原点位置和朝向;代码审查再检查声源是否落在可用展厅范围内。否则“音量突然太小”表面看是播放异常,实际是新音源被配置到了十几米之外。

数字展馆声源方位与距离参数界面

地图定位与参数卡使用一致的展区坐标。

哪些异常会把声音拉向错误的位置

第一类是位置坐标被换算两次。地图从设计稿尺寸放大到平板宽度时,如果先把触点转换为米,又在计算函数里再次除以缩放系数,听众位置会被错误拉向原点。解决方式是只允许地图输入适配层做像素到展馆米数的换算,核心模型永远只接收米。

第二类是朝向没有归一化。359° 和 -1° 代表相近方向,但未经归一化的 UI 会把它们当成几乎一整圈的跳变。normalizeAngle() 提供单一的 -180°~180°表示;在角度接近边界时,后续的平滑模块还要按最短角距离插值,不能直接对两端的角度做算术平均。

对于屏幕上的箭头,也要区分绝对方位和相对方位。绝对方位适合画在静态平面图上,相对方位适合写成“左前方”“右后方”或作为用户转身后的方向提示。二者虽然由同一组位置推导,却不应该共用一个未经标注的 angle 字段。把字段名写清楚,比事后猜测负号方向更有效。

第三类是旧实例接受新参数。音源切换需要异步准备资源,上一实例可能尚未释放。参数快照虽然有 revision,但它本身不会替音频节点判断实例是否仍然有效。正确连接点是音频节点的 serial 与新旧实例匹配后才绑定桥接器;释放节点同时解除绑定。页面不直接持有播放器实例,也不负责决定何时调用 release()。

还有一条不可忽略:增益并不等于完整空间音频。距离衰减和相对方位是业务层几何数据,AVPlayer.setVolume() 只控制流音量。需要进一步的方向渲染或头部相关处理时,应对照目标 SDK 的空间音频接口及设备支持情况建立单独适配器。AudioManager.getSpatializationManager() 是现有空间音频管理入口,但这篇计算出的角度并非该入口某个位置设置 API 的参数。

为下一篇留下稳定的更新接口

现在数据流已经足够清楚:资源 ID 负责识别展品,播放节点负责播放器的状态,空间模块负责几何与参数快照,桥接器负责把合规的增益应用到当前有效实例。业务可以独立更换地图布局、改写距离曲线、调整音源优先级,而不会把音频生命周期逻辑复制到每个控件中。

下一步要处理的是变化速度。用户转身时,角度可能跨越 ±180°;用户在地图上快速拖动时,瞬间距离也可能大幅改变。为了让这些变化在视觉与听觉层面连续,后续应为方位和增益分别设计最短路径插值、更新节流及目标值合并,而不是无限制增加播放器调用次数。

技术参考:HarmonyOS 音频播放音量管理、AudioManager 接口、HarmonyOS 空间计算能力。

Logo

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

更多推荐