数字展馆里的声音并不是独占设备的。参观者正在听青铜鼎的器型讲解,电话呼入,讲解暂停;通话结束后,页面仍显示“播放中”,进度却停住了。如果这时播放按钮又被点击一次,播放器可能收到与实际状态不一致的指令。另一个更隐蔽的问题出现在环境音:它在电话到来前已经播放,恢复焦点时却抢先启动,盖过了用户准备继续听的讲解。上一节解决的是方向样本过密和增益跃变,这一节需要给播放行为增加一条独立的控制线。

这里应该区分三件事:用户希望听什么、系统当前允许什么、播放器实际做到了什么。它们在正常播放时看似相同,遇到电话、导航语音、其他应用抢占音频时就会分开。把这三类信息塞进一个 isPlaying 布尔值,不仅难以恢复,还会破坏上一节的 GainRamp。正确的起点是把中断视为一次独立事件,先更新意图和节点状态,再决定是否发起播放命令。

数字展馆音频中断治理的视觉封面

展馆导览中的媒体状态,需要同时接受用户动作和外部音频焦点变化。

一次暂停,为什么要记住两份状态

以展厅环境音、青铜器讲解音、展柜提示音三种资源为例,它们可以继续沿用前几篇定义的稳定 sourceId。讲解音是用户主动选择的前景任务,环境音则是辅助氛围。电话中断后,两者的恢复规则不同:前景讲解可以向用户提供“继续播放”的操作;环境音不应只因为收到恢复提示就自行盖过讲解。至于短提示音,如果展示动作已经结束,恢复时补播一条过期提示反而会使参观者困惑。

为此可以建立一个与播放器无关的意图账本。它只记录业务事实,不保存 AVPlayer 对象,也不处理音频文件描述符。resumeRequested 表示用户有继续导览的意愿,interrupted 表示节点正受到外部焦点事件影响,generation 则为当前展品会话建立边界。切换展品后,即使旧回调迟到,也不能控制新会话。

// model/GuideIntentLedger.ets
export type AudioRole = 'ambient' | 'narration' | 'prompt';
export type GuidePhase = 'idle' | 'playing' | 'paused' | 'blocked';

export interface GuideIntent {
  id: string;
  role: AudioRole;
  phase: GuidePhase;
  resumeRequested: boolean;
  interrupted: boolean;
  generation: number;
}

export class GuideIntentLedger {
  private items: Map<string, GuideIntent> = new Map();
  private serial: number = 0;

  select(id: string, role: AudioRole): GuideIntent {
    const item: GuideIntent = {
      id: id, role: role, phase: 'idle',
      resumeRequested: role === 'narration',
      interrupted: false, generation: ++this.serial
    };
    this.items.set(id, item);
    return { ...item };
  }

  get(id: string): GuideIntent | undefined {
    const value = this.items.get(id);
    return value ? { ...value } : undefined;
  }

  update(id: string, patch: Partial<GuideIntent>): void {
    const original = this.items.get(id);
    if (!original) { return; }
    this.items.set(id, { ...original, ...patch });
  }

  cancel(id: string): void {
    this.items.delete(id);
  }
}

这里并没有把 phase 当成底层 AVPlayer.state 的复制品。播放器的 paused 是媒体状态,账本里的 blocked 是界面可以理解的“外部声音占用”。一旦用户自己按下暂停,应该将 resumeRequested 改成 false。否则用户暂停后恰巧接到电话,通话结束时程序又把讲解打开,行为就与用户意愿相反。对环境音也要设置清晰的优先级;焦点恢复只是“可以再次判断是否播放”,并不自动等于“所有节点都恢复”。

将 audioInterrupt 转换为业务事件

HarmonyOS 的媒体播放器可以监听 audioInterrupt。事件中的 forceType 用来辨别系统强制处理与应用可选择处理的情形,hintType 表示暂停、停止、降低音量、恢复音量等具体提示。对于系统已经强制暂停的节点,业务层不应重复执行一整套关闭与重新创建操作;更合适的做法是同步自身状态。永久停止与临时暂停也不能混为一谈,因为它们对应不同的恢复入口。

