Selaa lähdekoodia

优化数字人语音播报电流问题

liyanbo 3 kuukautta sitten
vanhempi
sitoutus
0ab669a78a
2 muutettua tiedostoa jossa 111 lisäystä ja 39 poistoa
  1. 11 22
      package-lock.json
  2. 100 17
      src/api/tts/useAudioPlayer.js

+ 11 - 22
package-lock.json

@@ -133,7 +133,6 @@
       "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.0.tgz",
       "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
         "@babel/code-frame": "^7.27.1",
@@ -1628,7 +1627,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=18"
       },
@@ -1652,7 +1650,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -2425,6 +2422,7 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz",
       "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@jridgewell/gen-mapping": "^0.3.5",
         "@jridgewell/trace-mapping": "^0.3.25"
@@ -3066,7 +3064,6 @@
       "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
       "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@types/lodash": "*"
       }
@@ -3095,7 +3092,6 @@
       "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
       "devOptional": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "undici-types": "~7.8.0"
       }
@@ -3437,7 +3433,6 @@
       "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "license": "MIT",
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -3710,7 +3705,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "caniuse-lite": "^1.0.30001726",
         "electron-to-chromium": "^1.5.173",
@@ -3746,7 +3740,8 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
@@ -3961,7 +3956,8 @@
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/concat-map": {
       "version": "0.0.1",
@@ -4397,7 +4393,6 @@
       "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.12.1",
@@ -5095,7 +5090,6 @@
       "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz",
       "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@alcalzone/ansi-tokenize": "^0.2.1",
         "ansi-escapes": "^7.2.0",
