Connect with us

Разработка

Создание живой звуковой волны в SwiftUI

В этом кратком руководстве мы расскажем вам, как использовать FFT для анализа наборов звуковых данных, чтобы визуализировать полученные частоты в виде живой функции звуковой волны в приложении SwiftUI.

Опубликовано

/

     
     

Быстрое преобразование Фурье, также известное как FFT, — это алгоритм, который быстро и эффективно вычисляет громкость и частоту любого дискретизированного звука. Этот расчет точно определяет, какие музыкальные ноты (частоты) скрываются внутри звука, преобразуя длинный список измерений звука (временная область) в список интенсивности нот (частотная область).

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

Это преобразование применяет подход «разделяй и властвуй» к дискретному преобразованию Фурье (DFT), разбивая сигнал на длинный список вычислений, анализируя и объединяя их в сумму синусоидальных волн с различными частотами, чтобы улучшить производительность и сократить время с до N log N.

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

В этом кратком руководстве мы расскажем вам, как использовать FFT для анализа наборов звуковых данных, чтобы визуализировать полученные частоты в виде живой функции звуковой волны в приложении SwiftUI.

Задача будет включать:

  1. сбор аудиоданных в реальном времени;
  2. использование быстрого преобразования Фурье (FFT) для обработки этих данных;
  3. и отображение формы волны.

Для этой цели мы будем использовать фреймворки Apple AVFoundation, Accelerate и Swift Charts.

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

По завершении этого урока у вас будет рабочее приложение SwiftUI, которое визуально отображает живую звуковую волну, как показано ниже:=

Прежде чем начать

Поскольку форма звуковой волны отображается в соответствии с аудиосигналом, который мы получаем через iPhone, нам необходимо запросить у пользователей разрешение на использование микрофона. Поэтому убедитесь, что вы уже создали новый проект Xcode и добавили свойство Privacy - Microphone Usage Description со своим значением в файл Info.plist вашего проекта, чтобы получить доступ к микрофону устройства в iOS.

Создание живой звуковой волны в SwiftUI

Пропуск этого шага приведет к сбою приложения сразу после его запуска.

Кроме того, если вы также добавите свойство Metal Capture Enabled с значением YES, вы избавитесь от некоторых «шумов» в консоли при запуске приложения.

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

Это также позволяет просматривать захват в отладчике GPU Xcode, который показывает все этапы рендеринга, текстуры и данные о производительности этих вычислений.

Этот шаг полезен, но не является строго необходимым, так как приложение будет нормально работать и не вылетать, даже если вы его пропустите.

Шаг 1: Сбор аудиоданных

Цель этого первого шага — определить класс, который будет получать доступ, отслеживать и обрабатывать аудиосигнал, поступающий с микрофона в режиме реального времени, с помощью AVAudioEngine.

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

Вот как это сделать.

1.1 Определение значений конфигурации

В новом файле AudioWaveformMonitor.swift:

// 1. Importing the frameworks
import SwiftUI
import AVFoundation
import Accelerate
import Charts

1. Начнем с импорта фреймворков:

  • AVFoundation используется для захвата и управления входным сигналом микрофона
  • Accelerate обеспечивает быстрые математические операции для обработки аудио в частотной области
  • SwiftUI и Charts отвечают за создание пользовательского интерфейса и визуальное отображение полученной волны
// 2. Declare key configuration values
enum Constants {
    // a. Amount of frequency bins to keep after performing the FFT
    static let sampleAmount: Int = 200
    // b. Reduce the number of plotted points
    static let downsampleFactor = 8
    // c. Handle high spikes distortion in the chart
    static let magnitudeLimit: Float = 100
}

2. Создаем перечисление Constants, которое группирует ключевые значения конфигурации.

  • sampleAmount определяет, сколько частотных ячеек мы хотим сохранить после выполнения FFT
  • downsampleFactor помогает уменьшить количество отображаемых точек, чтобы избежать загромождения и перегрузки графика
  • а magnitudeLimit гарантирует, что необычно высокие всплески в данных не искажают масштаб графика

Если вы хотите получить более перегруженный график, просто уменьшите значение downsampleFactor по своему усмотрению.

1.2 Определение класса AudioWaveformMonitor

В том же файле:

...

// 1. AudioWaveformMonitor class
@MainActor
@Observable
final class AudioWaveformMonitor {
    