下面的适配器只负责把媒体事件翻译成可测试的业务动作,给协调器提供统一入口。中断事件的处理在一个地方收敛,既方便追踪,也避免每张展品页面各自注册一次监听。

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

export type FocusAction = 'pause' | 'stop' | 'duck' | 'unduck' | 'resume';

export class InterruptAdapter {
  constructor(private player: media.AVPlayer,
    private dispatch: (action: FocusAction) => void) {}

  private readonly handler = (event: audio.InterruptEvent): void => {
    switch (event.hintType) {
      case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
        this.dispatch('pause');
        break;
      case audio.InterruptHint.INTERRUPT_HINT_STOP:
        this.dispatch('stop');
        break;
      case audio.InterruptHint.INTERRUPT_HINT_DUCK:
        this.dispatch('duck');
        break;
      case audio.InterruptHint.INTERRUPT_HINT_UNDUCK:
        this.dispatch('unduck');
        break;
      case audio.InterruptHint.INTERRUPT_HINT_RESUME:
        if (event.forceType === audio.InterruptForceType.INTERRUPT_SHARE) {
          this.dispatch('resume');
        }
        break;
      default:
        break;
    }
  };

  attach(): void {
    this.player.on('audioInterrupt', this.handler);
  }

  detach(): void {
    this.player.off('audioInterrupt');
  }
}

开发中应注意 DUCK 与 PAUSE 的区别。前者意味着降低音量继续播放,后者意味着停止推进播放进度。上一节为声源设计的方向增益属于应用控制,而音频焦点策略属于系统级播放关系,两者计算源头不同。如果 DUCK 发生时直接把方向增益永久写成零,UNDUCK 到来后就缺少还原依据。更稳妥的做法是将基础增益、方向增益、焦点衰减系数分层保存;焦点恢复时只撤销焦点层的影响。

DevEco Studio 中的中断处理与播放状态页面

将播放状态回调、中断事件和页面提示集中到同一条追踪链。

恢复不是简单地调用一次 play

想象一个连续操作:参观者打开青铜鼎讲解;电话到来使音频暂停;通话尚未结束,参观者把页面切到另一个展品;焦点恢复提示随后到达。若回调里无条件 play(),恢复的可能是已经被取消的旧讲解。除了意图账本,还需要会话代际检查,以及“系统可播放、用户仍想播放、节点仍有效”的三重条件。

这里给出一个协调器,它与实际播放器之间通过很窄的端口交互。这样一来,界面状态就不必依赖播放器回调到达的先后顺序,也方便在展区切换时删除旧端口。恢复动作由用户按钮明确触发;resume 提示更新等待状态,之后再根据产品策略决定是否恢复。

// audio/GuideFocusCoordinator.ets
import { GuideIntentLedger } from '../model/GuideIntentLedger';
import { FocusAction } from './InterruptAdapter';

export interface PlaybackPort {
  play(): Promise<void>;
  pause(): Promise<void>;
  setGain(value: number): void;
}

export class GuideFocusCoordinator {
  private ledger: GuideIntentLedger = new GuideIntentLedger();
  private ports: Map<string, PlaybackPort> = new Map();

  register(id: string, role: 'ambient' | 'narration' | 'prompt',
    port: PlaybackPort): number {
    const entry = this.ledger.select(id, role);
    this.ports.set(id, port);
    return entry.generation;
  }

  onFocus(id: string, generation: number, action: FocusAction): void {
    const entry = this.ledger.get(id);
    if (!entry || entry.generation !== generation) { return; }
    if (action === 'pause' || action === 'stop') {
      this.ledger.update(id, {
        interrupted: true, phase: 'blocked',
        resumeRequested: action === 'pause' && entry.resumeRequested
      });
      return;
    }
    if (action === 'duck') { this.ports.get(id)?.setGain(0.2); return; }
    if (action === 'unduck') { this.ports.get(id)?.setGain(1); return; }
    if (action === 'resume') {
      this.ledger.update(id, { interrupted: false, phase: 'paused' });
    }
  }

