DC143C'

Audio Kit 라이브러리를 이용한 기타 음 식별 앱 만들기

개요

기타 연주는 제 오랜 취미입니다. 시간이 나면 늘 연습을 하고, 유튜브 알고리즘은 관련 영상으로 도배되어 있으며, 악기를 살 돈도 없지만 한 달에 한 번은 괜히 낙원상가에 가보곤 합니다.

프로그래밍 역시 제 오랜 취미이자 삶의 일부입니다. 직업으로 삼을 정도니까요. 그렇다보니 문득 기타와 프로그래밍, 제 흥미를 양분하는 이 두 가지 분야를 연결하는 프로젝트를 진행해보면 어떨까 하는 아이디어가 떠올랐습니다.

프로젝트는 지금 이 순간에도 진행 중입니다. 앱의 자세한 기획과 아이디어를 전부 공유하기엔 조금 이르지만, 개발을 진행하는 중간 중간 제가 배우고 느낀 점들을 공유하고자 합니다.

이 포스트는 그 첫 번째 단계로, 기타 소리를 오디오 데이터로 변환하고 식별하는 기능을 구현하는 과정을 다룹니다.

AudioKit 이란?

AudioKit GitHub 레포지토리

AudioKit 쿡북 앱 GitHub 레포지토리

AudioKit은 Swift로 작성된 오디오 라이브러리입니다. AudioKit은 실시간 신호 처리, 음향 효과 적용, 오디오 분석 등을 지원하여 복잡한 오디오 작업을 손쉽게 구현할 수 있습니다.

제가 기획한 앱 역시 구현을 위해 오디오 데이터를 다루는 과정이 필수적이었습니다.

저는 앱의 성능과 크로스 플랫폼 개발의 이점을 얻기 위해 네이티브 영역에서 오디오 처리 및 식별 등의 고급 기능을 구현하고, Flutter로 앱의 UI 영역과 기본적인 기능을 연결하는 구조를 계획했습니다.

이중 애플 네이티브 생태계에서 오디오 처리 기능을 구현하기 위해 Swift로 작성된 AudioKit을 선택하게 되었습니다.

AudioKit CookBook App 스크린샷
AudioKit CookBook App 스크린샷

AudioKit은 오픈소스 라이브러리입니다.

위 스크린샷은 AudioKit 팀에서 제공하는 쿡북 앱으로, 다양한 예제와 샘플 코드를 통해 AudioKit의 기능을 학습할 수 있는 데모 앱입니다.

소스코드 역시 공개되어 있으며, 저는 이 쿡북 앱의 Tuner 모듈을 참고하여 기타 음성을 오디오 데이터로 변환하고 식별하는 기능을 구현하였습니다.

결국 음악 이론은 필요해

당장에 제가 구현한 기타 음 식별 기능을 소개해드리고 싶지만, 안타깝게도 그 전에 넘어야 할 지루한 과정이 있습니다.

기타의 음성을 녹음하여 오디오 데이터로 전환하고, 이것을 우리에게 익숙한 음이름 체계로 변환하는 과정은 약간의 음악 이론 지식이 필요합니다.

기타의 소리는 무엇으로 이루어져 있는지, 주파수는 무엇이고, 또 어떻게 변화하는지, 그리고 이것을 식별하여 우리에게 익숙한 음이름으로 변환할 수 있는 방법은 어떤 건지… 등은 알아야 위의 기능을 구현할 수 있으니까요.

주파수가 뭔가요?

제가 구현하고자 하는 기타 음 식별 기능에는 주파수 분석이 필요합니다.

주파수. 음악 이론에 관심이 없는 사람들도 들어본 적이 있을 만큼 익숙한 단어지만, 정작 주파수가 정확히 무엇인지 설명할 수 있는 사람은 많지 않습니다. 저조차도 그랬습니다.

소리와 주파수
소리와 주파수

모든 소리는 파동입니다. 파동은 보통 공기와 같은 매질을 통해 전달되며, 이 파동의 진동수(Hz, 헤르츠; 초당 진동 수)주파수라고 합니다.

