[SwiftUI] AVSpeechSynthesizer로 TTS(Text To Speech) 구현하기 (2)

양재현·2026년 2월 16일

이번 글에서는 TTS기능으로 텍스트를 읽어줄 때 해당 단어에 하이라이트 효과를 줘보려고 한다.

그러기 위해서는 AVSpeechSynthesizerDelegate 을 알아야 한다.

AVSpeechSynthesizerDelegate

AVSpeechSynthesizerDelegate 프로토콜은 크게 세 가지 카테고리의 이벤트를 전달해 준다.

  • 음성 출력을 시작하거나 마쳤을 때
  • 음성 출력을 멈췄다가 다시 재개할 때
  • 각각의 음성 단위(일반적으로 단어)를 생성할 때

그리고 우리가 목표로 하는 '실시간 단어 하이라이트'를 위해서 3가지의 딜리게이트 메서드를 이용할 것이다.

1. willSpeakRangeOfSpeechString

func speechSynthesizer(AVSpeechSynthesizer, willSpeakRangeOfSpeechString: NSRange, utterance: AVSpeechUtterance)

이 메서드는 각 단어 단위를 말하기 직전에 호출된다.

인자로 넘어오는 characterRange가 바로 현재 읽으려는 단어가 전체 문장에서 어디에 위치하는지(location)와 길이(length)를 담고 있다. 이 값을 UI에 반영하면 실시간 하이라이트가 완성된다.

2. didFinish

func speechSynthesizer(AVSpeechSynthesizer, didFinish: AVSpeechUtterance)

설정한 모든 대본(Utterance)을 끝까지 다 읽었을 때 호출된다. 재생이 완전히 끝났으므로, 화면에 남아있는 하이라이트 효과를 제거하고 상태를 초기화하는 로직을 여기에 작성할 것이다.

3. didCancel

func speechSynthesizer(AVSpeechSynthesizer, didCancel: AVSpeechUtterance)

재생 중에 stopSpeaking(at:)이 호출되어 강제 종료되었을 때 호출된다. 사용자가 다른 텍스트를 탭하거나 재생을 중단했을 때, 남아있는 UI 효과를 즉시 지워주기 위해 필요하다.


예제 코드와 함께 보겠다.

SpeechManager

import AVFAudio
import Foundation

@Observable 
final class SpeechManager: NSObject {
    private let synthesizer = AVSpeechSynthesizer()
    
    // 현재 재생 중인 텍스트 (어떤 문장을 하이라이트할지 결정)
    var activeText: String? = nil
    
    // 현재 읽고 있는 단어의 범위 (어느 부분을 빨간색으로 칠할지 결정)
    var highlightedRange: NSRange? = nil
    
    // 현재 실제로 재생되고 있는 '대본 객체'를 저장합니다.
    // @ObservationIgnored는 이 값이 바뀌어도 뷰를 다시 그릴 필요가 없을 때 사용합니다.
    @ObservationIgnored private var activeUtterance: AVSpeechUtterance?
    
    override init() {
        super.init()
        synthesizer.delegate = self
    }
    
    /// 음성 재생 시작 함수
    func play(_ text: String) {
        // 1. 이미 재생 중인 음성이 있다면 즉시 중단합니다.
        if synthesizer.isSpeaking {
            synthesizer.stopSpeaking(at: .immediate)
        }
        
        // 2. 대본(Utterance) 생성 및 설정
        let utterance = AVSpeechUtterance(string: text)
        utterance.rate = 0.5 // 말하기 속도 (0.0 ~ 1.0)
        utterance.voice = AVSpeechSynthesisVoice(language: "ko-KR") // 한국어 설정
        
        // 3. 현재 상태 업데이트
        self.activeText = text
        self.activeUtterance = utterance
        self.highlightedRange = nil // 재생 시작 전이므로 하이라이트 초기화
        
        // 4. 재생 명령
        synthesizer.speak(utterance)
    }
    
    /// 하이라이트 상태를 모두 지우는 헬퍼 함수
    private func clearState() {
        self.activeText = nil
        self.highlightedRange = nil
        self.activeUtterance = nil
    }
}

