Проблемы синхронизации: метроном с использованием обработчика завершения AVAudioEngine scheduleBuffer

Я хочу создать простое метрономное приложение с использованием AVAudioEngine со следующими функциями:

  • Точное время (я знаю, я знаю, я должен использовать Audio Units, но я все еще борюсь с материалом Core Audio/обертками Obj-C и т. д.)
  • Два разных звука на 1 и на 2/3/4 такта.
  • Какая-то визуальная обратная связь (по крайней мере, отображение текущего ритма), которая должна быть синхронизирована со звуком.

Поэтому я создал два коротких звука щелчка (26 мс / 1150 сэмплов @ 16 бит / 44,1 кГц / стерео wav-файлы) и загрузил их в 2 буфера. Их длина будет установлена ​​так, чтобы представлять один период.

Настройка моего пользовательского интерфейса проста: кнопка для переключения старт/пауза и метка для отображения текущего ритма (моя переменная счетчика).

При использовании свойства цикла scheduleBuffer синхронизация в порядке, но, поскольку мне нужно иметь 2 разных звука и способ синхронизации/обновления моего пользовательского интерфейса во время зацикливания кликов, я не могу использовать это. Вместо этого я решил использовать завершениеHandler, который перезапускает мою функцию playClickLoop() - см. мой код ниже.

К сожалению, при реализации этого я действительно не измерял точность времени. Теперь выясняется, что при установке скорости 120 ударов в минуту луп воспроизводится со скоростью всего около 117,5 ударов в минуту — довольно стабильно, но все же слишком медленно. Когда установлено значение 180 ударов в минуту, мое приложение воспроизводится со скоростью около 172,3 ударов в минуту.

Что тут происходит? Введена ли эта задержка с помощью завершенияHandler? Есть ли способ улучшить время? Или весь мой подход неверен?

Заранее спасибо! Алекс

import UIKit
import AVFoundation

class ViewController: UIViewController {
    
    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
    
    private let fileName1 = "sound1.wav"
    private let fileName2 = "sound2.wav"
    private var file1: AVAudioFile! = nil
    private var file2: AVAudioFile! = nil
    private var buffer1: AVAudioPCMBuffer! = nil
    private var buffer2: AVAudioPCMBuffer! = nil
    
    private let sampleRate: Double = 44100
    
    private var bpm: Double = 180.0
    private var periodLengthInSamples: Double { 60.0 / bpm * sampleRate }
    private var counter: Int = 0
    
    private enum MetronomeState {case run; case stop}
    private var state: MetronomeState = .stop
    
    @IBOutlet weak var label: UILabel!
    
    override func viewDidLoad() {
        
        super.viewDidLoad()
        
        //
        // MARK: Loading buffer1
        //
        let path1 = Bundle.main.path(forResource: fileName1, ofType: nil)!
        let url1 = URL(fileURLWithPath: path1)
        do {file1 = try AVAudioFile(forReading: url1)
            buffer1 = AVAudioPCMBuffer(
                pcmFormat: file1.processingFormat,
                frameCapacity: AVAudioFrameCount(periodLengthInSamples))
            try file1.read(into: buffer1!)
            buffer1.frameLength = AVAudioFrameCount(periodLengthInSamples)
        } catch { print("Error loading buffer1 \(error)") }
        
        //
        // MARK: Loading buffer2
        //
        let path2 = Bundle.main.path(forResource: fileName2, ofType: nil)!
        let url2 = URL(fileURLWithPath: path2)
        do {file2 = try AVAudioFile(forReading: url2)
            buffer2 = AVAudioPCMBuffer(
                pcmFormat: file2.processingFormat,
                frameCapacity: AVAudioFrameCount(periodLengthInSamples))
            try file2.read(into: buffer2!)
            buffer2.frameLength = AVAudioFrameCount(periodLengthInSamples)
        } catch { print("Error loading buffer2 \(error)") }
        
        //
        // MARK: Configure + start engine
        //
        engine.attach(player)
        engine.connect(player, to: engine.mainMixerNode, format: file1.processingFormat)
        engine.prepare()
        do { try engine.start() } catch { print(error) }
    }
    
    //
    // MARK: Play / Pause toggle action
    //
    @IBAction func buttonPresed(_ sender: UIButton) {
        
        sender.isSelected = !sender.isSelected
        
        if player.isPlaying {
            state = .stop
        } else {
            state = .run
            
            try! engine.start()
            player.play()
            
            playClickLoop()
        }
    }
    