    // 2. Shared instance of the class
    static let shared = AudioWaveformMonitor()
    
    // 3. Provide access to the microphone stream
    private var audioEngine = AVAudioEngine()
    
    // 4. Store the results 
    var fftMagnitudes = [Float](repeating: 0, count: Constants.sampleAmount)
    
    // 5. Pick a subset of fftMagnitudes at regular intervals according to the downsampleFacto
    var downsampledMagnitudes: [Float] {
        fftMagnitudes.lazy.enumerated().compactMap { index, value in
            index.isMultiple(of: Constants.downsampleFactor) ? value : nil
        }
    }
}

1. Определяем класс AudioWaveformMonitor, который:

  • обрабатывает звук с аудиовходав реальном времени
  • отвечает за преобразование его из временных выборок в частотные величины с помощью функции, выполняющей быстрое преобразование Фурье (FFT)

2. Создаем общий инстанс AudioWaveformMonitor.

3. Объявляем экземпляр AVAudioEngine, необходимый для обеспечения доступа к потоку микрофона.

4. Результаты AVAudioEngine хранятся в массиве fftMagnitudes.

5. Чтобы визуализация оставалась четкой, вычисленная переменная downsampledMagnitudes просто выбирает подмножество своих значений через равные промежутки времени в соответствии с downsampleFactor, который мы определили в константах.

1.3 Определение методов запуска и остановки

Теперь нам нужно определить методы для запуска и остановки мониторинга звука, поступающего с микрофона.

final class AudioWaveformMonitor {

    ...
    
    // 1. Track if audio monitoring is running or has stopped
    var isMonitoring = false
   
    private init() {}
	
    // The methods to be implemented
    func startMonitoring() async { ... }
    
    func stopMonitoring() { ... }
    
    func performFFT(data: [Float]) async -> [Float] { ... }
    
}

1. Добавляем свойство a isMonitoring для отслеживания, работает ли мониторинг звука или он остановлен, что полезно для координации состояния пользовательского интерфейса.

Наконец, мы определяем три различных метода следующим образом:

  • startMonitoring() подключается к микрофону и начинаем асинхронно обрабатывать входящие данные
  • stopMonitoring() отключает все и сбрасывает данные
  • performFFT() — это асинхронная функция, которая фактически выполняет преобразование FFT, отвечая за извлечение различных частот из необработанной волны, полученной с микрофона

Шаг 2: Запуск мониторинга, остановка мониторинга и выполнение FFT

Давайте подробнее рассмотрим определения трех функций: startMonitoring(), stopMonitoring() и performFFT().

2.1 Асинхронный запуск мониторинга звуковой волны

Прежде всего, нам нужно определить функцию startMonitoring(), которая:

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

Мы сформировали код таким образом, чтобы все оставалось безопасным в рамках более строгой модели параллелизма Swift 6.

Сначала давайте определим некоторые свойства, которые понадобятся для выполнения FFT.

final class AudioWaveformMonitor {
    
    ...
    
    // 1. The configuration parameter for the FFT
    private let bufferSize = 8192
    // 2. The FFT configuration 
    private var fftSetup: OpaquePointer?
    
    ...
}

1. Определяем bufferSize, на котором будет установлена конфигурация FFT.

2. Создаем свойство fftSetup типа OpaquePointer?, которое будет указывать на саму конфигурацию, используемую Accelerate.

Затем в методе startMonitoring():

func startMonitoring() async {
    // 1. Set up the input node from the audio engine
    let inputNode = audioEngine.inputNode
    // 2. Set up the input format from the audio engine
    let inputFormat = inputNode.inputFormat(forBus: 0)
    
    // 3. Set the FFT configuration
    fftSetup = vDSP_DFT_zop_CreateSetup(nil, UInt(self.bufferSize), .FORWARD)

    
}

1. Создаем свойство inputNode и устанавливаем его значение из аудиодвижка.

2. Создаем еще одно свойство inputFormat и устанавливаем его значение из аудиодвижка.

3. Устанавливаем конфигурацию FFT с помощью vDSP_DFT_zop_CreateSetup, подготовив Accelerate к вычислениям в частотной области.

