一个空间声场编辑页可以同时保存钢琴、雨声、提示音的位置,页面上的方位数字也可以随头部朝向变化。不过把它接入音频播放之后,会多出一个很容易忽略的问题:画面里的“正在播放”、播放器的实际状态、声位参数的版本,可能来自三个不同的更新时刻。用户按下暂停后如果继续调整钢琴的位置,重新开始时究竟应当采用哪组参数?我倾向于把这个问题放在会话状态层解决,而不是把按钮事件分散写在三个声源组件里面。

前两篇已经固定了三个声源的世界坐标:钢琴为(-2,0,3),雨声为(2,0,4),提示音为(0,1,2)。约定X轴朝右、Y轴朝上、Z轴朝前,以监听点(0,0,0)为原点;在头部朝向15°时,它们的相对水平方位依次为-48.69°、11.57°和-15°。本篇沿用这套几何数据,不改变坐标定义,而是处理位置变更与播放控制之间的先后关系。

播放会话与三声源状态的对应关系

一、播放按钮与声源配置不是同一个状态

页面通常包含声源清单、头部朝向滑动条和播放控制。声源卡片修改位置时,几何计算可以立即进行;音频播放器从准备到运行则涉及异步初始化,甚至可能在页面已经离开后才返回。把这些状态混在一个isPlaying布尔值里,暂停、失败、重建都不好表达。我在这里使用IDLE、PREPARING、READY、RUNNING、PAUSED、CLOSING和CLOSED七个应用层状态,另外保存单独递增的sceneRevision。

sceneRevision只说明声场快照何时更新,不等同于系统音频流的序号。修改头部角度或声源位置,增加一次修订;播放开始和暂停,改变会话状态,却不改动几何坐标。这样,界面在暂停时仍然可以编辑声场,播放恢复时从当前有效快照获取数据,不会因为一次按钮点击就丢失编辑结果。上一个章节的姿态时间戳过滤仍然保留,迟到的头部更新不能让新快照倒退。

当前三个声源可整理为下表,负号表示听者左侧,方位角统一归一化到[-180°,180°):

声源世界坐标世界方位头部15°时相对方位
S1 钢琴(-2,0,3)-33.69°-48.69°
S2 雨声(2,0,4)26.57°11.57°
S3 提示音(0,1,2)0°-15°

二、用不可变声场快照隔离播放更新

要解决的问题是:页面连续滑动头部角度时,旧请求不能覆盖新配置,同时需要让播放器拿到同一修订下的三个声源参数。我采用业务对象的深拷贝生成快照,在发布时一次性更新版本号;播放适配层接收整个快照,而不是逐个读取组件的可变字段。

export interface Vec3 { x: number; y: number; z: number }
export interface SourcePosition { id: string; name: string; position: Vec3 }
export interface BearingItem { id: string; degrees: number; distance: number }
export interface SceneSnapshot {
  revision: number;
  headYaw: number;
  bearings: BearingItem[];
}

function wrapDegrees(value: number): number {
  return ((value + 180) % 360 + 360) % 360 - 180;
}

export function createSnapshot(
  sources: SourcePosition[], headYaw: number, revision: number
): SceneSnapshot {
  const bearings: BearingItem[] = sources.map((source: SourcePosition) => {
    const p = source.position;
    const worldYaw = Math.atan2(p.x, p.z) * 180 / Math.PI;
    return {
      id: source.id,
      degrees: wrapDegrees(worldYaw - headYaw),
      distance: Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z)
    };
  });
  return { revision, headYaw, bearings };
}

这个计算函数没有持有页面节点、音频句柄或定时器,输入相同就会得到相同的结果。它沿用第一篇的方位定义和第二篇的角度归一化规则,方便把“声音应位于哪边”与“系统现在怎样输出”拆开排查。假设头部角度从15°调整到30°,几何层会得到钢琴-63.69°、雨声-3.43°、提示音-30°,但仅凭这些数值不会自动改变物理播放流。声位配置究竟由哪个音频处理节点消费,应在相应音频能力接入处明确处理。

三、用串行会话控制器处理播放和暂停

要解决的问题是连续点击造成的竞态。例如创建过程仍在等待,用户已经按过暂停;或者两次启动先后返回,较早的一次把界面改回运行态。会话操作统一排队,一次完成一项状态转移。sessionEpoch标识当前会话世代,异步结果回到界面前必须比较世代与关闭标记。

type SessionStatus = 'IDLE' | 'PREPARING' | 'READY' |
  'RUNNING' | 'PAUSED' | 'CLOSING' | 'CLOSED';

export interface PlaybackPort {
  prepare(): Promise<void>;
  start(): Promise<void>;
  pause(): Promise<void>;
  close(): Promise<void>;
}

export class SpatialSession {
  status: SessionStatus = 'IDLE';
  revision: number = 0;
  private epoch: number = 0;
  private chain: Promise<void> = Promise.resolve();
  constructor(private port: PlaybackPort) {}

