剑城渔翁 2025-05-29 14:37 采纳率: 40%
浏览 15

关于在苹果手机上H5音频流分段播放有爆音和顿挫感问题(可支持付费指导)

我的服务端是对接的阿里云的一个AI语音服务(不是百炼),使用的技术栈是websocket通信传输音频的blob数据,每个blob数据是一小段音频,大概半个字到一个字的量,数据大小4032Byte,也就是差不多4k,有些会大小不一;
现在遇到的问题是这样的,
我用Vue3起了一个项目,先是使用了Web Audio API来对音频进行播放,这一切都正常,我先是用电脑的浏览器播放测试,然后我用安卓手机浏览器打开测试,都正常,我后来使用苹果手机的浏览器打开测试的时候,无法播放出声音,查了一下资料才知道,苹果手机的限制问题和兼容性问题,没有直接支持播放这样的接口函数可以用的,需要自行对每段音频缓存来播放每一段音频,后来改了一下,根据每段进行播放,也做了环境判断,IOS环境和非IOS环境的播放,为了查出具体问题,我把每段音频每隔3秒进行了播放,发现在每段音频最后会出现一个小爆音,上一段和下一段衔接也不是很好,音频听起来,就有点很大的顿挫感;下面我会提供测试服务器的测试地址,和我的音频处理的核心代码:

websocket接收到音频后,会调用AudioServicer的 addAudioToBuffer函数,将接收到的音频块传入,如果有对这个技术熟悉的朋友可以付费解决,有意者直接报价

ios环境打开:safari浏览器或者微信、支付宝直接打开都可以,如果测试差别,也可以用电脑打开和安卓手机打开听效果
测试服务器地址:
https://aiu-test.szwalkplay.com/?code=test00000001

// 音频处理服务类
import { convertBitDepth } from '../utils/AudioUtil';
import config from '@/config';
import IOSAudioStreamer from './IOSAudioStreamer';

class AudioService {
  constructor() {
    this.mediaRecorder = null;
    this.audioContext = null;
    this.isRecording = false;
    this.isPlaying = false;
    this.targetSampleRate = config.audio.sampleRate;
    this.bitDepth = config.audio.bitDepth;
    this.channels = config.audio.channels;
    this.mediaSource = null;
    this.sourceBuffer = null;
    this.audioElement = null;
    this.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
    this.iosPlayer = new IOSAudioStreamer();
    this.iosPlayer.initByUserGesture();
  }

  // 初始化音频上下文
  async initAudioContext() {
    if (!this.audioContext) {
      this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
        sampleRate: this.targetSampleRate,
        latencyHint: 'interactive'
      });
      
