数字展馆的观众从青铜鼎转向另一侧展柜,界面里的指向箭头应该跟着改变,环境声和讲解声也应该平稳衔接。但传感数据或拖动操作会在很短时间内送来大量朝向值。若每次变化都立刻计算所有展品的空间参数、逐一修改播放音量,虽然单次运算并不复杂,整条链路却容易出现无意义的重复更新。更棘手的是角度跨过正负一百八十度时,数值会突然从 179° 变成 -179°。直接做普通平均会让指向箭头绕向反方向。

上一节建立了 ListenerPose、SpatialStore 和 SpatialGainBridge:前者统一展馆坐标与朝向,中间层计算声源距离和相对方位,桥接层把应用计算的目标增益交给播放器。本节不改动这些约定,而是在输入侧插入最新值合并与角度平滑,在输出侧加入渐进增益。这三段分别负责更新频率、数值连续性和听感连续性,不能混成一个阈值判断。

数字展馆声场角度变化与平滑策略

从朝向采样到播放器增益的链路,需要分开处理采样频率和参数变化幅度。

数字跳变不等于真实的急转身

青铜文明展厅沿用横向 x、纵深 z 的米制坐标,yawDeg 以面向展厅内部为零度,顺时针为正。角度可表示成 [-180°, 180°) 的规范范围。这样做很方便展示,却产生了一个数学边界:178°、179°、-179°、-178° 依次变化,本来只转了四度,如果用相邻值相减,第二次会得到 -358°。把这个差值再乘平滑系数,相当于主动制造一次大角度反转。

正确处理方式是让角度差始终走最短圆弧。这里采用指数型平滑,但系数由实际时间间隔决定,而不是让每条输入消息都固定走 20%。固定系数在事件频率变化时会改变动画手感:频率越高,单位时间内更新次数越多,最终跟随速度反而变快。用时间常量计算 alpha = 1 - exp(-dt / tau),能让相同的物理时间对应相近的收敛过程。

// model/AngleSmoother.ets
function wrapAngle(value: number): number {
  return ((value + 180) % 360 + 360) % 360 - 180;
}

export class AngleSmoother {
  private current: number | undefined;
  private lastAt: number = 0;

  reset(): void {
    this.current = undefined;
    this.lastAt = 0;
  }

  update(input: number, atMs: number): number {
    if (!Number.isFinite(input) || !Number.isFinite(atMs)) {
      return this.current ?? 0;
    }
    const next = wrapAngle(input);
    if (this.current === undefined) {
      this.current = next;
      this.lastAt = atMs;
      return next;
    }
    const dt = Math.max(0, Math.min(200, atMs - this.lastAt));
    this.lastAt = atMs;
    const tauMs = 170;
    const alpha = 1 - Math.exp(-dt / tauMs);
    const delta = wrapAngle(next - this.current);
    this.current = wrapAngle(this.current + alpha * delta);
    return this.current;
  }
}

这段代码有三个刻意保留的细节。接到第一份有效值时直接设为当前角度,避免页面首次显示从零度缓慢旋到当前位置;无效数值不进入平滑状态,防止 NaN 逐步扩散;对 dt 做上限约束,防止页面长时间离开前台后,下一次输入因为巨大时间差而出现突变。170ms 是调校起点,并不是通用的声学常数;应结合导览反馈速度和方向指示的容忍度调整。

还有一种容易忽略的情况:用户突然从展区 A 切换到展区 B,两个展区使用不同地图坐标原点。此时不是继续做旧角度的插值,而是重置平滑器并提交新展区的第一份位置。平滑适用于同一坐标语义下的连续变化,不负责掩盖场景切换。

让输入以自己的节奏进入,而不是强迫播放器追赶

事件采样与播放参数更新应当采用不同的频率。假设界面正在连续拖动朝向滑块,采样可以保留最近值,音频参数在一个固定间隔内只消费最新快照。重要的是“保留最后一份”而不是“舍弃某个时间点之后的所有输入”:如果用户在极短时间内从左转到右,节流窗口结束时必须落在最新位置,不能停留在窗口开始的位置。

实现时,门控器只持有一个待发送位置和一个计时器。submit 可以被不同输入来源调用,比如触控方位盘、方向滑块,或者接入具体设备方向能力后的适配器。上游来源负责把数据转换成上一节的 ListenerPose,门控器无需知道来源名称。

// model/LatestPoseGate.ets
import { ListenerPose } from './SpatialMath';

export class LatestPoseGate {
  private pending: ListenerPose | undefined;
  private timer: number = -1;
  private lastDeliveredAt: number = -100000;

  constructor(private receive: (pose: ListenerPose, at: number) => void,
    private intervalMs: number = 80) {}

