Browse Source

加入声道分离。拆分两段音频文件。目前可能影响其他录下来的音频文件

newdev_shunjiawei
fdp 1 year ago
parent
commit
0c03005b05
  1. 4
      lib/data/services/network/api.dart
  2. 24
      lib/data/services/speech_impl/azure_asr_service.dart
  3. 2
      lib/modules/devices/controllers/devices_controller.dart
  4. 13
      lib/modules/login/controllers/login_controller.dart
  5. 78
      lib/modules/login/views/login_view.dart
  6. 2
      lib/modules/opus_test/controllers/opus_test_controller.dart
  7. 15
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  8. 13
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  9. 299
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt
  10. 50
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  11. 14
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  12. 2
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  13. 126
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

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

24
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<bool> 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;
}
}

2
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) {

13
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();
}

78
lib/modules/login/views/login_view.dart

@ -232,26 +232,28 @@ class LoginView extends GetView<LoginController> {
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<LoginController>(
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<LoginController> {
),
SizedBox(width: 12.w),
Expanded(
child: TextField(
controller: controller.verificationCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: InputDecoration(
hintText: 'verificationCode'.tr, // 验证码
hintStyle: TextStyle(
child: GetBuilder<LoginController>(
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,
),
),
),

2
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文件

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

13
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<Int>("sampleRate") ?: 16000
val channels = call.argument<Int>("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<String, Any>) {
// 不处理设备信息
}
}
}

299
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<ByteArray>()
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<ByteArray>()
private val rightChannelQueue = LinkedBlockingQueue<ByteArray>()
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<String, Int> {
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
}
}

50
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音频配置设置完成")
}
/**
* 开始音频输入
*/

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

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

126
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<ScanResult>()
@ -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<ByteArray>()
private var audioQueueProcessorThread: Thread? = null
// 音频数据缓存
private val audioDataBuffer = mutableListOf<Byte>()
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()
}
}
}

Loading…
Cancel
Save