AVAudioPlayerNode вызывает искажение

У меня есть AVAudioPlayerNode, прикрепленный к AVAudioEngine. Буферы для образцов предоставляются playerNode методом scheduleBuffer().

Однако кажется, что playerNode искажает звук. Вместо того, чтобы просто «проходить через» буферы, вывод искажается и содержит статические помехи (но все еще в основном слышимые).

Соответствующий код:

let myBufferFormat = AVAudioFormat(standardFormatWithSampleRate: 48000, channels: 2)

// Configure player node
let playerNode = AVAudioPlayerNode()
audioEngine.attach(playerNode)
audioEngine.connect(playerNode, to: audioEngine.mainMixerNode, format: myBufferFormat)

// Provide audio buffers to playerNode
for await buffer in mySource.streamAudio() {
    await playerNode.scheduleBuffer(buffer)
}

В приведенном выше примере mySource.streamAudio() обеспечивает звук в реальном времени из ScreenCaptureKit SCStreamDelegate. Аудиобуферы поступают как CMSampleBuffer, преобразуются в AVAudioPCMBuffer, а затем передаются через AsyncStream звуковому движку выше. Я проверил, что преобразованные буферы действительны.

Может буферы не приходят достаточно быстро? Этот график из ~ 25 000 кадров предполагает, что inputNode периодически вставляет сегменты «нулевых» кадров:

Искажение кажется результатом этих пустых кадров.

Редактировать:

Даже если мы удалим AsyncStream из конвейера и немедленно обработаем буферы в обратном вызове ScreenCaptureKit, искажение сохранится. Вот сквозной пример, который можно запустить как есть (важная часть — didOutputSampleBuffer):

class Recorder: NSObject, SCStreamOutput {
    
    private let audioEngine = AVAudioEngine()
    private let playerNode = AVAudioPlayerNode()
    private var stream: SCStream?
    private let queue = DispatchQueue(label: "sampleQueue", qos: .userInitiated)
    
    func setupEngine() {
        let format = AVAudioFormat(standardFormatWithSampleRate: 48000, channels: 2)
        audioEngine.attach(playerNode)
        // playerNode --> mainMixerNode --> outputNode --> speakers
        audioEngine.connect(playerNode, to: audioEngine.mainMixerNode, format: format)
        audioEngine.prepare()
        try? audioEngine.start()
        playerNode.play()
    }
    
    func startCapture() async {
        // Capture audio from Safari
        let availableContent = try! await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false)
        let display = availableContent.displays.first!
        let app = availableContent.applications.first(where: {$0.applicationName == "Safari"})!
        let filter = SCContentFilter(display: display, including: [app], exceptingWindows: [])
        let config = SCStreamConfiguration()
        config.capturesAudio = true
        config.sampleRate = 48000
        config.channelCount = 2
        stream = SCStream(filter: filter, configuration: config, delegate: nil)
        try! stream!.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
        try! stream!.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) // To prevent warnings
        try! await stream!.startCapture()
    }
    
    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
        switch type {
        case .audio:
            let pcmBuffer = createPCMBuffer(from: sampleBuffer)!
            playerNode.scheduleBuffer(pcmBuffer, completionHandler: nil)
        default:
            break // Ignore video frames
        }
    }
    
    func createPCMBuffer(from sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? {
        var ablPointer: UnsafePointer<AudioBufferList>?
        try? sampleBuffer.withAudioBufferList { audioBufferList, blockBuffer in
            ablPointer = audioBufferList.unsafePointer
        }
        guard let audioBufferList = ablPointer,
              let absd = sampleBuffer.formatDescription?.audioStreamBasicDescription,
              let format = AVAudioFormat(standardFormatWithSampleRate: absd.mSampleRate, channels: absd.mChannelsPerFrame) else { return nil }
        return AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList)
    }
    
}

let recorder = Recorder()
recorder.setupEngine()
Task {
    await recorder.startCapture()
}
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
0
56
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ваше «Запись буфера в файл: искажено!» block почти наверняка делает что-то медленное и блокирующее (например, запись в файл). Вам звонят каждые 170 мс (8192/48k). Выполнение тап-блока не должно занимать больше времени, иначе вы отстанете и сбросите буферы.

