Browse Source
iOS 上助手一直不出声,和这次改连续对话无关,是播报本来就坏的。三轮真机对照 实测:just_audio 喂的是「长度未知的直播 WAV 流」,AVPlayer 只发一次 `Range: bytes=0-1` 的嗅探请求就判定资源不可播,回 -11850 且 processingState 直接落 idle,一次都没真正起播。声明长度、支持 range、把收到的字节全缓存下来 允许重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以安卓一直是 好的——这就是「安卓有声、苹果没声」的全部原因。 iOS 改走原生 AVAudioEngine 排队播 PCM(PcmSpeakerPlayer),安卓不动。 验证方式:一次性灌 3 秒音频,7ms 喂完但 3708ms 才播完,证明是实时渲染不是丢弃。 连续对话的上行也是断的,服务端一直回 ClientAudioTimeout。两个原因: - _ensureContinuousMic 被 Started 事件和 _enterContinuous 并发调用,两边都在 `_micRunning` 置位前通过守卫,同一个 recorder 被 startStream 两遍,第二次把 第一次那条流顶掉。控制器侧改成同步置位,MicPcmStreamer 内部再加一层 in-flight 去重。 - 开麦前没配好并激活音频会话。会话停在 soloAmbient 且未激活时,startStream 会**成功返回**、引擎也起得来,但输入 tap 一个 buffer 都不回调,客户端毫无报错。 麦克风和播放器统一到 EmaiAudioSession 一份会话配置:各配各的会在连续对话下 互相覆盖。iOS 固定 voiceChat——record 的 echoCancel 会打开 voice-processing 并 把 mode 改成 voiceChat 持有着,播放这边写回 default 就是两边互相掀桌子。 androidAudioAttributes 仍是 media,别改成 voiceCommunication(华为会静音)。 连续对话交互按要求改:进页面只建会话不开麦,短按开始、再按结束、可反复, 退出即停,10 秒无对话往来自动结束。计时依据必须是对话事件不是音频包—— 麦克风没人说话也在稳定出包,拿它续命这个超时永远不会触发。 另修 _enterContinuous 在 reconnect() 一返回就判 `!= ready`:ready 要等服务端回 Started(一次网络往返),几乎每次都命中,于是刚打开的连续对话被当场关掉, 而 Started 稍后到达又会把麦开起来,留下一个界面上看不见的常开麦克风。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
57 changed files with 1080 additions and 139 deletions
@ -0,0 +1,61 @@ |
|||
import '../../core/utils/logger.dart'; |
|||
import 'network/api.dart'; |
|||
|
|||
/// EMAI 会话用的 MCP 令牌。 |
|||
/// |
|||
/// 百炼调我们的 MCP 工具(查会议纪要、查待办…)时,需要知道「这是哪个用户」。 |
|||
/// 整条链路上唯一能按用户变的通道是 Start 指令的 |
|||
/// `biz_params.user_defined_params` —— 客户端连百炼只带百炼的 API Key, |
|||
/// 百炼调 MCP 的 Authorization 头又是控制台里配死的、所有用户共用。 |
|||
/// |
|||
/// ⚠️ 这里拿的是服务端 `user_getmcptoken` 签发的**短时效、仅限 MCP 用途**的令牌, |
|||
/// 不是登录 JWT —— 这条路要经过百炼(第三方),登录 JWT 等价于账号全部权限。 |
|||
class McpTokenService { |
|||
const McpTokenService._(); |
|||
|
|||
static const String _tag = 'McpToken'; |
|||
|
|||
static String? _token; |
|||
static int _expiresAt = 0; |
|||
|
|||
/// 提前多久就当它过期。会话可能持续很久,卡着过期时刻去用必然中途失效。 |
|||
static const int _skewSeconds = 300; |
|||
|
|||
static bool get _fresh { |
|||
if (_token == null || _token!.isEmpty) return false; |
|||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; |
|||
return _expiresAt - _skewSeconds > now; |
|||
} |
|||
|
|||
/// 取令牌。缓存未过期就直接用,否则重新申请。 |
|||
/// |
|||
/// 拿不到时返回 null 而不是抛异常 —— MCP 工具用不了只是少几项能力, |
|||
/// 不该因此让整个 EMAI 会话建立不起来。 |
|||
static Future<String?> get() async { |
|||
if (_fresh) return _token; |
|||
try { |
|||
final resp = await Api.getMcpToken(); |
|||
// ⚠️ AuthInterceptor 在业务码 != 0 时是把 data 置 null 后 resolve(不抛), |
|||
// 所以这里必须判 null;只 catch 是接不住业务错误的。 |
|||
if (resp == null) { |
|||
Logger.w(_tag, '服务端未签发 MCP 令牌(多半是没配 McpTokenKey)'); |
|||
return null; |
|||
} |
|||
final token = (resp['token'] ?? '').toString(); |
|||
if (token.isEmpty) return null; |
|||
_token = token; |
|||
_expiresAt = (resp['expiresat'] as num?)?.toInt() ?? 0; |
|||
Logger.i(_tag, 'MCP 令牌已更新,过期时刻=$_expiresAt'); |
|||
return _token; |
|||
} catch (e) { |
|||
Logger.w(_tag, '申请 MCP 令牌失败(MCP 工具本次不可用): $e'); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 换账号必须清掉,否则新账号会拿着上一个人的令牌去调 MCP。 |
|||
static void reset() { |
|||
_token = null; |
|||
_expiresAt = 0; |
|||
} |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
import 'dart:io' show Platform; |
|||
|
|||
import 'package:audio_session/audio_session.dart'; |
|||
import 'package:flutter/foundation.dart' show kIsWeb; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// EMAI 语音链路(麦克风上行 + TTS 下行)共用的一份 AVAudioSession 配置。 |
|||
/// |
|||
/// 为什么要抽出来:麦克风和播放器是两个模块,各自配一次会话就会互相覆盖。 |
|||
/// 连续对话下两者同时在跑,谁最后写谁生效,表现是「录着录着没声了」或 |
|||
/// 「播着播着麦死了」。统一到这里,两边写的是同一份配置,重复调用无害。 |
|||
/// |
|||
/// ⚠️ **开麦之前必须先调一次**。真机实测:会话停在 `soloAmbient` 且未激活时, |
|||
/// `record` 的 `startStream` 会**成功返回**、AVAudioEngine 也起得来,但输入 |
|||
/// tap 一个 buffer 都不回调 —— 上行一个字节都发不出去,服务端 30 秒后回 |
|||
/// `ClientAudioTimeout`,而客户端这边没有任何报错,只看到「麦克风采集启动」 |
|||
/// 那行日志,非常难查。 |
|||
class EmaiAudioSession { |
|||
const EmaiAudioSession._(); |
|||
|
|||
static const String _tag = 'EmaiAudioSession'; |
|||
|
|||
static bool _configured = false; |
|||
|
|||
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid); |
|||
|
|||
/// 配置(只做一次)并激活会话。失败只记日志,不抛—— |
|||
/// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。 |
|||
static Future<void> ensure() async { |
|||
if (!_apply) return; |
|||
try { |
|||
final session = await AudioSession.instance; |
|||
if (!_configured) { |
|||
_configured = true; |
|||
await session.configure(AudioSessionConfiguration( |
|||
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。 |
|||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, |
|||
avAudioSessionCategoryOptions: |
|||
AVAudioSessionCategoryOptions.defaultToSpeaker | |
|||
AVAudioSessionCategoryOptions.allowBluetooth, |
|||
// ⚠️ 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, |
|||
)); |
|||
} |
|||
await session.setActive(true); |
|||
} catch (e) { |
|||
Logger.w(_tag, '音频会话配置失败(继续尝试): $e'); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,187 @@ |
|||
import AVFoundation |
|||
import Foundation |
|||
|
|||
/// 流式 PCM 外放播放器(16bit 小端、单声道)。 |
|||
/// |
|||
/// 存在的理由:iOS 上 **just_audio 放不了「长度未知的直播 WAV 流」**。 |
|||
/// 真机实测(iPhone 12 / iOS 26.6)AVPlayer 拿到 just_audio 代理服务给的 |
|||
/// chunked 响应后,只发一次 `Range: bytes=0-1` 的嗅探请求就判定资源不可播, |
|||
/// 立刻回 `-11850 Operation Stopped` 且 processingState 直接落到 idle, |
|||
/// **一次都没真正起播**。声明长度、支持 range、把收到的字节全缓存下来允许 |
|||
/// 重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以 Android |
|||
/// 那条路一直是好的 —— 这就是「安卓有声、苹果没声」的全部原因。 |
|||
/// |
|||
/// 这里绕开 AVPlayer + HTTP 代理,直接 AVAudioEngine + AVAudioPlayerNode |
|||
/// 排队播放,和 `AzureTtsHelper` 里那套流式播放同源。 |
|||
/// |
|||
/// ⚠️ **不碰 AVAudioSession**:会话由 Dart 侧的 audio_session 统一配置 |
|||
/// (连续对话时录音的 voice-processing 也在同一个会话上)。这里再去 |
|||
/// setCategory/setActive 就会和录音互相掀桌子。 |
|||
class PcmSpeakerPlayer { |
|||
private let queue = DispatchQueue(label: "azure_speech.pcm_speaker") |
|||
|
|||
private var engine: AVAudioEngine? |
|||
private var node: AVAudioPlayerNode? |
|||
private var format: AVAudioFormat? |
|||
|
|||
/// 已排队但还没播完的缓冲数 |
|||
private var pending = 0 |
|||
/// 上层已经调过 end():pending 归零就算这一轮播完 |
|||
private var ended = false |
|||
/// 本轮代际,stop() 之后迟到的完成回调按代际丢弃 |
|||
private var generation = 0 |
|||
|
|||
/// 一轮音频播完(或本轮压根没有音频) |
|||
var onCompleted: (() -> Void)? |
|||
|
|||
// MARK: - 对外接口 |
|||
|
|||
func start(sampleRate: Double) -> Bool { |
|||
var ok = false |
|||
queue.sync { |
|||
generation += 1 |
|||
pending = 0 |
|||
ended = false |
|||
ok = ensureEngine(sampleRate: sampleRate) |
|||
} |
|||
return ok |
|||
} |
|||
|
|||
func feed(_ data: Data) { |
|||
queue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
guard let node = self.node, let engine = self.engine, engine.isRunning else { return } |
|||
guard let buffer = self.makeBuffer(from: data) else { return } |
|||
let gen = self.generation |
|||
self.pending += 1 |
|||
// ⚠️ 必须用 .dataPlayedBack 而不是默认的 .dataRendered: |
|||
// 播完这一轮要顺手停引擎(见 finishIfDone),用 dataRendered 会在 |
|||
// 声音真正出完之前就回调,尾音被切掉。 |
|||
node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in |
|||
self?.queue.async { |
|||
guard let self = self, gen == self.generation else { return } |
|||
self.pending -= 1 |
|||
self.finishIfDone() |
|||
} |
|||
} |
|||
if !node.isPlaying { node.play() } |
|||
} |
|||
} |
|||
|
|||
/// 上层不再喂数据了:已排队的播完就报完成 |
|||
func end() { |
|||
queue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.ended = true |
|||
self.finishIfDone() |
|||
} |
|||
} |
|||
|
|||
/// 打断:立刻停声,不报完成 |
|||
func stop() { |
|||
queue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.generation += 1 |
|||
self.pending = 0 |
|||
self.ended = false |
|||
self.node?.stop() |
|||
self.engine?.stop() |
|||
} |
|||
} |
|||
|
|||
func dispose() { |
|||
queue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.generation += 1 |
|||
self.node?.stop() |
|||
self.engine?.stop() |
|||
self.node = nil |
|||
self.engine = nil |
|||
self.format = nil |
|||
} |
|||
} |
|||
|
|||
// MARK: - 内部 |
|||
|
|||
private func finishIfDone() { |
|||
guard ended, pending <= 0 else { return } |
|||
ended = false |
|||
// ⚠️ 一轮播完必须把引擎停掉,不能让它空跑着占住音频 IO。 |
|||
// 连续对话下 record 要在同一个会话上开 voice-processing, |
|||
// 我们这台引擎还占着的话它会拿不到 IO,报 |
|||
// `avfaudio error 2003329396`(FourCC 'what')—— 表现就是 |
|||
// 「助手说完话之后麦克风再也开不起来」。真机上间歇复现过。 |
|||
// 对象留着不销毁,下一轮 ensureEngine 直接 start() 就好。 |
|||
node?.stop() |
|||
engine?.stop() |
|||
let cb = onCompleted |
|||
DispatchQueue.main.async { cb?() } |
|||
} |
|||
|
|||
/// 采样率变了要整条重建:AVAudioEngine 的连接格式是建链路时定死的 |
|||
private func ensureEngine(sampleRate: Double) -> Bool { |
|||
if let engine = engine, let node = node, let fmt = format, |
|||
fmt.sampleRate == sampleRate { |
|||
if !engine.isRunning { |
|||
do { try engine.start() } catch { tearDown(); return buildEngine(sampleRate: sampleRate) } |
|||
} |
|||
if !node.isPlaying { node.play() } |
|||
return true |
|||
} |
|||
tearDown() |
|||
return buildEngine(sampleRate: sampleRate) |
|||
} |
|||
|
|||
private func buildEngine(sampleRate: Double) -> Bool { |
|||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1) else { |
|||
return false |
|||
} |
|||
let e = AVAudioEngine() |
|||
let n = AVAudioPlayerNode() |
|||
e.attach(n) |
|||
e.connect(n, to: e.mainMixerNode, format: fmt) |
|||
do { |
|||
try e.start() |
|||
} catch { |
|||
NSLog("[PcmSpeakerPlayer] 引擎启动失败: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
n.play() |
|||
engine = e |
|||
node = n |
|||
format = fmt |
|||
return true |
|||
} |
|||
|
|||
private func tearDown() { |
|||
node?.stop() |
|||
engine?.stop() |
|||
node = nil |
|||
engine = nil |
|||
format = nil |
|||
} |
|||
|
|||
/// Int16 小端裸 PCM → Float32 单声道 buffer |
|||
private func makeBuffer(from data: Data) -> AVAudioPCMBuffer? { |
|||
guard let fmt = format else { return nil } |
|||
let frames = data.count / 2 |
|||
guard frames > 0, |
|||
let buffer = AVAudioPCMBuffer(pcmFormat: fmt, |
|||
frameCapacity: AVAudioFrameCount(frames)), |
|||
let dst = buffer.floatChannelData else { return nil } |
|||
buffer.frameLength = AVAudioFrameCount(frames) |
|||
data.withUnsafeBytes { raw in |
|||
guard let base = raw.baseAddress else { return } |
|||
// ⚠️ 不能 bindMemory(to: Int16.self):Data 的起始地址不保证 2 字节对齐, |
|||
// 未对齐访问在 arm64 上是未定义行为。逐字节取再拼。 |
|||
let bytes = base.assumingMemoryBound(to: UInt8.self) |
|||
for i in 0..<frames { |
|||
let lo = UInt16(bytes[i * 2]) |
|||
let hi = UInt16(bytes[i * 2 + 1]) |
|||
let sample = Int16(bitPattern: lo | (hi << 8)) |
|||
dst[0][i] = Float(sample) / 32768.0 |
|||
} |
|||
} |
|||
return buffer |
|||
} |
|||
} |
|||
Loading…
Reference in new issue