声音的方位感怎么做、耳机切换与音频焦点怎么处理、播放器和音效怎么接——这篇文章用一个跑在真机上的 Flutter 工程,把这三个问题一次讲透。

1. 声音是"扁"的——这个问题是怎么找上我的

起因其实很简单。「空间音频」这个词我见了很久——耳机商品的详情页上、手机系统的声音设置里都有,但一直没认真搞懂它背后到底做了什么。最近在系统性地折腾 Flutter on HarmonyOS,想把音频这一块的能力挨个过一遍,空间音频正好排在最前面。

动手之前,我的理解跟大多数人一样朴素:空间音频不就是左右声道调音量吗?为了验证这个想法,我随手写了段测试代码——48kHz 双声道 PCM,左耳乘个大系数、右耳乘个小系数,戴上耳机一听,确实分得出左右,但整个声场是"扁"的:声音永远贴着两耳之间那条线滑动,闭着眼感受,它出不了头,也分不出前后。跟系统播放器开着空间音频时"声音落在脑袋外面某个具体位置"的体验,完全不是一回事。

这时候再回头查资料,才发现自己想简单了。人对声源方位的感知,靠的是两只耳朵收到信号之间的微小时间差和强度差,加上外耳对声音的滤波(也就是常说的 HRTF),"左右声道调音量"只是这套机制里最粗糙的一角。

所以这个题目的边界其实很清楚:在 Flutter 里,把鸿蒙侧的空间音频相关能力桥接出来,渲染层自己补。具体拆成三件事,也就是「媒体:空间音频」这个题目点名的三个技术方向:

  • 声音的方位感怎么做(这是核心,也是最有趣的部分);
  • 耳机切换与音频焦点(插拔耳机、来电抢占这些"音频社交礼仪");
  • 播放器与音效接入(一个正经的 AVPlayer,加几个低延迟音效)。

下面是干完之后的完整记录,包括我踩的每一个坑。

2. 效果展示

按老规矩,先给效果再讲原理。Demo 叫"空间音频实验室",三个 Tab 正好对应上面三个技术点。

第一个页面是声场实验室。屏幕中央是一个俯视的人头(鼻尖朝上,也就是正前方),外面一圈轨迹,轨迹上的音符图标是声源。你可以用手指拖着声源绕着头转,也可以打开"自动环绕"让它自己转圈:

在这里插入图片描述

关键是耳朵里的变化:戴上任意一副普通立体声耳机(不用支持空间音频的高级货),点"开始发声",把声源拖到右边,声音明显从右耳方向来;拖到左边,换到左耳;拖到正前方,两耳均衡但你依然能"感觉"它在前面——虽然这个"前面"的置信度会低一些,后面会解释为什么。页面下方有两根电平表,实时显示左右耳的增益差和 ITD(双耳时间差)数值,拖动声源时数值跟着跳,参数是原生侧算完回传上来的,不是 UI 自己演的。

信号源有三种可选:440Hz 纯音、白噪声、扫频音。做听感实验的时候建议用白噪声——纯音是单频,时间差在稳态下不太听得出(这是心理声学里的经典坑,后文细说),白噪声的方位感是最"扎实"的。

第二个页面是设备与焦点。上半部分列出当前所有输出设备,每个设备标注了类型(扬声器、有线耳机、蓝牙 A2DP)和一个绿色标签——是否支持系统空间渲染;中间是"拔出耳机自动暂停"的开关;下面是滚动的实时事件日志,插拔耳机、焦点抢占、系统空间音频开关变化,全都带时间戳记在这里:

在这里插入图片描述

第三页是播放器。上半部分是一个标准播放器,播放内置的演示音轨(19.2 秒,我自己用 Python 合成的五声音阶琶音,避免任何版权问题),支持进度拖动和单曲循环;下面是三枚音效按钮"叮、嗒、嗖";再往下是我自己最喜欢的一个小设计——焦点实验台:

在这里插入图片描述

把"音效流:独立焦点"开关打开,先让音乐播起来,再点一下"叮"——音乐立刻被暂停,事件日志里同步出现一条 audioInterrupt 记录;把开关切回"共享焦点",再点"叮"——音乐和音效同时出声,互不打扰。同一个按钮,两种结果,中间只差一行 setInterruptMode 的调用。自己来回点两遍,比看十页文档都直观。

