diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index a2d002a07..f7d75ee87 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -24,6 +24,10 @@ import java.util.Collections import kotlin.coroutines.CoroutineContext import com.yunqiinnovation.ble_service.BleService import android.media.MediaPlayer +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.sync.Mutex import com.deep_voice.speech.tts.TtsEvent @@ -154,6 +158,62 @@ object AgentService : CoroutineScope { // 音频播放器 private var audioPlayer: AudioPlayer? = null // 初始化音频播放器 + // ========== 音频焦点(唤醒 AI 时打断外部和内部其他音频)========== + private var audioManagerForFocus: AudioManager? = null + private var aiAudioFocusRequest: AudioFocusRequest? = null + private val aiAudioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + Log.d(TAG, "[Agent] 音频焦点变化: $focusChange") + } + + private fun requestAiAudioFocus() { + try { + val ctx = if (::context.isInitialized) context else return + val am = audioManagerForFocus + ?: (ctx.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.also { audioManagerForFocus = it } + ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (aiAudioFocusRequest != null) return + val attrs = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(aiAudioFocusListener) + .build() + aiAudioFocusRequest = req + val result = am.requestAudioFocus(req) + Log.i(TAG, "[Agent] 请求音频焦点(EXCLUSIVE) 结果=$result") + } else { + @Suppress("DEPRECATION") + val result = am.requestAudioFocus( + aiAudioFocusListener, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + ) + Log.i(TAG, "[Agent] 请求音频焦点(EXCLUSIVE, legacy) 结果=$result") + } + } catch (e: Exception) { + Log.e(TAG, "[Agent] 请求音频焦点失败: ${e.message}", e) + } + } + + private fun abandonAiAudioFocus() { + try { + val am = audioManagerForFocus ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + aiAudioFocusRequest?.let { am.abandonAudioFocusRequest(it) } + aiAudioFocusRequest = null + } else { + @Suppress("DEPRECATION") + am.abandonAudioFocus(aiAudioFocusListener) + } + Log.i(TAG, "[Agent] 已归还音频焦点") + } catch (e: Exception) { + Log.e(TAG, "[Agent] 归还音频焦点失败: ${e.message}", e) + } + } + // 状态 - 使用原子类型确保线程安全 private val _isInitialized = AtomicBoolean(false) val isInitialized: Boolean get() = _isInitialized.get() @@ -427,6 +487,7 @@ object AgentService : CoroutineScope { stopTts() stopAiStream() stopIdleCheck() + abandonAiAudioFocus() audioPlayer?.release() audioPlayer = null @@ -723,6 +784,8 @@ object AgentService : CoroutineScope { * @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk */ fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean { + // 抢占音频焦点,让外部和内部其他音频暂停(参考翻译模块的实现) + requestAiAudioFocus() if (agentMode == "sts") { currentRecognitionMode = mode // 记录模式供 onEvent 回调使用 when (mode) { @@ -997,9 +1060,13 @@ object AgentService : CoroutineScope { if (agentMode == "sts") { // StsAgent 内部根据 inCall / pttRecording 状态自行决定停止方式 stsAgent?.stop() + abandonAiAudioFocus() + return + } + if (!isRecognitionActive) { + abandonAiAudioFocus() return } - if (!isRecognitionActive) return _log("INFO", "[Agent] 停止语音识别,模式: $currentRecognitionMode") @@ -1017,6 +1084,8 @@ object AgentService : CoroutineScope { // 重置识别模式 // currentRecognitionMode = "normal" return + } finally { + abandonAiAudioFocus() } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 219f19208..9ab8f5f82 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -79,6 +79,9 @@ class AgentServiceImpl: NSObject { // 添加音乐播放状态记录 public var wasMusicPlayingBeforeRecognition: Bool = false + // 唤醒前是否有外部 App(Apple Music / Spotify / QQ 音乐等)在播放音频 + // 用于决定 TTS 释放 session 时是否需要 .notifyOthersOnDeactivation + public var wasExternalAudioPlayingBeforeRecognition: Bool = false private var isSpeaking: Bool = false @@ -605,7 +608,13 @@ class AgentServiceImpl: NSObject { _log("INFO", "[Agent] openEncoder result: \(openResult ? "success" : "failed")") wasMusicPlayingBeforeRecognition = MusicService.shared.isPlaying - + // 捕获外部音频播放状态必须在激活自己的 session 之前,否则 isOtherAudioPlaying 会被我们自己的激活影响 + wasExternalAudioPlayingBeforeRecognition = AVAudioSession.sharedInstance().isOtherAudioPlaying + // 通知 TTS:本轮结束时需要 notifyOthersOnDeactivation + azureTtsHelper?.notifyOthersOnNextDeactivate = + wasMusicPlayingBeforeRecognition || wasExternalAudioPlayingBeforeRecognition + _log("INFO", "[Agent] pre-wake audio state: internal=\(wasMusicPlayingBeforeRecognition) external=\(wasExternalAudioPlayingBeforeRecognition)") + // 如果音乐正在播放,则暂停 if MusicService.shared.isPlaying { MusicService.shared.pause() diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt index 92b681ee6..32275bb61 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt @@ -340,7 +340,7 @@ class SimpleAudioPlayer(private val context: Context? = null) { .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() - val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) .setAudioAttributes(attrs) .setOnAudioFocusChangeListener(audioFocusChangeListener) .build() @@ -351,7 +351,7 @@ class SimpleAudioPlayer(private val context: Context? = null) { am.requestAudioFocus( audioFocusChangeListener, AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE ) } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index e9b5bbc25..36eb409d5 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift @@ -1414,6 +1414,11 @@ private var audioSessionQueueKeySet = false private var audioInterrupted = false private var audioSessionObserversAdded = false private var shouldDeactivateAudioSessionWhenIdle = false + +/// 外部调用方(如 AgentServiceImpl)在本轮唤醒/识别开始时设置: +/// - true 表示唤醒前有外部或内部音乐在放,TTS 空闲释放 session 时应带上 .notifyOthersOnDeactivation 让被打断的 App 恢复播放 +/// - 成功 deactivate 后由本类自动清零 +public var notifyOthersOnNextDeactivate: Bool = false // 新增:路由变化的去抖与配置重入保护标记 private var routeChangeDebounceWorkItem: DispatchWorkItem? private var isApplyingAudioSessionConfig = false @@ -1472,7 +1477,9 @@ private func applyAudioSessionForTTS() -> Bool { let otherAudioPlaying = audioSession.isOtherAudioPlaying let isRecording = (self.micCapture?.isCapturing == true) self.isHeadphones = hasHeadphones - self.shouldDeactivateAudioSessionWhenIdle = otherAudioPlaying + // 总是允许在空闲时释放音频会话,让被打断的内部/外部音频可以恢复 + self.shouldDeactivateAudioSessionWhenIdle = true + _ = otherAudioPlaying let desiredCategory: AVAudioSession.Category let desiredMode: AVAudioSession.Mode @@ -1491,9 +1498,7 @@ private func applyAudioSessionForTTS() -> Bool { desiredMode = .voiceChat desiredOptions = [.allowBluetooth, .defaultToSpeaker] } - if otherAudioPlaying { - desiredOptions.insert(.mixWithOthers) - } + // 不再 mixWithOthers:TTS 期间需要打断外部/内部其他音频 } else if self.currentRecognitionMode == "push_to_talk" || self.currentRecognitionMode == "normal" || self.currentRecognitionMode == "ble_wakeup" { if isRecording { self.micCapture?.stopCapture() @@ -1501,9 +1506,7 @@ private func applyAudioSessionForTTS() -> Bool { } desiredCategory = .playback desiredMode = .spokenAudio - if otherAudioPlaying { - desiredOptions.insert(.mixWithOthers) - } + // 不再 mixWithOthers:唤醒/TTS 期间需要打断外部和内部其他音频 } else if isRecording { desiredCategory = .playAndRecord desiredMode = .default @@ -1516,15 +1519,11 @@ private func applyAudioSessionForTTS() -> Bool { desiredOptions.insert(.allowBluetooth) desiredOptions.insert(.defaultToSpeaker) } - if otherAudioPlaying { - desiredOptions.insert(.mixWithOthers) - } + // 不再 mixWithOthers:录音期间也需要独占音频 } else { desiredCategory = .playback desiredMode = .spokenAudio - if otherAudioPlaying { - desiredOptions.insert(.mixWithOthers) - } + // 不再 mixWithOthers } do { @@ -1553,7 +1552,7 @@ private func applyAudioSessionForTTS() -> Bool { try audioSession.setActive(true) } catch { do { - try audioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try audioSession.setCategory(.playback, mode: .default) try audioSession.setActive(true) } catch { success = false @@ -1648,11 +1647,20 @@ private func deactivateAudioSessionAfterTTSIfIdle() { self.playerNode = nil self.pcmFormat = nil if self.shouldDeactivateAudioSessionWhenIdle { + // 麦克风仍在采集时,session 还被 ASR 持有,deactivate 必失败 + // 保留标记等下次 idle 再试,避免标记被清空后永远不再触发 + if self.micCapture?.isCapturing == true { return } + + let opts: AVAudioSession.SetActiveOptions = + self.notifyOthersOnNextDeactivate ? .notifyOthersOnDeactivation : [] do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + try AVAudioSession.sharedInstance().setActive(false, options: opts) + self.shouldDeactivateAudioSessionWhenIdle = false + self.notifyOthersOnNextDeactivate = false } catch { + // 失败保留标记,下次 idle 再尝试(常见原因:其他 AudioUnit 仍在运行) + os_log("TTS deactivate 失败,保留标记待下次重试: %{public}@", log: self.log, type: .info, error.localizedDescription) } - self.shouldDeactivateAudioSessionWhenIdle = false } } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift index b346d4924..ec20fdcbc 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift @@ -147,10 +147,9 @@ public class MicrophoneCapture: NSObject { // 取消扬声器强制输出,让音频通过蓝牙耳机输出 do { // 设置音频会话参数 - try audioSession.setCategory(.playback, - mode: .videoChat, - options: [.allowBluetoothA2DP, // 仅允许A2DP,不启用HFP - .mixWithOthers]) + try audioSession.setCategory(.playback, + mode: .videoChat, + options: [.allowBluetoothA2DP]) // 不再 mixWithOthers,AI 会话独占 try audioSession.overrideOutputAudioPort(.none) print("蓝牙模式:音频输出设置为蓝牙耳机") @@ -171,11 +170,10 @@ public class MicrophoneCapture: NSObject { if let builtInMic = availableInputs.first(where: { $0.portType == AVAudioSession.Port.builtInMic }) { do { // 设置音频会话参数 - try audioSession.setCategory(.playAndRecord, - mode: .videoChat, + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 - .mixWithOthers, - .defaultToSpeaker]) // 添加音频优先级控制 + .defaultToSpeaker]) // 不再 mixWithOthers,AI 会话独占 try audioSession.setPreferredInput(builtInMic) print("手机模式:音频输入设置为内置麦克风(手机)") } catch { diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index 49264d572..fa46f1f52 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -191,9 +191,10 @@ public class SimpleAudioReceiver: NSObject { } else if audioSourceType == .external { // --- 纯外部源 (仅播放) 逻辑 --- + // 不再 mixWithOthers:AI 播放需要打断外部和内部其他音频 desiredCategory = .playback desiredMode = .spokenAudio - desiredOptions = isHeadphones ? [.allowBluetoothA2DP, .mixWithOthers] : [.mixWithOthers] + desiredOptions = isHeadphones ? [.allowBluetoothA2DP] : [] } else { // 兜底,理论上不会执行 desiredCategory = .playAndRecord diff --git a/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt b/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt index f1c0ec79a..f26eae6b4 100644 --- a/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt +++ b/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt @@ -208,7 +208,7 @@ class BytedanceAudioPlayer(private val context: Context) : AudioDataListener, Au if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android O及以上使用新的API val focusRequest = android.media.AudioFocusRequest.Builder( - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE ) .setAudioAttributes( AudioAttributes.Builder() @@ -233,7 +233,7 @@ class BytedanceAudioPlayer(private val context: Context) : AudioDataListener, Au val result = audioManager.requestAudioFocus( audioFocusChangeListener, AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE ) if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { Log.d(TAG, "音频焦点请求成功(旧API)") diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 588beb412..356ff0160 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -136,13 +136,14 @@ class SystemFunctionHandler(private val context: Context? = null) { // 注册获取当前时间函数 client.registerLocalFunction( name = "get_current_time", - description = "获取当前日期和时间", + description = "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", parameters = mapOf( "type" to "object", "properties" to mapOf( "format" to mapOf( "type" to "string", - "description" to "时间格式,可选,默认为标准格式" + "enum" to listOf("short", "date", "time", "full", "iso"), + "description" to "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" ) ), "required" to emptyList() @@ -1051,11 +1052,12 @@ private class GetCurrentTimeHandler : FunctionHandler { } // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 + // 使用单位数 m/s(去掉前导零),避免 TTS 读出 "零五分零三秒" private fun formatShortTime(date: Date, lang: String): String = when (lang) { - "zh" -> SimpleDateFormat("H时mm分", Locale.CHINESE).format(date) - "ja" -> SimpleDateFormat("H時mm分", Locale.JAPANESE).format(date) - "ko" -> SimpleDateFormat("H시 mm분", Locale.KOREAN).format(date) - else -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) + "zh" -> SimpleDateFormat("H时m分", Locale.CHINESE).format(date) + "ja" -> SimpleDateFormat("H時m分", Locale.JAPANESE).format(date) + "ko" -> SimpleDateFormat("H시 m분", Locale.KOREAN).format(date) + else -> formatTimeWestern(date, withSeconds = false) } private fun formatDate(date: Date, lang: String): String = when (lang) { @@ -1066,24 +1068,48 @@ private class GetCurrentTimeHandler : FunctionHandler { } private fun formatTime(date: Date, lang: String): String = when (lang) { - "zh" -> SimpleDateFormat("H时mm分ss秒", Locale.CHINESE).format(date) - "ja" -> SimpleDateFormat("H時mm分ss秒", Locale.JAPANESE).format(date) - "ko" -> SimpleDateFormat("H시 mm분 ss초", Locale.KOREAN).format(date) - else -> SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(date) + "zh" -> SimpleDateFormat("H时m分s秒", Locale.CHINESE).format(date) + "ja" -> SimpleDateFormat("H時m分s秒", Locale.JAPANESE).format(date) + "ko" -> SimpleDateFormat("H시 m분 s초", Locale.KOREAN).format(date) + else -> formatTimeWestern(date, withSeconds = true) } private fun formatFull(date: Date, lang: String): String = when (lang) { - "zh" -> SimpleDateFormat("yyyy年M月d日 EEEE H时mm分ss秒", Locale.CHINESE).format(date) - "ja" -> SimpleDateFormat("yyyy年M月d日 EEEE H時mm分ss秒", Locale.JAPANESE).format(date) - "ko" -> SimpleDateFormat("yyyy년 M월 d일 EEEE H시 mm분 ss초", Locale.KOREAN).format(date) - else -> SimpleDateFormat("MMMM d, yyyy, EEEE, h:mm:ss a", Locale.getDefault()).format(date) + "zh" -> SimpleDateFormat("yyyy年M月d日 EEEE H时m分s秒", Locale.CHINESE).format(date) + "ja" -> SimpleDateFormat("yyyy年M月d日 EEEE H時m分s秒", Locale.JAPANESE).format(date) + "ko" -> SimpleDateFormat("yyyy년 M월 d일 EEEE H시 m분 s초", Locale.KOREAN).format(date) + else -> "${SimpleDateFormat("MMMM d, yyyy, EEEE, ", Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = true)}" } private fun formatDefault(date: Date, lang: String): String = when (lang) { - "zh" -> SimpleDateFormat("yyyy年M月d日 H时mm分", Locale.CHINESE).format(date) - "ja" -> SimpleDateFormat("yyyy年M月d日 H時mm分", Locale.JAPANESE).format(date) - "ko" -> SimpleDateFormat("yyyy년 M월 d일 H시 mm분", Locale.KOREAN).format(date) - else -> SimpleDateFormat("MMMM d, yyyy, h:mm a", Locale.getDefault()).format(date) + "zh" -> SimpleDateFormat("yyyy年M月d日 H时m分", Locale.CHINESE).format(date) + "ja" -> SimpleDateFormat("yyyy年M月d日 H時m分", Locale.JAPANESE).format(date) + "ko" -> SimpleDateFormat("yyyy년 M월 d일 H시 m분", Locale.KOREAN).format(date) + else -> "${SimpleDateFormat("MMMM d, yyyy, ", Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = false)}" + } + + /** + * 非 CJK 语言的时间格式(英语及其他 Latin 语系) + * 避免冒号,使用英文单词 hour/minute/second + AM/PM,对绝大多数 TTS 引擎更稳定 + * 例:2 hours 30 minutes 15 seconds PM + */ + private fun formatTimeWestern(date: Date, withSeconds: Boolean): String { + val cal = Calendar.getInstance().apply { time = date } + val h12 = cal.get(Calendar.HOUR).let { if (it == 0) 12 else it } + val minute = cal.get(Calendar.MINUTE) + val second = cal.get(Calendar.SECOND) + val ampm = if (cal.get(Calendar.AM_PM) == Calendar.AM) "AM" else "PM" + + val sb = StringBuilder() + sb.append(h12).append(if (h12 == 1) " hour" else " hours") + if (minute > 0 || withSeconds) { + sb.append(' ').append(minute).append(if (minute == 1) " minute" else " minutes") + } + if (withSeconds) { + sb.append(' ').append(second).append(if (second == 1) " second" else " seconds") + } + sb.append(' ').append(ampm) + return sb.toString() } } diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index 8d974dded..c589eb6a9 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -261,16 +261,17 @@ public class SystemFunctionHandler: NSObject { "properties": [ "format": [ "type": "string", - "description": "时间格式,可选" + "enum": ["short", "date", "time", "full", "iso"], + "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" ] ], "required": [] ] - + let handler = GetCurrentTimeHandler() _ = client.registerLocalFunction( name: "get_current_time", - description: "获取当前时间", + description: "获取当前时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", parameters: parameters, handler: handler ) @@ -863,59 +864,72 @@ private class SetReminderHandler: FunctionHandler { private class GetCurrentTimeHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { let format = arguments["format"] as? String - let result = getCurrentTime(format: format) - return "{\"result\": \"\(result.message)\", \"time\": \"\(result.message)\"}" + do { + let now = Date() + let resultTime = try buildResultTime(format: format, now: now) + let payload: [String: Any] = [ + "result": resultTime, + "time": resultTime, + "timestamp": Int64(now.timeIntervalSince1970 * 1000) + ] + return jsonString(payload) + } catch { + let payload: [String: Any] = [ + "error": [ + "code": "time_error", + "message": "获取时间失败: \(error.localizedDescription)" + ] + ] + return jsonString(payload) + } } - private func getCurrentTime(format: String?) -> Result { - let now = Date() + private func buildResultTime(format: String?, now: Date) throws -> String { let lang = Locale.current.languageCode ?? "en" - let timeStr: String switch format?.lowercased() { - case "short": - timeStr = formatShortTime(now, lang: lang) - case "date": - timeStr = formatDate(now, lang: lang) - case "time": - timeStr = formatTime(now, lang: lang) - case "full": - timeStr = formatFull(now, lang: lang) + case "short": timeStr = formatShortTime(now, lang: lang) + case "date": timeStr = formatDate(now, lang: lang) + case "time": timeStr = formatTime(now, lang: lang) + case "full": timeStr = formatFull(now, lang: lang) case "iso": let f = DateFormatter() f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" timeStr = f.string(from: now) - default: - timeStr = formatDefault(now, lang: lang) + default: timeStr = formatDefault(now, lang: lang) } - // 根据格式决定是否添加星期几 - let resultTime: String + // 根据格式决定是否追加星期(与 Android 一致:short/time/iso 不追加,full 已自带) switch format?.lowercased() { - case "short", "time", "iso": - resultTime = timeStr - case "full": - resultTime = timeStr // full格式已包含星期 + case "short", "time", "iso", "full": + return timeStr default: let weekFormatter = DateFormatter() weekFormatter.dateFormat = "EEEE" weekFormatter.locale = Locale.current - resultTime = "\(timeStr) \(weekFormatter.string(from: now))" + return "\(timeStr) \(weekFormatter.string(from: now))" } + } - return Result(success: true, message: resultTime) + private func jsonString(_ dict: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []), + let s = String(data: data, encoding: .utf8) else { + return "{}" + } + return s } // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 + // CJK 使用单位数 m/s(去掉前导零),避免 TTS 读出 "零五分零三秒" + // 其他语言使用英文单词 hour/minute/second,避免非英语 TTS 对冒号的解析不稳定 private func formatShortTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) } private func formatDate(_ date: Date, lang: String) -> String { @@ -932,34 +946,59 @@ private class GetCurrentTimeHandler: FunctionHandler { private func formatTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatFull(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, EEEE, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatDefault(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) + } + + /// 非 CJK 语言的时间格式(英语及其他 Latin 语系) + /// 避免冒号,使用英文单词 hour/minute/second + AM/PM,对绝大多数 TTS 引擎更稳定 + /// 例:2 hours 30 minutes 15 seconds PM + private func formatTimeWestern(_ date: Date, withSeconds: Bool) -> String { + let cal = Calendar.current + let hour24 = cal.component(.hour, from: date) + let minute = cal.component(.minute, from: date) + let second = cal.component(.second, from: date) + let h12Raw = hour24 % 12 + let h12 = h12Raw == 0 ? 12 : h12Raw + let ampm = hour24 < 12 ? "AM" : "PM" + + var parts: [String] = [] + parts.append("\(h12) \(h12 == 1 ? "hour" : "hours")") + if minute > 0 || withSeconds { + parts.append("\(minute) \(minute == 1 ? "minute" : "minutes")") + } + if withSeconds { + parts.append("\(second) \(second == 1 ? "second" : "seconds")") + } + parts.append(ampm) + return parts.joined(separator: " ") } } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt index b9adae914..5a8971464 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -255,14 +255,15 @@ class SystemFunctionHandler(private val context: Context) { private fun registerGetCurrentTimeFunction(client: MCPClient) { client.registerLocalFunction( "get_current_time", - "获取当前日期和时间", + "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", JSONObject(""" { "type": "object", "properties": { "format": { "type": "string", - "description": "时间格式,可选,默认为标准格式" + "enum": ["short", "date", "time", "full", "iso"], + "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" } }, "required": [] @@ -1097,11 +1098,12 @@ class SystemFunctionHandler(private val context: Context) { } // TTS友好的时间格式辅助方法 + // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 private fun formatShortTime(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("H时mm分", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("H時mm分", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("H시 mm분", java.util.Locale.KOREAN).format(date) - else -> java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).format(date) + "zh" -> java.text.SimpleDateFormat("H时m分", java.util.Locale.CHINESE).format(date) + "ja" -> java.text.SimpleDateFormat("H時m分", java.util.Locale.JAPANESE).format(date) + "ko" -> java.text.SimpleDateFormat("H시 m분", java.util.Locale.KOREAN).format(date) + else -> formatTimeWestern(date, withSeconds = false) } private fun formatDate(date: java.util.Date, lang: String): String = when (lang) { @@ -1112,24 +1114,47 @@ class SystemFunctionHandler(private val context: Context) { } private fun formatTime(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("H时mm分ss秒", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("H時mm分ss秒", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("H시 mm분 ss초", java.util.Locale.KOREAN).format(date) - else -> java.text.SimpleDateFormat("h:mm:ss a", java.util.Locale.getDefault()).format(date) + "zh" -> java.text.SimpleDateFormat("H时m分s秒", java.util.Locale.CHINESE).format(date) + "ja" -> java.text.SimpleDateFormat("H時m分s秒", java.util.Locale.JAPANESE).format(date) + "ko" -> java.text.SimpleDateFormat("H시 m분 s초", java.util.Locale.KOREAN).format(date) + else -> formatTimeWestern(date, withSeconds = true) } private fun formatFull(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H时mm分ss秒", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H時mm分ss秒", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 EEEE H시 mm분 ss초", java.util.Locale.KOREAN).format(date) - else -> java.text.SimpleDateFormat("MMMM d, yyyy, EEEE, h:mm:ss a", java.util.Locale.getDefault()).format(date) + "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H时m分s秒", java.util.Locale.CHINESE).format(date) + "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H時m分s秒", java.util.Locale.JAPANESE).format(date) + "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 EEEE H시 m분 s초", java.util.Locale.KOREAN).format(date) + else -> "${java.text.SimpleDateFormat("MMMM d, yyyy, EEEE, ", java.util.Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = true)}" } private fun formatDefaultTime(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 H时mm分", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 H時mm分", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 H시 mm분", java.util.Locale.KOREAN).format(date) - else -> java.text.SimpleDateFormat("MMMM d, yyyy, h:mm a", java.util.Locale.getDefault()).format(date) + "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 H时m分", java.util.Locale.CHINESE).format(date) + "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 H時m分", java.util.Locale.JAPANESE).format(date) + "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 H시 m분", java.util.Locale.KOREAN).format(date) + else -> "${java.text.SimpleDateFormat("MMMM d, yyyy, ", java.util.Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = false)}" + } + + /** + * 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 + * 例:2 hours 30 minutes 15 seconds PM + */ + private fun formatTimeWestern(date: java.util.Date, withSeconds: Boolean): String { + val cal = java.util.Calendar.getInstance().apply { time = date } + val h12 = cal.get(java.util.Calendar.HOUR).let { if (it == 0) 12 else it } + val minute = cal.get(java.util.Calendar.MINUTE) + val second = cal.get(java.util.Calendar.SECOND) + val ampm = if (cal.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "AM" else "PM" + + val sb = StringBuilder() + sb.append(h12).append(if (h12 == 1) " hour" else " hours") + if (minute > 0 || withSeconds) { + sb.append(' ').append(minute).append(if (minute == 1) " minute" else " minutes") + } + if (withSeconds) { + sb.append(' ').append(second).append(if (second == 1) " second" else " seconds") + } + sb.append(' ').append(ampm) + return sb.toString() } /** diff --git a/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift b/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift index 81553131d..a307bbff4 100644 --- a/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift +++ b/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift @@ -212,16 +212,17 @@ public class SystemFunctionHandler: NSObject { "properties": [ "format": [ "type": "string", - "description": "时间格式,可选,默认为标准格式" + "enum": ["short", "date", "time", "full", "iso"], + "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" ] ], "required": [] ] - + let handler = GetCurrentTimeHandler() _ = client.registerLocalFunction( name: "get_current_time", - description: "获取当前日期和时间", + description: "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", parameters: parameters, handler: handler ) @@ -610,15 +611,15 @@ private class GetCurrentTimeHandler: FunctionHandler { } // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 + // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 private func formatShortTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) } private func formatDate(_ date: Date, lang: String) -> String { @@ -635,34 +636,57 @@ private class GetCurrentTimeHandler: FunctionHandler { private func formatTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatFull(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, EEEE, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatDefault(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) + } + + /// 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 + private func formatTimeWestern(_ date: Date, withSeconds: Bool) -> String { + let cal = Calendar.current + let hour24 = cal.component(.hour, from: date) + let minute = cal.component(.minute, from: date) + let second = cal.component(.second, from: date) + let h12Raw = hour24 % 12 + let h12 = h12Raw == 0 ? 12 : h12Raw + let ampm = hour24 < 12 ? "AM" : "PM" + + var parts: [String] = [] + parts.append("\(h12) \(h12 == 1 ? "hour" : "hours")") + if minute > 0 || withSeconds { + parts.append("\(minute) \(minute == 1 ? "minute" : "minutes")") + } + if withSeconds { + parts.append("\(second) \(second == 1 ? "second" : "seconds")") + } + parts.append(ampm) + return parts.joined(separator: " ") } } diff --git a/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift b/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift index a57014c9e..f606121eb 100644 --- a/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift +++ b/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift @@ -212,16 +212,17 @@ public class SystemFunctionHandler: NSObject { "properties": [ "format": [ "type": "string", - "description": "时间格式,可选,默认为标准格式" + "enum": ["short", "date", "time", "full", "iso"], + "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" ] ], "required": [] ] - + let handler = GetCurrentTimeHandler() _ = client.registerLocalFunction( name: "get_current_time", - description: "获取当前日期和时间", + description: "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", parameters: parameters, handler: handler ) @@ -629,15 +630,15 @@ private class GetCurrentTimeHandler: FunctionHandler { } // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 + // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 private func formatShortTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) } private func formatDate(_ date: Date, lang: String) -> String { @@ -654,34 +655,57 @@ private class GetCurrentTimeHandler: FunctionHandler { private func formatTime(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: return formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatFull(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, EEEE, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: true) } - return f.string(from: date) } private func formatDefault(_ date: Date, lang: String) -> String { let f = DateFormatter() switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN") - case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP") - case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR") - default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current + case "zh": f.dateFormat = "yyyy年M月d日 H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) + case "ja": f.dateFormat = "yyyy年M月d日 H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) + case "ko": f.dateFormat = "yyyy년 M월 d일 H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) + default: + f.dateFormat = "MMMM d, yyyy, "; f.locale = Locale.current + return f.string(from: date) + formatTimeWestern(date, withSeconds: false) } - return f.string(from: date) + } + + /// 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 + private func formatTimeWestern(_ date: Date, withSeconds: Bool) -> String { + let cal = Calendar.current + let hour24 = cal.component(.hour, from: date) + let minute = cal.component(.minute, from: date) + let second = cal.component(.second, from: date) + let h12Raw = hour24 % 12 + let h12 = h12Raw == 0 ? 12 : h12Raw + let ampm = hour24 < 12 ? "AM" : "PM" + + var parts: [String] = [] + parts.append("\(h12) \(h12 == 1 ? "hour" : "hours")") + if minute > 0 || withSeconds { + parts.append("\(minute) \(minute == 1 ? "minute" : "minutes")") + } + if withSeconds { + parts.append("\(second) \(second == 1 ? "second" : "seconds")") + } + parts.append(ampm) + return parts.joined(separator: " ") } } diff --git a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt index 711a85779..d4c130e7e 100644 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt +++ b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt @@ -5,6 +5,7 @@ import android.media.* import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AutomaticGainControl import android.media.audiofx.NoiseSuppressor +import android.os.Build import android.util.Log import kotlinx.coroutines.* import java.io.ByteArrayOutputStream @@ -46,6 +47,12 @@ class RealtimeAudioManager(private val context: Context) { private var audioManager: AudioManager? = null private var originalAudioMode = AudioManager.MODE_NORMAL + // 音频焦点 + private var audioFocusRequest: AudioFocusRequest? = null + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + Log.d(TAG, "音频焦点变化: $focusChange") + } + // 回调 var onAudioData: ((ByteArray) -> Unit)? = null var onStatusChanged: ((VoiceStatus) -> Unit)? = null @@ -179,9 +186,12 @@ class RealtimeAudioManager(private val context: Context) { } try { + // 抢占音频焦点(暂停外部和内部其他音乐) + requestAudioFocus() + // 设置回音消除(总是启用) setupEchoCancellation() - + audioRecord?.startRecording() isRecording = true onStatusChanged?.invoke(VoiceStatus.RECORDING) @@ -243,10 +253,15 @@ class RealtimeAudioManager(private val context: Context) { isRecording = false recordJob?.cancel() audioRecord?.stop() - + // 清理回音消除 cleanupEchoCancellation() - + + // 录音和播放都停止时归还焦点,让外部音乐恢复 + if (!isPlaying) { + abandonAudioFocus() + } + onStatusChanged?.invoke(VoiceStatus.IDLE) Log.i(TAG, "录音已停止") return true @@ -346,10 +361,15 @@ class RealtimeAudioManager(private val context: Context) { playJob?.cancel() // 取消协程 playJob = null stopPlaybackInternal() - + // 恢复音频模式 restoreAudioMode() - + + // 录音和播放都停止时归还焦点,让外部音乐恢复 + if (!isRecording) { + abandonAudioFocus() + } + return true } catch (e: Exception) { Log.e(TAG, "停止播放失败: ${e.message}", e) @@ -390,11 +410,14 @@ class RealtimeAudioManager(private val context: Context) { fun dispose() { stopRecording() stopPlaying() - + // 清理回音消除和恢复音频模式 cleanupEchoCancellation() restoreAudioMode() - + + // 兜底归还音频焦点 + abandonAudioFocus() + scope.cancel() releaseAudioObjects() playbackQueue.clear() @@ -402,6 +425,54 @@ class RealtimeAudioManager(private val context: Context) { Log.i(TAG, "音频管理器已释放") } + /// 请求音频焦点:使用 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,让外部和内部其他音频暂停 + private fun requestAudioFocus() { + val am = audioManager ?: return + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (audioFocusRequest != null) return + val attrs = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + audioFocusRequest = req + val result = am.requestAudioFocus(req) + Log.i(TAG, "请求音频焦点(GAIN) 结果=$result") + } else { + @Suppress("DEPRECATION") + val result = am.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + ) + Log.i(TAG, "请求音频焦点(GAIN, legacy) 结果=$result") + } + } catch (e: Exception) { + Log.e(TAG, "请求音频焦点失败: ${e.message}", e) + } + } + + /// 归还音频焦点 + private fun abandonAudioFocus() { + val am = audioManager ?: return + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { am.abandonAudioFocusRequest(it) } + audioFocusRequest = null + } else { + @Suppress("DEPRECATION") + am.abandonAudioFocus(audioFocusChangeListener) + } + Log.i(TAG, "已归还音频焦点") + } catch (e: Exception) { + Log.e(TAG, "归还音频焦点失败: ${e.message}", e) + } + } + diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift index 9277b9ea9..acca2477f 100644 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift +++ b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift @@ -38,22 +38,31 @@ class RealtimeAudioManager { init() { playbackQueue = DispatchQueue(label: "com.realtime.playback", qos: .userInitiated) - // setupAudioSession() } - - // /// 设置音频会话 - // private func setupAudioSession() { - // do { - // let session = AVAudioSession.sharedInstance() - // try session.setCategory(.playAndRecord, - // mode: .default, - // options: [.defaultToSpeaker, .allowBluetooth]) - // try session.setActive(true) - // os_log("音频会话设置成功", log: log, type: .info) - // } catch { - // os_log("音频会话设置失败: %@", log: log, type: .error, error.localizedDescription) - // } - // } + + /// 激活会话,抢占音频焦点(暂停外部和内部其他音频) + private func activateAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, + mode: .voiceChat, + options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + os_log("音频会话已激活(抢占焦点)", log: log, type: .info) + } catch { + os_log("音频会话激活失败: %@", log: log, type: .error, error.localizedDescription) + } + } + + /// 释放会话,归还音频焦点(让外部和内部其他音频恢复) + private func deactivateAudioSession() { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + os_log("音频会话已释放(归还焦点)", log: log, type: .info) + } catch { + os_log("音频会话释放失败: %@", log: log, type: .error, error.localizedDescription) + } + } /// 初始化音频管理器 func initialize(config: AudioConfig) -> Bool { @@ -134,11 +143,14 @@ class RealtimeAudioManager { return false } + // 抢占音频焦点(暂停外部和内部其他音乐) + activateAudioSession() + // 安装录音tap inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(frameSize / 2), format: recordingFormat) { [weak self] buffer, time in self?.processRecordedAudio(buffer: buffer) } - + do { try audioEngine.start() isRecording = true @@ -185,6 +197,11 @@ class RealtimeAudioManager { isRecording = false onStatusChanged?(.idle) os_log("停止录音", log: log, type: .info) + + // 录音和播放都停止时归还焦点,让外部音乐恢复 + if !isPlaying { + deactivateAudioSession() + } return true } @@ -294,6 +311,11 @@ class RealtimeAudioManager { isPlaying = false onStatusChanged?(.idle) os_log("停止播放", log: log, type: .info) + + // 录音和播放都停止时归还焦点,让外部音乐恢复 + if !isRecording { + deactivateAudioSession() + } return true }