voice-check-modal.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. <template>
  2. <view class="modal" v-if="visible">
  3. <view class="modal-content">
  4. <view class="modal-title">麦克风检测</view>
  5. <view class="modal-text">请朗读下面的文字</view>
  6. <!-- 语音波形动画 -->
  7. <view class="wave-container" v-if="isRecording">
  8. <view
  9. class="wave-bar"
  10. v-for="(item, index) in waveData"
  11. :key="index"
  12. :style="{ height: item + 'rpx' }"
  13. ></view>
  14. <!-- <view class="timer">{{ formatTime(recordingTime) }}</view> -->
  15. </view>
  16. <!-- 待朗读文字 -->
  17. <view class="read-text">成为奇才,就是现在,我们开始吧。</view>
  18. <!-- 按钮 -->
  19. <button class="confirm-btn" @tap="handleConfirm">
  20. {{ isRecording ? '停止录制' : '开始录制' }}
  21. </button>
  22. <view class="status-message">
  23. {{ statusMessage }}
  24. </view>
  25. </view>
  26. </view>
  27. </template>
  28. <script>
  29. const recorderManager = uni.getRecorderManager();
  30. export default {
  31. name: 'VoiceCheckModal',
  32. props: {
  33. visible: {
  34. type: Boolean,
  35. default: false
  36. }
  37. },
  38. data() {
  39. return {
  40. isRecording: false,
  41. waveData: Array(30).fill(20),
  42. recordingTime: 0,
  43. statusMessage: '点击"开始录制"按钮朗读文字',
  44. silenceCounter: 0,
  45. recordingTimer: null,
  46. volumeLevel: 0,
  47. animationTimer: null,
  48. lastVolume: 0,
  49. hasPermission: false, // 添加录音权限状态
  50. noSoundTimeout: null, // 添加无声音超时计时器
  51. recordingStarted: false, // 添加录音开始标志
  52. audioPath: null, // 添加录音文件路径
  53. platform: '', // 添加平台标识
  54. isRetrying: false // 添加重试标记
  55. }
  56. },
  57. methods: {
  58. // 检查录音权限 - 跨平台兼容版本
  59. async checkPermission() {
  60. // 获取系统信息
  61. const systemInfo = uni.getSystemInfoSync();
  62. const platform = systemInfo.platform;
  63. try {
  64. // iOS平台处理
  65. if (platform === 'ios') {
  66. // iOS使用getSystemSetting检查权限状态
  67. const res = await new Promise((resolve, reject) => {
  68. uni.getSetting({
  69. success: (result) => {
  70. if (result.authSetting['scope.record']) {
  71. resolve(true);
  72. } else {
  73. // 请求权限
  74. uni.authorize({
  75. scope: 'scope.record',
  76. success: () => resolve(true),
  77. fail: (err) => reject(err)
  78. });
  79. }
  80. },
  81. fail: (err) => reject(err)
  82. });
  83. });
  84. this.hasPermission = true;
  85. return true;
  86. }
  87. // 安卓和鸿蒙平台处理
  88. else {
  89. const res = await uni.authorize({
  90. scope: 'scope.record'
  91. });
  92. this.hasPermission = true;
  93. return true;
  94. }
  95. } catch (error) {
  96. console.error('权限请求失败:', error);
  97. uni.showModal({
  98. title: '提示',
  99. content: '需要录音权限才能进行录音,请在设置中开启录音权限',
  100. confirmText: '去设置',
  101. success: (res) => {
  102. if (res.confirm) {
  103. uni.openSetting();
  104. }
  105. }
  106. });
  107. this.hasPermission = false;
  108. return false;
  109. }
  110. },
  111. async startRecording() {
  112. // 检查权限
  113. if (!await this.checkPermission()) {
  114. return;
  115. }
  116. this.isRecording = true;
  117. this.recordingTime = 0;
  118. this.silenceCounter = 0;
  119. this.statusMessage = '正在检测声音...';
  120. this.waveData = this.waveData.map(() => 20);
  121. this.lastVolume = 0;
  122. this.recordingStarted = true;
  123. // 获取系统信息以适配不同平台
  124. const systemInfo = uni.getSystemInfoSync();
  125. const platform = systemInfo.platform;
  126. // 根据不同平台配置录音参数
  127. const recorderOptions = {
  128. duration: 60000, // 最长录音时间
  129. format: 'mp3', // 使用mp3格式提高兼容性
  130. frameSize: 5
  131. };
  132. // iOS平台特殊处理
  133. if (platform === 'ios') {
  134. // iOS推荐参数
  135. recorderOptions.sampleRate = 44100;
  136. recorderOptions.numberOfChannels = 1;
  137. recorderOptions.encodeBitRate = 96000;
  138. } else {
  139. // 安卓和鸿蒙平台参数
  140. recorderOptions.sampleRate = 16000;
  141. recorderOptions.numberOfChannels = 1;
  142. recorderOptions.encodeBitRate = 48000;
  143. }
  144. // 开始录音
  145. recorderManager.start(recorderOptions);
  146. // 计时器
  147. this.recordingTimer = setInterval(() => {
  148. this.recordingTime++;
  149. }, 1000);
  150. // 动画定时器
  151. this.animationTimer = setInterval(() => {
  152. if (this.isRecording) {
  153. this.updateWaveform();
  154. }
  155. }, 50);
  156. // 设置无声音检测超时
  157. this.noSoundTimeout = setTimeout(() => {
  158. if (this.silenceCounter > 20) {
  159. this.showNoSoundTip();
  160. }
  161. }, 3000);
  162. },
  163. stopRecording() {
  164. this.isRecording = false;
  165. this.recordingStarted = false;
  166. recorderManager.stop();
  167. clearInterval(this.recordingTimer);
  168. clearInterval(this.animationTimer);
  169. clearTimeout(this.noSoundTimeout);
  170. this.statusMessage = '录制完成!';
  171. setTimeout(() => {
  172. this.waveData = Array(30).fill(20);
  173. }, 500);
  174. // 延迟触发完成事件,确保audioPath已经设置
  175. setTimeout(() => {
  176. // 处理音频文件
  177. this.processAudioFile();
  178. // 触发完成事件
  179. this.$emit('complete', {
  180. success: true,
  181. audioPath: this.audioPath,
  182. platform: this.platform
  183. });
  184. }, 300);
  185. },
  186. // 添加音频文件处理方法
  187. processAudioFile() {
  188. if (!this.audioPath) return;
  189. // 获取系统信息
  190. const systemInfo = uni.getSystemInfoSync();
  191. this.platform = systemInfo.platform;
  192. console.log('处理音频文件:', this.audioPath, '平台:', this.platform);
  193. // 针对不同平台的音频文件处理
  194. if (this.platform === 'ios') {
  195. // iOS平台特殊处理
  196. if (!this.audioPath.startsWith('file://')) {
  197. this.audioPath = 'file://' + this.audioPath;
  198. }
  199. } else if (this.platform === 'android') {
  200. // 安卓平台特殊处理
  201. // 通常不需要特殊处理,但如果有特殊需求可以在这里添加
  202. } else if (this.platform === 'harmony') {
  203. // 鸿蒙平台特殊处理
  204. // 根据鸿蒙系统的特性进行处理
  205. }
  206. // 验证音频文件是否存在
  207. uni.getFileInfo({
  208. filePath: this.audioPath,
  209. success: (res) => {
  210. console.log('音频文件信息:', res);
  211. if (res.size === 0) {
  212. console.warn('警告: 音频文件大小为0');
  213. // 可以选择重新录制或提示用户
  214. }
  215. },
  216. fail: (err) => {
  217. console.error('获取音频文件信息失败:', err);
  218. // 处理文件不存在的情况
  219. }
  220. });
  221. },
  222. // 显示无声音提示
  223. showNoSoundTip() {
  224. if (!this.isRecording) return;
  225. uni.showModal({
  226. title: '未检测到声音',
  227. content: '请检查以下问题:\n1. 麦克风是否正常工作\n2. 是否允许使用麦克风\n3. 是否靠近麦克风说话\n4. 说话音量是否足够',
  228. showCancel: true,
  229. cancelText: '重新录制',
  230. confirmText: '继续录制',
  231. success: (res) => {
  232. if (res.cancel) {
  233. // 重新录制:停止当前录音并重新开始
  234. this.restartRecording();
  235. } else {
  236. // 继续录制:重置计数器继续当前录音
  237. this.continueRecording();
  238. }
  239. }
  240. });
  241. },
  242. // 重新录制方法
  243. restartRecording() {
  244. // 先停止当前录音
  245. recorderManager.stop();
  246. clearInterval(this.recordingTimer);
  247. clearInterval(this.animationTimer);
  248. clearTimeout(this.noSoundTimeout);
  249. // 重置所有状态
  250. this.isRecording = false;
  251. this.recordingStarted = false;
  252. this.recordingTime = 0;
  253. this.silenceCounter = 0;
  254. this.volumeLevel = 0;
  255. this.lastVolume = 0;
  256. this.waveData = Array(30).fill(20);
  257. // 短暂延迟后开始新的录音
  258. setTimeout(() => {
  259. this.startRecording();
  260. }, 500);
  261. },
  262. // 继续录制方法
  263. continueRecording() {
  264. // 重置计数器和状态,但保持录音继续
  265. this.silenceCounter = 0;
  266. this.statusMessage = '正在检测声音...';
  267. // 重新设置无声音检测超时
  268. clearTimeout(this.noSoundTimeout);
  269. this.noSoundTimeout = setTimeout(() => {
  270. if (this.silenceCounter > 20) {
  271. this.showNoSoundTip();
  272. }
  273. }, 3000);
  274. },
  275. detectAudio(int16Array) {
  276. // 获取系统信息
  277. const systemInfo = uni.getSystemInfoSync();
  278. const platform = systemInfo.platform;
  279. // 计算平均音量 - 优化算法以提高准确性
  280. let sum = 0;
  281. let peak = 0;
  282. // 使用分块处理以提高性能
  283. const blockSize = 128;
  284. for (let i = 0; i < int16Array.length; i += blockSize) {
  285. const end = Math.min(i + blockSize, int16Array.length);
  286. for (let j = i; j < end; j++) {
  287. const absValue = Math.abs(int16Array[j]);
  288. sum += absValue;
  289. peak = Math.max(peak, absValue);
  290. }
  291. }
  292. const avg = sum / int16Array.length;
  293. // 根据不同平台调整阈值
  294. let SPEAK_THRESHOLD = 500; // 默认阈值
  295. let volumeScale = 2000;
  296. if (platform === 'ios') {
  297. SPEAK_THRESHOLD = 300; // iOS通常需要更低的阈值
  298. volumeScale = 1500;
  299. } else if (platform === 'android') {
  300. SPEAK_THRESHOLD = 500; // 安卓标准阈值
  301. volumeScale = 2000;
  302. } else {
  303. // 鸿蒙或其他平台
  304. SPEAK_THRESHOLD = 400;
  305. volumeScale = 1800;
  306. }
  307. // 使用峰值和平均值的组合来计算音量级别
  308. const combinedLevel = (avg * 0.7 + peak * 0.3) / volumeScale;
  309. this.volumeLevel = this.smoothVolume(combinedLevel);
  310. // 更新状态
  311. if (avg > SPEAK_THRESHOLD || peak > SPEAK_THRESHOLD * 3) {
  312. this.statusMessage = '检测到声音:录音中...';
  313. this.silenceCounter = 0;
  314. clearTimeout(this.noSoundTimeout); // 清除无声音超时
  315. // 立即更新波形以反映实际声音
  316. this.updateWaveform(false);
  317. } else {
  318. this.silenceCounter++;
  319. if (this.silenceCounter > 10) {
  320. this.statusMessage = '未检测到声音,请靠近麦克风并说话...';
  321. // 如果持续30次都没有声音,显示提示
  322. if (this.silenceCounter === 30 && this.isRecording) {
  323. this.showNoSoundTip();
  324. }
  325. // 静音时逐渐降低波形
  326. this.updateWaveform(true);
  327. }
  328. }
  329. },
  330. // 添加音量平滑处理函数
  331. smoothVolume(newVolume) {
  332. const smoothFactor = 0.3; // 平滑系数
  333. const smoothedVolume = this.lastVolume + (newVolume - this.lastVolume) * smoothFactor;
  334. this.lastVolume = smoothedVolume;
  335. return Math.min(1, Math.max(0, smoothedVolume));
  336. },
  337. updateWaveform(isSilent = false) {
  338. // 获取系统信息以优化动画性能
  339. const systemInfo = uni.getSystemInfoSync();
  340. const platform = systemInfo.platform;
  341. // 根据平台调整动画参数
  342. let decayFactor = 0.95; // 静音时波形衰减因子
  343. let animationScale = 1.0; // 动画缩放因子
  344. if (platform === 'ios') {
  345. // iOS上动画通常需要更平滑
  346. decayFactor = 0.97;
  347. animationScale = 0.9;
  348. } else if (platform === 'android') {
  349. // 安卓标准参数
  350. decayFactor = 0.95;
  351. animationScale = 1.0;
  352. } else {
  353. // 鸿蒙或其他平台
  354. decayFactor = 0.96;
  355. animationScale = 0.95;
  356. }
  357. if (isSilent) {
  358. // 静音时波形逐渐降低
  359. this.waveData = this.waveData.map(height => Math.max(20, height * decayFactor));
  360. } else {
  361. const now = Date.now() * 0.002;
  362. // 使用当前音量级别动态调整波形
  363. const volumeEffect = this.volumeLevel * animationScale;
  364. const baseHeight = 20 + volumeEffect * 60;
  365. const waveAmplitude = volumeEffect * 40;
  366. // 更新波形数据
  367. this.waveData = this.waveData.map((currentHeight, i) => {
  368. // 使用余弦和正弦的组合来创建更流畅的波形
  369. const phase = i * 0.2 + now;
  370. const wave1 = Math.cos(phase) * 0.5;
  371. const wave2 = Math.sin(phase * 1.5) * 0.3;
  372. // 添加随机因素使波形更自然
  373. const randomFactor = Math.random() * 0.1;
  374. const combinedWave = (wave1 + wave2 + randomFactor) * volumeEffect;
  375. // 计算目标高度
  376. const targetHeight = baseHeight + combinedWave * waveAmplitude;
  377. // 平滑过渡到新高度 (LERP - 线性插值)
  378. const smoothFactor = platform === 'ios' ? 0.3 : 0.4; // iOS上更平滑
  379. const newHeight = currentHeight + (targetHeight - currentHeight) * smoothFactor;
  380. // 确保高度在合理范围内
  381. return Math.max(20, Math.min(120, newHeight));
  382. });
  383. }
  384. },
  385. handleConfirm() {
  386. if (!this.isRecording) {
  387. this.startRecording();
  388. } else {
  389. this.stopRecording();
  390. }
  391. },
  392. formatTime(seconds) {
  393. const mins = Math.floor(seconds / 60);
  394. const secs = seconds % 60;
  395. return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  396. }
  397. },
  398. mounted() {
  399. // 获取系统信息
  400. const systemInfo = uni.getSystemInfoSync();
  401. const platform = systemInfo.platform;
  402. // 监听录音错误 - 增强错误处理
  403. recorderManager.onError((error) => {
  404. console.error('录音错误:', error);
  405. // 根据不同平台处理错误
  406. let errorMessage = '录音出现错误,请检查麦克风权限或重试';
  407. // 针对iOS的特定错误处理
  408. if (platform === 'ios') {
  409. if (error.errMsg && error.errMsg.includes('authorize')) {
  410. errorMessage = 'iOS需要麦克风权限,请在设置中允许访问麦克风';
  411. } else if (error.errMsg && error.errMsg.includes('busy')) {
  412. errorMessage = '麦克风正被其他应用使用,请关闭其他使用麦克风的应用';
  413. }
  414. }
  415. // 针对安卓的特定错误处理
  416. else if (platform === 'android') {
  417. if (error.errMsg && error.errMsg.includes('permission')) {
  418. errorMessage = '安卓系统需要麦克风权限,请在设置中允许访问麦克风';
  419. }
  420. }
  421. // 针对鸿蒙的特定错误处理
  422. else if (platform === 'harmony') {
  423. errorMessage = '请确保已授予麦克风权限并重试';
  424. }
  425. uni.showModal({
  426. title: '录音错误',
  427. content: errorMessage,
  428. showCancel: false,
  429. success: () => {
  430. this.stopRecording();
  431. }
  432. });
  433. });
  434. // 监听录音开始
  435. recorderManager.onStart(() => {
  436. console.log('录音开始');
  437. this.recordingStarted = true;
  438. // 平台特定处理
  439. if (platform === 'ios') {
  440. // iOS上可能需要额外延迟来确保录音真正开始
  441. setTimeout(() => {
  442. // 如果没有检测到声音,开始模拟波形动画
  443. if (this.silenceCounter > 5) {
  444. this.updateWaveform(false);
  445. }
  446. }, 500);
  447. }
  448. });
  449. // 监听录音帧数据
  450. recorderManager.onFrameRecorded(res => {
  451. if (!res.frameBuffer || !this.recordingStarted) return;
  452. try {
  453. const int16Array = new Int16Array(res.frameBuffer);
  454. this.detectAudio(int16Array);
  455. } catch (error) {
  456. console.error('处理音频帧数据错误:', error);
  457. // 出错时使用默认波形动画
  458. this.updateWaveform(false);
  459. }
  460. });
  461. // 录音结束回调
  462. recorderManager.onStop(res => {
  463. this.audioPath = res.tempFilePath;
  464. this.isRecording = false;
  465. this.recordingStarted = false;
  466. clearInterval(this.recordingTimer);
  467. clearInterval(this.animationTimer);
  468. clearTimeout(this.noSoundTimeout);
  469. this.statusMessage = '录制完成!';
  470. // 确保在所有平台上都能正确获取录音文件
  471. if (res.tempFilePath) {
  472. console.log('录音文件路径:', res.tempFilePath);
  473. // 针对iOS的特殊处理
  474. if (platform === 'ios' && !res.tempFilePath.startsWith('file://')) {
  475. this.audioPath = 'file://' + res.tempFilePath;
  476. } else {
  477. this.audioPath = res.tempFilePath;
  478. }
  479. } else {
  480. console.error('未获取到录音文件路径');
  481. uni.showToast({
  482. title: '录音保存失败',
  483. icon: 'none'
  484. });
  485. }
  486. });
  487. // 添加中断处理 - 主要针对iOS
  488. recorderManager.onInterruptionBegin && recorderManager.onInterruptionBegin(() => {
  489. console.log('录音被中断');
  490. this.statusMessage = '录音被中断,请重试';
  491. this.stopRecording();
  492. });
  493. recorderManager.onInterruptionEnd && recorderManager.onInterruptionEnd(() => {
  494. console.log('录音中断结束');
  495. // 可以选择自动重新开始录音
  496. // this.startRecording();
  497. });
  498. },
  499. beforeDestroy() {
  500. // 停止所有计时器
  501. clearInterval(this.recordingTimer);
  502. clearInterval(this.animationTimer);
  503. clearTimeout(this.noSoundTimeout);
  504. // 确保录音停止
  505. if (this.isRecording) {
  506. recorderManager.stop();
  507. }
  508. // 移除事件监听器
  509. try {
  510. // 注意:uni-app不支持直接移除事件监听器
  511. // 但我们可以确保组件销毁时停止所有活动
  512. this.isRecording = false;
  513. this.recordingStarted = false;
  514. } catch (error) {
  515. console.error('清理资源时出错:', error);
  516. }
  517. }
  518. }
  519. </script>
  520. <style scoped>
  521. .modal {
  522. position: fixed;
  523. top: 0;
  524. left: 0;
  525. right: 0;
  526. bottom: 0;
  527. background: rgba(0, 0, 0, 0.6);
  528. display: flex;
  529. align-items: center;
  530. justify-content: center;
  531. z-index: 999;
  532. }
  533. .modal-content {
  534. width: 80%;
  535. background: #fff;
  536. border-radius: 20rpx;
  537. padding: 40rpx;
  538. text-align: center;
  539. box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.2);
  540. }
  541. .modal-title {
  542. font-size: 36rpx;
  543. font-weight: bold;
  544. margin-bottom: 20rpx;
  545. color: #0039b3;
  546. }
  547. .modal-text {
  548. font-size: 30rpx;
  549. color: #666;
  550. margin-bottom: 30rpx;
  551. }
  552. .wave-container {
  553. height: 200rpx;
  554. display: flex;
  555. align-items: center;
  556. justify-content: center;
  557. gap: 4rpx; /* 减小波形条之间的间距 */
  558. margin: 30rpx 0;
  559. background: #f8f9fa;
  560. border-radius: 15rpx;
  561. position: relative;
  562. overflow: hidden;
  563. border: 1rpx solid #eee;
  564. }
  565. .wave-bar {
  566. width: 6rpx; /* 减小波形条的宽度 */
  567. background: linear-gradient(to top, #1a73e8, #0039b3);
  568. border-radius: 3rpx;
  569. transition: height 0.1s ease-out; /* 加快过渡动画速度 */
  570. }
  571. .timer {
  572. position: absolute;
  573. top: 15rpx;
  574. right: 15rpx;
  575. background: rgba(0, 57, 179, 0.1);
  576. color: #0039b3;
  577. padding: 5rpx 15rpx;
  578. border-radius: 20rpx;
  579. font-size: 26rpx;
  580. font-weight: 500;
  581. }
  582. .read-text {
  583. background: #f8f9fa;
  584. padding: 30rpx;
  585. border-radius: 15rpx;
  586. font-size: 32rpx;
  587. margin: 30rpx 0;
  588. color: #333;
  589. line-height: 1.6;
  590. border: 1rpx solid #eee;
  591. }
  592. .confirm-btn {
  593. width: 80%;
  594. height: 80rpx;
  595. line-height: 80rpx;
  596. background: linear-gradient(90deg, #0039b3, #1a73e8);
  597. color: #fff;
  598. border-radius: 40rpx;
  599. margin-top: 30rpx;
  600. font-size: 32rpx;
  601. font-weight: 500;
  602. box-shadow: 0 5rpx 15rpx rgba(0, 57, 179, 0.3);
  603. }
  604. .status-message {
  605. font-size: 28rpx;
  606. color: #666;
  607. margin-top: 30rpx;
  608. height: 40rpx;
  609. display: flex;
  610. align-items: center;
  611. justify-content: center;
  612. }
  613. /* 添加状态消息的动画效果 */
  614. @keyframes blink {
  615. 0% { opacity: 1; }
  616. 50% { opacity: 0.5; }
  617. 100% { opacity: 1; }
  618. }
  619. .status-message.warning {
  620. color: #ff9800;
  621. animation: blink 1.5s infinite;
  622. }
  623. </style>