У меня есть 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()
}
Ваше «Запись буфера в файл: искажено!» block почти наверняка делает что-то медленное и блокирующее (например, запись в файл). Вам звонят каждые 170 мс (8192/48k). Выполнение тап-блока не должно занимать больше времени, иначе вы отстанете и сбросите буферы.
Можно не отставать при записи в файл, но это зависит от того, как вы это делаете. Если вы делаете что-то очень неэффективное (например, повторно открываете и очищаете файл для каждого буфера), то вы, вероятно, не успеваете.
Если эта теория верна, то выход живого динамика не должен быть статическим, а только ваш выходной файл.
В этом случае вы недостаточно быстро планируете свои буферы или истощаете поток аудиорендеринга (используя все ядра для других целей). Я бы сначала заподозрил первого. Как вы гарантируете, что streamAudio()
возвращает аудиобуферы до того, как они потребуются? Цикл for-await
будет переключать потоки. Что такое TaskPriority? Может ли этот поток когда-нибудь умереть с голоду? Я думаю, вам, возможно, придется обновить вопрос, чтобы удалить вещи, которые не влияют на вас (касание), и добавить вещи, которые влияют (как вы генерируете этот звук и следите за тем, чтобы он всегда был запланирован вовремя?)
Спасибо - я обновил вопрос, чтобы объяснить источник аудиобуферов. Я думаю, вы можете быть правы в том, что буферы планируются недостаточно быстро, но что можно сделать, когда они поступают в режиме реального времени? ScreenCaptureKit предоставляет буферы длиной 960 кадров, которые мы сразу (после конвертации) планируем в inputNode
. Tap выдает буферы длиной 4800 через равные промежутки времени, независимо от того, действительно ли 4800 кадров поступили из нашего источника.
Я должен упомянуть цель этого проекта: смешать входящий звук, захваченный в реальном времени (из ScreenCaptureKit), с живым звуком микрофона. Я открыт для других методов, но AVAudioEngine показался мне лучшим способом сделать это. И AVAudioPlayerNode — это единственный способ, которым я знаю, как получить внешние буферы в движок...
Если вы используете ScreenCaptureKit, разве вы не получаете обратные вызовы didOutputSampleBuffer
? Вот где вы должны планировать буфер. Ваш код не объясняет streamAudio()
, и я ожидаю, что это ваши самые большие проблемы. for-await
, скорее всего, вносит в это много недетерминированных задержек, а также синхронизацию, которую вы не хотите. (Я не говорю, что асинхронность медленная или плохая, но на самом деле она не предназначена для работы в реальном времени, и это кажется особенно странным способом сделать это.)
Хорошо, я удалил AsyncStream и максимально изолировал проблему. Пожалуйста, смотрите мое последнее редактирование: это показывает полный пример и должно быть воспроизведено. Буферы по расписанию в движок прямо из didOutputSampleBuffer
.
Виной всему была функция 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
с помощью указателя.
К сожалению, это не наш виновник здесь. Выход живого динамика также искажается, и полное удаление крана не имеет значения.