在这里插入图片描述

3. 方位感到底从哪来:两只耳朵的三条线索

写代码之前得先把原理捋好。

人在水平面上判断声源方位,主要靠三条线索(心理声学里叫双耳线索):

线索一:双耳时间差(ITD,Interaural Time Difference)。 声音走的是机械波,速度约 340m/s。成年人的头宽大约 20cm 出头,声源在正右侧时,声音到左耳要比到右耳多绕半个头,这个时间差最大约 0.6~0.65ms。别小看这零点几毫秒,听觉系统对它的分辨率高得惊人。经典的 Woodworth 公式把它近似成:

ITD ≈ (r/c) · (θ + sin θ)      // r 头半径、c 声速、θ 方位角

工程上常用更简单的形式:ITD = 0.65ms × sin(方位角)——声源在正前正后(sin=0)时无时间差,在正左正右(|sin|=1)时达到最大值。我在 Demo 里用的就是后者,够用了。

线索二:双耳声级差(ILD,Interaural Level Difference)。 头是一个实心的障碍物,频率越高的声音越容易被头"挡住"(衍射能力随波长变短而下降)。声源偏右时,左耳收到的高频成分会明显弱一截。简化处理:给远耳一个最多 14dB 左右的衰减,衰减量同样跟着 |sin θ| 走。

线索三:频谱整形(头影效应 + 耳廓效应)。 上面两条已经能做出不错的水平方位感,但前后分辨还是差。真实听觉靠耳廓的复杂反射滤波(pinna effect)区分前后,这个没法用简单公式复刻,我用了偷懒但有效的近似:给远耳加一个一阶低通(截止频率 2.2kHz 到 1.0kHz 之间随 |sin θ| 变化),模拟头部遮挡造成的高频损失。实测对"侧方"的定位有不错的加成。

把这三条叠起来,就是 Demo 里那套"HRTF":普通立体声耳机 + 双耳渲染 = 水平面方位感。它当然不如系统级空间音频(那是个性化 HRTF + 头部追踪 + 传感器融合的完整方案),但它有一个非常大的优点——不挑设备,而且全部计算量极小,48kHz 实时合成在手机上毫无压力。

也要说清楚它的能力边界:ITD 和 ILD 只由侧向角决定,而方位角 θ 和 180°−θ 的侧向分量相同,所以纯双耳线索存在天然的"前后镜像"混淆——这就是很多人戴耳机听传统立体声时"分不清前后"的根源。真实听觉靠耳廓滤波和头部微小转动的动态信息来消解这个歧义;系统级空间音频的头部追踪本质上就是把这个"转动线索"补回来。了解了这个边界,你就知道什么时候该用自研渲染(水平面方位感、任何耳机、零依赖),什么时候该引导用户开系统空间化(完整体验、依赖设备支持)。

这里也顺带说一个我亲身踩过的认知坑:为什么用纯音(440Hz)的时候方位感不明显?因为稳态纯音的 ITD 感知依赖相位差,而 440Hz 波长近 80cm,远大于头宽,双耳相位差很小;白噪声包含全频段瞬态结构,时间差线索直接暴露出来。这也是我把白噪声放进信号源列表的原因——做空间音频调试,白噪声才是标准测试信号。

再说系统侧。鸿蒙从 API 版本 12/18 起在 @ohos.multimedia.audio 里提供了一套空间化接口:AudioSpatializationManager 可以查询当前设备空间化是否开启(isSpatializationEnabledForCurrentDevice),还能监听它的变化事件;输出设备的描述符上也有 spatializationSupported 字段标注硬件能力。要注意的是:开关本身是用户的系统设置权限,三方应用只读(写接口是系统应用专属),App 能做的是把状态读出来、把变化事件接住、在 UI 上给出引导。Demo 首页那张"系统空间音频"状态卡就是这么做的,真机上你把系统设置里的空间音频一开一关,卡片会实时跳变。

在这里插入图片描述

4. 工程怎么搭:一条通道,三个引擎