파동의 진동수는 소리의 높낮이를, 파동의 진폭은 소리의 크기(dB, 데시벨)를 결정합니다. 소리의 높낮이는 주파수에 따라 결정되며, 주파수가 높을수록 음이 높아지고, 주파수가 낮을수록 음이 낮아집니다.

당연하지만 기타의 현을 튕길 때도 파동이 일어납니다. 이 현의 진동수를 통해 주파수를 결정할 수 있습니다.

현의 두께, 장력, 길이 등이 진동수에 영향을 미치며, 프렛을 눌러 현의 길이를 조절함으로써 다양한 음정을 만들어냅니다.

즉, 기타를 타현할 때의 주파수를 알아내면 음정과 음이름을 알아낼수 있겠죠. 우리가 AudioKit을 사용하여 분석해야 할 것이 바로 이것입니다.

물리적 파동에서 디지털 데이터로, 데이터를 음 이름으로

이제 기타 음 식별 기능을 구현을 위해 무엇이 필요한지 알았으니, 만들어볼 차례입니다.

기타 음 식별 기능 작동 시퀀스
기타 음 식별 기능 작동 시퀀스

위 시퀀스 구조도는 우리가 구현할 기타 음 식별 기능의 시퀀스를 간략하게 보여줍니다.

  • 앱 사용자가 기타를 연주(현을 튕겨 음파를 발생)한다.
  • 마이크로 음파(아날로그 신호)를 캡쳐한다.
  • AudioProcessor 코드에서 아날로그 신호를 PCM 형식의 디지털 신호로 변환한다
  • 변환한 디지털 신호를 FFT 알고리즘을 통해 주파수를 분석한다.
  • 분석한 주파수를 음 이름에 매핑한다.
  • UI에 음 이름을 표시한다. 유저가 이를 시인한다.

PCM, FFT 등.. 익숙하지 않은 용어들이 난무하고 있습니다. 상술한 시퀀스를 구현하는 코드들과 함께 용어의 정의와 구현하는 과정을 차근차근 알아보도록 합시다.

마이크로 기타 음파를 캡쳐

기타 음 식별 기능 구현의 첫 번째 과정은 유저가 연주하는 기타의 음파를 캡쳐하는 것입니다.

// 오디오 처리를 위한 필수 프레임워크들
import AudioKit
import AVFoundation
import AudioToolbox

class AudioProcessor {
    // MARK: - Properties
    private let engine = AudioEngine()  // 오디오 엔진
    private var mic: AudioEngine.InputNode  // 마이크 입력
    private var inputGain: Fader  // 입력 게인 조절
    
    // MARK: - 오디오 세션 설정
    private func setupAudioSession() throws {
        do {
            // 오디오 세션 카테고리 설정
            // .playAndRecord: 녹음과 재생 모두 가능
            // options: 블루투스 등 다양한 입력 장치 지원
            try Settings.session.setCategory(.playAndRecord,
                                           options: [.defaultToSpeaker,
                                                   .mixWithOthers,
                                                   .allowBluetooth,
                                                   .allowBluetoothA2DP])
            // 오디오 세션 활성화
            try Settings.session.setActive(true)
        } catch {
            print("오디오 설정 실패:", error)
            print("Error details:", error.localizedDescription)
            fatalError(error.localizedDescription)
        }
    }
}

음성 캡쳐를 위해서는, 먼저 오디오 세션을 설정해야 합니다. 위 코드는 통상적인 컨트롤러 코드의 initial 부분에 대응한다고 생각하셔도 무방합니다.

AudioKit의 전역적인 설정은 Settings 클래스에서 관리합니다.

해당 코드에서 오디오 세션의 카테고리는 어떤 방식을 사용할 것인지(예: 녹음과 재생 모두 가능한 형태. 혹은 녹음만 가능한 형태 등), 옵션은 어떤 추가적인 기능을 사용할 것인지(예: 블루투스 등)를 결정합니다.

class AudioProcessor {
    ///위의 오디오 세션 코드 이후에 작성됩니다.

