diff --git a/.hbuilderx/launch.json b/.hbuilderx/launch.json index b7f2bc25..eb3df681 100644 --- a/.hbuilderx/launch.json +++ b/.hbuilderx/launch.json @@ -7,7 +7,11 @@ "type" : "uni-app:app-android" }, { + "bundleId" : "cn.com.jlbank.aits", + "certificateFile" : "/Users/jlbank/Desktop/tra-app/certificate/ios/开发证书.p12", + "certificateProfileFile" : "/Users/jlbank/Desktop/tra-app/certificate/ios/jiaixuedev.mobileprovision", "playground" : "standard", + "runSignStatus" : 1, "type" : "uni-app:app-ios" } ] diff --git a/Ios/HBuilder-Hello/HBuilder-Hello.xcodeproj/project.xcworkspace/xcuserdata/jlbank.xcuserdatad/UserInterfaceState.xcuserstate b/Ios/HBuilder-Hello/HBuilder-Hello.xcodeproj/project.xcworkspace/xcuserdata/jlbank.xcuserdatad/UserInterfaceState.xcuserstate index 6a7028fd..083859e1 100644 Binary files a/Ios/HBuilder-Hello/HBuilder-Hello.xcodeproj/project.xcworkspace/xcuserdata/jlbank.xcuserdatad/UserInterfaceState.xcuserstate and b/Ios/HBuilder-Hello/HBuilder-Hello.xcodeproj/project.xcworkspace/xcuserdata/jlbank.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/src/common/permission.js b/src/common/permission.js index 70571bd5..2d62c0be 100644 --- a/src/common/permission.js +++ b/src/common/permission.js @@ -75,8 +75,8 @@ function judgeIosPermissionRecord() { console.log('麦克风state', state); switch (state) { case 'authorized': - console.log("麦克风权限是否开启"); - resolve() + console.log("麦克风权限已经开启"); + resolve('authorized') break; case 'denied': // ios拒绝,安卓拒绝或者未授权都是这个 if (IsAndEnv) { // 安卓二次判定 @@ -94,10 +94,10 @@ function judgeIosPermissionRecord() { // 前往权限设置页面 uni.openAppAuthorizeSetting(); } - reject() + reject(new Error('前往权限设置页面用户点击了否')) break; case 'not determined': // 表示尚未请求授权,仅 iOS 会出现。此种情况下引导用户打开系统设置,不展示开关 - resolve() + resolve('not determined') break; case 'config error': // 只有在 App 端时返回 resolve() diff --git a/src/components/touch-btn/touch-btn.vue b/src/components/touch-btn/touch-btn.vue index 3107971d..adc64619 100644 --- a/src/components/touch-btn/touch-btn.vue +++ b/src/components/touch-btn/touch-btn.vue @@ -96,12 +96,13 @@ import { wsUrl, bottomAreaTopBoundary, horizontalSplitX } from './touch-btn'; import { ref, computed, onMounted, nextTick, watch, reactive } from 'vue'; import common from '@/common/common'; - import { onHide } from '@dcloudio/uni-app'; + import { onHide, onLaunch } from '@dcloudio/uni-app'; import { getPhoneEnvBool, getToken } from '@/common/common.js'; - import { startAudioRecord, stopAudioRecord } from '@/uni_modules/ty-recording'; + import { startAudioRecord, stopAudioRecord,preRequestRecordPermission } from '@/uni_modules/ty-recording'; import { ProtocolCodec, MessageType, ControlCommand, ControlCode } from '@/pages/sparring/js/ProtocolCodec'; import permissionApi from '@/common/permission'; - + + const emit = defineEmits(['submit']); const ws = ref(null); const status = ref('voice'); @@ -166,7 +167,11 @@ // 开启语音 const startRecord = async (obj) => { try { - await permissionApi.judgeIosPermission('record'); + const state = await permissionApi.judgeIosPermission('record'); + if(state === 'not determined') { + preRequestRecordPermission(() => {}) + return; + } } catch (e) { console.error('麦克风权限申请失败:', e); return; @@ -174,53 +179,61 @@ const changedTouche = obj.touches[0]; touchPos.x = changedTouche.clientX; touchPos.y = changedTouche.clientY; - ws.value = uni.connectSocket({ - url: wsUrl + '?summary=' + getToken(), - fail: () => {}, - success: () => {} - }); - ws.value.onOpen((res) => { - console.log('ws已连接'); - popupRef.value.open(); - startAudioRecord({ - onFrame: (pcmData) => { - ws.value.send({ - data: ProtocolCodec.pack(MessageType.AUDIO_DATA, pcmData) - }); - }, - onStart: ({ startTime }) => { - console.log('开始', startTime); - }, - onStop: () => { - console.log('关闭'); - }, - onError: ({ errCode, errMsg }) => { - if (errCode === 9010001) { - console.log(errMsg); - } + popupRef.value.open(); + startAudioRecord({ + onFrame: (pcmData: any) => { + console.log('pcmData', pcmData); + // console.log('pcmData', pcmData.length); + // ws.value.send({ + // data: ProtocolCodec.pack(MessageType.AUDIO_DATA, pcmData) + // }); + }, + onStart: (res: any) => { + console.log('启动', res); + + }, + onStop: () => { + console.log('关闭'); + }, + onError: ({ errCode, errMsg }) => { + console.log('onError',errMsg); + if (errCode === 9010001) { + console.log(errMsg); } - }); - }); - ws.value.onError((err) => { - console.log('err', err); - }); - ws.value.onClose((err) => { - console.log('onClose', err); - }); - ws.value.onMessage((res) => { - try { - const { msgType, body } = ProtocolCodec.unpack(res.data); - console.log('来消息了', msgType, body); - if (msgType === MessageType.TEXT_MESSAGE) { - tempText.value = ''; - allText.value = allText.value + body; - } else if (msgType === MessageType.TIP_MESSAGE) { - tempText.value = body; - } - } catch (e) { - console.log('后端发来的信息有问题'); } }); + + + // ws.value = uni.connectSocket({ + // url: wsUrl + '?summary=' + getToken(), + // fail: () => {}, + // success: () => {} + // }); + // ws.value.onOpen((res) => { + // console.log('ws已连接'); + // popupRef.value.open(); + + // }); + // ws.value.onError((err) => { + // console.log('err', err); + // }); + // ws.value.onClose((err) => { + // console.log('onClose', err); + // }); + // ws.value.onMessage((res) => { + // try { + // const { msgType, body } = ProtocolCodec.unpack(res.data); + // console.log('来消息了', msgType, body); + // if (msgType === MessageType.TEXT_MESSAGE) { + // tempText.value = ''; + // allText.value = allText.value + body; + // } else if (msgType === MessageType.TIP_MESSAGE) { + // // tempText.value = body; + // } + // } catch (e) { + // console.log('后端发来的信息有问题'); + // } + // }); }; // 关掉遮罩层 const closePopup = () => { diff --git a/src/pages/index/dialog.vue b/src/pages/index/dialog.vue index 318194b6..b369bf04 100644 --- a/src/pages/index/dialog.vue +++ b/src/pages/index/dialog.vue @@ -163,8 +163,10 @@ onShow, onUnload, onHide, - onBackPress + onBackPress, + onLaunch } from '@dcloudio/uni-app'; + import { get_base_url, goto_login_fun diff --git a/src/pages/login/login.vue b/src/pages/login/login.vue index 10e9cae3..a32f4051 100644 --- a/src/pages/login/login.vue +++ b/src/pages/login/login.vue @@ -81,8 +81,8 @@ const socketStore = useSocketStore(); const { taskSocket } = storeToRefs(socketStore); const show_password = ref(false); - const account = ref(''); - const password = ref(''); + const account = ref('admin'); + const password = ref('123456'); const read_text_state = ref>([]); const imeHeight = ref('0px'); const back_state = ref(true); diff --git a/src/uni_modules/ty-camera/utssdk/app-ios/index.uts b/src/uni_modules/ty-camera/utssdk/app-ios/index.uts index c24f8c2c..e69de29b 100644 --- a/src/uni_modules/ty-camera/utssdk/app-ios/index.uts +++ b/src/uni_modules/ty-camera/utssdk/app-ios/index.uts @@ -1,85 +0,0 @@ -/** - * 引用 iOS 系统库,示例如下: - * import { UIDevice } from "UIKit"; - * [可选实现,按需引入] - */ - -/* 引入 interface.uts 文件中定义的变量 */ -import { MyApiOptions, MyApiResult, MyApi, MyApiSync } from '../interface.uts'; - -/* 引入 unierror.uts 文件中定义的变量 */ -import { MyApiFailImpl } from '../unierror'; - -/** - * 引入三方库 - * [可选实现,按需引入] - * - * 在 iOS 平台引入三方库有以下两种方式: - * 1、通过引入三方库framework 或者.a 等方式,需要将 .framework 放到 ./Frameworks 目录下,将.a 放到 ./Libs 目录下。更多信息[详见](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html#ios-平台原生配置) - * 2、通过 cocoaPods 方式引入,将要引入的 pod 信息配置到 config.json 文件下的 dependencies-pods 字段下。详细配置方式[详见](https://uniapp.dcloud.net.cn/plugin/uts-ios-cocoapods.html) - * - * 在通过上述任意方式依赖三方库后,使用时需要在文件中 import: - * 示例:import { LottieLoopMode } from 'Lottie' - */ - -/** - * UTSiOS 为平台内置对象,不需要 import 可直接调用其API,[详见](https://uniapp.dcloud.net.cn/uts/utsios.html) - */ - -/** - * 异步方法 - * - * uni-app项目中(vue/nvue)调用示例: - * 1、引入方法声明 import { myApi } from "@/uni_modules/uts-api" - * 2、方法调用 - * myApi({ - * paramA: false, - * complete: (res) => { - * console.log(res) - * } - * }); - * - */ -export const myApi : MyApi = function (options : MyApiOptions) { - - if (options.paramA == true) { - // 返回数据 - const res : MyApiResult = { - fieldA: 85, - fieldB: true, - fieldC: 'some message' - }; - options.success?.(res); - options.complete?.(res); - - } else { - // 返回错误 - let failResult = new MyApiFailImpl(9010001); - options.fail?.(failResult) - options.complete?.(failResult) - } - -} - -/** - * 同步方法 - * - * uni-app项目中(vue/nvue)调用示例: - * 1、引入方法声明 import { myApiSync } from "@/uni_modules/uts-api" - * 2、方法调用 - * myApiSync(true); - * - */ -export const myApiSync : MyApiSync = function (paramA : boolean) : MyApiResult { - // 返回数据,根据插件功能获取实际的返回值 - const res : MyApiResult = { - fieldA: 85, - fieldB: paramA, - fieldC: 'some message' - }; - return res; -} - -/** - * 更多插件开发的信息详见:https://uniapp.dcloud.net.cn/plugin/uts-plugin.html - */ diff --git a/src/uni_modules/ty-recording/utssdk/app-ios/index copy.uts b/src/uni_modules/ty-recording/utssdk/app-ios/index copy.uts new file mode 100644 index 00000000..32f903d1 --- /dev/null +++ b/src/uni_modules/ty-recording/utssdk/app-ios/index copy.uts @@ -0,0 +1,251 @@ +import { StartAudioRecordOptions } from '../interface.uts'; +import { StartAudioRecordFailImpl, AudioErrorCode } from '../unierror.uts'; + + +import { UTSiOS } from "DCloudUTSFoundation" +import { AVAudioEngine, AVAudioSession, AVAudioFormat, AVAudioInputNode, AVAudioPCMBuffer, AVAudioCommonFormat } from "AVFoundation"; +import { NSData, NSMutableData } from "Foundation"; +import { Int, Int16 } from 'Swift'; + +// @argumentLabel("forBus") +/** + * 音频采集状态(全局变量,保证生命周期) + */ +let isRecording = false; +/** + * 音频引擎实例(全局变量,避免异步回调中被释放) + */ +let audioEngine: AVAudioEngine | null = null; +/** + * 音频输入节点(全局变量,避免被释放) + */ +let inputNode: AVAudioInputNode | null = null; +// 全局复用缓冲区(避免每次创建新数组) +let reuseInt32Array: Int32Array | null = new Int32Array(4800); +// 新增:缓存录制的所有Int16 PCM数据 +let recordedPCMData: UInt8[] = []; +// 3. 保存完整PCM数据到文件 +const sampleRate = 16000.0; // 和采集时的目标格式一致 +const channels = 1; // 实际以采集时的声道数为准(可从inputFormat获取) + +/** + * 启动音频采集(iOS端核心实现) + * @param options 采集配置及回调 + */ +@UTSJS.keepAlive +export function startAudioRecord(options: StartAudioRecordOptions): void { + // 避免重复启动 + console.log('避免重复启动', isRecording); + // if (isRecording) return; + + try { + recordedPCMData = []; + const audioSession = AVAudioSession.sharedInstance(); + UTSiOS.try(audioSession.setCategory( + AVAudioSession.Category.playAndRecord, + mode=AVAudioSession.Mode.spokenAudio, + options= AVAudioSession.CategoryOptions.duckOthers // 可选:其他音频静音 + )); + // UTSiOS.try(audioSession.setCategory(AVAudioSession.Category.playAndRecord)); + // UTSiOS.try(audioSession.setMode(AVAudioSession.Mode.spokenAudio)); + UTSiOS.try(audioSession.setActive(true)); + audioSession.requestRecordPermission((granted: boolean) => { + // 闭包内处理权限结果(异步执行) + if (granted) { + // 权限通过:继续初始化采集逻辑 + try { + // 创建音频引擎(移到闭包内,保证权限通过后才执行) + audioEngine = AVAudioEngine.init(); + if (audioEngine === null) { // 解包:先判断是否为nil + throw new Error("音频输入节点为空,无法获取音频格式"); + } + inputNode = audioEngine!.inputNode; + if (inputNode === null) { // 解包:先判断是否为nil + throw new Error("音频输入节点为空,无法获取音频格式"); + } + const unwrappedInputNode = inputNode!; + const inputFormat = unwrappedInputNode.inputFormat(forBus= 0); + console.log("硬件原生采样率:", inputFormat.sampleRate); + console.log("硬件原生声道数:", inputFormat.channelCount); + console.log("硬件原生格式:", inputFormat.commonFormat); + + const targetFormat = AVAudioFormat.init( + commonFormat= AVAudioCommonFormat.pcmFormatInt16, // 16位整型(文档枚举值) + sampleRate= inputFormat.sampleRate, // 采样率(Double类型,符合文档定义) + channels= inputFormat.channelCount, // 声道数(AVAudioChannelCount) + interleaved= false // 是否交错(Bool,单声道无影响) + ); + unwrappedInputNode.installTap( + onBus= 0, // 总线编号,通常传 0 + bufferSize= 2048, // 缓冲区大小,常用 1024/2048 + format= targetFormat, // 音频格式 + block=(buffer: AVAudioPCMBuffer, time: any) => { + const frameLen = buffer.frameLength as number; + const bufferListRawPtr = buffer.audioBufferList; + const audioBufferList = bufferListRawPtr.pointee; // 拿到结构体本身 + const audioBufferPtr = audioBufferList.mBuffers; // AudioBuffer指针 + // // console.log(audioBufferPtr.mData); + const mData = audioBufferPtr.mData; + + // const mDataByteSize:Int = 9600; // 你之前看到的字节数 + // const uint8Ptr = mData!.bindMemory(to= UInt8.self, capacity= mDataByteSize); + // const pcmNormalArray: number[] = []; + // for (let i:Int = 0; i < mDataByteSize; i++) { + // // 直接读取指针的第i个字节,转为0-255的普通数字 + // pcmNormalArray.push(uint8Ptr[i] as number); + // } + // const halfLength:Int = 4800; + // const firstHalf = pcmNormalArray.slice(0, halfLength); + // const secondHalf = pcmNormalArray.slice(halfLength, mDataByteSize); + // options.onFrame?.(firstHalf); + // options.onFrame?.(secondHalf); + + + // options.onFrame?.(pcmNormalArray); + // let uint8 = new Uint8Array([4, 5, 8, 12]); + // let array = Array.from(uint8); + // console.log(array); + // options.onFrame?.(array); + // const audioBuffer = audioBufferPtr.pointee; // 拿到AudioBuffer结构体 + // console.log("音频数据字节数:", audioBuffer.mDataByteSize); // 比如4096(2048帧×2字节) + // console.log("声道数:", audioBuffer.mNumberChannels); // 1 + // console.log("二进制数据指针:", audioBuffer.mData); // 指向16位PCM数据的内存地址 + + // const int16Ptr = buffer.int16ChannelData; + // if (int16Ptr != null && frameLen > 0) { + // const dataPtr = int16Ptr![0]; + // const CHUNK_FRAME_SIZE = frameLen / 2; // 16位=2字节/帧 → 512帧/分片 + // // 关键2:循环拆分 9600 字节为多个小分片 + // for (let startFrame:number = 0; startFrame < frameLen; startFrame += CHUNK_FRAME_SIZE) { + // // 计算当前分片的结束帧(避免越界) + // const endFrame = Math.min(startFrame + CHUNK_FRAME_SIZE, frameLen); + // let _recordedPCMData: UInt8[] = []; + // // 只处理当前分片的帧 + // for (let i: Int = startFrame as Int; i < endFrame; i++) { + // const int16Value = dataPtr[i]; + // let byte1 = UInt8(int16Value & 0xFF); + // let byte2 = UInt8((int16Value >> 8) & 0xFF); + // _recordedPCMData.push(byte1); + // _recordedPCMData.push(byte2); + // } + // options.onFrame?.(_recordedPCMData); + // } + // } + } + ); + UTSiOS.try(audioEngine!.start()); + if (audioEngine!.isRunning) { + console.log("引擎确实处于运行状态"); + isRecording = true; + } else { + console.error("调用 start() 但引擎未运行"); + } + isRecording = true; + } catch (error) { + isRecording = false; + } + } else { + // 权限被拒绝:回调错误 + isRecording = false; + } + }); + isRecording = true; + } catch (error) { + // 初始化失败回调错误 + const err = new StartAudioRecordFailImpl(9010002); + options.onError?.(err); + // 重置状态 + return; + } +} + +/** + * 停止音频采集(配套停止逻辑,保证功能完整) + * @param options 回调配置 + */ + +export function stopAudioRecord(): void { + console.log('停止音频采集'); + if (audioEngine === null) { // 解包:先判断是否为nil + isRecording = false; + return; // 直接返回,不执行后续逻辑 + } + const _audioEngine = audioEngine! + if (_audioEngine.isRunning) { + _audioEngine.stop(); + } + if (inputNode === null) { // 解包:先判断是否为nil + isRecording = false; + return; // 直接返回,不执行后续逻辑 + } + const _inputNode = inputNode! + _inputNode.removeTap(onBus= 0); // 移除音频回调 + console.log('移除音频回调'); + + // 停用音频会话 + const audioSession = AVAudioSession.sharedInstance(); + + try { + UTSiOS.try(audioSession.setActive(false)); + }catch (e) { + console.log(e) + } + console.log('数组大小',recordedPCMData.length); + const filePath = savePCMToFile(recordedPCMData, sampleRate, channels); + console.log('filePath', filePath); + inputNode = null; + audioEngine = null; // 释放全局引擎 + isRecording = false; + +} + +/** + * 将Int16 PCM数组保存为文件(iOS沙盒路径) + * @param pcmData Int16格式的PCM数据 + * @param sampleRate 采样率 + * @param channels 声道数 + * @returns 文件路径 | null + */ +function savePCMToFile(pcmData: UInt8[], sampleRate: number, channels: number): string | null { + try { + const dataPath = UTSiOS.getDataPath(); + console.log('dataPath', dataPath); + if (dataPath === null) { + throw new Error("获取应用数据路径失败"); + } + // 2. 生成唯一文件名(避免覆盖) + const timestamp = new Date().getTime().toString(); + const filePath = dataPath + "/" + "recording_" + timestamp + ".pcm"; + const fileURL = NSURL.fileURL(withPath= filePath); + console.log(fileURL); + + // 3. 将Int16数组转为NSData(字节数据) + const byteCount = pcmData.length * 2; // 每个Int16占2字节 + const byteArray = Uint8Array.from(recordedPCMData); + const dataOptional = NSMutableData(length= byteArray.length); + // const dataOptional = NSMutableData(length= byteCount); + if (dataOptional === null) throw new Error("创建NSMutableData失败"); + const data = dataOptional!; + + for (let j:Int = 0; j < recordedPCMData.length; j++) { + // 获取当前字节值(UInt8) + const byteValue = recordedPCMData[j]; + // 将单个字节写入 NSMutableData 的指定位置 + data.replaceBytes(in = NSMakeRange(j, 1), withBytes= UTSiOS.getPointer(byteValue), length= 1); + } + + + // 4. 写入文件(UTS兼容的Swift风格) + const writeSuccess = data.write(toFile=filePath, atomically= true); + // console.log(data); + if (writeSuccess) { + console.log("PCM文件保存成功:", filePath); + return filePath; + } else { + throw new Error("文件写入失败"); + } + } catch (error) { + console.error("保存PCM文件出错:", error); + return null; + } +} diff --git a/src/uni_modules/ty-recording/utssdk/app-ios/index.uts b/src/uni_modules/ty-recording/utssdk/app-ios/index.uts index 118cff78..e1415b03 100644 --- a/src/uni_modules/ty-recording/utssdk/app-ios/index.uts +++ b/src/uni_modules/ty-recording/utssdk/app-ios/index.uts @@ -3,10 +3,10 @@ import { StartAudioRecordFailImpl, AudioErrorCode } from '../unierror.uts'; import { UTSiOS } from "DCloudUTSFoundation" -import { AVAudioEngine, AVAudioSession, AVAudioFormat, AVAudioInputNode, AVAudioPCMBuffer } from "AVFoundation"; +import { AVAudioEngine, AVAudioSession, AVAudioFormat, AVAudioInputNode, AVAudioPCMBuffer, AVAudioCommonFormat } from "AVFoundation"; import { NSData, NSMutableData } from "Foundation"; +import { Int, Int16 } from 'Swift'; -// @argumentLabel("forBus") /** * 音频采集状态(全局变量,保证生命周期) */ @@ -21,6 +21,57 @@ let audioEngine: AVAudioEngine | null = null; let inputNode: AVAudioInputNode | null = null; // 全局复用缓冲区(避免每次创建新数组) let reuseInt32Array: Int32Array | null = new Int32Array(4800); +// 新增:缓存录制的所有Int16 PCM数据 +let recordedPCMData: UInt8[] = []; +// 3. 保存完整PCM数据到文件 +const sampleRate = 16000.0; // 和采集时的目标格式一致 +const channels = 1; // 实际以采集时的声道数为准(可从inputFormat获取) + +// 新增:录音启动时间戳(毫秒) +let recordStartTime: number | null = null; +// 新增:实时时长定时器(用于更新UI) +let durationTimer: any = null; + +// 预申请权限函数(可在App启动时调用) +/** + * 预申请麦克风权限(规范版,含资源管理) + * @param callback 权限申请结果回调(可选) + */ +export function preRequestRecordPermission(callback?: (granted: boolean) => void) { + const audioSession = AVAudioSession.sharedInstance(); + + try { + // 1. 配置基础音频会话(仅为权限申请,用record类别) + UTSiOS.try(audioSession.setCategory( + AVAudioSession.Category.record, + mode=AVAudioSession.Mode.default + )); + + // 2. 激活会话(权限申请建议激活,避免部分设备弹窗不触发) + UTSiOS.try(audioSession.setActive(true)); + + // 3. 发起权限申请 + audioSession.requestRecordPermission((granted: boolean) => { + console.log("预申请麦克风权限结果:", granted); + + // 4. 权限申请完成后,立即停用会话(核心:释放资源) + try { + UTSiOS.try(audioSession.setActive( + false, + options= AVAudioSession.SetActiveOptions.notifyOthersOnDeactivation + )); + } catch (e) { + console.warn("预申请后停用音频会话失败:", e); + } + + // 5. 回调权限结果(供上层处理) + callback?.(granted); + }); + } catch (error) { + console.error("预申请麦克风权限失败:", error); + callback?.(false); + } +} /** * 启动音频采集(iOS端核心实现) @@ -30,79 +81,136 @@ let reuseInt32Array: Int32Array | null = new Int32Array(4800); export function startAudioRecord(options: StartAudioRecordOptions): void { // 避免重复启动 console.log('避免重复启动', isRecording); - // if (isRecording) return; - + if (isRecording) return; try { + recordedPCMData = new Array(); + // 获取 iOS 系统的音频会话单例对象。 const audioSession = AVAudioSession.sharedInstance(); + // 设置音频会话的类别、模式和附加选项,定义 App 的音频行为。 UTSiOS.try(audioSession.setCategory( AVAudioSession.Category.playAndRecord, mode=AVAudioSession.Mode.spokenAudio, options= AVAudioSession.CategoryOptions.duckOthers // 可选:其他音频静音 )); - // UTSiOS.try(audioSession.setCategory(AVAudioSession.Category.playAndRecord)); - // UTSiOS.try(audioSession.setMode(AVAudioSession.Mode.spokenAudio)); UTSiOS.try(audioSession.setActive(true)); + isRecording = true; audioSession.requestRecordPermission((granted: boolean) => { - // 闭包内处理权限结果(异步执行) - if (granted) { - // 权限通过:继续初始化采集逻辑 - try { - // 创建音频引擎(移到闭包内,保证权限通过后才执行) - audioEngine = AVAudioEngine.init(); - if (audioEngine === null) { // 解包:先判断是否为nil - throw new Error("音频输入节点为空,无法获取音频格式"); + if(!granted){ + try { + UTSiOS.try(audioSession.setActive(false, options= AVAudioSession.SetActiveOptions.notifyOthersOnDeactivation)); + } catch (e) { + console.warn("停用音频会话失败:", e); + } + // 2. 重置状态 + isRecording = false; + const err = new StartAudioRecordFailImpl(9010002); // 麦克风权限被永久拒绝,请前往设置开启 + options.onError?.(err) + } else { + + // 权限通过:继续初始化采集逻辑 + try { + // 创建音频引擎(移到闭包内,保证权限通过后才执行) + audioEngine = AVAudioEngine.init(); + if (audioEngine === null) { // 解包:先判断是否为nil + throw new Error("音频输入节点为空,无法获取音频格式"); + } + inputNode = audioEngine!.inputNode; + if (inputNode === null) { // 解包:先判断是否为nil + throw new Error("音频输入节点为空,无法获取音频格式"); + } + const unwrappedInputNode = inputNode!; + const inputFormat = unwrappedInputNode.inputFormat(forBus= 0); + console.log("硬件原生采样率:", inputFormat.sampleRate); + console.log("硬件原生声道数:", inputFormat.channelCount); + console.log("硬件原生格式:", inputFormat.commonFormat); + recordStartTime = new Date().getTime(); + // 1. 生成录音唯一标识(UUID,方便多录音实例管理) + const recordId = `${new Date().getTime()}-${Math.floor(Math.random() * 10000)}`; + options.onStart?.({ + recordId: recordId, // 唯一标识 + sampleRate: inputFormat.sampleRate, // 采样率 + startTime: recordStartTime, // 启动时间戳 + }); + const targetFormat = AVAudioFormat.init( + commonFormat= AVAudioCommonFormat.pcmFormatInt16, // 16位整型(文档枚举值) + sampleRate= inputFormat.sampleRate, // 采样率(Double类型,符合文档定义) + channels= inputFormat.channelCount, // 声道数(AVAudioChannelCount) + interleaved= false // 是否交错(Bool,单声道无影响) + ); + unwrappedInputNode.installTap( + onBus= 0, // 总线编号,通常传 0 + bufferSize= 2048, // 缓冲区大小,常用 1024/2048 + format= targetFormat, // 音频格式 + block=(buffer: AVAudioPCMBuffer, time: any) => { + const frameLen = buffer.frameLength as number; + const bufferListRawPtr = buffer.audioBufferList; + const audioBufferList = bufferListRawPtr.pointee; // 拿到结构体本身 + const audioBufferPtr = audioBufferList.mBuffers; // AudioBuffer指针 + // // console.log(audioBufferPtr.mData); + const mData = audioBufferPtr.mData; + + // const mDataByteSize:Int = 9600; // 你之前看到的字节数 + // const uint8Ptr = mData!.bindMemory(to= UInt8.self, capacity= mDataByteSize); + // const pcmNormalArray: number[] = []; + // for (let i:Int = 0; i < mDataByteSize; i++) { + // // 直接读取指针的第i个字节,转为0-255的普通数字 + // pcmNormalArray.push(uint8Ptr[i] as number); + // } + // const halfLength:Int = 4800; + // const firstHalf = pcmNormalArray.slice(0, halfLength); + // const secondHalf = pcmNormalArray.slice(halfLength, mDataByteSize); + // options.onFrame?.(firstHalf); + // options.onFrame?.(secondHalf); + + + // options.onFrame?.(pcmNormalArray); + // let uint8 = new Uint8Array([4, 5, 8, 12]); + // let array = Array.from(uint8); + // console.log(array); + // options.onFrame?.(array); + // const audioBuffer = audioBufferPtr.pointee; // 拿到AudioBuffer结构体 + // console.log("音频数据字节数:", audioBuffer.mDataByteSize); // 比如4096(2048帧×2字节) + // console.log("声道数:", audioBuffer.mNumberChannels); // 1 + // console.log("二进制数据指针:", audioBuffer.mData); // 指向16位PCM数据的内存地址 + + // const int16Ptr = buffer.int16ChannelData; + // if (int16Ptr != null && frameLen > 0) { + // const dataPtr = int16Ptr![0]; + // const CHUNK_FRAME_SIZE = frameLen / 2; // 16位=2字节/帧 → 512帧/分片 + // // 关键2:循环拆分 9600 字节为多个小分片 + // for (let startFrame:number = 0; startFrame < frameLen; startFrame += CHUNK_FRAME_SIZE) { + // // 计算当前分片的结束帧(避免越界) + // const endFrame = Math.min(startFrame + CHUNK_FRAME_SIZE, frameLen); + // let _recordedPCMData: UInt8[] = []; + // // 只处理当前分片的帧 + // for (let i: Int = startFrame as Int; i < endFrame; i++) { + // const int16Value = dataPtr[i]; + // let byte1 = UInt8(int16Value & 0xFF); + // let byte2 = UInt8((int16Value >> 8) & 0xFF); + // _recordedPCMData.push(byte1); + // _recordedPCMData.push(byte2); + // } + // options.onFrame?.(_recordedPCMData); + // } + // } } - inputNode = audioEngine!.inputNode; - if (inputNode === null) { // 解包:先判断是否为nil - throw new Error("音频输入节点为空,无法获取音频格式"); - } - const unwrappedInputNode = inputNode!; - const inputFormat = unwrappedInputNode.inputFormat(forBus= 0); - unwrappedInputNode.installTap( - onBus= 0, // 总线编号,通常传 0 - bufferSize= 1024, // 缓冲区大小,常用 1024/2048 - format= inputFormat, // 音频格式 - block=(buffer: AVAudioPCMBuffer, time: any) => { - const floatData = buffer.floatChannelData; - const channelData = floatData![0]; - const frameLength = buffer.frameLength as number; - const bytesPerSample = 2; // 16位整型=2字节/样本 - const totalBytes = frameLength * bytesPerSample; - - console.log(channelData); - console.log(totalBytes); - const pcmData = NSMutableData.dataWithCapacity(20); // 可变Data,支持写入 - - // const data = new Float32Array(int32Array.byteLength); - // data.set(int32Array.buffer, 1); // 内存块拷贝,替代逐字节写入 - - options.onFrame?.(pcmData); - // if (floatData !== null) { - // // 单声道取第0个通道(立体声可取[0]左声道、[1]右声道) - // const channelData = floatData[0]; - - // options.onFrame?.(channelData) - // } - } - ); - UTSiOS.try(audioEngine!.start()); - if (audioEngine!.isRunning) { - console.log("引擎确实处于运行状态"); - isRecording = true; - } else { - console.error("调用 start() 但引擎未运行"); - } - isRecording = true; - } catch (error) { - isRecording = false; - } - } else { - // 权限被拒绝:回调错误 - isRecording = false; - } + ); + UTSiOS.try(audioEngine!.start()); + if (audioEngine!.isRunning) { + console.log("引擎确实处于运行状态"); + isRecording = true; + } else { + console.error("调用 start() 但引擎未运行"); + } + isRecording = true; + } catch (error) { + isRecording = false; + } + } }); - isRecording = true; + } catch (error) { + console.log('报错了', error); // 初始化失败回调错误 const err = new StartAudioRecordFailImpl(9010002); options.onError?.(err); @@ -117,6 +225,7 @@ export function startAudioRecord(options: StartAudioRecordOptions): void { */ export function stopAudioRecord(): void { + console.log('停止音频采集'); if (audioEngine === null) { // 解包:先判断是否为nil isRecording = false; return; // 直接返回,不执行后续逻辑 @@ -131,7 +240,7 @@ export function stopAudioRecord(): void { } const _inputNode = inputNode! _inputNode.removeTap(onBus= 0); // 移除音频回调 - + console.log('移除音频回调'); // 停用音频会话 const audioSession = AVAudioSession.sharedInstance(); @@ -141,8 +250,62 @@ export function stopAudioRecord(): void { }catch (e) { console.log(e) } + // console.log('数组大小',recordedPCMData.length); + // const filePath = savePCMToFile(recordedPCMData, sampleRate, channels); + // console.log('filePath', filePath); inputNode = null; audioEngine = null; // 释放全局引擎 isRecording = false; -} \ No newline at end of file +} + +/** + * 将Int16 PCM数组保存为文件(iOS沙盒路径) + * @param pcmData Int16格式的PCM数据 + * @param sampleRate 采样率 + * @param channels 声道数 + * @returns 文件路径 | null + */ +function savePCMToFile(pcmData: UInt8[], sampleRate: number, channels: number): string | null { + try { + const dataPath = UTSiOS.getDataPath(); + console.log('dataPath', dataPath); + if (dataPath === null) { + throw new Error("获取应用数据路径失败"); + } + // 2. 生成唯一文件名(避免覆盖) + const timestamp = new Date().getTime().toString(); + const filePath = dataPath + "/" + "recording_" + timestamp + ".pcm"; + const fileURL = NSURL.fileURL(withPath= filePath); + console.log(fileURL); + + // 3. 将Int16数组转为NSData(字节数据) + const byteCount = pcmData.length * 2; // 每个Int16占2字节 + const byteArray = Uint8Array.from(recordedPCMData); + const dataOptional = NSMutableData(length= byteArray.length); + // const dataOptional = NSMutableData(length= byteCount); + if (dataOptional === null) throw new Error("创建NSMutableData失败"); + const data = dataOptional!; + + for (let j:Int = 0; j < recordedPCMData.length; j++) { + // 获取当前字节值(UInt8) + const byteValue = recordedPCMData[j]; + // 将单个字节写入 NSMutableData 的指定位置 + data.replaceBytes(in = NSMakeRange(j, 1), withBytes= UTSiOS.getPointer(byteValue), length= 1); + } + + + // 4. 写入文件(UTS兼容的Swift风格) + const writeSuccess = data.write(toFile=filePath, atomically= true); + // console.log(data); + if (writeSuccess) { + console.log("PCM文件保存成功:", filePath); + return filePath; + } else { + throw new Error("文件写入失败"); + } + } catch (error) { + console.error("保存PCM文件出错:", error); + return null; + } +} diff --git a/src/uni_modules/ty-recording/utssdk/interface.uts b/src/uni_modules/ty-recording/utssdk/interface.uts index e95f00cf..c6c92c8b 100644 --- a/src/uni_modules/ty-recording/utssdk/interface.uts +++ b/src/uni_modules/ty-recording/utssdk/interface.uts @@ -8,7 +8,6 @@ export type AudioStartRes = { recordId ?: string; // 录音唯一标识(方便多录音实例管理) sampleRate ?: number; // 采样率(如16000) - format ?: string; // 录音格式(如pcm/mp3) startTime : number; // 开启时间戳(毫秒) }; @@ -51,7 +50,7 @@ export type StartAudioRecordOptions = { onVolume ?: (volume : number) => void; // 实时音量回调(如每秒5次,按设备采集频率) // 【一次性状态回调】- 特定节点仅触发一次 - onStart ?: (res : AudioStartRes) => void; // 成功开启录音的回调(仅触发1次) + onStart ?: (res: AudioStartRes) => void; // 成功开启录音的回调(仅触发1次) onError ?: (res : AudioErrorRes) => void; // 录音错误回调(任意阶段出错都触发,仅1次) onStop ?: () => void; // 录音结束的纯状态回调(仅通知结束,无返回,1次) onFile ?: (res : AudioFileRes) => void; // 录音结束后,返回录音文件的回调(1次,核心)