先看整体结构,再进代码。Flutter 侧就是普通的 Material 3 应用,三个页面加一个桥接服务;鸿蒙侧的宿主工程由 flutter create --platforms ohos 生成,我在里面加了一个 ArkTS 文件,所有和系统音频打交道的代码都集中在那里:

spatial_audio_demo/
├─ lib/                                # Flutter 侧
│  ├─ main.dart                        # 入口 + 底部导航
│  ├─ services/audio_bridge.dart       # 通道封装(含非鸿蒙平台降级)
│  └─ pages/
│     ├─ field_lab_page.dart           # 声场实验室(360° 声场画布)
│     ├─ routing_focus_page.dart       # 设备与焦点
│     └─ player_page.dart              # 播放器 + 焦点实验
├─ ohos/entry/src/main/ets/
│  ├─ entryability/EntryAbility.ets    # 注册通道
│  └─ spatial/SpatialAudioBridge.ets   # ★ 全部原生音频逻辑
└─ ohos/entry/src/main/resources/rawfile/
   └─ demo_track.wav                   # 演示音轨

在这里插入图片描述

通道设计上我没有搞花活,一个 MethodChannel 负责方法调用,一个 EventChannel 负责原生到 Dart 的所有事件推送(插拔、焦点、空间化状态、播放进度、合成参数),事件体里带 type 字段区分:

// lib/services/audio_bridge.dart(节选)
static const MethodChannel _methods =
    MethodChannel('spatial_audio_demo/methods');
static const EventChannel _events =
    EventChannel('spatial_audio_demo/events');

有一个细节值得说:桥接层做了平台降级。init() 的时候先调一次 getSnapshot,如果抛 MissingPluginException(比如你顺手在 Android 模拟器上跑了一下 UI),就标记 supported = false,后续所有方法调用静默忽略,AppBar 上会出现一个"无原生桥"的小角标。不然你在别的平台上打开 App 点啥都没反应,还以为代码坏了——这种"静默失败"的锅我背过,印象深刻。

ArkTS 侧的注册发生在 EntryAbility 里,拿 FlutterEngine 的 BinaryMessenger 就行:

// ohos/entry/src/main/ets/entryability/EntryAbility.ets
export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
    // 注册空间音频桥:合成/路由/焦点/播放器全部从这里出去
    SpatialAudioBridge.getInstance().attach(
      this.context,
      flutterEngine.dartExecutor.getBinaryMessenger()
    )
  }

  onDestroy(): void {
    // 释放音频资源,别让渲染器泄漏占着焦点
    SpatialAudioBridge.getInstance().detach()
    super.onDestroy()
  }
}

桥内部按职责拆成四个类:SynthEngine(方位感合成)、PlayerController(AVPlayer)、SfxEngine(音效),加上路由和空间化监听挂在桥本身。下面逐个讲核心代码。

5. 把代码讲透

5.1 声场合成:每 100ms 一次"双耳渲染"

先创建渲染器。参数很常规,48kHz、双声道、S16LE 的 PCM,usage 用 STREAM_USAGE_MUSIC(这个字段决定你的流在焦点体系里的"身份",音乐类就老实写 MUSIC):

// SpatialAudioBridge.ets - SynthEngine.start()
const opts: 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
  }
};
this.renderer = await audio.createAudioRenderer(opts);
await this.renderer.start();

然后是整个 Demo 最核心的 40 行——renderChunk,把第 3 节的三条线索翻译成代码。每块合成 100ms(4800 帧),写进一个 ArrayBuffer 再喂给渲染器:

// SpatialAudioBridge.ets - renderChunk():一块 100ms 立体声的诞生
private renderChunk(view: DataView, frames: number): void {
  // 方位角一阶平滑(时间常数约 20ms):拖动声源时避免参数跳变产生爆音
  const targetRad = this.azimuthDeg * Math.PI / 180;
  this.azSmoothRad += (targetRad - this.azSmoothRad) * 0.15;
  const lateral = Math.sin(this.azSmoothRad);      // 侧向分量,方位感的关键变量

  // 线索一 ITD:0.65ms × sin(θ),折算成采样点数(48kHz 下最多约 31 个采样)
  const itdSamp = 0.00065 * lateral * SAMPLE_RATE;
  const delayL = Math.max(0, Math.round(itdSamp));  // 声源在右 → 左耳晚收到
  const delayR = Math.max(0, Math.round(-itdSamp));

  // 线索二 ILD:远耳最多衰减 14dB,折算成线性增益
  const gFar = Math.pow(10, -14 * Math.abs(lateral) / 20);
  const gL = lateral >= 0 ? gFar : 1;
  const gR = lateral >= 0 ? 1 : gFar;

  // 线索三 头影:远耳低通,截止频率 2.2kHz → 1.0kHz 随侧向程度下降
  const fcFar = 2200 - 1200 * Math.abs(lateral);
  const aFar = 1 - Math.exp(-2 * Math.PI * fcFar / SAMPLE_RATE);

  for (let i = 0; i < frames; i++) {
    const t = this.frameIndex / SAMPLE_RATE;
    let s = 0;
    if (this.tone === 'noise') {
      s = (Math.random() * 2 - 1) * 0.25;            // 白噪声
    } else if (this.tone === 'chirp') {
      s = Math.sin(2 * Math.PI * 440 * t) * 0.3;     // 演示版扫频简化为纯音
    } else {
      s = Math.sin(2 * Math.PI * 440 * t) * 0.3;     // 440Hz 纯音
    }
    // 启动淡入包络:抑制"咔"的爆破音
    if (this.attackFrames > 0) {
      s *= (1 - this.attackFrames / (SAMPLE_RATE / 30));
      this.attackFrames--;
    }
    // 单声道信号写入环形缓冲,两耳按各自延迟取样 → ITD 生效
    this.ring[this.ringPos] = s;
    const sl = this.ring[(this.ringPos - delayL + this.ring.length) % this.ring.length];
    const sr = this.ring[(this.ringPos - delayR + this.ring.length) % this.ring.length];
    this.ringPos = (this.ringPos + 1) % this.ring.length;

    // 远耳做一阶低通(头影效应近似)
    if (lateral >= 0) { this.lpL += aFar * (sl - this.lpL); }
    else              { this.lpR += aFar * (sr - this.lpR); }
    const outL = lateral >= 0 ? this.lpL : sl;
    const outR = lateral >= 0 ? sr : this.lpR;

    // 交织写入 L/R,注意 PCM16 小端序(第三个参数 true)
    const idx = i * 4;
    view.setInt16(idx,     this.clamp16(outL * gL), true);
    view.setInt16(idx + 2, this.clamp16(outR * gR), true);
    this.frameIndex++;
  }
  // 把本块用到的参数快照推给 Flutter(UI 电平表/ITD 读数的数据源)
  this.notifyParams();
}

几个我调试时才意识到的地方,写出来给后来人省时间:

ITD 用环形缓冲实现,而不是给每个耳朵单独算相位。 我第一版给左右耳各维护一个相位累加器直接算 sin(2πf(t+Δt)),纯音是没问题的,但换到白噪声就傻眼了——噪声没有相位可言,只能延迟取样。所以正确做法是:把"声源信号"当成单一时间序列写进环形缓冲(长度 256 采样,远大于最大延迟 31 采样),两只耳朵各自按 ringPos - delay 取值。这样纯音、噪声、扫频、甚至以后接真实音频文件,都不用改渲染逻辑。

setInt16 的第三个参数必须是 true。 PCM16 小端序,ArkTS 的 DataView 默认是大端,忘了这个参数的声音是一坨噪音——我盯着这个"bug"听了十分钟,一度怀疑自己的 ITD 公式写错了。

爆音要在三处防。 启动时(淡入包络)、拖动时(方位角平滑,系数 0.15 相当于约 20ms 时间常数)、削波(clamp16)。少防一处,用户的耳朵就会替你发现。

写循环本身很朴素——await write() 会阻塞到数据拷入服务侧缓冲,所以一个 while 循环就能把 PCM 源源不断送出去,不占 UI 线程:

private async fillLoop(): Promise<void> {
  if (!this.loopStopped) return;          // 防重入
  this.loopStopped = false;
  const framesPerChunk = SAMPLE_RATE / 10;  // 100ms
  const pcm = new ArrayBuffer(framesPerChunk * 2 * 2);
  const view = new DataView(pcm);
  while (this.running && this.renderer !== null) {
    this.renderChunk(view, framesPerChunk);
    try {
      const written = await this.renderer.write(pcm);
      if (written < 0) break;             // 写失败就退出,别死循环
    } catch (e) {
      break;
    }
  }
  this.loopStopped = true;
}

顺手记一笔性能账:每块 4800 帧、双声道 PCM16 是 19200 字节,一秒十块,即每秒 192KB 的合成与传输量;每帧的计算是两次三角函数、几次乘加和一个低通,ArkTS 在真机上跑这个毫无压力(帧预算是 10ms,实际单块合成耗时远低于 1ms)。环形缓冲开的 256 采样,只为容纳最大 31 采样的 ITD 延迟,内存可以忽略不计。换句话说,这套渲染层哪怕同时跑十几个声源(比如游戏场景),也远够不着性能墙——瓶颈根本不在这里,而在你怎么设计混音策略。

5.2 耳机插拔:deviceChange 与"拔了就停"

设备路由这块,鸿蒙的 API 坡度不大,但有个签名差异容易踩(见踩坑一节)。核心就两步:先拿管理器,再挂监听。注意 getRoutingManager() 是 AudioManager 上的方法,不是模块级函数:

// SpatialAudioBridge.ets - attach()
const audioManager = audio.getAudioManager();
this.routing = audioManager.getRoutingManager();       // API 12+
this.spatial = audioManager.getSpatializationManager(); // API 18+

// 输出设备插拔监听:第二个参数要传 deviceFlag,和普通 on() 的签名不一样
this.routing.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG,
  (action: audio.DeviceChangeAction) => {
    const devices = action.deviceDescriptors.map(toDeviceInfo); // 转成跨端友好的结构
    this.emit({
      type: 'deviceChange',
      action: action.type === audio.DeviceChangeType.CONNECT ? 'connect' : 'disconnect',
      devices: devices,
      preferred: this.preferredDevice()
    });
  });

AudioDeviceDescriptor 上的 spatializationSupported 字段(API 18 起)值得单独念叨:它能告诉你当前接的这个设备能不能做系统空间渲染。Demo 设备列表里那个绿色小标签就是它,蓝牙 A2DP 耳机插上瞬间就能看到差异。另外我还用了 getPreferredOutputDeviceForRendererInfoSync() 查"MUSIC 流实际会走哪个设备",这个信息比设备列表更有用——比如蓝牙和有线同时插着的时候,用户关心的其实是"我的声音从哪出"。

"拔出耳机自动暂停"是产品侧的正确礼仪(否则安静场合一拔耳机,私房歌单当场外放,社死)。逻辑放在 Dart 侧做决策、原生侧只上报事件,这样产品规则改起来不用动 ArkTS:

// lib/pages/routing_focus_page.dart
void _onDeviceChange(AudioSnapshot s) {
  setState(() => _snapshot = s);
  final unplug = !s.headphoneConnected;
  if (unplug && _autoPauseOnUnplug && _wasPlaying) {
    AudioBridge.instance.playerPause();     // AVPlayer 暂停
    AudioBridge.instance.synthStop();       // 声场合成也停掉
  }
}

5.3 音频焦点:InterruptMode 的两种玩法

音频焦点是我觉得鸿蒙音频体系里设计得比较克制的一部分:你的流开始播放时自动申请焦点,被抢占时通过 audioInterrupt 事件告诉你,怎么响应(暂停?降音量?无视?)由你自己定。不需要显式 requestAudioFocus 这一套(老 API 已废),渲染器本身就是焦点单元。

合成引擎上的监听长这样:

// SynthEngine.start() 里注册
this.renderer.on('audioInterrupt', (ev: audio.InterruptEvent) => {
  if (ev.hintType === audio.InterruptHint.INTERRUPT_HINT_PAUSE ||
      ev.hintType === audio.InterruptHint.INTERRUPT_HINT_STOP) {
    this.running = false;              // 停写循环 = 暂停,同时上报给 UI
    this.notifyInterrupt(ev);
  } else if (ev.hintType === audio.InterruptHint.INTERRUPT_HINT_RESUME) {
    this.running = true;               // 对方释放焦点,系统提示可以恢复
    this.fillLoop();
    this.notifyInterrupt(ev);
  } else {
    this.notifyInterrupt(ev);          // IGNORE 等提示:记录但不动作
  }
});

真正有意思的是同一个应用内部的焦点关系,由 setInterruptMode 决定:

  • INDEPENDENT_MODE(独立焦点):每条流独立申请,音效一响,主音乐被打断——适合"音效比 BGM 重要"的场景,比如游戏里的技能音效;
  • SHARE_MODE(共享焦点):应用内所有流共享一个焦点,音效和 BGM 同时出声——适合 UI 点击音这类点缀。

Demo 播放页那个焦点实验台就是切换这个开关。音效引擎创建时按当前模式设置:

// SfxEngine.ensureRenderer()
const r = await audio.createAudioRenderer(opts);
await r.setInterruptMode(this.independent
  ? audio.InterruptMode.INDEPENDENT_MODE   // 一响就打断主音乐
  : audio.InterruptMode.SHARE_MODE);       // 与主音乐共存

对外部抢占(来电、其他音乐 App),AVPlayer 侧同样监听 audioInterrupt。有一点要留心:强制打断(INTERRUPT_FORCE)系统会直接暂停播放器;共享式打断(INTERRUPT_SHARE)系统只发提示,暂停动作要你自己做。我 Dart 侧的处理按 hintCode 分流:

// lib/pages/routing_focus_page.dart
_interruptSub = bridge.interrupt.listen((e) {
  if (e.source == 'player') {
    if (e.hintCode == 2 || e.hintCode == 3) {   // PAUSE / STOP:自己动手
      AudioBridge.instance.playerPause();
    } else if (e.hintCode == 1) {               // RESUME:可以恢复
      AudioBridge.instance.playerPlay();
    }
  }
});

验证方法很朴素:真机放歌,然后打个电话给自己(或者放另一个音乐 App),观察 Demo 是否暂停、日志是否出现 audioInterrupt、挂断后按 RESUME 提示恢复。这一套跑通,你的 App 就符合鸿蒙的音频礼仪了。

5.4 播放器:AVPlayer 吃 rawfile 的正确姿势

演示音轨放在鸿蒙工程的 resources/rawfile/ 里。AVPlayer 播放本地资源推荐走 fdSrc(文件描述符)而不是拼 fd:// 的 url 字符串——后者要自己拼 offset/length 查询参数,容易拼错:

// PlayerController.prepare()
const player = await media.createAVPlayer();
// 状态机:idle → initialized(设置数据源) → prepared(prepare)
player.on('stateChange', (s: string) => {
  this.state = s;
  if (s === 'initialized') {
    player.prepare();               // 设置完数据源立刻 prepare
  } else if (s === 'prepared') {
    this.duration = player.duration; // 此刻才能拿到时长
    this.notify(false, 0);
  }
  // playing / paused / completed 状态分别上报给 Flutter
});
player.on('timeUpdate', (t: number) => { /* 进度上报,播放中每秒回调 */ });
player.on('audioInterrupt', (ev: audio.InterruptEvent) => { /* 见 5.3 */ });

// rawfile → RawFileDescriptor → fdSrc,三行搞定本地播放
this.rawFd = await ctx.resourceManager.getRawFd('demo_track.wav');
player.fdSrc = {
  fd: this.rawFd.fd,
  offset: this.rawFd.offset,
  length: this.rawFd.length
};

两个容易漏的点:一是 AVPlayer 是严格状态机,prepare() 只能在 initialized 状态调,seek 在 idle/initialized 会直接报错,所以操作前判断状态是必须的(Demo 里每个方法都做了状态守卫);二是用完记得 closeRawFd,否则文件描述符泄漏,量大之后会有奇怪的资源问题。释放路径:

async release(): Promise<void> {
  // ... player.release() 之后
  if (this.rawFd !== null && this.context !== null) {
    await this.context.resourceManager.closeRawFd('demo_track.wav');
    this.rawFd = null;
  }
}

另外 audioInterruptMode 属性让 AVPlayer 也能参与 5.3 的焦点实验(在 play() 之前设置生效),Demo 里两个开关分别控制播放器和音效流,组合出四种行为,全都可以真机复现。

5.5 音效:短平快,用完就还

音效(按钮"叮"、滑页"嗖")走独立的 AudioRenderer,单声道。设计要点是即用即停:播放前 start(),PCM 一次写完,按音效时长 setTimeout 后 stop()。stop 很重要——运行中的 MUSIC 流占着焦点不放,会跟自己的主播放器打架(独立焦点模式下):

// SfxEngine.play():写完数据后按时长归还焦点
await r.write(buf);   // buf 是实时合成的 PCM16
const playMs = (buf.byteLength / 2 / SAMPLE_RATE) * 1000;
this.drainingTimer = setTimeout(async () => {
  if (this.renderer !== null &&
      this.renderer.state === audio.AudioState.STATE_RUNNING) {
    await this.renderer.stop();      // 播完即停,释放焦点
  }
}, playMs + 80);                     // 留 80ms 排水余量

音效本身是实时合成的(“叮”= 1318.5Hz 指数衰减正弦,“嗒”= 5ms 噪声脉冲,“嗖”= 低通扫频白噪声),不依赖任何音频文件,这个思路可以平移到任何需要轻量 UI 音效的鸿蒙应用里。

6. 我踩过的坑(每一条都有真实报错)

这一节可能是全文最值钱的部分。以下坑全部来自这次开发的真实报错和排查记录。

坑一:getRoutingManager 不在模块顶层。 我照着"直觉"写 audio.getRoutingManager(),编译器直接回我:

Property 'getRoutingManager' does not exist on type 'typeof audio'

翻 .d.ts 才看清:getRoutingManager()和 getSpatializationManager()都是 AudioManager 实例上的方法,要先 audio.getAudioManager()。同样中招的还有状态枚举——我写 audio.RendererState.STATE_RUNNING,报 Property 'RendererState' does not exist,正确名字是 audio.AudioState。教训:写鸿蒙原生代码,开着 SDK 的 .d.ts 写,别凭记忆。(DevEco 安装目录 sdk/default/openharmony/ets/api/ 下全都有,拿 grep 一查一个准。)

坑二:Windows 命令行构建触发 ohpm 批处理递归崩溃。 flutter build hap --debug 跑到 ohpm install 时直接炸出:

******  B A T C H   R E C U R S I O N  exceeds STACK limits  ******
Recursion Count=234, Stack Usage=90 percent

排查发现是 DevEco 的 ohpm.bat 里用"逐字符递归"处理含 “ohpm” 字样的首参数,在 flutter 工具链的调用链路下参数被整串传入,递归深度爆栈。绕过方案(实测有效):先手动装依赖,再直接调 hvigor:

cd ohos
ohpm install
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
# 产物在 entry/build/default/outputs/default/

顺带一提,hvigor 会在 --debug 模式下打出它拼的完整命令行(ohpm install command is "..."),这个日志对排查这类问题非常有用。

坑三:系统空间化开关,三方应用只读。 我本来计划给 Demo 加一个"应用内开启系统空间音频"的开关,翻文档发现 setSpatializationEnabled 是系统应用专属接口。三方能用的是 isSpatializationEnabledForCurrentDevice() + spatializationEnabledChangeForCurrentDevice 事件。产品上的正确姿势是感知 + 引导:读状态、监听变化、在合适的时机把用户指到系统设置里去,而不是试图替用户做决定。

坑四:纯音听不出方位感,一度以为 ITD 写错了。 5.1 节末尾讲过原理(稳态纯音的相位差感知弱)。当时我用 440Hz 纯音自测,声音只有左右音量差,几乎没有"方向"感,差点把对的代码改成错的。后来换成白噪声一耳朵就对了。做空间音频,先备一个白噪声信号源,再谈别的。

坑五:调试 HAP 体积吓人。 debug 包打出来 130 多 MB,第一次见还是慌的。这是 Flutter debug 模式带 JIT 和完整符号的正常现象,release 包会回到正常量级。别花时间"优化"它。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