      if (this.audioContext.state === 'suspended') {
        await this.audioContext.resume();
      }
    }
  }

  // 初始化MediaSource
  async initMediaSource() {
    if (!this.mediaSource) {
      this.mediaSource = new MediaSource();
      this.audioElement = new Audio();
      this.audioElement.src = URL.createObjectURL(this.mediaSource);
      
      this.mediaSource.addEventListener('sourceopen', () => {
        try {
          this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
        } catch (error) {
          console.error('创建 SourceBuffer 失败:', error);
        }
      });
    }
  }

  // 开始录音
  async startRecording(onDataAvailable) {
    try {
      await this.initAudioContext();
      
      const stream = await navigator.mediaDevices.getUserMedia({ 
        audio: {
          sampleRate: this.targetSampleRate,
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true
        } 
      });

      const sourceNode = this.audioContext.createMediaStreamSource(stream);
      const processorNode = this.audioContext.createScriptProcessor(4096, 1, 1);
      
      processorNode.onaudioprocess = (e) => {
        if (!this.isRecording) return;
        
        const inputBuffer = e.inputBuffer;
        const channelData = inputBuffer.getChannelData(0);
        
        const inputArray = new Array(channelData.length);
        const outputArray = new Int16Array(channelData.length);
        
        for (let i = 0; i < channelData.length; i++) {
          inputArray[i] = channelData[i];
        }
        
        convertBitDepth(
          inputArray,
          "32f",
          outputArray,
          "16"
        );
        
        onDataAvailable(outputArray.buffer);
      };

      sourceNode.connect(processorNode);
      processorNode.connect(this.audioContext.destination);
      
      this.isRecording = true;
      console.log('录音已开始');
    } catch (error) {
      console.error('录音启动失败:', error);
      throw error;
    }
  }

  // 停止录音
  stopRecording() {
    if (this.isRecording) {
      this.isRecording = false;
      
      if (this.processorNode) {
        this.processorNode.disconnect();
      }
      if (this.sourceNode) {
        this.sourceNode.disconnect();
      }
      
      if (this.sourceNode?.mediaStream) {
        this.sourceNode.mediaStream.getTracks().forEach(track => track.stop());
      }
      
      console.log('录音已停止');
    }
  }

  // 添加音频数据到缓冲区
  async addAudioToBuffer(audioData) {
    try {
      if (this.isIOS) {
        const blob = new Blob([audioData], {type: 'audio/mpeg'});
        await this.iosPlayer.feedData(blob);
      } else {
        await this.initAudioContext();
        await this.initMediaSource();
        
        const arrayBuffer = await audioData.arrayBuffer();
        
        if (this.mediaSource.readyState === 'closed') {
          this.mediaSource = new MediaSource();
          this.audioElement.src = URL.createObjectURL(this.mediaSource);
          await new Promise(resolve => {
            this.mediaSource.addEventListener('sourceopen', resolve, { once: true });
          });
          this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
        }
        
        if (this.sourceBuffer && this.sourceBuffer.updating) {
          await new Promise(resolve => {
            this.sourceBuffer.addEventListener('updateend', resolve, { once: true });
          });
        }
        
        if (this.sourceBuffer) {
          this.sourceBuffer.appendBuffer(arrayBuffer);
          
          if (this.audioElement && this.audioElement.paused) {
            try {
              await this.audioElement.play();
            } catch (error) {
              console.warn('播放失败,尝试恢复:', error);
              setTimeout(async () => {
                try {
                  await this.audioElement.play();
                } catch (retryError) {
                  console.error('重试播放失败:', retryError);
                }
              }, 100);
            }
          }
        }
      }
    } catch (error) {
      console.error('添加音频到缓冲区失败:', error);
      throw error;
    }
  }

  // 停止当前播放
  stopCurrentAudio() {
    if (this.isIOS) {
      this.iosPlayer.stop();
    } else {
      if (this.audioElement) {
        this.audioElement.pause();
        this.audioElement.currentTime = 0;
      }
      if (this.mediaSource) {
        this.mediaSource.endOfStream();
      }
      this.mediaSource = null;
      this.sourceBuffer = null;
    }
    
    this.isPlaying = false;
  }

  // 获取当前状态
  getStatus() {
    return {
      isPlaying: this.isPlaying,
      mediaSourceState: this.mediaSource?.readyState,
      sampleRate: this.audioContext?.sampleRate || this.targetSampleRate,
      bitDepth: this.bitDepth,
      channels: this.channels
    };
  }
}

export default new AudioService(); 

//IOS音频播放工具
class IOSAudioStreamer {
    constructor() {
      this.audioContext = null;
      this.audioBufferQueue = [];
      this.isPlayingBuffer = false;
      this.currentSource = null;
      this.lastEndTime = 0;
      this.fadeDuration = 0.05;
      this.overlapDuration = 0.02;
      this.targetSampleRate = 16000;
      this.isFirstPlay = true;
      this.debugMode = true;
      this.amplitudeLimit = 0.98;
    }

    // 调试日志
    log(...args) {
      if (this.debugMode) {
        console.log('[IOSAudioStreamer]', ...args);
      }
    }

    // 初始化音频上下文
    async initAudioContext() {
      if (!this.audioContext) {
        this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
          sampleRate: this.targetSampleRate,
          latencyHint: 'interactive'
        });
        