    // MARK: - 마이크 초기화 및 설정
    private func setupMicrophone() {
        // 마이크 입력 확인
        guard let input = engine.input else {
            fatalError("마이크를 찾을 수 없습니다")
        }
        guard let device = engine.inputDevice else {
            fatalError("입력 장치를 찾을 수 없습니다")
        }
        
        // 마이크 및 오디오 처리 체인 설정
        mic = input
        inputGain = Fader(mic, gain: 3.0)  // 입력 신호 증폭. PitchTap에서 사용되는 게인. 현재 포스팅에선 미사용.
        tappableNodeA = Fader(inputGain)
        tappableNodeB = Fader(tappableNodeA)
        silence = Fader(tappableNodeB, gain: 0)
        engine.output = silence
        
        // 주파수 감지를 위한 탭 설정 (중요!선
        fftTap = FFTTap(mic, bufferSize: 4096) { [weak self] fftData in
            self?.fftData = fftData
        }
    }
    
    // MARK: - 오디오 엔진 시작
    func start() {
        do {
            try engine.start()  // 오디오 엔진 시작
            fftTap.start()    // 주파수 감지 시작
        } catch {
            print("AudioEngine 시작 실패:", error)
        }
    }
}

그 후엔, 마이크와 오디오 엔진을 초기화하고 시작하는 과정이 필요합니다.

이 부분에서는 마이크의 게인(입력 신호 증폭. 저는 3.0으로 설정하였습니다)을 조절하고, 오디오 처리 체인(오디오 엔진의 입력과 출력을 연결하는 역할)을 설정하고, 오디오 신호를 전달받을 탭을 설정합니다.

은 오디오 신호를 처리하는 데 사용되는 객체로, AudioKit은 목적에 따라 다양한 탭을 제공합니다. 저는 주파수 스펙트럼 분석을 위한 FFTTap을 사용했습니다.

FFTTap을 비롯한 탭 객체들은 쉽게 말해 오디오 데이터 스트림을 특수한 목적에 맞게끔 처리하는 리스너라고 이해할 수 있습니다.

  • PitchTap: 피치(주파수) 감지
  • FFTTap: 주파수 스펙트럼 분석
  • AmplitudeTap: 음량 변화 감지

AudioKit에서 제공하는 탭의 목록은 위와 같으며, FFTTap을 활용하여 기타 소리의 주파수 스펙트럼을 분석하는 부분은 우리가 구현할 기능의 핵심입니다.

탭의 작동 방식과 활용 방법은 이후 문단에서 집중 조명합니다.

PCM(Pulse Code Modulation) 변조…?

그럼, 이제 PCM 변조를 구현해야 할까요? 사실, PCM 변조는 AVFoundation 프레임워크에서 이미 구현되어 있습니다.

PCM(Pulse Code Modulation)은 오디오 데이터를 디지털 신호로 변환하는 과정입니다.

iOS에서는 AVFoundation 프레임워크가 이 변환을 자동으로 처리해주기 때문에 직접 구현할 필요는 없지만, 오디오 프로그래밍의 기초가 되는 중요한 개념입니다.

PCM은 아날로그 신호를 이산적인 디지털 값으로 변환하는 과정으로, 다음 두 단계로 이루어집니다:

  1. 샘플링(Sampling): 연속적인 음파를 일정 간격으로 측정
    • CD 품질의 경우 초당 44,100번 측정 (44.1kHz)
    • 사람이 들을 수 있는 최고 주파수(20kHz)의 2배 이상 샘플링
  2. 양자화(Quantization): 측정된 값을 디지털로 변환
    • 16비트 양자화: 65,536개의 서로 다른 값으로 표현
    • 24비트 양자화: 더 정밀한 음질 구현 가능

우리가 구현할 기능은 이미 PCM 변조를 통해 디지털 신호로 이루어진 오디오 데이터를 활용하고 있습니다.

 fftTap = FFTTap(mic, bufferSize: 4096) { [weak self] fftData in
            self?.fftData = fftData
        }

대표적으로 위의 FFTTap에서 오디오 데이터를 받아 주파수 스펙트럼을 분석하는 과정 역시 변환된 디지털 신호로 처리하고 있습니다.

FFT(Fast Fourier Transform) 주파수 분석