    private func playClickLoop() {
        
        //
        //  MARK: Completion handler
        //
        let scheduleBufferCompletionHandler = { [unowned self] /*(_: AVAudioPlayerNodeCompletionCallbackType)*/ in
            
            DispatchQueue.main.async {
                
                switch state {
                
                case .run:
                    self.playClickLoop()
            
                case .stop:
                    engine.stop()
                    player.stop()
                    counter = 0
                }
            }
        }
        
        //
        // MARK: Schedule buffer + play
        //
        if engine.isRunning {
            
            counter += 1; if counter > 4 {counter = 1} // Counting from 1 to 4 only
            
            if counter == 1 {
                //
                // MARK: Playing sound1 on beat 1
                //
                player.scheduleBuffer(buffer1,
                                      at: nil,
                                      options: [.interruptsAtLoop],
                                      //completionCallbackType: .dataPlayedBack,
                                      completionHandler: scheduleBufferCompletionHandler)
            } else {
                //
                // MARK: Playing sound2 on beats 2, 3 & 4
                //
                player.scheduleBuffer(buffer2,
                                      at: nil,
                                      options: [.interruptsAtLoop],
                                      //completionCallbackType: .dataRendered,
                                      completionHandler: scheduleBufferCompletionHandler)
            }
            //
            // MARK: Display current beat on UILabel + to console
            //
            DispatchQueue.main.async {
                self.label.text = String(self.counter)
                print(self.counter)
            }
        }
    }
}

person McNail    schedule 01.05.2021    source источник
comment
Каждый раз, когда вы говорите DispatchQueue.main.async, вы отбрасываете все возможности определения точного времени. Теперь вы асинхронны, то есть сделайте это в какое-то время в будущем, и мне все равно, когда.   -  person matt    schedule 01.05.2021
comment
Вы смотрели на какие-либо из многих других вопросов / ответов о метрономе? Например, самый первый, stackoverflow.com/questions/32641990/, выглядит полезным.   -  person matt    schedule 01.05.2021
comment
Спасибо за ваш комментарий. Удаление команды DispatchQueue.main.async из моего завершенияHandler не изменило проблему синхронизации, она по-прежнему постоянно воспроизводится со скоростью 117,5 ударов в минуту вместо 120 ударов в минуту или со скоростью 172,3 ударов в минуту вместо 180 ударов в минуту, как раньше. Может здесь что-то другое не так? К сожалению, я не могу избавиться от второй команды DispatchQueue.main.async — она нужна для доступа к пользовательскому интерфейсу.   -  person McNail    schedule 01.05.2021
comment
Чтобы ответить на ваш второй комментарий: Конечно, я прочитал здесь множество других вопросов/ответов. К сожалению, ваша ссылка не помогает в моем случае: я НЕ могу использовать свойство цикла scheduleBuffer - мне нужен способ синхронизации моего пользовательского интерфейса со звуком, поэтому я использую завершениеHandler для повторного запуска функции воспроизведения.   -  person McNail    schedule 01.05.2021
comment
Кстати, сейчас он есть на GitHub, на случай, если кто-нибудь захочет попробовать его на своей машине: github.com/Alexander-Nagel/)   -  person McNail    schedule 01.05.2021


Ответы (2)


Как предложил выше Фил Фрейхофнер, вот решение моей проблемы:

Самый важный урок, который я усвоил: обратный вызов completeHandler, предоставляемый командой scheduleBuffer, вызывается недостаточно рано, чтобы инициировать перепланирование другого буфера, пока первый еще воспроизводится. Это приведет к (неразборчивым) промежуткам между звуками и испортит синхронизацию. В резерве уже должен быть другой буфер, т. е. он был запланирован до того, как был запланирован текущий.

Использование параметра completeCallbackType в scheduleBuffer не сильно изменилось, учитывая время обратного вызова завершения: при установке для него значения .dataRendered или .dataConsumed обратный вызов уже был слишком поздним для повторного планирования другого буфера. Использование .dataPlayedback сделало ситуацию только хуже :-)

Итак, чтобы добиться плавного воспроизведения (с правильной синхронизацией!) я просто активировал таймер, который срабатывает дважды за период. Все события таймера с нечетными номерами будут перепланировать другой буфер.

Иногда решение настолько простое, что даже стыдно... Но иногда приходится сначала перепробовать почти все неправильные подходы, чтобы найти его ;-)

Мое полное рабочее решение (включая два звуковых файла и пользовательский интерфейс) можно найти здесь, на GitHub:

https://github.com/Alexander-Nagel/Metronome-using-AVAudioEngine

import UIKit
import AVFoundation

private let DEBUGGING_OUTPUT = true

class ViewController: UIViewController{
    
    private var engine = AVAudioEngine()
    private var player = AVAudioPlayerNode()
    private var mixer = AVAudioMixerNode()
    
