Refactor TTS system with Combine and VoiceManager

- Replace @MainActor with Combine framework for TTS functionality
- Create VoiceManager class for voice selection and caching
- Add UserDefaults persistence for selected voice
- Optimize performance with voice caching and immediate UI updates
- Remove @MainActor from TTS-related Use Cases
- Add proper pause/resume delegate methods
- Improve reactive UI updates with @StateObject
- Clean up code and remove unnecessary comments
This commit is contained in:
Ilyas Hallak 2025-07-09 23:15:23 +02:00
parent 09f1ddea58
commit 9b89e58115
7 changed files with 234 additions and 112 deletions

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4D",
"red" : "0x00"
"blue" : "0x5A",
"green" : "0x4A",
"red" : "0x1F"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4D",
"red" : "0x00"
"blue" : "0x5A",
"green" : "0x4A",
"red" : "0x1F"
}
},
"idiom" : "universal"

View File

@ -2,7 +2,7 @@ import SwiftUI
struct GlobalPlayerContainerView<Content: View>: View {
let content: Content
@State private var speechQueue = SpeechQueue.shared
@StateObject private var viewModel = SpeechPlayerViewModel()
init(@ViewBuilder content: () -> Content) {
self.content = content()
@ -13,7 +13,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
if speechQueue.hasItems {
if viewModel.hasItems {
VStack(spacing: 0) {
SpeechPlayerView()
.padding(.horizontal, 16)
@ -26,7 +26,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
}
}
}
.animation(.spring(), value: speechQueue.hasItems)
.animation(.spring(), value: viewModel.hasItems)
}
}

View File

@ -4,8 +4,6 @@ struct SpeechPlayerView: View {
@State var viewModel = SpeechPlayerViewModel()
@State private var isExpanded = false
@State private var dragOffset: CGFloat = 0
@State private var speechQueue = SpeechQueue.shared
@State private var ttsManager = TTSManager.shared
private let minHeight: CGFloat = 60
private let maxHeight: CGFloat = 300
@ -45,13 +43,13 @@ struct SpeechPlayerView: View {
HStack(spacing: 16) {
// Play/Pause Button
Button(action: {
if ttsManager.isCurrentlySpeaking() {
if viewModel.isSpeaking {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
@ -64,11 +62,15 @@ struct SpeechPlayerView: View {
.lineLimit(1)
.truncationMode(.tail)
if speechQueue.queueCount > 0 {
Text("\(speechQueue.queueCount) Artikel in Queue")
if viewModel.queueCount > 0 {
Text("\(viewModel.queueCount) Artikel in Queue")
.font(.caption)
.foregroundColor(.secondary)
}
}.onTapGesture {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
Spacer()
@ -123,13 +125,13 @@ struct SpeechPlayerView: View {
// Controls
HStack(spacing: 24) {
Button(action: {
if ttsManager.isCurrentlySpeaking() {
if viewModel.isSpeaking {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
.font(.title)
.foregroundColor(.accentColor)
}
@ -144,7 +146,7 @@ struct SpeechPlayerView: View {
}
// Queue List
if speechQueue.queueCount == 0 {
if viewModel.queueCount == 0 {
Text("Keine Artikel in der Queue")
.font(.subheadline)
.foregroundColor(.secondary)
@ -152,7 +154,7 @@ struct SpeechPlayerView: View {
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(Array(speechQueue.queueItems.enumerated()), id: \.offset) { index, item in
ForEach(Array(viewModel.queueItems.enumerated()), id: \.offset) { index, item in
HStack {
Text("\(index + 1).")
.font(.caption)

View File

@ -1,24 +1,57 @@
import Foundation
import Combine
@Observable
class SpeechPlayerViewModel {
var isSpeaking: Bool {
return TTSManager.shared.isCurrentlySpeaking()
class SpeechPlayerViewModel: ObservableObject {
private let ttsManager: TTSManager
private let speechQueue: SpeechQueue
private var cancellables = Set<AnyCancellable>()
@Published var isSpeaking: Bool = false
@Published var currentText: String = ""
@Published var queueCount: Int = 0
@Published var queueItems: [String] = []
@Published var hasItems: Bool = false
init(ttsManager: TTSManager = .shared, speechQueue: SpeechQueue = .shared) {
self.ttsManager = ttsManager
self.speechQueue = speechQueue
setupBindings()
}
var currentText: String {
return SpeechQueue.shared.currentText
private func setupBindings() {
// TTSManager bindings
ttsManager.$isSpeaking
.assign(to: \.isSpeaking, on: self)
.store(in: &cancellables)
ttsManager.$currentUtterance
.assign(to: \.currentText, on: self)
.store(in: &cancellables)
// SpeechQueue bindings
speechQueue.$queueItems
.assign(to: \.queueItems, on: self)
.store(in: &cancellables)
speechQueue.$queueItems
.map { $0.count }
.assign(to: \.queueCount, on: self)
.store(in: &cancellables)
speechQueue.$hasItems
.assign(to: \.hasItems, on: self)
.store(in: &cancellables)
}
func pause() {
TTSManager.shared.pause()
ttsManager.pause()
}
func resume() {
TTSManager.shared.resume()
ttsManager.resume()
}
func stop() {
SpeechQueue.shared.clear()
speechQueue.clear()
}
}

View File

@ -1,7 +1,7 @@
import Foundation
import Combine
@Observable
class SpeechQueue {
class SpeechQueue: ObservableObject {
private var queue: [String] = []
private var isProcessing = false
private let ttsManager: TTSManager
@ -9,24 +9,16 @@ class SpeechQueue {
static let shared = SpeechQueue()
var hasItems: Bool {
return !queue.isEmpty || ttsManager.isCurrentlySpeaking()
}
@Published var queueItems: [String] = []
@Published var currentText: String = ""
@Published var hasItems: Bool = false
var queueCount: Int {
return queue.count
return queueItems.count
}
var currentItem: String? {
return queue.first
}
var queueItems: [String] {
return queue
}
var currentText: String {
return queue.first ?? ""
return queueItems.first
}
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
@ -36,11 +28,13 @@ class SpeechQueue {
func enqueue(_ text: String) {
queue.append(text)
updatePublishedProperties()
processQueue()
}
func enqueue(contentsOf texts: [String]) {
queue.append(contentsOf: texts)
updatePublishedProperties()
processQueue()
}
@ -48,6 +42,13 @@ class SpeechQueue {
queue.removeAll()
ttsManager.stop()
isProcessing = false
updatePublishedProperties()
}
private func updatePublishedProperties() {
queueItems = queue
currentText = queue.first ?? ""
hasItems = !queue.isEmpty || ttsManager.isCurrentlySpeaking()
}
private func processQueue() {
@ -56,18 +57,19 @@ class SpeechQueue {
let next = queue.removeFirst()
ttsManager.speak(text: next, language: language)
// Delegate/Notification für didFinish kann hier angebunden werden
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.waitForSpeechToFinish()
}
}
private func waitForSpeechToFinish() {
if ttsManager.isCurrentlySpeaking() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.waitForSpeechToFinish()
}
} else {
self.isProcessing = false
self.updatePublishedProperties()
self.processQueue()
}
}

View File

@ -1,11 +1,15 @@
import Foundation
import UIKit
import AVFoundation
import Combine
class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
static let shared = TTSManager()
private let synthesizer = AVSpeechSynthesizer()
private var isSpeaking = false
private let voiceManager = VoiceManager.shared
@Published var isSpeaking = false
@Published var currentUtterance = ""
override private init() {
super.init()
@ -19,10 +23,8 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
try audioSession.setActive(true)
// Background-Audio aktivieren
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
// Notification für App-Lifecycle
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppDidEnterBackground),
@ -43,76 +45,59 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
func speak(text: String, language: String = "de-DE") {
guard !text.isEmpty else { return }
DispatchQueue.main.async {
self.isSpeaking = true
self.currentUtterance = text
}
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
let utterance = AVSpeechUtterance(string: text)
// Versuche eine hochwertige Stimme zu finden
if let enhancedVoice = findEnhancedVoice(for: language) {
utterance.voice = enhancedVoice
} else {
utterance.voice = AVSpeechSynthesisVoice(language: language)
}
utterance.voice = voiceManager.getVoice(for: language)
utterance.rate = 0.5
utterance.pitchMultiplier = 1.0
utterance.volume = 1.0
synthesizer.speak(utterance)
isSpeaking = true
}
private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice? {
let availableVoices = AVSpeechSynthesisVoice.speechVoices()
// Bevorzugte Stimmen für alle Sprachen
let preferredVoiceNames = [
"Anna", // Deutsche Premium-Stimme
"Helena", // Deutsche Premium-Stimme
"Siri", // Siri-Stimme (falls verfügbar)
"Enhanced", // Enhanced-Stimmen
"Karen", // Englische Premium-Stimme
"Daniel", // Englische Premium-Stimme
"Marie", // Französische Premium-Stimme
"Paolo", // Italienische Premium-Stimme
"Carmen", // Spanische Premium-Stimme
"Yuki" // Japanische Premium-Stimme
]
// Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen
for voiceName in preferredVoiceNames {
if let voice = availableVoices.first(where: {
$0.language == language &&
$0.name.contains(voiceName)
}) {
return voice
}
}
// Fallback: Erste verfügbare Stimme für die Sprache
return availableVoices.first(where: { $0.language == language })
}
func pause() {
synthesizer.pauseSpeaking(at: .word)
synthesizer.pauseSpeaking(at: .immediate)
isSpeaking = false
}
func resume() {
synthesizer.continueSpeaking()
isSpeaking = true
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
isSpeaking = false
currentUtterance = ""
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
isSpeaking = false
currentUtterance = ""
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
isSpeaking = false
currentUtterance = ""
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
isSpeaking = false
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
isSpeaking = true
}
func isCurrentlySpeaking() -> Bool {
@ -120,7 +105,6 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
}
@objc private func handleAppDidEnterBackground() {
// App geht in Hintergrund - Audio-Session beibehalten
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
@ -129,7 +113,6 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
}
@objc private func handleAppWillEnterForeground() {
// App kommt in Vordergrund - Audio-Session erneuern
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
@ -140,26 +123,4 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
deinit {
NotificationCenter.default.removeObserver(self)
}
// Debug-Methode: Zeigt alle verfügbaren Stimmen für eine Sprache
func printAvailableVoices(for language: String = "de-DE") {
let voices = AVSpeechSynthesisVoice.speechVoices()
let filteredVoices = voices.filter { $0.language.starts(with: language.prefix(2)) }
print("Verfügbare Stimmen für \(language):")
for voice in filteredVoices {
print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)")
}
}
// Debug-Methode: Zeigt alle verfügbaren Sprachen
func printAllAvailableLanguages() {
let voices = AVSpeechSynthesisVoice.speechVoices()
let languages = Set(voices.map { $0.language })
print("Verfügbare Sprachen:")
for language in languages.sorted() {
print("- \(language)")
}
}
}

View File

@ -0,0 +1,124 @@
import Foundation
import AVFoundation
class VoiceManager: ObservableObject {
static let shared = VoiceManager()
private let userDefaults = UserDefaults.standard
private let selectedVoiceKey = "selectedVoice"
private var cachedVoices: [String: AVSpeechSynthesisVoice] = [:]
@Published var selectedVoice: AVSpeechSynthesisVoice?
@Published var availableVoices: [AVSpeechSynthesisVoice] = []
private init() {
loadAvailableVoices()
loadSelectedVoice()
}
// MARK: - Public Methods
func getVoice(for language: String = "de-DE") -> AVSpeechSynthesisVoice {
// Verwende ausgewählte Stimme falls verfügbar
if let selected = selectedVoice, selected.language == language {
return selected
}
// Verwende gecachte Stimme
if let cachedVoice = cachedVoices[language] {
return cachedVoice
}
// Finde und cache eine neue Stimme
let voice = findEnhancedVoice(for: language)
cachedVoices[language] = voice
return voice
}
func setSelectedVoice(_ voice: AVSpeechSynthesisVoice) {
selectedVoice = voice
saveSelectedVoice(voice)
}
func getAvailableVoices(for language: String = "de-DE") -> [AVSpeechSynthesisVoice] {
return availableVoices.filter { $0.language == language }
}
func getPreferredVoices(for language: String = "de-DE") -> [AVSpeechSynthesisVoice] {
let preferredVoiceNames = [
"Anna", // Deutsche Premium-Stimme
"Helena", // Deutsche Premium-Stimme
"Siri", // Siri-Stimme (falls verfügbar)
"Enhanced", // Enhanced-Stimmen
"Karen", // Englische Premium-Stimme
"Daniel", // Englische Premium-Stimme
"Marie", // Französische Premium-Stimme
"Paolo", // Italienische Premium-Stimme
"Carmen", // Spanische Premium-Stimme
"Yuki" // Japanische Premium-Stimme
]
var preferredVoices: [AVSpeechSynthesisVoice] = []
for voiceName in preferredVoiceNames {
if let voice = availableVoices.first(where: {
$0.language == language &&
$0.name.contains(voiceName)
}) {
preferredVoices.append(voice)
}
}
return preferredVoices
}
// MARK: - Private Methods
private func loadAvailableVoices() {
availableVoices = AVSpeechSynthesisVoice.speechVoices()
}
private func loadSelectedVoice() {
if let voiceIdentifier = userDefaults.string(forKey: selectedVoiceKey),
let voice = availableVoices.first(where: { $0.identifier == voiceIdentifier }) {
selectedVoice = voice
}
}
private func saveSelectedVoice(_ voice: AVSpeechSynthesisVoice) {
userDefaults.set(voice.identifier, forKey: selectedVoiceKey)
}
private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice {
// Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen
let preferredVoices = getPreferredVoices(for: language)
if let preferredVoice = preferredVoices.first {
return preferredVoice
}
// Fallback: Erste verfügbare Stimme für die Sprache
return availableVoices.first(where: { $0.language == language }) ??
AVSpeechSynthesisVoice(language: language) ??
AVSpeechSynthesisVoice()
}
// MARK: - Debug Methods
func printAvailableVoices(for language: String = "de-DE") {
let filteredVoices = availableVoices.filter { $0.language.starts(with: language.prefix(2)) }
print("Verfügbare Stimmen für \(language):")
for voice in filteredVoices {
print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)")
}
}
func printAllAvailableLanguages() {
let languages = Set(availableVoices.map { $0.language })
print("Verfügbare Sprachen:")
for language in languages.sorted() {
print("- \(language)")
}
}
}