그래서, 아까부터 자주 거론되던 FFT가 대체 뭘까요?

FFT(고속 푸리에 변환)는 시간 영역의 신호를 주파수 영역의 신호로 변환하는 알고리즘입니다.

오디오 엔지니어링 분야 외에도 영상 처리, 신호 처리, 통신 분야에서 널리 사용되는 알고리즘으로, 우리는 기타 소리를 오디오 데이터로 변환하고 이를 FFT를 통해 주파수 분석하는 과정으로 음 식별 기능을 구현합니다.

시간과 주파수

고속 푸리에 변환
(좌) 시간 영역, (우) 주파수 영역. FFT 변환을 나타내는 예시 그래프 이미지

FFT에 대해 조금만 더 짚고 넘어가봅시다.

소리는 크게 두 가지 관점으로 이해할수 있습니다. 하나는 시간의 영역이고, 하나는 주파수의 영역입니다.

시간 영역은 시간에 따라 소리가 어떻게 변화하는지를 보여줍니다. 파형의 형태로 표현되는 이 영역은, 시간에 따른 신호의 크기를 나타냅니다.

주파수 영역은 소리의 주파수 성분을 보여줍니다. 소리를 구성하는 각 음의 높낮이(Pitch)와 진폭(Amplitude)를 보여주는 영역이죠. 주파수 영역의 신호는 주파수 스펙트럼이라고 부릅니다.

마이크로 들어오는 오디오 데이터는 시간에 따른 진폭 변화를 나타내는 시간 영역 신호입니다. 그러나 이 형태로는 소리를 구성하는 주파수 성분을 직접적으로 알 수 없습니다.

FFT(고속 푸리에 변환)를 통해 시간 영역의 신호를 주파수 영역으로 변환하면, 소리를 구성하는 각 주파수와 그 세기를 파악할 수 있게 되어, 음의 식별, 음색 분석, 소리의 특성 파악 등이 가능해집니다. 이것이 우리가 FFT를 사용하는 이유입니다.

FFTTap 주파수 스펙트럼 분석

그렇다면, 우리의 프로젝트에서 FFT를 활용하는 부분은 어디일까요?

 fftTap = FFTTap(mic, bufferSize: 4096) { [weak self] fftData in
            self?.fftData = fftData
        }

위 문단에서도 몇 번 등장한 FFTTap이 바로 이 부분입니다. FFTTap은 마이크에서 캡쳐한 오디오 데이터를 받아 주파수 스팩트럼을 분석합니다.

실제로 프로젝트에서 동작하는 전체 코드는 다음과 같습니다.

class AudioProcessor: ObservableObject {
    // ... 기존 코드 ...
    
    // FFT를 통한 피치 감지 결과를 저장할 구조체
    struct FFTPitchResult {
        let note: String
        let frequency: Float
        let magnitude: Float
    }
    
    // FFT 분석을 통한 피치 감지 함수
    private func detectPitchFromFFT(_ fftData: [Float]) -> FFTPitchResult? {
        let binWidth = 44100.0 / (2.0 * Float(fftData.count))
        var strongestPeak: (frequency: Float, magnitude: Float) = (0, 0)
        
        // 1. 가장 강한 주파수 성분 찾기
        for i in 1..<fftData.count-1 {
            if fftData[i] > fftData[i-1] && fftData[i] > fftData[i+1] {
                let frequency = Float(i) * binWidth
                if frequency >= 82.0 && frequency <= 2000.0 {  // 기타 주파수 범위
                    if fftData[i] > strongestPeak.magnitude {
                        strongestPeak = (frequency, fftData[i])
                    }
                }
            }
        }
        
        // 2. 주파수를 가장 가까운 음계로 매핑
        if let (note, positions) = noteRecognizer.recognizePitch(frequency: strongestPeak.frequency) {
            return FFTPitchResult(
                note: note,
                frequency: strongestPeak.frequency,
                magnitude: strongestPeak.magnitude
            )
        }
        
        return nil
    }
    