  submit(pose: ListenerPose): void {
    if (![pose.x, pose.z, pose.yawDeg].every(
      (v: number) => Number.isFinite(v))) { return; }
    this.pending = { x: pose.x, z: pose.z, yawDeg: pose.yawDeg };
    if (this.timer !== -1) { return; }
    const due = Math.max(0, this.lastDeliveredAt + this.intervalMs - Date.now());
    this.timer = setTimeout(() => {
      this.timer = -1;
      const latest = this.pending;
      this.pending = undefined;
      if (latest) {
        const now = Date.now();
        this.lastDeliveredAt = now;
        this.receive(latest, now);
      }
    }, due);
  }

  dispose(): void {
    if (this.timer !== -1) { clearTimeout(this.timer); }
    this.timer = -1;
    this.pending = undefined;
  }
}

门控器的 intervalMs 是业务刷新上限,不是音频输出的固定帧率。若声音服务在页面退出、展区销毁时已经结束,还允许旧定时回调继续更新,就会把已经释放的播放器当成活动节点。因此必须在页面销毁或展区解绑时调用 dispose()。假如输入位置本身带有时间戳,实际工程还可以比较样本的时间顺序,丢弃迟到且早于当前采样的旧位置。这里的 Date.now() 主要用于提交频率控制,并不承担跨设备时钟同步。

朝向和音量不应共享一个插值器

上一节 spatialOf() 给出 relativeAzimuthDeg、distanceM、targetGain。其中距离是展馆坐标计算值,角度由听众朝向参与计算;在原方案里,targetGain 主要取决于距离,并不会仅因转头改变。这一点需要讲明白,否则界面展示的朝向平滑与听感变化之间会出现解释不通的关系。

若业务要求观众面朝展品时突出讲解、背向展品时稍稍压低讲解,可以在应用层增加一个朝向权重,但不要把它当成系统三维空间声像渲染。这里的策略是把讲解声保持在 65% 以上的方向性系数,正前方到 100%;展厅环境音则不使用方向系数,避免参观者稍微转头就引起全场背景音忽大忽小。播放器音量始终钳制在 [0, 1]。

// audio/DirectionalGain.ets
import { SpatialSnapshot } from '../model/SpatialMath';

function clampGain(v: number): number {
  return Math.max(0, Math.min(1, v));
}

export function composeTargetGain(
  snapshot: SpatialSnapshot, role: string): number {
  if (role !== 'narration') {
    return clampGain(snapshot.targetGain);
  }
  const radians = snapshot.relativeAzimuthDeg * Math.PI / 180;
  const forward = Math.max(0, Math.cos(radians));
  const directionalWeight = 0.65 + forward * 0.35;
  return clampGain(snapshot.targetGain * directionalWeight);
}

这里的余弦只是一条平滑的业务音量曲线;讲解音源不会因为这段函数就获得左右耳独立声像,更不会自动拥有头部追踪效果。它适用于需要清晰解释控制规则的展馆导览:越靠近且越正对展品,讲解的目标增益越高;身处外围或背离展品时,增益逐步降低。真正的空间音频处理应按已开放的系统能力及对应版本文档选择接入方式,避免把一个浮点数的变化误认为完整的空间渲染。

目标值也不能直接作为每次输出的最终值。比如观众从一件展品迅速转到另一件展品,目标增益从 0.30 跳到 0.80,若一步写入播放器,就会形成明显的音量台阶。下方小型渐进控制器每 40ms 最多推进 0.04,对多个音源分别保存当前值,达到目标后自动停止定时器。它沿用上一节已经建立的 SpatialGainBridge,只改变写入节奏。

// audio/GainRamp.ets
import { SpatialGainBridge } from './SpatialGainBridge';

export class GainRamp {
  private current: Map<string, number> = new Map();
  private target: Map<string, number> = new Map();
  private interval: number = -1;

  constructor(private bridge: SpatialGainBridge) {}

  set(id: string, value: number): void {
    if (!Number.isFinite(value)) { return; }
    const targetValue = Math.max(0, Math.min(1, value));
    this.target.set(id, targetValue);
    if (!this.current.has(id)) { this.current.set(id, targetValue); }
    if (this.interval === -1) {
      this.interval = setInterval(() => this.tick(), 40);
    }
  }

  private tick(): void {
    let moving = false;
    this.target.forEach((dest: number, id: string) => {
      const now = this.current.get(id) ?? dest;
      const delta = dest - now;
      const next = now + Math.max(-0.04, Math.min(0.04, delta));
      this.current.set(id, next);
      this.bridge.setTarget(id, next);
      if (Math.abs(dest - next) > 0.0001) { moving = true; }
    });
    if (!moving && this.interval !== -1) {
      clearInterval(this.interval);
      this.interval = -1;
    }
  }

