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

Swift中处理后音频的保存与分享实现问题求助

iOS音频保存与分享功能故障排查与修复

问题背景

我正在修改一款iOS应用,功能是文本转语音生成音频,可调整音调和语速,之后支持用户保存并分享处理后的音频。目前录制与音频处理环节正常,但保存和分享功能存在问题。

原代码实现

func saveOutputAudio(rate: Float = 1.0, pitch: Float = 0.0, echo: Bool = false, reverb: Bool = false, completionHandler: (() -> Void)? = nil) {
    let audioFileURL = getDocumentsDirector().appendingPathComponent(fileName) as URL
    let audioFile = try! AVAudioFile(forReading: audioFileURL)
    let audioFormat = audioFile.processingFormat
    let audioFrameCount = UInt32(audioFile.length)
    let audioFileBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: audioFrameCount)!
    try! audioFile.read(into: audioFileBuffer)

    let audioEngine = AVAudioEngine()
    let audioPlayerNode = AVAudioPlayerNode()
    audioEngine.attach(audioPlayerNode)

    let changeRateEffect = AVAudioUnitTimePitch()
    changeRateEffect.rate = rate
    audioEngine.attach(changeRateEffect)

    let changePitchEffect = AVAudioUnitTimePitch()
    changePitchEffect.pitch = pitch
    audioEngine.attach(changePitchEffect)

    let echoEffect = AVAudioUnitDelay()
    echoEffect.wetDryMix = echo ? 50 : 0
    audioEngine.attach(echoEffect)

    let reverbEffect = AVAudioUnitReverb()
    reverbEffect.wetDryMix = reverb ? 50 : 0
    audioEngine.attach(reverbEffect)

    audioEngine.connect(audioPlayerNode, to: changeRateEffect, format: audioFormat)
    audioEngine.connect(changeRateEffect, to: changePitchEffect, format: audioFormat)
    audioEngine.connect(changePitchEffect, to: echoEffect, format: audioFormat)
    audioEngine.connect(echoEffect, to: reverbEffect, format: audioFormat)
    audioEngine.connect(reverbEffect, to: audioEngine.mainMixerNode, format: audioFormat)

    audioPlayerNode.scheduleBuffer(audioFileBuffer, at: nil, options: .loops, completionHandler: nil)
    try! audioEngine.start()
    audioPlayerNode.play()

    let outputFileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("outputAudio.m4a")
    let outputFile = try! AVAudioFile(forWriting: outputFileURL, settings: audioFormat.settings)
    let outputFileBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: audioFrameCount)!

    var counter = 0
    while audioPlayerNode.isPlaying && counter < 2 {
        if let nextRenderTime = audioPlayerNode.lastRenderTime {
            let outputBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: audioFrameCount)!
            try! outputFile.write(from: outputBuffer)
            counter += 1
        } else {
            usleep(10_000)
        }
    }

    try! audioEngine.stop()

    // Get the audio data from the output file
    let audioData = try! Data(contentsOf: outputFileURL)

    // Convert audio data to M4A format
    let convertedData = convertToM4A(audioData: audioData)

    // Share the processed audio data
    let activityViewController = UIActivityViewController(activityItems: [convertedData], applicationActivities: nil)
    activityViewController.popoverPresentationController?.sourceView = self.view
    self.present(activityViewController, animated: true) {
        completionHandler?()
    }
}

问题分析

原代码存在多个关键问题导致保存和分享失败:

  • 错误的音频捕获方式:循环创建空AVAudioPCMBuffer写入文件,未捕获经过音频引擎处理后的有效数据,保存的文件为空或无效。
  • 冗余效果器使用:同时用两个AVAudioUnitTimePitch实例,单个实例即可同时调整速率和音调。
  • 无错误处理:大量try!和强制解包!会在异常时直接崩溃,无法定位问题。
  • 不合理的循环逻辑:仅循环2次写入,无法保证完整捕获音频内容,usleep阻塞主线程影响UI响应。
  • 格式配置错误:直接使用原音频settings创建输出文件,可能不兼容M4A格式,额外的convertToM4A可能导致重复处理或转换失败。

修复方案

以下是修正后的代码,解决了上述所有问题:

func saveOutputAudio(rate: Float = 1.0, pitch: Float = 0.0, echo: Bool = false, reverb: Bool = false, completionHandler: (() -> Void)? = nil) {
    // 1. 安全读取源音频文件
    guard let documentsDir = getDocumentsDirector() else {
        print("无法获取文档目录")
        completionHandler?()
        return
    }
    let audioFileURL = documentsDir.appendingPathComponent(fileName)
    guard let audioFile = try? AVAudioFile(forReading: audioFileURL) else {
        print("无法读取源音频文件")
        completionHandler?()
        return
    }
    let audioFormat = audioFile.processingFormat
    let audioFrameCount = UInt32(audioFile.length)
    guard let audioFileBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: audioFrameCount),
          try? audioFile.read(into: audioFileBuffer) != nil else {
        print("无法创建或填充音频缓冲区")
        completionHandler?()
        return
    }

    // 2. 配置音频引擎和效果器
    let audioEngine = AVAudioEngine()
    let audioPlayerNode = AVAudioPlayerNode()
    audioEngine.attach(audioPlayerNode)

    // 单个AVAudioUnitTimePitch同时调整速率和音调
    let timePitchEffect = AVAudioUnitTimePitch()
    timePitchEffect.rate = rate
    timePitchEffect.pitch = pitch
    audioEngine.attach(timePitchEffect)

    let echoEffect = AVAudioUnitDelay()
    echoEffect.wetDryMix = echo ? 50 : 0
    audioEngine.attach(echoEffect)

    let reverbEffect = AVAudioUnitReverb()
    reverbEffect.wetDryMix = reverb ? 50 : 0
    audioEngine.attach(reverbEffect)

    // 连接节点
    audioEngine.connect(audioPlayerNode, to: timePitchEffect, format: audioFormat)
    audioEngine.connect(timePitchEffect, to: echoEffect, format: audioFormat)
    audioEngine.connect(echoEffect, to: reverbEffect, format: audioFormat)
    audioEngine.connect(reverbEffect, to: audioEngine.mainMixerNode, format: audioFormat)

    // 3. 配置输出文件(直接生成M4A兼容格式)
    let outputFileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("outputAudio.m4a")
    // 删除已存在的旧文件
    try? FileManager.default.removeItem(at: outputFileURL)
    
    // 配置AAC编码的M4A输出设置
    let outputSettings: [String: Any] = [
        AVFormatIDKey: kAudioFormatMPEG4AAC,
        AVSampleRateKey: audioFormat.sampleRate,
        AVNumberOfChannelsKey: audioFormat.channelCount,
        AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
    ]
    
    guard let outputFile = try? AVAudioFile(forWriting: outputFileURL, settings: outputSettings, commonFormat: .pcmFormatFloat32, interleaved: false) else {
        print("无法创建输出音频文件")
        completionHandler?()
        return
    }

    // 4. 安装Tap捕获处理后的音频
    let mainMixer = audioEngine.mainMixerNode
    var isRecordingComplete = false
    
    mainMixer.installTap(onBus: 0, bufferSize: 4096, format: mainMixer.outputFormat(forBus: 0)) { buffer, time in
        guard !isRecordingComplete else { return }
        do {
            try outputFile.write(from: buffer)
        } catch {
            print("写入音频文件失败: \(error.localizedDescription)")
            isRecordingComplete = true
        }
    }

    // 5. 播放并等待处理完成
    audioPlayerNode.scheduleBuffer(audioFileBuffer, at: nil, completionHandler: {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            isRecordingComplete = true
            audioEngine.stop()
            audioPlayerNode.stop()
            mainMixer.removeTap(onBus: 0)
            
            // 6. 读取输出文件并分享
            guard let _ = try? Data(contentsOf: outputFileURL) else {
                print("无法读取输出音频数据")
                completionHandler?()
                return
            }
            
            let activityViewController = UIActivityViewController(activityItems: [outputFileURL], applicationActivities: nil)
            activityViewController.popoverPresentationController?.sourceView = self.view
            self.present(activityViewController, animated: true) {
                completionHandler?()
            }
        }
    })

    do {
        try audioEngine.start()
        audioPlayerNode.play()
    } catch {
        print("音频引擎启动失败: \(error.localizedDescription)")
        completionHandler?()
    }
}

关键修复点说明

  • 使用Tap捕获音频:通过installTap直接获取经过所有效果器处理后的音频数据,确保写入文件的内容有效。
  • 合并效果器实例:单个AVAudioUnitTimePitch同时设置速率和音调,避免冗余和潜在冲突。
  • 添加错误处理:用guard和try?替代强制解包,捕获并打印错误,避免崩溃并便于调试。
  • 异步完成逻辑:通过scheduleBuffer的回调判断播放完成,延迟0.1秒确保最后一段数据写入,避免丢失内容。
  • 直接生成M4A:配置AAC编码的输出设置,无需额外格式转换,分享时传入文件URL提升兼容性。
  • 移除阻塞操作:去掉usleep和循环,使用异步回调处理,避免阻塞主线程影响用户体验。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.24 18:18:07