XLOG

실시간 STT (Sound to Text) 구현하기 본문

Swift

실시간 STT (Sound to Text) 구현하기

X_PROFIT 2024. 12. 22. 21:25

Accessibility 를 공부하다가 보니 흔하게 사용하는 TextField 에서 음성입력 버튼이 없다는 걸 깨달았다. searchable 로 navigation bar 에 search controller 를 추가하더라도 없었다. 물론 키보드가 올라오면 음성입력할 수 있는 버튼이 있지만, 가장 최하단에 있어서 접근하는 것이 여간 불편한 일이라는 걸 깨달았다.

해당 코드는에서는 AVAudioEngine 을 사용할 계획이다. 

1. 권한 설정

info.plist 에 마이크 사용 및 음성 인식 사용 권한 요청을 추가해 준다.

2. STTManager 정의

2-1 권한 요청 (Combine 으로 진행)

import Foundation
import AVFoundation
import Speech

final class STTManager {
    enum STTError: Error {
        case notSpeechAuth
        case notAVAudioAuth
    }
    
    private var audioEngine: AVAudioEngine?
    private var speechRecognizer: SFSpeechRecognizer?
    private var request: SFSpeechRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?
    
    
    init() {}
    
    func requestAuth() async throws {
        let speechRecognizerAuth = await requestSFSpeechRecognizerAuth()
        let audioSessionAuth = await requestAVAudioEngineAuth()
        
        if !speechRecognizerAuth {
            throw STTError.notSpeechAuth
        }
        
        if !audioSessionAuth {
            throw STTError.notAVAudioAuth
        }
    }
}

extension STTManager {
    private func requestSFSpeechRecognizerAuth() async -> Bool {
        return await withCheckedContinuation { continuation in
            SFSpeechRecognizer.requestAuthorization({ status in
                switch status {
                case .authorized:
                    continuation.resume(returning: true)
                default:
                    continuation.resume(returning: false)
                }
            })
        }
    }
    
    private func requestAVAudioEngineAuth() async -> Bool {
        return await withCheckedContinuation { continuation in
            if #available(iOS 17.0, *) {
                AVAudioApplication.requestRecordPermission { status in
                    if status {
                        continuation.resume(returning: true)
                    } else {
                        continuation.resume(returning: false)
                    }
                }
            } else {
                let audioSession = AVAudioSession.sharedInstance()
                audioSession.requestRecordPermission { status in
                    if status {
                        continuation.resume(returning: true)
                    } else {
                        continuation.resume(returning: false)
                    }
                }
            }
        }
    }
}

권한 요청의 경우 두 가지 요청을 Concurrency 로 진행, 두 가지 요청이 완료되면 해당 값에 따라 throw 를 보내 적절한 Alert 창을 띄울 . 수 있도록 Error 를 throw 함.

2-2 STT 기능 정의

import Combine

final class STTManager {
		'''
    	'''
        
    	private var sttPublisher: PassthroughSubject<String, STTError>?
        
		'''
        '''
        // MARK: start()
		func startStt() -> AnyPublisher<String, STTError> {
            self.sttPublisher = .init()
            
            self.speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ko-KR"))
            
            if let speechRecognizer, !speechRecognizer.isAvailable {
                self.sttPublisher?.send(completion: .failure(.notAvailable))
            }
            do {
                let (audioEngine, request) = try prepare()
                self.audioEngine = audioEngine
                self.request = request
                self.recognitionTask = speechRecognizer?.recognitionTask(with: request) { [weak self] (result, error) in
                    if let error {
                        self?.sttPublisher?.send(completion: .failure(.error(error)))
                        self?.reset()
                    } else if let result {
                        self?.sttPublisher?.send(result.bestTranscription.formattedString)
                    }
                }
            } catch {
                self.sttPublisher?.send(completion: .failure(.error(error)))
            }
            
            return self.sttPublisher!.eraseToAnyPublisher()
        }
        
        // MARK: stopStt()
        func stopStt() {
            self.sttPublisher?.send(completion: .finished)
            self.reset()
        }
        
        // MARK: prepare()
        private func prepare() throws -> (AVAudioEngine, SFSpeechAudioBufferRecognitionRequest) {
            let audioEngine = AVAudioEngine()
            let request = SFSpeechAudioBufferRecognitionRequest()
            // 실시간 응답 설정
            request.shouldReportPartialResults = true
            
            let audioSession = AVAudioSession.sharedInstance()
            // duckOther option 은 실행중인 다른 앱의 audio 사운드를 줄이는 용도
            try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
            // option 은 해당 앱에 audio session 이 종료되면 종료됨을 알리는 옵션
            try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
            let inputNode = audioEngine.inputNode
            
            let recordingFormat = inputNode.outputFormat(forBus: 0)
            // Buffer 사이즈는 실시간 음성인식에 가장 많이 사용하는 사이즈로, 
            // 버퍼 사이즈가 작으면 Delay 는 줄지만 그만큼 많은 성능을 요한다.
            // 반대로 크면 delay 가 늘어난다.
            inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
                request.append(buffer)
            }
            
