Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

newdev_shunjiawei
liwei1dao 1 year ago
parent
commit
de86fa29ea
  1. 65
      lib/data/services/novice_guide_service.dart
  2. 263
      lib/modules/agent/views/agent_view.dart
  3. 269
      lib/modules/home/views/home_view.dart
  4. 22
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  5. 26
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  6. 453
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

65
lib/data/services/novice_guide_service.dart

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:ble_service/ble_service.dart';
import 'package:flutter_intro/flutter_intro.dart';
@ -82,21 +83,23 @@ class NoviceGuideService extends GetxService {
'send': '小言会为你拨打电话',
'end': '小言已成功为您拨打电话,试试其他指令吧',
'method': () async {
// 拨打电话需要电话权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.phone,
permissionName: 'phone'.tr, // 电话
explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能
permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限
);
//拨打电话还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限
);
if (Platform.isAndroid) {
// 拨打电话需要电话权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.phone,
permissionName: 'phone'.tr, // 电话
explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能
permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限
);
//拨打电话还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限
);
}
},
},
{
@ -105,21 +108,23 @@ class NoviceGuideService extends GetxService {
'send': '小言会为你发送短信',
'end': '短信已发送,语音操控更便捷',
'method': () async {
// 发送短信需要短信权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.sms,
permissionName: 'sms'.tr, // 短信
explanationText: 'smsPermissionDesc'.tr, // 用于发送短信功能
permanentDenialText: 'smsPermissionDetailDesc'.tr, // 发送短信功能需要短信权限
);
//发送短信还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限
);
if (Platform.isAndroid) {
// 发送短信需要短信权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.sms,
permissionName: 'sms'.tr, // 短信
explanationText: 'smsPermissionDesc'.tr, // 用于发送短信功能
permanentDenialText: 'smsPermissionDetailDesc'.tr, // 发送短信功能需要短信权限
);
//发送短信还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限
);
}
},
},
];

263
lib/modules/agent/views/agent_view.dart