    init() {
        // ... 기존 초기화 코드 ...
        
        // FFTTap 설정
        fftTap = FFTTap(mic, bufferSize: 4096) { [weak self] fftData in
            // FFT 데이터를 통한 피치 감지
            if let pitchResult = self?.detectPitchFromFFT(fftData) {
                // 진폭이 충분히 큰 경우에만 처리
                if pitchResult.magnitude > 0.05 {
                    let performanceData = PerformanceData(
                        timestamp: CACurrentMediaTime(),
                        type: .single,
                        notes: [pitchResult.note],
                        frequency: pitchResult.frequency,
                        amplitude: pitchResult.magnitude,
                        positions: nil,
                        mostLikelyPosition: nil
                    )
                    
                    // 제 앱에선 UI 표현을 위해 Flutter 영역으로 데이터를 전달합니다.
                    // 순수 Swift로 앱을 개발하시는 경우에는 이 영역에서 별개의 처리를 진행해야 합니다.
                    self?.sendToFlutter(performanceData)
                }
            }
        }
    }
}

마이크를 통해 캡쳐한 오디오 데이터를 FFTTap에서 받아 주파수 스펙트럼을 분석하고, 이 과정을 통해 처리된 fftData(주파수 스팩트럼 데이터)를 detectPitchFromFFT 함수에 전달합니다.

detectPitchFromFFT 함수는 기타 소리의 일반적인 주파수 범위(82.0 ~ 2000.0Hz. 피킹 하모닉스같은 특정 주법의 경우 더 높게 나타날 수 있습니다)를 고려하여, 가장 강한 주파수 성분을 찾아냅니다.

음 이름 매칭

이제 마이크 및 오디오 세션 설정부터 FFTTap을 활용한 주파수 스팩트럼 분석까지 구현했습니다. 드디어 음 이름 매칭 기능을 구현할 차례입니다.

우리가 분석한 주파수 데이터를 음 이름으로 매칭하려면, 기타가 낼 수 있는 주파수의 범위와 표준 음계의 주파수 범위 리스트를 알아야 합니다.

표준 음계

음악에서 각 음계는 특정 주파수와 대응됩니다. 우리가 흔히 알고 있는 ‘도레미파솔라시도’는 각각 고유한 주파수를 가지고 있으며, 이는 수학적으로 계산이 가능합니다.

현대 음악에서는 A4(라4)를 440Hz로 정의합니다. 이를 ‘표준 음고(Standard Pitch)’ 혹은 ‘Concert Pitch’라고 부릅니다.

모든 음계의 주파수는 이 A4(440Hz)를 기준으로 계산할 수 있습니다. 한 옥타브 위의 음은 주파수가 2배가 되고, 한 옥타브 아래의 음은 주파수가 1/2이 됩니다.

반음 간격과 주파수 계산

두 음 사이의 상대적인 주파수 비율은 어떻게 계산할까요?

여기에는 다음과 같은 공식을 대입할수 있습니다:

f = 440 × 2^(n/12)

여기서:

  • f는 구하고자 하는 음의 주파수
  • n은 A4로부터의 반음 간격 (위로 갈수록 양수, 아래로 갈수록 음수)
  • 12는 한 옥타브 안의 반음 개수

기타의 주파수 범위

그럼 위 공식으로 기타의 주파수 범위를 알아봅시다.

기타는 6개의 현과 보통 21~24개의 프렛으로 구성되어 있습니다. 각 현은 프렛을 누르는 위치에 따라 다른 주파수를 발생시킵니다.

프렛과 프렛 사이의 간격은 반음(Semitone)을 나타내며, 각 프렛은 이전 프렛보다 약 1.059배(2^(1/12))의 주파수를 가집니다.

