Успех мультимедийной платформы зачастую определяет качество контента. Поэтому существует множество инструментов для управления контентом — например, ручная или автоматическая модерация на предмет грубого и оскорбительного контента или нарушения авторских прав; автоматическая или ручная обработка загруженного контента. Один из способов автоматического улучшения контента — обрезка тишины.
Задача выделения голоса или звуков очень нетривиальная. О том, как мы искали пути ее решения при разработке одного из наших проектовголосового чата BlaBlaPlay — и как реализовать это в вашем iOS-приложении, расскажем в этой статье.

[Теория] Когда нужна функция обрезки тишины?

Например, у вас есть аудиофайл с промежутками, где уровень громкости слишком низкий для человеческого восприятия. В таком случае может потребоваться удалить эти участки из файла. Или, например, вы записываете аудиосообщение и долго собираетесь с мыслями в начале. Чтобы не перематывать несколько секунд тишины снова снова, их проще обрезать вовсе. Можно описать еще много подобных случаев, но вывод напрашивается один — обрезка тишины требуется для улучшения контента и качества его восприятия.
Реализовать это можно по-разному, используя разные инструменты. Изначально можно выделить 2 основные группы инструментов:

  • для ручного удаления: например, любой аудиоредактор имеет базовую функцию выделения отрезка, который нужно удалить / оставить
  • для автоматического удаления — они используют ряд вспомогательных технологий для достижения результата. Рассмотрим их подробнее.

[Теория] Автоматические способы определения тишины

Их может быть довольно много и выбор зависит от стоящей перед разработчиком задачи. Это в первую очередь связано с тем, что одни инструменты позволяют выделять из аудиопотока исключительно голос, а другие — работать с голосом и с посторонними звуками.

Определение тишины по уровню звука

Итак, определение по уровню звука или, если говорить более точно, по значению — это самый простой и быстрый для выполнения способ. Поэтому его можно использовать для потокового аудио и определять тишину в реальном времени. Но в то же время это самый неточный и хрупкий способ определения тишины. Технология простая: 

  1. Задается константное значение в децибелах — примерно равное порогу слышимости для человека. 
  2. Далее все, что ниже этого порога, автоматически считается тишиной и подлежит обрезке.

Способ подходит только в случае, когда необходимо определить абсолютную тишину и не нужно определять голос или любые посторонние шумы. Абсолютная тишина — явление редкое, поэтому способ малоэффективен. Следовательно, применяться он может только для индикации наличия или отсутствия звука, а не обработки тишины.

Выделение голоса из аудиопотока

Это подход от обратного — если есть речь, значит, тишины нет.

Выделение звука из аудиопотока — задача нетривиальная и решается путем оценки фрагмента уровней звука или его спектрограммы — схемы колебаний уровня звука. Есть 2 подхода к оценке: аналитический и нейросетевой. В нашем приложении мы использовали Voice Activity Detector (VAD) — детектор речи для выделения голоса или речи из шума или тишины. Рассмотрим на его примере.

Аналитическая оценка

В рамках работы с речевым сигналом как правило используют частотно-временную область обработки. Среди основных методов:

• анализ с использованием вейвлет-преобразования.

• анализ с использованием линейного предсказания.

• анализ с использованием преобразования Гильберта-Хуанга.

• анализ с использованием преобразования Фурье.

• анализ с использованием корреляционной функции.

Наиболее эффективным для выделения речи является метод, основанный на том, что речевой аппарат человека способен генерировать определенный пул частот. Эти частоты называются “форманты”.

Входные данные в этом методе представляют собой непрерывную осциллограмму (кривую, отображающую колебания) звуковой волны. Для выделения речи ее разбивают на фреймы — фрагменты звукового потока длительностью от 10 до 20 мс и шагом 10 мс. Такой размер соответствует скорости человеческой речи: в среднем за 3 секунды человек произносит 3 слова, в каждом из которых около 4 звуков, каждый звук разбивается на 3 этапа. Каждый фрейм независимо трансформируется и подвергается извлечению акустических признаков

Рисунок: Разделение осциллограммы на фреймы.

Далее для каждого окна выполняется преобразование Фурье:

  1. Производится поиск пиковых значений
  2. На основе формального анализа акустических признаков принимается решение: есть речевой сигнал или нет. Подробнее процесс описан в работе У.А. Ли. 1983. “Методы автоматического распознавания речи”.

[Теория] Нейросетевой подход к оценке

Нейросетевой подход состоит из двух частей. 

  1. Так называемый feature extractor — средство для извлечения признаков и построения low dimensional space. На вход экстрактору подается осциллограмма звуковой волны и, например, с помощью преобразования Фурье, строится ее low dimensional space.  Т.е. из большого числа признаков выделяются ключевые и формируется в новое пространство.
Преобразование high dimensional space to low dimensional space.
  1. Дальше экстрактор организует звуки в пространстве так, чтобы похожие были рядом. Например, звуки речи будут сгруппированы вместе, но размещены вдали от звуков барабанов и автомобилей.