  async confirmResume(id: string, generation: number): Promise<boolean> {
    const entry = this.ledger.get(id);
    const port = this.ports.get(id);
    if (!entry || !port || entry.generation !== generation ||
      entry.interrupted || !entry.resumeRequested || entry.role !== 'narration') {
      return false;
    }
    await port.play();
    this.ledger.update(id, { phase: 'playing' });
    return true;
  }

  remove(id: string): void {
    this.ledger.cancel(id);
    this.ports.delete(id);
  }
}

setGain(0.2) 与 setGain(1) 在上段中表示便于观察的焦点层系数,实际接到上一节的 GainRamp 时,应维护单独的 focusGain,由 baseGain × spatialGain × focusGain 统一合成目标值,而不能直接覆盖总音量。对于 STOP,应视为当前声音会话失效,让用户重新发起播放或选择展品;对于 PAUSE,保留有效的播放位置与讲解意图。port.play() 抛出异常时不要提前更新 phase,避免按钮显示“播放中”但媒体未进入播放状态。

真正接线时,每个 AVPlayer 实例只绑定一个适配器。createAVPlayer()、stateChange、error 和 audioInterrupt 分属生命周期不同环节;监听对象一旦释放,相关注册和待执行的渐进计时器都要关闭。特别是在展区切换后,旧代际的回调只能被忽略,不能重新打开前一个讲解节点。错误码应与音源 ID、当前代际、用户意图一起记录,定位时才能分辨“准备失败”与“焦点被让出”。

把状态显示给人看,而不是只显示一个按钮

页面可以保留两条相互独立的信息:当前节点的播放状态,以及播放为何停止。电话介入时,主按钮切换成暂停状态,同时显示“外部音频占用”;焦点归还后,主按钮再呈现“继续导览”。环境音可以单独保持静默,直到讲解恢复并完成音量安排。不要只观察 timeUpdate 是否继续上报来判断焦点已经恢复,播放进度可能受缓冲、停止或资源切换影响。

// pages/GuideStatusPanel.ets
@Entry
@Component
struct GuideStatusPanel {
  @State title: string = '青铜鼎讲解';
  @State blocked: boolean = true;
  @State readyToResume: boolean = false;
  @State progressLabel: string = '01:26 / 04:18';

  @Builder
  private ResumeArea() {
    Column({ space: 12 }) {
      Text(this.blocked ? '外部声音占用 · 已暂停' :
        (this.readyToResume ? '焦点已恢复 · 等待确认' : '导览状态正常'))
        .fontSize(16)
        .fontColor('#B3D9F6');
      Button('继续播放')
        .enabled(!this.blocked && this.readyToResume)
        .onClick(() => {
          // 将当前展品的 sourceId 和 generation 交给协调器
          this.readyToResume = false;
        });
      Text(this.progressLabel).fontColor('#D8E8FF');
    }
  }

  build() {
    Column({ space: 20 }) {
      Text(this.title).fontSize(25).fontWeight(FontWeight.Bold);
      this.ResumeArea();
      Text('展厅环境音在导览恢复后再统一调整')
        .fontSize(14).fontColor('#869AB8');
    }
    .padding(24)
    .width('100%')
    .backgroundColor('#112843');
  }
}

数字展馆音频中断后的导览恢复页面

页面分别呈现用户播放意图、外部中断状态与恢复入口。

排查这类问题时,可以按“音源 ID → 用户意图 → audioInterrupt 类型 → 实际 stateChange → 恢复命令”逐段检查。电话接入时,应观察是否仅暂停受影响节点,背景声是否保持可控;通话结束时,应检查旧代际没有取得控制权;用户主动按暂停后,应确认恢复提示不会绕过这一决定。对于降低音量和还原音量的场景,则重点看合成后的增益是否因层次覆盖而失去原值。这样定位得到的是状态之间的因果关系,而不是几条孤立的播放日志。