  private enqueue(op: () => Promise<void>): Promise<void> {
    const next = this.chain.then(op);
    this.chain = next.catch(() => {});
    return next;
  }

  play(): Promise<void> {
    return this.enqueue(async () => {
      if (this.status === 'CLOSED' || this.status === 'CLOSING') return;
      const token = this.epoch;
      try {
        if (this.status === 'IDLE') {
          this.status = 'PREPARING';
          await this.port.prepare();
          if (token !== this.epoch) return;
          this.status = 'READY';
        }
        if (this.status === 'READY' || this.status === 'PAUSED') {
          await this.port.start();
          if (token === this.epoch) this.status = 'RUNNING';
        }
      } catch (error) {
        if (token === this.epoch) this.status = 'IDLE';
        throw error;
      }
    });
  }

  pause(): Promise<void> {
    return this.enqueue(async () => {
      if (this.status !== 'RUNNING') return;
      await this.port.pause();
      this.status = 'PAUSED';
    });
  }

  updateRevision(): void { this.revision += 1; }
  invalidate(): void { this.epoch += 1; }
}

这里的PlaybackPort是应用内适配协议;prepare、start和pause分别由具体播放实现衔接。它没有把音频流的每一个底层状态硬套为页面状态。enqueue把异常保留给当前调用方,同时恢复队列的继续执行能力。关闭流程还需要由专门的方法串行进入,这部分放在下一篇处理。实际接入时,应避免把invalidate作为立即释放资源的捷径,异步关闭仍需经过有序收尾。

四、将系统音频对象接在明确的适配位置

Audio Kit提供getAudioManager(),其getSpatializationManager()入口可以获取空间音频管理器;它说明系统具备相应管理接口,不能因此推导出任一播放流都已经采用应用需要的声源几何。应用层维护的relativeYaw和distance是可测试的数据合同,而不同声音节点如何使用这些值,需要按实际选用的音频处理方案连接。这里让播放创建代码与空间参数数据保持分离。

创建普通媒体用途AudioRenderer时,要明确采样率、声道数、样本格式与流用途。以下配置采用Audio Kit公开的对象与常量;PCM数据写入、音频焦点以及空间节点接线交由播放适配层完成。只有适配层正确驱动音频数据写入,播放按钮才有实际音频输出的含义。

import { audio } from '@kit.AudioKit';

export async function createMediaRenderer(): Promise<audio.AudioRenderer> {
  const options: audio.AudioRendererOptions = {
    streamInfo: {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
      channels: audio.AudioChannel.CHANNEL_2,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    },
    rendererInfo: {
      usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
      rendererFlags: 0
    }
  };
  const manager = audio.getAudioManager();
  const spatialManager: audio.AudioSpatializationManager =
    manager.getSpatializationManager();
  // 管理器由适配层持有;每条音频流是否采用空间处理需分别配置。
  void spatialManager;
  return await audio.createAudioRenderer(options);
}

图像中的READY表示业务层已收到适配层准备完成的结果,不宜把它写成系统承诺提供的固定会话名称。播放暂停后,声源几何仍在应用数据仓中。若底层发生播放中断,界面应以AudioRenderer的stateChange事件与当前世代协调更新,避免仅依据按钮的上一次点击猜测播放器状态。对系统事件订阅,还需在离开会话时成对撤销。

开发环境中的会话控制器与声位快照

五、把调试顺序改成“状态—版本—输出”

遇到“画面已经变到右侧,声音仍在左边”的问题,我会从三个记录入手:会话状态是否允许应用新快照、快照的revision是否为最新、选定的音频处理路径是否消费了这次声源方位变化。若会话仍是PREPARING,不能将更新丢弃,只需保留最新快照等待进入READY;若已PAUSED,声位编辑照常保存,恢复播放时补发当前修订。另一个容易遗漏的情况是AudioRenderer状态先变化而页面的按钮状态尚未刷新,这时应以音频事件更新状态映射,而非重复调用开始方法。

我会把应用侧记录控制在epoch、status、revision、headYaw和三组相对方位,足以定位主要顺序问题,又不会让高频姿态数据把HiLog淹没。对短时间连续点按,关注一次操作对应的状态前后值以及请求的入队顺序;对退出页面后返回的回调,关注它是否因为旧世代而被丢弃。三者都能在没有额外运行时依赖的纯函数或状态机测试中检查。

空间声场的播放控制与相对方位

六、会话继续,坐标模型保持稳定

这个工程阶段最重要的约束是把三份状态划开:SceneSnapshot保存有修订号的声位数据,SpatialSession安排播放控制顺序,AudioRenderer与空间音频管理器由独立适配层持有。暂停不会清掉头部15°或三组相对方位,声场快照也不会因为播放器重建而重新定义世界坐标。下一篇接着处理停止、资源释放、迟到事件和失败后的回退,把“可以继续播放”与“会话已经安全退出”区分开来。

参考资料:AudioManager与空间音频管理器、AudioRenderer播放流状态管理、HarmonyOS空间计算与空间音频。

Logo

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

更多推荐