Далее модель классификации берет выходные данные экстрактора признаков и вычисляет вероятность речи среди полученных данных.

Что использовать?

Процесс выделения звуков или речи сложный и требует много вычислительных ресурсов для быстрой работы.  Давайте разберемся, когда и какой метод стоит применять.

Итак, как видим, определение по уровню сигнала не подойдет, если вам нужна точность. 

С аналитическим и нейросетевым подходами есть нюансы. Оба требуют высокой вычислительной мощности — это ограничивает их использование с потоковым аудио. Но в случае с аналитическим подходом эту проблему можно решить, используя более простые реализации в угоду точности. Например, WebRTC_VAD — он хоть и не сильно точен, но работает быстро с потоковым аудио даже на слабых устройствах.

А если вычислительная мощность есть и вы хотите определять не только речь, но и звуки птиц, гитары или чего угодно еще, нейросеть решит все ваши проблемы с высоким уровнем точности и за приемлемое время.

[Практика] Пример для детекта и обрезки тишины iOS в начале аудиозаписи

Все iPhone достаточно производительны, а фреймворки от Apple оптимизированы под устройства. Поэтому можем смело использовать нейросети для детекта тишины в потоковом аудио, используя подход от обратного: где нет звуков — там тишина.  Для детекта будем использовать фреймворк Sound Analysis, а для записи аудио и обрезки — AVFoundation.

Получение и отправка буферов аудио во время записи

Для выборки буферов из потока аудиозаписи нужен объект AVAudioEngine. А для доставки полученных буферов нужно добавить наблюдателя на выход к подключенной аудионоде. 

 
 private var audioEngine: AVAudioEngine?

public func start(withSoundDetection: Bool) {
            guard let settings = setupAudioSession() else { return }
            do {
                let configuration = try configureAudioEngine()
                audioEngine = configuration.0
                audioRecordingEvents.onNext(.createsAudioFromat(audioFormat: configuration.1))
            } catch {
                audioRecordingEvents.onError(AudioRecordError.creatingAudioEngineError)
                print("Can not start audio engine")
            }
        configureAudioRecord(settings: settings)
    }


private func configureAudioEngine() throws -> (AVAudioEngine, AVAudioFormat) {
        let audioEngine = AVAudioEngine()
        let inputNode = audioEngine.inputNode
        let recordingFormat = inputNode.outputFormat(forBus: 0)
        inputNode.installTap(onBus: 0, bufferSize: 4096, format: recordingFormat) { [weak self] buffer, time in
            self?.audioRecordingEvents.onNext(.audioBuffer(buffer, time))
        }
        audioEngine.prepare()
        try audioEngine.start()
        return (audioEngine, recordingFormat)
    }
 

Обработка буфера в нейросети и получение результата

Фреймворк Sound Analysis из коробки распознает 300 звуков, чего более чем достаточно для нашей задачи. Создадим класс классификатора и правильно сконфигурируем объект SNClassifySoundRequest.

 
 final class AudioClassifire
    private var analyzer: SNAudioStreamAnalyzer?
    private var request: SNClassifySoundRequest?

 init() {
        request = try? SNClassifySoundRequest(classifierIdentifier: .version1)
        request?.windowDuration = CMTimeMakeWithSeconds(1.3, preferredTimescale: 44_100)
        request?.overlapFactor = 0.9
    }
}
 

При создании SNClassifySoundRequest  c использованием константного значения для окна windowDuration крайне важно использовать overlapFactor отличный от нуля. Он описывает, насколько окна перекрывают друг друга при анализе. Тем самым создает непрерывный контекст и связность между окнами.

Дальше нужен какой-то класс наблюдателя, соответствующий протоколу SNResultsObserving. В него будут приходить все результаты классификации.

 
 enum AudioClassificationEvent {
    case result(SNClassificationResult)
    case complete
    case failure(Error)
}


protocol AudioClassifireObserver: SNResultsObserving {
    var audioClassificationEvent: PublishSubject { get }
}


final class AudioClassifireObserverImpl: NSObject, AudioClassifireObserver {
    private(set) var audioClassificationEvent = PublishSubject()
}


extension AudioClassifireObserverImpl: SNResultsObserving {
    func request(_ request: SNRequest, didProduce result: SNResult) {
        guard let result = result as? SNClassificationResult else { return }
        audioClassificationEvent.onNext(.result(result))
    }
    
    func requestDidComplete(_ request: SNRequest) {
        audioClassificationEvent.onNext(.complete)
    }
    
    func request(_ request: SNRequest, didFailWithError error: Error) {
        audioClassificationEvent.onNext(.failure(error))
    }
}
 