// MARK: - AVSpeechSynthesizerDelegate
// 음성 재생 도중 발생하는 이벤트들을 수신하는 곳입니다.
extension SpeechManager: AVSpeechSynthesizerDelegate {
    
    /// [단어 단위 추적] 특정 범위의 단어를 말하기 직전에 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        // 지금 들어온 이벤트가 '가장 최근에 요청한 재생' 건인지 확인합니다.
        // 연타 시 이전 음성의 이벤트가 새 음성의 하이라이트를 망치지 않게 합니다.
        guard self.activeUtterance == utterance else { return }
        
        // 현재 읽고 있는 위치를 업데이트합니다.
        self.highlightedRange = characterRange
    }
    
    /// [완료 이벤트] 텍스트를 끝까지 다 읽었을 때 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        // 방금 끝난 게 현재 재생 중인 대본이 맞다면 상태를 초기화합니다.
        guard self.activeUtterance == utterance else { return }
        self.clearState()
    }
    
    /// [취소 이벤트] stopSpeaking() 등으로 인해 재생이 중단되었을 때 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        // 재생이 취소되었으므로 하이라이트를 지워줍니다.
        guard self.activeUtterance == utterance else { return }
        self.clearState()
    }
}

여기서 핵심은 AVSpeechSynthesizerDelegate 메서드인 willSpeakRangeOfSpeechString 에서 받아온 characterRange를 통해서 이제 어느 단어를 읽을건지 업데이트 해준다.

TTSDemoView

import SwiftUI

struct TTSDemoView: View {
    @State private var speechManager = SpeechManager()
    
    let sentences = [
        "안녕하세요, 첫 번째 테스트 문장입니다.",
        "리스트를 탭하면 음성 재생과 하이라이트가 시작됩니다.",
        "연타를 해도 로직이 꼬이지 않고 부드럽게 재생됩니다."
    ]
    
    var body: some View {
        NavigationStack {
            List(sentences, id: \.self) { text in
                VStack(alignment: .leading) {
                    // 스타일이 적용된 텍스트
                    Text(highlightedText(for: text))
                        .font(.system(size: 18, weight: .medium))
                        .padding(.vertical, 8)
                }
                .contentShape(Rectangle())
                .onTapGesture {
                    // 단순하게 텍스트만 전달해서 재생
                    speechManager.play(text)
                }
            }
            .navigationTitle("TTS 하이라이트")
        }
    }
    
    // MARK: - 하이라이트 로직
    private func highlightedText(for text: String) -> AttributedString {
        var attrString = AttributedString(text)
        
        // 현재 매니저가 읽고 있는 텍스트와 이 줄의 텍스트가 같을 때만 빨간색 적용
        if speechManager.activeText == text,
           let range = speechManager.highlightedRange,
           let attrRange = Range(range, in: attrString) {
            attrString[attrRange].foregroundColor = .red
        }
        
        return attrString
    }
}


#Preview {
    TTSDemoView()
}

여기서 핵심은 func highlightedText(for text: String) 함순데 이 친구가 SpeechManager가 전달해 주는 '숫자 데이터(NSRange)'를 시각적인 '빨간색 하이라이트'로 변환하는 다리 역할을 한다.
즉, delegate로 받아온 characterRange로 현재 TTS로 읽어주는 위치가 빨간색 글씨로 보인다는 것이다.

  • 참고로 Range(range, in: attrString)를 사용하여 Objective-C 방식의 숫자 위치(NSRange)를 Swift 방식의 인덱스 범위(Range)로 변환하고 있다.

그렇게 문장 안에 TTS로 읽어주고 있는 attrRange범위가 빨간색으로 변한다.

테스트 영상

소리는 안나오지만 각 리스트를 탭하면 TTS가 읽어주는 그 위치가 빨간색으로 변하게 된다.

🍎 참고

https://developer.apple.com/documentation/avfaudio/avspeechsynthesizerdelegate

0개의 댓글