@ -0,0 +1,36 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CLIENT_ID</key> |
||||
|
<string>907754492821-oc9e77svgmmlgouo0op7vv00e1hb8278.apps.googleusercontent.com</string> |
||||
|
<key>REVERSED_CLIENT_ID</key> |
||||
|
<string>com.googleusercontent.apps.907754492821-oc9e77svgmmlgouo0op7vv00e1hb8278</string> |
||||
|
<key>ANDROID_CLIENT_ID</key> |
||||
|
<string>907754492821-9jakqvathu61e6b8g95i41ssi7o4dnka.apps.googleusercontent.com</string> |
||||
|
<key>API_KEY</key> |
||||
|
<string>AIzaSyAvyBQcOXuO_q851vevqZIufsPRTMCe808</string> |
||||
|
<key>GCM_SENDER_ID</key> |
||||
|
<string>907754492821</string> |
||||
|
<key>PLIST_VERSION</key> |
||||
|
<string>1</string> |
||||
|
<key>BUNDLE_ID</key> |
||||
|
<string>com.yunqiinnovation.deepsound</string> |
||||
|
<key>PROJECT_ID</key> |
||||
|
<string>deepsound-e0e3d</string> |
||||
|
<key>STORAGE_BUCKET</key> |
||||
|
<string>deepsound-e0e3d.firebasestorage.app</string> |
||||
|
<key>IS_ADS_ENABLED</key> |
||||
|
<false></false> |
||||
|
<key>IS_ANALYTICS_ENABLED</key> |
||||
|
<false></false> |
||||
|
<key>IS_APPINVITE_ENABLED</key> |
||||
|
<true></true> |
||||
|
<key>IS_GCM_ENABLED</key> |
||||
|
<true></true> |
||||
|
<key>IS_SIGNIN_ENABLED</key> |
||||
|
<true></true> |
||||
|
<key>GOOGLE_APP_ID</key> |
||||
|
<string>1:907754492821:ios:246794fc44b23174145c9c</string> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -1,123 +0,0 @@ |
|||||
{ |
|
||||
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", |
|
||||
"pins" : [ |
|
||||
{ |
|
||||
"identity" : "abseil-cpp-binary", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|
||||
"state" : { |
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|
||||
"version" : "1.2024072200.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "app-check", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/app-check.git", |
|
||||
"state" : { |
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|
||||
"version" : "11.2.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "firebase-ios-sdk", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|
||||
"state" : { |
|
||||
"revision" : "2e4a4daac9da51f79c131e76cb47e785dd7708c4", |
|
||||
"version" : "11.10.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googleappmeasurement", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|
||||
"state" : { |
|
||||
"revision" : "defe1970a9099d47765592ebd7ac5f5e9fdb57c6", |
|
||||
"version" : "11.10.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googledatatransport", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|
||||
"state" : { |
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|
||||
"version" : "10.1.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googleutilities", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleUtilities.git", |
|
||||
"state" : { |
|
||||
"revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", |
|
||||
"version" : "8.0.2" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "grpc-binary", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/grpc-binary.git", |
|
||||
"state" : { |
|
||||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|
||||
"version" : "1.69.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "gtm-session-fetcher", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|
||||
"state" : { |
|
||||
"revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", |
|
||||
"version" : "4.4.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "interop-ios-for-google-sdks", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|
||||
"state" : { |
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|
||||
"version" : "101.0.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "leveldb", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/leveldb.git", |
|
||||
"state" : { |
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|
||||
"version" : "1.22.5" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "nanopb", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/nanopb.git", |
|
||||
"state" : { |
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|
||||
"version" : "2.30910.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "promises", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/promises.git", |
|
||||
"state" : { |
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|
||||
"version" : "2.4.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "swift-protobuf", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/apple/swift-protobuf.git", |
|
||||
"state" : { |
|
||||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|
||||
"version" : "1.29.0" |
|
||||
} |
|
||||
} |
|
||||
], |
|
||||
"version" : 3 |
|
||||
} |
|
||||
@ -1,123 +0,0 @@ |
|||||
{ |
|
||||
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", |
|
||||
"pins" : [ |
|
||||
{ |
|
||||
"identity" : "abseil-cpp-binary", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|
||||
"state" : { |
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|
||||
"version" : "1.2024072200.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "app-check", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/app-check.git", |
|
||||
"state" : { |
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|
||||
"version" : "11.2.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "firebase-ios-sdk", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|
||||
"state" : { |
|
||||
"revision" : "2e4a4daac9da51f79c131e76cb47e785dd7708c4", |
|
||||
"version" : "11.10.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googleappmeasurement", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|
||||
"state" : { |
|
||||
"revision" : "defe1970a9099d47765592ebd7ac5f5e9fdb57c6", |
|
||||
"version" : "11.10.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googledatatransport", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|
||||
"state" : { |
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|
||||
"version" : "10.1.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "googleutilities", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/GoogleUtilities.git", |
|
||||
"state" : { |
|
||||
"revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", |
|
||||
"version" : "8.0.2" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "grpc-binary", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/grpc-binary.git", |
|
||||
"state" : { |
|
||||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|
||||
"version" : "1.69.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "gtm-session-fetcher", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|
||||
"state" : { |
|
||||
"revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", |
|
||||
"version" : "4.4.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "interop-ios-for-google-sdks", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|
||||
"state" : { |
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|
||||
"version" : "101.0.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "leveldb", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/leveldb.git", |
|
||||
"state" : { |
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|
||||
"version" : "1.22.5" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "nanopb", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/firebase/nanopb.git", |
|
||||
"state" : { |
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|
||||
"version" : "2.30910.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "promises", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/google/promises.git", |
|
||||
"state" : { |
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|
||||
"version" : "2.4.0" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"identity" : "swift-protobuf", |
|
||||
"kind" : "remoteSourceControl", |
|
||||
"location" : "https://github.com/apple/swift-protobuf.git", |
|
||||
"state" : { |
|
||||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|
||||
"version" : "1.29.0" |
|
||||
} |
|
||||
} |
|
||||
], |
|
||||
"version" : 3 |
|
||||
} |
|
||||
@ -1,743 +1,13 @@ |
|||||
import Flutter |
import Flutter |
||||
import UIKit |
import UIKit |
||||
import AVFoundation |
|
||||
|
|
||||
@main |
@main |
||||
@objc class AppDelegate: FlutterAppDelegate { |
@objc class AppDelegate: FlutterAppDelegate { |
||||
private var classicBluetoothHelper: ClassicBluetoothHelper? |
|
||||
private var mediaButtonHelper: Any? |
|
||||
// 添加Azure语音服务辅助类 |
|
||||
private var azureAsrHelper: Any? |
|
||||
private var azureTtsHelper: Any? |
|
||||
// 添加火山AI服务 |
|
||||
private var volcanoAIService: VolcanoAIService? |
|
||||
// 语音交互服务 |
|
||||
private var voiceInteractionService: VoiceInteractionService? |
|
||||
|
|
||||
private var audioSessionManager: AudioSessionManager? |
|
||||
|
|
||||
override func application( |
override func application( |
||||
_ application: UIApplication, |
_ application: UIApplication, |
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
||||
) -> Bool { |
) -> Bool { |
||||
|
|
||||
// 获取flutter控制器 |
|
||||
guard let controller = window?.rootViewController as? FlutterViewController else { |
|
||||
NSLog("AppDelegate: Flutter控制器不可用") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 初始化音频会话管理器 |
|
||||
audioSessionManager = AudioSessionManager.shared |
|
||||
|
|
||||
|
|
||||
// 统一注册所有方法通道 |
|
||||
registerMethodChannel() |
|
||||
|
|
||||
GeneratedPluginRegistrant.register(with: self) |
GeneratedPluginRegistrant.register(with: self) |
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
||||
} |
} |
||||
|
|
||||
private func registerMethodChannel() { |
|
||||
let controller = window?.rootViewController as? FlutterViewController |
|
||||
guard let controller = controller else { |
|
||||
NSLog("AppDelegate: FlutterViewController 不可用") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 注册经典蓝牙方法通道 |
|
||||
registerClassicBluetoothChannels(controller: controller) |
|
||||
|
|
||||
// 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 |
|
||||
if #available(iOS 13.0, *) { |
|
||||
// 注册媒体按钮方法通道 |
|
||||
registerMediaButtonChannels(controller: controller) |
|
||||
|
|
||||
// 注册Azure ASR方法通道 |
|
||||
registerAzureAsrChannels(controller: controller) |
|
||||
|
|
||||
// 注册Azure TTS方法通道 |
|
||||
registerAzureTtsChannels(controller: controller) |
|
||||
|
|
||||
// 注册火山AI服务方法通道 |
|
||||
registerVolcanoAIServiceChannels(controller: controller) |
|
||||
|
|
||||
// 注册语音交互服务方法通道 |
|
||||
registerVoiceInteractionChannels(controller: controller) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 经典蓝牙通道 |
|
||||
|
|
||||
private func registerClassicBluetoothChannels(controller: FlutterViewController) { |
|
||||
// 初始化蓝牙辅助类 |
|
||||
if classicBluetoothHelper == nil { |
|
||||
classicBluetoothHelper = ClassicBluetoothHelper() |
|
||||
} |
|
||||
|
|
||||
guard let classicBluetoothHelper = self.classicBluetoothHelper else { |
|
||||
NSLog("AppDelegate: 经典蓝牙辅助实例不可用") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 注册经典蓝牙方法通道 |
|
||||
let classicBluetoothChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.classic_bluetooth", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
classicBluetoothChannel.setMethodCallHandler { [weak self, weak classicBluetoothHelper] (call, result) in |
|
||||
guard let _ = self, let classicBluetoothHelper = classicBluetoothHelper else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "经典蓝牙辅助实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "isBluetoothEnabled": |
|
||||
result(classicBluetoothHelper.isBluetoothEnabled()) |
|
||||
|
|
||||
case "getConnectedHeadsetDevices": |
|
||||
// 获取已连接的耳机设备 |
|
||||
classicBluetoothHelper.getConnectedHeadsetDevices { devices, error in |
|
||||
if let error = error { |
|
||||
result(FlutterError(code: "GET_DEVICES_FAILED", message: error, details: nil)) |
|
||||
} else { |
|
||||
result(devices) |
|
||||
} |
|
||||
} |
|
||||
case "initialize": |
|
||||
classicBluetoothHelper.initialize() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册蓝牙流通道 |
|
||||
let classicBluetoothStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.classic_bluetooth_events", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建蓝牙流处理器(作为局部变量) |
|
||||
let bluetoothStreamHandler = BluetoothStreamHandler(bluetoothHelper: classicBluetoothHelper) |
|
||||
classicBluetoothStreamChannel.setStreamHandler(bluetoothStreamHandler) |
|
||||
} |
|
||||
|
|
||||
// MARK: - 消息通道设置 |
|
||||
|
|
||||
// MARK: - 蓝牙媒体按键通道 |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
private func registerMediaButtonChannels(controller: FlutterViewController) { |
|
||||
// 初始化媒体按钮辅助类 |
|
||||
if mediaButtonHelper == nil { |
|
||||
mediaButtonHelper = BluetoothMediaButtonHelper() |
|
||||
} |
|
||||
// guard let mediaButtonHelper = self.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
|
||||
// NSLog("AppDelegate: 媒体按钮辅助实例不可用") |
|
||||
// return |
|
||||
// } |
|
||||
// mediaButtonHelper.startButtonListening() |
|
||||
// 注册媒体按钮方法通道 |
|
||||
let mediaButtonChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.bluetooth_media_button", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
mediaButtonChannel.setMethodCallHandler { [weak self] (call, result) in |
|
||||
guard let _ = self, let mediaButtonHelper = self?.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "媒体按钮辅助实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "startButtonListening": |
|
||||
let success = mediaButtonHelper.startButtonListening() |
|
||||
result(success) |
|
||||
|
|
||||
case "stopButtonListening": |
|
||||
let success = mediaButtonHelper.stopButtonListening() |
|
||||
result(success) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册媒体按钮流通道 |
|
||||
let mediaButtonStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.bluetooth_media_button_events", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建媒体按钮流处理器 |
|
||||
let mediaButtonStreamHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper as! BluetoothMediaButtonHelper) |
|
||||
mediaButtonStreamChannel.setStreamHandler(mediaButtonStreamHandler) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// 注册Azure ASR通道 |
|
||||
@available(iOS 13.0, *) |
|
||||
private func registerAzureAsrChannels(controller: FlutterViewController) { |
|
||||
// 注册Azure ASR方法通道 |
|
||||
let azureAsrChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.azure_asr", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 初始化Azure ASR辅助类 |
|
||||
azureAsrHelper = AzureAsrHelper() |
|
||||
|
|
||||
// 设置ASR方法处理器 |
|
||||
azureAsrChannel.setMethodCallHandler { [weak self] (call, result) in |
|
||||
guard let self = self, |
|
||||
let asrHelper = self.azureAsrHelper as? AzureAsrHelper else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "Azure ASR辅助实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "initialize": |
|
||||
guard let args = call.arguments as? [String: Any], |
|
||||
let key = args["subscriptionKey"] as? String, |
|
||||
let region = args["region"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let languages = args["supportedLanguages"] as? [String] |
|
||||
let success = asrHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, supportedLanguages: languages) |
|
||||
result(success) |
|
||||
|
|
||||
case "recognizeOnce": |
|
||||
let success = asrHelper.recognizeOnce() |
|
||||
result(success) |
|
||||
|
|
||||
case "startContinuousRecognition": |
|
||||
let success = asrHelper.startContinuousRecognition() |
|
||||
result(success) |
|
||||
|
|
||||
case "stopContinuousRecognition": |
|
||||
let success = asrHelper.stopContinuousRecognition() |
|
||||
result(success) |
|
||||
|
|
||||
case "isContinuousRecognitionActive": |
|
||||
let isActive = asrHelper.isContinuousRecognitionActive() |
|
||||
result(isActive) |
|
||||
|
|
||||
case "dispose": |
|
||||
asrHelper.dispose() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册Azure ASR流通道 |
|
||||
let azureAsrStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.azure_asr_events", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建ASR流处理器 |
|
||||
let asrStreamHandler = AsrStreamHandler(azureAsrHelper: azureAsrHelper as! AzureAsrHelper) |
|
||||
azureAsrStreamChannel.setStreamHandler(asrStreamHandler) |
|
||||
} |
|
||||
|
|
||||
// 注册Azure TTS通道 |
|
||||
@available(iOS 13.0, *) |
|
||||
private func registerAzureTtsChannels(controller: FlutterViewController) { |
|
||||
// 注册Azure TTS方法通道 |
|
||||
let azureTtsChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.azure_tts", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 初始化Azure TTS辅助类 |
|
||||
azureTtsHelper = AzureTtsHelper() |
|
||||
|
|
||||
// 设置TTS方法处理器 |
|
||||
azureTtsChannel.setMethodCallHandler { [weak self] (call, result) in |
|
||||
guard let self = self, |
|
||||
let ttsHelper = self.azureTtsHelper as? AzureTtsHelper else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "Azure TTS辅助实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "initialize": |
|
||||
guard let args = call.arguments as? [String: Any], |
|
||||
let key = args["subscriptionKey"] as? String, |
|
||||
let region = args["region"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let language = args["language"] as? String ?? "zh-CN" |
|
||||
let success = ttsHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, language: language) |
|
||||
result(success) |
|
||||
|
|
||||
case "setVoice": |
|
||||
guard let args = call.arguments as? [String: Any], |
|
||||
let voiceName = args["voiceName"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "语音名称参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let success = ttsHelper.setVoice(voiceName: voiceName) |
|
||||
result(success) |
|
||||
|
|
||||
case "setSpeechParams": |
|
||||
guard let args = call.arguments as? [String: Any] else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "语音参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let rate = args["rate"] as? Int ?? 0 |
|
||||
let pitch = args["pitch"] as? Int ?? 0 |
|
||||
let volume = args["volume"] as? Int ?? 100 |
|
||||
|
|
||||
let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) |
|
||||
result(success) |
|
||||
|
|
||||
case "speakText": |
|
||||
guard let args = call.arguments as? [String: Any], |
|
||||
let text = args["text"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "文本参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let success = ttsHelper.speakText(text: text) |
|
||||
result(success) |
|
||||
|
|
||||
case "stopSpeaking": |
|
||||
let success = ttsHelper.stopSpeaking() |
|
||||
result(success) |
|
||||
|
|
||||
case "isSpeaking": |
|
||||
let isSpeaking = ttsHelper.isSpeaking() |
|
||||
result(isSpeaking) |
|
||||
|
|
||||
case "dispose": |
|
||||
ttsHelper.dispose() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册Azure TTS流通道 |
|
||||
let azureTtsStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.azure_tts_events", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建TTS流处理器 |
|
||||
let ttsStreamHandler = TtsStreamHandler(azureTtsHelper: azureTtsHelper as! AzureTtsHelper) |
|
||||
azureTtsStreamChannel.setStreamHandler(ttsStreamHandler) |
|
||||
} |
|
||||
|
|
||||
// 注册火山AI服务方法通道 |
|
||||
@available(iOS 13.0, *) |
|
||||
private func registerVolcanoAIServiceChannels(controller: FlutterViewController) { |
|
||||
guard let volcanoAIService = volcanoAIService else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 注册火山AI方法通道 |
|
||||
let volcanoAIChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.volcano_ai", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
volcanoAIChannel.setMethodCallHandler { [weak self, weak volcanoAIService] (call, result) in |
|
||||
guard let _ = self, let volcanoAIService = volcanoAIService else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "火山AI服务实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "initialize": |
|
||||
let apiKey = call.arguments as? String ?? "" |
|
||||
let success = volcanoAIService.initialize(apiKey: apiKey) |
|
||||
result(success) |
|
||||
|
|
||||
case "sendMessage": |
|
||||
guard let arguments = call.arguments as? [String: Any], |
|
||||
let message = arguments["message"] as? String, |
|
||||
let systemPrompt = arguments["systemPrompt"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if message.isEmpty { |
|
||||
result(FlutterError(code: "EMPTY_MESSAGE", message: "消息不能为空", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 异步发送消息 |
|
||||
Task { |
|
||||
do { |
|
||||
// 创建用户消息 |
|
||||
let userMessage = volcanoAIService.createUserMessage(content: message) |
|
||||
|
|
||||
// 发送消息并等待响应 |
|
||||
let response = try await volcanoAIService.sendMessage( |
|
||||
messages: [userMessage], |
|
||||
systemPrompt: systemPrompt |
|
||||
) |
|
||||
|
|
||||
// 返回响应 |
|
||||
DispatchQueue.main.async { |
|
||||
result(response) |
|
||||
} |
|
||||
} catch { |
|
||||
// 处理错误 |
|
||||
DispatchQueue.main.async { |
|
||||
result(FlutterError( |
|
||||
code: "AI_ERROR", |
|
||||
message: "AI请求失败: \(error.localizedDescription)", |
|
||||
details: nil |
|
||||
)) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册火山AI流通道 |
|
||||
let volcanoAIStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.volcano_ai_stream", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建火山AI流处理器(作为局部变量) |
|
||||
let volcanoAIStreamHandler = VolcanoAIStreamHandler(volcanoAIService: volcanoAIService) |
|
||||
volcanoAIStreamChannel.setStreamHandler(volcanoAIStreamHandler) |
|
||||
} |
|
||||
|
|
||||
// 注册语音交互服务方法通道 |
|
||||
@available(iOS 13.0, *) |
|
||||
private func registerVoiceInteractionChannels(controller: FlutterViewController) { |
|
||||
// 初始化语音交互服务 |
|
||||
voiceInteractionService = VoiceInteractionService.shared |
|
||||
|
|
||||
// 注册语音交互服务方法通道 |
|
||||
let voiceInteractionChannel = FlutterMethodChannel( |
|
||||
name: "com.deep_voice.voice_interaction", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
voiceInteractionChannel.setMethodCallHandler { [weak self, weak voiceInteractionService] (call, result) in |
|
||||
guard let _ = self, let voiceInteractionService = voiceInteractionService else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", message: "语音交互服务实例不可用", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "startService": |
|
||||
guard let args = call.arguments as? [String: Any], |
|
||||
let azureKey = args["azure_speech_key"] as? String, |
|
||||
let azureRegion = args["azure_speech_region"] as? String, |
|
||||
let volcanoKey = args["volcano_ai_api_key"] as? String else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let success = voiceInteractionService.start(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
|
||||
result(success) |
|
||||
|
|
||||
case "stopService": |
|
||||
voiceInteractionService.stop() |
|
||||
result(true) |
|
||||
|
|
||||
case "isServiceRunning": |
|
||||
let isRunning = voiceInteractionService.isServiceRunning() |
|
||||
result(isRunning) |
|
||||
|
|
||||
case "pauseVoiceInteraction": |
|
||||
voiceInteractionService.pauseVoiceInteraction() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 注册语音交互流通道 |
|
||||
let voiceInteractionStreamChannel = FlutterEventChannel( |
|
||||
name: "com.deep_voice.voice_interaction_events", |
|
||||
binaryMessenger: controller.binaryMessenger |
|
||||
) |
|
||||
|
|
||||
// 创建语音交互流处理器(作为局部变量) |
|
||||
let voiceInteractionStreamHandler = VoiceInteractionStreamHandler(voiceInteractionService: voiceInteractionService!) |
|
||||
voiceInteractionStreamChannel.setStreamHandler(voiceInteractionStreamHandler) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 蓝牙事件流处理器 |
|
||||
class BluetoothStreamHandler: NSObject, FlutterStreamHandler, BluetoothEventHandler { |
|
||||
private let bluetoothHelper: ClassicBluetoothHelper |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(bluetoothHelper: ClassicBluetoothHelper) { |
|
||||
self.bluetoothHelper = bluetoothHelper |
|
||||
super.init() |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
bluetoothHelper.setEventHandler(self) |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
// NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
// NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 实现BluetoothEventHandler协议 |
|
||||
func sendEvent(_ event: [String: Any]) { |
|
||||
NSLog("BluetoothStreamHandler: 接收到蓝牙事件: \(event)") |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.eventSink?(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 媒体按钮事件流处理器 |
|
||||
@available(iOS 13.0, *) |
|
||||
class MediaButtonStreamHandler: NSObject, FlutterStreamHandler, MediaButtonEventHandler { |
|
||||
private let mediaButtonHelper: BluetoothMediaButtonHelper |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(mediaButtonHelper: BluetoothMediaButtonHelper) { |
|
||||
self.mediaButtonHelper = mediaButtonHelper |
|
||||
super.init() |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
mediaButtonHelper.setEventHandler(self) |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
NSLog("MediaButtonStreamHandler: 开始监听媒体按钮事件") |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
NSLog("MediaButtonStreamHandler: 取消监听媒体按钮事件") |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 实现MediaButtonEventHandler协议 |
|
||||
func sendEvent(_ event: [String: Any]) { |
|
||||
NSLog("MediaButtonStreamHandler: 收到按钮事件 \(event)") |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.eventSink?(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// ASR事件处理器协议 |
|
||||
@available(iOS 13.0, *) |
|
||||
protocol AsrEventHandler: AnyObject { |
|
||||
func sendEvent(_ event: [String: Any]) |
|
||||
} |
|
||||
|
|
||||
// ASR事件流处理器 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AsrStreamHandler: NSObject, FlutterStreamHandler, AsrEventHandler { |
|
||||
private let azureAsrHelper: AzureAsrHelper |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(azureAsrHelper: AzureAsrHelper) { |
|
||||
self.azureAsrHelper = azureAsrHelper |
|
||||
super.init() |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
azureAsrHelper.setEventHandler { [weak self] event in |
|
||||
self?.sendEvent(event) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
NSLog("AsrStreamHandler: 开始监听ASR事件") |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
NSLog("AsrStreamHandler: 取消监听ASR事件") |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 实现AsrEventHandler协议 |
|
||||
func sendEvent(_ event: [String: Any]) { |
|
||||
// NSLog("AsrStreamHandler: 收到事件 \(event)") |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.eventSink?(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// TTS事件处理器协议 |
|
||||
@available(iOS 13.0, *) |
|
||||
protocol TtsEventHandler: AnyObject { |
|
||||
func sendEvent(_ event: [String: Any]) |
|
||||
} |
|
||||
|
|
||||
// TTS事件流处理器 |
|
||||
@available(iOS 13.0, *) |
|
||||
class TtsStreamHandler: NSObject, FlutterStreamHandler, TtsEventHandler { |
|
||||
private let azureTtsHelper: AzureTtsHelper |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(azureTtsHelper: AzureTtsHelper) { |
|
||||
self.azureTtsHelper = azureTtsHelper |
|
||||
super.init() |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
azureTtsHelper.setEventHandler { [weak self] event in |
|
||||
self?.sendEvent(event) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
NSLog("TtsStreamHandler: 开始监听TTS事件") |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
NSLog("TtsStreamHandler: 取消监听TTS事件") |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 实现TtsEventHandler协议 |
|
||||
func sendEvent(_ event: [String: Any]) { |
|
||||
NSLog("TtsStreamHandler: 收到事件 \(event)") |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.eventSink?(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 火山AI事件流处理器 |
|
||||
@available(iOS 13.0, *) |
|
||||
class VolcanoAIStreamHandler: NSObject, FlutterStreamHandler { |
|
||||
private let volcanoAIService: VolcanoAIService |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(volcanoAIService: VolcanoAIService) { |
|
||||
self.volcanoAIService = volcanoAIService |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 处理来自Flutter的流式请求 |
|
||||
func handleStreamRequest(messages: [[String: Any]], systemPrompt: String) { |
|
||||
volcanoAIService.sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: VolcanoAIService.StreamCallback( |
|
||||
onToken: { [weak self] token in |
|
||||
guard let self = self, let eventSink = self.eventSink else { return } |
|
||||
|
|
||||
let event: [String: Any] = [ |
|
||||
"type": "token", |
|
||||
"token": token |
|
||||
] |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
eventSink(event) |
|
||||
} |
|
||||
}, |
|
||||
onComplete: { [weak self] in |
|
||||
guard let self = self, let eventSink = self.eventSink else { return } |
|
||||
|
|
||||
let event: [String: Any] = [ |
|
||||
"type": "complete" |
|
||||
] |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
eventSink(event) |
|
||||
} |
|
||||
}, |
|
||||
onError: { [weak self] error in |
|
||||
guard let self = self, let eventSink = self.eventSink else { return } |
|
||||
|
|
||||
let event: [String: Any] = [ |
|
||||
"type": "error", |
|
||||
"error": error.localizedDescription |
|
||||
] |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
eventSink(event) |
|
||||
} |
|
||||
} |
|
||||
)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 语音交互事件处理器协议 |
|
||||
@available(iOS 13.0, *) |
|
||||
protocol VoiceInteractionEventHandler: AnyObject { |
|
||||
func sendEvent(_ event: [String: Any]) |
|
||||
} |
|
||||
|
|
||||
// 语音交互流处理器 |
|
||||
@available(iOS 13.0, *) |
|
||||
class VoiceInteractionStreamHandler: NSObject, FlutterStreamHandler, VoiceInteractionEventHandler { |
|
||||
private let voiceInteractionService: VoiceInteractionService |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
|
|
||||
init(voiceInteractionService: VoiceInteractionService) { |
|
||||
self.voiceInteractionService = voiceInteractionService |
|
||||
super.init() |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
voiceInteractionService.setEventHandler(self) |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
NSLog("VoiceInteractionStreamHandler: 开始监听语音交互事件") |
|
||||
self.eventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
NSLog("VoiceInteractionStreamHandler: 取消监听语音交互事件") |
|
||||
eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// 实现VoiceInteractionEventHandler协议 |
|
||||
func sendEvent(_ event: [String: Any]) { |
|
||||
NSLog("VoiceInteractionStreamHandler: 收到事件 \(event)") |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.eventSink?(event) |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
|
|||||
@ -1,589 +0,0 @@ |
|||||
import Foundation |
|
||||
import AVFoundation |
|
||||
import UIKit |
|
||||
|
|
||||
/// 统一音频会话管理类 |
|
||||
/// 用于统一管理所有组件的音频会话配置 |
|
||||
@objc class AudioSessionManager: NSObject { |
|
||||
// MARK: - 单例实现 |
|
||||
|
|
||||
@objc static let shared = AudioSessionManager() |
|
||||
|
|
||||
// 音频会话 |
|
||||
private let audioSession = AVAudioSession.sharedInstance() |
|
||||
|
|
||||
// 音频会话状态 |
|
||||
private var isConfigured = false |
|
||||
private var isActive = false |
|
||||
|
|
||||
// 当前配置类型 |
|
||||
private var currentCategory: AVAudioSession.Category? |
|
||||
private var currentMode: AVAudioSession.Mode? |
|
||||
private var currentOptions: AVAudioSession.CategoryOptions? |
|
||||
|
|
||||
// 中断监听器 |
|
||||
private var interruptionObserver: NSObjectProtocol? |
|
||||
|
|
||||
// 路由变化监听器 |
|
||||
private var routeChangeObserver: NSObjectProtocol? |
|
||||
|
|
||||
// 私有初始化方法,确保只能通过shared访问 |
|
||||
private override init() { |
|
||||
super.init() |
|
||||
NSLog("[AudioSessionManager] 音频会话管理器已初始化") |
|
||||
setupNotifications() |
|
||||
|
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
removeNotifications() |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// MARK: - 通知设置 |
|
||||
|
|
||||
private func setupNotifications() { |
|
||||
// 监听中断事件 |
|
||||
interruptionObserver = NotificationCenter.default.addObserver( |
|
||||
forName: AVAudioSession.interruptionNotification, |
|
||||
object: nil, |
|
||||
queue: .main) { [weak self] notification in |
|
||||
self?.handleInterruption(notification) |
|
||||
} |
|
||||
|
|
||||
// 监听路由变化事件 |
|
||||
routeChangeObserver = NotificationCenter.default.addObserver( |
|
||||
forName: AVAudioSession.routeChangeNotification, |
|
||||
object: nil, |
|
||||
queue: .main) { [weak self] notification in |
|
||||
self?.handleRouteChange(notification) |
|
||||
} |
|
||||
|
|
||||
// 监听应用进入后台/前台 |
|
||||
NotificationCenter.default.addObserver( |
|
||||
self, |
|
||||
selector: #selector(handleAppDidEnterBackground), |
|
||||
name: UIApplication.didEnterBackgroundNotification, |
|
||||
object: nil) |
|
||||
|
|
||||
NotificationCenter.default.addObserver( |
|
||||
self, |
|
||||
selector: #selector(handleAppWillEnterForeground), |
|
||||
name: UIApplication.willEnterForegroundNotification, |
|
||||
object: nil) |
|
||||
} |
|
||||
|
|
||||
private func removeNotifications() { |
|
||||
if let interruptionObserver = interruptionObserver { |
|
||||
NotificationCenter.default.removeObserver(interruptionObserver) |
|
||||
} |
|
||||
|
|
||||
if let routeChangeObserver = routeChangeObserver { |
|
||||
NotificationCenter.default.removeObserver(routeChangeObserver) |
|
||||
} |
|
||||
|
|
||||
NotificationCenter.default.removeObserver(self) |
|
||||
} |
|
||||
|
|
||||
// MARK: - 通知处理 |
|
||||
|
|
||||
private func handleInterruption(_ notification: Notification) { |
|
||||
guard let userInfo = notification.userInfo, |
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, |
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch type { |
|
||||
case .began: |
|
||||
// 中断开始 |
|
||||
NSLog("[AudioSessionManager] 音频会话被中断") |
|
||||
isActive = false |
|
||||
|
|
||||
case .ended: |
|
||||
// 中断结束,尝试恢复 |
|
||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { |
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) |
|
||||
if options.contains(.shouldResume) { |
|
||||
NSLog("[AudioSessionManager] 尝试恢复被中断的音频会话") |
|
||||
try? activateSession() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@unknown default: |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleRouteChange(_ notification: Notification) { |
|
||||
guard let userInfo = notification.userInfo, |
|
||||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch reason { |
|
||||
case .newDeviceAvailable: |
|
||||
// 新设备连接 |
|
||||
let currentRoute = audioSession.currentRoute |
|
||||
for output in currentRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
|
||||
NSLog("[AudioSessionManager] 检测到蓝牙音频设备连接: \(output.portName)") |
|
||||
// 蓝牙设备连接,可能需要更新配置 |
|
||||
if isActive && currentOptions?.contains(.allowBluetooth) == true { |
|
||||
try? forceReconfigure() |
|
||||
} |
|
||||
break |
|
||||
} |
|
||||
|
|
||||
case .oldDeviceUnavailable: |
|
||||
// 设备断开连接 |
|
||||
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
|
||||
for output in previousRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
|
||||
NSLog("[AudioSessionManager] 蓝牙音频设备断开连接: \(output.portName)") |
|
||||
// 蓝牙设备断开,重新配置到内置扬声器 |
|
||||
if isActive { |
|
||||
try? forceReconfigure() |
|
||||
} |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
default: |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@objc private func handleAppDidEnterBackground() { |
|
||||
NSLog("[AudioSessionManager] 应用进入后台") |
|
||||
// 应用进入后台时可能需要调整音频会话 |
|
||||
|
|
||||
} |
|
||||
|
|
||||
@objc private func handleAppWillEnterForeground() { |
|
||||
NSLog("[AudioSessionManager] 应用即将进入前台") |
|
||||
// 应用返回前台,检查并恢复音频会话 |
|
||||
if isConfigured && !isActive { |
|
||||
try? activateSession() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 音频会话配置 |
|
||||
|
|
||||
/// 统一配置方法 - 所有音频配置都通过此方法进行 |
|
||||
@objc func configureSession(category: AVAudioSession.Category, |
|
||||
mode: AVAudioSession.Mode, |
|
||||
options: AVAudioSession.CategoryOptions, |
|
||||
force: Bool = false) throws { |
|
||||
|
|
||||
NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode), 选项=\(options), 强制=\(force)") |
|
||||
|
|
||||
// 如果已配置且不强制重新配置,则直接返回 |
|
||||
if isConfigured && !force && |
|
||||
currentCategory == category && |
|
||||
currentMode == mode && |
|
||||
currentOptions == options { |
|
||||
NSLog("[AudioSessionManager] 音频会话已配置,跳过配置") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode)") |
|
||||
|
|
||||
// 暂时停用当前会话(如果正在活动) |
|
||||
if isActive { |
|
||||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
|
||||
} |
|
||||
|
|
||||
// 设置新的分类、模式和选项 |
|
||||
try audioSession.setCategory(category, mode: mode, options: options) |
|
||||
|
|
||||
// 设置采样率和缓冲区大小(对于语音识别优化) |
|
||||
// if mode == .spokenAudio { |
|
||||
// try audioSession.setPreferredSampleRate(16000) |
|
||||
// try audioSession.setPreferredIOBufferDuration(0.01) // 10ms |
|
||||
// } |
|
||||
|
|
||||
// 重新激活会话 |
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|
||||
isActive = true |
|
||||
// 切换到扬声器 |
|
||||
// 记录当前配置 |
|
||||
currentCategory = category |
|
||||
currentMode = mode |
|
||||
currentOptions = options |
|
||||
isConfigured = true |
|
||||
|
|
||||
// 检查并设置首选的输入设备(如果需要) |
|
||||
if category == .playAndRecord || category == .record { |
|
||||
try configurePreferredInput() |
|
||||
} |
|
||||
return |
|
||||
} catch { |
|
||||
NSLog("[AudioSessionManager] 配置音频会话失败: \(error.localizedDescription)") |
|
||||
throw error |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 配置首选输入设备 |
|
||||
private func configurePreferredInput() throws { |
|
||||
// 获取可用输入设备 |
|
||||
guard let availableInputs = audioSession.availableInputs else { |
|
||||
NSLog("[AudioSessionManager] 无法获取可用输入设备") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 首选顺序:有线耳机 > 蓝牙耳机 > 内置麦克风 |
|
||||
var preferredInput: AVAudioSessionPortDescription? = nil |
|
||||
|
|
||||
// 先尝试找到有线耳机 |
|
||||
if let wiredHeadset = availableInputs.first(where: { $0.portType == .headsetMic }) { |
|
||||
preferredInput = wiredHeadset |
|
||||
NSLog("[AudioSessionManager] 设置首选输入设备: 有线耳机") |
|
||||
} |
|
||||
// 如果没有有线耳机,尝试使用蓝牙 |
|
||||
else if let bluetooth = availableInputs.first(where: { $0.portType == .bluetoothHFP }) { |
|
||||
preferredInput = bluetooth |
|
||||
NSLog("[AudioSessionManager] 设置首选输入设备: 蓝牙耳机") |
|
||||
} |
|
||||
// 如果没有外部设备,使用内置麦克风 |
|
||||
else if let builtIn = availableInputs.first(where: { $0.portType == .builtInMic }) { |
|
||||
preferredInput = builtIn |
|
||||
NSLog("[AudioSessionManager] 设置首选输入设备: 内置麦克风") |
|
||||
} |
|
||||
|
|
||||
// 设置首选输入设备 |
|
||||
if let input = preferredInput { |
|
||||
try audioSession.setPreferredInput(input) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
/// 激活音频会话(如果已配置) |
|
||||
@objc func activateSession() throws { |
|
||||
if !isConfigured { |
|
||||
NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话,应用默认配置") |
|
||||
// 未配置,应用默认配置 |
|
||||
// try configureSession( |
|
||||
// category: .playAndRecord, |
|
||||
// mode: .spokenAudio, |
|
||||
// options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], |
|
||||
// force: true |
|
||||
// ) |
|
||||
|
|
||||
try configureSession( |
|
||||
category: .playAndRecord, // 允许同时录音和播放 |
|
||||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|
||||
options: [ |
|
||||
.allowBluetooth, // 允许蓝牙设备 |
|
||||
.defaultToSpeaker, // 默认使用扬声器 |
|
||||
.mixWithOthers // 允许与其他应用混音 |
|
||||
], |
|
||||
force: false // 是否强制重新配置 |
|
||||
) |
|
||||
|
|
||||
|
|
||||
} else if !isActive { |
|
||||
NSLog("[AudioSessionManager] 激活已配置的音频会话") |
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|
||||
isActive = true |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停用音频会话(保留配置) |
|
||||
@objc func deactivateSession() throws { |
|
||||
if isActive { |
|
||||
NSLog("[AudioSessionManager] 停用音频会话") |
|
||||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
|
||||
isActive = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 重新配置当前会话 |
|
||||
@objc func forceReconfigure() throws { |
|
||||
NSLog("[AudioSessionManager] 强制重新配置音频会话") |
|
||||
// try configureSession( |
|
||||
// category: currentCategory ?? .playAndRecord, |
|
||||
// mode: currentMode ?? .default, |
|
||||
// options: currentOptions ?? [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker], |
|
||||
// force: true |
|
||||
// ) |
|
||||
|
|
||||
try configureSession( |
|
||||
category: .playAndRecord, // 允许同时录音和播放 |
|
||||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|
||||
options: [ |
|
||||
.allowBluetooth, // 允许蓝牙设备 |
|
||||
.defaultToSpeaker, // 默认使用扬声器 |
|
||||
.mixWithOthers // 允许与其他应用混音 |
|
||||
], |
|
||||
force: false // 是否强制重新配置 |
|
||||
) |
|
||||
try audioSession.overrideOutputAudioPort(.speaker) |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
// MARK: - 状态检查 |
|
||||
|
|
||||
/// 检查麦克风权限 |
|
||||
@objc func checkMicrophonePermission(completion: @escaping (Bool) -> Void) { |
|
||||
switch AVAudioSession.sharedInstance().recordPermission { |
|
||||
case .granted: |
|
||||
completion(true) |
|
||||
case .denied: |
|
||||
NSLog("[AudioSessionManager] 麦克风权限被拒绝") |
|
||||
completion(false) |
|
||||
case .undetermined: |
|
||||
NSLog("[AudioSessionManager] 请求麦克风权限") |
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in |
|
||||
DispatchQueue.main.async { |
|
||||
completion(granted) |
|
||||
} |
|
||||
} |
|
||||
@unknown default: |
|
||||
completion(false) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取当前音频会话的配置信息 |
|
||||
@objc func getCurrentConfiguration() -> [String: Any] { |
|
||||
return [ |
|
||||
"isConfigured": isConfigured, |
|
||||
"isActive": isActive, |
|
||||
"category": String(describing: currentCategory), |
|
||||
"mode": String(describing: currentMode), |
|
||||
"bluetoothEnabled": currentOptions?.contains(.allowBluetooth) == true, |
|
||||
"currentInputs": audioSession.currentRoute.inputs.map { [$0.portType.rawValue: $0.portName] }, |
|
||||
"currentOutputs": audioSession.currentRoute.outputs.map { [$0.portType.rawValue: $0.portName] } |
|
||||
] |
|
||||
} |
|
||||
|
|
||||
/// 检查当前是否有蓝牙耳机连接 |
|
||||
@objc func hasBluetoothHeadsetConnected() -> Bool { |
|
||||
let outputs = audioSession.currentRoute.outputs |
|
||||
return outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE } |
|
||||
} |
|
||||
|
|
||||
/// 检查蓝牙是否启用 |
|
||||
@objc func isBluetoothEnabled() -> Bool { |
|
||||
// 在iOS中无法直接检查蓝牙是否启用,我们可以通过检查音频会话配置来推断 |
|
||||
|
|
||||
// 检查音频会话是否配置了允许蓝牙 |
|
||||
if let options = currentOptions { |
|
||||
return options.contains(.allowBluetooth) || options.contains(.allowBluetoothA2DP) |
|
||||
} |
|
||||
|
|
||||
// 如果尚未配置音频会话,尝试检查当前路由中是否有蓝牙设备 |
|
||||
let outputs = audioSession.currentRoute.outputs |
|
||||
let hasBluetooth = outputs.contains { |
|
||||
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE |
|
||||
} |
|
||||
|
|
||||
return hasBluetooth |
|
||||
} |
|
||||
|
|
||||
/// 获取当前音频路由 |
|
||||
@objc func getCurrentAudioRoute() -> String { |
|
||||
let outputs = audioSession.currentRoute.outputs |
|
||||
if let output = outputs.first { |
|
||||
return "\(output.portType.rawValue) (\(output.portName))" |
|
||||
} |
|
||||
return "unknown" |
|
||||
} |
|
||||
|
|
||||
/// 获取当前输入设备 |
|
||||
@objc func getCurrentInputDevice() -> String { |
|
||||
let inputs = audioSession.currentRoute.inputs |
|
||||
if let input = inputs.first { |
|
||||
return "\(input.portType.rawValue) (\(input.portName))" |
|
||||
} |
|
||||
return "unknown" |
|
||||
} |
|
||||
|
|
||||
// MARK: - 路由管理和监听 |
|
||||
|
|
||||
/// 获取当前路由信息 |
|
||||
@objc func getCurrentRoute() -> AVAudioSessionRouteDescription { |
|
||||
return audioSession.currentRoute |
|
||||
} |
|
||||
|
|
||||
/// 添加路由变更监听器 |
|
||||
/// - Parameters: |
|
||||
/// - observer: 监听对象 |
|
||||
/// - selector: 监听方法选择器 |
|
||||
@objc func addRouteChangeListener(_ observer: Any, selector: Selector) { |
|
||||
NotificationCenter.default.addObserver( |
|
||||
observer, |
|
||||
selector: selector, |
|
||||
name: AVAudioSession.routeChangeNotification, |
|
||||
object: nil |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/// 移除路由变更监听器 |
|
||||
/// - Parameter observer: 要移除的监听对象 |
|
||||
@objc func removeRouteChangeListener(_ observer: Any) { |
|
||||
NotificationCenter.default.removeObserver( |
|
||||
observer, |
|
||||
name: AVAudioSession.routeChangeNotification, |
|
||||
object: nil |
|
||||
) |
|
||||
} |
|
||||
/// 获取设备类型描述 |
|
||||
private func getDeviceType(portType: AVAudioSession.Port) -> String { |
|
||||
switch portType { |
|
||||
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: |
|
||||
return "bluetooth" |
|
||||
case .headphones, .headsetMic: |
|
||||
return "wired" |
|
||||
case .airPlay: |
|
||||
return "airplay" |
|
||||
default: |
|
||||
return "unknown" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
// /// 为Azure语音识别专门配置的音频会话 |
|
||||
// /// 此函数优化Azure语音识别的音频处理配置,提供更强的回音消除能力 |
|
||||
// /// - Parameters: |
|
||||
// /// - force: 是否强制重新配置 |
|
||||
// /// - configureAdditionalSettings: 配置完成后进行额外的音频设置 |
|
||||
// /// - Returns: 配置是否成功 |
|
||||
// @objc func configureForAzureSpeechRecognition(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
|
||||
// NSLog("[AudioSessionManager] 配置Azure ASR语音识别的音频会话") |
|
||||
// do { |
|
||||
// // 配置音频会话以优化语音识别 |
|
||||
// try configureSession( |
|
||||
// category: .playAndRecord, // 允许同时录音和播放 |
|
||||
// mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|
||||
// options: [ |
|
||||
// .allowBluetooth, // 允许蓝牙设备 |
|
||||
// .defaultToSpeaker, // 默认使用扬声器 |
|
||||
// .mixWithOthers // 允许与其他应用混音 |
|
||||
// ], |
|
||||
// force: force // 是否强制重新配置 |
|
||||
// ) |
|
||||
|
|
||||
// // 额外的优化配置 |
|
||||
// if configureAdditionalSettings { |
|
||||
// // 获取当前是否使用耳机 |
|
||||
// let currentRoute = audioSession.currentRoute |
|
||||
// let hasHeadphones = currentRoute.outputs.contains { |
|
||||
// $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|
||||
// } |
|
||||
|
|
||||
// // 设置采样率为16kHz(Azure Speech API推荐) |
|
||||
// try audioSession.setPreferredSampleRate(16000.0) |
|
||||
|
|
||||
// // 设置较小的缓冲区大小以减少延迟 |
|
||||
// try audioSession.setPreferredIOBufferDuration(0.01) |
|
||||
|
|
||||
// // 根据是否有耳机连接调整输入增益 |
|
||||
// if !hasHeadphones { |
|
||||
// // 无耳机时降低输入增益以减少回音 |
|
||||
// try audioSession.setInputGain(0.8) |
|
||||
// NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
|
||||
// } else { |
|
||||
// // 使用耳机时可以使用较高增益 |
|
||||
// try audioSession.setInputGain(1.0) |
|
||||
// NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
|
||||
// } |
|
||||
// } |
|
||||
|
|
||||
// NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
|
||||
// return true |
|
||||
// } catch { |
|
||||
// NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
|
||||
// return false |
|
||||
// } |
|
||||
// } |
|
||||
|
|
||||
|
|
||||
@objc func configureForVoiceInteraction(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
|
||||
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话") |
|
||||
do { |
|
||||
// 配置音频会话以优化语音识别 |
|
||||
try configureSession( |
|
||||
category: .playAndRecord, // 允许同时录音和播放 |
|
||||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|
||||
options: [ |
|
||||
.allowBluetooth, // 允许蓝牙设备 |
|
||||
.defaultToSpeaker, // 默认使用扬声器 |
|
||||
.mixWithOthers // 允许与其他应用混音 |
|
||||
], |
|
||||
force: force // 是否强制重新配置 |
|
||||
) |
|
||||
|
|
||||
try audioSession.overrideOutputAudioPort(.speaker) |
|
||||
|
|
||||
// 额外的优化配置 |
|
||||
if configureAdditionalSettings { |
|
||||
// 获取当前是否使用耳机 |
|
||||
let currentRoute = audioSession.currentRoute |
|
||||
let hasHeadphones = currentRoute.outputs.contains { |
|
||||
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|
||||
} |
|
||||
|
|
||||
// 设置采样率为16kHz(Azure Speech API推荐) |
|
||||
try audioSession.setPreferredSampleRate(16000.0) |
|
||||
|
|
||||
// 设置较小的缓冲区大小以减少延迟 |
|
||||
try audioSession.setPreferredIOBufferDuration(0.01) |
|
||||
|
|
||||
// 根据是否有耳机连接调整输入增益 |
|
||||
if !hasHeadphones { |
|
||||
// 无耳机时降低输入增益以减少回音 |
|
||||
try audioSession.setInputGain(0.8) |
|
||||
NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
|
||||
} else { |
|
||||
// 使用耳机时可以使用较高增益 |
|
||||
try audioSession.setInputGain(1.0) |
|
||||
NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
|
||||
return true |
|
||||
} catch { |
|
||||
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/// 配置用于语音交互(同时支持ASR和TTS)的音频会话 |
|
||||
/// 此函数综合优化语音识别和语音合成,适用于需要双向交互的场景 |
|
||||
/// - Returns: 配置是否成功 |
|
||||
// @objc func configureForVoiceInteraction(force: Bool = false) -> Bool { |
|
||||
// do { |
|
||||
// // 配置音频会话以支持语音交互 |
|
||||
// // 使用playAndRecord类别允许同时录音和播放 |
|
||||
// // 使用spokenAudio模式优化语音交互 |
|
||||
// try configureSession( |
|
||||
// category: .playAndRecord, |
|
||||
// mode: .voiceChat, |
|
||||
// options: [ |
|
||||
// // .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 |
|
||||
// .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 |
|
||||
// .defaultToSpeaker, // 默认使用扬声器输出 |
|
||||
// .mixWithOthers |
|
||||
// ], |
|
||||
// force: force // 强制重新配置,确保设置生效 |
|
||||
// ) |
|
||||
|
|
||||
// // 设置首选输入设备 |
|
||||
// try configurePreferredInput() |
|
||||
|
|
||||
// NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") |
|
||||
// return true |
|
||||
// } catch { |
|
||||
// NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") |
|
||||
// return false |
|
||||
// } |
|
||||
// } |
|
||||
|
|
||||
} |
|
||||
@ -1,813 +0,0 @@ |
|||||
import Foundation |
|
||||
import MicrosoftCognitiveServicesSpeech |
|
||||
import AVFoundation |
|
||||
import AudioToolbox |
|
||||
|
|
||||
/// Azure ASR工具类,负责实现语音识别服务接口 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AzureAsrHelper: NSObject { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
/// 是否使用自定义音频处理器 - 设置为false时将使用系统默认麦克风输入 |
|
||||
private let useCustomAudioProcessor = true |
|
||||
|
|
||||
/// 事件处理回调 |
|
||||
private var eventHandler: (([String: Any]) -> Void)? |
|
||||
|
|
||||
/// 语音配置信息 |
|
||||
private var speechSubscriptionKey: String = "" |
|
||||
private var serviceRegion: String = "" |
|
||||
|
|
||||
/// 语音识别相关 |
|
||||
private var speechConfig: SPXSpeechConfiguration? |
|
||||
private var recognizer: SPXSpeechRecognizer? |
|
||||
private var audioConfig: SPXAudioConfiguration? |
|
||||
|
|
||||
/// 添加自定义音频处理相关 |
|
||||
private var pushStream: SPXPushAudioInputStream? |
|
||||
private var audioProcessor: CustomAudioProcessor? |
|
||||
private var isProcessingAudio = false |
|
||||
private var audioProcessingTimer: Timer? |
|
||||
|
|
||||
/// 状态标志 |
|
||||
private var isInitialized = false |
|
||||
private var _isContinuousRecognitionActive = false |
|
||||
|
|
||||
/// 当前语言和支持的语言 |
|
||||
private var currentLanguage = "zh-CN" |
|
||||
private var supportedLanguages: [String] = ["zh-CN", "en-US"] |
|
||||
private var isAutoDetectLanguage = false |
|
||||
|
|
||||
/// 音频会话管理器 |
|
||||
private let audioSessionManager = AudioSessionManager.shared |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
|
|
||||
override init() { |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
dispose() |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// MARK: - ASR Service 接口实现 |
|
||||
|
|
||||
/// 初始化语音识别服务 |
|
||||
/// - Parameters: |
|
||||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|
||||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|
||||
/// - supportedLanguages: 支持的语言代码数组 (可选) |
|
||||
/// - Returns: 初始化是否成功 |
|
||||
func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { |
|
||||
print("[AzureAsrHelper] 初始化 Azure 语音服务") |
|
||||
|
|
||||
// 检查配置是否为空 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查语言参数 |
|
||||
if let languages = supportedLanguages, languages.isEmpty { |
|
||||
print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") |
|
||||
} |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
// 记录配置信息 |
|
||||
self.speechSubscriptionKey = speechSubscriptionKey |
|
||||
self.serviceRegion = serviceRegion |
|
||||
|
|
||||
// 设置语言 |
|
||||
if let languages = supportedLanguages, !languages.isEmpty { |
|
||||
self.supportedLanguages = languages |
|
||||
} |
|
||||
|
|
||||
// 根据支持的语言数量决定是否启用自动语言检测 |
|
||||
isAutoDetectLanguage = self.supportedLanguages.count >= 2 |
|
||||
|
|
||||
// 如果只有一种语言,设置为当前语言 |
|
||||
if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { |
|
||||
currentLanguage = self.supportedLanguages[0] |
|
||||
} |
|
||||
|
|
||||
// 创建识别器和设置回调 |
|
||||
if !createRecognizerAndSetupCallbacks() { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
|
||||
isInitialized = true |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 创建识别器并设置回调 |
|
||||
private func createRecognizerAndSetupCallbacks() -> Bool { |
|
||||
// 释放之前的 recognizer |
|
||||
recognizer = nil |
|
||||
audioConfig = nil |
|
||||
|
|
||||
do { |
|
||||
// 创建语音配置 |
|
||||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|
||||
|
|
||||
// 设置音频输入参数 |
|
||||
try setupAudioSession() |
|
||||
|
|
||||
// 根据设置决定是否使用自定义音频处理器 |
|
||||
if useCustomAudioProcessor { |
|
||||
// 创建自定义音频流和处理器,替代默认的麦克风输入 |
|
||||
pushStream = try SPXPushAudioInputStream() |
|
||||
audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) |
|
||||
|
|
||||
// 初始化自定义音频处理器 |
|
||||
audioProcessor = CustomAudioProcessor() |
|
||||
} else { |
|
||||
// 使用默认麦克风输入 |
|
||||
audioConfig = try SPXAudioConfiguration() |
|
||||
} |
|
||||
|
|
||||
// 设置语言配置 |
|
||||
if isAutoDetectLanguage { |
|
||||
// 设置自动语言检测 |
|
||||
speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) |
|
||||
|
|
||||
// 创建自动语言检测配置 |
|
||||
let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
|
||||
|
|
||||
// 创建识别器 |
|
||||
recognizer = try SPXSpeechRecognizer( |
|
||||
speechConfiguration: speechConfig!, |
|
||||
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, |
|
||||
audioConfiguration: audioConfig! |
|
||||
) |
|
||||
} else { |
|
||||
// 设置指定的识别语言 |
|
||||
speechConfig?.speechRecognitionLanguage = currentLanguage |
|
||||
|
|
||||
// 创建识别器 |
|
||||
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) |
|
||||
} |
|
||||
|
|
||||
// 设置所有回调 |
|
||||
setupAllCallbacks() |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") |
|
||||
eventHandler?(["type": "error", "message": "创建识别器失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音频会话 |
|
||||
private func setupAudioSession() throws { |
|
||||
print("[AzureAsrHelper] 开始配置音频会话...") |
|
||||
|
|
||||
// 使用AudioSessionManager配置音频会话,使用专门为Azure ASR优化的配置 |
|
||||
let success = audioSessionManager.configureForVoiceInteraction() |
|
||||
if !success { |
|
||||
print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/// 设置所有回调 |
|
||||
private func setupAllCallbacks() { |
|
||||
guard let recognizer = recognizer else { return } |
|
||||
|
|
||||
// 最终识别结果 |
|
||||
recognizer.addRecognizedEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
if event.result.reason == SPXResultReason.recognizedSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|
||||
print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|
||||
self.eventHandler?(["type": "result", |
|
||||
"text": event.result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 识别中事件 |
|
||||
recognizer.addRecognizingEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
if event.result.reason == SPXResultReason.recognizingSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|
||||
// print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|
||||
self.eventHandler?(["type": "recognizing", |
|
||||
"text": event.result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 会话事件 |
|
||||
recognizer.addSessionStartedEventHandler { [weak self] _, _ in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别会话已开始") |
|
||||
self._isContinuousRecognitionActive = true |
|
||||
self.eventHandler?(["type": "sessionStarted"]) |
|
||||
} |
|
||||
|
|
||||
recognizer.addSessionStoppedEventHandler { [weak self] _, _ in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别会话已结束") |
|
||||
self._isContinuousRecognitionActive = false |
|
||||
self.eventHandler?(["type": "sessionStopped"]) |
|
||||
} |
|
||||
|
|
||||
// 取消事件 |
|
||||
recognizer.addCanceledEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
let reason = event.reason.rawValue |
|
||||
let errorDetails = event.errorDetails ?? "未知错误" |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别取消: \(errorDetails)") |
|
||||
|
|
||||
self.eventHandler?(["type": "canceled", "reason": reason, "errorDetails": errorDetails]) |
|
||||
|
|
||||
self._isContinuousRecognitionActive = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 执行一次性语音识别 |
|
||||
/// - Returns: 是否成功启动识别 |
|
||||
func recognizeOnce() -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|
||||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查参数有效性 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 如果正在连续识别,先停止 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
stopContinuousRecognition() |
|
||||
} |
|
||||
|
|
||||
// 确保识别器已创建 |
|
||||
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 启动音频处理 |
|
||||
startAudioProcessing() |
|
||||
|
|
||||
// 通知会话开始 |
|
||||
eventHandler?(["type": "sessionStarted"]) |
|
||||
|
|
||||
// 执行识别 |
|
||||
try recognizer?.recognizeOnceAsync { [weak self] result in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
self.stopAudioProcessing() |
|
||||
|
|
||||
if result.reason == SPXResultReason.recognizedSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|
||||
self.eventHandler?(["type": "result", |
|
||||
"text": result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} else if result.reason == SPXResultReason.noMatch { |
|
||||
print("[AzureAsrHelper] 无匹配结果") |
|
||||
self.eventHandler?(["type": "noMatch"]) |
|
||||
} else if result.reason == SPXResultReason.canceled { |
|
||||
do { |
|
||||
let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) |
|
||||
let errorDetails = details.errorDetails ?? "未知错误" |
|
||||
self.eventHandler?(["type": "error", "message": "识别取消: \(errorDetails)"]) |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") |
|
||||
self.eventHandler?(["type": "error", "message": "识别取消,无法获取详细原因"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") |
|
||||
eventHandler?(["type": "error", "message": "识别异常: \(error.localizedDescription)"]) |
|
||||
stopAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始连续语音识别 |
|
||||
/// - Returns: 是否成功启动识别 |
|
||||
func startContinuousRecognition() -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|
||||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查参数有效性 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查是否已经在识别 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 确保识别器已创建 |
|
||||
if recognizer == nil { |
|
||||
print("[AzureAsrHelper] 尝试重新创建识别器...") |
|
||||
if !createRecognizerAndSetupCallbacks() { |
|
||||
eventHandler?(["type": "error", "message": "无法创建语音识别器"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动音频处理 |
|
||||
startAudioProcessing() |
|
||||
|
|
||||
// 尝试启动连续识别 |
|
||||
do { |
|
||||
// 启动连续识别 |
|
||||
try recognizer?.startContinuousRecognition() |
|
||||
_isContinuousRecognitionActive = true |
|
||||
|
|
||||
print("[AzureAsrHelper] 连续识别已启动") |
|
||||
return true |
|
||||
} catch { |
|
||||
_isContinuousRecognitionActive = false |
|
||||
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
stopAudioProcessing() |
|
||||
|
|
||||
// 发送错误通知 |
|
||||
eventHandler?(["type": "error", "message": "开始连续识别失败: \(error.localizedDescription)"]) |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/// 停止连续语音识别 |
|
||||
/// - Returns: 是否成功停止识别 |
|
||||
func stopContinuousRecognition() -> Bool { |
|
||||
// 停止音频处理 |
|
||||
stopAudioProcessing() |
|
||||
|
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|
||||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if !_isContinuousRecognitionActive { |
|
||||
print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
print("[AzureAsrHelper] 停止连续语音识别") |
|
||||
|
|
||||
// 防止空指针异常 |
|
||||
guard let recognizer = recognizer else { |
|
||||
print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") |
|
||||
_isContinuousRecognitionActive = false |
|
||||
eventHandler?(["type": "sessionStopped"]) |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 先标记为非活跃状态,防止重复调用 |
|
||||
_isContinuousRecognitionActive = false |
|
||||
|
|
||||
// 异步执行停止操作,避免阻塞主线程 |
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
do { |
|
||||
// 在后台线程执行停止操作 |
|
||||
try recognizer.stopContinuousRecognition() |
|
||||
|
|
||||
// 主线程回调通知结果 |
|
||||
DispatchQueue.main.async { |
|
||||
print("[AzureAsrHelper] 连续识别已停止") |
|
||||
self.eventHandler?(["type": "sessionStopped"]) |
|
||||
} |
|
||||
} catch { |
|
||||
// 主线程回调通知错误 |
|
||||
DispatchQueue.main.async { |
|
||||
print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") |
|
||||
// 停止失败,恢复状态 |
|
||||
self._isContinuousRecognitionActive = true |
|
||||
self.eventHandler?(["type": "error", "message": "停止识别失败: \(error.localizedDescription)"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 立即返回,不阻塞调用方 |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 检查连续识别是否活跃 |
|
||||
/// - Returns: 连续识别是否处于活跃状态 |
|
||||
func isContinuousRecognitionActive() -> Bool { |
|
||||
return _isContinuousRecognitionActive |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
func dispose() { |
|
||||
// 停止音频处理 |
|
||||
stopAudioProcessing() |
|
||||
|
|
||||
// 尝试停止所有识别操作 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
do { |
|
||||
if let recognizer = recognizer { |
|
||||
try recognizer.stopContinuousRecognition() |
|
||||
print("[AzureAsrHelper] 连续识别已停止") |
|
||||
} |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
_isContinuousRecognitionActive = false |
|
||||
} |
|
||||
|
|
||||
// 释放识别器 |
|
||||
recognizer = nil |
|
||||
audioConfig = nil |
|
||||
speechConfig = nil |
|
||||
pushStream = nil |
|
||||
audioProcessor = nil |
|
||||
|
|
||||
// 重置状态 |
|
||||
isInitialized = false |
|
||||
|
|
||||
print("[AzureAsrHelper] 资源已释放") |
|
||||
} |
|
||||
|
|
||||
/// 从结果中获取检测到的语言 |
|
||||
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
|
||||
if isAutoDetectLanguage { |
|
||||
do { |
|
||||
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
|
||||
return langResult.language ?? currentLanguage |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") |
|
||||
return currentLanguage |
|
||||
} |
|
||||
} else { |
|
||||
return currentLanguage |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置事件处理器 |
|
||||
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
|
||||
self.eventHandler = handler |
|
||||
} |
|
||||
|
|
||||
// MARK: - 音频处理 |
|
||||
|
|
||||
/// 开始音频处理 |
|
||||
private func startAudioProcessing() { |
|
||||
// 如果未使用自定义音频处理器,跳过 |
|
||||
if !useCustomAudioProcessor { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } |
|
||||
|
|
||||
isProcessingAudio = true |
|
||||
|
|
||||
// 启动音频处理器 |
|
||||
if !audioProcessor.startRecord() { |
|
||||
print("[AzureAsrHelper] 错误: 启动音频处理器失败") |
|
||||
eventHandler?(["type": "error", "message": "启动音频处理器失败"]) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 启动音频处理定时器 |
|
||||
audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in |
|
||||
guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 读取处理后的音频数据 |
|
||||
var bytes = [UInt8](repeating: 0, count: 2560) |
|
||||
let bytesRead = processor.read(bytes: &bytes) |
|
||||
|
|
||||
if bytesRead > 0 { |
|
||||
// 推送数据到Azure语音服务 |
|
||||
let data = Data(bytes: bytes, count: bytesRead) |
|
||||
stream.write(data) |
|
||||
|
|
||||
// 通知音频数据可用(可选) |
|
||||
// self.eventHandler?(["type": "audioData", "data": bytes]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
print("[AzureAsrHelper] 音频处理已启动") |
|
||||
} |
|
||||
|
|
||||
/// 停止音频处理 |
|
||||
private func stopAudioProcessing() { |
|
||||
// 如果未使用自定义音频处理器,跳过 |
|
||||
if !useCustomAudioProcessor { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 停止定时器 |
|
||||
audioProcessingTimer?.invalidate() |
|
||||
audioProcessingTimer = nil |
|
||||
|
|
||||
// 停止音频处理器 |
|
||||
audioProcessor?.stopRecord() |
|
||||
|
|
||||
isProcessingAudio = false |
|
||||
print("[AzureAsrHelper] 音频处理已停止") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 自定义音频处理器 |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
class CustomAudioProcessor: NSObject { |
|
||||
// 音频单元 |
|
||||
private var ioUnit: AudioUnit? |
|
||||
|
|
||||
// 音频格式 |
|
||||
private var audioFormat: AudioStreamBasicDescription |
|
||||
|
|
||||
// 音频缓冲 |
|
||||
private var audioBufferList: AudioBufferList |
|
||||
private var audioList: [Float] = [] |
|
||||
private let audioListQueue = DispatchQueue(label: "audioListQueue") |
|
||||
|
|
||||
// 回音消除状态 |
|
||||
private var isEchoCancellationEnabled = true |
|
||||
|
|
||||
override init() { |
|
||||
// 设置音频格式 - 16kHz, 16位, 单声道 |
|
||||
audioFormat = AudioStreamBasicDescription( |
|
||||
mSampleRate: 16000.0, |
|
||||
mFormatID: kAudioFormatLinearPCM, |
|
||||
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, |
|
||||
mBytesPerPacket: 2, |
|
||||
mFramesPerPacket: 1, |
|
||||
mBytesPerFrame: 2, |
|
||||
mChannelsPerFrame: 1, |
|
||||
mBitsPerChannel: 16, |
|
||||
mReserved: 0 |
|
||||
) |
|
||||
|
|
||||
// 初始化音频缓冲 |
|
||||
audioBufferList = AudioBufferList( |
|
||||
mNumberBuffers: 1, |
|
||||
mBuffers: AudioBuffer( |
|
||||
mNumberChannels: 1, |
|
||||
mDataByteSize: 4096, |
|
||||
mData: malloc(4096) |
|
||||
) |
|
||||
) |
|
||||
|
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
stopRecord() |
|
||||
free(audioBufferList.mBuffers.mData) |
|
||||
} |
|
||||
|
|
||||
/// 启动音频处理 |
|
||||
/// - Returns: 是否成功启动 |
|
||||
func startRecord() -> Bool { |
|
||||
print("[CustomAudioProcessor] 配置音频单元") |
|
||||
|
|
||||
// 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 |
|
||||
var ioUnitDescription = AudioComponentDescription( |
|
||||
componentType: kAudioUnitType_Output, |
|
||||
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
|
||||
componentManufacturer: kAudioUnitManufacturer_Apple, |
|
||||
componentFlags: 0, |
|
||||
componentFlagsMask: 0 |
|
||||
) |
|
||||
|
|
||||
// 查找音频组件 |
|
||||
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
|
||||
print("[CustomAudioProcessor] 错误: 未找到音频组件") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 创建音频单元实例 |
|
||||
if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { |
|
||||
ioUnit = nil |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 启用输入端口 |
|
||||
var enableInput: UInt32 = 1 |
|
||||
let kInputBus: AudioUnitElement = 1 |
|
||||
let kOutputBus: AudioUnitElement = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|
||||
kAudioUnitScope_Input, kInputBus, &enableInput, |
|
||||
UInt32(MemoryLayout<UInt32>.size)), "启用输入端口") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 禁用输出端口 (我们只需要输入) |
|
||||
var enableOutput: UInt32 = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|
||||
kAudioUnitScope_Output, kOutputBus, |
|
||||
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "禁用输出端口") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置缓冲区分配标志 |
|
||||
var flag: UInt32 = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
|
||||
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置缓冲区分配标志") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置音频格式 |
|
||||
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|
||||
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|
||||
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 启用回音消除 - 注意: kAUVoiceIOProperty_BypassVoiceProcessing值为1时表示绕过处理,值为0表示启用处理 |
|
||||
if isEchoCancellationEnabled { |
|
||||
var echoCancellation: UInt32 = 0 // 0表示不绕过,即启用回音消除 |
|
||||
AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, |
|
||||
kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout<UInt32>.size)) |
|
||||
} |
|
||||
|
|
||||
// 设置输入回调 - 当有新音频数据时调用 |
|
||||
var inputCallback = AURenderCallbackStruct( |
|
||||
inputProc: CustomAudioProcessor.onAudioDataAvailable, |
|
||||
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
|
||||
) |
|
||||
|
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, |
|
||||
kAudioOutputUnitProperty_SetInputCallback, |
|
||||
kAudioUnitScope_Global, kInputBus, |
|
||||
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 初始化音频单元 |
|
||||
var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|
||||
while hasError { |
|
||||
Thread.sleep(forTimeInterval: 0.1) |
|
||||
hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|
||||
} |
|
||||
|
|
||||
// 启动音频单元 |
|
||||
hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") |
|
||||
|
|
||||
print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") |
|
||||
return !hasError |
|
||||
} |
|
||||
|
|
||||
/// 停止音频处理 |
|
||||
func stopRecord() { |
|
||||
print("[CustomAudioProcessor] 停止音频处理器") |
|
||||
|
|
||||
if let ioUnit = ioUnit { |
|
||||
// 停止音频单元 |
|
||||
_ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") |
|
||||
|
|
||||
// 关闭音频单元 |
|
||||
_ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") |
|
||||
_ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") |
|
||||
|
|
||||
self.ioUnit = nil |
|
||||
} |
|
||||
|
|
||||
// 清空音频数据缓冲 |
|
||||
audioListQueue.sync { |
|
||||
audioList.removeAll() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 音频数据回调 - 当有新的音频数据可用时调用 |
|
||||
private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
|
||||
// 获取实例 |
|
||||
let processor = Unmanaged<CustomAudioProcessor>.fromOpaque(inRefCon).takeUnretainedValue() |
|
||||
|
|
||||
// 计算预期数据大小 |
|
||||
let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame |
|
||||
|
|
||||
// 确保缓冲区足够大 |
|
||||
if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
|
||||
processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
|
||||
processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
|
||||
} |
|
||||
|
|
||||
// 渲染音频数据 |
|
||||
let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, |
|
||||
inBusNumber, inNumberFrames, &processor.audioBufferList), |
|
||||
"渲染音频数据") |
|
||||
|
|
||||
// 将Int16数据转换为浮点数据进行处理 |
|
||||
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
|
||||
let buffer = processor.audioBufferList.mBuffers |
|
||||
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
|
||||
|
|
||||
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
|
||||
// 归一化到[-1.0, 1.0]范围 |
|
||||
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 |
|
||||
} |
|
||||
|
|
||||
// 保存处理后的数据 |
|
||||
if status == noErr { |
|
||||
processor.audioListQueue.async { |
|
||||
processor.audioList.append(contentsOf: audioDataFloat) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return status |
|
||||
} |
|
||||
|
|
||||
/// 读取处理后的音频数据 |
|
||||
/// - Parameter bytes: 输出字节数组 |
|
||||
/// - Returns: 读取的字节数 |
|
||||
func read(bytes: inout [UInt8]) -> Int { |
|
||||
return audioListQueue.sync { |
|
||||
// 如果没有数据,返回0 |
|
||||
if audioList.isEmpty { |
|
||||
return 0 |
|
||||
} |
|
||||
|
|
||||
// 确保有足够的数据 (至少1280个样本) |
|
||||
if audioList.count < 1280 { |
|
||||
return 0 |
|
||||
} |
|
||||
|
|
||||
// 读取一帧数据 (1280个样本) |
|
||||
let frameLength = 1280 |
|
||||
let buffer = Array(audioList.prefix(frameLength)) |
|
||||
audioList.removeFirst(frameLength) |
|
||||
|
|
||||
// 将浮点数据转回Int16格式 |
|
||||
var int16Data = buffer.map { Int16($0 * 32767) } |
|
||||
|
|
||||
// 转换为字节数组 |
|
||||
let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) |
|
||||
bytes = [UInt8](data) |
|
||||
|
|
||||
// 每个样本2字节 (16位PCM) |
|
||||
return frameLength * 2 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查错误并打印日志 |
|
||||
/// - Parameters: |
|
||||
/// - status: 操作状态 |
|
||||
/// - operation: 操作描述 |
|
||||
/// - Returns: 是否发生错误 |
|
||||
private func checkError(_ status: OSStatus, _ operation: String) -> Bool { |
|
||||
if status != noErr { |
|
||||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|
||||
return true |
|
||||
} |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
/// 检查OSStatus并返回状态 |
|
||||
/// - Parameters: |
|
||||
/// - status: 操作状态 |
|
||||
/// - operation: 操作描述 |
|
||||
/// - Returns: 原始状态 |
|
||||
private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
|
||||
if status != noErr { |
|
||||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|
||||
} |
|
||||
return status |
|
||||
} |
|
||||
} |
|
||||
@ -1,454 +0,0 @@ |
|||||
import Foundation |
|
||||
import MicrosoftCognitiveServicesSpeech |
|
||||
import AVFoundation |
|
||||
|
|
||||
/// Azure TTS工具类,负责实现TTS服务接口 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AzureTtsHelper: NSObject { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
/// 事件处理回调 |
|
||||
private var eventHandler: (([String: Any]) -> Void)? |
|
||||
|
|
||||
/// 语音配置信息 |
|
||||
private var speechSubscriptionKey: String = "" |
|
||||
private var serviceRegion: String = "" |
|
||||
|
|
||||
/// 语音合成配置 |
|
||||
private var speechConfig: SPXSpeechConfiguration? |
|
||||
|
|
||||
/// 语音合成器 |
|
||||
private var synthesizer: SPXSpeechSynthesizer? |
|
||||
|
|
||||
/// 是否初始化成功 |
|
||||
private var isInitialized = false |
|
||||
|
|
||||
/// 当前是否正在播放 |
|
||||
private var _isSpeaking = false |
|
||||
|
|
||||
/// 音频会话配置 |
|
||||
private var isAudioSessionConfigured = false |
|
||||
|
|
||||
/// 音频会话管理器 |
|
||||
private let audioSessionManager = AudioSessionManager.shared |
|
||||
|
|
||||
// MARK: - 语音设置 |
|
||||
|
|
||||
/// 当前语音 |
|
||||
private var currentVoice = "zh-CN-XiaoxiaoNeural" |
|
||||
|
|
||||
/// 支持的语音映射 |
|
||||
private var voiceMap: [String: String] = [ |
|
||||
"zh-CN": "zh-CN-XiaoxiaoNeural", |
|
||||
"en-US": "en-US-JennyNeural", |
|
||||
"ja-JP": "ja-JP-NanamiNeural", |
|
||||
"ko-KR": "ko-KR-SunHiNeural", |
|
||||
"zh-TW": "zh-TW-HsiaoChenNeural", |
|
||||
"zh-HK": "zh-HK-HiuMaanNeural" |
|
||||
] |
|
||||
|
|
||||
/// 当前语音合成参数 |
|
||||
private var currentSpeechRate = "0%" |
|
||||
private var currentPitch = "0%" |
|
||||
private var currentVolume = "100%" |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
|
|
||||
override init() { |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
dispose() |
|
||||
} |
|
||||
|
|
||||
/// 设置事件处理器 |
|
||||
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
|
||||
self.eventHandler = handler |
|
||||
} |
|
||||
|
|
||||
// MARK: - TTS 接口实现 |
|
||||
|
|
||||
/// 初始化语音合成服务 |
|
||||
/// - Parameters: |
|
||||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|
||||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|
||||
/// - language: 语言代码 (默认 zh-CN) |
|
||||
/// - Returns: 初始化是否成功 |
|
||||
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { |
|
||||
print("[AzureTtsHelper] 初始化语音合成服务") |
|
||||
|
|
||||
// 检查配置是否为空 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
// 记录配置信息 |
|
||||
self.speechSubscriptionKey = speechSubscriptionKey |
|
||||
self.serviceRegion = serviceRegion |
|
||||
|
|
||||
// 配置音频会话 |
|
||||
if !configureAudioSession() { |
|
||||
print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 创建语音配置 |
|
||||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|
||||
|
|
||||
// 设置默认语音 |
|
||||
let defaultVoice = getDefaultVoiceForLanguage(language) |
|
||||
currentVoice = defaultVoice |
|
||||
speechConfig?.speechSynthesisVoiceName = defaultVoice |
|
||||
|
|
||||
// 创建语音合成器 |
|
||||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
setupSynthesizerEvents() |
|
||||
|
|
||||
isInitialized = true |
|
||||
print("[AzureTtsHelper] TTS 引擎初始化成功") |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") |
|
||||
eventHandler?(["type": "error", "message": "初始化语音合成服务失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 配置音频会话 |
|
||||
private func configureAudioSession() -> Bool { |
|
||||
do { |
|
||||
// 使用音频会话管理器配置媒体播放模式 |
|
||||
try audioSessionManager.configureForVoiceInteraction() |
|
||||
|
|
||||
isAudioSessionConfigured = true |
|
||||
print("[AzureTtsHelper] 音频会话配置成功") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") |
|
||||
isAudioSessionConfigured = false |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") |
|
||||
/// - Returns: 设置是否成功 |
|
||||
func setVoice(voiceName: String) -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查参数有效性 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if voiceName.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: 声音名称为空") |
|
||||
eventHandler?(["type": "error", "message": "声音名称不能为空"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if voiceName == currentVoice { |
|
||||
print("[AzureTtsHelper] 已设置语音: \(voiceName)") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
print("[AzureTtsHelper] 设置声音: \(voiceName)") |
|
||||
currentVoice = voiceName |
|
||||
|
|
||||
// 更新语音配置 |
|
||||
if let speechConfig = speechConfig { |
|
||||
speechConfig.speechSynthesisVoiceName = voiceName |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
/// 设置语音合成参数 |
|
||||
/// - Parameters: |
|
||||
/// - rate: 语速,范围 -100 到 100,默认为 0 |
|
||||
/// - pitch: 音调,范围 -100 到 100,默认为 0 |
|
||||
/// - volume: 音量,范围 0 到 100,默认为 100 |
|
||||
/// - Returns: 是否设置成功 |
|
||||
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查参数有效性 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 转换参数格式 |
|
||||
currentSpeechRate = formatRateParam(rate) |
|
||||
currentPitch = formatPitchParam(pitch) |
|
||||
currentVolume = formatVolumeParam(volume) |
|
||||
|
|
||||
print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 合成文本为语音并播放 |
|
||||
/// - Parameter text: 要合成的文本 |
|
||||
/// - Returns: 操作是否成功启动 |
|
||||
func speakText(text: String) -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 检查参数有效性 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if text.isEmpty { |
|
||||
print("[AzureTtsHelper] 警告: 要播放的文本为空") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 确保音频会话已配置 |
|
||||
if !isAudioSessionConfigured { |
|
||||
_ = configureAudioSession() |
|
||||
} |
|
||||
|
|
||||
print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") |
|
||||
|
|
||||
// 生成SSML |
|
||||
let ssml = generateSsml(text: text) |
|
||||
|
|
||||
// 直接进行SSML合成 |
|
||||
return speakSsmlInternal(text: ssml) |
|
||||
} |
|
||||
|
|
||||
/// 内部SSML合成和播放 |
|
||||
private func speakSsmlInternal(text: String) -> Bool { |
|
||||
guard let synthesizer = synthesizer else { |
|
||||
print("[AzureTtsHelper] 错误: 合成器未初始化") |
|
||||
eventHandler?(["type": "error", "message": "合成器未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
_isSpeaking = true |
|
||||
eventHandler?(["type": "started"]) |
|
||||
|
|
||||
Task { |
|
||||
do { |
|
||||
// 使用异步方法进行合成并直接播放 |
|
||||
_ = try synthesizer.startSpeakingSsml(text) |
|
||||
|
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") |
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler?(["type": "error", "message": "语音合成失败: \(error.localizedDescription)"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 停止当前语音合成 |
|
||||
/// - Returns: 操作是否成功 |
|
||||
func stopSpeaking() -> Bool { |
|
||||
// 检查是否初始化 |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if !_isSpeaking { |
|
||||
print("[AzureTtsHelper] 未进行语音播放,忽略停止请求") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 停止合成 |
|
||||
do { |
|
||||
try synthesizer?.stopSpeaking() |
|
||||
_isSpeaking = false |
|
||||
eventHandler?(["type": "canceled"]) |
|
||||
print("[AzureTtsHelper] 已停止语音合成") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") |
|
||||
eventHandler?(["type": "error", "message": "停止语音合成失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查是否正在播放 |
|
||||
/// - Returns: 当前是否正在播放语音 |
|
||||
func isSpeaking() -> Bool { |
|
||||
return _isSpeaking |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
func dispose() { |
|
||||
// 尝试停止所有语音合成 |
|
||||
if _isSpeaking { |
|
||||
do { |
|
||||
if let synthesizer = synthesizer { |
|
||||
try synthesizer.stopSpeaking() |
|
||||
print("[AzureTtsHelper] 语音合成已停止") |
|
||||
} |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 警告: 停止语音合成失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
_isSpeaking = false |
|
||||
} |
|
||||
|
|
||||
// 释放合成器 |
|
||||
synthesizer = nil |
|
||||
speechConfig = nil |
|
||||
|
|
||||
// 重置状态 |
|
||||
isInitialized = false |
|
||||
isAudioSessionConfigured = false |
|
||||
|
|
||||
print("[AzureTtsHelper] 资源已释放") |
|
||||
} |
|
||||
|
|
||||
// MARK: - 私有辅助方法 |
|
||||
|
|
||||
/// 设置合成器事件处理 |
|
||||
private func setupSynthesizerEvents() { |
|
||||
guard let synthesizer = synthesizer else { return } |
|
||||
|
|
||||
// 添加书签到达事件处理 |
|
||||
synthesizer.addBookmarkReachedEventHandler { _, e in |
|
||||
print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") |
|
||||
} |
|
||||
|
|
||||
// 合成完成事件 |
|
||||
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in |
|
||||
guard let self = self else { return } |
|
||||
print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") |
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler?(["type": "completed"]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 合成取消事件 |
|
||||
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
let result = e.result |
|
||||
do { |
|
||||
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) |
|
||||
print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") |
|
||||
|
|
||||
if cancellationDetails.reason == SPXCancellationReason.error { |
|
||||
print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") |
|
||||
print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") |
|
||||
} |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler?(["type": "error", "message": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) |
|
||||
} |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 获取取消详情时出错: \(error)") |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler?(["type": "error", "message": "语音合成被取消"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 合成开始事件 |
|
||||
synthesizer.addSynthesisStartedEventHandler { _, _ in |
|
||||
// print("[AzureTtsHelper] 语音合成开始") |
|
||||
} |
|
||||
|
|
||||
// 合成中事件 |
|
||||
synthesizer.addSynthesizingEventHandler { _, _ in |
|
||||
// print("[AzureTtsHelper] 语音合成中") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 生成 SSML 文本 |
|
||||
private func generateSsml(text: String) -> String { |
|
||||
return """ |
|
||||
<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'> |
|
||||
<voice name='\(currentVoice)'> |
|
||||
<prosody rate='\(currentSpeechRate)' pitch='\(currentPitch)' volume='\(currentVolume)'> |
|
||||
\(text) |
|
||||
</prosody> |
|
||||
</voice> |
|
||||
</speak> |
|
||||
""" |
|
||||
} |
|
||||
|
|
||||
/// 格式化语速参数 |
|
||||
private func formatRateParam(_ rate: Int) -> String { |
|
||||
let clampedRate = rate.clamp(min: -100, max: 100) |
|
||||
if clampedRate == 0 { |
|
||||
return "0%" |
|
||||
} else if clampedRate < 0 { |
|
||||
return "\(Int(Double(clampedRate) * 0.9))%" |
|
||||
} else { |
|
||||
return "+\(clampedRate)%" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 格式化音调参数 |
|
||||
private func formatPitchParam(_ pitch: Int) -> String { |
|
||||
let clampedPitch = pitch.clamp(min: -100, max: 100) |
|
||||
if clampedPitch == 0 { |
|
||||
return "0%" |
|
||||
} else { |
|
||||
return "\(Int(Double(clampedPitch) * 0.5))%" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 格式化音量参数 |
|
||||
private func formatVolumeParam(_ volume: Int) -> String { |
|
||||
let clampedVolume = volume.clamp(min: 0, max: 100) |
|
||||
return "\(clampedVolume)%" |
|
||||
} |
|
||||
|
|
||||
/// 获取指定语言的默认语音 |
|
||||
private func getDefaultVoiceForLanguage(_ language: String) -> String { |
|
||||
return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 扩展 |
|
||||
|
|
||||
extension Int { |
|
||||
func clamp(min: Int, max: Int) -> Int { |
|
||||
if self < min { return min } |
|
||||
if self > max { return max } |
|
||||
return self |
|
||||
} |
|
||||
} |
|
||||
@ -1,464 +0,0 @@ |
|||||
import Foundation |
|
||||
import MediaPlayer |
|
||||
import AVFoundation |
|
||||
import UIKit |
|
||||
|
|
||||
/// 媒体按钮事件处理协议 |
|
||||
@available(iOS 13.0, *) |
|
||||
@objc protocol MediaButtonEventHandler: AnyObject { |
|
||||
func sendEvent(_ event: [String: Any]) |
|
||||
} |
|
||||
|
|
||||
/// 蓝牙媒体按键事件处理类 |
|
||||
/// 该类仅在iOS 13.0及以上版本可用 |
|
||||
@available(iOS 13.0, *) |
|
||||
class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
@objc static let shared = BluetoothMediaButtonHelper() |
|
||||
|
|
||||
/// 日志标签 |
|
||||
private let TAG = "BluetoothMediaButtonHelper" |
|
||||
|
|
||||
/// 远程控制中心 |
|
||||
private var remoteCommandCenter: MPRemoteCommandCenter |
|
||||
|
|
||||
/// 事件处理器 - 用于发送事件到Flutter |
|
||||
private weak var eventHandler: MediaButtonEventHandler? |
|
||||
|
|
||||
/// 是否已启用监听 |
|
||||
private var isListening = false |
|
||||
|
|
||||
/// 静音音频播放器 - 用于保持音频会话活跃 |
|
||||
private var silentAudioPlayer: AVAudioPlayer? |
|
||||
|
|
||||
/// 播放计时器 - 用于定期更新播放信息 |
|
||||
private var playbackTimer: Timer? |
|
||||
|
|
||||
/// 当前播放时间 - 模拟播放进度 |
|
||||
private var currentPlaybackTime: TimeInterval = 0 |
|
||||
|
|
||||
/// 总时长 - 模拟 |
|
||||
private let totalDuration: TimeInterval = 300.0 |
|
||||
|
|
||||
/// 音频会话管理器 |
|
||||
private let audioSessionManager = AudioSessionManager.shared |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
|
|
||||
override init() { |
|
||||
// 获取远程控制中心单例 |
|
||||
self.remoteCommandCenter = MPRemoteCommandCenter.shared() |
|
||||
|
|
||||
super.init() |
|
||||
|
|
||||
NSLog("\(TAG): 初始化媒体按钮处理器") |
|
||||
|
|
||||
// 初始化静音音频播放器 |
|
||||
initSilentAudioPlayer() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
stopButtonListening() |
|
||||
NSLog("\(TAG): 媒体按钮处理器已释放") |
|
||||
} |
|
||||
|
|
||||
// MARK: - 公共方法 |
|
||||
|
|
||||
/// 设置事件处理器 |
|
||||
@objc func setEventHandler(_ handler: MediaButtonEventHandler) { |
|
||||
eventHandler = handler |
|
||||
NSLog("\(TAG): 已设置媒体按钮事件处理器") |
|
||||
} |
|
||||
|
|
||||
/// 开始监听蓝牙媒体按钮事件 |
|
||||
@objc func startButtonListening() -> Bool { |
|
||||
if isListening { |
|
||||
NSLog("\(TAG): 已经在监听蓝牙媒体按钮事件") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 开始监听蓝牙媒体按钮事件") |
|
||||
|
|
||||
// 立即注册远程命令 - 这不需要音频会话 |
|
||||
registerRemoteCommands() |
|
||||
|
|
||||
// 标记为已在监听 |
|
||||
isListening = true |
|
||||
|
|
||||
// 确保NowPlaying信息可用 - 这也不依赖于活跃的音频会话 |
|
||||
setupNowPlaying(isPlaying: true) |
|
||||
|
|
||||
// 尝试启用静音音频播放(但即使失败,远程命令仍然可用) |
|
||||
do { |
|
||||
// try audioSessionManager.configureForBluetoothMediaButtons() |
|
||||
startSilentAudio() |
|
||||
|
|
||||
// 延时1秒 |
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { |
|
||||
self.setupNowPlaying(isPlaying: false) |
|
||||
self.stopSilentAudio() |
|
||||
} |
|
||||
|
|
||||
// startUpdatingPlaybackInfo() |
|
||||
} catch { |
|
||||
NSLog("\(TAG): 音频设置或播放失败: \(error.localizedDescription)") |
|
||||
// 继续执行,因为远程命令在某些设备上不需要播放音频也能工作 |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 蓝牙媒体按钮监听已启动") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 停止监听蓝牙媒体按钮事件 |
|
||||
@objc func stopButtonListening() -> Bool { |
|
||||
if !isListening { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 停止监听蓝牙媒体按钮事件") |
|
||||
|
|
||||
// 停止播放静音音频 |
|
||||
stopSilentAudio() |
|
||||
|
|
||||
// 停止更新播放信息 |
|
||||
stopUpdatingPlaybackInfo() |
|
||||
|
|
||||
// 取消注册远程命令 |
|
||||
unregisterRemoteCommands() |
|
||||
|
|
||||
// 清除NowPlaying信息 |
|
||||
DispatchQueue.main.async { |
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
|
||||
} |
|
||||
|
|
||||
isListening = false |
|
||||
NSLog("\(TAG): 蓝牙媒体按钮监听已停止") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// MARK: - 私有方法 |
|
||||
|
|
||||
/// 初始化静音音频播放器 |
|
||||
private func initSilentAudioPlayer() { |
|
||||
do { |
|
||||
// 尝试加载sample.mp3文件 |
|
||||
if let audioPath = Bundle.main.path(forResource: "sample", ofType: "mp3") { |
|
||||
let audioUrl = URL(fileURLWithPath: audioPath) |
|
||||
|
|
||||
silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) |
|
||||
silentAudioPlayer?.numberOfLoops = -1 // 无限循环播放 |
|
||||
silentAudioPlayer?.volume = 0.0 // 设置为全音量 |
|
||||
silentAudioPlayer?.prepareToPlay() // 预加载 |
|
||||
silentAudioPlayer?.delegate = self |
|
||||
|
|
||||
NSLog("\(TAG): 音频播放器使用sample.mp3初始化成功") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 未找到sample.mp3文件,使用生成的静音数据") |
|
||||
|
|
||||
} catch { |
|
||||
NSLog("\(TAG): 初始化静音音频播放器失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始播放静音音频 |
|
||||
private func startSilentAudio() { |
|
||||
guard let player = silentAudioPlayer else { |
|
||||
NSLog("\(TAG): 无法开始播放,播放器未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if !player.isPlaying { |
|
||||
let success = player.play() |
|
||||
if success { |
|
||||
NSLog("\(TAG): 开始播放静音音频") |
|
||||
} else { |
|
||||
NSLog("\(TAG): 播放启动失败") |
|
||||
} |
|
||||
} else { |
|
||||
NSLog("\(TAG): 静音音频已在播放中") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放静音音频 |
|
||||
private func stopSilentAudio() { |
|
||||
silentAudioPlayer?.stop() |
|
||||
NSLog("\(TAG): 停止播放静音音频") |
|
||||
} |
|
||||
|
|
||||
/// 开始更新播放信息 |
|
||||
private func startUpdatingPlaybackInfo() { |
|
||||
// 初始设置 |
|
||||
setupNowPlaying(isPlaying: true) |
|
||||
|
|
||||
// 定期更新播放信息 - 恢复定时器,确保持续更新播放状态 |
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
// 更新播放时间(模拟) |
|
||||
self.currentPlaybackTime += 1.0 |
|
||||
if self.currentPlaybackTime >= self.totalDuration { |
|
||||
self.currentPlaybackTime = 0.0 |
|
||||
} |
|
||||
|
|
||||
// 更新NowPlaying信息 |
|
||||
self.setupNowPlaying(isPlaying: true) |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 开始更新播放信息") |
|
||||
} |
|
||||
|
|
||||
/// 停止更新播放信息 |
|
||||
private func stopUpdatingPlaybackInfo() { |
|
||||
playbackTimer?.invalidate() |
|
||||
playbackTimer = nil |
|
||||
|
|
||||
// 清空NowPlaying信息 |
|
||||
DispatchQueue.main.async { |
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
|
||||
} |
|
||||
|
|
||||
NSLog("\(TAG): 停止更新播放信息") |
|
||||
} |
|
||||
|
|
||||
/// 设置NowPlaying信息 |
|
||||
private func setupNowPlaying(isPlaying: Bool) { |
|
||||
NSLog("\(TAG): 设置NowPlaying信息, isPlaying=\(isPlaying)") |
|
||||
|
|
||||
// 创建音频专辑图片 |
|
||||
let albumArt = UIImage(named: "AppIcon") // 使用应用图标作为专辑图片 |
|
||||
var nowPlayingInfo: [String: Any] = [ |
|
||||
MPMediaItemPropertyTitle: "DeepSound 语音助手", |
|
||||
MPMediaItemPropertyArtist: "DeepSound", |
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0, // 1.0表示正在播放 |
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentPlaybackTime, |
|
||||
MPMediaItemPropertyPlaybackDuration: totalDuration |
|
||||
] |
|
||||
|
|
||||
// 如果有专辑图片,添加到NowPlaying信息中 |
|
||||
if let albumArt = albumArt { |
|
||||
let artwork = MPMediaItemArtwork(boundsSize: albumArt.size) { size in |
|
||||
return albumArt |
|
||||
} |
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork |
|
||||
} |
|
||||
|
|
||||
// 设置NowPlaying信息 (在主线程执行) |
|
||||
DispatchQueue.main.async { |
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo |
|
||||
NSLog("\(self.TAG): NowPlaying信息设置完成") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 注册远程命令 |
|
||||
private func registerRemoteCommands() { |
|
||||
let commandCenter = MPRemoteCommandCenter.shared() |
|
||||
|
|
||||
// 播放/暂停命令 |
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = true |
|
||||
commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|
||||
commandCenter.playCommand.isEnabled = true |
|
||||
commandCenter.playCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|
||||
commandCenter.pauseCommand.isEnabled = true |
|
||||
commandCenter.pauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|
||||
|
|
||||
// 下一曲/上一曲命令 |
|
||||
commandCenter.nextTrackCommand.isEnabled = true |
|
||||
commandCenter.nextTrackCommand.addTarget(self, action: #selector(handleNextTrackCommand(_:))) |
|
||||
commandCenter.previousTrackCommand.isEnabled = true |
|
||||
commandCenter.previousTrackCommand.addTarget(self, action: #selector(handlePreviousTrackCommand(_:))) |
|
||||
|
|
||||
// 快进/快退命令 |
|
||||
commandCenter.seekForwardCommand.isEnabled = true |
|
||||
commandCenter.seekForwardCommand.addTarget(self, action: #selector(handleSeekForwardCommand(_:))) |
|
||||
commandCenter.seekBackwardCommand.isEnabled = true |
|
||||
commandCenter.seekBackwardCommand.addTarget(self, action: #selector(handleSeekBackwardCommand(_:))) |
|
||||
|
|
||||
// 停止命令 |
|
||||
commandCenter.stopCommand.isEnabled = true |
|
||||
commandCenter.stopCommand.addTarget(self, action: #selector(handleStopCommand(_:))) |
|
||||
|
|
||||
|
|
||||
|
|
||||
NSLog("\(TAG): 远程命令已注册") |
|
||||
} |
|
||||
|
|
||||
/// 取消注册远程命令 |
|
||||
private func unregisterRemoteCommands() { |
|
||||
NSLog("\(TAG): 取消注册远程命令") |
|
||||
|
|
||||
// 基本控制命令 |
|
||||
remoteCommandCenter.playCommand.isEnabled = false |
|
||||
remoteCommandCenter.playCommand.removeTarget(nil) |
|
||||
|
|
||||
remoteCommandCenter.pauseCommand.isEnabled = false |
|
||||
remoteCommandCenter.pauseCommand.removeTarget(nil) |
|
||||
|
|
||||
remoteCommandCenter.togglePlayPauseCommand.isEnabled = false |
|
||||
remoteCommandCenter.togglePlayPauseCommand.removeTarget(nil) |
|
||||
|
|
||||
remoteCommandCenter.stopCommand.isEnabled = false |
|
||||
remoteCommandCenter.stopCommand.removeTarget(nil) |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
/// 发送按钮事件到Flutter |
|
||||
private func sendButtonEvent(type: String) { |
|
||||
// 创建事件数据 |
|
||||
let eventData: [String: Any] = [ |
|
||||
"type": "mediaButtonEvent", |
|
||||
"buttonType": type, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
] |
|
||||
|
|
||||
// 发送事件到处理器 |
|
||||
eventHandler?.sendEvent(eventData) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// MARK: - 命令处理方法 |
|
||||
|
|
||||
/// 处理播放/暂停命令 |
|
||||
@objc private func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到播放/暂停命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "play_pause") |
|
||||
|
|
||||
// 调用VoiceInteractionService处理媒体按钮事件 |
|
||||
if VoiceInteractionService.shared.isServiceRunning() { |
|
||||
NSLog("\(TAG): 转发到VoiceInteractionService处理") |
|
||||
VoiceInteractionService.shared.handleMediaButtonAction() |
|
||||
} |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
/// 处理下一曲命令 |
|
||||
@objc private func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到下一曲命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "next_track") |
|
||||
|
|
||||
// 也可以转发到VoiceInteractionService |
|
||||
if VoiceInteractionService.shared.isServiceRunning() { |
|
||||
VoiceInteractionService.shared.handleMediaButtonAction() |
|
||||
} |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
/// 处理上一曲命令 |
|
||||
@objc private func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到上一曲命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "previous_track") |
|
||||
|
|
||||
// 也可以转发到VoiceInteractionService |
|
||||
if VoiceInteractionService.shared.isServiceRunning() { |
|
||||
VoiceInteractionService.shared.handleMediaButtonAction() |
|
||||
} |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
/// 处理快进命令 |
|
||||
@objc private func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到快进命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "seek_forward") |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
/// 处理快退命令 |
|
||||
@objc private func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到快退命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "seek_backward") |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
/// 处理停止命令 |
|
||||
@objc private func handleStopCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|
||||
NSLog("\(TAG): 收到停止命令") |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
sendButtonEvent(type: "stop") |
|
||||
|
|
||||
// 调用VoiceInteractionService处理媒体按钮事件 |
|
||||
if VoiceInteractionService.shared.isServiceRunning() { |
|
||||
VoiceInteractionService.shared.handleMediaButtonAction() |
|
||||
} |
|
||||
|
|
||||
return .success |
|
||||
} |
|
||||
|
|
||||
// MARK: - AVAudioPlayerDelegate方法 |
|
||||
|
|
||||
/// 音频播放结束回调 |
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { |
|
||||
NSLog("\(TAG): 音频播放结束, 成功: \(flag)") |
|
||||
|
|
||||
// 如果仍在监听状态,重新开始播放 |
|
||||
if isListening && player == silentAudioPlayer { |
|
||||
player.play() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 音频播放错误回调 |
|
||||
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { |
|
||||
if let error = error { |
|
||||
NSLog("\(TAG): 音频解码错误: \(error.localizedDescription)") |
|
||||
} else { |
|
||||
NSLog("\(TAG): 音频解码发生未知错误") |
|
||||
} |
|
||||
|
|
||||
// 重新初始化播放器 |
|
||||
if player == silentAudioPlayer && isListening { |
|
||||
initSilentAudioPlayer() |
|
||||
startSilentAudio() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清理和释放资源 |
|
||||
func dispose() { |
|
||||
NSLog("\(TAG): 释放BluetoothMediaButtonHelper资源") |
|
||||
// 停止监听 |
|
||||
stopButtonListening() |
|
||||
|
|
||||
// 释放播放器和计时器资源 |
|
||||
silentAudioPlayer = nil |
|
||||
if let timer = playbackTimer { |
|
||||
timer.invalidate() |
|
||||
playbackTimer = nil |
|
||||
} |
|
||||
|
|
||||
// 清除引用 |
|
||||
eventHandler = nil |
|
||||
|
|
||||
NSLog("\(TAG): 资源已释放") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
extension UInt32 { |
|
||||
var data: Data { |
|
||||
var int = self |
|
||||
return Data(bytes: &int, count: MemoryLayout<UInt32>.size) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
extension UInt16 { |
|
||||
var data: Data { |
|
||||
var int = self |
|
||||
return Data(bytes: &int, count: MemoryLayout<UInt16>.size) |
|
||||
} |
|
||||
} |
|
||||
@ -1,243 +0,0 @@ |
|||||
import Foundation |
|
||||
import CoreBluetooth |
|
||||
import AVFoundation |
|
||||
import UIKit |
|
||||
|
|
||||
/// 蓝牙事件处理协议 |
|
||||
@objc protocol BluetoothEventHandler: AnyObject { |
|
||||
func sendEvent(_ event: [String: Any]) |
|
||||
} |
|
||||
|
|
||||
@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
// 音频会话管理器 |
|
||||
private let audioSessionManager = AudioSessionManager.shared |
|
||||
|
|
||||
// 蓝牙管理器 |
|
||||
private var centralManager: CBCentralManager? |
|
||||
|
|
||||
// 事件处理器 |
|
||||
private weak var eventHandler: BluetoothEventHandler? |
|
||||
|
|
||||
// MARK: - 初始化与设置 |
|
||||
|
|
||||
override init() { |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
@objc func setEventHandler(_ handler: BluetoothEventHandler) { |
|
||||
eventHandler = handler |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// MARK: - CBCentralManagerDelegate |
|
||||
|
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|
||||
let stateString = getBluetoothStateString(state: central.state) |
|
||||
sendBluetoothStateEvent(state: stateString) |
|
||||
} |
|
||||
|
|
||||
// MARK: - 设备监控 |
|
||||
|
|
||||
// 获取蓝牙状态字符串 |
|
||||
private func getBluetoothStateString(state: CBManagerState) -> String { |
|
||||
switch state { |
|
||||
case .poweredOn: |
|
||||
return "on" |
|
||||
case .poweredOff: |
|
||||
return "off" |
|
||||
default: |
|
||||
return "unknown" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 发送蓝牙状态事件 |
|
||||
private func sendBluetoothStateEvent(state: String) { |
|
||||
let event: [String: Any] = [ |
|
||||
"type": "bluetoothStateChanged", |
|
||||
"state": state, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
] |
|
||||
eventHandler?.sendEvent(event) |
|
||||
} |
|
||||
|
|
||||
// 启动蓝牙设备监控 |
|
||||
@objc func initialize() { |
|
||||
if centralManager == nil { |
|
||||
centralManager = CBCentralManager(delegate: self, queue: nil) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
// 发送当前蓝牙状态 |
|
||||
if let manager = centralManager { |
|
||||
let stateString = getBluetoothStateString(state: manager.state) |
|
||||
sendBluetoothStateEvent(state: stateString) |
|
||||
} |
|
||||
// 移除之前的监听器 |
|
||||
NotificationCenter.default.removeObserver(self) |
|
||||
// 添加新的监听器 |
|
||||
audioSessionManager.addRouteChangeListener(self, selector: #selector(handleRouteChange(_:))) |
|
||||
// BluetoothMediaButtonHelper.shared.startButtonListening() |
|
||||
} |
|
||||
|
|
||||
// 处理音频路由变化 |
|
||||
@objc private func handleRouteChange(_ notification: Notification) { |
|
||||
guard let userInfo = notification.userInfo, |
|
||||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 获取当前设备 |
|
||||
let currentDevices = getConnectedAudioDevices() |
|
||||
|
|
||||
// 检查原因 |
|
||||
switch reason { |
|
||||
case .newDeviceAvailable: |
|
||||
// 新设备已连接 |
|
||||
for device in currentDevices { |
|
||||
// 发送设备连接事件 |
|
||||
NSLog("发送设备连接事件 - 设备: \(device["name"] ?? "未知设备") [\(device["address"] ?? "未知地址")]") |
|
||||
sendDeviceConnectedEvent(device: device) |
|
||||
} |
|
||||
|
|
||||
case .oldDeviceUnavailable: |
|
||||
// 获取断开之前的路由 |
|
||||
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
|
||||
// 过滤蓝牙相关设备 |
|
||||
for output in previousRoute.outputs where isBluetoothOutput(output.portType) { |
|
||||
let device: [String: String] = [ |
|
||||
"name": output.portName, |
|
||||
"address": cleanDeviceAddress(output.uid) |
|
||||
] |
|
||||
// 发送设备断开事件 |
|
||||
sendDeviceDisconnectedEvent(device: device) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
default: |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 检查是否为蓝牙输出 |
|
||||
private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { |
|
||||
return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE |
|
||||
} |
|
||||
|
|
||||
// MARK: - 设备事件 |
|
||||
|
|
||||
// 发送设备连接事件 |
|
||||
private func sendDeviceConnectedEvent(device: [String: String]) { |
|
||||
guard let name = device["name"], let address = device["address"] else { return } |
|
||||
|
|
||||
let deviceMap: [String: Any] = [ |
|
||||
"name": name, |
|
||||
"address": address, |
|
||||
"type": "headset" |
|
||||
] |
|
||||
|
|
||||
let event: [String: Any] = [ |
|
||||
"type": "deviceConnected", |
|
||||
"device": deviceMap, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
] |
|
||||
|
|
||||
eventHandler?.sendEvent(event) |
|
||||
} |
|
||||
|
|
||||
// 发送设备断开事件 |
|
||||
private func sendDeviceDisconnectedEvent(device: [String: String]) { |
|
||||
guard let name = device["name"], let address = device["address"] else { return } |
|
||||
|
|
||||
let deviceMap: [String: Any] = [ |
|
||||
"name": name, |
|
||||
"address": address, |
|
||||
"type": "headset" |
|
||||
] |
|
||||
|
|
||||
let event: [String: Any] = [ |
|
||||
"type": "deviceDisconnected", |
|
||||
"device": deviceMap, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
] |
|
||||
|
|
||||
eventHandler?.sendEvent(event) |
|
||||
} |
|
||||
|
|
||||
// MARK: - 公共API |
|
||||
|
|
||||
// 获取当前连接的耳机设备 |
|
||||
@objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { |
|
||||
// 检查蓝牙状态 |
|
||||
if centralManager?.state != .poweredOn { |
|
||||
completion(nil, "蓝牙未启用") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 获取设备 |
|
||||
let devices = getConnectedAudioDevices() |
|
||||
completion(devices, nil) |
|
||||
} catch { |
|
||||
completion(nil, "获取设备失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取当前连接的音频设备 |
|
||||
private func getConnectedAudioDevices() -> [[String: String]] { |
|
||||
var result: [[String: String]] = [] |
|
||||
|
|
||||
// 获取当前音频路由 |
|
||||
let outputs = audioSessionManager.getCurrentRoute().outputs |
|
||||
|
|
||||
// 过滤蓝牙相关设备 |
|
||||
for output in outputs where isBluetoothOutput(output.portType) { |
|
||||
let device: [String: String] = [ |
|
||||
"name": output.portName, |
|
||||
"address": cleanDeviceAddress(output.uid) // 处理设备地址,移除后缀 |
|
||||
] |
|
||||
result.append(device) |
|
||||
} |
|
||||
|
|
||||
return result |
|
||||
} |
|
||||
|
|
||||
// 释放资源 |
|
||||
@objc func dispose() { |
|
||||
audioSessionManager.removeRouteChangeListener(self) |
|
||||
eventHandler = nil |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
// 检查蓝牙是否启用 |
|
||||
@objc func isBluetoothEnabled() -> Bool { |
|
||||
return centralManager?.state == .poweredOn |
|
||||
} |
|
||||
|
|
||||
// 清理设备地址,移除后缀 |
|
||||
private func cleanDeviceAddress(_ address: String) -> String { |
|
||||
// 检查是否包含常见分隔符 |
|
||||
if let range = address.range(of: "-") ?? address.range(of: ":") ?? address.range(of: "_") { |
|
||||
// 只保留分隔符之前的部分 |
|
||||
return String(address[..<range.lowerBound]) |
|
||||
} |
|
||||
|
|
||||
// iOS设备UID有时会包含额外信息,尝试只保留标准MAC地址格式部分 |
|
||||
// MAC地址通常是12个十六进制字符,可能带有分隔符 |
|
||||
let hexPattern = "[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}|[0-9A-Fa-f]{12}" |
|
||||
if let regex = try? NSRegularExpression(pattern: hexPattern, options: []), |
|
||||
let match = regex.firstMatch(in: address, options: [], range: NSRange(location: 0, length: address.utf16.count)) { |
|
||||
let matchRange = match.range |
|
||||
if let range = Range(matchRange, in: address) { |
|
||||
return String(address[range]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return address |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,5 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict/> |
||||
|
</plist> |
||||
@ -1,565 +0,0 @@ |
|||||
import Foundation |
|
||||
import UIKit |
|
||||
import AVFoundation |
|
||||
|
|
||||
/** |
|
||||
* 语音交互服务,iOS原生实现 |
|
||||
* |
|
||||
* 负责: |
|
||||
* 1) 监听蓝牙耳机按键 |
|
||||
* 2) 处理语音识别 |
|
||||
* 3) 与火山AI服务交互 |
|
||||
* 4) 文本转语音播放 |
|
||||
*/ |
|
||||
@available(iOS 13.0, *) |
|
||||
class VoiceInteractionService: NSObject { |
|
||||
// 常量 |
|
||||
private let TAG = "VoiceInteractionService" |
|
||||
private let RECOGNITION_TIMEOUT: TimeInterval = 8.0 |
|
||||
|
|
||||
// 单例 |
|
||||
static let shared = VoiceInteractionService() |
|
||||
|
|
||||
// 服务状态 |
|
||||
private var isRunning = false |
|
||||
private var isActive = false |
|
||||
private var isRecognitionActive = false |
|
||||
private var isTimeoutPaused = false |
|
||||
private var hasSpeechDetected = false |
|
||||
private var isTtsSpeaking = false |
|
||||
|
|
||||
// 事件处理器 |
|
||||
private weak var eventHandler: VoiceInteractionEventHandler? |
|
||||
|
|
||||
// 按键处理 |
|
||||
private var lastKeyEventTime: TimeInterval = 0 |
|
||||
private var keyEventCount = 0 |
|
||||
|
|
||||
// 活动时间 |
|
||||
private var lastActivityTime: TimeInterval = 0 |
|
||||
|
|
||||
// 当前用户输入 |
|
||||
private var currentUserInput = "" |
|
||||
private var lastUserMessage = "" |
|
||||
private var lastAssistantMessage = "" |
|
||||
|
|
||||
// 服务组件 |
|
||||
private var azureAsrHelper: AzureAsrHelper? |
|
||||
private var azureTtsHelper: AzureTtsHelper? |
|
||||
private var volcanoAIService: VolcanoAIService? |
|
||||
|
|
||||
|
|
||||
private var silenceTimer: Timer? |
|
||||
|
|
||||
// 系统提示词 |
|
||||
private let systemPrompt = """ |
|
||||
你是一个智能语音助手,能够简洁明了地回答用户的问题。 |
|
||||
请保持回答简短、准确,避免过长的解释。 |
|
||||
如果用户的问题不清楚,请礼貌地请求澄清。 |
|
||||
不要使用复杂的术语,除非用户明确要求。 |
|
||||
用户用语音和你交互。 |
|
||||
""" |
|
||||
|
|
||||
// 初始化方法 |
|
||||
private override init() { |
|
||||
super.init() |
|
||||
NSLog("%@: VoiceInteractionService 初始化中", TAG) |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 启动服务 |
|
||||
* |
|
||||
* @param apiKey Azure语音服务API密钥 |
|
||||
* @param region Azure语音服务区域 |
|
||||
* @param volcanoKey 火山AI服务API密钥 |
|
||||
* @return 启动是否成功 |
|
||||
*/ |
|
||||
func start(azureKey: String, azureRegion: String, volcanoKey: String) -> Bool { |
|
||||
guard !isRunning else { |
|
||||
NSLog("%@: 服务已经在运行", TAG) |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
NSLog("%@: 启动服务中", TAG) |
|
||||
|
|
||||
// 重置状态 |
|
||||
resetState() |
|
||||
|
|
||||
// 初始化各个服务组件 |
|
||||
initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
|
||||
|
|
||||
// 启动monitorService定时器 |
|
||||
silenceTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(monitorService), userInfo: nil, repeats: true) |
|
||||
NSLog("%@: monitorService定时器已启动", TAG) |
|
||||
|
|
||||
// 标记服务为运行状态 |
|
||||
isRunning = true |
|
||||
|
|
||||
NSLog("%@: 服务启动完成,等待蓝牙按键事件", TAG) |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止服务 |
|
||||
*/ |
|
||||
func stop() { |
|
||||
guard isRunning else { |
|
||||
NSLog("%@: 服务未运行", TAG) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
NSLog("%@: 停止服务中", TAG) |
|
||||
|
|
||||
// 停止monitorService定时器 |
|
||||
silenceTimer?.invalidate() |
|
||||
silenceTimer = nil |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
if isRecognitionActive { |
|
||||
stopVoiceRecognition() |
|
||||
} |
|
||||
|
|
||||
// 停止TTS |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
// 释放服务实例 |
|
||||
azureAsrHelper?.dispose() |
|
||||
azureTtsHelper?.dispose() |
|
||||
|
|
||||
// 更新状态 |
|
||||
resetState() |
|
||||
isRunning = false |
|
||||
|
|
||||
NSLog("%@: 服务已停止", TAG) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取服务运行状态 |
|
||||
*/ |
|
||||
func isServiceRunning() -> Bool { |
|
||||
return isRunning |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 重置状态 |
|
||||
*/ |
|
||||
private func resetState() { |
|
||||
isActive = false |
|
||||
isRecognitionActive = false |
|
||||
isTimeoutPaused = false |
|
||||
hasSpeechDetected = false |
|
||||
isTtsSpeaking = false |
|
||||
lastUserMessage = "" |
|
||||
lastAssistantMessage = "" |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 初始化服务 |
|
||||
*/ |
|
||||
private func initServices(azureKey: String, azureRegion: String, volcanoKey: String) { |
|
||||
NSLog("%@: 初始化服务组件", TAG) |
|
||||
|
|
||||
// 初始化Azure ASR |
|
||||
azureAsrHelper = AzureAsrHelper() |
|
||||
if let helper = azureAsrHelper { |
|
||||
helper.setEventHandler({ [weak self] eventData in |
|
||||
// 处理ASR事件 |
|
||||
self?.handleAsrEvent(eventData: eventData) |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// 初始化Azure TTS |
|
||||
azureTtsHelper = AzureTtsHelper() |
|
||||
if let helper = azureTtsHelper { |
|
||||
helper.setEventHandler({ [weak self] eventData in |
|
||||
// 处理TTS事件 |
|
||||
self?.handleTtsEvent(eventData: eventData) |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// 初始化火山AI服务 |
|
||||
volcanoAIService = VolcanoAIService() |
|
||||
|
|
||||
// 配置Azure ASR |
|
||||
if !azureKey.isEmpty && !azureRegion.isEmpty { |
|
||||
azureAsrHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, supportedLanguages: ["zh-CN"]) |
|
||||
NSLog("%@: Azure ASR初始化完成", TAG) |
|
||||
} else { |
|
||||
NSLog("%@: Azure配置信息不完整,无法初始化Azure ASR", TAG) |
|
||||
} |
|
||||
|
|
||||
// 配置Azure TTS |
|
||||
if !azureKey.isEmpty && !azureRegion.isEmpty { |
|
||||
azureTtsHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, language: "zh-CN") |
|
||||
NSLog("%@: Azure TTS初始化完成", TAG) |
|
||||
} else { |
|
||||
NSLog("%@: Azure配置信息不完整,无法初始化Azure TTS", TAG) |
|
||||
} |
|
||||
|
|
||||
// 配置火山AI |
|
||||
if !volcanoKey.isEmpty { |
|
||||
volcanoAIService?.initialize(apiKey: volcanoKey) |
|
||||
NSLog("%@: 火山AI服务初始化完成", TAG) |
|
||||
} else { |
|
||||
NSLog("%@: 火山AI配置信息不完整,无法初始化火山AI服务", TAG) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 监控服务状态 |
|
||||
*/ |
|
||||
@objc private func monitorService() { |
|
||||
// 确保服务保持活跃状态 |
|
||||
if !isActive { |
|
||||
isActive = true |
|
||||
} |
|
||||
|
|
||||
// 检查语音识别状态 |
|
||||
if isRecognitionActive { |
|
||||
let currentTime = Date().timeIntervalSince1970 |
|
||||
let elapsedTime = currentTime - lastActivityTime |
|
||||
|
|
||||
// NSLog("%@: 已过去时间: %@, 是否检测到语音: %@, 是否TTS播放: %@", TAG, String(elapsedTime), String(hasSpeechDetected), String(isTtsSpeaking), String(elapsedTime)) |
|
||||
|
|
||||
// 如果超过超时时间没有检测到语音,且不在TTS播放中,暂停语音识别 |
|
||||
if !hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT { |
|
||||
// NSLog("%@: 超过%@秒未检测到语音,停止识别", TAG, String(RECOGNITION_TIMEOUT)) |
|
||||
isTimeoutPaused = true |
|
||||
playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") |
|
||||
|
|
||||
// 停止语音识别并确保资源完全释放 |
|
||||
stopVoiceRecognition() |
|
||||
|
|
||||
// 清理识别状态 |
|
||||
isRecognitionActive = false |
|
||||
hasSpeechDetected = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理ASR事件 |
|
||||
*/ |
|
||||
private func handleAsrEvent(eventData: [String: Any]) { |
|
||||
if let type = eventData["type"] as? String { |
|
||||
// NSLog("%@: 处理ASR事件: %@", TAG, type) |
|
||||
|
|
||||
switch type { |
|
||||
case "recognizing": |
|
||||
if let text = eventData["text"] as? String, !text.isEmpty { |
|
||||
hasSpeechDetected = true |
|
||||
stopCurrentTTS() |
|
||||
updateLastActivityTime() |
|
||||
} else if let data = eventData["data"] as? [String: Any], |
|
||||
let text = data["text"] as? String, |
|
||||
!text.isEmpty { |
|
||||
hasSpeechDetected = true |
|
||||
stopCurrentTTS() |
|
||||
updateLastActivityTime() |
|
||||
} |
|
||||
|
|
||||
case "result": |
|
||||
var text: String? |
|
||||
|
|
||||
// 直接读取text或从data中读取 |
|
||||
if let directText = eventData["text"] as? String, !directText.isEmpty { |
|
||||
text = directText |
|
||||
} else if let data = eventData["data"] as? [String: Any], |
|
||||
let dataText = data["text"] as? String, |
|
||||
!dataText.isEmpty { |
|
||||
text = dataText |
|
||||
} |
|
||||
|
|
||||
if let finalText = text { |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
// 保存用户消息 |
|
||||
lastUserMessage = finalText |
|
||||
|
|
||||
processWithVolcanoAI(finalText) |
|
||||
} |
|
||||
|
|
||||
// 重置状态,继续识别 |
|
||||
hasSpeechDetected = false |
|
||||
|
|
||||
case "sessionStarted": |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
case "sessionStopped": |
|
||||
isRecognitionActive = false |
|
||||
|
|
||||
case "canceled": |
|
||||
isRecognitionActive = false |
|
||||
|
|
||||
case "error": |
|
||||
isRecognitionActive = false |
|
||||
if let message = eventData["message"] as? String { |
|
||||
NSLog("%@: ASR错误: %@", TAG, message) |
|
||||
} |
|
||||
playNotification("语音识别出错") |
|
||||
|
|
||||
default: |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理TTS事件 |
|
||||
*/ |
|
||||
private func handleTtsEvent(eventData: [String: Any]) { |
|
||||
if let type = eventData["type"] as? String { |
|
||||
// NSLog("%@: 处理TTS事件: %@", TAG, type) |
|
||||
|
|
||||
switch type { |
|
||||
case "started": |
|
||||
isTtsSpeaking = true |
|
||||
|
|
||||
case "completed": |
|
||||
isTtsSpeaking = false |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
case "canceled": |
|
||||
isTtsSpeaking = false |
|
||||
|
|
||||
case "error": |
|
||||
isTtsSpeaking = false |
|
||||
if let message = eventData["message"] as? String { |
|
||||
NSLog("%@: TTS错误: %@", TAG, message) |
|
||||
} |
|
||||
|
|
||||
default: |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理双击事件 |
|
||||
*/ |
|
||||
func handleMediaButtonAction() { |
|
||||
NSLog("%@: 处理媒体按钮事件", TAG) |
|
||||
|
|
||||
// 屏蔽短时间重复响应的问题 |
|
||||
let currentTime = Date().timeIntervalSince1970 |
|
||||
let timeDiff = currentTime - lastKeyEventTime |
|
||||
if timeDiff < 0.5 { |
|
||||
NSLog("%@: 短时间重复响应,忽略", TAG) |
|
||||
return |
|
||||
} |
|
||||
lastKeyEventTime = currentTime |
|
||||
|
|
||||
// 停止当前TTS播放 |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
// 检查应用是否在后台 |
|
||||
if UIApplication.shared.applicationState == .background { |
|
||||
NSLog("%@: 应用在后台,无法开始语音识别", TAG) |
|
||||
playNotification("请打开应用后重试!") |
|
||||
return |
|
||||
} |
|
||||
// 播放提示音 |
|
||||
playPrompt("我在!") |
|
||||
|
|
||||
// 重置超时暂停标志 |
|
||||
isTimeoutPaused = false |
|
||||
|
|
||||
// 启动或重置语音识别 |
|
||||
if !isRecognitionActive { |
|
||||
NSLog("%@: 语音识别未激活,开始启动", TAG) |
|
||||
|
|
||||
startVoiceRecognition() |
|
||||
} else { |
|
||||
NSLog("%@: 语音识别已激活,更新活动时间", TAG) |
|
||||
updateLastActivityTime() |
|
||||
hasSpeechDetected = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 开始语音识别 |
|
||||
*/ |
|
||||
private func startVoiceRecognition() { |
|
||||
if isRecognitionActive { return } |
|
||||
|
|
||||
|
|
||||
|
|
||||
// 通知Flutter语音识别已启动 |
|
||||
notifyVoiceRecognitionStarted() |
|
||||
|
|
||||
isActive = true |
|
||||
isRecognitionActive = true |
|
||||
hasSpeechDetected = false |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
if let asrHelper = azureAsrHelper { |
|
||||
let success = asrHelper.startContinuousRecognition() |
|
||||
if !success { |
|
||||
isRecognitionActive = false |
|
||||
NSLog("%@: 启动语音识别失败", TAG) |
|
||||
playNotification("启动语音识别失败") |
|
||||
} |
|
||||
} else { |
|
||||
isRecognitionActive = false |
|
||||
NSLog("%@: Azure ASR服务未初始化", TAG) |
|
||||
playNotification("语音识别服务未初始化") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止语音识别 |
|
||||
*/ |
|
||||
private func stopVoiceRecognition() { |
|
||||
if !isRecognitionActive { return } |
|
||||
|
|
||||
NSLog("%@: 停止语音识别", TAG) |
|
||||
|
|
||||
if let asrHelper = azureAsrHelper { |
|
||||
let success = asrHelper.stopContinuousRecognition() |
|
||||
if !success { |
|
||||
NSLog("%@: 停止语音识别失败", TAG) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
isRecognitionActive = false |
|
||||
hasSpeechDetected = false |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 使用火山AI处理语音识别结果 |
|
||||
*/ |
|
||||
private func processWithVolcanoAI(_ text: String) { |
|
||||
// 保存当前用户输入 |
|
||||
currentUserInput = text |
|
||||
|
|
||||
// 在后台线程处理 |
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|
||||
guard let self = self, let volcanoAIService = self.volcanoAIService else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 创建消息 |
|
||||
let message = volcanoAIService.createUserMessage(content: text) |
|
||||
let messages = [message] |
|
||||
|
|
||||
// 发送请求 |
|
||||
let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) |
|
||||
|
|
||||
// 保存助手回复 |
|
||||
self.lastAssistantMessage = response |
|
||||
|
|
||||
// 播放AI回复 |
|
||||
self.speakAIResponse(response) |
|
||||
|
|
||||
// 同步聊天记录到Flutter端 |
|
||||
self.notifyChatHistoryUpdated(userMessage: text, assistantMessage: response) |
|
||||
} catch { |
|
||||
NSLog("%@: AI处理出错: %@", self.TAG, error.localizedDescription) |
|
||||
self.playNotification("AI处理出错") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放AI回复 |
|
||||
*/ |
|
||||
private func speakAIResponse(_ text: String) { |
|
||||
isTtsSpeaking = true |
|
||||
azureTtsHelper?.speakText(text: text) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放提示音 |
|
||||
*/ |
|
||||
private func playPrompt(_ message: String) { |
|
||||
isTtsSpeaking = true |
|
||||
azureTtsHelper?.speakText(text: message) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放通知提示音 |
|
||||
*/ |
|
||||
private func playNotification(_ message: String) { |
|
||||
isTtsSpeaking = true |
|
||||
azureTtsHelper?.speakText(text: message) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止当前TTS播放 |
|
||||
*/ |
|
||||
private func stopCurrentTTS() { |
|
||||
if isTtsSpeaking { |
|
||||
azureTtsHelper?.stopSpeaking() |
|
||||
isTtsSpeaking = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 更新最后活动时间 |
|
||||
*/ |
|
||||
private func updateLastActivityTime() { |
|
||||
lastActivityTime = Date().timeIntervalSince1970 |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 暂停语音交互 |
|
||||
*/ |
|
||||
func pauseVoiceInteraction() { |
|
||||
NSLog("%@: 暂停语音交互", TAG) |
|
||||
|
|
||||
// 停止当前语音播放 |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
if isRecognitionActive { |
|
||||
stopVoiceRecognition() |
|
||||
} |
|
||||
|
|
||||
// 设置状态为暂停,但服务保持运行 |
|
||||
isTimeoutPaused = true |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 通知语音识别已开始 |
|
||||
*/ |
|
||||
private func notifyVoiceRecognitionStarted() { |
|
||||
// 发送识别开始事件 |
|
||||
sendEvent(type: "recognition_started", data: [:]) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 通知聊天历史更新 |
|
||||
*/ |
|
||||
private func notifyChatHistoryUpdated(userMessage: String, assistantMessage: String) { |
|
||||
// 发送聊天历史更新事件 |
|
||||
sendEvent(type: "chat_history_updated", data: [ |
|
||||
"agentId": "personal_assistant", |
|
||||
"userMessage": userMessage, |
|
||||
"assistantMessage": assistantMessage |
|
||||
]) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置事件处理器 |
|
||||
*/ |
|
||||
func setEventHandler(_ handler: VoiceInteractionEventHandler) { |
|
||||
self.eventHandler = handler |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送事件到Flutter端 |
|
||||
*/ |
|
||||
private func sendEvent(type: String, data: [String: Any] = [:]) { |
|
||||
var eventData = data |
|
||||
eventData["type"] = type |
|
||||
eventData["timestamp"] = Int(Date().timeIntervalSince1970 * 1000) |
|
||||
|
|
||||
// 使用事件处理器发送事件 |
|
||||
eventHandler?.sendEvent(eventData) |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
@ -1,391 +0,0 @@ |
|||||
import Foundation |
|
||||
|
|
||||
/** |
|
||||
* 火山AI服务的iOS原生实现 |
|
||||
* |
|
||||
* 参考Android端的VolcanoAIService实现,提供同步和异步的API调用方式 |
|
||||
*/ |
|
||||
class VolcanoAIService { |
|
||||
private let TAG = "VolcanoAIService" |
|
||||
private let baseUrl = "https://ark.cn-beijing.volces.com/api/v3" |
|
||||
private let chatEndpoint = "/chat/completions" |
|
||||
private let session: URLSession |
|
||||
|
|
||||
private var apiKey: String = "" |
|
||||
private var isInitialized = false |
|
||||
|
|
||||
init() { |
|
||||
let config = URLSessionConfiguration.default |
|
||||
config.timeoutIntervalForRequest = 30.0 |
|
||||
config.timeoutIntervalForResource = 30.0 |
|
||||
self.session = URLSession(configuration: config) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 初始化火山AI服务 |
|
||||
* |
|
||||
* @param apiKey 火山AI API密钥 |
|
||||
* @return 初始化是否成功 |
|
||||
*/ |
|
||||
func initialize(apiKey: String) -> Bool { |
|
||||
self.apiKey = apiKey |
|
||||
isInitialized = !apiKey.isEmpty |
|
||||
|
|
||||
if !isInitialized { |
|
||||
NSLog("%@: 初始化失败:API key 不能为空", TAG) |
|
||||
} else { |
|
||||
NSLog("%@: 火山AI服务初始化成功", TAG) |
|
||||
} |
|
||||
|
|
||||
return isInitialized |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 生成个性化问候语 |
|
||||
* |
|
||||
* @param agentName 代理名称 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @param callback 回调函数,返回生成的问候语 |
|
||||
*/ |
|
||||
func generateGreeting(agentName: String, systemPrompt: String, callback: @escaping (String?, Error?) -> Void) { |
|
||||
let messages: [[String: Any]] = [ |
|
||||
["role": "system", "content": systemPrompt], |
|
||||
["role": "user", "content": "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。"] |
|
||||
] |
|
||||
|
|
||||
let messagesData = try? JSONSerialization.data(withJSONObject: messages, options: []) |
|
||||
let messagesArray = try? JSONSerialization.jsonObject(with: messagesData!, options: []) as? [[String: Any]] |
|
||||
|
|
||||
var result = "" |
|
||||
|
|
||||
sendMessageStream(messages: messagesArray!, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
|
||||
onToken: { token in |
|
||||
result.append(token) |
|
||||
}, |
|
||||
onComplete: { |
|
||||
callback(result, nil) |
|
||||
}, |
|
||||
onError: { error in |
|
||||
callback(nil, error) |
|
||||
} |
|
||||
)) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送消息(非流式输出) |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @return 返回AI的回复 |
|
||||
* @throws VolcanoAIError 如果API调用失败 |
|
||||
*/ |
|
||||
func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { |
|
||||
// 检查是否已初始化 |
|
||||
if !isInitialized || apiKey.isEmpty { |
|
||||
throw VolcanoAIError.serviceNotInitialized |
|
||||
} |
|
||||
|
|
||||
var fullMessages: [[String: Any]] = [ |
|
||||
["role": "system", "content": systemPrompt] |
|
||||
] |
|
||||
|
|
||||
fullMessages.append(contentsOf: messages) |
|
||||
|
|
||||
let requestBody: [String: Any] = [ |
|
||||
"model": "doubao-1-5-lite-32k-250115", |
|
||||
"messages": fullMessages, |
|
||||
"temperature": 0.7, |
|
||||
"max_tokens": 2000, |
|
||||
"stream": false |
|
||||
] |
|
||||
|
|
||||
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
|
||||
throw VolcanoAIError.invalidURL |
|
||||
} |
|
||||
|
|
||||
var request = URLRequest(url: url) |
|
||||
request.httpMethod = "POST" |
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
|
||||
|
|
||||
do { |
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
|
||||
} catch { |
|
||||
throw VolcanoAIError.invalidRequestBody |
|
||||
} |
|
||||
|
|
||||
let semaphore = DispatchSemaphore(value: 0) |
|
||||
var responseData: Data? |
|
||||
var responseError: Error? |
|
||||
|
|
||||
let task = session.dataTask(with: request) { data, response, error in |
|
||||
if let error = error { |
|
||||
responseError = VolcanoAIError.networkError(error.localizedDescription) |
|
||||
semaphore.signal() |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
|
||||
responseError = VolcanoAIError.invalidResponse |
|
||||
semaphore.signal() |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if !(200...299).contains(httpResponse.statusCode) { |
|
||||
var errorMessage = "Unknown error occurred" |
|
||||
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
|
||||
let error = json["error"] as? [String: Any], |
|
||||
let message = error["message"] as? String { |
|
||||
errorMessage = message |
|
||||
} |
|
||||
responseError = VolcanoAIError.apiError(errorMessage) |
|
||||
semaphore.signal() |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
responseData = data |
|
||||
semaphore.signal() |
|
||||
} |
|
||||
|
|
||||
task.resume() |
|
||||
_ = semaphore.wait(timeout: .distantFuture) |
|
||||
|
|
||||
if let error = responseError { |
|
||||
throw error |
|
||||
} |
|
||||
|
|
||||
guard let data = responseData else { |
|
||||
throw VolcanoAIError.emptyResponse |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], |
|
||||
let choices = json["choices"] as? [[String: Any]], |
|
||||
let firstChoice = choices.first, |
|
||||
let message = firstChoice["message"] as? [String: Any], |
|
||||
let content = message["content"] as? String else { |
|
||||
throw VolcanoAIError.invalidResponseFormat |
|
||||
} |
|
||||
|
|
||||
return content |
|
||||
} catch { |
|
||||
throw VolcanoAIError.invalidResponseFormat |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送消息(流式输出) |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @param streamCallback 回调函数,用于接收流式输出的结果 |
|
||||
*/ |
|
||||
func sendMessageStream(messages: [[String: Any]], systemPrompt: String, streamCallback: StreamCallback) { |
|
||||
// 检查是否已初始化 |
|
||||
if !isInitialized || apiKey.isEmpty { |
|
||||
streamCallback.onError(VolcanoAIError.serviceNotInitialized) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
var fullMessages: [[String: Any]] = [ |
|
||||
["role": "system", "content": systemPrompt] |
|
||||
] |
|
||||
|
|
||||
fullMessages.append(contentsOf: messages) |
|
||||
|
|
||||
let requestBody: [String: Any] = [ |
|
||||
"model": "doubao-1-5-lite-32k-250115", |
|
||||
"messages": fullMessages, |
|
||||
"temperature": 0.7, |
|
||||
"max_tokens": 2000, |
|
||||
"stream": true |
|
||||
] |
|
||||
|
|
||||
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
|
||||
streamCallback.onError(VolcanoAIError.invalidURL) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
var request = URLRequest(url: url) |
|
||||
request.httpMethod = "POST" |
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
|
||||
request.addValue("text/event-stream", forHTTPHeaderField: "Accept") |
|
||||
|
|
||||
do { |
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
|
||||
} catch { |
|
||||
streamCallback.onError(VolcanoAIError.invalidRequestBody) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let task = session.dataTask(with: request) { data, response, error in |
|
||||
if let error = error { |
|
||||
streamCallback.onError(VolcanoAIError.networkError(error.localizedDescription)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
|
||||
streamCallback.onError(VolcanoAIError.invalidResponse) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if !(200...299).contains(httpResponse.statusCode) { |
|
||||
var errorMessage = "Unknown error occurred" |
|
||||
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
|
||||
let error = json["error"] as? [String: Any], |
|
||||
let message = error["message"] as? String { |
|
||||
errorMessage = message |
|
||||
} |
|
||||
streamCallback.onError(VolcanoAIError.apiError(errorMessage)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let data = data else { |
|
||||
streamCallback.onError(VolcanoAIError.emptyResponse) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 处理SSE流数据 |
|
||||
let responseString = String(data: data, encoding: .utf8) ?? "" |
|
||||
let lines = responseString.components(separatedBy: "\n") |
|
||||
|
|
||||
for line in lines { |
|
||||
if line.isEmpty { continue } |
|
||||
|
|
||||
if line.hasPrefix("data: ") { |
|
||||
let data = String(line.dropFirst(6)) |
|
||||
if data == "[DONE]" { |
|
||||
streamCallback.onComplete() |
|
||||
break |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
if let jsonData = data.data(using: .utf8), |
|
||||
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], |
|
||||
let choices = json["choices"] as? [[String: Any]], |
|
||||
let firstChoice = choices.first, |
|
||||
let delta = firstChoice["delta"] as? [String: Any], |
|
||||
let content = delta["content"] as? String { |
|
||||
streamCallback.onToken(content) |
|
||||
} |
|
||||
} catch { |
|
||||
// 忽略无效的JSON数据 |
|
||||
continue |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
task.resume() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 同步方式发送消息(流式输出) |
|
||||
* |
|
||||
* 注意:此方法会阻塞当前线程,请在后台线程中调用 |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @return 返回完整的AI回复 |
|
||||
* @throws VolcanoAIError 如果API调用失败 |
|
||||
*/ |
|
||||
func sendMessageStreamSync(messages: [[String: Any]], systemPrompt: String) throws -> String { |
|
||||
var result = "" |
|
||||
let semaphore = DispatchSemaphore(value: 0) |
|
||||
var responseError: Error? |
|
||||
|
|
||||
sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
|
||||
onToken: { token in |
|
||||
result.append(token) |
|
||||
}, |
|
||||
onComplete: { |
|
||||
semaphore.signal() |
|
||||
}, |
|
||||
onError: { error in |
|
||||
responseError = error |
|
||||
semaphore.signal() |
|
||||
} |
|
||||
)) |
|
||||
|
|
||||
// 等待流式输出完成或出错 |
|
||||
_ = semaphore.wait(timeout: .now() + 60) |
|
||||
|
|
||||
if let error = responseError { |
|
||||
throw error |
|
||||
} |
|
||||
|
|
||||
return result |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建用户消息 |
|
||||
*/ |
|
||||
func createUserMessage(content: String) -> [String: Any] { |
|
||||
return ["role": "user", "content": content] |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建系统消息 |
|
||||
*/ |
|
||||
func createSystemMessage(content: String) -> [String: Any] { |
|
||||
return ["role": "system", "content": content] |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建助手消息 |
|
||||
*/ |
|
||||
func createAssistantMessage(content: String) -> [String: Any] { |
|
||||
return ["role": "assistant", "content": content] |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 流式输出回调类 |
|
||||
*/ |
|
||||
class StreamCallback { |
|
||||
let onToken: (String) -> Void |
|
||||
let onComplete: () -> Void |
|
||||
let onError: (Error) -> Void |
|
||||
|
|
||||
init(onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void) { |
|
||||
self.onToken = onToken |
|
||||
self.onComplete = onComplete |
|
||||
self.onError = onError |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 火山AI错误枚举 |
|
||||
*/ |
|
||||
enum VolcanoAIError: Error { |
|
||||
case serviceNotInitialized |
|
||||
case invalidURL |
|
||||
case invalidRequestBody |
|
||||
case networkError(String) |
|
||||
case invalidResponse |
|
||||
case emptyResponse |
|
||||
case invalidResponseFormat |
|
||||
case apiError(String) |
|
||||
|
|
||||
var localizedDescription: String { |
|
||||
switch self { |
|
||||
case .serviceNotInitialized: |
|
||||
return "火山AI服务未初始化或API key为空,请先调用initialize方法" |
|
||||
case .invalidURL: |
|
||||
return "无效的URL" |
|
||||
case .invalidRequestBody: |
|
||||
return "无效的请求体" |
|
||||
case .networkError(let message): |
|
||||
return "网络错误: \(message)" |
|
||||
case .invalidResponse: |
|
||||
return "无效的响应" |
|
||||
case .emptyResponse: |
|
||||
return "空响应" |
|
||||
case .invalidResponseFormat: |
|
||||
return "无效的响应格式" |
|
||||
case .apiError(let message): |
|
||||
return "API错误: \(message)" |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,45 @@ |
|||||
|
# Miscellaneous |
||||
|
*.class |
||||
|
*.log |
||||
|
*.pyc |
||||
|
*.swp |
||||
|
.DS_Store |
||||
|
.atom/ |
||||
|
.build/ |
||||
|
.buildlog/ |
||||
|
.history |
||||
|
.svn/ |
||||
|
.swiftpm/ |
||||
|
migrate_working_dir/ |
||||
|
|
||||
|
# IntelliJ related |
||||
|
*.iml |
||||
|
*.ipr |
||||
|
*.iws |
||||
|
.idea/ |
||||
|
|
||||
|
# The .vscode folder contains launch configuration and tasks you configure in |
||||
|
# VS Code which you may wish to be included in version control, so this line |
||||
|
# is commented out by default. |
||||
|
#.vscode/ |
||||
|
|
||||
|
# Flutter/Dart/Pub related |
||||
|
**/doc/api/ |
||||
|
**/ios/Flutter/.last_build_id |
||||
|
.dart_tool/ |
||||
|
.flutter-plugins |
||||
|
.flutter-plugins-dependencies |
||||
|
.pub-cache/ |
||||
|
.pub/ |
||||
|
/build/ |
||||
|
|
||||
|
# Symbolication related |
||||
|
app.*.symbols |
||||
|
|
||||
|
# Obfuscation related |
||||
|
app.*.map.json |
||||
|
|
||||
|
# Android Studio will place build artifacts here |
||||
|
/android/app/debug |
||||
|
/android/app/profile |
||||
|
/android/app/release |
||||
@ -0,0 +1,30 @@ |
|||||
|
# This file tracks properties of this Flutter project. |
||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||
|
# |
||||
|
# This file should be version controlled and should not be manually edited. |
||||
|
|
||||
|
version: |
||||
|
revision: "ea121f8859e4b13e47a8f845e4586164519588bc" |
||||
|
channel: "stable" |
||||
|
|
||||
|
project_type: app |
||||
|
|
||||
|
# Tracks metadata for the flutter migrate command |
||||
|
migration: |
||||
|
platforms: |
||||
|
- platform: root |
||||
|
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc |
||||
|
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc |
||||
|
- platform: ios |
||||
|
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc |
||||
|
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc |
||||
|
|
||||
|
# User provided section |
||||
|
|
||||
|
# List of Local paths (relative to this file) that should be |
||||
|
# ignored by the migrate tool. |
||||
|
# |
||||
|
# Files that are not part of the templates will be ignored by default. |
||||
|
unmanaged_files: |
||||
|
- 'lib/main.dart' |
||||
|
- 'ios/Runner.xcodeproj/project.pbxproj' |
||||
@ -0,0 +1 @@ |
|||||
|
include: package:flutter_lints/flutter.yaml |
||||
@ -0,0 +1,34 @@ |
|||||
|
**/dgph |
||||
|
*.mode1v3 |
||||
|
*.mode2v3 |
||||
|
*.moved-aside |
||||
|
*.pbxuser |
||||
|
*.perspectivev3 |
||||
|
**/*sync/ |
||||
|
.sconsign.dblite |
||||
|
.tags* |
||||
|
**/.vagrant/ |
||||
|
**/DerivedData/ |
||||
|
Icon? |
||||
|
**/Pods/ |
||||
|
**/.symlinks/ |
||||
|
profile |
||||
|
xcuserdata |
||||
|
**/.generated/ |
||||
|
Flutter/App.framework |
||||
|
Flutter/Flutter.framework |
||||
|
Flutter/Flutter.podspec |
||||
|
Flutter/Generated.xcconfig |
||||
|
Flutter/ephemeral/ |
||||
|
Flutter/app.flx |
||||
|
Flutter/app.zip |
||||
|
Flutter/flutter_assets/ |
||||
|
Flutter/flutter_export_environment.sh |
||||
|
ServiceDefinitions.json |
||||
|
Runner/GeneratedPluginRegistrant.* |
||||
|
|
||||
|
# Exceptions to above rules. |
||||
|
!default.mode1v3 |
||||
|
!default.mode2v3 |
||||
|
!default.pbxuser |
||||
|
!default.perspectivev3 |
||||
@ -0,0 +1,26 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CFBundleDevelopmentRegion</key> |
||||
|
<string>en</string> |
||||
|
<key>CFBundleExecutable</key> |
||||
|
<string>App</string> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>io.flutter.flutter.app</string> |
||||
|
<key>CFBundleInfoDictionaryVersion</key> |
||||
|
<string>6.0</string> |
||||
|
<key>CFBundleName</key> |
||||
|
<string>App</string> |
||||
|
<key>CFBundlePackageType</key> |
||||
|
<string>FMWK</string> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>1.0</string> |
||||
|
<key>CFBundleSignature</key> |
||||
|
<string>????</string> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>1.0</string> |
||||
|
<key>MinimumOSVersion</key> |
||||
|
<string>12.0</string> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1 @@ |
|||||
|
#include "Generated.xcconfig" |
||||
@ -0,0 +1 @@ |
|||||
|
#include "Generated.xcconfig" |
||||
@ -0,0 +1,619 @@ |
|||||
|
// !$*UTF8*$! |
||||
|
{ |
||||
|
archiveVersion = 1; |
||||
|
classes = { |
||||
|
}; |
||||
|
objectVersion = 54; |
||||
|
objects = { |
||||
|
|
||||
|
/* Begin PBXBuildFile section */ |
||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; |
||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; |
||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; |
||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; |
||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; |
||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; |
||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; |
||||
|
/* End PBXBuildFile section */ |
||||
|
|
||||
|
/* Begin PBXContainerItemProxy section */ |
||||
|
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { |
||||
|
isa = PBXContainerItemProxy; |
||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */; |
||||
|
proxyType = 1; |
||||
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D; |
||||
|
remoteInfo = Runner; |
||||
|
}; |
||||
|
/* End PBXContainerItemProxy section */ |
||||
|
|
||||
|
/* Begin PBXCopyFilesBuildPhase section */ |
||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = { |
||||
|
isa = PBXCopyFilesBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
dstPath = ""; |
||||
|
dstSubfolderSpec = 10; |
||||
|
files = ( |
||||
|
); |
||||
|
name = "Embed Frameworks"; |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
/* End PBXCopyFilesBuildPhase section */ |
||||
|
|
||||
|
/* Begin PBXFileReference section */ |
||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; |
||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; |
||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; |
||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; |
||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; |
||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; |
||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; |
||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; |
||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; |
||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; |
||||
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
||||
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; |
||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; |
||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; |
||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
||||
|
/* End PBXFileReference section */ |
||||
|
|
||||
|
/* Begin PBXFrameworksBuildPhase section */ |
||||
|
97C146EB1CF9000F007C117D /* Frameworks */ = { |
||||
|
isa = PBXFrameworksBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
/* End PBXFrameworksBuildPhase section */ |
||||
|
|
||||
|
/* Begin PBXGroup section */ |
||||
|
331C8082294A63A400263BE5 /* RunnerTests */ = { |
||||
|
isa = PBXGroup; |
||||
|
children = ( |
||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */, |
||||
|
); |
||||
|
path = RunnerTests; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
9740EEB11CF90186004384FC /* Flutter */ = { |
||||
|
isa = PBXGroup; |
||||
|
children = ( |
||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, |
||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */, |
||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, |
||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */, |
||||
|
); |
||||
|
name = Flutter; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
97C146E51CF9000F007C117D = { |
||||
|
isa = PBXGroup; |
||||
|
children = ( |
||||
|
9740EEB11CF90186004384FC /* Flutter */, |
||||
|
97C146F01CF9000F007C117D /* Runner */, |
||||
|
97C146EF1CF9000F007C117D /* Products */, |
||||
|
331C8082294A63A400263BE5 /* RunnerTests */, |
||||
|
); |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
97C146EF1CF9000F007C117D /* Products */ = { |
||||
|
isa = PBXGroup; |
||||
|
children = ( |
||||
|
97C146EE1CF9000F007C117D /* Runner.app */, |
||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */, |
||||
|
); |
||||
|
name = Products; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
97C146F01CF9000F007C117D /* Runner */ = { |
||||
|
isa = PBXGroup; |
||||
|
children = ( |
||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */, |
||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */, |
||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, |
||||
|
97C147021CF9000F007C117D /* Info.plist */, |
||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, |
||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, |
||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, |
||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, |
||||
|
); |
||||
|
path = Runner; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
/* End PBXGroup section */ |
||||
|
|
||||
|
/* Begin PBXNativeTarget section */ |
||||
|
331C8080294A63A400263BE5 /* RunnerTests */ = { |
||||
|
isa = PBXNativeTarget; |
||||
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; |
||||
|
buildPhases = ( |
||||
|
331C807D294A63A400263BE5 /* Sources */, |
||||
|
331C807F294A63A400263BE5 /* Resources */, |
||||
|
); |
||||
|
buildRules = ( |
||||
|
); |
||||
|
dependencies = ( |
||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */, |
||||
|
); |
||||
|
name = RunnerTests; |
||||
|
productName = RunnerTests; |
||||
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; |
||||
|
productType = "com.apple.product-type.bundle.unit-test"; |
||||
|
}; |
||||
|
97C146ED1CF9000F007C117D /* Runner */ = { |
||||
|
isa = PBXNativeTarget; |
||||
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; |
||||
|
buildPhases = ( |
||||
|
9740EEB61CF901F6004384FC /* Run Script */, |
||||
|
97C146EA1CF9000F007C117D /* Sources */, |
||||
|
97C146EB1CF9000F007C117D /* Frameworks */, |
||||
|
97C146EC1CF9000F007C117D /* Resources */, |
||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */, |
||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */, |
||||
|
); |
||||
|
buildRules = ( |
||||
|
); |
||||
|
dependencies = ( |
||||
|
); |
||||
|
name = Runner; |
||||
|
productName = Runner; |
||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; |
||||
|
productType = "com.apple.product-type.application"; |
||||
|
}; |
||||
|
/* End PBXNativeTarget section */ |
||||
|
|
||||
|
/* Begin PBXProject section */ |
||||
|
97C146E61CF9000F007C117D /* Project object */ = { |
||||
|
isa = PBXProject; |
||||
|
attributes = { |
||||
|
BuildIndependentTargetsInParallel = YES; |
||||
|
LastUpgradeCheck = 1510; |
||||
|
ORGANIZATIONNAME = ""; |
||||
|
TargetAttributes = { |
||||
|
331C8080294A63A400263BE5 = { |
||||
|
CreatedOnToolsVersion = 14.0; |
||||
|
TestTargetID = 97C146ED1CF9000F007C117D; |
||||
|
}; |
||||
|
97C146ED1CF9000F007C117D = { |
||||
|
CreatedOnToolsVersion = 7.3.1; |
||||
|
LastSwiftMigration = 1100; |
||||
|
}; |
||||
|
}; |
||||
|
}; |
||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; |
||||
|
compatibilityVersion = "Xcode 9.3"; |
||||
|
developmentRegion = en; |
||||
|
hasScannedForEncodings = 0; |
||||
|
knownRegions = ( |
||||
|
en, |
||||
|
Base, |
||||
|
); |
||||
|
mainGroup = 97C146E51CF9000F007C117D; |
||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; |
||||
|
projectDirPath = ""; |
||||
|
projectRoot = ""; |
||||
|
targets = ( |
||||
|
97C146ED1CF9000F007C117D /* Runner */, |
||||
|
331C8080294A63A400263BE5 /* RunnerTests */, |
||||
|
); |
||||
|
}; |
||||
|
/* End PBXProject section */ |
||||
|
|
||||
|
/* Begin PBXResourcesBuildPhase section */ |
||||
|
331C807F294A63A400263BE5 /* Resources */ = { |
||||
|
isa = PBXResourcesBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
97C146EC1CF9000F007C117D /* Resources */ = { |
||||
|
isa = PBXResourcesBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, |
||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, |
||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, |
||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
/* End PBXResourcesBuildPhase section */ |
||||
|
|
||||
|
/* Begin PBXShellScriptBuildPhase section */ |
||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { |
||||
|
isa = PBXShellScriptBuildPhase; |
||||
|
alwaysOutOfDate = 1; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
); |
||||
|
inputPaths = ( |
||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", |
||||
|
); |
||||
|
name = "Thin Binary"; |
||||
|
outputPaths = ( |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
shellPath = /bin/sh; |
||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; |
||||
|
}; |
||||
|
9740EEB61CF901F6004384FC /* Run Script */ = { |
||||
|
isa = PBXShellScriptBuildPhase; |
||||
|
alwaysOutOfDate = 1; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
); |
||||
|
inputPaths = ( |
||||
|
); |
||||
|
name = "Run Script"; |
||||
|
outputPaths = ( |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
shellPath = /bin/sh; |
||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; |
||||
|
}; |
||||
|
/* End PBXShellScriptBuildPhase section */ |
||||
|
|
||||
|
/* Begin PBXSourcesBuildPhase section */ |
||||
|
331C807D294A63A400263BE5 /* Sources */ = { |
||||
|
isa = PBXSourcesBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
97C146EA1CF9000F007C117D /* Sources */ = { |
||||
|
isa = PBXSourcesBuildPhase; |
||||
|
buildActionMask = 2147483647; |
||||
|
files = ( |
||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, |
||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, |
||||
|
); |
||||
|
runOnlyForDeploymentPostprocessing = 0; |
||||
|
}; |
||||
|
/* End PBXSourcesBuildPhase section */ |
||||
|
|
||||
|
/* Begin PBXTargetDependency section */ |
||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { |
||||
|
isa = PBXTargetDependency; |
||||
|
target = 97C146ED1CF9000F007C117D /* Runner */; |
||||
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; |
||||
|
}; |
||||
|
/* End PBXTargetDependency section */ |
||||
|
|
||||
|
/* Begin PBXVariantGroup section */ |
||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */ = { |
||||
|
isa = PBXVariantGroup; |
||||
|
children = ( |
||||
|
97C146FB1CF9000F007C117D /* Base */, |
||||
|
); |
||||
|
name = Main.storyboard; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { |
||||
|
isa = PBXVariantGroup; |
||||
|
children = ( |
||||
|
97C147001CF9000F007C117D /* Base */, |
||||
|
); |
||||
|
name = LaunchScreen.storyboard; |
||||
|
sourceTree = "<group>"; |
||||
|
}; |
||||
|
/* End PBXVariantGroup section */ |
||||
|
|
||||
|
/* Begin XCBuildConfiguration section */ |
||||
|
249021D3217E4FDB00AE95B9 /* Profile */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
ALWAYS_SEARCH_USER_PATHS = NO; |
||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
||||
|
CLANG_ANALYZER_NONNULL = YES; |
||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
||||
|
CLANG_CXX_LIBRARY = "libc++"; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CLANG_ENABLE_OBJC_ARC = YES; |
||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
||||
|
CLANG_WARN_BOOL_CONVERSION = YES; |
||||
|
CLANG_WARN_COMMA = YES; |
||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES; |
||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
||||
|
CLANG_WARN_EMPTY_BODY = YES; |
||||
|
CLANG_WARN_ENUM_CONVERSION = YES; |
||||
|
CLANG_WARN_INFINITE_RECURSION = YES; |
||||
|
CLANG_WARN_INT_CONVERSION = YES; |
||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES; |
||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
||||
|
CLANG_WARN_UNREACHABLE_CODE = YES; |
||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
||||
|
COPY_PHASE_STRIP = NO; |
||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; |
||||
|
ENABLE_NS_ASSERTIONS = NO; |
||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES; |
||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
||||
|
GCC_C_LANGUAGE_STANDARD = gnu99; |
||||
|
GCC_NO_COMMON_BLOCKS = YES; |
||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES; |
||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
||||
|
GCC_WARN_UNUSED_FUNCTION = YES; |
||||
|
GCC_WARN_UNUSED_VARIABLE = YES; |
||||
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
||||
|
MTL_ENABLE_DEBUG_INFO = NO; |
||||
|
SDKROOT = iphoneos; |
||||
|
SUPPORTED_PLATFORMS = iphoneos; |
||||
|
TARGETED_DEVICE_FAMILY = "1,2"; |
||||
|
VALIDATE_PRODUCT = YES; |
||||
|
}; |
||||
|
name = Profile; |
||||
|
}; |
||||
|
249021D4217E4FDB00AE95B9 /* Profile */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; |
||||
|
buildSettings = { |
||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
||||
|
DEVELOPMENT_TEAM = PNNU77KTCP; |
||||
|
ENABLE_BITCODE = NO; |
||||
|
INFOPLIST_FILE = Runner/Info.plist; |
||||
|
LD_RUNPATH_SEARCH_PATHS = ( |
||||
|
"$(inherited)", |
||||
|
"@executable_path/Frameworks", |
||||
|
); |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
VERSIONING_SYSTEM = "apple-generic"; |
||||
|
}; |
||||
|
name = Profile; |
||||
|
}; |
||||
|
331C8088294A63A400263BE5 /* Debug */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
BUNDLE_LOADER = "$(TEST_HOST)"; |
||||
|
CODE_SIGN_STYLE = Automatic; |
||||
|
CURRENT_PROJECT_VERSION = 1; |
||||
|
GENERATE_INFOPLIST_FILE = YES; |
||||
|
MARKETING_VERSION = 1.0; |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; |
||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
||||
|
}; |
||||
|
name = Debug; |
||||
|
}; |
||||
|
331C8089294A63A400263BE5 /* Release */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
BUNDLE_LOADER = "$(TEST_HOST)"; |
||||
|
CODE_SIGN_STYLE = Automatic; |
||||
|
CURRENT_PROJECT_VERSION = 1; |
||||
|
GENERATE_INFOPLIST_FILE = YES; |
||||
|
MARKETING_VERSION = 1.0; |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
||||
|
}; |
||||
|
name = Release; |
||||
|
}; |
||||
|
331C808A294A63A400263BE5 /* Profile */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
BUNDLE_LOADER = "$(TEST_HOST)"; |
||||
|
CODE_SIGN_STYLE = Automatic; |
||||
|
CURRENT_PROJECT_VERSION = 1; |
||||
|
GENERATE_INFOPLIST_FILE = YES; |
||||
|
MARKETING_VERSION = 1.0; |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
||||
|
}; |
||||
|
name = Profile; |
||||
|
}; |
||||
|
97C147031CF9000F007C117D /* Debug */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
ALWAYS_SEARCH_USER_PATHS = NO; |
||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
||||
|
CLANG_ANALYZER_NONNULL = YES; |
||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
||||
|
CLANG_CXX_LIBRARY = "libc++"; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CLANG_ENABLE_OBJC_ARC = YES; |
||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
||||
|
CLANG_WARN_BOOL_CONVERSION = YES; |
||||
|
CLANG_WARN_COMMA = YES; |
||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES; |
||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
||||
|
CLANG_WARN_EMPTY_BODY = YES; |
||||
|
CLANG_WARN_ENUM_CONVERSION = YES; |
||||
|
CLANG_WARN_INFINITE_RECURSION = YES; |
||||
|
CLANG_WARN_INT_CONVERSION = YES; |
||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES; |
||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
||||
|
CLANG_WARN_UNREACHABLE_CODE = YES; |
||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
||||
|
COPY_PHASE_STRIP = NO; |
||||
|
DEBUG_INFORMATION_FORMAT = dwarf; |
||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES; |
||||
|
ENABLE_TESTABILITY = YES; |
||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
||||
|
GCC_C_LANGUAGE_STANDARD = gnu99; |
||||
|
GCC_DYNAMIC_NO_PIC = NO; |
||||
|
GCC_NO_COMMON_BLOCKS = YES; |
||||
|
GCC_OPTIMIZATION_LEVEL = 0; |
||||
|
GCC_PREPROCESSOR_DEFINITIONS = ( |
||||
|
"DEBUG=1", |
||||
|
"$(inherited)", |
||||
|
); |
||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES; |
||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
||||
|
GCC_WARN_UNUSED_FUNCTION = YES; |
||||
|
GCC_WARN_UNUSED_VARIABLE = YES; |
||||
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
||||
|
MTL_ENABLE_DEBUG_INFO = YES; |
||||
|
ONLY_ACTIVE_ARCH = YES; |
||||
|
SDKROOT = iphoneos; |
||||
|
TARGETED_DEVICE_FAMILY = "1,2"; |
||||
|
}; |
||||
|
name = Debug; |
||||
|
}; |
||||
|
97C147041CF9000F007C117D /* Release */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
buildSettings = { |
||||
|
ALWAYS_SEARCH_USER_PATHS = NO; |
||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
||||
|
CLANG_ANALYZER_NONNULL = YES; |
||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
||||
|
CLANG_CXX_LIBRARY = "libc++"; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CLANG_ENABLE_OBJC_ARC = YES; |
||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
||||
|
CLANG_WARN_BOOL_CONVERSION = YES; |
||||
|
CLANG_WARN_COMMA = YES; |
||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES; |
||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
||||
|
CLANG_WARN_EMPTY_BODY = YES; |
||||
|
CLANG_WARN_ENUM_CONVERSION = YES; |
||||
|
CLANG_WARN_INFINITE_RECURSION = YES; |
||||
|
CLANG_WARN_INT_CONVERSION = YES; |
||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES; |
||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
||||
|
CLANG_WARN_UNREACHABLE_CODE = YES; |
||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
||||
|
COPY_PHASE_STRIP = NO; |
||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; |
||||
|
ENABLE_NS_ASSERTIONS = NO; |
||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES; |
||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
||||
|
GCC_C_LANGUAGE_STANDARD = gnu99; |
||||
|
GCC_NO_COMMON_BLOCKS = YES; |
||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES; |
||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
||||
|
GCC_WARN_UNUSED_FUNCTION = YES; |
||||
|
GCC_WARN_UNUSED_VARIABLE = YES; |
||||
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
||||
|
MTL_ENABLE_DEBUG_INFO = NO; |
||||
|
SDKROOT = iphoneos; |
||||
|
SUPPORTED_PLATFORMS = iphoneos; |
||||
|
SWIFT_COMPILATION_MODE = wholemodule; |
||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O"; |
||||
|
TARGETED_DEVICE_FAMILY = "1,2"; |
||||
|
VALIDATE_PRODUCT = YES; |
||||
|
}; |
||||
|
name = Release; |
||||
|
}; |
||||
|
97C147061CF9000F007C117D /* Debug */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; |
||||
|
buildSettings = { |
||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
||||
|
DEVELOPMENT_TEAM = PNNU77KTCP; |
||||
|
ENABLE_BITCODE = NO; |
||||
|
INFOPLIST_FILE = Runner/Info.plist; |
||||
|
LD_RUNPATH_SEARCH_PATHS = ( |
||||
|
"$(inherited)", |
||||
|
"@executable_path/Frameworks", |
||||
|
); |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
VERSIONING_SYSTEM = "apple-generic"; |
||||
|
}; |
||||
|
name = Debug; |
||||
|
}; |
||||
|
97C147071CF9000F007C117D /* Release */ = { |
||||
|
isa = XCBuildConfiguration; |
||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; |
||||
|
buildSettings = { |
||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
||||
|
CLANG_ENABLE_MODULES = YES; |
||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
||||
|
DEVELOPMENT_TEAM = PNNU77KTCP; |
||||
|
ENABLE_BITCODE = NO; |
||||
|
INFOPLIST_FILE = Runner/Info.plist; |
||||
|
LD_RUNPATH_SEARCH_PATHS = ( |
||||
|
"$(inherited)", |
||||
|
"@executable_path/Frameworks", |
||||
|
); |
||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
||||
|
PRODUCT_NAME = "$(TARGET_NAME)"; |
||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
||||
|
SWIFT_VERSION = 5.0; |
||||
|
VERSIONING_SYSTEM = "apple-generic"; |
||||
|
}; |
||||
|
name = Release; |
||||
|
}; |
||||
|
/* End XCBuildConfiguration section */ |
||||
|
|
||||
|
/* Begin XCConfigurationList section */ |
||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { |
||||
|
isa = XCConfigurationList; |
||||
|
buildConfigurations = ( |
||||
|
331C8088294A63A400263BE5 /* Debug */, |
||||
|
331C8089294A63A400263BE5 /* Release */, |
||||
|
331C808A294A63A400263BE5 /* Profile */, |
||||
|
); |
||||
|
defaultConfigurationIsVisible = 0; |
||||
|
defaultConfigurationName = Release; |
||||
|
}; |
||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { |
||||
|
isa = XCConfigurationList; |
||||
|
buildConfigurations = ( |
||||
|
97C147031CF9000F007C117D /* Debug */, |
||||
|
97C147041CF9000F007C117D /* Release */, |
||||
|
249021D3217E4FDB00AE95B9 /* Profile */, |
||||
|
); |
||||
|
defaultConfigurationIsVisible = 0; |
||||
|
defaultConfigurationName = Release; |
||||
|
}; |
||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { |
||||
|
isa = XCConfigurationList; |
||||
|
buildConfigurations = ( |
||||
|
97C147061CF9000F007C117D /* Debug */, |
||||
|
97C147071CF9000F007C117D /* Release */, |
||||
|
249021D4217E4FDB00AE95B9 /* Profile */, |
||||
|
); |
||||
|
defaultConfigurationIsVisible = 0; |
||||
|
defaultConfigurationName = Release; |
||||
|
}; |
||||
|
/* End XCConfigurationList section */ |
||||
|
}; |
||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */; |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<Workspace |
||||
|
version = "1.0"> |
||||
|
<FileRef |
||||
|
location = "self:"> |
||||
|
</FileRef> |
||||
|
</Workspace> |
||||
@ -0,0 +1,8 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>PreviewsEnabled</key> |
||||
|
<false/> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,99 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<Scheme |
||||
|
LastUpgradeVersion = "1510" |
||||
|
version = "1.3"> |
||||
|
<BuildAction |
||||
|
parallelizeBuildables = "YES" |
||||
|
buildImplicitDependencies = "YES"> |
||||
|
<BuildActionEntries> |
||||
|
<BuildActionEntry |
||||
|
buildForTesting = "YES" |
||||
|
buildForRunning = "YES" |
||||
|
buildForProfiling = "YES" |
||||
|
buildForArchiving = "YES" |
||||
|
buildForAnalyzing = "YES"> |
||||
|
<BuildableReference |
||||
|
BuildableIdentifier = "primary" |
||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D" |
||||
|
BuildableName = "Runner.app" |
||||
|
BlueprintName = "Runner" |
||||
|
ReferencedContainer = "container:Runner.xcodeproj"> |
||||
|
</BuildableReference> |
||||
|
</BuildActionEntry> |
||||
|
</BuildActionEntries> |
||||
|
</BuildAction> |
||||
|
<TestAction |
||||
|
buildConfiguration = "Debug" |
||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
|
shouldUseLaunchSchemeArgsEnv = "YES"> |
||||
|
<MacroExpansion> |
||||
|
<BuildableReference |
||||
|
BuildableIdentifier = "primary" |
||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D" |
||||
|
BuildableName = "Runner.app" |
||||
|
BlueprintName = "Runner" |
||||
|
ReferencedContainer = "container:Runner.xcodeproj"> |
||||
|
</BuildableReference> |
||||
|
</MacroExpansion> |
||||
|
<Testables> |
||||
|
<TestableReference |
||||
|
skipped = "NO" |
||||
|
parallelizable = "YES"> |
||||
|
<BuildableReference |
||||
|
BuildableIdentifier = "primary" |
||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5" |
||||
|
BuildableName = "RunnerTests.xctest" |
||||
|
BlueprintName = "RunnerTests" |
||||
|
ReferencedContainer = "container:Runner.xcodeproj"> |
||||
|
</BuildableReference> |
||||
|
</TestableReference> |
||||
|
</Testables> |
||||
|
</TestAction> |
||||
|
<LaunchAction |
||||
|
buildConfiguration = "Debug" |
||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
|
launchStyle = "0" |
||||
|
useCustomWorkingDirectory = "NO" |
||||
|
ignoresPersistentStateOnLaunch = "NO" |
||||
|
debugDocumentVersioning = "YES" |
||||
|
debugServiceExtension = "internal" |
||||
|
enableGPUValidationMode = "1" |
||||
|
allowLocationSimulation = "YES"> |
||||
|
<BuildableProductRunnable |
||||
|
runnableDebuggingMode = "0"> |
||||
|
<BuildableReference |
||||
|
BuildableIdentifier = "primary" |
||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D" |
||||
|
BuildableName = "Runner.app" |
||||
|
BlueprintName = "Runner" |
||||
|
ReferencedContainer = "container:Runner.xcodeproj"> |
||||
|
</BuildableReference> |
||||
|
</BuildableProductRunnable> |
||||
|
</LaunchAction> |
||||
|
<ProfileAction |
||||
|
buildConfiguration = "Profile" |
||||
|
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
|
savedToolIdentifier = "" |
||||
|
useCustomWorkingDirectory = "NO" |
||||
|
debugDocumentVersioning = "YES"> |
||||
|
<BuildableProductRunnable |
||||
|
runnableDebuggingMode = "0"> |
||||
|
<BuildableReference |
||||
|
BuildableIdentifier = "primary" |
||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D" |
||||
|
BuildableName = "Runner.app" |
||||
|
BlueprintName = "Runner" |
||||
|
ReferencedContainer = "container:Runner.xcodeproj"> |
||||
|
</BuildableReference> |
||||
|
</BuildableProductRunnable> |
||||
|
</ProfileAction> |
||||
|
<AnalyzeAction |
||||
|
buildConfiguration = "Debug"> |
||||
|
</AnalyzeAction> |
||||
|
<ArchiveAction |
||||
|
buildConfiguration = "Release" |
||||
|
revealArchiveInOrganizer = "YES"> |
||||
|
</ArchiveAction> |
||||
|
</Scheme> |
||||
@ -0,0 +1,7 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<Workspace |
||||
|
version = "1.0"> |
||||
|
<FileRef |
||||
|
location = "group:Runner.xcodeproj"> |
||||
|
</FileRef> |
||||
|
</Workspace> |
||||
@ -0,0 +1,8 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>IDEDidComputeMac32BitWarning</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,8 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>PreviewsEnabled</key> |
||||
|
<false/> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,13 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
|
||||
|
@main |
||||
|
@objc class AppDelegate: FlutterAppDelegate { |
||||
|
override func application( |
||||
|
_ application: UIApplication, |
||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
||||
|
) -> Bool { |
||||
|
GeneratedPluginRegistrant.register(with: self) |
||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,122 @@ |
|||||
|
{ |
||||
|
"images" : [ |
||||
|
{ |
||||
|
"size" : "20x20", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-20x20@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "20x20", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-20x20@3x.png", |
||||
|
"scale" : "3x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "29x29", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-29x29@1x.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "29x29", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-29x29@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "29x29", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-29x29@3x.png", |
||||
|
"scale" : "3x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "40x40", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-40x40@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "40x40", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-40x40@3x.png", |
||||
|
"scale" : "3x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "60x60", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-60x60@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "60x60", |
||||
|
"idiom" : "iphone", |
||||
|
"filename" : "Icon-App-60x60@3x.png", |
||||
|
"scale" : "3x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "20x20", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-20x20@1x.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "20x20", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-20x20@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "29x29", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-29x29@1x.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "29x29", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-29x29@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "40x40", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-40x40@1x.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "40x40", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-40x40@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "76x76", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-76x76@1x.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "76x76", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-76x76@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "83.5x83.5", |
||||
|
"idiom" : "ipad", |
||||
|
"filename" : "Icon-App-83.5x83.5@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"size" : "1024x1024", |
||||
|
"idiom" : "ios-marketing", |
||||
|
"filename" : "Icon-App-1024x1024@1x.png", |
||||
|
"scale" : "1x" |
||||
|
} |
||||
|
], |
||||
|
"info" : { |
||||
|
"version" : 1, |
||||
|
"author" : "xcode" |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,23 @@ |
|||||
|
{ |
||||
|
"images" : [ |
||||
|
{ |
||||
|
"idiom" : "universal", |
||||
|
"filename" : "LaunchImage.png", |
||||
|
"scale" : "1x" |
||||
|
}, |
||||
|
{ |
||||
|
"idiom" : "universal", |
||||
|
"filename" : "LaunchImage@2x.png", |
||||
|
"scale" : "2x" |
||||
|
}, |
||||
|
{ |
||||
|
"idiom" : "universal", |
||||
|
"filename" : "LaunchImage@3x.png", |
||||
|
"scale" : "3x" |
||||
|
} |
||||
|
], |
||||
|
"info" : { |
||||
|
"version" : 1, |
||||
|
"author" : "xcode" |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@ -0,0 +1,5 @@ |
|||||
|
# Launch Screen Assets |
||||
|
|
||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory. |
||||
|
|
||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. |
||||
@ -0,0 +1,37 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> |
||||
|
<dependencies> |
||||
|
<deployment identifier="iOS"/> |
||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/> |
||||
|
</dependencies> |
||||
|
<scenes> |
||||
|
<!--View Controller--> |
||||
|
<scene sceneID="EHf-IW-A2E"> |
||||
|
<objects> |
||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController"> |
||||
|
<layoutGuides> |
||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/> |
||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/> |
||||
|
</layoutGuides> |
||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> |
||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
||||
|
<subviews> |
||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> |
||||
|
</imageView> |
||||
|
</subviews> |
||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> |
||||
|
<constraints> |
||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> |
||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> |
||||
|
</constraints> |
||||
|
</view> |
||||
|
</viewController> |
||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> |
||||
|
</objects> |
||||
|
<point key="canvasLocation" x="53" y="375"/> |
||||
|
</scene> |
||||
|
</scenes> |
||||
|
<resources> |
||||
|
<image name="LaunchImage" width="168" height="185"/> |
||||
|
</resources> |
||||
|
</document> |
||||
@ -0,0 +1,26 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r"> |
||||
|
<dependencies> |
||||
|
<deployment identifier="iOS"/> |
||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> |
||||
|
</dependencies> |
||||
|
<scenes> |
||||
|
<!--Flutter View Controller--> |
||||
|
<scene sceneID="tne-QT-ifu"> |
||||
|
<objects> |
||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController"> |
||||
|
<layoutGuides> |
||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/> |
||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> |
||||
|
</layoutGuides> |
||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> |
||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/> |
||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/> |
||||
|
</view> |
||||
|
</viewController> |
||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> |
||||
|
</objects> |
||||
|
</scene> |
||||
|
</scenes> |
||||
|
</document> |
||||
@ -0,0 +1,49 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CFBundleDevelopmentRegion</key> |
||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string> |
||||
|
<key>CFBundleDisplayName</key> |
||||
|
<string>Azure Speech</string> |
||||
|
<key>CFBundleExecutable</key> |
||||
|
<string>$(EXECUTABLE_NAME)</string> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> |
||||
|
<key>CFBundleInfoDictionaryVersion</key> |
||||
|
<string>6.0</string> |
||||
|
<key>CFBundleName</key> |
||||
|
<string>azure_speech</string> |
||||
|
<key>CFBundlePackageType</key> |
||||
|
<string>APPL</string> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>$(FLUTTER_BUILD_NAME)</string> |
||||
|
<key>CFBundleSignature</key> |
||||
|
<string>????</string> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string> |
||||
|
<key>LSRequiresIPhoneOS</key> |
||||
|
<true/> |
||||
|
<key>UILaunchStoryboardName</key> |
||||
|
<string>LaunchScreen</string> |
||||
|
<key>UIMainStoryboardFile</key> |
||||
|
<string>Main</string> |
||||
|
<key>UISupportedInterfaceOrientations</key> |
||||
|
<array> |
||||
|
<string>UIInterfaceOrientationPortrait</string> |
||||
|
<string>UIInterfaceOrientationLandscapeLeft</string> |
||||
|
<string>UIInterfaceOrientationLandscapeRight</string> |
||||
|
</array> |
||||
|
<key>UISupportedInterfaceOrientations~ipad</key> |
||||
|
<array> |
||||
|
<string>UIInterfaceOrientationPortrait</string> |
||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string> |
||||
|
<string>UIInterfaceOrientationLandscapeLeft</string> |
||||
|
<string>UIInterfaceOrientationLandscapeRight</string> |
||||
|
</array> |
||||
|
<key>CADisableMinimumFrameDurationOnPhone</key> |
||||
|
<true/> |
||||
|
<key>UIApplicationSupportsIndirectInputEvents</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1 @@ |
|||||
|
#import "GeneratedPluginRegistrant.h" |
||||
@ -0,0 +1,12 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
import XCTest |
||||
|
|
||||
|
class RunnerTests: XCTestCase { |
||||
|
|
||||
|
func testExample() { |
||||
|
// If you add code to the Runner application, consider adding tests here. |
||||
|
// See https://developer.apple.com/documentation/xctest for more information about using XCTest. |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
void main() { |
||||
|
runApp(const MainApp()); |
||||
|
} |
||||
|
|
||||
|
class MainApp extends StatelessWidget { |
||||
|
const MainApp({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return const MaterialApp( |
||||
|
home: Scaffold( |
||||
|
body: Center( |
||||
|
child: Text('Hello World!'), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -1,410 +1,237 @@ |
|||||
import Foundation |
import Foundation |
||||
import SQLite3 |
import SQLite3 |
||||
|
|
||||
/// ChatStorageHelper - iOS版本的聊天存储助手 |
/** |
||||
/// 负责SQLite数据库操作和业务逻辑 |
* ChatStorageHelper - 聊天数据存储助手 |
||||
|
* 负责处理SQLite数据库操作,提供消息存储和检索功能 |
||||
|
*/ |
||||
class ChatStorageHelper { |
class ChatStorageHelper { |
||||
// MARK: - Constants |
|
||||
private let TAG = "ChatStorageHelper" |
|
||||
|
|
||||
// 数据库常量 |
|
||||
private let DATABASE_NAME = "chat_messages.db" |
|
||||
private let DATABASE_VERSION = 1 |
|
||||
|
|
||||
// 表和列名 |
|
||||
private let TABLE_MESSAGES = "messages" |
|
||||
private let COLUMN_ID = "id" |
|
||||
private let COLUMN_SESSION_ID = "session_id" |
|
||||
private let COLUMN_MESSAGE = "message" |
|
||||
private let COLUMN_SENDER = "sender" |
|
||||
private let COLUMN_TIMESTAMP = "timestamp" |
|
||||
private let COLUMN_METADATA = "metadata" |
|
||||
|
|
||||
// MARK: - Properties |
|
||||
private var db: OpaquePointer? |
private var db: OpaquePointer? |
||||
private let dbPath: String |
private let dbPath: String |
||||
|
|
||||
// MARK: - Initialization |
init() { |
||||
init(_ context: Any) { |
// 获取文档目录路径 |
||||
// 获取数据库文件路径 |
let fileURL = try! FileManager.default |
||||
let fileManager = FileManager.default |
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) |
||||
if let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { |
.appendingPathComponent("chat_storage.db") |
||||
let databaseDirectory = documentsDirectory.appendingPathComponent("Databases") |
|
||||
|
dbPath = fileURL.path |
||||
// 确保目录存在 |
|
||||
try? fileManager.createDirectory(at: databaseDirectory, withIntermediateDirectories: true) |
// 打开数据库 |
||||
|
if sqlite3_open(dbPath, &db) != SQLITE_OK { |
||||
dbPath = databaseDirectory.appendingPathComponent(DATABASE_NAME).path |
print("无法打开数据库") |
||||
|
return |
||||
// 打开数据库连接 |
|
||||
openDatabase() |
|
||||
} else { |
|
||||
dbPath = "" |
|
||||
print("\(TAG) 无法获取文档目录路径") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
closeDatabase() |
|
||||
} |
|
||||
|
|
||||
// MARK: - Database Operations |
|
||||
|
|
||||
/// 打开数据库连接 |
|
||||
private func openDatabase() { |
|
||||
if sqlite3_open(dbPath, &db) == SQLITE_OK { |
|
||||
createTables() |
|
||||
print("\(TAG) 数据库已成功打开") |
|
||||
} else { |
|
||||
print("\(TAG) 无法打开数据库: \(String(describing: errorMessage))") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 关闭数据库连接 |
|
||||
private func closeDatabase() { |
|
||||
if db != nil { |
|
||||
sqlite3_close(db) |
|
||||
db = nil |
|
||||
} |
} |
||||
} |
|
||||
|
|
||||
/// 创建必要的数据库表 |
|
||||
private func createTables() { |
|
||||
// 创建消息表 |
// 创建消息表 |
||||
let createTableSQL = """ |
let createTableString = """ |
||||
CREATE TABLE IF NOT EXISTS \(TABLE_MESSAGES) ( |
CREATE TABLE IF NOT EXISTS messages ( |
||||
\(COLUMN_ID) INTEGER PRIMARY KEY AUTOINCREMENT, |
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
\(COLUMN_SESSION_ID) TEXT NOT NULL, |
session_id TEXT NOT NULL, |
||||
\(COLUMN_MESSAGE) TEXT NOT NULL, |
message TEXT NOT NULL, |
||||
\(COLUMN_SENDER) TEXT NOT NULL, |
sender TEXT NOT NULL, |
||||
\(COLUMN_TIMESTAMP) TEXT NOT NULL, |
metadata TEXT, |
||||
\(COLUMN_METADATA) TEXT |
created_at INTEGER NOT NULL |
||||
); |
); |
||||
|
CREATE INDEX IF NOT EXISTS idx_session_id ON messages (session_id); |
||||
|
CREATE INDEX IF NOT EXISTS idx_created_at ON messages (created_at); |
||||
""" |
""" |
||||
|
|
||||
if executeUpdate(sql: createTableSQL) { |
if sqlite3_exec(db, createTableString, nil, nil, nil) != SQLITE_OK { |
||||
// 创建索引以加快查询速度 |
let errmsg = String(cString: sqlite3_errmsg(db)!) |
||||
executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_session_id ON \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID))") |
print("创建表失败: \(errmsg)") |
||||
executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_timestamp ON \(TABLE_MESSAGES) (\(COLUMN_TIMESTAMP))") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取SQLite错误消息 |
|
||||
private var errorMessage: String { |
|
||||
if let errorPointer = sqlite3_errmsg(db) { |
|
||||
let errorMessage = String(cString: errorPointer) |
|
||||
return errorMessage |
|
||||
} else { |
|
||||
return "未知错误" |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 执行SQL更新操作(INSERT, UPDATE, DELETE, CREATE等) |
deinit { |
||||
private func executeUpdate(sql: String, parameters: [Any]? = nil) -> Bool { |
if db != nil { |
||||
var statement: OpaquePointer? |
sqlite3_close(db) |
||||
|
|
||||
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { |
|
||||
// 绑定参数 |
|
||||
if let params = parameters { |
|
||||
for (index, param) in params.enumerated() { |
|
||||
bindParameter(statement: statement, index: index + 1, value: param) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 执行语句 |
|
||||
let result = sqlite3_step(statement) |
|
||||
sqlite3_finalize(statement) |
|
||||
|
|
||||
return result == SQLITE_DONE |
|
||||
} else { |
|
||||
print("\(TAG) 准备SQL语句失败: \(errorMessage)") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 绑定SQL参数 |
|
||||
private func bindParameter(statement: OpaquePointer?, index: Int, value: Any) { |
|
||||
if let value = value as? Int { |
|
||||
sqlite3_bind_int(statement, Int32(index), Int32(value)) |
|
||||
} else if let value = value as? Double { |
|
||||
sqlite3_bind_double(statement, Int32(index), value) |
|
||||
} else if let value = value as? String { |
|
||||
sqlite3_bind_text(statement, Int32(index), (value as NSString).utf8String, -1, nil) |
|
||||
} else if value is NSNull { |
|
||||
sqlite3_bind_null(statement, Int32(index)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 查询数据并处理结果 |
|
||||
private func executeQuery(sql: String, parameters: [Any]? = nil, handler: (OpaquePointer) -> Void) { |
|
||||
var statement: OpaquePointer? |
|
||||
|
|
||||
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { |
|
||||
// 绑定参数 |
|
||||
if let params = parameters { |
|
||||
for (index, param) in params.enumerated() { |
|
||||
bindParameter(statement: statement, index: index + 1, value: param) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理结果 |
|
||||
while sqlite3_step(statement) == SQLITE_ROW { |
|
||||
handler(statement!) |
|
||||
} |
|
||||
} else { |
|
||||
print("\(TAG) 执行查询失败: \(errorMessage)") |
|
||||
} |
} |
||||
|
|
||||
sqlite3_finalize(statement) |
|
||||
} |
} |
||||
|
|
||||
// MARK: - Public Methods |
/** |
||||
|
* 保存消息到数据库 |
||||
/// 保存消息 |
* @param sessionId 会话ID |
||||
/// - Parameters: |
* @param message 消息内容 |
||||
/// - sessionId: 会话ID |
* @param sender 发送者("user" 或 "agent") |
||||
/// - message: 消息内容 |
* @param metadata 可选的元数据 |
||||
/// - sender: 发送者 |
* @return 插入的消息ID,失败则返回-1 |
||||
/// - metadata: 元数据 |
*/ |
||||
/// - Returns: 成功返回消息ID,失败返回-1 |
func saveMessage(sessionId: String, message: String, sender: String, metadata: String?) -> Int64 { |
||||
func saveMessage(sessionId: String, message: String, sender: String, metadata: String? = nil) -> Int64 { |
let insertStatementString = "INSERT INTO messages (session_id, message, sender, metadata, created_at) VALUES (?, ?, ?, ?, ?);" |
||||
let dateFormatter = DateFormatter() |
var insertStatement: OpaquePointer? |
||||
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" |
|
||||
let timestamp = dateFormatter.string(from: Date()) |
if sqlite3_prepare_v2(db, insertStatementString, -1, &insertStatement, nil) == SQLITE_OK { |
||||
|
sqlite3_bind_text(insertStatement, 1, (sessionId as NSString).utf8String, -1, nil) |
||||
let sql = """ |
sqlite3_bind_text(insertStatement, 2, (message as NSString).utf8String, -1, nil) |
||||
INSERT INTO \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID), \(COLUMN_MESSAGE), \(COLUMN_SENDER), \(COLUMN_TIMESTAMP), \(COLUMN_METADATA)) |
sqlite3_bind_text(insertStatement, 3, (sender as NSString).utf8String, -1, nil) |
||||
VALUES (?, ?, ?, ?, ?) |
|
||||
""" |
if let metadata = metadata { |
||||
|
sqlite3_bind_text(insertStatement, 4, (metadata as NSString).utf8String, -1, nil) |
||||
var parameters: [Any] = [sessionId, message, sender, timestamp] |
} else { |
||||
if let metadata = metadata { |
sqlite3_bind_null(insertStatement, 4) |
||||
parameters.append(metadata) |
} |
||||
|
|
||||
|
let currentTime = Int(Date().timeIntervalSince1970) |
||||
|
sqlite3_bind_int(insertStatement, 5, Int32(currentTime)) |
||||
|
|
||||
|
if sqlite3_step(insertStatement) == SQLITE_DONE { |
||||
|
let id = sqlite3_last_insert_rowid(db) |
||||
|
sqlite3_finalize(insertStatement) |
||||
|
return id |
||||
|
} else { |
||||
|
print("插入消息失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
|
} |
||||
|
|
||||
|
sqlite3_finalize(insertStatement) |
||||
} else { |
} else { |
||||
parameters.append(NSNull()) |
print("插入消息语句准备失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
} |
} |
||||
|
|
||||
if executeUpdate(sql: sql, parameters: parameters) { |
return -1 |
||||
return sqlite3_last_insert_rowid(db) |
|
||||
} else { |
|
||||
print("\(TAG) 保存消息失败") |
|
||||
return -1 |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
/// 获取指定会话的消息 |
/** |
||||
/// - Parameters: |
* 获取特定会话的消息,支持分页 |
||||
/// - sessionId: 会话ID |
* @param sessionId 会话ID |
||||
/// - page: 页码 |
* @param page 页码(从1开始) |
||||
/// - pageSize: 每页大小 |
* @param pageSize 每页消息数量 |
||||
/// - Returns: JSON字符串,包含消息列表和分页信息 |
* @return 消息列表的JSON字符串 |
||||
func getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20) -> [String: Any] { |
*/ |
||||
var messages: [[String: Any]] = [] |
func getMessages(sessionId: String, page: Int, pageSize: Int) -> String { |
||||
let offset = (page - 1) * pageSize |
let offset = (page - 1) * pageSize |
||||
|
var messagesArray: [[String: Any]] = [] |
||||
// 查询消息 |
|
||||
let sql = """ |
let queryString = """ |
||||
SELECT * FROM \(TABLE_MESSAGES) |
SELECT id, message, sender, metadata, created_at |
||||
WHERE \(COLUMN_SESSION_ID) = ? |
FROM messages |
||||
ORDER BY \(COLUMN_TIMESTAMP) DESC |
WHERE session_id = ? |
||||
LIMIT ? OFFSET ? |
ORDER BY created_at DESC |
||||
|
LIMIT ? OFFSET ? |
||||
""" |
""" |
||||
|
|
||||
executeQuery(sql: sql, parameters: [sessionId, pageSize, offset]) { statement in |
var queryStatement: OpaquePointer? |
||||
var message: [String: Any] = [:] |
|
||||
|
if sqlite3_prepare_v2(db, queryString, -1, &queryStatement, nil) == SQLITE_OK { |
||||
// 获取ID |
sqlite3_bind_text(queryStatement, 1, (sessionId as NSString).utf8String, -1, nil) |
||||
message["id"] = Int(sqlite3_column_int(statement, 0)) |
sqlite3_bind_int(queryStatement, 2, Int32(pageSize)) |
||||
|
sqlite3_bind_int(queryStatement, 3, Int32(offset)) |
||||
// 获取会话ID |
|
||||
if let sessionId = sqlite3_column_text(statement, 1) { |
|
||||
message["sessionId"] = String(cString: sessionId) |
|
||||
} |
|
||||
|
|
||||
// 获取消息内容 |
|
||||
if let messageText = sqlite3_column_text(statement, 2) { |
|
||||
message["message"] = String(cString: messageText) |
|
||||
} |
|
||||
|
|
||||
// 获取发送者 |
while sqlite3_step(queryStatement) == SQLITE_ROW { |
||||
if let sender = sqlite3_column_text(statement, 3) { |
let id = sqlite3_column_int(queryStatement, 0) |
||||
message["sender"] = String(cString: sender) |
|
||||
|
let messagePtr = sqlite3_column_text(queryStatement, 1) |
||||
|
let message = messagePtr != nil ? String(cString: messagePtr!) : "" |
||||
|
|
||||
|
let senderPtr = sqlite3_column_text(queryStatement, 2) |
||||
|
let sender = senderPtr != nil ? String(cString: senderPtr!) : "" |
||||
|
|
||||
|
let metadataPtr = sqlite3_column_text(queryStatement, 3) |
||||
|
let metadata = metadataPtr != nil ? String(cString: metadataPtr!) : nil |
||||
|
|
||||
|
let createdAt = sqlite3_column_int(queryStatement, 4) |
||||
|
|
||||
|
var messageDict: [String: Any] = [ |
||||
|
"id": id, |
||||
|
"message": message, |
||||
|
"sender": sender, |
||||
|
"created_at": createdAt |
||||
|
] |
||||
|
|
||||
|
if let metadata = metadata { |
||||
|
messageDict["metadata"] = metadata |
||||
|
} |
||||
|
|
||||
|
messagesArray.append(messageDict) |
||||
} |
} |
||||
|
|
||||
// 获取时间戳 |
sqlite3_finalize(queryStatement) |
||||
if let timestamp = sqlite3_column_text(statement, 4) { |
|
||||
message["timestamp"] = String(cString: timestamp) |
|
||||
} |
|
||||
|
|
||||
// 获取元数据(如果有) |
do { |
||||
if sqlite3_column_type(statement, 5) != SQLITE_NULL { |
let jsonData = try JSONSerialization.data(withJSONObject: messagesArray, options: []) |
||||
if let metadata = sqlite3_column_text(statement, 5) { |
if let jsonString = String(data: jsonData, encoding: .utf8) { |
||||
message["metadata"] = String(cString: metadata) |
return jsonString |
||||
} |
} |
||||
|
} catch { |
||||
|
print("转换消息为JSON失败: \(error.localizedDescription)") |
||||
} |
} |
||||
|
} else { |
||||
messages.append(message) |
print("查询消息失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
} |
|
||||
|
|
||||
// 获取总记录数 |
|
||||
var totalCount = 0 |
|
||||
let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" |
|
||||
|
|
||||
executeQuery(sql: countSql, parameters: [sessionId]) { statement in |
|
||||
totalCount = Int(sqlite3_column_int(statement, 0)) |
|
||||
} |
} |
||||
|
|
||||
// 计算总页数 |
return "[]" |
||||
let totalPages = (totalCount + pageSize - 1) / pageSize |
|
||||
|
|
||||
return [ |
|
||||
"messages": messages, |
|
||||
"page": page, |
|
||||
"pageSize": pageSize, |
|
||||
"totalCount": totalCount, |
|
||||
"totalPages": totalPages |
|
||||
] |
|
||||
} |
} |
||||
|
|
||||
/// 获取会话列表 |
|
||||
/// - Returns: 会话列表数据 |
|
||||
func getSessions() -> [[String: Any]] { |
/** |
||||
var sessions: [[String: Any]] = [] |
* 删除消息 |
||||
|
* @param sessionId 可选的会话ID,指定则删除该会话所有消息 |
||||
// 查询不同的会话ID及其最新消息 |
* @param messageIds 可选的消息ID列表,指定则删除特定消息 |
||||
let sql = """ |
* @return 是否删除成功 |
||||
SELECT s1.\(COLUMN_SESSION_ID), s1.\(COLUMN_MESSAGE), s1.\(COLUMN_SENDER), s1.\(COLUMN_TIMESTAMP) |
*/ |
||||
FROM \(TABLE_MESSAGES) s1 |
func deleteMessages(sessionId: String?, messageIds: [Int]?) -> Bool { |
||||
INNER JOIN ( |
if let sessionId = sessionId { |
||||
SELECT \(COLUMN_SESSION_ID), MAX(\(COLUMN_TIMESTAMP)) as max_timestamp |
let deleteString = "DELETE FROM messages WHERE session_id = ?;" |
||||
FROM \(TABLE_MESSAGES) |
var deleteStatement: OpaquePointer? |
||||
GROUP BY \(COLUMN_SESSION_ID) |
|
||||
) s2 ON s1.\(COLUMN_SESSION_ID) = s2.\(COLUMN_SESSION_ID) AND s1.\(COLUMN_TIMESTAMP) = s2.max_timestamp |
if sqlite3_prepare_v2(db, deleteString, -1, &deleteStatement, nil) == SQLITE_OK { |
||||
ORDER BY s1.\(COLUMN_TIMESTAMP) DESC |
sqlite3_bind_text(deleteStatement, 1, (sessionId as NSString).utf8String, -1, nil) |
||||
""" |
|
||||
|
|
||||
executeQuery(sql: sql) { statement in |
|
||||
var session: [String: Any] = [:] |
|
||||
|
|
||||
// 获取会话ID |
|
||||
if let sessionId = sqlite3_column_text(statement, 0) { |
|
||||
let sessionIdStr = String(cString: sessionId) |
|
||||
session["sessionId"] = sessionIdStr |
|
||||
|
|
||||
// 获取该会话的消息数量 |
if sqlite3_step(deleteStatement) == SQLITE_DONE { |
||||
var messageCount = 0 |
sqlite3_finalize(deleteStatement) |
||||
let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" |
return true |
||||
|
} else { |
||||
|
print("删除会话消息失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
|
} |
||||
|
|
||||
executeQuery(sql: countSql, parameters: [sessionIdStr]) { countStmt in |
sqlite3_finalize(deleteStatement) |
||||
messageCount = Int(sqlite3_column_int(countStmt, 0)) |
} else { |
||||
|
print("删除会话消息语句准备失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
|
} |
||||
|
} else if let messageIds = messageIds, !messageIds.isEmpty { |
||||
|
// 构建参数占位符 |
||||
|
let placeholders = String(repeating: "?,", count: messageIds.count).dropLast() |
||||
|
let deleteString = "DELETE FROM messages WHERE id IN (\(placeholders));" |
||||
|
var deleteStatement: OpaquePointer? |
||||
|
|
||||
|
if sqlite3_prepare_v2(db, deleteString, -1, &deleteStatement, nil) == SQLITE_OK { |
||||
|
for (index, id) in messageIds.enumerated() { |
||||
|
sqlite3_bind_int(deleteStatement, Int32(index + 1), Int32(id)) |
||||
} |
} |
||||
|
|
||||
session["messageCount"] = messageCount |
if sqlite3_step(deleteStatement) == SQLITE_DONE { |
||||
} |
sqlite3_finalize(deleteStatement) |
||||
|
return true |
||||
// 获取最后一条消息 |
} else { |
||||
if let message = sqlite3_column_text(statement, 1) { |
print("删除特定消息失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
session["lastMessage"] = String(cString: message) |
} |
||||
} |
|
||||
|
sqlite3_finalize(deleteStatement) |
||||
// 获取最后发送者 |
} else { |
||||
if let sender = sqlite3_column_text(statement, 2) { |
print("删除特定消息语句准备失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
session["lastSender"] = String(cString: sender) |
|
||||
} |
|
||||
|
|
||||
// 获取时间戳 |
|
||||
if let timestamp = sqlite3_column_text(statement, 3) { |
|
||||
session["timestamp"] = String(cString: timestamp) |
|
||||
} |
} |
||||
|
} else { |
||||
sessions.append(session) |
return false |
||||
} |
|
||||
|
|
||||
return sessions |
|
||||
} |
|
||||
|
|
||||
/// 删除消息 |
|
||||
/// - Parameters: |
|
||||
/// - sessionId: 会话ID,如果提供则删除整个会话 |
|
||||
/// - messageIds: 消息ID列表,如果提供则删除指定消息 |
|
||||
/// - Returns: 成功返回true,失败返回false |
|
||||
func deleteMessages(sessionId: String? = nil, messageIds: [Int]? = nil) -> Bool { |
|
||||
if let messageIds = messageIds, !messageIds.isEmpty { |
|
||||
// 删除指定ID的消息 |
|
||||
let placeholders = Array(repeating: "?", count: messageIds.count).joined(separator: ",") |
|
||||
let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_ID) IN (\(placeholders))" |
|
||||
|
|
||||
return executeUpdate(sql: sql, parameters: messageIds.map { $0 }) |
|
||||
} else if let sessionId = sessionId, !sessionId.isEmpty { |
|
||||
// 删除会话中的所有消息 |
|
||||
let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" |
|
||||
return executeUpdate(sql: sql, parameters: [sessionId]) |
|
||||
} |
} |
||||
|
|
||||
return false |
return false |
||||
} |
} |
||||
|
|
||||
/// 清空数据库 |
/** |
||||
/// - Returns: 成功返回true,失败返回false |
* 清空数据库 |
||||
|
* @return 是否清空成功 |
||||
|
*/ |
||||
func clearDatabase() -> Bool { |
func clearDatabase() -> Bool { |
||||
let sql = "DELETE FROM \(TABLE_MESSAGES)" |
let deleteString = "DELETE FROM messages;" |
||||
return executeUpdate(sql: sql) |
|
||||
} |
|
||||
|
|
||||
/// 获取指定会话的最近消息 |
|
||||
/// - Parameters: |
|
||||
/// - sessionId: 会话ID |
|
||||
/// - limit: 限制数量 |
|
||||
/// - Returns: 消息列表,按时间正序排列 |
|
||||
func getRecentMessages(sessionId: String, limit: Int) -> [[String: Any]] { |
|
||||
var messages: [[String: Any]] = [] |
|
||||
|
|
||||
// 查询语句 - 按时间倒序获取最近的N条,然后在结果中再按时间正序 |
|
||||
let sql = """ |
|
||||
SELECT * FROM ( |
|
||||
SELECT * FROM \(TABLE_MESSAGES) |
|
||||
WHERE \(COLUMN_SESSION_ID) = ? |
|
||||
ORDER BY \(COLUMN_TIMESTAMP) DESC |
|
||||
LIMIT ? |
|
||||
) tmp ORDER BY \(COLUMN_TIMESTAMP) ASC |
|
||||
""" |
|
||||
|
|
||||
executeQuery(sql: sql, parameters: [sessionId, limit]) { statement in |
if sqlite3_exec(db, deleteString, nil, nil, nil) == SQLITE_OK { |
||||
var message: [String: Any] = [:] |
return true |
||||
|
} else { |
||||
// 获取ID |
print("清空数据库失败: \(String(cString: sqlite3_errmsg(db)!))") |
||||
message["id"] = Int(sqlite3_column_int(statement, 0)) |
return false |
||||
|
|
||||
// 获取会话ID |
|
||||
if let sessionId = sqlite3_column_text(statement, 1) { |
|
||||
message["sessionId"] = String(cString: sessionId) |
|
||||
} |
|
||||
|
|
||||
// 获取消息内容 |
|
||||
if let messageText = sqlite3_column_text(statement, 2) { |
|
||||
message["message"] = String(cString: messageText) |
|
||||
} |
|
||||
|
|
||||
// 获取发送者 |
|
||||
if let sender = sqlite3_column_text(statement, 3) { |
|
||||
message["sender"] = String(cString: sender) |
|
||||
} |
|
||||
|
|
||||
// 获取时间戳 |
|
||||
if let timestamp = sqlite3_column_text(statement, 4) { |
|
||||
message["timestamp"] = String(cString: timestamp) |
|
||||
} |
|
||||
|
|
||||
// 获取元数据(如果有) |
|
||||
if sqlite3_column_type(statement, 5) != SQLITE_NULL { |
|
||||
if let metadata = sqlite3_column_text(statement, 5) { |
|
||||
message["metadata"] = String(cString: metadata) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
messages.append(message) |
|
||||
} |
} |
||||
|
|
||||
return messages |
|
||||
} |
} |
||||
|
|
||||
|
|
||||
} |
} |
||||
@ -1,182 +0,0 @@ |
|||||
import Flutter |
|
||||
import UIKit |
|
||||
|
|
||||
public class ChatStoragePlugin: NSObject, FlutterPlugin { |
|
||||
private let TAG = "ChatStoragePlugin" |
|
||||
private var chatStorageHelper: ChatStorageHelper? |
|
||||
|
|
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
|
||||
let channel = FlutterMethodChannel(name: "chat_storage", binaryMessenger: registrar.messenger()) |
|
||||
let instance = ChatStoragePlugin() |
|
||||
registrar.addMethodCallDelegate(instance, channel: channel) |
|
||||
} |
|
||||
|
|
||||
override init() { |
|
||||
super.init() |
|
||||
chatStorageHelper = ChatStorageHelper(()) |
|
||||
print("\(TAG) 聊天存储插件已加载") |
|
||||
} |
|
||||
|
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
guard let helper = chatStorageHelper else { |
|
||||
result(FlutterError(code: "NOT_INITIALIZED", message: "存储助手未初始化", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
switch call.method { |
|
||||
case "saveMessage": |
|
||||
handleSaveMessage(call, result, helper) |
|
||||
|
|
||||
case "getMessages": |
|
||||
handleGetMessages(call, result, helper) |
|
||||
|
|
||||
case "getSessions": |
|
||||
handleGetSessions(call, result, helper) |
|
||||
|
|
||||
case "deleteMessages": |
|
||||
handleDeleteMessages(call, result, helper) |
|
||||
|
|
||||
case "clearDatabase": |
|
||||
handleClearDatabase(call, result, helper) |
|
||||
|
|
||||
case "getRecentMessages": |
|
||||
handleGetRecentMessages(call, result, helper) |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - Method handlers |
|
||||
|
|
||||
private func handleSaveMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
guard let arguments = call.arguments as? [String: Any] else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let sessionId = arguments["sessionId"] as? String, |
|
||||
let message = arguments["message"] as? String, |
|
||||
let sender = arguments["sender"] as? String else { |
|
||||
result(FlutterError(code: "MISSING_ARGS", message: "缺少必要参数", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let metadata = arguments["metadata"] as? String |
|
||||
|
|
||||
do { |
|
||||
let id = helper.saveMessage(sessionId: sessionId, message: message, sender: sender, metadata: metadata) |
|
||||
|
|
||||
if id != -1 { |
|
||||
result(["success": true, "id": id]) |
|
||||
} else { |
|
||||
result(FlutterError(code: "DB_ERROR", message: "插入消息失败", details: nil)) |
|
||||
} |
|
||||
} catch { |
|
||||
print("\(TAG) 保存消息时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "保存消息时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleGetMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
guard let arguments = call.arguments as? [String: Any] else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let sessionId = arguments["sessionId"] as? String else { |
|
||||
result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let page = arguments["page"] as? Int ?? 1 |
|
||||
let pageSize = arguments["pageSize"] as? Int ?? 20 |
|
||||
|
|
||||
do { |
|
||||
let messagesData = helper.getMessages(sessionId: sessionId, page: page, pageSize: pageSize) |
|
||||
|
|
||||
// 将结果转换为JSON字符串 |
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: messagesData), |
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) { |
|
||||
result(jsonString) |
|
||||
} else { |
|
||||
result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil)) |
|
||||
} |
|
||||
} catch { |
|
||||
print("\(TAG) 获取消息时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "获取消息时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleGetSessions(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
do { |
|
||||
let sessions = helper.getSessions() |
|
||||
|
|
||||
// 将结果转换为JSON字符串 |
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: sessions), |
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) { |
|
||||
result(jsonString) |
|
||||
} else { |
|
||||
result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil)) |
|
||||
} |
|
||||
} catch { |
|
||||
print("\(TAG) 获取会话列表时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "获取会话列表时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleDeleteMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
guard let arguments = call.arguments as? [String: Any] else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let sessionId = arguments["sessionId"] as? String |
|
||||
let messageIds = arguments["messageIds"] as? [Int] |
|
||||
|
|
||||
do { |
|
||||
let success = helper.deleteMessages(sessionId: sessionId, messageIds: messageIds) |
|
||||
|
|
||||
if success { |
|
||||
result(["success": true]) |
|
||||
} else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "必须提供sessionId或messageIds", details: nil)) |
|
||||
} |
|
||||
} catch { |
|
||||
print("\(TAG) 删除消息时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "删除消息时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleClearDatabase(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
do { |
|
||||
let success = helper.clearDatabase() |
|
||||
result(["success": success]) |
|
||||
} catch { |
|
||||
print("\(TAG) 清空数据库时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "清空数据库时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleGetRecentMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { |
|
||||
guard let arguments = call.arguments as? [String: Any] else { |
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let sessionId = arguments["sessionId"] as? String else { |
|
||||
result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let limit = arguments["limit"] as? Int ?? 10 |
|
||||
|
|
||||
do { |
|
||||
let messages = helper.getRecentMessages(sessionId: sessionId, limit: limit) |
|
||||
result(messages) |
|
||||
} catch { |
|
||||
print("\(TAG) 获取最近消息时发生错误: \(error.localizedDescription)") |
|
||||
result(FlutterError(code: "EXCEPTION", message: "获取最近消息时发生错误: \(error.localizedDescription)", details: nil)) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,11 +1,119 @@ |
|||||
import Flutter |
import Flutter |
||||
import UIKit |
import UIKit |
||||
|
|
||||
// 这个类是为了满足Flutter插件的命名约定 |
/** |
||||
// 实际实现在ChatStoragePlugin.swift中 |
* SwiftChatStoragePlugin - Flutter插件接口 |
||||
|
* 负责处理Flutter端的方法调用,并委托给ChatStorageHelper |
||||
|
*/ |
||||
public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { |
public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { |
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
private let TAG = "ChatStoragePlugin" |
||||
// 调用实际实现类的注册方法 |
private var chatStorageHelper: ChatStorageHelper! |
||||
ChatStoragePlugin.register(with: registrar) |
|
||||
} |
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
let channel = FlutterMethodChannel(name: "chat_storage", binaryMessenger: registrar.messenger()) |
||||
|
let instance = SwiftChatStoragePlugin() |
||||
|
registrar.addMethodCallDelegate(instance, channel: channel) |
||||
|
|
||||
|
print("\(instance.TAG): 聊天存储插件已加载") |
||||
|
} |
||||
|
|
||||
|
override init() { |
||||
|
super.init() |
||||
|
chatStorageHelper = ChatStorageHelper() |
||||
|
} |
||||
|
|
||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
switch call.method { |
||||
|
case "saveMessage": |
||||
|
do { |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let sessionId = args["sessionId"] as? String, |
||||
|
let message = args["message"] as? String, |
||||
|
let sender = args["sender"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let metadata = args["metadata"] as? String |
||||
|
|
||||
|
let id = chatStorageHelper.saveMessage( |
||||
|
sessionId: sessionId, |
||||
|
message: message, |
||||
|
sender: sender, |
||||
|
metadata: metadata |
||||
|
) |
||||
|
|
||||
|
if id != -1 { |
||||
|
result(["success": true, "id": id]) |
||||
|
} else { |
||||
|
result(FlutterError(code: "DB_ERROR", message: "插入消息失败", details: nil)) |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(TAG): 保存消息时发生错误 \(error.localizedDescription)") |
||||
|
result(FlutterError(code: "EXCEPTION", message: "保存消息时发生错误: \(error.localizedDescription)", details: nil)) |
||||
|
} |
||||
|
|
||||
|
case "getMessages": |
||||
|
do { |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let sessionId = args["sessionId"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let page = args["page"] as? Int ?? 1 |
||||
|
let pageSize = args["pageSize"] as? Int ?? 20 |
||||
|
|
||||
|
let messagesJson = chatStorageHelper.getMessages( |
||||
|
sessionId: sessionId, |
||||
|
page: page, |
||||
|
pageSize: pageSize |
||||
|
) |
||||
|
|
||||
|
result(messagesJson) |
||||
|
} catch { |
||||
|
print("\(TAG): 获取消息时发生错误 \(error.localizedDescription)") |
||||
|
result(FlutterError(code: "EXCEPTION", message: "获取消息时发生错误: \(error.localizedDescription)", details: nil)) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
case "deleteMessages": |
||||
|
do { |
||||
|
guard let args = call.arguments as? [String: Any] else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let sessionId = args["sessionId"] as? String |
||||
|
let messageIds = args["messageIds"] as? [Int] |
||||
|
|
||||
|
let success = chatStorageHelper.deleteMessages( |
||||
|
sessionId: sessionId, |
||||
|
messageIds: messageIds |
||||
|
) |
||||
|
|
||||
|
if success { |
||||
|
result(["success": true]) |
||||
|
} else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "必须提供sessionId或messageIds", details: nil)) |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(TAG): 删除消息时发生错误 \(error.localizedDescription)") |
||||
|
result(FlutterError(code: "EXCEPTION", message: "删除消息时发生错误: \(error.localizedDescription)", details: nil)) |
||||
|
} |
||||
|
|
||||
|
case "clearDatabase": |
||||
|
do { |
||||
|
let success = chatStorageHelper.clearDatabase() |
||||
|
result(["success": success]) |
||||
|
} catch { |
||||
|
print("\(TAG): 清空数据库时发生错误 \(error.localizedDescription)") |
||||
|
result(FlutterError(code: "EXCEPTION", message: "清空数据库时发生错误: \(error.localizedDescription)", details: nil)) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
@ -1,64 +1,47 @@ |
|||||
# Chat Storage 插件 - iOS 实现 |
# Chat Storage 插件 - iOS 实现 |
||||
|
|
||||
## 概述 |
这是 Chat Storage 插件的 iOS 实现,用于本地存储聊天消息。 |
||||
|
|
||||
Chat Storage 插件的 iOS 实现,为 Flutter 应用提供本地 SQLite 数据库支持,用于存储和管理聊天记录。 |
## 功能 |
||||
|
|
||||
## 功能特性 |
- 保存消息到本地 SQLite 数据库 |
||||
|
- 分页获取会话消息 |
||||
|
- 获取会话列表 |
||||
|
- 删除指定会话或消息 |
||||
|
- 清空数据库 |
||||
|
- 获取最近消息 |
||||
|
|
||||
- 基于 SQLite 的聊天记录存储 |
## 实现细节 |
||||
- 支持按会话分组消息 |
|
||||
- 提供分页查询功能 |
|
||||
- 支持元数据存储 |
|
||||
- 完整的增删改查 API |
|
||||
|
|
||||
## 技术实现 |
- 使用 Swift 语言编写 |
||||
|
- 使用原生 SQLite3 API 进行数据库操作 |
||||
|
- 数据库文件位置:应用文档目录下的 `chat_storage.db` |
||||
|
- 表结构: |
||||
|
```sql |
||||
|
CREATE TABLE messages ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
session_id TEXT NOT NULL, |
||||
|
message TEXT NOT NULL, |
||||
|
sender TEXT NOT NULL, |
||||
|
metadata TEXT, |
||||
|
created_at INTEGER NOT NULL |
||||
|
); |
||||
|
``` |
||||
|
|
||||
本插件使用原生 Swift 实现,通过 SQLite C API 直接操作数据库,不依赖第三方 SQLite 封装库,以保持最小的依赖要求和最佳性能。 |
## 构建要求 |
||||
|
|
||||
### 核心组件 |
- iOS 12.0+ |
||||
|
- Xcode 14.0+ |
||||
|
- Swift 5.0+ |
||||
|
|
||||
- `ChatStorageHelper`: 负责 SQLite 数据库操作,提供完整的数据操作 API |
## 安装 |
||||
- `ChatStoragePlugin`: 插件主类,处理与 Flutter 的通信 |
|
||||
|
|
||||
### 数据库结构 |
该插件已包含在 Flutter 应用的依赖中,不需要单独安装。 |
||||
|
|
||||
数据库包含一个 `messages` 表,结构如下: |
## 开发者 |
||||
|
|
||||
```sql |
若需修改插件代码,请确保: |
||||
CREATE TABLE messages ( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
session_id TEXT NOT NULL, |
|
||||
message TEXT NOT NULL, |
|
||||
sender TEXT NOT NULL, |
|
||||
timestamp TEXT NOT NULL, |
|
||||
metadata TEXT |
|
||||
); |
|
||||
|
|
||||
-- 索引 |
1. 修改后运行 `flutter clean` 和 `flutter pub get` 重新加载插件 |
||||
CREATE INDEX idx_session_id ON messages (session_id); |
2. 若修改了数据库结构,需要考虑数据迁移方案 |
||||
CREATE INDEX idx_timestamp ON messages (timestamp); |
3. 如需测试,可以使用 `pod lib lint` 验证插件的 iOS 部分 |
||||
``` |
|
||||
|
|
||||
## 使用注意事项 |
|
||||
|
|
||||
### SQLite 多线程访问 |
|
||||
|
|
||||
本插件所有数据库操作均在主线程执行,以避免 SQLite 多线程访问的复杂性。在处理大量数据时,建议在 Flutter 层面使用 `compute` 函数或 `Isolate` 进行异步处理。 |
|
||||
|
|
||||
### 数据格式 |
|
||||
|
|
||||
- 时间戳使用 `yyyy-MM-dd HH:mm:ss` 格式的字符串 |
|
||||
- JSON 转换遵循 Swift/Objective-C 的类型映射规则 |
|
||||
|
|
||||
## 调试与故障排除 |
|
||||
|
|
||||
- 日志前缀为 `ChatStoragePlugin` 和 `ChatStorageHelper` |
|
||||
- 可通过 Console.app 查看日志输出 |
|
||||
- 数据库文件存储在应用的 Documents/Databases 目录下 |
|
||||
|
|
||||
## 开发者须知 |
|
||||
|
|
||||
如果需要扩展此插件的功能,比如添加新的表或字段,应同时更新 Android 和 iOS 端的实现,并确保在版本升级时提供适当的数据库迁移策略。 |
|
||||
|
|
||||
数据库迁移逻辑目前实现为重建表,在生产环境中应该改为保留用户数据的方式。 |
|
||||
@ -0,0 +1,21 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2024 YunQi Innovation |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
||||
@ -1,108 +0,0 @@ |
|||||
import Flutter |
|
||||
import UIKit |
|
||||
import ExternalAccessory |
|
||||
|
|
||||
public class ClassicBluetoothPlugin: NSObject, FlutterPlugin { |
|
||||
private let TAG = "ClassicBluetoothPlugin" |
|
||||
private var methodChannel: FlutterMethodChannel? |
|
||||
private var eventChannel: FlutterEventChannel? |
|
||||
private var eventSink: FlutterEventSink? |
|
||||
private let bluetoothHelper = ClassicBluetoothHelper() |
|
||||
|
|
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
|
||||
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger()) |
|
||||
let instance = ClassicBluetoothPlugin() |
|
||||
registrar.addMethodCallDelegate(instance, channel: channel) |
|
||||
|
|
||||
// 设置事件通道(用于蓝牙状态监控) |
|
||||
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.classic_bluetooth/events", binaryMessenger: registrar.messenger()) |
|
||||
eventChannel.setStreamHandler(instance) |
|
||||
|
|
||||
instance.methodChannel = channel |
|
||||
instance.eventChannel = eventChannel |
|
||||
} |
|
||||
|
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
switch call.method { |
|
||||
case "getConnectedDevices": |
|
||||
getConnectedDevices(result: result) |
|
||||
case "isBluetoothEnabled": |
|
||||
result(bluetoothHelper.isBluetoothEnabled()) |
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func getConnectedDevices(result: @escaping FlutterResult) { |
|
||||
if !bluetoothHelper.isBluetoothEnabled() { |
|
||||
result([]) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
bluetoothHelper.getConnectedDevices { devices, error in |
|
||||
if let error = error { |
|
||||
result(FlutterError(code: "BLUETOOTH_ERROR", message: error.localizedDescription, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
result(devices) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 发送事件到Flutter |
|
||||
private func sendEvent(_ event: [String: Any]) { |
|
||||
if let eventSink = eventSink { |
|
||||
eventSink(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - FlutterStreamHandler |
|
||||
extension ClassicBluetoothPlugin: FlutterStreamHandler { |
|
||||
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
self.eventSink = events |
|
||||
|
|
||||
// 开始监听蓝牙状态变化 |
|
||||
bluetoothHelper.startBluetoothStateMonitoring { [weak self] eventType, data in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
switch eventType { |
|
||||
case .stateChanged: |
|
||||
if let state = data["state"] as? String { |
|
||||
self.sendEvent([ |
|
||||
"type": "stateChanged", |
|
||||
"state": state, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
]) |
|
||||
} |
|
||||
case .deviceConnected: |
|
||||
if let device = data["device"] as? [String: String] { |
|
||||
self.sendEvent([ |
|
||||
"type": "deviceConnected", |
|
||||
"device": device, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
]) |
|
||||
} |
|
||||
case .deviceDisconnected: |
|
||||
if let device = data["device"] as? [String: String] { |
|
||||
self.sendEvent([ |
|
||||
"type": "deviceDisconnected", |
|
||||
"device": device, |
|
||||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 报告当前蓝牙状态 |
|
||||
bluetoothHelper.reportCurrentBluetoothState() |
|
||||
|
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
public func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
eventSink = nil |
|
||||
bluetoothHelper.stopBluetoothStateMonitoring() |
|
||||
return nil |
|
||||
} |
|
||||
} |
|
||||
@ -1,11 +1,110 @@ |
|||||
import Flutter |
import Flutter |
||||
import UIKit |
import UIKit |
||||
|
import ExternalAccessory |
||||
|
|
||||
// 这个文件是为了满足Flutter插件的命名约定 |
// 这个文件是为了满足Flutter插件的命名约定 |
||||
// 实际实现在ClassicBluetoothPlugin.swift中 |
// 实际实现在ClassicBluetoothPlugin.swift中 |
||||
public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { |
public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { |
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
private let TAG = "ClassicBluetoothPlugin" |
||||
// 调用真正的实现 |
private var methodChannel: FlutterMethodChannel? |
||||
ClassicBluetoothPlugin.register(with: registrar) |
private var eventChannel: FlutterEventChannel? |
||||
} |
private var eventSink: FlutterEventSink? |
||||
|
private let bluetoothHelper = ClassicBluetoothHelper() |
||||
|
|
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger()) |
||||
|
let instance = SwiftClassicBluetoothPlugin() |
||||
|
registrar.addMethodCallDelegate(instance, channel: channel) |
||||
|
|
||||
|
// 设置事件通道(用于蓝牙状态监控) |
||||
|
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.classic_bluetooth/events", binaryMessenger: registrar.messenger()) |
||||
|
eventChannel.setStreamHandler(instance) |
||||
|
|
||||
|
instance.methodChannel = channel |
||||
|
instance.eventChannel = eventChannel |
||||
|
} |
||||
|
|
||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
switch call.method { |
||||
|
case "getConnectedDevices": |
||||
|
getConnectedDevices(result: result) |
||||
|
case "isBluetoothEnabled": |
||||
|
result(bluetoothHelper.isBluetoothEnabled()) |
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func getConnectedDevices(result: @escaping FlutterResult) { |
||||
|
if !bluetoothHelper.isBluetoothEnabled() { |
||||
|
result([]) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
bluetoothHelper.getConnectedDevices { devices, error in |
||||
|
if let error = error { |
||||
|
result(FlutterError(code: "BLUETOOTH_ERROR", message: error.localizedDescription, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
result(devices) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
private func sendEvent(_ event: [String: Any]) { |
||||
|
if let eventSink = eventSink { |
||||
|
eventSink(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - FlutterStreamHandler |
||||
|
extension SwiftClassicBluetoothPlugin: FlutterStreamHandler { |
||||
|
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
self.eventSink = events |
||||
|
|
||||
|
// 开始监听蓝牙状态变化 |
||||
|
bluetoothHelper.startBluetoothStateMonitoring { [weak self] eventType, data in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
switch eventType { |
||||
|
case .stateChanged: |
||||
|
if let state = data["state"] as? String { |
||||
|
self.sendEvent([ |
||||
|
"type": "stateChanged", |
||||
|
"state": state, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
]) |
||||
|
} |
||||
|
case .deviceConnected: |
||||
|
if let device = data["device"] as? [String: String] { |
||||
|
self.sendEvent([ |
||||
|
"type": "deviceConnected", |
||||
|
"device": device, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
]) |
||||
|
} |
||||
|
case .deviceDisconnected: |
||||
|
if let device = data["device"] as? [String: String] { |
||||
|
self.sendEvent([ |
||||
|
"type": "deviceDisconnected", |
||||
|
"device": device, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 报告当前蓝牙状态 |
||||
|
bluetoothHelper.reportCurrentBluetoothState() |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
public func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
eventSink = nil |
||||
|
bluetoothHelper.stopBluetoothStateMonitoring() |
||||
|
return nil |
||||
|
} |
||||
} |
} |
||||
@ -1,2 +1,2 @@ |
|||||
#Fri May 16 09:26:08 IST 2025 |
#Sat May 17 17:27:45 IST 2025 |
||||
gradle.version=8.10 |
gradle.version=8.10 |
||||
|
|||||