func startMonitoring() async {
    
    ...
    
    // Listen to microphone input
    let audioStream = AsyncStream<[Float]> { continuation in
        inputNode.installTap(onBus: 0, bufferSize: UInt32(bufferSize), format: inputFormat) { @Sendable buffer, _ in
            // 1. Access the first audio channel
            let channelData = buffer.floatChannelData?[0]
            let frameCount = Int(buffer.frameLength)
            
            // 2. Convert it into a Float array
            let floatData = Array(UnsafeBufferPointer(start: channelData, count: frameCount))
            
            // 3. Yield into the stream
            continuation.yield(floatData)
        }
    }

    
}

Далее мы создаем AsyncStream, который прослушивает входной сигнал микрофона с помощью installTap(onBus:). Внутри tap:

  1. Получаем доступ к первому аудиоканалу buffer.floatChannelData?[0]
  2. Преобразуем его в массив Float с помощью UnsafeBufferPointer
  3. yield этот массив в поток

Экземпляр audioStream связывает аудиовход с колбеком с более структурированным параллелизмом Swift 6. Пометив замыкание как @Sendable, мы гарантируем его потокобезопасность и соответствие самым последним, более строгим правилам параллельности.

Кроме того, поскольку AudioWaveformMonitor помечен @MainActor, все обновления свойств, включая изменения fftMagnitudes, происходят безопасно в основном потоке, сохраняя синхронизацию с интерфейсом, построенным с помощью SwiftUI.

func startMonitoring() async {
    
    ...
    
    do {
        // 1. Start the audioEngine
        try audioEngine.start()
        // 2. Update the property to monitor the state of the audioEngine
        isMonitoring = true
    } catch {
        print("Error starting audio engine: \(error.localizedDescription)")
        return
    }
    
    // 3. Retrieving the data from the audioStream
    for await floatData in audioStream {
        // 4. For each buffer, compute the FFT and store the results
        self.fftMagnitudes = await self.performFFT(data: floatData)
    }
}

Перед обработкой потока:

  1. Запускаем аудиодвижок
  2. И устанавливаем isMonitoring в true, чтобы отразить активное состояние
  3. Наконец, входим в цикл for await (выполняющийся на главном акторе), чтобы получить данные типа float
  4. Для каждого буфера вызываем асинхронную функцию performFFT(_:) для вычисления FFT вне главного потока, чтобы избежать замедления интерфейса, затем ожидаем и сохраняем результаты в fftMagnitudes

2.2 Остановка мониторинга звуковой волны

Теперь давайте определим функцию stopMonitoring(), чтобы остановить мониторинг звука.

func stopMonitoring() {
    // 1. Stop the audioEngine
    audioEngine.stop()
    
    // 2. Remove the tap from the microphone input
    audioEngine.inputNode.removeTap(onBus: 0)
    
    // 3. Reset the fftMagnitudes array to all zeros, to clear the visualization
    fftMagnitudes = [Float](repeating: 0, count: Constants.sampleAmount)
    
    // 4. Release the FFT setup free system memory
    if let setup = fftSetup {
        vDSP_DFT_DestroySetup(setup)
        fftSetup = nil
    }
    
    // 5. Update the audioEngine state property
    isMonitoring = false
}

Эта функция останавливает мониторинг звука следующим образом:

  1. Останавливает звуковой движок
  2. Отключает микрофон от линейного входа
  3. Затем сбрасывает массив fftMagnitudes до нулевых значений, обеспечивая полное очищение визуализации
  4. Кроме того, она освобождает настройки FFT (fftSetup), используемые для вычислений в частотной области, с помощью vDSP_DFT_DestroySetup, помогая освободить память и системные ресурсы
  5. Наконец, она устанавливает isMonitoring в false, чтобы отразить, что мониторинг полностью остановлен

2.3 Асинхронное выполнение быстрого преобразования Фурье

Наиболее важной функцией является performFFT().

Она отвечает за асинхронное создание волшебства. Она принимает массив чисел с плавающей запятой (data), представляющих аудиообразцы, взятые с микрофона, и выполняет быстрое преобразование Фурье (FFT) с использованием фреймворка Apple Accelerate.

Хотя код вызывает функции, которые вычисляют дискретное преобразование Фурье (DFT), он использует высокооптимизированные подпрограммы, которые реализуют алгоритм быстрого преобразования Фурье (FFT).

По сути, FFT — это эффективный способ вычисления DFT, и фреймворк Apple Accelerate предоставляет эту быструю, оптимизированную версию. Поэтому мы часто называем его FFT, хотя технически мы вычисляем DFT.

