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",
|
||||
"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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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