diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index 0301daa87..08733ee66 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -146,10 +146,10 @@ class Api { } //解绑定设备 - static unbindearphone([params]) { + static unbinddevice([params]) { return DioManager().request( NWMethod.post, - '/api/home/user_unbindearphone', + '/api/home/user_unbinddevice', params: params, ); } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 309dd2ae0..c02347552 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -398,7 +398,29 @@ class AzureAsrService extends GetxService implements AsrService { return result; } catch (e) { - Logger.error('停止录音: ${e.toString()}'); + Logger.error('暂停录音: ${e.toString()}'); + rethrow; + } + } + + /// 设置音频配置 + /// + /// [sampleRate] 采样率,默认16000 + /// [channels] 声道数,默认1(单声道) + Future setAudioConfig({ + int sampleRate = 16000, + int channels = 1, + }) async { + try { + final bool result = await _channel.invokeMethod('setAudioConfig', { + 'sampleRate': sampleRate, + 'channels': channels, + }); + + Logger.info('音频配置设置${result ? '成功' : '失败'}: 采样率=$sampleRate, 声道数=$channels'); + return result; + } catch (e) { + Logger.error('设置音频配置失败: ${e.toString()}'); rethrow; } } diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 6121ed7f8..1a1fe15a8 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -35,7 +35,7 @@ class DevicesController extends GetxController { } void unbindearphone(UserDevice device) async { - final response = await Api.unbindearphone({ + final response = await Api.unbinddevice({ "id": device.id, }); if (response != null) { diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index fcc7e2f62..0ddfa56a5 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -220,8 +220,17 @@ class LoginController extends GetxController { @override void onClose() { - contactController.dispose(); - verificationCodeController.dispose(); + // Safely dispose controllers + try { + contactController.dispose(); + } catch (e) { + // Controller already disposed + } + try { + verificationCodeController.dispose(); + } catch (e) { + // Controller already disposed + } super.onClose(); } diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 543ba1827..4b46914e8 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -232,26 +232,28 @@ class LoginView extends GetView { margin: EdgeInsets.symmetric(horizontal: 8.w), ), Expanded( - child: TextField( - controller: controller.contactController, - decoration: InputDecoration( - hintText: controller.selectedCountryFlag == '✉️' - ? 'email'.tr // 邮箱 - : 'phoneNumber'.tr, // 手机号码 - hintStyle: TextStyle( + child: GetBuilder( + builder: (controller) => TextField( + controller: controller.contactController, + decoration: InputDecoration( + hintText: controller.selectedCountryFlag == '✉️' + ? 'email'.tr // 邮箱 + : 'phoneNumber'.tr, // 手机号码 + hintStyle: TextStyle( + fontSize: 14.sp, + color: isDarkMode + ? Colors.grey[500] + : Colors.black38, // 调整提示文字颜色 + ), + border: InputBorder.none, + fillColor: Colors.transparent, + filled: true, + focusedBorder: InputBorder.none, + ), + style: TextStyle( fontSize: 14.sp, - color: isDarkMode - ? Colors.grey[500] - : Colors.black38, // 调整提示文字颜色 + color: isDarkMode ? Colors.white : Colors.black, ), - border: InputBorder.none, - fillColor: Colors.transparent, - filled: true, - focusedBorder: InputBorder.none, - ), - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, ), ), ), @@ -360,27 +362,29 @@ class LoginView extends GetView { ), SizedBox(width: 12.w), Expanded( - child: TextField( - controller: controller.verificationCodeController, - keyboardType: TextInputType.number, - maxLength: 6, - decoration: InputDecoration( - hintText: 'verificationCode'.tr, // 验证码 - hintStyle: TextStyle( + child: GetBuilder( + builder: (controller) => TextField( + controller: controller.verificationCodeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: 'verificationCode'.tr, // 验证码 + hintStyle: TextStyle( + fontSize: 14.sp, + color: isDarkMode + ? Colors.grey[500] + : Colors.black38, // 调整提示文字颜色 + ), + border: InputBorder.none, + counterText: '', + fillColor: Colors.transparent, + filled: true, + focusedBorder: InputBorder.none, + ), + style: TextStyle( fontSize: 14.sp, - color: isDarkMode - ? Colors.grey[500] - : Colors.black38, // 调整提示文字颜色 + color: isDarkMode ? Colors.white : Colors.black, ), - border: InputBorder.none, - counterText: '', - fillColor: Colors.transparent, - filled: true, - focusedBorder: InputBorder.none, - ), - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, ), ), ), diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index 6297dee3a..75b60b3ad 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -231,7 +231,7 @@ class OpusTestController extends GetxController { isDecoding.value = false; return; } - + print("pcmPath: $pcmPath"); tempPcmPath = pcmPath; // 将PCM文件转换为WAV文件 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 35fa385de..0d85eda9c 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -154,6 +154,21 @@ class AzureAsrHelper(private val context: Context) { } } + /** + * 设置音频配置 + * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + * @param channels 声道数 (1=单声道, 2=立体声) + */ + fun setAudioConfig(sampleRate: Int, channels: Int) { + try { + // 设置RecordFile的音频配置 + recordfile?.setAudioConfig(sampleRate, channels) + Log.d(tag, "音频配置已设置: 采样率=${sampleRate}Hz, 声道数=${channels}") + } catch (e: Exception) { + Log.e(tag, "设置音频配置失败: ${e.message}") + } + } + private fun isRecognizerValid(): Boolean { return recognizer != null } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index b415262e8..30137c898 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -460,6 +460,17 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } } + "setAudioConfig" -> { + val sampleRate = call.argument("sampleRate") ?: 16000 + val channels = call.argument("channels") ?: 1 + try { + azureAsrHelper.setAudioConfig(sampleRate, channels) + result.success(true) + } catch (e: Exception) { + result.error("SET_AUDIO_CONFIG_ERROR", e.message, null) + } + } + else -> { result.notImplemented() } @@ -624,4 +635,4 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { // 不处理设备信息 } -} \ No newline at end of file +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt index c1e1b4602..cddc6a245 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -22,8 +22,9 @@ object RecordFile { private val dataBuffer = mutableListOf() private var totalBytesWritten = 0 - // 音频配置 - private val sampleRate = 16000 + // 音频配置 - 可配置的采样率和声道数 + private var sampleRate = 16000 + private var channels = 2 // 1=单声道, 2=立体声 // 新增:用于存储最后成功保存的文件 private var lastSavedFile: File? = null @@ -31,6 +32,64 @@ object RecordFile { var isPause = false var fileName = "" + // 双声道分离相关变量 + private var leftChannelFile: File? = null + private var rightChannelFile: File? = null + private var leftChannelFos: FileOutputStream? = null + private var rightChannelFos: FileOutputStream? = null + private val leftChannelQueue = LinkedBlockingQueue() + private val rightChannelQueue = LinkedBlockingQueue() + private var leftChannelWriteThread: Thread? = null + private var rightChannelWriteThread: Thread? = null + private var leftChannelBytesWritten = 0 + private var rightChannelBytesWritten = 0 + + + /** + * 设置音频配置 + * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + * @param channels 声道数 (1=单声道, 2=立体声) + */ + internal fun setAudioConfig(sampleRate: Int, channels: Int) { + this.sampleRate = sampleRate + this.channels = channels + Log.d("tag", "音频配置已更新: 采样率=${sampleRate}Hz, 声道数=${channels}") + } + + /** + * 获取左声道文件路径 + * @return 左声道文件的绝对路径,如果不存在则返回null + */ + internal fun getLeftChannelFilePath(): String? { + return leftChannelFile?.absolutePath + } + + /** + * 获取右声道文件路径 + * @return 右声道文件的绝对路径,如果不存在则返回null + */ + internal fun getRightChannelFilePath(): String? { + return rightChannelFile?.absolutePath + } + + /** + * 获取当前音频配置信息 + * @return Map包含采样率和声道数信息 + */ + internal fun getAudioConfig(): Map { + return mapOf( + "sampleRate" to sampleRate, + "channels" to channels + ) + } + + /** + * 检查是否为双声道模式 + * @return true表示双声道,false表示单声道 + */ + internal fun isStereoMode(): Boolean { + return channels == 2 + } // 修改:添加 filePath 参数 internal fun creatingFiles(filePath: String) { @@ -54,6 +113,105 @@ object RecordFile { fos = FileOutputStream(currentAudioFile, true) startWriteThread() + + // 如果是双声道,创建左右声道文件 + if (channels == 2) { + createStereoChannelFiles(filePath) + } + } + + /** + * 创建左右声道文件 + */ + private fun createStereoChannelFiles(originalFilePath: String) { + val originalFile = File(originalFilePath) + val parentDir = originalFile.parentFile + val nameWithoutExt = originalFile.nameWithoutExtension + + // 创建左声道文件 + leftChannelFile = File(parentDir, "${nameWithoutExt}_left.wav") + leftChannelFile?.createNewFile() + leftChannelFos = FileOutputStream(leftChannelFile).apply { + write(generateMonoWavHeader(0)) + close() + } + leftChannelFos = FileOutputStream(leftChannelFile, true) + + // 创建右声道文件 + rightChannelFile = File(parentDir, "${nameWithoutExt}_right.wav") + rightChannelFile?.createNewFile() + rightChannelFos = FileOutputStream(rightChannelFile).apply { + write(generateMonoWavHeader(0)) + close() + } + rightChannelFos = FileOutputStream(rightChannelFile, true) + + // 启动左右声道写入线程 + startStereoWriteThreads() + + Log.d("tag", "双声道文件创建完成: ${leftChannelFile?.name}, ${rightChannelFile?.name}") + } + + /** + * 生成单声道WAV文件头 + */ + private fun generateMonoWavHeader(dataLength: Int): ByteArray { + val totalLength = 36 + dataLength + val bytesPerSample = 2 + val monoChannels = 1 + val byteRate = sampleRate * monoChannels * bytesPerSample + val blockAlign = monoChannels * bytesPerSample + + return byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + (totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), + ((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), + 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), + 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), + 16, 0, 0, 0, + 1, 0, + (monoChannels and 0xFF).toByte(), ((monoChannels shr 8) and 0xFF).toByte(), + (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), + ((sampleRate shr 16) and 0xFF).toByte(), ((sampleRate shr 24) and 0xFF).toByte(), + (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), + ((byteRate shr 16) and 0xFF).toByte(), ((byteRate shr 24) and 0xFF).toByte(), + (blockAlign and 0xFF).toByte(), ((blockAlign shr 8) and 0xFF).toByte(), + 16, 0, + 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), + (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), + ((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() + ) + } + + /** + * 启动左右声道写入线程 + */ + private fun startStereoWriteThreads() { + // 左声道写入线程 + leftChannelWriteThread = Thread { + try { + while (isWriting.get() || leftChannelQueue.isNotEmpty()) { + val data = leftChannelQueue.poll() ?: continue + leftChannelFos?.write(data) + } + } catch (e: Exception) { + Log.e("tag", "左声道异步写入失败: ${e.message}") + } + } + leftChannelWriteThread?.start() + + // 右声道写入线程 + rightChannelWriteThread = Thread { + try { + while (isWriting.get() || rightChannelQueue.isNotEmpty()) { + val data = rightChannelQueue.poll() ?: continue + rightChannelFos?.write(data) + } + } catch (e: Exception) { + Log.e("tag", "右声道异步写入失败: ${e.message}") + } + } + rightChannelWriteThread?.start() } @@ -76,19 +234,65 @@ object RecordFile { /** * 保存音频数据到 WAV 文件(异步) + * 如果是双声道,会自动拆分成左右声道分别保存 */ internal fun saveAudioDataToWav(buffer: ByteArray) { if (fos == null || currentAudioFile == null) return - // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) - totalBytesWritten += buffer.size + + if (channels == 2) { + // 双声道:拆分左右声道 + splitStereoToMono(buffer) + } else { + // 单声道:直接保存 + writeQueue.offer(buffer.copyOf()) + totalBytesWritten += buffer.size + } } + /** + * 将双声道音频数据拆分成左右声道 + * @param stereoBuffer 双声道音频数据(16位PCM,交错格式:L1R1L2R2...) + */ + private fun splitStereoToMono(stereoBuffer: ByteArray) { + if (stereoBuffer.size % 4 != 0) { + Log.w("tag", "双声道音频数据长度不正确: ${stereoBuffer.size}") + return + } + + val sampleCount = stereoBuffer.size / 4 // 每个样本4字节(左右声道各2字节) + val leftBuffer = ByteArray(sampleCount * 2) // 左声道缓冲区 + val rightBuffer = ByteArray(sampleCount * 2) // 右声道缓冲区 + + // 拆分交错的左右声道数据 + for (i in 0 until sampleCount) { + val stereoIndex = i * 4 + val monoIndex = i * 2 + + // 左声道(低位字节在前,高位字节在后) + leftBuffer[monoIndex] = stereoBuffer[stereoIndex] + leftBuffer[monoIndex + 1] = stereoBuffer[stereoIndex + 1] + + // 右声道 + rightBuffer[monoIndex] = stereoBuffer[stereoIndex + 2] + rightBuffer[monoIndex + 1] = stereoBuffer[stereoIndex + 3] + } + + // 将左右声道数据分别放入队列 + leftChannelQueue.offer(leftBuffer) + rightChannelQueue.offer(rightBuffer) + leftChannelBytesWritten += leftBuffer.size + rightChannelBytesWritten += rightBuffer.size + + Log.d("tag", "双声道拆分完成: 左声道${leftBuffer.size}字节, 右声道${rightBuffer.size}字节") + } - // 更新文件头生成(修正RIFF长度计算) + + // 更新文件头生成(支持可配置的采样率和声道数) private fun generateWavHeader(dataLength: Int): ByteArray { val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 - val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数 + val bytesPerSample = 2 // 16位PCM + val byteRate = sampleRate * channels * bytesPerSample // 采样率 * 声道数 * 字节/样本 + val blockAlign = channels * bytesPerSample // 声道数 * 字节/样本 return byteArrayOf( 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), @@ -98,10 +302,12 @@ object RecordFile { 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), 16, 0, 0, 0, // PCM头长度 1, 0, // PCM格式 - 1, 0, // 单声道 - (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率 - (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率 - 2, 0, // 块对齐 (通道数 * 样本位数/8) + (channels and 0xFF).toByte(), ((channels shr 8) and 0xFF).toByte(), // 声道数 + (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), + ((sampleRate shr 16) and 0xFF).toByte(), ((sampleRate shr 24) and 0xFF).toByte(), // 采样率 + (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), + ((byteRate shr 16) and 0xFF).toByte(), ((byteRate shr 24) and 0xFF).toByte(), // 字节率 + (blockAlign and 0xFF).toByte(), ((blockAlign shr 8) and 0xFF).toByte(), // 块对齐 16, 0, // 样本位数 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), @@ -225,31 +431,92 @@ object RecordFile { writeThread?.join(500) fos?.close() + var mainFileSuccess = false + var leftChannelSuccess = false + var rightChannelSuccess = false + + // 处理主文件 currentAudioFile?.let { file -> if (file.length() <= 44 || isSave == false) { file.delete() Log.d("tag", "音频文件过小已删除: ${file.absolutePath}") - return false } else { RandomAccessFile(file, "rw").use { raf -> raf.seek(0) raf.write(generateWavHeader(totalBytesWritten)) } Log.d("tag", "音频文件保存完成: ${file.absolutePath}") - - // 保存成功文件引用 lastSavedFile = file - return true + mainFileSuccess = true } } + + // 处理左右声道文件(如果是双声道) + if (channels == 2) { + leftChannelSuccess = closeStereoChannelFile(leftChannelFile, leftChannelFos, leftChannelBytesWritten, "左声道", isSave) + rightChannelSuccess = closeStereoChannelFile(rightChannelFile, rightChannelFos, rightChannelBytesWritten, "右声道", isSave) + + // 等待左右声道写入线程结束 + leftChannelWriteThread?.join(500) + rightChannelWriteThread?.join(500) + } + + return if (channels == 2) { + leftChannelSuccess && rightChannelSuccess + } else { + mainFileSuccess + } } catch (e: Exception) { Log.e("tag", "更新WAV文件头失败: ${e.message}") return false } finally { + // 清理资源 fos = null currentAudioFile = null writeThread = null + + // 清理双声道资源 + leftChannelFos = null + rightChannelFos = null + leftChannelFile = null + rightChannelFile = null + leftChannelWriteThread = null + rightChannelWriteThread = null + leftChannelBytesWritten = 0 + rightChannelBytesWritten = 0 + totalBytesWritten = 0 + } + } + + /** + * 关闭单个声道文件 + */ + private fun closeStereoChannelFile( + channelFile: File?, + channelFos: FileOutputStream?, + bytesWritten: Int, + channelName: String, + isSave: Boolean + ): Boolean { + return try { + channelFos?.close() + channelFile?.let { file -> + if (file.length() <= 44 || isSave == false) { + file.delete() + Log.d("tag", "${channelName}文件过小已删除: ${file.absolutePath}") + false + } else { + RandomAccessFile(file, "rw").use { raf -> + raf.seek(0) + raf.write(generateMonoWavHeader(bytesWritten)) + } + Log.d("tag", "${channelName}文件保存完成: ${file.absolutePath}") + true + } + } ?: false + } catch (e: Exception) { + Log.e("tag", "${channelName}文件关闭失败: ${e.message}") + false } - return false } } \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index e45b51003..48208e494 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -253,6 +253,30 @@ public class AzureAsrHelper: NSObject { } } + /** + * 设置音频配置 + * @param sampleRate 采样率,默认16000 + * @param channels 声道数,默认1 + * @return 设置是否成功 + */ + public func setAudioConfig(sampleRate: Int, channels: Int) -> Bool { + do { + print("设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") + + // 设置音频流的配置 + audioStream?.setAudioConfig(sampleRate: sampleRate, channels: channels) + + // 设置录音文件的配置 + recordfile?.setAudioConfig(sampleRate: sampleRate, channels: channels) + + print("音频配置设置成功") + return true + } catch { + print("设置音频配置失败: \(error.localizedDescription)") + return false + } + } + /** * 设置识别器 @@ -553,6 +577,32 @@ public class AzureAsrHelper: NSObject { interleaved: true ) } + + /** + * 设置音频配置 + * @param sampleRate 采样率,默认16000 + * @param channels 声道数,默认1 + */ + public func setAudioConfig(sampleRate: Int, channels: Int) { + print("AudioStream设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") + + // 更新音频格式 + audioFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: Double(sampleRate), + channels: AVAudioChannelCount(channels), + interleaved: true + ) + + // 如果已经有推流,重新创建 + if pushAudioStream != nil { + let audioStreamFormat = SPXAudioStreamFormat() + audioStreamFormat?.initUsingPCM(withSampleRate: UInt(sampleRate), bitsPerSample: 16, channels: UInt(channels)) + pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat) + } + + print("AudioStream音频配置设置完成") + } /** * 开始音频输入 */ diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index f07e56855..048575f9a 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -290,6 +290,18 @@ import os.log azureTtsHelper.stop() result(true) + case "setAudioConfig": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数不能为空", details: nil)) + return + } + + let sampleRate = args["sampleRate"] as? Int ?? 16000 + let channels = args["channels"] as? Int ?? 1 + + let success = azureAsrHelper.setAudioConfig(sampleRate: sampleRate, channels: channels) + result(success) + default: result(FlutterMethodNotImplemented) } @@ -549,4 +561,4 @@ extension AzureSpeechPlugin: AudioDataListener { sendTtsEvent(eventMap) } -} +} diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 9ae9dd608..fe2eefa6e 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -15,7 +15,7 @@ object BleConst { val AUDIO_SERVICE_UUID1: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ - val RECEIVE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC2-0001-1111-2222-123456789ABC") + val WRITE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 0426e6409..a883b2452 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -76,9 +76,8 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null - private var audioChar1: BluetoothGattCharacteristic? = null + private var writeChar1: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null - // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() @@ -102,6 +101,18 @@ object BleService { // Opus解码器实例 private var opusManager: OpusManager? = null private val mainHandler = Handler(Looper.getMainLooper()) + + // 音频解码处理线程 + private var audioDecodeThread: HandlerThread? = null + private var audioDecodeHandler: Handler? = null + + // 音频数据队列和处理线程 + private val audioDataQueue = LinkedBlockingQueue() + private var audioQueueProcessorThread: Thread? = null + + // 音频数据缓存 + private val audioDataBuffer = mutableListOf() + private val AUDIO_BUFFER_SIZE = 1280// 1280字节缓存阈值 // 初始化状态 private var isInitialized = false @@ -147,6 +158,16 @@ object BleService { // 初始化Handler scanHandler = Handler(Looper.getMainLooper()) + + // 初始化音频解码线程 + audioDecodeThread = HandlerThread("AudioDecodeThread").apply { + start() + audioDecodeHandler = Handler(looper) + } + Log.d(TAG, "音频解码线程初始化成功") + + // 启动队列处理 + startAudioQueueProcessing() // 初始化OpusManager和OTAManager try { @@ -158,6 +179,7 @@ object BleService { // 根据需要决定是否因为Opus初始化失败而返回false } recordfile = RecordingFile(this.context) + recordfile!!.fileName = "不拆分" isInitialized = true Log.d(TAG, "BLE服务初始化成功") return true @@ -514,7 +536,7 @@ object BleService { notifyChar = null writeChar = null audioChar = null - audioChar1 = null + writeChar1 = null } } @@ -591,14 +613,15 @@ object BleService { // 获取音频服务1特征 val audioSvc1 = g.getService(BleConst.AUDIO_SERVICE_UUID1) - audioChar1 = audioSvc1?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID1) - if (notifyChar == null || writeChar == null) { + writeChar1 = audioSvc1?.getCharacteristic(BleConst.WRITE_AUDIO_CHAR_UUID1) + if (writeChar1 == null) { Log.e(TAG, "未找到主服务所需特征") updateConnectionState(BleConst.STATE_ERROR) return } - + Log.d(TAG, "找到writeChar1服务所需特征") writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + writeChar1?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE //要先设置音频服务的通知,否则接收不到 // 设置音频服务的通知(如果存在) if (audioChar != null) { @@ -607,12 +630,7 @@ object BleService { } else { Log.w(TAG, "音频服务特征未找到") } - if (audioChar1 != null) { - setupNotifications(g, audioChar1) - Log.i(TAG, "音频服务特征找到并设置通知") - } else { - Log.w(TAG, "音频服务特征未找到") - } + // 设置主服务的通知 setupNotifications(g, notifyChar) //开启解码 @@ -711,9 +729,25 @@ object BleService { private fun processAudioData(data: ByteArray) { try { if (opusManager?.isDecodeStream == true) { - recordfile?.saveAudioDataToWav(data) - opusManager?.writeAudioStream(data) + + // 将接收到的数据添加到缓存中 + synchronized(audioDataBuffer) { + audioDataBuffer.addAll(data.toList()) + + // 当缓存达到阈值时,提取完整的帧进行解码 + while (audioDataBuffer.size >= AUDIO_BUFFER_SIZE) { + // 提取一个完整的帧(1280字节) + val frameData = ByteArray(AUDIO_BUFFER_SIZE) + for (i in 0 until AUDIO_BUFFER_SIZE) { + frameData[i] = audioDataBuffer.removeAt(0) + } + + // 将完整的帧加入队列,由专门的线程处理 + audioDataQueue.offer(frameData) + Log.d(TAG, "缓存达到阈值,提取 ${frameData.size} 字节帧进行解码") + } + } } else { // Log.d(TAG, "Opus解码流未启动,忽略音频数据") } @@ -1098,6 +1132,12 @@ object BleService { opusManager?.stopDecodeStream() Log.d(TAG, "已停止正在进行的Opus解码流") } + + // 清理音频数据缓存,确保开始时是干净的状态 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "开始解码前已清理音频数据缓存") + } val option = OpusOption() .setHasHead(hasHeader) @@ -1140,6 +1180,13 @@ object BleService { private fun stopOpusStreamDecoding(): Boolean { if (opusManager?.isDecodeStream == true) { opusManager?.stopDecodeStream() + + // 清理音频数据缓存 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "已清理音频数据缓存") + } + Log.i(TAG, "已停止Opus数据流解码") return true } @@ -1214,7 +1261,7 @@ object BleService { // ) startOpusStreamDecoding(false, 1, 16000, 40) - Log.i(TAG, "打开解码0xA2") + Log.i(TAG, "打开解码0xA1") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( BleConst.CODEC_CONTROL_DECODE_ON.toByte(), @@ -1228,8 +1275,8 @@ object BleService { * 控制编解码 - 打开解码 */ fun openA2DPDecoder(): Boolean { - Log.i(TAG, "打开编码 0xA1") - startOpusStreamDecoding(false, 1, 16000, 80) + Log.i(TAG, "打开编码 0xA2") + startOpusStreamDecoding(false,2, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1320,7 +1367,7 @@ object BleService { * 连接状态检查 */ private fun checkConn(): Boolean = - bluetoothGatt != null && writeChar != null && + bluetoothGatt != null && writeChar != null && writeChar1 != null && connectionState.value == BleConst.STATE_CONNECTED /** @@ -1348,6 +1395,26 @@ object BleService { stopScan() scanHandler.removeCallbacksAndMessages(null) disconnectGatt() + + // 清理音频解码线程和队列 + audioDecodeHandler?.removeCallbacksAndMessages(null) + audioDecodeThread?.quitSafely() + audioDecodeThread = null + audioDecodeHandler = null + + // 停止音频队列处理线程 + audioQueueProcessorThread?.interrupt() + audioQueueProcessorThread = null + audioDataQueue.clear() + + // 清理音频数据缓存 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "已清理音频数据缓存") + } + + Log.d(TAG, "音频解码线程和队列处理线程已清理") + // 释放OpusManager opusManager?.let { if (it.isDecodeStream) { @@ -1534,6 +1601,29 @@ object BleService { } } } + + /** + * 启动音频队列处理 + */ + private fun startAudioQueueProcessing() { + audioQueueProcessorThread = Thread { + while (!Thread.currentThread().isInterrupted) { + try { + // 从队列中取出音频数据进行解码 + val audioData = audioDataQueue.take() // 阻塞等待数据 + opusManager?.writeAudioStream(audioData) + } catch (e: InterruptedException) { + Log.d(TAG, "音频队列处理线程被中断") + break + } catch (e: Exception) { + Log.e(TAG, "音频队列处理异常: ${e.message}", e) + } + } + }.apply { + name = "AudioQueueProcessor" + start() + } + } }