diff --git a/study.xcodeproj/project.pbxproj b/study.xcodeproj/project.pbxproj index 7a25236..361f3e9 100644 --- a/study.xcodeproj/project.pbxproj +++ b/study.xcodeproj/project.pbxproj @@ -94,6 +94,8 @@ ); mainGroup = 2415A43F2DD0DF8E00EDC236; minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); preferredProjectObjectVersion = 77; productRefGroup = 2415A4492DD0DF8E00EDC236 /* Products */; projectDirPath = ""; @@ -248,10 +250,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"study/assets\""; - DEVELOPMENT_TEAM = WL6LTCP7D2; + DEVELOPMENT_TEAM = P26KYG6RH6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "기타 소리를 듣고 피드백을 드리기 위해 마이크 접근 권한이 필요합니다."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -269,11 +272,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "ada-4th-c3.study"; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.2; }; name = Debug; @@ -287,10 +291,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"study/assets\""; - DEVELOPMENT_TEAM = WL6LTCP7D2; + DEVELOPMENT_TEAM = P26KYG6RH6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "기타 소리를 듣고 피드백을 드리기 위해 마이크 접근 권한이 필요합니다."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -308,11 +313,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "ada-4th-c3.study"; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.2; }; name = Release; diff --git a/study.xcodeproj/xcshareddata/xcschemes/study.xcscheme b/study.xcodeproj/xcshareddata/xcschemes/study.xcscheme new file mode 100644 index 0000000..99ade34 --- /dev/null +++ b/study.xcodeproj/xcshareddata/xcschemes/study.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/study/core/base/BaseView.swift b/study/core/base/BaseView.swift index 017179b..bfa3a73 100644 --- a/study/core/base/BaseView.swift +++ b/study/core/base/BaseView.swift @@ -28,5 +28,8 @@ struct BaseView>: Vie .navigationBarBackButtonHidden(navigationBarBackButtonHidden) .navigationBarHidden(navigationBarHidden) } + .onDisappear { + viewModel.dispose() + } } } diff --git a/study/core/base/BaseViewModel.swift b/study/core/base/BaseViewModel.swift index 3497488..4c8df9a 100644 --- a/study/core/base/BaseViewModel.swift +++ b/study/core/base/BaseViewModel.swift @@ -18,4 +18,6 @@ class BaseViewModel: ObservableObject { } } } + + func dispose() {} } diff --git a/study/features/audio/FestFourierTransform.swift b/study/features/audio/FestFourierTransform.swift new file mode 100644 index 0000000..92f7bb6 --- /dev/null +++ b/study/features/audio/FestFourierTransform.swift @@ -0,0 +1,29 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +import AVFoundation + +// DFT(Discrete Fourier Transform)는 O(N^2) 걸림 +// FFT(Fest Fourier Transform)는 분할 정복으로 계산량을 O(NLogN)으로 줄임 +// 길이 n의 신호를 짝수 인덱스 샘플과 홀수 인덱스 샘플로 나눔 +class FestFourierTransform { + func run(_ input: [Complex]) -> [Complex] { + let n = input.count + if n == 1 { + return input + } + if n & (n - 1) != 0 { + fatalError("Input size must be a power of 2") + } + let even = run((0 ..< n / 2).map { input[2 * $0] }) + let odd = run((0 ..< n / 2).map { input[2 * $0 + 1] }) + + var output = Array(repeating: Complex(0, 0), count: n) + for k in 0 ..< n / 2 { + let twiddle = Complex(cos(-2 * Double.pi * Double(k) / Double(n)), + sin(-2 * Double.pi * Double(k) / Double(n))) + output[k] = even[k] + twiddle * odd[k] + output[k + n / 2] = even[k] - twiddle * odd[k] + } + return output + } +} diff --git a/study/features/audio/Recorder.swift b/study/features/audio/Recorder.swift new file mode 100644 index 0000000..3367261 --- /dev/null +++ b/study/features/audio/Recorder.swift @@ -0,0 +1,30 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +import AVFoundation + +class Recorder: ObservableObject { + private var audioEngine = AVAudioEngine() + private var inputNode: AVAudioInputNode? + var inputFormat: AVAudioFormat? + + func start(_ windowSize: Int, _ handler: @escaping AVAudioNodeTapBlock) { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setPreferredSampleRate(48000) + try session.setActive(true) + } catch { + print("AVAudioSession 설정 중 오류 발생: \(error)") + } + + inputNode = audioEngine.inputNode + inputFormat = inputNode!.outputFormat(forBus: 0) + inputNode?.installTap(onBus: 0, bufferSize: AVAudioFrameCount(windowSize), format: inputFormat, block: handler) + try? audioEngine.start() + } + + func stop() { + inputNode?.removeTap(onBus: 0) + audioEngine.stop() + } +} diff --git a/study/features/audio/entities/Complex.swift b/study/features/audio/entities/Complex.swift new file mode 100644 index 0000000..dd4e370 --- /dev/null +++ b/study/features/audio/entities/Complex.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +struct Complex { + // 실수부 (cos 성분) + var real: Double + + // 허수부 (sin 성부) + var imag: Double + + init(_ real: Double, _ imag: Double) { + self.real = real + self.imag = imag + } + + static func + (lhs: Complex, rhs: Complex) -> Complex { + Complex(lhs.real + rhs.real, lhs.imag + rhs.imag) + } + + static func - (lhs: Complex, rhs: Complex) -> Complex { + Complex(lhs.real - rhs.real, lhs.imag - rhs.imag) + } + + static func * (lhs: Complex, rhs: Complex) -> Complex { + Complex(lhs.real * rhs.real - lhs.imag * rhs.imag, + lhs.real * rhs.imag + lhs.imag * rhs.real) + } +} diff --git a/study/presentations/router/RouterView.swift b/study/presentations/router/RouterView.swift index 5fd9a9e..7822001 100644 --- a/study/presentations/router/RouterView.swift +++ b/study/presentations/router/RouterView.swift @@ -28,6 +28,7 @@ struct RouterView: View { case .todo: TodoView() case .bucket: BucketView() case .prototypeSample: PrototypeSampleView() + case .frequencyAnalysis: FrequencyAnalysisView() } } .toolbarBackground(.hidden, for: .navigationBar) diff --git a/study/presentations/router/RouterViewState.swift b/study/presentations/router/RouterViewState.swift index 5a13370..2c9e446 100644 --- a/study/presentations/router/RouterViewState.swift +++ b/study/presentations/router/RouterViewState.swift @@ -10,6 +10,7 @@ enum SubPage { case todo case bucket case prototypeSample + case frequencyAnalysis } struct RouterViewState { diff --git a/study/presentations/views/home/HomeView.swift b/study/presentations/views/home/HomeView.swift index d17dcfd..131ccd3 100644 --- a/study/presentations/views/home/HomeView.swift +++ b/study/presentations/views/home/HomeView.swift @@ -32,6 +32,9 @@ struct HomeView: View { Tile(title: "Sample", subtitle: "Nickname") { router.push(.prototypeSample) } + Tile(title: "Frequency Analysis", subtitle: "Nell") { + router.push(.frequencyAnalysis) + } } } } diff --git a/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisView.swift b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisView.swift new file mode 100644 index 0000000..8a4ee65 --- /dev/null +++ b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisView.swift @@ -0,0 +1,25 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +import SwiftUI + +struct FrequencyAnalysisView: View { + var body: some View { + BaseView( + create: { FrequencyAnalysisViewModel() } + ) { _, state in + VStack { + Toolbar(title: "Frequency Analysis") + Spacer() + Text(state.note) + .font(.title) + Spacer() + } + } + } +} + +#Preview { + BasePreview { + FrequencyAnalysisView() + } +} diff --git a/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewModel.swift b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewModel.swift new file mode 100644 index 0000000..eb52b99 --- /dev/null +++ b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewModel.swift @@ -0,0 +1,142 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +import AVFoundation +import SwiftUI + +final class FrequencyAnalysisViewModel: BaseViewModel { + private let recorder = Recorder() + + // FFT 입력 길이 + // 오디오에서 몇 초 구간을 분석할지 결정 + // 크기가 클수록 느리지만 정확도↑ + // 주파수 해상도(Frequency Resolution, 5.86Hz) = Sample Rate(48000Hz) / windowSize(8192) + // FFT 결과 배열의 각 인덱스는 5.86Hz 간격의 주파수 성분을 나타냄 + // 2의 거듭 제곱이어야 Cooley-Tukey FFT 알고리즘의 분할 정복이 재귀적으로 딱 나누어 떨어져 성능에 좋음 + private let windowSize = 8192 // 2^13 + + private let noteFrequencies: [(name: String, freq: Double)] = [ + ("E2", 82.41), ("F2", 87.31), ("F#2/Gb2", 92.50), ("G2", 98.00), ("G#2/Ab2", 103.83), ("A2", 110.00), ("A#2/Bb2", 116.54), ("B2", 123.47), ("C3", 130.81), ("C#3/Db3", 138.59), + ("D3", 146.83), ("D#3/Eb3", 155.56), ("E3", 164.81), ("F3", 174.61), ("F#3/Gb3", 185.00), + ("G3", 196.00), ("G#3/Ab3", 207.65), ("A3", 220.00), ("A#3/Bb3", 233.08), ("B3", 246.94), + ("C4", 261.63), ("C#4/Db4", 277.18), ("D4", 293.66), ("D#4/Eb4", 311.13), ("E4", 329.63), + ("F4", 349.23), ("F#4/Gb4", 369.99), ("G4", 392.00), ("G#4/Ab4", 415.30), ("A4", 440.00), + ("A#4/Bb4", 466.16), ("B4", 493.88), ("C5", 523.25), ("C#5/Db5", 554.37), ("D5", 587.33), + ("D#5/Eb5", 622.25), ("E5", 659.26), ("F5", 698.46), ("F#5/Gb5", 739.99), ("G5", 783.99), + ("G#5/Ab5", 830.61), ("A5", 880.00), ("A#5/Bb5", 932.33), ("B5", 987.77), ("C6", 1046.50), + ] + + private func getClosestNoteName(for frequency: Double) -> String { + var minDiff = Double.infinity + var closestNote = "" + for note in noteFrequencies { + let diff = abs(note.freq - frequency) + if diff < minDiff { + minDiff = diff + closestNote = note.name + } + } + return closestNote + } + + init() { + super.init(state: .init( + amplitudes: Array(repeating: 0.0, count: 512), + note: "", + freq: 0 + )) + recorder.start(windowSize, recordHandler) + } + + override func dispose() { + recorder.stop() + } + + private func recordHandler(buffer: AVAudioPCMBuffer, time _: AVAudioTime) { + // Audio data in buffer : 음압 + guard let channelData = buffer.floatChannelData?[0] else { return } + let frameLength = Int(buffer.frameLength) + + // RMS : 음수 값들을 모두 양수로 바꾸어 볼륨 판별 + var sumSquares: Double = 0 + for i in 0 ..< frameLength { + let sample = Double(channelData[i]) + sumSquares += sample * sample + } + let rms = sqrt(sumSquares / Double(frameLength)) + + // 볼륨이 작은 경우 걸러내기 + let threshold = 0.01 + if rms < threshold { + return + } + + var samples = [Complex]() + for i in 0 ..< windowSize { + // Hamming Window + // - FFT는 입력 신호가 주기적이고 무한하다고 가정하지만 실제 오디오 신호는 짧은 구간만 자른 것(프레임)이라 양 끝이 갑자기 끊키면서 스펙트럼 누설(leakage)이라는 왜곡을 유발함. + // - 신호의 양 끝을 부드럽게 0에 가깝게 만드는 창(window) 함수로 FFT에 부드러운 경계 조건을 만들어줌. + let window = 0.5 * (1 - cos(2 * Double.pi * Double(i) / Double(windowSize - 1))) + + // 복소수로 표현 (허수부 0으로 구현) + // FFT(푸리에 변환)는 원래 복소수 정현파의 합으로 신호를 분해하는 알고리즘이라 + // 입력 신호가 실수이더라도 계산상 복소수 공간에서 주파수 분석을 해야함. + samples.append(Complex(Double(channelData[i]) * window, 0)) + } + + // Ensure samples count is a power of two + let N = windowSize + if samples.count >= N { + let fftInput = Array(samples[0 ..< N]) + + // FFT 결과 + // - windowSize 크기의 배열 + // - Frequency Resolution(5.86Hz) = Sample Rage(48000) / windowSize(8192) + // - 0번째 index : 0 ~ 5.86Hz 사이 주파수 성분의 크기(magnitude)와 위상(phase)을 표현 + let fftResult = FestFourierTransform().run(fftInput) + + // 크기(Magnitude, 신호가 그 주파수에서 얼마나 강한지 나타냄) = 루트(실수부^2 + 허수부^2) + // 위상(Phase, 그 주파수 성분의 시간적 위치나 진행 상태) = arctan(허수부 / 실수부) + let magnitudes = fftResult.map { sqrt($0.real * $0.real + $0.imag * $0.imag) } + + // Nyquist frequency (24000Hz) = sampling rate(48000Hz) / 2 + // - 샘플링 주파수의 절반 이상인 주파수 성분은 올바르게 표현할 수 없습니다. + // - 신호가 왜곡되어 낮은 주파수로 착각되는 현상(aliasing)이 발생하기 때문. + // - FFT의 결과는 복소수 배열인데, 이 배열은 대칭 구조를 가지기 때문. + // - 0~N/2까지는 양의 주파수 성분, 나머지는 음의 주파수 성분을 복소수 켤레로 나타냄. + // - 실제로는 인덱스 0 ~ N/2까지만 유효한 주파수 정보를 가지고 있고, 나머지는 대칭(복소공간의 복사본)이므로 버림. + let halfN = N / 2 + let magnitudesHalf = Array(magnitudes[0 ..< halfN]) + + // HPS (Harmonic Product Spectrum) + // - 소리에는 기본 주파수와 배수인 배음(harmonics)가 함께 존재하는데, 여기에서 기본 주파수 피치를 검출하는 기법(E2를 연주했지만 E3가 잡히는 FFT 문제 해결) + // - 원래는 다운샘플링을 해서 배음이 겹쳐지면서 기본음이 강화되는 방식인데, 여기에서 곱 연산으로 기본 주파수를 강화하는 방식으로 찾음. + // - 다른 인덱스는 곱했을 때 더 작아져서 상대적으로 기본 주파수 후보 위치가 더 명확해짐. + // - E3를 연주해도 E2가 미세하게 포함될 가능성은 있지만, 보통 기본 주파수보다 낮은 주파수는 약하고 명확하지 않음. + var hps = magnitudesHalf + + // 4개 배음까지 고려 + let harmonics = 4 + for h in 2 ... harmonics { + for i in 0 ..< (halfN / h) { + // E2 = E2 * E3 * E4 * E5 + // E3 = E3 * E4 * E5 * E6 + hps[i] *= magnitudes[i * h] + } + } + + // E2가 82.41Hz이므로 70Hz 이상에서만 피크 찾기 + let minIndex = Int(70.0 * Double(N) / recorder.inputFormat!.sampleRate) + let searchRange = minIndex ..< (halfN / harmonics) + + if let maxIndex = searchRange.max(by: { hps[$0] < hps[$1] }) { + let frequency = Double(maxIndex) * recorder.inputFormat!.sampleRate / Double(N) + let noteName = getClosestNoteName(for: frequency) + emit(state.copy( + note: noteName, + freq: frequency + )) + print("Frequency: \(frequency) Hz, Note: \(noteName)") + } + } + } +} diff --git a/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewState.swift b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewState.swift new file mode 100644 index 0000000..9364524 --- /dev/null +++ b/study/presentations/views/prototype/frequencyAnalysis/FrequencyAnalysisViewState.swift @@ -0,0 +1,15 @@ +// Copyright © 2025 ADA 4th Challenge3 Team1. All rights reserved. + +struct FrequencyAnalysisViewState { + let amplitudes: [Float] + let note: String + let freq: Double + + func copy(amplitudes: [Float]? = nil, note: String? = nil, freq: Double? = nil) -> FrequencyAnalysisViewState { + return FrequencyAnalysisViewState( + amplitudes: amplitudes ?? self.amplitudes, + note: note ?? self.note, + freq: freq ?? self.freq + ) + } +} diff --git a/study/presentations/views/splash/SplashView.swift b/study/presentations/views/splash/SplashView.swift index 52d3cdc..e8a7d0e 100644 --- a/study/presentations/views/splash/SplashView.swift +++ b/study/presentations/views/splash/SplashView.swift @@ -13,7 +13,7 @@ struct SplashView: View { .progressViewStyle(CircularProgressViewStyle()) .tint(.accentColor) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { viewModel.onLoaded() router.setRoot(.home) }