Browse Source

上传iOS 的音乐优化

weicu
liwei1dao 6 months ago
parent
commit
2081761da5
  1. 71
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  2. 9
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  3. 4
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt
  4. 40
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  5. 6
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift
  6. 3
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  7. 4
      local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt
  8. 62
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  9. 129
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift
  10. 61
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt
  11. 68
      local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift
  12. 68
      local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift
  13. 71
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt
  14. 54
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift

71
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()
}
}

9
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,6 +608,12 @@ 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 {

4
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
)
}
}

40
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
}
}
}

6
local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift

@ -149,8 +149,7 @@ public class MicrophoneCapture: NSObject {
// 设置音频会话参数
try audioSession.setCategory(.playback,
mode: .videoChat,
options: [.allowBluetoothA2DP, // 仅允许A2DP,不启用HFP
.mixWithOthers])
options: [.allowBluetoothA2DP]) // 不再 mixWithOthers,AI 会话独占
try audioSession.overrideOutputAudioPort(.none)
print("蓝牙模式:音频输出设置为蓝牙耳机")
@ -174,8 +173,7 @@ public class MicrophoneCapture: NSObject {
try audioSession.setCategory(.playAndRecord,
mode: .videoChat,
options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路
.mixWithOthers,
.defaultToSpeaker]) // 添加音频优先级控制
.defaultToSpeaker]) // 不再 mixWithOthers,AI 会话独占
try audioSession.setPreferredInput(builtInMic)
print("手机模式:音频输入设置为内置麦克风(手机)")
} catch {

3
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

4
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)")

62
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<String>()
@ -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()
}
}

129
local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift

@ -261,7 +261,8 @@ public class SystemFunctionHandler: NSObject {
"properties": [
"format": [
"type": "string",
"description": "时间格式,可选"
"enum": ["short", "date", "time", "full", "iso"],
"description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。"
]
],
"required": []
@ -270,7 +271,7 @@ public class SystemFunctionHandler: NSObject {
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: " ")
}
}

61
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()
}
/**

68
local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift

@ -212,7 +212,8 @@ public class SystemFunctionHandler: NSObject {
"properties": [
"format": [
"type": "string",
"description": "时间格式,可选,默认为标准格式"
"enum": ["short", "date", "time", "full", "iso"],
"description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。"
]
],
"required": []
@ -221,7 +222,7 @@ public class SystemFunctionHandler: NSObject {
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: " ")
}
}

68
local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift

@ -212,7 +212,8 @@ public class SystemFunctionHandler: NSObject {
"properties": [
"format": [
"type": "string",
"description": "时间格式,可选,默认为标准格式"
"enum": ["short", "date", "time", "full", "iso"],
"description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。"
]
],
"required": []
@ -221,7 +222,7 @@ public class SystemFunctionHandler: NSObject {
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: " ")
}
}

71
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,6 +186,9 @@ class RealtimeAudioManager(private val context: Context) {
}
try {
// 抢占音频焦点(暂停外部和内部其他音乐)
requestAudioFocus()
// 设置回音消除(总是启用)
setupEchoCancellation()
@ -247,6 +257,11 @@ class RealtimeAudioManager(private val context: Context) {
// 清理回音消除
cleanupEchoCancellation()
// 录音和播放都停止时归还焦点,让外部音乐恢复
if (!isPlaying) {
abandonAudioFocus()
}
onStatusChanged?.invoke(VoiceStatus.IDLE)
Log.i(TAG, "录音已停止")
return true
@ -350,6 +365,11 @@ class RealtimeAudioManager(private val context: Context) {
// 恢复音频模式
restoreAudioMode()
// 录音和播放都停止时归还焦点,让外部音乐恢复
if (!isRecording) {
abandonAudioFocus()
}
return true
} catch (e: Exception) {
Log.e(TAG, "停止播放失败: ${e.message}", e)
@ -395,6 +415,9 @@ class RealtimeAudioManager(private val context: Context) {
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)
}
}

54
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,6 +143,9 @@ 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)
@ -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
}

Loading…
Cancel
Save