        if (this.audioContext.state === 'suspended') {
          await this.audioContext.resume();
        }
        this.log('AudioContext 初始化成功,采样率:', this.audioContext.sampleRate);
      }
    }

    // 预处理音频数据以避免爆音
    preprocessAudioBuffer(audioBuffer) {
      const numberOfChannels = audioBuffer.numberOfChannels;
      const length = audioBuffer.length;
      
      // 创建新的音频缓冲区
      const processedBuffer = this.audioContext.createBuffer(
        numberOfChannels,
        length,
        audioBuffer.sampleRate
      );

      // 处理每个通道
      for (let channel = 0; channel < numberOfChannels; channel++) {
        const inputData = audioBuffer.getChannelData(channel);
        const outputData = processedBuffer.getChannelData(channel);
        
        // 应用淡入淡出效果到整个音频段
        const fadeInLength = Math.min(length, Math.floor(this.fadeDuration * audioBuffer.sampleRate));
        const fadeOutLength = Math.min(length, Math.floor(this.fadeDuration * audioBuffer.sampleRate));
        
        // 复制并处理数据
        for (let i = 0; i < length; i++) {
          let sample = inputData[i];
          
          // 应用平滑的淡入效果
          if (i < fadeInLength) {
            // 使用余弦函数实现更平滑的淡入
            const fadeInFactor = 0.5 * (1 - Math.cos(Math.PI * i / fadeInLength));
            sample *= fadeInFactor;
          }
          
          // 应用平滑的淡出效果
          if (i > length - fadeOutLength) {
            // 使用余弦函数实现更平滑的淡出
            const fadeOutFactor = 0.5 * (1 - Math.cos(Math.PI * (length - i) / fadeOutLength));
            sample *= fadeOutFactor;
          }
          
          // 使用更平滑的振幅限制
          if (Math.abs(sample) > this.amplitudeLimit) {
            const sign = Math.sign(sample);
            const excess = Math.abs(sample) - this.amplitudeLimit;
            // 使用平滑的压缩曲线
            sample = sign * (this.amplitudeLimit + (1 - Math.exp(-excess * 2)));
          }
          
          outputData[i] = sample;
        }
      }

      return processedBuffer;
    }

    // 解码音频数据
    async decodeAudioData(arrayBuffer) {
      try {
        this.log('开始解码音频数据,大小:', arrayBuffer.byteLength);
        
        // 使用 Web Audio API 解码
        const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
        this.log('音频解码成功,时长:', audioBuffer.duration);
        
        // 预处理音频数据
        const processedBuffer = this.preprocessAudioBuffer(audioBuffer);
        
        // 检查音频数据
        let maxSample = 0;
        for (let channel = 0; channel < processedBuffer.numberOfChannels; channel++) {
          const channelData = processedBuffer.getChannelData(channel);
          for (let i = 0; i < channelData.length; i++) {
            maxSample = Math.max(maxSample, Math.abs(channelData[i]));
          }
        }
        this.log('音频缓冲区创建成功,最大样本值:', maxSample);

        return processedBuffer;
      } catch (error) {
        this.log('音频解码错误:', error);
        throw error;
      }
    }

    // 创建淡入淡出效果
    createFadeEffect(source, startTime, duration, isFadeIn = true) {
      const gainNode = this.audioContext.createGain();
      source.connect(gainNode);
      gainNode.connect(this.audioContext.destination);
      
      if (isFadeIn) {
        gainNode.gain.setValueAtTime(0, startTime);
        gainNode.gain.linearRampToValueAtTime(1, startTime + duration);
      } else {
        gainNode.gain.setValueAtTime(1, startTime);
        gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
      }

      return gainNode;
    }

    // 核心数据接收入口
    async feedData(audioData) {
      try {
        await this.initAudioContext();
        
        const arrayBuffer = await audioData.arrayBuffer();
        this.log('接收到音频数据,大小:', arrayBuffer.byteLength);
        
        const audioBuffer = await this.decodeAudioData(arrayBuffer);
        this.log('音频解码成功,时长:', audioBuffer.duration);
        
        this.audioBufferQueue.push(audioBuffer);
        
        if (!this.isPlayingBuffer) {
          this.playNextBuffer();
        }
      } catch (error) {
        this.log('处理音频数据失败:', error);
        throw error;
      }
    }

    // 播放下一个音频缓冲区
    async playNextBuffer() {
      if (this.audioBufferQueue.length === 0) {
        this.isPlayingBuffer = false;
        return;
      }

      this.isPlayingBuffer = true;
      
      const audioBuffer = this.audioBufferQueue.shift();
      const source = this.audioContext.createBufferSource();
      source.buffer = audioBuffer;
      
      const currentTime = this.audioContext.currentTime;
      let startTime;
      
      if (this.isFirstPlay) {
        startTime = currentTime + 0.1;
        this.isFirstPlay = false;
        this.log('首次播放,延迟:', 0.1);
      } else if (this.lastEndTime > 0) {
        startTime = Math.max(currentTime, this.lastEndTime - this.overlapDuration);
        this.log('连续播放,开始时间:', startTime);
      } else {
        startTime = currentTime;
        this.log('开始播放,时间:', startTime);
      }
      
      const fadeInGain = this.createFadeEffect(source, startTime, this.fadeDuration, true);
      source.gainNode = fadeInGain;
      
      const endTime = startTime + audioBuffer.duration;
      const fadeOutGain = this.createFadeEffect(source, endTime - this.fadeDuration, this.fadeDuration, false);
      
      source.start(startTime);
      this.log('音频开始播放,时长:', audioBuffer.duration);
      
      this.lastEndTime = endTime;
      this.currentSource = source;
      
      source.onended = () => {
        this.log('音频播放结束');
        source.disconnect();
        if (source.gainNode) {
          source.gainNode.disconnect();
        }
        fadeOutGain.disconnect();
        
        if (this.currentSource === source) {
          this.currentSource = null;
        }
        
        this.playNextBuffer();
      };
    }

    // 停止当前播放
    stop() {
      this.log('停止播放');
      if (this.currentSource) {
        try {
          this.currentSource.stop();
        } catch (error) {
          this.log('停止音频源失败:', error);
        }
        this.currentSource.disconnect();
        if (this.currentSource.gainNode) {
          this.currentSource.gainNode.disconnect();
        }
        this.currentSource = null;
      }
      
      if (this.audioContext) {
        this.audioContext.close();
        this.audioContext = null;
      }
      
      this.isPlayingBuffer = false;
      this.audioBufferQueue = [];
      this.lastEndTime = 0;
      this.isFirstPlay = true;
    }

    // 用户必须通过点击事件触发初始化
    initByUserGesture() {
      const btn = document.createElement('button');
      btn.style.position = 'absolute';
      btn.style.opacity = '0';
      btn.innerHTML = '激活播放';
      btn.onclick = async () => {
        this.log('用户点击激活按钮');
        await this.initAudioContext();
        document.body.removeChild(btn);
      };
      document.body.appendChild(btn);
    }
}

export default IOSAudioStreamer;


  • 写回答

5条回答 默认 最新

  • 檀越@新空间 2025-05-29 14:38
    关注

    下午好🌅🌅🌅
    本答案参考通义千问

    针对您描述的问题,苹果手机上的 H5 音频流分段播放出现爆音和顿挫感的问题,主要与 iOS 的浏览器对 Web Audio API 和 MediaSource 的兼容性和限制有关。以下是对问题的分析以及解决方案。


    问题分析

    1. iOS 的兼容性限制

      • iOS 的 Safari 浏览器对 Web Audio API 和 MediaSource 的支持有限,尤其是在音频流分段播放时,需要手动管理音频缓冲区。
      • 每段音频的加载和播放需要精确的时间控制,否则会导致爆音或顿挫感。
    2. 音频分段问题

      • 每段音频的大小约为 4KB,且数据格式可能不一致(如位深、采样率等)。如果处理不当,可能导致解码错误或时间戳不匹配。
    3. 缓冲区管理问题

      • 如果音频分段播放时,缓冲区未正确填充或时间戳设置不准确,可能会导致音频播放中断或重叠。

    解决方案

    1. 使用 IOSAudioStreamer 进行分段音频播放

    IOSAudioStreamer 是一种专门用于 iOS 平台的音频分段播放工具,可以有效解决爆音和顿挫感问题。以下是改进后的代码逻辑:

    class IOSAudioStreamer {
      constructor() {
        this.audioContext = null;
        this.audioElement = null;
        this.sourceBuffer = null;
        this.isInitialized = false;
      }
    
      async initByUserGesture() {
        if (!this.audioContext) {
          this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        }
        if (!this.audioElement) {
          this.audioElement = new Audio();
        }
      }
    
      async playAudioSegment(blob) {
        if (!this.isInitialized) {
          await this.initByUserGesture();
          this.isInitialized = true;
        }
    
        return new Promise((resolve, reject) => {
          const audioUrl = URL.createObjectURL(blob);
          const audio = new Audio(audioUrl);
    
          audio.addEventListener('canplaythrough', () => {
            audio.play().then(() => {
              resolve();
            }).catch((error) => {
              reject(error);
            });
          });
    
          audio.addEventListener('ended', () => {
            URL.revokeObjectURL(audioUrl); // 释放内存
          });
        });
      }
    }
    

    2. 改进 addAudioToBuffer 函数

    在接收音频数据时,确保每段音频的时间戳连续,并正确处理音频格式转换。

    class AudioService {
      constructor() {
        // ...其他代码...
        this.iosPlayer = new IOSAudioStreamer();
        this.iosPlayer.initByUserGesture();
      }
    
      async addAudioToBuffer(blob) {
        if (this.isIOS) {
          try {
            await this.iosPlayer.playAudioSegment(blob);
          } catch (error) {
            console.error('音频播放失败:', error);
          }
        } else {
          // 非 iOS 环境使用 Web Audio API 或 MediaSource
          // 根据您的现有实现调整代码
        }
      }
    }
    

    3. 调整音频分段播放策略

    • 时间戳同步:确保每段音频的时间戳连续,避免时间戳跳跃导致的顿挫感。
    • 音频格式统一:在发送音频数据前,确保所有音频片段的格式一致(如采样率、位深等)。
    • 缓冲区预加载:在播放当前音频片段的同时,提前加载下一音频片段,减少播放中断的可能性。

    4. 示例代码:完整流程优化

    以下是完整的优化代码示例:

    class AudioService {
      constructor() {
        this.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
        this.iosPlayer = new IOSAudioStreamer();
        this.iosPlayer.initByUserGesture();
      }
    
      async addAudioToBuffer(blob) {
        if (this.isIOS) {
          try {
            await this.iosPlayer.playAudioSegment(blob);
          } catch (error) {
            console.error('音频播放失败:', error);
          }
        } else {
          // 非 iOS 环境使用 Web Audio API 或 MediaSource
          // 根据您的现有实现调整代码
        }
      }
    }
    
    class IOSAudioStreamer {
      constructor() {
        this.audioContext = null;
        this.audioElement = null;
        this.sourceBuffer = null;
        this.isInitialized = false;
      }
    
      async initByUserGesture() {
        if (!this.audioContext) {
          this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        }
        if (!this.audioElement) {
          this.audioElement = new Audio();
        }
      }
    
      async playAudioSegment(blob) {
        if (!this.isInitialized) {
          await this.initByUserGesture();
          this.isInitialized = true;
        }
    
        return new Promise((resolve, reject) => {
          const audioUrl = URL.createObjectURL(blob);
          const audio = new Audio(audioUrl);
    
          audio.addEventListener('canplaythrough', () => {
            audio.play().then(() => {
              resolve();
            }).catch((error) => {
              reject(error);
            });
          });
    
          audio.addEventListener('ended', () => {
            URL.revokeObjectURL(audioUrl); // 释放内存
          });
        });
      }
    }
    

    注意事项

    1. 用户手势触发:iOS 浏览器要求音频播放必须由用户手势触发,因此需要在用户操作后初始化音频播放器。
    2. 性能优化:对于大量音频分段,建议使用 Web Workers 来处理音频解码和播放逻辑,以避免主线程阻塞。
    3. 调试工具:使用浏览器开发者工具(如 Chrome DevTools 或 Safari Web Inspector)检查音频播放的性能和日志。

    通过以上优化,应该能够有效解决 iOS 设备上的爆音和顿挫感问题。如果仍有问题,可以进一步排查音频数据格式或网络延迟等问题。

    评论

报告相同问题?

问题事件

  • 修改了问题 5月29日
  • 创建了问题 5月29日