func performFFT(data: [Float]) async -> [Float] {
    // Check the configuration
    guard let setup = fftSetup else {
        return [Float](repeating: 0, count: Constants.sampleAmount)
    }
    
    // 1. Copy of the audio samples as float
    var realIn = data
    // 2. The imaginary part
    var imagIn = [Float](repeating: 0, count: bufferSize)
    
    // 3. The transformed values of the real data
    var realOut = [Float](repeating: 0, count: bufferSize)
    // The transformed values of the imaginary data
    var imagOut = [Float](repeating: 0, count: bufferSize)
}

После того, как эта асинхронная функция выполняет быстрое преобразование Фурье (FFT) входящих аудиоданных, она получает их частотный состав и возвращает массив амплитуд, готовый для отображения формы волны.

Мы начинаем с проверки наличия настройки FFT. Если ее нет, мы возвращаем массив нулей длиной Constants.sampleAmount. Предполагая, что настройка действительна, мы инициализируем следующие массивы:

  1. realIn содержит действительную часть входного аудиосигнала (данные)
  2. imagIn — мнимую часть, которая устанавливается равной нулю (поскольку входной сигнал имеет действительное значение)
  3. И realOut и imagOut будут хранить преобразованные значения после применения FFT
func performFFT(data: [Float]) async -> [Float] {
    ...
    // Property storing computed magnitudes
    var magnitudes = [Float](repeating: 0, count: Constants.sampleAmount)
    
    // 1. Nested loops to safely access all data
    realIn.withUnsafeMutableBufferPointer { realInPtr in
        imagIn.withUnsafeMutableBufferPointer { imagInPtr in
            realOut.withUnsafeMutableBufferPointer { realOutPtr in
                imagOut.withUnsafeMutableBufferPointer { imagOutPtr in
		                // 2. Execute the Discrete Fourier Transform (DFT)
                    vDSP_DFT_Execute(setup, realInPtr.baseAddress!, imagInPtr.baseAddress!, realOutPtr.baseAddress!, imagOutPtr.baseAddress!)
                    
                    
                    // 3. Hold the DFT output
                    var complex = DSPSplitComplex(realp: realOutPtr.baseAddress!, imagp: imagOutPtr.baseAddress!)
                    // 4. Compute and save the magnitude of each frequency component
                    vDSP_zvabs(&complex, 1, &magnitudes, 1, UInt(Constants.sampleAmount))
                    
                }
            }
        }
    }
    
    return magnitudes.map { min($0, Constants.magnitudeLimit) }
}
  1. Используя фреймворк Accelerate, мы предоставляем прямые указатели (baseAddress!) на каждый массив, чтобы фреймворк мог взаимодействовать с данными, и с помощью последовательности вложенных замыканий (withUnsafeMutableBufferPointer) обеспечиваем безопасный доступ к памяти
  2. На этом этапе функция выполняет дискретное преобразование Фурье (DFT) с помощью vDSP_DFT_Execute, разбивая и преобразуя сигнал в его частотные компоненты и сохраняя результаты в realOut и imagOut.
  3. ЗдесьDSPSplitComplex — это структура, которая хранит выходные данные DFT в виде отдельных действительных и мнимых частей.
  4. Она используется vDSP_zvabs для вычисления амплитуды каждой частотной компоненты с помощью евклидовой нормы:

magnitude=√realOut²+imagOut²

Результат затем сохраняется в массиве Float под названием magnitudes.

