You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

120 lines
6.3 KiB

import 'dart:io' show Platform;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/utils/logger.dart';
/// 这条语音链路要用手机的哪些能力。决定 AVAudioSession 的类别,
/// 而类别直接决定**声音从哪儿出来**。
enum EmaiAudioProfile {
/// 手机麦克风 + 手机放音(EMAI 页「按住说话」)。必须 playAndRecord。
phoneMic,
/// 只放音,麦克风在**设备**上(Smartcar 车载香薰按键唤醒:采音走 BLE opus,
/// 手机一个字节都不录)。
///
/// 这一档必须用 playback 而不是 playAndRecord —— 见 [ensure] 里的说明。
playbackOnly,
}
/// EMAI 语音链路(麦克风上行 + TTS 下行)共用的一份 AVAudioSession 配置。
///
/// 为什么要抽出来:麦克风和播放器是两个模块,各自配一次会话就会互相覆盖。
/// 连续对话下两者同时在跑,谁最后写谁生效,表现是「录着录着没声了」或
/// 「播着播着麦死了」。统一到这里,两边写的是同一份配置,重复调用无害。
///
/// ⚠️ **开麦之前必须先调一次**。真机实测:会话停在 `soloAmbient` 且未激活时,
/// `record` 的 `startStream` 会**成功返回**、AVAudioEngine 也起得来,但输入
/// tap 一个 buffer 都不回调 —— 上行一个字节都发不出去,服务端 30 秒后回
/// `ClientAudioTimeout`,而客户端这边没有任何报错,只看到「麦克风采集启动」
/// 那行日志,非常难查。
class EmaiAudioSession {
const EmaiAudioSession._();
static const String _tag = 'EmaiAudioSession';
/// 当前生效的档位;换档要重新 configure,不能像原来那样只配一次。
static EmaiAudioProfile? _configured;
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
/// 按档位配置并激活会话。失败只记日志,不抛——
/// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。
///
/// ⚠️ **playbackOnly 这一档是有来历的**(2026-09-14 修):车载香薰唤醒 AI 后
/// 回复声音从**手机扬声器**出来,而不是从香薰喇叭出来。香薰的下行本来就设计成
/// 走经典蓝牙 A2DP(`_SmartcarPhoneSpeaker` 的注释写的就是「A2DP 连着时从设备
/// 喇叭出来」),坏在这份会话配置上,两处叠加:
/// 1. `defaultToSpeaker` 把输出钉在内置扬声器上;
/// 2. 选项里只有 `allowBluetooth`(那是 HFP 免提),**没有 allowBluetoothA2dp**
/// —— playAndRecord 类别下 A2DP 输出默认是关的。
/// 而这条链路手机压根不录音,用 playAndRecord 本身就是错配。改成 playback 之后
/// 输出走系统当前路由,A2DP 连着就从设备喇叭出。
///
/// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化),
/// 只补上 allowBluetoothA2dp。
static Future<void> ensure(
{EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async {
if (!_apply) return;
try {
final session = await AudioSession.instance;
if (_configured != profile) {
_configured = profile;
await session.configure(profile == EmaiAudioProfile.playbackOnly
? _playbackOnlyConfig()
: _phoneMicConfig());
}
await session.setActive(true);
} catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
}
}
/// 只放音:输出跟随系统路由(蓝牙 A2DP / AirPlay / 扬声器)。
static AudioSessionConfiguration _playbackOnlyConfig() =>
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playback,
// ⚠️ 不要写 defaultToSpeaker:它只在 playAndRecord 下合法,而且正是
// 「声音不从蓝牙设备出来」的那个元凶。
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetoothA2dp |
AVAudioSessionCategoryOptions.allowAirPlay,
avAudioSessionMode: AVAudioSessionMode.defaultMode,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.media,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
);
static AudioSessionConfiguration _phoneMicConfig() =>
AudioSessionConfiguration(
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
// allowBluetooth 是 HFP(免提,双向窄带);allowBluetoothA2dp 才是高音质
// 单向输出。两个都给,让系统按当前是否真的在录音去挑。
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.allowBluetoothA2dp,
// ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开
// voice-processing,系统随之把 mode 改成 voiceChat 并一直持有
// (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。
// defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。
avAudioSessionMode: Platform.isIOS
? AVAudioSessionMode.voiceChat
: AVAudioSessionMode.defaultMode,
// ⚠️ Android 的 usage 千万别用 voiceCommunication:华为
// (EMUI/HarmonyOS) 对通话流卡得很严,不在 MODE_IN_COMMUNICATION 下建的
// VOICE_COMMUNICATION 音轨会被直接静音,logcat 只留一行
// `AudioTrack: set playback slient`,音轨建得出来、数据也在写,就是没声音。
// ALN-AL80 上实测复现过,用 media 就没这问题。
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.media,
),
// 助手说话时把背景音乐暂停,而不是压低——压低了在嘈杂环境还是听不清
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
);
}