  dispose(): void {
    if (this.interval !== -1) { clearInterval(this.interval); }
    this.interval = -1;
    this.current.clear();
    this.target.clear();
  }
}

GainRamp 并不创建或释放 AVPlayer;它只将已经校验过的音量目标交给桥接层。播放器仍由第二篇的生命周期管理器负责。这里有一个实际项目中值得改进的细节:首次 set() 让当前值等于目标值,会尽量减少新音源绑定时的突变;如果产品要求新音源一定从静音渐入,可以把初始化值调整为零,并明确由播放节点启动之前先写入零音量。不要在播放器释放后留下计时器继续调用 setVolume()。

白色开发环境中的朝向筛选与音量目标调度

平滑处理、业务音量规则与播放器操作分别归属不同模块。

把三层接起来,并留下能定位问题的状态

协调器的主要动作很短:接收最新位置,利用 AngleSmoother 得到连续朝向,交给上一节 SpatialStore 生成快照,再为不同音源角色计算目标值。以下代码强调调用关系,三份业务模块与前文的文件位置相互对应。

// audio/SceneOrientationCoordinator.ets
import { ListenerPose, PositionedSource,
  SpatialSnapshot } from '../model/SpatialMath';
import { SpatialStore } from '../model/SpatialStore';
import { AngleSmoother } from '../model/AngleSmoother';
import { LatestPoseGate } from '../model/LatestPoseGate';
import { GainRamp } from './GainRamp';
import { composeTargetGain } from './DirectionalGain';

export class SceneOrientationCoordinator {
  private smoother: AngleSmoother = new AngleSmoother();
  private store: SpatialStore = new SpatialStore();
  private gate: LatestPoseGate;

  constructor(private sources: PositionedSource[],
    private ramp: GainRamp) {
    this.gate = new LatestPoseGate((pose: ListenerPose, now: number) => {
      const yaw = this.smoother.update(pose.yawDeg, now);
      const batch = this.store.update({ x: pose.x, z: pose.z,
        yawDeg: yaw }, this.sources);
      batch.items.forEach((item: SpatialSnapshot) => {
        const role = item.id === 'bronze-story' ? 'narration' : 'ambient';
        this.ramp.set(item.id, composeTargetGain(item, role));
      });
    });
  }

  onPoseChanged(pose: ListenerPose): void { this.gate.submit(pose); }
  close(): void {
    this.gate.dispose();
    this.ramp.dispose();
    this.smoother.reset();
  }
}

角色映射在这段代码里写得很小,只是为了和“青铜器讲解、展厅环境音”这两个既有资产保持连贯。资产多时应从第一篇的音源清单获取角色,不能依赖某个 ID 的拼写特征。若下一步支持多个讲解节点,可以将 role 放进 PositionedSource 或通过音源目录查询,算法本身无需改变。

开发环境下可用一个 Slider 提供可控的方向输入,便于逐项观察角度跨界、暂停拖动与快速旋转时的行为。按钮和滑块的数值都只改变 ListenerPose,无需直接修改播放器。这样的输入方式还可以在接入具体方向数据来源以前,把数值链路与播放资源链路分别检查。

声源朝向与目标音量的手机界面

应用界面可将原始方向、平滑方向、相对方位和目标增益分别展示,避免把不同阶段的数值混淆。

排查时建议记录三组信息:输入层的时间戳和原始 yawDeg,空间层的 relativeAzimuthDeg 与 targetGain,输出层的已提交增益和节点状态。若箭头跳变而音量稳定,重点看角度归一化;若箭头稳定但声音仍有突跳,检查目标值是否绕过了渐进控制器;若切换展区后出现旧音源回响,优先追查计时器、节点绑定和关闭顺序。不要用“每秒调用了多少次”单独代表听感质量,长时间节流也会牺牲用户转身时的响应速度。

一条稳定链路,比连续堆叠回调更有价值

到这里,数字展馆已经完成音源目录、播放节点、空间参数和朝向连续更新四个层次。它们之间最关键的约束是:输入源可以变,展馆坐标语义不变;处理频率可以调整,最终提交的始终是最新有效位置;音量曲线可以改,播放器生命周期的所有权不变。下一篇将讨论电话、导航等外部声音事件介入时,导览播放节点怎样记录意图、响应音频中断并恢复一致状态,使这套声场交互在复杂使用环境下仍然可控。

参考文档:华为开发者:播放音量管理;华为开发者:Slider 交互回调。

Logo

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

更多推荐