    private let fileName1 = "sound1.wav"
    private let fileName2 = "sound2.wav"
    private var file1: AVAudioFile! = nil
    private var file2: AVAudioFile! = nil
    private var buffer1: AVAudioPCMBuffer! = nil
    private var buffer2: AVAudioPCMBuffer! = nil
    
    private let sampleRate: Double = 44100
    
    private var bpm: Double = 133.33
    private var periodLengthInSamples: Double {
        60.0 / bpm * sampleRate
    }
    private var timerEventCounter: Int = 1
    private var currentBeat: Int = 1
    private var timer: Timer! = nil
    
    private enum MetronomeState {case running; case stopped}
    private var state: MetronomeState = .stopped
        
    @IBOutlet weak var beatLabel: UILabel!
    @IBOutlet weak var bpmLabel: UILabel!
    @IBOutlet weak var playPauseButton: UIButton!
    
    override func viewDidLoad() {
        
        super.viewDidLoad()
        
        bpmLabel.text = "\(bpm) BPM"
        
        setupAudio()
    }
    
    private func setupAudio() {
        
        //
        // MARK: Loading buffer1
        //
        let path1 = Bundle.main.path(forResource: fileName1, ofType: nil)!
        let url1 = URL(fileURLWithPath: path1)
        do {file1 = try AVAudioFile(forReading: url1)
            buffer1 = AVAudioPCMBuffer(
                pcmFormat: file1.processingFormat,
                frameCapacity: AVAudioFrameCount(periodLengthInSamples))
            try file1.read(into: buffer1!)
            buffer1.frameLength = AVAudioFrameCount(periodLengthInSamples)
        } catch { print("Error loading buffer1 \(error)") }
        
        //
        // MARK: Loading buffer2
        //
        let path2 = Bundle.main.path(forResource: fileName2, ofType: nil)!
        let url2 = URL(fileURLWithPath: path2)
        do {file2 = try AVAudioFile(forReading: url2)
            buffer2 = AVAudioPCMBuffer(
                pcmFormat: file2.processingFormat,
                frameCapacity: AVAudioFrameCount(periodLengthInSamples))
            try file2.read(into: buffer2!)
            buffer2.frameLength = AVAudioFrameCount(periodLengthInSamples)
        } catch { print("Error loading buffer2 \(error)") }
        
        //
        // MARK: Configure + start engine
        //
        engine.attach(player)
        engine.connect(player, to: engine.mainMixerNode, format: file1.processingFormat)
        engine.prepare()
        do { try engine.start() } catch { print(error) }
    }
    
    //
    // MARK: Play / Pause toggle action
    //
    @IBAction func buttonPresed(_ sender: UIButton) {
        
        sender.isSelected = !sender.isSelected
        
        if state == .running {
            
            //
            // PAUSE: Stop timer and reset counters
            //
            state = .stopped
            
            timer.invalidate()
            
            timerEventCounter = 1
            currentBeat = 1
            
        } else {
            
            //
            // START: Pre-load first sound and start timer
            //
            state = .running
            
            scheduleFirstBuffer()
            
            startTimer()
        }
    }
    
    private func startTimer() {
        
        if DEBUGGING_OUTPUT {
            print("# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #  ")
            print()
        }
        
        //
        // Compute interval for 2 events per period and set up timer
        //
        let timerIntervallInSamples = 0.5 * self.periodLengthInSamples / sampleRate
        
        timer = Timer.scheduledTimer(withTimeInterval: timerIntervallInSamples, repeats: true) { timer in
            
            //
            // Only for debugging: Print counter values at start of timer event
            //
            // Values at begin of timer event
            if DEBUGGING_OUTPUT {
                print("timerEvent #\(self.timerEventCounter) at \(self.bpm) BPM")
                print("Entering \ttimerEventCounter: \(self.timerEventCounter) \tcurrentBeat: \(self.currentBeat) ")
            }
            
            //
            // Schedule next buffer at 1st, 3rd, 5th & 7th timerEvent
            //
            var bufferScheduled: String = "" // only needed for debugging / console output
            switch self.timerEventCounter {
            case 7:
                
                //
                // Schedule main sound
                //
                self.player.scheduleBuffer(self.buffer1, at:nil, options: [], completionHandler: nil)
                bufferScheduled = "buffer1"
                
            case 1, 3, 5:
                
                //
                // Schedule subdivision sound
                //
                self.player.scheduleBuffer(self.buffer2, at:nil, options: [], completionHandler: nil)
                bufferScheduled = "buffer2"
                
            default:
                bufferScheduled = ""
            }
            
            //
            // Display current beat & increase currentBeat (1...4) at 2nd, 4th, 6th & 8th timerEvent
            //
            if self.timerEventCounter % 2 == 0 {
                DispatchQueue.main.async {
                    self.beatLabel.text = String(self.currentBeat)
                }
                self.currentBeat += 1; if self.currentBeat > 4 {self.currentBeat = 1}
            }
            
            //
            // Increase timerEventCounter, two events per beat.
            //
            self.timerEventCounter += 1; if self.timerEventCounter > 8 {self.timerEventCounter = 1}
            
            
            //
            // Only for debugging: Print counter values at end of timer event
            //
            if DEBUGGING_OUTPUT {
                print("Exiting \ttimerEventCounter: \(self.timerEventCounter) \tcurrentBeat: \(self.currentBeat) \tscheduling: \(bufferScheduled)")
                print()
            }
        }
    }
    
