오늘은 STT(Speech-To-Text)를 구현해보려고한다. 내 목소리가 실시간으로 텍스트로 변하는 예제를 구현해보겠다.
그전에 먼저, SFSpeechRecognizer에 관해 알아야 한다.
SFSpeechRecognizer
SFSpeechRecognizer는 iOS에서 음성 인식 프로세스를 관리하는 핵심 객체다. 단순히 소리를 듣는 것을 넘어, 언어 설정, 권한 확인, 실제 변환 작업(Task)까지 모두 이 객체를 통해 이루어진다.
* 주요 역할
1. 음성 인식의 엔진: 마이크를 통한 실시간 음성이나 오디오 파일의 음성을 텍스트로 변환한다.
2. 다양한 언어 지원: Locale 설정을 통해 한국어(ko-KR)를 포함한 다양한 언어를 지원한다.
3. On-Device & Server: 기기 자체에서 처리하거나 애플 서버를 거쳐 높은 정확도로 처리한다.
* 음성 인식 구현 6단계
공식 문서에서 권장하는 표준 프로세스는 다음과 같다.
1. 권한 요청 : SFSpeechRecognizer.requestAuthorization을 호출해 사용자 승인을 받는다.
2. 객체 생성 : SFSpeechRecognizer(locale:)를 통해 인스턴스를 만듭니다.
3. 가용성 확인 : isAvailable 프로퍼티로 현재 서비스 이용 가능 여부를 체크한다. (인터넷 연결 환경 등에 따라 달라질 수 있음)
4. 오디오 준비 : 마이크 입력(실시간) 또는 오디오 파일(기존 녹음)을 준비한다.
5. Request 객체 생성 : 상황에 맞는 리퀘스트 객체를 생성한다.
파일 처리 : SFSpeechURLRecognitionRequest
실시간 스트림 : SFSpeechAudioBufferRecognitionRequest
6. 인식 시작 : recognitionTask(with:resultHandler:) 메서드를 호출하여 변환을 시작한다.
4가지 주요 객체
코드 실습 전, 미리 보면 좋을 4가지 객체를 알아보겠다.
1. AVAudioEngine (음성 엔진)
inputNode를 통해 마이크에 접근하고, installTap으로 소리 데이터를 낚아챈다.2. SFSpeechRecognizer (음성 인식기)
isAvailable) 관리한다.3. SFSpeechAudioBufferRecognitionRequest (인식 요청 객체)
shouldReportPartialResults 설정을 통해 문장이 끝나기 전에도 실시간으로 텍스트를 보여줄지 결정한다.4. SFSpeechRecognitionTask (인식 작업 상태)
cancel)하거나 종료(finish)하는 핸들 역할을 한다.권한설정
info.plst에서 아래 권한을 추가해줘야 크래시가 나지 않는다.

STTManager
import Foundation
import Speech
@Observable
class STTManager {
// 음성 엔진: 오디오 입력(마이크)을 처리하고 전달하는 역할
private var audioEngine = AVAudioEngine()
// 음성 인식기: 설정된 언어(ko-KR)에 따라 음성을 텍스트로 변환
private var speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ko-KR"))
// 인식 요청 객체: 실시간 음성 버퍼를 담아 전달하는 용도
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
// 인식 작업 상태: 현재 진행 중인 음성 인식 작업의 핸들러
private var recognitionTask: SFSpeechRecognitionTask?
var transcript: String = "" // 변환된 텍스트가 저장되는 변수
func startTranscribing() {
reset() // 1. 이전 작업이 남아있을 수 있으므로 초기화
let audioSession = AVAudioSession.sharedInstance() // 오디오 컨트롤 타워를 불러옵니다.
// 2. 오디오 세션 설정: 녹음 모드로 설정하고, 데이터 측정(measurement)모드, 다른 앱의 소리를 줄임(duckOthers)
try? audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
try? audioSession.setActive(true, options: .notifyOthersOnDeactivation)
// 3. 버퍼 기반 리퀘스트 생성 (실시간 마이크 입력용)
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
guard let recognitionRequest = recognitionRequest else { return }
// 4. 부분 결과 보고: true 설정 시 문장이 끝나기 전에도 실시간 인식 결과를 반환함
recognitionRequest.shouldReportPartialResults = true
let inputNode = audioEngine.inputNode
// 5. 음성 인식 작업 시작
recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { result, error in
if let result = result {
// 실시간으로 변환된 가장 가능성 높은 문장을 업데이트
self.transcript = result.bestTranscription.formattedString
}
// 에러가 발생하거나 최종 인식이 완료되면 중지
if error != nil || result?.isFinal == true {
self.stopTranscribing()
}
}
// 6. 마이크 입력을 리퀘스트에 연결 (탭 설치)
let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
// 오디오 버퍼를 실시간으로 인식 요청 객체에 추가
recognitionRequest.append(buffer)
}
// 7. 오디오 엔진 가동
try? audioEngine.start()
}
func stopTranscribing() {
audioEngine.stop() // 엔진 정지
audioEngine.inputNode.removeTap(onBus: 0) // 설치했던 탭 제거
recognitionRequest?.endAudio() // 리퀘스트 종료 알림
recognitionTask?.cancel() // 태스크 취소
}
private func reset() {
transcript = ""
recognitionTask?.cancel()
recognitionTask = nil
}
// 권한 요청: Info.plist에 Privacy - Speech Recognition... 설정이 필수입니다.
func requestPermissions() {
SFSpeechRecognizer.requestAuthorization { authStatus in
DispatchQueue.main.async {
switch authStatus {
case .authorized: print("음성 인식 허용됨")
case .denied: print("사용자가 거부함")
case .restricted, .notDetermined: print("권한 설정 필요")
@unknown default: break
}
}
}
}
}
View
import SwiftUI
struct TestView: View {
@State private var sttManager = STTManager()
@State private var isRecording = false
var body: some View {
VStack(spacing: 30) {
Text("실시간 음성 인식")
.font(.title).bold()
ScrollView {
Text(sttManager.transcript)
.font(.body)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 300)
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
Button(action: {
if isRecording {
sttManager.stopTranscribing()
} else {
sttManager.startTranscribing()
}
isRecording.toggle()
}) {
Circle()
.fill(isRecording ? .red : .blue)
.frame(width: 70, height: 70)
.overlay(
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
.foregroundColor(.white)
.font(.title)
)
}
}
.padding()
.onAppear {
sttManager.requestPermissions()
}
}
}
구현 영상

🍎 참고
https://developer.apple.com/documentation/speech/sfspeechrecognizer