@@ -5648,15 +5642,13 @@
       "version": "4.17.21",
       "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash-es": {
       "version": "4.17.21",
       "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
       "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash-unified": {
       "version": "1.0.3",
@@ -6198,7 +6190,6 @@
       "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz",
       "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -6320,7 +6311,6 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
       "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -6581,7 +6571,6 @@
       "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
       "devOptional": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "chokidar": "^4.0.0",
         "immutable": "^5.0.2",
@@ -6687,6 +6676,7 @@
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
       "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
       "license": "BSD-3-Clause",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -6705,6 +6695,7 @@
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
       "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "buffer-from": "^1.0.0",
         "source-map": "^0.6.0"
@@ -7105,7 +7096,6 @@
       "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.21.5.tgz",
       "integrity": "sha512-WRq86tXZKrThA9mK+IR+v4tIQVVvnb5LhvL71fD2AX7TxVOPdaeK1X/wyuUruBqWaOG3w2sZXoMY6HF2Jlo9qA==",
       "license": "Apache-2.0",
-      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.12.5",
         "@videojs/http-streaming": "2.16.2",
@@ -7345,7 +7335,6 @@
       "resolved": "https://registry.npmmirror.com/vite/-/vite-7.0.0.tgz",
       "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.6",
@@ -7420,7 +7409,6 @@
       "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.17.tgz",
       "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@vue/compiler-dom": "3.5.17",
         "@vue/compiler-sfc": "3.5.17",
@@ -7443,7 +7431,6 @@
       "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
       "hasInstallScript": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "vue-demi-fix": "bin/vue-demi-fix.js",
         "vue-demi-switch": "bin/vue-demi-switch.js"
@@ -7470,6 +7457,7 @@
       "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "debug": "^4.4.0",
         "eslint-scope": "^8.2.0",
@@ -7494,6 +7482,7 @@
       "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
       "dev": true,
       "license": "ISC",
+      "peer": true,
       "bin": {
         "semver": "bin/semver.js"
       },

+ 100 - 17
src/api/tts/useAudioPlayer.js

@@ -3,20 +3,59 @@ export function useAudioPlayer() {
     let audioQueue = [];
     let isPlaying = false;
     let currentTime = 0; // 当前播放时间(用于连续播放)
-    const SAMPLE_RATE = 16000; // 匹配后端采样率
+    const TARGET_SAMPLE_RATE = 16000; // 目标采样率(匹配后端)
     const CHANNELS = 1; // 单声道
     const BIT_DEPTH = 16; // 16位深
+    const FADE_DURATION = 0.01; // 淡入淡出持续时间(秒)
+    let actualSampleRate = TARGET_SAMPLE_RATE; // 实际使用的采样率
 
     // 初始化AudioContext
     const initAudioContext = () => {
         if (!audioContext) {
-            audioContext = new (window.AudioContext || window.webkitAudioContext)({
-                sampleRate: SAMPLE_RATE
-            });
+            try {
+                // 尝试创建指定采样率的AudioContext
+                audioContext = new (window.AudioContext || window.webkitAudioContext)({
+                    sampleRate: TARGET_SAMPLE_RATE
+                });
+            } catch (e) {
+                // 如果浏览器不支持指定的采样率,使用默认采样率
+                console.warn(`浏览器不支持${TARGET_SAMPLE_RATE}Hz采样率,使用默认采样率:`, e);
+                audioContext = new (window.AudioContext || window.webkitAudioContext)();
+            }
+            actualSampleRate = audioContext.sampleRate;
+            console.log(`使用的音频采样率: ${actualSampleRate}Hz`);
             currentTime = 0; // 重置播放时间
         }
     };
 
+    // 添加淡入淡出效果,减少音频块过渡时的突变噪声
+    const applyFadeInOut = (audioBuffer) => {
+        const channelData = audioBuffer.getChannelData(0);
+        const fadeSamples = Math.floor(FADE_DURATION * audioBuffer.sampleRate);
+
+        // 淡入效果
+        for (let i = 0; i < fadeSamples && i < channelData.length; i++) {
+            channelData[i] *= (i / fadeSamples);
+        }
+
+        // 淡出效果
+        for (let i = channelData.length - fadeSamples; i < channelData.length; i++) {
+            if (i >= 0) {
+                channelData[i] *= ((channelData.length - i) / fadeSamples);
+            }
+        }
+    };
+
+    // 创建低通滤波器,减少高频噪声
+    const createLowPassFilter = () => {
+        const filter = audioContext.createBiquadFilter();
+        filter.type = 'lowpass';
+        filter.frequency.value = 4000; // 4kHz截止频率
+        filter.Q.value = 0.707; // 巴特沃斯滤波器
+        filter.connect(audioContext.destination);
+        return filter;
+    };
+
     // 播放音频块(支持流式PCM)
     const playAudioChunk = async (base64Audio) => {
         initAudioContext();
@@ -45,52 +84,96 @@ export function useAudioPlayer() {
             if (currentTime === 0) {
                 // 解码完整WAV文件(仅首次)
                 const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
+                applyFadeInOut(audioBuffer); // 添加淡入淡出
                 playBuffer(audioBuffer);
                 currentTime += audioBuffer.duration; // 更新播放时间
             }
             // 2. 处理后续PCM分片(无文件头)
             else {
+                // 确保数据是16位PCM格式
+                if (audioData.length % 2 !== 0) {
+                    // 如果数据长度不是偶数,可能存在问题,丢弃最后一个字节
+                    audioData = audioData.slice(0, audioData.length - 1);
+                }
+
                 // 将16位PCM字节转换为Float32Array(AudioContext要求格式)
                 const float32Data = convertPCMToFloat32(audioData);
+
+                // 如果实际采样率与目标采样率不一致,进行重采样
+                const resampledData = actualSampleRate !== TARGET_SAMPLE_RATE
+                    ? resampleAudio(float32Data, TARGET_SAMPLE_RATE, actualSampleRate)
+                    : float32Data;
+
                 // 创建音频缓冲区
-                const audioBuffer = audioContext.createBuffer(CHANNELS, float32Data.length, SAMPLE_RATE);
-                audioBuffer.copyToChannel(float32Data, 0); // 复制到音频通道
+                const audioBuffer = audioContext.createBuffer(CHANNELS, resampledData.length, actualSampleRate);
+                audioBuffer.copyToChannel(resampledData, 0); // 复制到音频通道
+
+                applyFadeInOut(audioBuffer); // 添加淡入淡出效果
                 playBuffer(audioBuffer);
                 currentTime += audioBuffer.duration; // 更新播放时间
             }
         } catch (error) {
-            console.error('音频处理失败:', error);
+            console.error("音频处理失败:", error);
             isPlaying = false;
         }
     };
 
+    // 重采样音频数据以匹配AudioContext的采样率
+    const resampleAudio = (inputData, inputSampleRate, outputSampleRate) => {
+        const inputLength = inputData.length;
+        const outputLength = Math.floor(inputLength * (outputSampleRate / inputSampleRate));
+        const outputData = new Float32Array(outputLength);
+
+        for (let i = 0; i < outputLength; i++) {
+            const inputIndex = i * (inputSampleRate / outputSampleRate);
+            const index1 = Math.floor(inputIndex);
+            const index2 = Math.min(index1 + 1, inputLength - 1);
+            const fraction = inputIndex - index1;
+
+            // 线性插值
+            outputData[i] = inputData[index1] * (1 - fraction) + inputData[index2] * fraction;
+        }
+
+        return outputData;
+    };
+
     // 播放音频缓冲区并调度下一个分片
     const playBuffer = (audioBuffer) => {
         const source = audioContext.createBufferSource();
         source.buffer = audioBuffer;
-        source.connect(audioContext.destination);
-        source.start(currentTime); // 从当前时间点开始播放
+
+        // 添加低通滤波器减少噪声
+        const filter = createLowPassFilter();
+        source.connect(filter);
+
+        // 确保从正确的时间点开始播放,处理可能的微小偏差
+        const scheduledTime = Math.max(currentTime, audioContext.currentTime);
+        source.start(scheduledTime);
+
         // 播放结束后继续处理队列
         source.onended = processAudioQueue;
     };
 
     // 将16位PCM字节转换为Float32Array([-1.0, 1.0]范围)
     const convertPCMToFloat32 = (bytes) => {
-        const int16Array = new Int16Array(bytes.buffer);
-        const float32Array = new Float32Array(int16Array.length);
-        for (let i = 0; i < int16Array.length; i++) {
-            float32Array[i] = int16Array[i] / 32768; // 16位PCM最大值为32767
+        // 创建DataView确保正确处理小端字节序
+        const dataView = new DataView(bytes.buffer);
+        const float32Array = new Float32Array(bytes.length / 2); // 16位PCM,每2字节一个样本
+
+        for (let i = 0; i < float32Array.length; i++) {
+            // 使用getInt16方法并指定littleEndian=true来确保正确的字节序
+            const int16 = dataView.getInt16(i * 2, true);
+            float32Array[i] = int16 / 32768; // 16位PCM最大值为32767,除以32768可得到[-1, 1)范围
         }
+
         return float32Array;
     };
 
     // 停止播放并清理
     const stopPlayback = () => {
         if (audioContext) {
-            audioContext.close().then(() => {
-                audioContext = null;
-                currentTime = 0; // 重置播放时间
-            });
+            // 不关闭AudioContext,只重置播放状态
+            currentTime = 0; // 重置播放时间
         }
         audioQueue = [];
         isPlaying = false;