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.
181 lines
10 KiB
181 lines
10 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,
|
|
|
|
/// 只放音,麦克风在**设备**上(EaiCar 车载香薰按键唤醒:采音走 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';
|
|
|
|
/// 最近一次配置的档位,只用于日志。
|
|
///
|
|
/// ⚠️ 不能拿它当「已配好就跳过」的缓存:这个值是 Dart 进程内的,而 AVAudioSession
|
|
/// 是全进程共享的——翻译页 / 支架录音 / 通话翻译的原生代码都会直接改类别
|
|
/// (`.record` / `.playback`…),改完这里毫不知情。缓存命中就只 setActive,
|
|
/// 会话却停在一个**没有输入**的类别上,接下来 record 的 installTap 触发
|
|
/// AVAudioEngine 的 ObjC 断言,Dart 的 try/catch 接不住,进程直接崩。
|
|
/// 「耳机对话完切回手机就崩」就是这条。setCategory 传相同值在 iOS 上是空操作,
|
|
/// 每次都配没有代价。
|
|
static EmaiAudioProfile? _configured;
|
|
|
|
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
|
|
|
/// 按档位配置并激活会话。失败只记日志,不抛——
|
|
/// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。
|
|
///
|
|
/// ⚠️ **playbackOnly 这一档是有来历的**(2026-09-14 修):车载香薰唤醒 AI 后
|
|
/// 回复声音从**手机扬声器**出来,而不是从香薰喇叭出来。香薰的下行本来就设计成
|
|
/// 走经典蓝牙 A2DP(`_EaiCarPhoneSpeaker` 的注释写的就是「A2DP 连着时从设备
|
|
/// 喇叭出来」),坏在这份会话配置上,两处叠加:
|
|
/// 1. `defaultToSpeaker` 把输出钉在内置扬声器上;
|
|
/// 2. 选项里只有 `allowBluetooth`(那是 HFP 免提),**没有 allowBluetoothA2dp**
|
|
/// —— playAndRecord 类别下 A2DP 输出默认是关的。
|
|
/// 而这条链路手机压根不录音,用 playAndRecord 本身就是错配。改成 playback 之后
|
|
/// 输出走系统当前路由,A2DP 连着就从设备喇叭出。
|
|
///
|
|
/// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化),
|
|
/// 只补上 allowBluetoothA2dp。
|
|
///
|
|
/// 返回值:**输出此刻是不是走蓝牙 A2DP**(只有 iOS 的 phoneMic 档会为 true)。
|
|
/// 调用方据此决定要不要开 voice-processing(回声消除)——见下面的说明。
|
|
///
|
|
/// ⚠️ **iOS 上 A2DP 输出和 voice-processing 是二选一的**(2026-09-18 定性):
|
|
/// 手机麦档位原来固定 `.voiceChat` + record 的 echoCancel,而 iOS 一旦进入
|
|
/// 语音处理模式就**只认 HFP / 听筒 / 扬声器**,A2DP 设备直接从可选输出里消失。
|
|
/// 于是耳机连着、用手机跟 EMAI 对话(哪怕是打字),回复一响手机就从耳机
|
|
/// 切到扬声器。产品口径是「手机触发的对话走 A2DP」,所以:
|
|
/// - 先按 `.default` 模式 + allowBluetoothA2dp 配一次,看当前输出有没有 A2DP;
|
|
/// - 有 → 保持 default 模式,**不开** voice-processing(回复从耳机出,手机麦
|
|
/// 离扬声器远,回声本来就小);
|
|
/// - 没有 → 退回 `.voiceChat`,让 record 开回声消除(外放场景必须有 AEC,
|
|
/// 否则连续对话里助手一开口就被自己的声音打断)。
|
|
static Future<bool> ensure(
|
|
{EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async {
|
|
if (!_apply) return false;
|
|
try {
|
|
final session = await AudioSession.instance;
|
|
if (_configured != profile) {
|
|
Logger.i(_tag, '音频会话档位 ${_configured?.name ?? '-'} → ${profile.name}');
|
|
}
|
|
_configured = profile;
|
|
if (profile == EmaiAudioProfile.playbackOnly) {
|
|
await session.configure(_playbackOnlyConfig());
|
|
await session.setActive(true);
|
|
return false;
|
|
}
|
|
// phoneMic:先按「允许 A2DP」的形状配,再看系统实际把输出路到了哪
|
|
await session.configure(_phoneMicConfig(a2dpOutput: true));
|
|
await session.setActive(true);
|
|
final a2dp = await _outputIsA2dp();
|
|
if (a2dp) {
|
|
Logger.i(_tag, '输出走蓝牙 A2DP,手机麦档位保持 default 模式(不开语音处理)');
|
|
return true;
|
|
}
|
|
if (Platform.isIOS) {
|
|
// 没有 A2DP 设备:退回语音处理模式,外放要靠它做回声消除
|
|
await session.configure(_phoneMicConfig(a2dpOutput: false));
|
|
await session.setActive(true);
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 当前会话的输出里有没有蓝牙 A2DP 口。只有 iOS 有这个查询;Android 的
|
|
/// 媒体流本来就由系统自动路到 A2DP,不需要客户端判。
|
|
static Future<bool> _outputIsA2dp() async {
|
|
if (!Platform.isIOS) return false;
|
|
try {
|
|
final route = await AVAudioSession().currentRoute;
|
|
final ports = route.outputs.map((o) => o.portType).toList();
|
|
final a2dp = ports.contains(AVAudioSessionPort.bluetoothA2dp);
|
|
Logger.i(_tag, '当前输出: ${ports.map((p) => p.name).join(',')} a2dp=$a2dp');
|
|
return a2dp;
|
|
} catch (e) {
|
|
Logger.w(_tag, '查询输出路由失败(按无 A2DP 处理): $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 只放音:输出跟随系统路由(蓝牙 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({required bool a2dpOutput}) =>
|
|
AudioSessionConfiguration(
|
|
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。
|
|
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
|
// ⚠️ 这一档刻意**不给 allowBluetooth(HFP)**,只给 A2DP:
|
|
// 这是「用手机麦对话」的档位。给了 HFP,耳机还连着时 iOS 会在 setActive
|
|
// 之后异步把输入切到耳机的 HFP 麦(SCO 建链、采样率变 8k/16k),
|
|
// 而 record 恰好在这个窗口里读 inputFormat 再 installTap ——
|
|
// 格式对不上就是 `format.sampleRate == hwFormat.sampleRate` 的 ObjC 断言,
|
|
// 进程直接崩。只走 A2DP 的话输入固定在手机麦,路由不会中途翻转;
|
|
// 回复照样从 A2DP 耳机出来(耳机没配 A2DP 就走扬声器)。
|
|
avAudioSessionCategoryOptions:
|
|
AVAudioSessionCategoryOptions.defaultToSpeaker |
|
|
AVAudioSessionCategoryOptions.allowBluetoothA2dp,
|
|
// ⚠️ iOS 两种模式二选一(见 ensure 的说明):
|
|
// - 走 A2DP 时必须是 default:voiceChat = 语音处理,A2DP 输出会被系统剔掉;
|
|
// - 不走 A2DP 时固定 voiceChat:record 开了 echoCancel 会打开
|
|
// voice-processing,系统随之把 mode 改成 voiceChat 并一直持有
|
|
// (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。
|
|
// defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。
|
|
avAudioSessionMode: Platform.isIOS && !a2dpOutput
|
|
? 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,
|
|
);
|
|
}
|
|
|