From 9b89e5811582b32f6a15bc2b12cf20f80fa15f8e Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 9 Jul 2025 23:15:23 +0200 Subject: [PATCH] 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 --- .../green2.colorset/Contents.json | 12 +- .../GlobalPlayerContainerView.swift | 6 +- .../UI/SpeechPlayer/SpeechPlayerView.swift | 22 ++-- .../SpeechPlayer/SpeechPlayerViewModel.swift | 51 +++++-- readeck/Utils/SpeechQueue.swift | 36 ++--- readeck/Utils/TTSManager.swift | 95 ++++---------- readeck/Utils/VoiceManager.swift | 124 ++++++++++++++++++ 7 files changed, 234 insertions(+), 112 deletions(-) create mode 100644 readeck/Utils/VoiceManager.swift diff --git a/readeck/Assets.xcassets/green2.colorset/Contents.json b/readeck/Assets.xcassets/green2.colorset/Contents.json index f5a0250..8b258f9 100644 --- a/readeck/Assets.xcassets/green2.colorset/Contents.json +++ b/readeck/Assets.xcassets/green2.colorset/Contents.json @@ -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" diff --git a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift index f5f69ce..a629987 100644 --- a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift +++ b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift @@ -2,7 +2,7 @@ import SwiftUI struct GlobalPlayerContainerView: 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: 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: View { } } } - .animation(.spring(), value: speechQueue.hasItems) + .animation(.spring(), value: viewModel.hasItems) } } diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift index 16b7707..591764b 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift @@ -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) diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift index fe5d1da..8a9b10b 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift @@ -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() + + @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() } } diff --git a/readeck/Utils/SpeechQueue.swift b/readeck/Utils/SpeechQueue.swift index 4dc66c8..caae268 100644 --- a/readeck/Utils/SpeechQueue.swift +++ b/readeck/Utils/SpeechQueue.swift @@ -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() } } diff --git a/readeck/Utils/TTSManager.swift b/readeck/Utils/TTSManager.swift index d5f4c78..324f9a8 100644 --- a/readeck/Utils/TTSManager.swift +++ b/readeck/Utils/TTSManager.swift @@ -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)") - } - } } diff --git a/readeck/Utils/VoiceManager.swift b/readeck/Utils/VoiceManager.swift new file mode 100644 index 0000000..40857b1 --- /dev/null +++ b/readeck/Utils/VoiceManager.swift @@ -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)") + } + } +} \ No newline at end of file