Наблюдателя создали, теперь можно создать сам поток для анализа, приходящих аудиобуферов — SNAudioStreamAnalyzer.

 
 func configureClassification(audioFormat: AVAudioFormat) -> AudioClassifireObserver? {
        guard let request else { return nil}
        let observer = AudioClassifireObserverImpl()
        analyzer = SNAudioStreamAnalyzer(format: audioFormat)
        try? analyzer?.add(request, withObserver: observer)
        self.observer = observer
        return observer
    }
 

Теперь все готово. Можно принимать аудиобуферы и отправлять их в нейросеть на анализ. Сделать это очень просто:

 
 func addSample(_ sample: AVAudioPCMBuffer, when: AVAudioTime) {
        analysisQueue.async {
            self.analyzer?.analyze(sample, atAudioFramePosition: when.sampleTime)
        }
   }
 

После того, как AVAudioPCMBuffer будет успешно распознан в AudioClassifireObserver.audioClassificationEvent придет событие AudioClassificationEvent.result(SNClassificationResult). Оно будет содержать все распознанные звуки и уровни их доверия (confidence). Соответственно, если звуков нет или их confidence < 0.75, можно считать, что звук не был распознан — результатом можно пренебречь. Определить это можно так:

 
 func detectSounds(_ result: SNClassificationResult) -> [SNClassification] {
        return result.classifications.filter { $0.confidence > 0.75 }
    }
 

Обрезка аудиофайла на основе работы детектора звуков

После того, как запись началась и первые аудиобуферы начали поступать в нейросеть для анализа, нужно запустить таймер. Он будет отсчитывать время до появления первых ненулевых результатов. Тут стоит помнить, что первые результаты будут получены не раньше, чем через  request?.windowDuration = CMTimeMakeWithSeconds(1.3, preferredTimescale: 44_100). Поэтому стартовые значения таймера должны это учитывать.

 
 private var recordSilenceTime: Double = 0.6
private var silenceTimer: DispatchSourceTimer?

private func processAudioClassifireResult(_ result: SNClassificationResult) {
        let results = audioClassifire.detectSounds(result)
        guard !results.isEmpty && !currentState.classificationWasStopped && silenceTimer == nil else {
            startSilenceTimer()
            return
        }
        results.forEach {
            print("Classification result is \($0.description) with confidence: \($0.confidence)")
        }
        stopObservingClassifire()
    }
 

При первых ненулевых результатах останавливаем таймер и на основании recordSilenceTime можем отрезать кусочек от начала аудиозаписи.

 
 private func processRecordedAudio(fileName: String, filesPath: URL) {
if recordSilenceTime > 0.6,
               let trimmedFile = fileName.components(separatedBy: ".").first {
                let trimmer = AudioTrimmerImpl()
                trimmer.trimAsset(AVURLAsset(url: url), fileName: "\(trimmedFile)", trimTo: recordSilenceTime) { [weak self] url in
                    DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
                        let record = AVURLAsset(url: url)
                    }
                }
            }
}
 

Обрезка файла осуществляется с помощью AVAssetExportSession.

  
  func trimAsset(_ asset: AVURLAsset, fileName: String, trimTo: Double, completion: @escaping (String) -> Void) {
        let trimmedSoundFileURL = documentsDirectory.appendingPathComponent("\(fileName)-trimmed.mp4")
        do {
            if FileManager.default.fileExists(atPath: trimmedSoundFileURL.absoluteString) {
                try deleteFile(path: trimmedSoundFileURL)
            }
        } catch {
            print("could not remove \(trimmedSoundFileURL)")
        }
        
        print("Export to \(trimmedSoundFileURL)")
        
        if let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) {
            exporter.outputFileType = AVFileType.mp4
            exporter.outputURL = trimmedSoundFileURL
            exporter.metadata = asset.metadata
            
            let timescale = asset.duration.timescale
            let startTime = CMTime(seconds: trimTo, preferredTimescale: timescale)
            let stopTime = CMTime(seconds: asset.duration.seconds, preferredTimescale: timescale)
            exporter.timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: stopTime)
            
            exporter.exportAsynchronously(completionHandler: {
                switch exporter.status {
                case  AVAssetExportSession.Status.failed:
                    if let error = exporter.error {
                        print("export failed \(error)")
                    }
                case AVAssetExportSession.Status.cancelled:
                    print("export cancelled \(String(describing: exporter.error))")
                default:
                    print("export complete")
                    completion(fileName)
                }
            })
        } else {
            print("cannot create AVAssetExportSession for asset \(asset)")
        }
    }
 

Результаты и вывод

Обнаружение тишины или звуков в аудиозаписи становится более доступно для приложений, которые в основном не специализируются на профессиональной обработке, не теряя в эффективности и точности полученного результата.

У Apple уже есть готовые инструменты для этого, поэтому тратить год и больше для разработки такого функционала вручную не нужно. Love neural networks. Посмотрите, как это работает в нашем BlaBlaPlay или напишите нам, чтобы реализовать функцию обрезки тишины в вашем iOS-приложении.

  • Разработка