状态事件的时间顺序也值得单独检查。假设讲解节点接到 PAUSE 后进入等待,用户在半秒内又切换展柜,旧节点释放时仍会发送一次 stateChange。如果页面直接用最后到达的回调修改全局状态,就会把新展柜的“准备中”覆盖成旧节点的“已暂停”。这正是代际编号存在的原因:事件在产生时要绑定源 ID 和代际,协调器收到后验证两者是否仍有效。对同一代际内的多个异步播放请求,还应按顺序提交或采用操作序列号,避免后发的暂停被先发的播放完成回调反向覆盖。

另外,展厅环境音通常比讲解音更早创建,也更容易在页面隐藏时被忽略。界面退出不等于用户永久关闭导览,页面短暂遮挡和真正离开展区应走不同的生命周期路径。建议在业务层分别记录 sceneVisible、foregroundSourceId 与 focusAvailable,只有三项共同满足时才允许调整前景讲解;环境音按更低优先级处理。对于来电时需要快速静默的情景,最关键的不是重复触发 pause(),而是确保页面定时器、渐变器与状态提示和媒体焦点一起进入一致的暂停态。

还有一个容易遗漏的场景是音频短暂被降低音量后,又接着被强制暂停。此时恢复顺序不能简单按“最后一个提示到来”推断;协调器应从当前实际焦点阶段计算最终音量。可以把 DUCK 当作一个覆盖在空间增益之上的暂态系数,在 PAUSE 中保留数值但停止渐变输出,在确认恢复播放后重新从当前安全音量渐进到目标值。这样既避免突然放大,也保住了之前按距离和方位计算出的音场关系。

状态事件的时间顺序也值得单独检查。假设讲解节点接到 PAUSE 后进入等待,用户在半秒内又切换展柜,旧节点释放时仍会发送一次 stateChange。如果页面直接用最后到达的回调修改全局状态,就会把新展柜的“准备中”覆盖成旧节点的“已暂停”。这正是代际编号存在的原因:事件在产生时要绑定源 ID 和代际,协调器收到后验证两者是否仍有效。对同一代际内的多个异步播放请求,还应按顺序提交或采用操作序列号,避免后发的暂停被先发的播放完成回调反向覆盖。

另外,展厅环境音通常比讲解音更早创建,也更容易在页面隐藏时被忽略。界面退出不等于用户永久关闭导览,页面短暂遮挡和真正离开展区应走不同的生命周期路径。建议在业务层分别记录 sceneVisible、foregroundSourceId 与 focusAvailable,只有三项共同满足时才允许调整前景讲解;环境音按更低优先级处理。对于来电时需要快速静默的情景,最关键的不是重复触发 pause(),而是确保页面定时器、渐变器与状态提示和媒体焦点一起进入一致的暂停态。

还有一个容易遗漏的场景是音频短暂被降低音量后,又接着被强制暂停。此时恢复顺序不能简单按“最后一个提示到来”推断;协调器应从当前实际焦点阶段计算最终音量。可以把 DUCK 当作一个覆盖在空间增益之上的暂态系数,在 PAUSE 中保留数值但停止渐变输出,在确认恢复播放后重新从当前安全音量渐进到目标值。这样既避免突然放大,也保住了之前按距离和方位计算出的音场关系。

让声场控制服从播放会话

到这里,展馆声音已经从素材管理、节点生命周期、方位计算、平滑更新走到音频焦点协调。一个可长期维护的声景系统,核心不是永远保持声音持续,而是在外部事件改变播放条件时,仍能忠实保留用户动作和业务语义。下一篇可以沿着这条思路处理设备间的声场信息传递:哪些数据属于展馆场景,哪些属于本地播放器,何时迁移控制权,怎样让两个终端不同时抢播同一段讲解。

参考文档:华为开发者:音视频播放与中断处理;华为开发者:音频管理;华为开发者:音量管理。

Logo

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

更多推荐