예를 들어 1번 현(고음 E)의 개방현(0번 프렛)이 329.63Hz라면:

  • 1번 프렛: 349.23Hz (F4)
  • 2번 프렛: 369.99Hz (F#4)
  • 3번 프렛: 392.00Hz (G4)

와 같이 진행됩니다.

위 공식과 기타 프랫의 구성으로 대략적인 기타의 전체 음역대를 구해보면, 다음과 같습니다:

현 번호 개방현(0프렛) 12프렛 24프렛
1번 현(E4) 329.63Hz 659.26Hz 1318.51Hz
2번 현(B3) 246.94Hz 493.88Hz 987.77Hz
3번 현(G3) 196.00Hz 392.00Hz 783.99Hz
4번 현(D3) 146.83Hz 293.66Hz 587.33Hz
5번 현(A2) 110.00Hz 220.00Hz 440.00Hz
6번 현(E2) 82.41Hz 164.81Hz 329.63Hz
기타 음역대
음계 주파수 그래프 이미지

이제 이렇게 파악한 기타의 음계별 주파수 범위를 표준 음계와 대조하면, 주파수 데이터를 음 이름 리스트로 매칭할 수 있습니다.

import Foundation

class NoteRecognizer {
    // 음계 주파수 정의
    private let noteFrequencies: [String: Float] = [
        "E2": 82.41, "F2": 87.31, "F#2": 92.50, "G2": 98.00,
        "G#2": 103.83, "A2": 110.00, "A#2": 116.54, "B2": 123.47,
        "C3": 130.81, "C#3": 138.59, "D3": 146.83, "D#3": 155.56,
        "E3": 164.81, "F3": 174.61, "F#3": 185.00, "G3": 196.00,
        "G#3": 207.65, "A3": 220.00, "A#3": 233.08, "B3": 246.94,
        "C4": 261.63, "C#4": 277.18, "D4": 293.66, "D#4": 311.13,
        "E4": 329.63, "F4": 349.23, "F#4": 369.99, "G4": 392.00,
        "G#4": 415.30, "A4": 440.00, "A#4": 466.16, "B4": 493.88,
        "C5": 523.25, "C#5": 554.37, "D5": 587.33, "D#5": 622.25,
        "E5": 659.26, "F5": 698.46, "F#5": 739.99, "G5": 783.99,
        "G#5": 830.61, "A5": 880.00, "A#5": 932.33, "B5": 987.77,
        "C6": 1046.50, "C#6": 1108.73, "D6": 1174.66, "D#6": 1244.51,
        "E6": 1318.51
    ]
    
    // 주파수를 음계로 변환하는 메서드
    func recognizePitch(frequency: Float) -> String? {
        var matches: [(note: String, difference: Float)] = []
        
        // 모든 가능한 매칭을 수집
        for (note, noteFreq) in noteFrequencies {
            let difference = abs(frequency - noteFreq)
            if difference < 3.0 {  // 3Hz 이내의 모든 매칭을 고려
                matches.append((note, difference))
            }
        }
        
        // 매칭된 것이 없으면 nil 반환
        guard !matches.isEmpty else { return nil }
        
        // 가장 가까운 음계 반환
        let bestMatch = matches.min(by: { $0.difference < $1.difference })
        return bestMatch?.note
    }
}

음이름-주파수 리스트와 주파수를 음이름으로 변환하는 메서드를 지닌 NoteRecognizer 클래스는 AudioProcessor에서 FFT 변환을 통해 수집한 주파수 데이터로 음 이름을 매칭합니다.

기타의 특성상 현의 울림이 완전히 일정하지 않으므로, 3Hz 이내의 주파수 차이를 허용하여 모든 음 이름 매칭을 고려, 그 중 가장 가까운 음 이름을 반환합니다.

class AudioProcessor: ObservableObject {
    // ... 기존 코드 ...
    
    // FFT를 통한 피치 감지 결과를 저장할 구조체
    struct FFTPitchResult {
        let note: String
        let frequency: Float
        let magnitude: Float
    }
    
    // FFT 분석을 통한 피치 감지 함수
    private func detectPitchFromFFT(_ fftData: [Float]) -> FFTPitchResult? {
        let binWidth = 44100.0 / (2.0 * Float(fftData.count))
        var strongestPeak: (frequency: Float, magnitude: Float) = (0, 0)
        
        // 1. 가장 강한 주파수 성분 찾기
        for i in 1..<fftData.count-1 {
            if fftData[i] > fftData[i-1] && fftData[i] > fftData[i+1] {
                let frequency = Float(i) * binWidth
                if frequency >= 82.0 && frequency <= 2000.0 {  // 기타 주파수 범위
                    if fftData[i] > strongestPeak.magnitude {
                        strongestPeak = (frequency, fftData[i])
                    }
                }
            }
        }
        
        // 2. 주파수를 가장 가까운 음계로 매핑
        if let (note, positions) = noteRecognizer.recognizePitch(frequency: strongestPeak.frequency) {
            return FFTPitchResult(
                note: note,
                frequency: strongestPeak.frequency,
                magnitude: strongestPeak.magnitude
            )
        }
        
        return nil
    }
    
    init() {
        // ... 기존 초기화 코드 ...
        
        // FFTTap 설정
        fftTap = FFTTap(mic, bufferSize: 4096) { [weak self] fftData in
            // FFT 데이터를 통한 피치 감지
            if let pitchResult = self?.detectPitchFromFFT(fftData) {
                // 진폭이 충분히 큰 경우에만 처리
                if pitchResult.magnitude > 0.05 {
                    let performanceData = PerformanceData(
                        timestamp: CACurrentMediaTime(),
                        type: .single,
                        notes: [pitchResult.note],
                        frequency: pitchResult.frequency,
                        amplitude: pitchResult.magnitude,
                        positions: nil,
                        mostLikelyPosition: nil
                    )
                    
                    // 제 앱에선 UI 표현을 위해 Flutter 영역으로 데이터를 전달합니다.
                    // 순수 Swift로 앱을 개발하시는 경우에는 이 영역에서 별개의 처리를 진행해야 합니다.
                    self?.sendToFlutter(performanceData)
                }
            }
        }
    }
}

다시 AudioProcessor 클래스로 돌아와서, NoteRecognizer 클래스의 recognizePitch 메서드를 통해 주파수 데이터를 음 이름으로 매칭하는 과정을 확인합니다.

기능적인 시퀀스를 요약하자면, 마이크로 기타 소리를 캡쳐하고, 이를 FFTTap을 통해 주파수 스펙트럼으로 분석하고, 이것을 다시 NoteRecognizer를 통해 음 이름으로 매칭하는 과정입니다.

여기에 상술했듯 저는 UI나 앱의 기본적인 기능을 플러터로 구현하고 있기에, sendToFlutter라는 메서드를 통해 MethodChannel로 플러터 앱에 데이터를 전달하고 있습니다.

FFT를 통한 주파수 변환으로 음 이름을 매칭 후, 플러터 앱으로 전달되는 화면입니다.

이렇게 기타 소리를 캡쳐하여 음 이름을 식별하는 기능을 구현했습니다. 🎉🎉

마치며

오디오 엔지니어링 분야는 정말 넓고 깊습니다.

알아야 할 기반 지식도 많고, 특수한 데이터 처리 방법도 많기에 원래라면 저도 엄두를 내지 못하던 분야였습니다.

하지만 AudioKit을 활용하면 이러한 부담이 조금은 덜해집니다. 푸리에 변환 알고리즘이나, 오디오 버퍼 처리 등의 복잡한 기능을 라이브러리에서 처리해주니 이 분야에 관심과 열정만 있다면 해볼만 하다는 생각이 듭니다.

덕분에 저도 예전부터 만들고 싶던 기타 관련 프로젝트를 시작할수 있었죠.

아마 앱스토어에 등록된 대다수의 기타 튜너 앱도 이러한 기술과 방식을 사용하지 않을까 싶습니다. 이 포스트에서 다룬 음계 식별 기능에 실시간 그래프 UI와 기타 소리만 걸러내는 필터를 적용하면 거의 튜너 앱과 다름이 없으니까요.

저 역시도 그런 방향으로 프로젝트를 진행해볼까 생각해봤지만, 조금은 다른 방향으로 나아갈 생각입니다.

오늘 포스트에서 다룬 내용은 그 첫걸음입니다.

앞으로 더 쓸만한 기능을 개발해가며, 여러분과 나누고 싶은 정보가 생기면 새로운 포스트로 찾아뵙겠습니다.

읽어주셔서 고맙습니다.

추천 포스트