일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- Performance
- toolbarvisibility
- 최적화
- 네트워크
- Network
- Concurrency
- RxSwift
- 달력
- stateobject
- gesture
- combine
- withAnimation
- firebase
- 접근성제어
- swift
- CS
- SwiftUI
- WWDC
- authentication
- ios
- dataflow
- GCD
- iphone
- avsession
- arkit
- auth
- state
- view
- UIKit
- Animation
- Today
- Total
XLOG
실시간 STT (Sound to Text) 구현하기 본문
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 를 사용했던 경험 또한 좋은 공부가 되었던 것 같다.
참고
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 |