[SwiftUI] SFSpeechRecognizer와 함께 실시간으로 STT 출력하기

양재현·2026년 2월 17일

오늘은 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 (음성 인식기)

  • 역할 : 음성 인식 서비스의 중앙 제어 장치다.
  • 주요 기능 : 생성할 때 언어(Locale)를 지정하며, 현재 음성 인식이 가능한 상태인지(isAvailable) 관리한다.

3. SFSpeechAudioBufferRecognitionRequest (인식 요청 객체)

  • 역할 : 오디오 엔진에서 가져온 음성 데이터(Buffer)를 담아 인식기에 전달하는 바구니다.
  • 주요 기능 : shouldReportPartialResults 설정을 통해 문장이 끝나기 전에도 실시간으로 텍스트를 보여줄지 결정한다.

4. SFSpeechRecognitionTask (인식 작업 상태)

  • 역할 : 실제 음성 인식 프로세스의 실행 상태를 제어한다.
  • 주요 기능 : 인식이 잘 되고 있는지 모니터링하고, 작업을 취소(cancel)하거나 종료(finish)하는 핸들 역할을 한다.

코드 예제와 함께 보기전에, 권한 설정부터 하겠다.

권한설정

info.plst에서 아래 권한을 추가해줘야 크래시가 나지 않는다.

  • Privacy - Speech Recognition Usage Description
  • Privacy - Microphone Usage Description

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

0개의 댓글