@ -196,17 +196,7 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
IntroStepBuilder(
group: 'ai',
order: 3,
getOverlayPosition: ({
required Offset offset,
required Size screenSize,
required Size size,
}) {
return OverlayPosition(
top: 200.h,
width: screenSize.width,
crossAxisAlignment: CrossAxisAlignment.end,
);
},
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
@ -252,6 +242,8 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
? IntroStepBuilder(
group: 'ai',
order: 4,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
@ -413,8 +405,7 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
SizedBox(height: 12.h),
],
);
} else if (controller.isTextInputMode.value &&
!controller.hasTextInput.value) {
} else {
// 文本输入模式下,且输入框为空
return Column(
children: [
@ -457,8 +448,6 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
SizedBox(height: 12.h),
],
);
} else {
return const SizedBox.shrink();
}
},
),
@ -514,6 +503,8 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
return IntroStepBuilder(
group: 'ai',
order: 1,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
@ -759,149 +750,143 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
SizedBox(width: 8.w),
// 动态按钮区域:展开按钮 + 发送按钮 或 图片选择按钮
Builder(builder: (context) {
StepWidgetParams? widgetParams;
return IntroStepBuilder(
group: 'ai',
order: 2,
onHighlightWidgetTap: () async {
await controller
.noviceGuideService.aiStepsList[
controller.noviceGuideService.aiStep]
['method']();
widgetParams!.onNext!();
controller.sendMessage();
},
overlayBuilder: (params) {
widgetParams = params;
return IntroOverlayBuilder.aiBuilder(
params,
'点击发送,${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['send']}',
alignment: false,
);
},
builder:
(BuildContext context, GlobalKey key) =>
Obx(
key: key,
() {
final hasText =
controller.hasTextInput.value;
final hasImage =
controller.isImageInputActive.value;
final shouldShowSend = hasText || hasImage;
final shouldShowExpand =
controller.shouldShowExpandButton.value;
IntroStepBuilder(
group: 'ai',
order: 2,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'点击发送,${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['send']}',
alignment: false,
onTap: () async {
await controller
.noviceGuideService.aiStepsList[
controller.noviceGuideService
.aiStep]['method']();
params.onNext!();
controller.sendMessage();
},
);
},
builder: (BuildContext context, GlobalKey key) =>
Obx(
key: key,
() {
final hasText = controller.hasTextInput.value;
final hasImage =
controller.isImageInputActive.value;
final shouldShowSend = hasText || hasImage;
final shouldShowExpand =
controller.shouldShowExpandButton.value;
if (shouldShowSend) {
// 显示发送按钮(可能还有展开按钮)
return Column(
children: [
// 展开按钮(在发送按钮上方)
if (shouldShowExpand && hasText)
Padding(
padding:
EdgeInsets.only(bottom: 40.h),
child: GestureDetector(
onTap: () {
// 打开底部弹出编辑模式
if (shouldShowSend) {
// 显示发送按钮(可能还有展开按钮)
return Column(
children: [
// 展开按钮(在发送按钮上方)
if (shouldShowExpand && hasText)
Padding(
padding:
EdgeInsets.only(bottom: 40.h),
child: GestureDetector(
onTap: () {
// 打开底部弹出编辑模式
controller.openFullScreenEdit();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor:
Colors.transparent,
builder: (context) =>
const FullScreenEditor(),
).then((_) {
// 弹出页关闭时的回调
controller
.openFullScreenEdit();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor:
Colors.transparent,
builder: (context) =>
const FullScreenEditor(),
).then((_) {
// 弹出页关闭时的回调
controller
.closeFullScreenEdit();
});
},
child: Container(
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[700]
: Colors.grey[300],
borderRadius:
BorderRadius.circular(
16.w),
),
child: Icon(
Icons.expand_less,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 20.sp,
),
.closeFullScreenEdit();
});
},
child: Container(
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[700]
: Colors.grey[300],
borderRadius:
BorderRadius.circular(
16.w),
),
child: Icon(
Icons.expand_less,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 20.sp,
),
),
),
// 发送按钮
GestureDetector(
onTap: () =>
controller.sendMessage(),
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: primaryColor,
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
Icons.send,
color: Colors.white,
size: 22.sp,
),
),
),
],
);
} else {
// 显示'+'选择按钮
return Obx(() {
final isExpanded = controller
.isPlusOptionsExpanded.value;
return GestureDetector(
onTap: () {
controller.togglePlusOptions();
},
// 发送按钮
GestureDetector(
onTap: () => controller.sendMessage(),
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
color: primaryColor,
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
isExpanded
? Icons.close
: Icons.add,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
Icons.send,
color: Colors.white,
size: 22.sp,
),
),
);
});
}
},
),
);
}),
),
],
);
} else {
// 显示'+'选择按钮
return Obx(() {
final isExpanded = controller
.isPlusOptionsExpanded.value;
return GestureDetector(
onTap: () {
controller.togglePlusOptions();
},
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
isExpanded
? Icons.close
: Icons.add,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
),
);
});
}
},
),
),
],
),

269
lib/modules/home/views/home_view.dart

@ -72,86 +72,72 @@ class HomeView extends GetView<HomeController> {
@override
Widget build(BuildContext context) {
// 预缓存图片
precacheImage(
const AssetImage('assets/images/headphone_new3.png'), context);
precacheImage(
const AssetImage('assets/images/headphone_new4.png'), context);
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : const Color(0xFFF9FAFC),
body: SafeArea(
child: Padding(
child: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部标题栏
_buildAppBar(isDarkMode),
// 可变高度的顶部图片区域 - 使用Expanded让它占用剩余空间
Expanded(
child: Column(
children: [
Obx(() => _buildAdaptiveEarbudsImage()),
SizedBox(height: 20.h),
// 设备状态卡片
IntroStepBuilder(
group: 'home',
order: 1,
onWidgetLoad: () {
controller.noviceGuideService.startHomeGuide();
},
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言',
onNext: () {
controller.noviceGuideService.startWakeupListen();
},
);
},
builder: (BuildContext context, GlobalKey key) =>
SizedBox(
key: key,
child: _buildAnimatedDeviceStatusCard(isDarkMode),
),
),
SizedBox(height: 20.h),
IntroStepBuilder(
group: 'home',
order: 2,
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
params,
controller.noviceGuideService.isWakeup.value,
onNext: () {
controller.noviceGuideService.closeWakeupListen();
},
),
);
// 顶部图片
_buildEarbudsImage(),
SizedBox(height: 20.h),
// 设备状态卡片
IntroStepBuilder(
group: 'home',
order: 1,
onWidgetLoad: () {
controller.noviceGuideService.startHomeGuide();
},
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言',
onNext: () {
controller.noviceGuideService.startWakeupListen();
},
);
},
builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key,
child: _buildDeviceStatusCard(isDarkMode),
),
),
SizedBox(height: 20.h),
IntroStepBuilder(
group: 'home',
order: 2,
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
params,
controller.noviceGuideService.isWakeup.value,
onNext: () {
controller.noviceGuideService.closeWakeupListen();
},
builder: (BuildContext context, GlobalKey key) =>
SizedBox(
key: key,
),
),
],
);
},
builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key,
),
),
// 热门AI体验 - 固定在底部,基于内容的固定高度
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, // 重要:让高度基于内容
children: [
_buildSectionTitle(isDarkMode),
SizedBox(height: 16.h),
// 给AI布局一个固定的高度,或者让它基于内容自适应
_buildPremiumAILayout(),
],
),
// 热门AI体验
_buildSectionTitle(isDarkMode),
SizedBox(height: 16.h),
_buildPremiumAILayout(),
],
),
),
@ -201,47 +187,70 @@ class HomeView extends GetView<HomeController> {
);
}
// 自适应高度的耳机图片 - 根据设备连接状态动态调整高度
// 在自适应图片组件中添加RepaintBoundary
Widget _buildAdaptiveEarbudsImage() {
// 耳机图片
Widget _buildEarbudsImage() {
final bool isDarkMode = Get.isDarkMode;
final double imageHeight =
controller.isFullyConnectedRx.value ? 160.h : 220.h;
return RepaintBoundary(
// 添加重绘边界,优化动画性能
child: AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
height: imageHeight,
child: PageView(
children: [
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: Image.asset(
isDarkMode
? 'assets/images/headphone_new3.png'
: 'assets/images/headphone_new4.png',
height: imageHeight,
fit: BoxFit.contain,
),
return SizedBox(
height: 220.h,
child: PageView(
children: [
// Center(
// child: ClipRRect(
// // 添加 ClipRRect 来实现圆角
// borderRadius: BorderRadius.circular(16.r), // 使用与其他部件一致的圆角大小
// child: SizedBox(
// width: double.infinity,
// height: 200.h,
// child: Image.asset(
// isDarkMode
// ? 'assets/images/robot_light.png'
// : 'assets/images/robot_light.png',
// fit: BoxFit.fitWidth,
// ),
// ),
// ),
// ),
Center(
child: ClipRRect(
// 添加 ClipRRect
borderRadius: BorderRadius.circular(16.r),
child: Image.asset(
isDarkMode
? 'assets/images/headphone_new3.png'
: 'assets/images/headphone_new4.png',
height: 220.h,
fit: BoxFit.contain,
),
),
],
),
),
// Center(
// child: ClipRRect(
// // 添加 ClipRRect
// borderRadius: BorderRadius.circular(16.r),
// child: SizedBox(
// width: double.infinity,
// height: 200.h,
// child: Image.asset(
// isDarkMode
// ? 'assets/images/glasses_light.png'
// : 'assets/images/glasses_light.png',
// fit: BoxFit.fitWidth,
// ),
// ),
// ),
// ),
],
),
);
}
// 带动画的设备状态卡片 - 实现平滑的高度变化过渡
Widget _buildAnimatedDeviceStatusCard(bool isDarkMode) {
// 设备状态卡片
Widget _buildDeviceStatusCard(bool isDarkMode) {
return Obx(() {
// 使用连接状态作为key,强制在状态改变时重建widget
final isConnected = controller.isFullyConnectedRx.value;
return AnimatedContainer(
duration: const Duration(milliseconds: 500), // 平滑过渡动画
curve: Curves.easeInOut,
return Container(
key: ValueKey('device_status_$isConnected'),
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF1A1A1A) : Colors.white,
borderRadius: BorderRadius.circular(16.r),
@ -255,58 +264,30 @@ class HomeView extends GetView<HomeController> {
),
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
child: Column(
mainAxisSize: MainAxisSize.min, // 重要:让容器高度自适应内容
children: [
// 状态标题行
_buildDeviceStatusHeader(isDarkMode),
// 设备信息 - 使用 AnimatedSwitcher 实现内容切换动画
// 设备信息
SizedBox(height: 12.h),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Container(
key: ValueKey('device_info_$isConnected'),
child: isConnected
? _buildConnectedDeviceInfo(isDarkMode)
: _buildDisconnectedInfo(isDarkMode),
),
),
// 连接按钮 - 使用 AnimatedSwitcher 实现按钮切换动画
Obx(() {
// 移除连接失败和连接中的状态判断,直接根据是否完全连接来判断
// 使用响应式访问确保UI更新
return controller.isFullyConnectedRx.value
? _buildConnectedDeviceInfo(isDarkMode) //已连接设备信息
: _buildDisconnectedInfo(isDarkMode); //未连接提示
}),
// 连接按钮(仅在未连接时显示)
SizedBox(height: 12.h),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Container(
key: ValueKey('device_button_$isConnected'),
child: !isConnected
? _buildConnectButton()
: _buildDeviceButton(isDarkMode),
),
),
Obx(() {
// 使用响应式访问确保UI更新
if (!controller.isFullyConnectedRx.value) {
return _buildConnectButton();
} else {
return _buildDeviceButton(isDarkMode);
}
}),
],
),
);

22
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift

@ -206,12 +206,11 @@ public class AzureAsrHelper: NSObject {
audioStream = SimpleAudioReceiver()
audioStream?.initAudioRecord()
}
// 预创建音频配置
if let pushStream = audioStream?.pushAudioStream {
if let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("预初始化音频组件完成", log: log, type: .info)
}
}
}
@ -465,12 +464,17 @@ public class AzureAsrHelper: NSObject {
* 设置麦克风音频流
*/
private func setupMicrophoneStream() {
audioStream = SimpleAudioReceiver()
// 【优化】检查音频配置是否已存在
if audioConfig == nil, let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流完成", log: log, type: .info)
}
// 只有在 audioStream 为 nil 时才创建新实例
if audioStream == nil {
audioStream = SimpleAudioReceiver()
audioStream?.initAudioRecord() // 确保调用初始化
}
// 检查音频配置是否已存在
if audioConfig == nil, let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流完成", log: log, type: .info)
}
}

26
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

@ -319,31 +319,7 @@ public class AzureTtsHelper: NSObject, ITtsService {
return true
}
// /**
// * 设置音频输出设备
// */
// public func setAudioOutputDevice(_ device: AudioOutputDevice) -> Bool {
// do {
// let session = AVAudioSession.sharedInstance()
// try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth])
//
// switch device {
// case .default:
// try session.overrideOutputAudioPort(.none)
// case .speaker:
// try session.overrideOutputAudioPort(.speaker)
// case .headphones:
// try session.overrideOutputAudioPort(.none)
// }
//
// try session.setActive(true)
// return true
// } catch {
// os_log("设置音频输出设备失败: %{public}@", log: log, type: .error, error.localizedDescription)
// return false
// }
// }
//
// MARK: - 私有方法
/**

453
local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -54,11 +54,28 @@ public class SimpleAudioReceiver: NSObject {
// 初始化音频引擎
audioEngine = AVAudioEngine()
isRunning = true
// 创建新的写线程
writeThread = DispatchQueue(label: "audio.stream.writer")
startWriteThread()
// 配置音频引擎
do {
try startCapture { buffer in
// 处理实时音频数据
let channelData = buffer.floatChannelData?[0]
let frameCount = buffer.frameLength
print("麦克风启动: \(frameCount)")
print("🎵 音频数据: 帧数=\(frameCount), 声道数据=\(channelData != nil ? "有效" : "无效")")
self.writeQueue.put(self.audioBufferToData(buffer))
// 分析或传输数据...
}
} catch {
print("麦克风启动失败: \(error)")
}
}
/// 获取最佳音频格式 (iOS 通常支持标准采样率)
@ -145,6 +162,7 @@ public class SimpleAudioReceiver: NSObject {
print("写入数据长度: \(dataToWrite.count)")
self.audioDataCallback!.onAudio(dataToWrite)
}
print("写入数据长度: \(dataToWrite.count)")
try self.pushAudioStream?.write(dataToWrite)
if self.recordfile != nil {
// print("写入recordfile数据长度: \(dataToWrite.count)")
@ -173,107 +191,113 @@ public class SimpleAudioReceiver: NSObject {
// 放入队列,由写线程写入
writeQueue.put(data)
}
/**
* 开始采集音频数据
* @param bufferHandler 音频缓冲区处理回调
* @throws 音频配置或启动错误
*/
func startCapture(bufferHandler: @escaping (AVAudioPCMBuffer) -> Void) throws {
try setupAudioSession()
guard let audioEngine = audioEngine else {
throw NSError(domain: "SimpleAudioReceiver", code: -1, userInfo: [NSLocalizedDescriptionKey: "Audio engine not initialized"])
}
// 确保音频引擎完全停止
if audioEngine.isRunning {
audioEngine.stop()
// 等待一小段时间确保完全停止
Thread.sleep(forTimeInterval: 0.1)
}
// 先安全地移除已存在的 tap
removeTapSafely()
// 重置音频引擎(在获取格式之前)
audioEngine.reset()
// 重新获取输入节点和格式(reset后需要重新获取)
let inputNode = audioEngine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
print("硬件输入格式: 采样率=\(inputFormat.sampleRate)Hz, 声道数=\(inputFormat.channelCount), 格式=\(inputFormat.commonFormat.rawValue)")
// 验证格式是否有效
if inputFormat.sampleRate <= 0 || inputFormat.channelCount <= 0 {
// 如果格式无效,使用默认格式
let defaultFormat = AVAudioFormat(standardFormatWithSampleRate: 48000, channels: 1)!
print("使用默认音频格式: 采样率=\(defaultFormat.sampleRate)Hz, 声道数=\(defaultFormat.channelCount)")
inputNode.installTap(onBus: 0,
bufferSize: 1024,
format: defaultFormat) { (buffer, time) in
bufferHandler(buffer)
}
} else {
// 使用硬件原生格式
inputNode.installTap(onBus: 0,
bufferSize: 1024,
format: inputFormat) { (buffer, time) in
bufferHandler(buffer)
}
}
if #available(iOS 13.0, *) {
try inputNode.setVoiceProcessingEnabled(true)
}
audioEngine.prepare()
print("音频引擎启动成功")
}
// 私有方法:启动麦克风捕获
/**
* 启动麦克风捕获
* 优化:复用已初始化的音频引擎,减少启动延迟
* 安全地移除音频tap
* 避免在移除不存在的tap时出错
*/
private func removeTapSafely() {
guard let audioEngine = audioEngine else { return }
let inputNode = audioEngine.inputNode
// 尝试移除tap,如果没有tap也不会出错
do {
inputNode.removeTap(onBus: 0)
print("成功移除音频tap")
} catch {
// 如果没有tap可移除,这是正常的
print("移除tap时出现错误(可能没有tap存在): \(error)")
}
}
private func runMicrophoneCapture() {
do {
// 【新增】首先配置音频会话
try audioSession.setCategory(
.playAndRecord,
mode: .measurement, // 使用 measurement 模式获得最佳录音质量
options: [.allowBluetooth, .defaultToSpeaker]
)
// 【新增】请求麦克风权限(如果尚未授权)
if audioSession.recordPermission != .granted {
audioSession.requestRecordPermission { granted in
if !granted {
print("麦克风权限被拒绝")
}
}
}
// 【新增】激活音频会话(在配置音频引擎之前)
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
print("🎤 开始配置麦克风捕获")
// 检查音频引擎是否已初始化,避免重复创建
if audioEngine == nil {
audioEngine = AVAudioEngine()
print("🔧 创建新的音频引擎")
}
// 如果音频引擎正在运行,先停止
if audioEngine?.isRunning == true {
audioEngine?.stop()
print("⏹️ 停止现有音频引擎")
}
// 获取音频输入节点(麦克风)
guard let inputNode = audioEngine?.inputNode else {
throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"])
}
// 移除之前的音频处理块,避免重复添加
inputNode.removeTap(onBus: 0)
// 获取硬件支持的原始音频格式
let hardwareFormat = inputNode.inputFormat(forBus: 0)
// iOS 13+ 启用语音处理
if #available(iOS 13.0, *) {
try inputNode.setVoiceProcessingEnabled(true)
}
// 检查目标音频格式和转换器是否可用
guard let targetFormat = audioFormat, // 外部定义的期望音频格式
let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else {
throw NSError(domain: "AudioSetup", code: 2)
}
// 在输入节点上安装录音回调
inputNode.installTap(onBus: 0,
bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小
format: hardwareFormat) { // 使用原始硬件格式
[weak self] buffer, time in // 弱引用避免循环引用
// 确保实例存在且正在写入状态
guard let self = self, self._isWriting else { return }
// 创建目标格式的音频缓冲区
let convertedBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat,
// 计算转换后的帧容量(考虑采样率差异)
frameCapacity: AVAudioFrameCount(
targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate
)
)!
var error: NSError?
// 执行音频格式转换
let status = converter.convert(
to: convertedBuffer,
error: &error,
withInputFrom: { inNumPackets, outStatus in
outStatus.pointee = .haveData // 标记有数据可用
return buffer // 返回原始音频数据
}
)
// 转换成功且无错误
if status == .haveData, error == nil {
// 将音频缓冲区转换为二进制数据
let data = self.audioBufferToData(convertedBuffer)
// 将数据放入写入队列(后续处理)
self.writeQueue.put(data)
}
}
// 激活音频会话(允许录音)
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
// try startCapture { buffer in
// // 处理实时音频数据
// let channelData = buffer.floatChannelData?[0]
// let frameCount = buffer.frameLength
// print("麦克风启动: \(frameCount)")
// print("🎵 音频数据: 帧数=\(frameCount), 声道数据=\(channelData != nil ? "有效" : "无效")")
// self.writeQueue.put(self.audioBufferToData(buffer))
// // 分析或传输数据...
// }
try audioEngine?.start()
print("✅ 麦克风捕获配置完成")
try audioEngine?.start()
} catch {
print("麦克风启动失败: \(error)")
// 【新增】添加详细错误处理
@ -283,38 +307,7 @@ public class SimpleAudioReceiver: NSObject {
}
}
}
private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data {
let frameLength = Int(buffer.frameLength)
let channelCount = 1
let dataLength = frameLength * channelCount * MemoryLayout<Int16>.size
// Handle 16-bit integer format
if let int16Data = buffer.int16ChannelData {
return Data(
bytes: int16Data.pointee,
count: dataLength
)
}
// Handle float format
else if let floatData = buffer.floatChannelData {
var int16Array = [Int16](repeating: 0, count: frameLength)
let floatBuffer = floatData.pointee
for i in 0..<frameLength {
let sample = floatBuffer[i]
let clamped = max(-1.0, min(sample, 1.0))
let scaled = clamped * Float(Int16.max)
int16Array[i] = Int16(scaled)
}
return Data(
bytes: int16Array,
count: dataLength
)
}
return Data() // Fallback for unsupported formats
}
private func runExternalCapture() {
if audioSourceType == .external {
//pushAudioData(data: Data())
@ -323,114 +316,168 @@ public class SimpleAudioReceiver: NSObject {
//audioEngine?.inputNode.removeTap(onBus: 0)
}
}
/**
* 继续麦克风捕获
*/
/**
* 继续麦克风捕获
* 停止麦克风捕获
* 安全地停止音频会话和音频引擎
*/
public func resumeRecord() {
guard _isWriting==false else { return }
_isWriting = true
// 启动录音引擎
do {
print("resumeRecord")
try audioEngine?.start()
} catch {
os_log("Failed to start audio engine: %@", log: log, type: .error, error.localizedDescription)
}
}
public func stopMicrophoneCapture() {
guard _isWriting==true else { return }
_isWriting=false
audioEngine?.pause()
print("stopMicrophoneCapture")
guard _isWriting == true else { return }
_isWriting = false
// 先停止音频引擎
audioEngine?.stop()
// 然后安全地移除 tap
removeTapSafely()
print("stopMicrophoneCapture")
}
/**
* 释放音频资源
* 完全清理所有音频相关资源
*/
public func releaseAudioResources() {
print("释放了")
// 停止麦克风捕获
stopMicrophoneCapture()
// 停止音频引擎
audioEngine?.stop()
writeThread?.async {
// 关闭写入队列
writeThread?.async {
self.writeQueue.close()
}
writeThread = nil
audioEngine?.inputNode.removeTap(onBus: 0)
isRunning=false
// 安全地移除 tap
removeTapSafely()
// 重置状态
isRunning = false
audioEngine = nil
}
// 音频路由管理
public enum AudioOutputRoute {
case speaker
case receiver
case bluetooth
// MARK: - 协议定义
/**
* 将音频缓冲区转换为Data格式
* 统一处理采样率转换和格式转换
* @param buffer 音频缓冲区
* @return 转换后的音频数据
*/
private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data {
guard let floatChannelData = buffer.floatChannelData else {
return Data()
}
public func setAudioOutputRoute(_ route: AudioOutputRoute) {
let frameLength = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
let inputSampleRate = buffer.format.sampleRate
let targetSampleRate: Double = 16000
// 统一处理:只使用第一个声道(单声道),确保一致性
let firstChannelData = floatChannelData[0]
// 如果采样率不是16kHz,进行降采样
if inputSampleRate != targetSampleRate {
let ratio = inputSampleRate / targetSampleRate
let outputFrameCount = Int(Double(frameLength) / ratio)
do {
//
if self._isWriting {
print("当前正在识别")
try audioEngine?.pause()
// 先停用以避免冲突
try audioSession.setActive(false)
}
print("setAudioOutputRoutecurrent,route=\(route)")
switch route {
case .speaker:
print("setAudioOutputRoutecurrent,speaker")
// 使用扬声器时必须用videoChat模式
try audioSession.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.defaultToSpeaker]
)
try audioSession.overrideOutputAudioPort(.speaker)
var outputData = Data()
outputData.reserveCapacity(outputFrameCount * 2) // 16位 = 2字节
for outputIndex in 0..<outputFrameCount {
let inputIndex = Int(Double(outputIndex) * ratio)
if inputIndex < frameLength {
let floatSample = firstChannelData[inputIndex]
case .receiver:
// 听筒模式使用voiceChat节省资源
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth]
)
try audioSession.overrideOutputAudioPort(.none)
// 将Float32 (-1.0 to 1.0) 转换为Int16 (-32768 to 32767)
let clampedSample = max(-1.0, min(1.0, floatSample))
let int16Sample = Int16(clampedSample * 32767.0)
case .bluetooth:
// 完整蓝牙设备支持
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.overrideOutputAudioPort(.none)
// 不需要override,系统自动路由
// 转换为小端字节序
withUnsafeBytes(of: int16Sample.littleEndian) { bytes in
outputData.append(contentsOf: bytes)
}
}
self.currentRoute = route
if self._isWriting {
// 重新激活
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
try audioEngine?.start()
// writeThread?.async {
// self.writeQueue.close()
// }
}
print("音频降采样: \(inputSampleRate)Hz -> \(targetSampleRate)Hz, 帧数: \(frameLength) -> \(outputFrameCount)")
return outputData
} else {
// 采样率已经是16kHz,直接转换格式(只处理第一个声道)
var data = Data()
data.reserveCapacity(frameLength * 2) // 单声道,16位
for frame in 0..<frameLength {
let floatSample = firstChannelData[frame]
let clampedSample = max(-1.0, min(1.0, floatSample))
let int16Sample = Int16(clampedSample * 32767.0)
withUnsafeBytes(of: int16Sample.littleEndian) { bytes in
data.append(contentsOf: bytes)
}
} catch {
print("路由切换失败: \(error)")
}
print("音频格式转换: \(inputSampleRate)Hz, 帧数: \(frameLength), 输出大小: \(data.count) 字节")
return data
}
}
/**
* 设置音频会话配置
* 确保音频会话正确配置用于录音
*/
private func setupAudioSession() throws {
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth])
try audioSession.setActive(true)
// 设置首选的音频参数
try audioSession.setPreferredSampleRate(48000)
try audioSession.setPreferredInputNumberOfChannels(1)
print("音频会话配置成功: 采样率=\(audioSession.sampleRate)Hz, 输入声道=\(audioSession.inputNumberOfChannels)")
}
/**
* 继续麦克风捕获
*/
public func resumeRecord() {
guard _isWriting == false else { return }
_isWriting = true
// 启动录音引擎
do {
print("resumeRecord")
// 确保音频引擎存在且已准备好
guard let audioEngine = audioEngine else {
print("Audio engine not initialized")
return
}
if !audioEngine.isRunning {
try audioEngine.start()
}
} catch {
os_log("Failed to start audio engine: %@", log: log, type: .error, error.localizedDescription)
_isWriting = false
}
}
// 音频路由管理
public enum AudioOutputRoute {
case speaker
case receiver
case bluetooth
}
/**
* 音频数据回调接口
@ -526,5 +573,3 @@ private class LinkedBlockingQueue<T> {
}
}
// MARK: - 协议定义

Loading…
Cancel
Save