            audioEngine.prepare()
            try audioEngine.start()
            
            return (audioEngine, request)
        }
        
        // MARK: reset()
        private func reset() {
            self.audioEngine?.stop()
            self.audioEngine?.inputNode.removeTap(onBus: 0)
            self.recognitionTask?.cancel()
            self.recognitionTask = nil
            self.audioEngine = nil
            self.speechRecognizer = nil
            self.request = nil
        }
}

실제 STT 기능은 Combine에 Publisher를 활용하여 task에서 데이터를 받을 때마다 값을 전달할 수 있도록 정의

2-3 STTViewModel, STTView 정의

//
//  STTViewModel.swift
//
//  Created by Sooik Kim on 12/22/24.
//

import SwiftUI
import Combine


final class STTViewModel: ObservableObject {
    
    @Published var text: String = ""
    @Published var isProgressing: Bool = false
    
    private let manager: STTManager
    private var cancellables: Set<AnyCancellable> = []
    
    init(_ manager: STTManager = .init()) {
        self.manager = manager
    }
    
    enum Action {
        case micButtonTapped
    }
    
    func send(_ action: Action) {
        switch action {
        case .micButtonTapped:
            if !isProgressing {
                start()
            } else {
                stop()
            }
        }
    }
    
    private func start() {
        Task { @MainActor in
            do {
                // 권한 요청
                // 없을 시 Error throw
                try await manager.requestAuth()
                // 권한 확인 후
                self.isProgressing = true
                // text 값의 변화가 일정시간 없다면 자동 종료
                self.debounce()
                self.manager.startStt()
                    .receive(on: DispatchQueue.main)
                    .sink { completion in
                        switch completion {
                        case .failure(let error):
                            print(error)
                        case .finished:
                            print("finished")
                        }
                    } receiveValue: { [weak self] text in
                        self?.text = text
                    }
                    .store(in: &cancellables)
            } catch {
                // 적절한 Error 핸들링
                print(error)
            }
        }
    }
    
    private func stop() {
        self.isProgressing = false
        self.manager.stopStt()
        self.cancellables.removeAll()
    }
    
    // text 값의 변화가 일정시간 없다면 자동 종료
    private func debounce() {
        $text.debounce(for: .seconds(5), scheduler: RunLoop.main).sink { [weak self] _ in
            self?.stop()
        }
        .store(in: &cancellables)
    }
}
//
//  STTView.swift

//  Created by Sooik Kim on 12/22/24.
//

import SwiftUI

struct STTView: View {
    
    @StateObject private var viewModel: STTViewModel = .init()
    
    var body: some View {
        VStack {
            Text(viewModel.text)
            
            if viewModel.isProgressing {
                ProgressView()
            }
            
            Button {
                viewModel.send(.micButtonTapped)
            } label: {
                Image(systemName: viewModel.isProgressing ? "xmark.circle.fill" : "microphone.circle.fill")
            }
        }
    }
}

#Preview {
    STTView()
}

 

3. 결과

 

 

4. 느낀점

Accessibility 를 공부하며, 작업을 해본것이지만 bufferSize 를 왜 1024 로 하는지도 찾아보면서 Swift 가 아닌 공학적인 지식의 필요성을 느낀다. 어떻게 공부를 하면 좋을지는 계속 찾아봐야겠지만 Combine 과 Concurrency 어떤것을 사용하는게 좋을지에 대한 고민도 할 수 있었다. 다시 Combine 과 Concurrency 를 깊이 있게 공부하고 둘의 비교, 어떤걸 어느 순간에 사용해야할지에 대한 판단력도 길러야 할 것 같다. 그리고 최근 ViewModel 에 send 함수를 통해 View 에서 ViewModel 함수를 좀 더 직관적으로 사용할 수 있도록 하고 있는데 TCA 를 사용했던 경험 또한 좋은 공부가 되었던 것 같다.

참고

https://velog.io/@240-coding/iOS%EC%97%90%EC%84%9C-Speech-Recognition-%EB%B0%9C%EC%9D%8C-%ED%8F%89%EA%B0%80-API-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0

 

iOS에서 Speech Recognition + 발음 평가 API 사용해보기

iOS에서 음성 녹음, Speech Recognition, 발음 평가 API 사용해보기

velog.io

https://developer.apple.com/tutorials/app-dev-training/transcribing-speech-to-text

'Swift' 카테고리의 다른 글

Fatal error: Duplicate keys of type 'Node' were found in a Dictionary 해결  (0) 2024.08.16
[Swift] CoreData  (0) 2024.06.08
[Swift] IAP, 인앱결제 StoreKit2  (2) 2024.05.19
[Swift] Concurrency  (0) 2024.05.19