    private func scheduleFirstBuffer() {
        
        player.stop()
        
        //
        // pre-load accented main sound (for beat "1") before trigger starts
        //
        player.scheduleBuffer(buffer1, at: nil, options: [], completionHandler: nil)
        player.play()
        beatLabel.text = String(currentBeat)
    }
}

Большое спасибо за вашу помощь всем! Это замечательное сообщество.

Алекс

person McNail    schedule 03.05.2021

Насколько точен инструмент или процесс, который вы используете для измерения?

Я не могу точно сказать, что в ваших файлах правильное количество кадров PCM, так как я не программист на C. Похоже, данные из заголовка wav включаются при загрузке файлов. Это заставляет меня задаться вопросом, может быть, есть некоторая задержка, связанная с воспроизведением, когда информация заголовка обрабатывается повторно в начале каждого воспроизведения или цикла.

Мне повезло создать метроном на Java, используя план непрерывного вывода бесконечного потока, полученного при чтении кадров PCM. Синхронизация достигается путем подсчета кадров ИКМ и маршрутизации либо в тишине (точка данных ИКМ = 0), либо в данных ИКМ щелчка, в зависимости от периода выбранной настройки метронома и продолжительности щелчка в кадрах ИКМ.

person Phil Freihofner    schedule 02.05.2021
comment
Спасибо за ваши мысли. Я тоже не программист на C - мой код написан на Swift :-) Что касается ваших предложений: нет никакого реального инструмента/процесса, задействованного в вычислении моих буферов. Я просто вычисляю periodLengthInSamples (60 / bpm * sampleRate), затем, после загрузки моих (очень коротких!) кликов в буферы, я использую buffer1.frameLength = AVAudioFrameCount(periodLengthInSamples), чтобы установить буферы на необходимую длину. На самом деле это буфер PCM, поэтому нет данных заголовка, которые могли бы испортить мою синхронизацию. - person McNail; 02.05.2021
comment
К сожалению, использование параметра completionCallbackType: для scheduleBuffer также не помогает: установка его в .dataRendered или .dataConsumed теоретически должна привести к более раннему обратному вызову (вместо установки в .dataPlayedback), но, как выясняется, еще недостаточно рано, чтобы получить бесшовное воспроизведение с точностью до синхронизации. - person McNail; 02.05.2021
comment
Мой новый (рабочий!) подход: я просто установил таймер, который срабатывает дважды на каждый звук. Непосредственно перед запуском таймера я планирую самый первый звук, затем запускаю проигрыватель, а через несколько мгновений запускается таймер: каждое нечетное событие таймера перепланирует следующий буфер. Первое событие таймера происходит всего через несколько мс после начала воспроизведения первого звука. Это намного раньше, чем ждать обратного вызова завершения первых звуков, как я сделал это в своем коде выше. - person McNail; 02.05.2021
comment
Таким образом, всегда есть еще один запланированный буфер, ожидающий воспроизведения, а буферы воспроизведения буферов плавные и идеальные. Больше никаких обратных вызовов, больше не нужно DispatchQueue.main.async{}, ура! ;) - person McNail; 02.05.2021
comment
Что мне очень помогло понять и визуализировать всю проблему AVAudioEngine / планирования, так это это введение в AVAudioEngine. - person McNail; 02.05.2021
comment
Рад слышать, что вы смогли найти решение, которым вы довольны. Я рекомендую вам написать его и опубликовать как ответ, чтобы людям было легче его найти и извлечь из него уроки. У меня была сонная голова, когда я опубликовал свое предложение в это утро, и использование буферов напомнило мне C. - person Phil Freihofner; 03.05.2021
comment
Спасибо за ваше предложение! Я только что опубликовал свой ответ. - person McNail; 03.05.2021