이번 글에서는 TTS기능으로 텍스트를 읽어줄 때 해당 단어에 하이라이트 효과를 줘보려고 한다.
그러기 위해서는 AVSpeechSynthesizerDelegate 을 알아야 한다.
AVSpeechSynthesizerDelegate
AVSpeechSynthesizerDelegate 프로토콜은 크게 세 가지 카테고리의 이벤트를 전달해 준다.
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