Как добавить функцию обрезки тишины в iOS приложение — обложка

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. Так называемый feature extractor — средство для извлечения признаков и построения low dimensional space. На вход экстрактору подается осциллограмма звуковой волны и, например, с помощью преобразования Фурье, строится её 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 уже есть готовые инструменты для этого, поэтому разрабатывать такой функционал с нуля и тратить на это год не нужно. Люблю нейронные сети. Посмотрите, как это работает в нашем BlaBlaPlay или напишите нам, чтобы реализовать функцию обрезки тишины в вашем iOS-приложении.

  • Разработка