Можно не отставать при записи в файл, но это зависит от того, как вы это делаете. Если вы делаете что-то очень неэффективное (например, повторно открываете и очищаете файл для каждого буфера), то вы, вероятно, не успеваете.

Если эта теория верна, то выход живого динамика не должен быть статическим, а только ваш выходной файл.

К сожалению, это не наш виновник здесь. Выход живого динамика также искажается, и полное удаление крана не имеет значения.

Hundley 25.01.2023 04:20

В этом случае вы недостаточно быстро планируете свои буферы или истощаете поток аудиорендеринга (используя все ядра для других целей). Я бы сначала заподозрил первого. Как вы гарантируете, что streamAudio() возвращает аудиобуферы до того, как они потребуются? Цикл for-await будет переключать потоки. Что такое TaskPriority? Может ли этот поток когда-нибудь умереть с голоду? Я думаю, вам, возможно, придется обновить вопрос, чтобы удалить вещи, которые не влияют на вас (касание), и добавить вещи, которые влияют (как вы генерируете этот звук и следите за тем, чтобы он всегда был запланирован вовремя?)

Rob Napier 25.01.2023 04:26

Спасибо - я обновил вопрос, чтобы объяснить источник аудиобуферов. Я думаю, вы можете быть правы в том, что буферы планируются недостаточно быстро, но что можно сделать, когда они поступают в режиме реального времени? ScreenCaptureKit предоставляет буферы длиной 960 кадров, которые мы сразу (после конвертации) планируем в inputNode. Tap выдает буферы длиной 4800 через равные промежутки времени, независимо от того, действительно ли 4800 кадров поступили из нашего источника.

Hundley 25.01.2023 05:18

Я должен упомянуть цель этого проекта: смешать входящий звук, захваченный в реальном времени (из ScreenCaptureKit), с живым звуком микрофона. Я открыт для других методов, но AVAudioEngine показался мне лучшим способом сделать это. И AVAudioPlayerNode — это единственный способ, которым я знаю, как получить внешние буферы в движок...

Hundley 25.01.2023 05:40

Если вы используете ScreenCaptureKit, разве вы не получаете обратные вызовы didOutputSampleBuffer? Вот где вы должны планировать буфер. Ваш код не объясняет streamAudio(), и я ожидаю, что это ваши самые большие проблемы. for-await, скорее всего, вносит в это много недетерминированных задержек, а также синхронизацию, которую вы не хотите. (Я не говорю, что асинхронность медленная или плохая, но на самом деле она не предназначена для работы в реальном времени, и это кажется особенно странным способом сделать это.)

Rob Napier 25.01.2023 14:57

Хорошо, я удалил AsyncStream и максимально изолировал проблему. Пожалуйста, смотрите мое последнее редактирование: это показывает полный пример и должно быть воспроизведено. Буферы по расписанию в движок прямо из didOutputSampleBuffer.

Hundley 25.01.2023 19:06
Ответ принят как подходящий

Виной всему была функция createPCMBuffer(). Замените его на это, и все пойдет гладко:

func createPCMBuffer(from sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? {
    let numSamples = AVAudioFrameCount(sampleBuffer.numSamples)
    let format = AVAudioFormat(cmAudioFormatDescription: sampleBuffer.formatDescription!)
    let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: numSamples)!
    pcmBuffer.frameLength = numSamples
    CMSampleBufferCopyPCMDataIntoAudioBufferList(sampleBuffer, at: 0, frameCount: Int32(numSamples), into: pcmBuffer.mutableAudioBufferList)
    return pcmBuffer
}

Исходная функция в моем вопросе была взята непосредственно из примера проекта Apple ScreenCaptureKit. Технически это работает, и звук при записи в файл звучит нормально, но, видимо, он недостаточно быстр для звука в реальном времени.

Редактировать: На самом деле, вероятно, дело не в скорости, так как новая функция в среднем в 2-3 раза медленнее из-за копирования данных. Возможно, базовые данные освобождались при создании AVAudioPCMBuffer с помощью указателя.

Другие вопросы по теме