func performFFT(data: [Float]) async -> [Float] {
    ...
    // The returing values
    return magnitudes.map { min($0, Constants.magnitudeLimit) }

Хотя буфер необработанного аудио содержит тысячи семплов (в нашем случае 8192), мы сохраняем только 200 (Constants.sampleAmount), чтобы обеспечить плавное и эффективное отображение графика.

Наконец, мы возвращаем результаты, которые сопоставляются, чтобы ограничить их максимальное значение до Constants.magnitudeLimit. Это ограничение гарантирует, что аномально громкие или шумные частоты не искажают визуальное представление.

Эти величины отражают силу каждой частоты в аудиосигнале, что является ключевым моментом для визуализации живых аудиоволн.

Шаг 3: Создание пользовательского интерфейса с помощью SwiftUI

И наконец, мы создаем интерфейс для отображения живой волны на основе данных FFT в реальном времени в нашем приложении. Мы будем использовать Swift Charts для визуализации величин в виде линейного графика.

struct ContentView: View {

    // 1. AudioWaveformMonitor shared instance
    private var monitor = AudioWaveformMonitor.shared
    // Gradients for the chart
    private let chartGradient = LinearGradient(
        gradient: Gradient(colors: [.blue, .purple, .red]),
        startPoint: .leading,
        endPoint: .trailing
    )
    
    var body: some View {
        VStack(spacing: 20) {
		        // Title
            Text("Live Audio Waveform")
                .font(.title2.bold())
                .padding(.top, 20)
            
            Spacer()
            
            // 2. The button to start and stop the monitoring based on AudioWaveformMonitor.isMonitoring property
            Button(action: {
                if monitor.isMonitoring {
                    monitor.stopMonitoring()
                } else {
                    Task { await monitor.startMonitoring() }
                }
            }) {
                Label(monitor.isMonitoring ? "Stop" : "Start", systemImage: monitor.isMonitoring ? "stop.fill" : "waveform")
                    .font(.title2.bold())
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(monitor.isMonitoring ? Color.red : Color.blue)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: 12))
                    .padding(.horizontal)
            }
        }
        .padding(.bottom, 20)
        .padding()
    }
}

Создаем представление, содержащее:

  1. Общий экземпляр AudioWaveformMonitor
  2. Кнопку для запуска и остановки мониторинга на основе свойства AudioWaveformMonitor.isMonitoring, которая позволяет пользователю запускать и останавливать мониторинг аудио, переключая ее метку и цвет между «Start» и «Stop» в зависимости от состояния мониторинга.

Теперь импортируем фреймворк Charts и добавим следующее между заголовком и Spacer:

struct ContentView: View {
		...
    
    var body: some View {
        VStack(spacing: 20) {
		        ...
            
            // 1. Chart
            Chart(monitor.downsampledMagnitudes.indices, id: \\ .self) { index in
		            // 2. The LineMark
                LineMark(
		                // a. frequency bins adjusted by Constants.downsampleFactor to spread points apart
                    x: .value("Frequency", index * Constants.downsampleFactor),
                    
                    // b. the magnitude (intensity) of each frequency 
                    y: .value("Magnitude", monitor.downsampledMagnitudes[index])
                )
                
                // 3. Smoothing the curves
                .interpolationMethod(.catmullRom)
                
                // The line style
                .lineStyle(StrokeStyle(lineWidth: 3))
                // The color
                .foregroundStyle(chartGradient)
            }
            .chartYScale(domain: 0...max(monitor.fftMagnitudes.max() ?? 0, Constants.magnitudeLimit))
            .chartXAxis(.hidden)
            .chartYAxis(.hidden)
            .frame(height: 300)
            .padding()
            
            // 3. Smoothing the curves
            .animation(.easeOut, value: monitor.downsampledMagnitudes)
            
            ...
}

1. Добавляем диаграмму, которая визуализирует живую звуковую волну, отображая частоты FFT в формате линейного графика.

2. Используем LineMark:

  • Ось x представляет частотные диапазоны, скорректированные Constants.downsampleFactor для распределения точек
  • Ось y показывает величину (интенсивность) каждой частоты, но ограничена Constants.magnitudeLimit, чтобы избежать чрезмерных пиков

3. Для сглаживания кривых мы используем .interpolationMethod(.catmullRom) и неявную анимацию .animation(.easeOut, value: monitor.downsampledMagnitudes), которая обновляет график при каждом обновлении величин.

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

Конечный результат

Теперь мы разработали базовое приложение, которое может получать аудио в реальном времени с микрофона, анализировать его с помощью FFT и отображать амплитуды частот в виде динамической волны с помощью SwiftUI и Swift Charts. Эта настройка является отличной основой для создания более продвинутых приложений для обработки аудио.

Не стесняйтесь расширять его, добавляя такие функции, как распознавание звука, фильтрация аудио, запись или более сложные визуализации в соответствии с потребностями вашего проекта!

Да, мы только что нарисовали звуковую волну с помощью LineMark из Swift Chart. Но если вы ищете другие способы визуализации звука, существует бесконечное множество режимов визуализации, которые, конечно же, можно применить.

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

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

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: