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:
parent
09f1ddea58
commit
9b89e58115
@ -5,9 +5,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x5B",
|
"blue" : "0x5A",
|
||||||
"green" : "0x4D",
|
"green" : "0x4A",
|
||||||
"red" : "0x00"
|
"red" : "0x1F"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
@ -23,9 +23,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x5B",
|
"blue" : "0x5A",
|
||||||
"green" : "0x4D",
|
"green" : "0x4A",
|
||||||
"red" : "0x00"
|
"red" : "0x1F"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct GlobalPlayerContainerView<Content: View>: View {
|
struct GlobalPlayerContainerView<Content: View>: View {
|
||||||
let content: Content
|
let content: Content
|
||||||
@State private var speechQueue = SpeechQueue.shared
|
@StateObject private var viewModel = SpeechPlayerViewModel()
|
||||||
|
|
||||||
init(@ViewBuilder content: () -> Content) {
|
init(@ViewBuilder content: () -> Content) {
|
||||||
self.content = content()
|
self.content = content()
|
||||||
@ -13,7 +13,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
|||||||
content
|
content
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
if speechQueue.hasItems {
|
if viewModel.hasItems {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SpeechPlayerView()
|
SpeechPlayerView()
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@ -26,7 +26,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.spring(), value: speechQueue.hasItems)
|
.animation(.spring(), value: viewModel.hasItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,6 @@ struct SpeechPlayerView: View {
|
|||||||
@State var viewModel = SpeechPlayerViewModel()
|
@State var viewModel = SpeechPlayerViewModel()
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var dragOffset: CGFloat = 0
|
@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 minHeight: CGFloat = 60
|
||||||
private let maxHeight: CGFloat = 300
|
private let maxHeight: CGFloat = 300
|
||||||
@ -45,13 +43,13 @@ struct SpeechPlayerView: View {
|
|||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Play/Pause Button
|
// Play/Pause Button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if ttsManager.isCurrentlySpeaking() {
|
if viewModel.isSpeaking {
|
||||||
viewModel.pause()
|
viewModel.pause()
|
||||||
} else {
|
} else {
|
||||||
viewModel.resume()
|
viewModel.resume()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
@ -64,11 +62,15 @@ struct SpeechPlayerView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
if speechQueue.queueCount > 0 {
|
if viewModel.queueCount > 0 {
|
||||||
Text("\(speechQueue.queueCount) Artikel in Queue")
|
Text("\(viewModel.queueCount) Artikel in Queue")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
}.onTapGesture {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -123,13 +125,13 @@ struct SpeechPlayerView: View {
|
|||||||
// Controls
|
// Controls
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 24) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if ttsManager.isCurrentlySpeaking() {
|
if viewModel.isSpeaking {
|
||||||
viewModel.pause()
|
viewModel.pause()
|
||||||
} else {
|
} else {
|
||||||
viewModel.resume()
|
viewModel.resume()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
@ -144,7 +146,7 @@ struct SpeechPlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Queue List
|
// Queue List
|
||||||
if speechQueue.queueCount == 0 {
|
if viewModel.queueCount == 0 {
|
||||||
Text("Keine Artikel in der Queue")
|
Text("Keine Artikel in der Queue")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -152,7 +154,7 @@ struct SpeechPlayerView: View {
|
|||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 8) {
|
LazyVStack(spacing: 8) {
|
||||||
ForEach(Array(speechQueue.queueItems.enumerated()), id: \.offset) { index, item in
|
ForEach(Array(viewModel.queueItems.enumerated()), id: \.offset) { index, item in
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(index + 1).")
|
Text("\(index + 1).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@ -1,24 +1,57 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@Observable
|
class SpeechPlayerViewModel: ObservableObject {
|
||||||
class SpeechPlayerViewModel {
|
private let ttsManager: TTSManager
|
||||||
var isSpeaking: Bool {
|
private let speechQueue: SpeechQueue
|
||||||
return TTSManager.shared.isCurrentlySpeaking()
|
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 {
|
private func setupBindings() {
|
||||||
return SpeechQueue.shared.currentText
|
// 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() {
|
func pause() {
|
||||||
TTSManager.shared.pause()
|
ttsManager.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
TTSManager.shared.resume()
|
ttsManager.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
SpeechQueue.shared.clear()
|
speechQueue.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@Observable
|
class SpeechQueue: ObservableObject {
|
||||||
class SpeechQueue {
|
|
||||||
private var queue: [String] = []
|
private var queue: [String] = []
|
||||||
private var isProcessing = false
|
private var isProcessing = false
|
||||||
private let ttsManager: TTSManager
|
private let ttsManager: TTSManager
|
||||||
@ -9,24 +9,16 @@ class SpeechQueue {
|
|||||||
|
|
||||||
static let shared = SpeechQueue()
|
static let shared = SpeechQueue()
|
||||||
|
|
||||||
var hasItems: Bool {
|
@Published var queueItems: [String] = []
|
||||||
return !queue.isEmpty || ttsManager.isCurrentlySpeaking()
|
@Published var currentText: String = ""
|
||||||
}
|
@Published var hasItems: Bool = false
|
||||||
|
|
||||||
var queueCount: Int {
|
var queueCount: Int {
|
||||||
return queue.count
|
return queueItems.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentItem: String? {
|
var currentItem: String? {
|
||||||
return queue.first
|
return queueItems.first
|
||||||
}
|
|
||||||
|
|
||||||
var queueItems: [String] {
|
|
||||||
return queue
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentText: String {
|
|
||||||
return queue.first ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
|
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
|
||||||
@ -36,11 +28,13 @@ class SpeechQueue {
|
|||||||
|
|
||||||
func enqueue(_ text: String) {
|
func enqueue(_ text: String) {
|
||||||
queue.append(text)
|
queue.append(text)
|
||||||
|
updatePublishedProperties()
|
||||||
processQueue()
|
processQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueue(contentsOf texts: [String]) {
|
func enqueue(contentsOf texts: [String]) {
|
||||||
queue.append(contentsOf: texts)
|
queue.append(contentsOf: texts)
|
||||||
|
updatePublishedProperties()
|
||||||
processQueue()
|
processQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +42,13 @@ class SpeechQueue {
|
|||||||
queue.removeAll()
|
queue.removeAll()
|
||||||
ttsManager.stop()
|
ttsManager.stop()
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
updatePublishedProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePublishedProperties() {
|
||||||
|
queueItems = queue
|
||||||
|
currentText = queue.first ?? ""
|
||||||
|
hasItems = !queue.isEmpty || ttsManager.isCurrentlySpeaking()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processQueue() {
|
private func processQueue() {
|
||||||
@ -56,18 +57,19 @@ class SpeechQueue {
|
|||||||
let next = queue.removeFirst()
|
let next = queue.removeFirst()
|
||||||
ttsManager.speak(text: next, language: language)
|
ttsManager.speak(text: next, language: language)
|
||||||
// Delegate/Notification für didFinish kann hier angebunden werden
|
// 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()
|
self?.waitForSpeechToFinish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForSpeechToFinish() {
|
private func waitForSpeechToFinish() {
|
||||||
if ttsManager.isCurrentlySpeaking() {
|
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()
|
self?.waitForSpeechToFinish()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.isProcessing = false
|
self.isProcessing = false
|
||||||
|
self.updatePublishedProperties()
|
||||||
self.processQueue()
|
self.processQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
||||||
static let shared = TTSManager()
|
static let shared = TTSManager()
|
||||||
private let synthesizer = AVSpeechSynthesizer()
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
private var isSpeaking = false
|
private let voiceManager = VoiceManager.shared
|
||||||
|
|
||||||
|
@Published var isSpeaking = false
|
||||||
|
@Published var currentUtterance = ""
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@ -19,10 +23,8 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
|
|
||||||
// Background-Audio aktivieren
|
|
||||||
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
|
||||||
// Notification für App-Lifecycle
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleAppDidEnterBackground),
|
selector: #selector(handleAppDidEnterBackground),
|
||||||
@ -43,76 +45,59 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
|
|
||||||
func speak(text: String, language: String = "de-DE") {
|
func speak(text: String, language: String = "de-DE") {
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isSpeaking = true
|
||||||
|
self.currentUtterance = text
|
||||||
|
}
|
||||||
|
|
||||||
if synthesizer.isSpeaking {
|
if synthesizer.isSpeaking {
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
}
|
}
|
||||||
|
|
||||||
let utterance = AVSpeechUtterance(string: text)
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
|
||||||
// Versuche eine hochwertige Stimme zu finden
|
utterance.voice = voiceManager.getVoice(for: language)
|
||||||
if let enhancedVoice = findEnhancedVoice(for: language) {
|
|
||||||
utterance.voice = enhancedVoice
|
|
||||||
} else {
|
|
||||||
utterance.voice = AVSpeechSynthesisVoice(language: language)
|
|
||||||
}
|
|
||||||
|
|
||||||
utterance.rate = 0.5
|
utterance.rate = 0.5
|
||||||
utterance.pitchMultiplier = 1.0
|
utterance.pitchMultiplier = 1.0
|
||||||
utterance.volume = 1.0
|
utterance.volume = 1.0
|
||||||
|
|
||||||
synthesizer.speak(utterance)
|
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() {
|
func pause() {
|
||||||
synthesizer.pauseSpeaking(at: .word)
|
synthesizer.pauseSpeaking(at: .immediate)
|
||||||
|
isSpeaking = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
synthesizer.continueSpeaking()
|
synthesizer.continueSpeaking()
|
||||||
|
isSpeaking = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCurrentlySpeaking() -> Bool {
|
func isCurrentlySpeaking() -> Bool {
|
||||||
@ -120,7 +105,6 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAppDidEnterBackground() {
|
@objc private func handleAppDidEnterBackground() {
|
||||||
// App geht in Hintergrund - Audio-Session beibehalten
|
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
@ -129,7 +113,6 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAppWillEnterForeground() {
|
@objc private func handleAppWillEnterForeground() {
|
||||||
// App kommt in Vordergrund - Audio-Session erneuern
|
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
@ -140,26 +123,4 @@ class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
124
readeck/Utils/VoiceManager.swift
Normal file
124
readeck/Utils/VoiceManager.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user