You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift中WebSocket音频流无缝实时播放:解决播放间隙问题

解决WebSocket音频块播放间隙的无缝实时播放方案

核心问题分析

当前代码出现块间静音间隙的主要原因包括:

  • 未使用精准的播放时间调度,系统无法无缝衔接前后音频buffer
  • 解码过程未保留连续流的解码器上下文,导致块间解码不连续
  • 播放启动时机与buffer调度不同步,可能导致初始或后续衔接断流

具体修复步骤

1. 保留解码器上下文,确保连续解码

对于AAC、MP3等编码格式,解码器需要维持上下文状态来处理连续的音频块,不能每次解码都新建实例。以下是基于AVAudioConverter的连续解码实现:

// 添加解码器相关属性
private var audioConverter: AVAudioConverter?
private var inputAudioFormat: AVAudioFormat? // 输入音频编码格式(如AAC)

private func decodeAudioData(_ chunk: Data) -> AVAudioPCMBuffer? {
    // 初始化输入格式(示例为AAC,需根据实际编码调整)
    if inputAudioFormat == nil {
        var asbd = AudioStreamBasicDescription(
            mSampleRate: 44100.0,
            mFormatID: kAudioFormatMPEG4AAC,
            mFormatFlags: 0,
            mBytesPerPacket: 0,
            mFramesPerPacket: 1024,
            mBytesPerFrame: 0,
            mChannelsPerFrame: 2,
            mBitsPerChannel: 0,
            mReserved: 0
        )
        inputAudioFormat = AVAudioFormat(streamDescription: &asbd)
    }
    
    guard let inputFormat = inputAudioFormat, let outputFormat = outputFormat else {
        NSLog("[AudioStreaming] Invalid audio format")
        return nil
    }
    
    // 初始化转换器(仅首次创建)
    if audioConverter == nil {
        audioConverter = AVAudioConverter(from: inputFormat, to: outputFormat)
    }
    guard let converter = audioConverter else { return nil }
    
    // 将二进制数据转换为输入PCM Buffer
    let inputBuffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: AVAudioFrameCount(chunk.count))!
    chunk.copyBytes(to: inputBuffer.floatChannelBuffers![0], count: chunk.count)
    inputBuffer.frameLength = inputBuffer.frameCapacity
    
    // 准备输出PCM Buffer
    let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: AVAudioFrameCount(
        inputBuffer.frameLength * outputFormat.sampleRate / inputFormat.sampleRate
    ))!
    
    // 执行转换
    let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
        outStatus.pointee = .haveData
        return inputBuffer
    }
    
    var error: NSError?
    let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
    
    guard status == .haveData, error == nil else {
        NSLog("[AudioStreaming] Decode failed: \(error?.localizedDescription ?? "unknown")")
        return nil
    }
    
    return outputBuffer
}

2. 精准调度音频Buffer的播放时间

通过player.lastRenderTime和playerTime计算下一个Buffer的准确播放起始时间,确保无缝衔接:

private func addToBuffer(buffer: AVAudioPCMBuffer) {
    guard buffer.format.isEqual(outputFormat) else {
        NSLog("[AudioStreaming] Format mismatch: \(buffer.format) vs \(outputFormat!)")
        return
    }
    
    buffersInQueue += 1
    
    // 计算无缝衔接的播放时间点
    let targetPlayTime: AVAudioTime?
    if let lastRenderTime = player.lastRenderTime, let playerTime = player.playerTime(forNodeTime: lastRenderTime) {
        // 基于当前播放进度计算下一个buffer的起始时间
        let nextSampleTime = playerTime.sampleTime + AVAudioFramePosition(buffer.frameLength)
        targetPlayTime = AVAudioTime(sampleTime: nextSampleTime, atRate: playerTime.sampleRate)
    } else {
        // 首次播放或无法获取播放进度时,立即调度
        targetPlayTime = nil
    }
    
    // 调度buffer并设置完成回调
    player.scheduleBuffer(buffer, at: targetPlayTime, options: .interruptsAtLoopEnd) { [weak self] in
        DispatchQueue.main.async {
            guard let self = self else { return }
            self.buffersInQueue -= 1
            print("Chunk played, remaining queue count: \(self.buffersInQueue)")
            
            if self.buffersInQueue == 0 {
                print("Last chunk played")
            }
        }
    }
}

3. 优化播放启动逻辑

确保第一个Buffer已完成调度后再启动播放,避免启动时无数据导致的间隙:

func receiveMessage() {
    webSocketTask?.receive { [weak self] result in
        switch result {
        case .failure(let error):
            print("WebSocket error: \(error)")
        
        case .success(let message):
            switch message {
            case .data(let data):
                guard let audioBuffer = self?.decodeAudioData(data) else {
                    NSLog("[AudioStreaming] Failed to decode chunk")
                    break
                }
                self?.addToBuffer(buffer: audioBuffer)
                
                // 首次播放:确保buffer已调度后启动
                if !(self?.hasStartedPlaying ?? true) {
                    self?.hasStartedPlaying = true
                    // 微小延迟确保buffer已加入调度队列
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.005) {
                        self?.player.play()
                    }
                }
            case .string(let text):
                if text == "END" {
                    self?.closeConnection()
                }
            @unknown default:
                fatalError("Unknown message format")
            }
        }
        
        self?.receiveMessage()
    }
}

4. 初始化音频会话(关键前置步骤)

确保音频会话正确配置并激活,避免播放权限或模式问题:

private func setupAudioSession() {
    let session = AVAudioSession.sharedInstance()
    do {
        try session.setCategory(.playback, mode: .default, options: .mixWithOthers)
        try session.setActive(true)
    } catch {
        NSLog("[AudioStreaming] Audio session setup failed: \(error)")
    }
}

// 在初始化时调用
init() {
    setupAudioSession()
    // 初始化player、outputFormat等其他组件
}

额外优化建议

  • 维护一个预加载队列,当队列中buffer数量低于阈值(如2个)时,可适当调整接收或解码优先级,避免网络波动导致的断流
  • 处理解码失败或格式错误的情况,避免无效buffer进入调度队列导致播放异常

内容的提问来源于stack exchange,提问作者yarslvd

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 07:17:08