├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── SwiftTTS │ └── SwiftTTS.swift ├── SwiftTTSCombine │ └── SwiftTTSCombine.swift └── SwiftTTSDependency │ └── SwiftTTSDependency.swift └── Tests └── SwiftTTSTests └── SwiftTTSTests.swift /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Swift Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macOS-12 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Build 17 | run: xcodebuild -scheme swift-tts-Package -destination "platform=iOS Simulator,name=iPhone 14 Pro" 18 | 19 | - name: Run test 20 | run: xcodebuild test -scheme swift-tts-Package -destination "platform=iOS Simulator,name=iPhone 14 Pro" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Renaud Jenny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", 9 | "version" : "0.9.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-clocks", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-clocks", 16 | "state" : { 17 | "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", 18 | "version" : "0.2.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-dependencies", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-dependencies", 25 | "state" : { 26 | "revision" : "6bb1034e8a1bfbf46dfb766b6c09b7b17e1cba10", 27 | "version" : "0.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "xctest-dynamic-overlay", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 34 | "state" : { 35 | "revision" : "ab8c9f45843694dd16be4297e6d44c0634fd9913", 36 | "version" : "0.8.4" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-tts", 8 | platforms: [.iOS(.v15), .macOS(.v13)], 9 | products: [ 10 | .library(name: "SwiftTTS", targets: ["SwiftTTS"]), 11 | .library(name: "SwiftTTSDependency", targets: ["SwiftTTSDependency"]), 12 | .library(name: "SwiftTTSCombine", targets: ["SwiftTTSCombine"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.2.0"), 16 | ], 17 | targets: [ 18 | .target(name: "SwiftTTS", dependencies: []), 19 | .testTarget(name: "SwiftTTSTests", dependencies: ["SwiftTTS"]), 20 | .target( 21 | name: "SwiftTTSDependency", 22 | dependencies: [ 23 | .product(name: "Dependencies", package: "swift-dependencies"), 24 | "SwiftTTS", 25 | ] 26 | ), 27 | .target(name: "SwiftTTSCombine", dependencies: []), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftTTS 2 | 3 | [![Swift Test](https://github.com/renaudjenny/swift-tts/actions/workflows/test.yml/badge.svg)](https://github.com/renaudjenny/swift-tts/actions/workflows/test.yml) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Frenaudjenny%2Fswift-tts%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/renaudjenny/swift-tts) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Frenaudjenny%2Fswift-tts%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/renaudjenny/swift-tts) 6 | 7 | This package contains some very straightforward wrappers around TTS part of AVFoundation/AVSpeechSynthesizer to allow you using Text to Speech with ease. 8 | 9 | * `SwiftTTS` Using **Swift Concurrency** with `async` `await`, a couple of `AsyncStream` 10 | * `SwiftTTSDependency` A wrapper around the library above facilitating the integration with [Point-Free Dependencies](https://github.com/pointfreeco/swift-dependencies) library or a project made with The Composable Architecture (TCA). 11 | * `SwiftTTSCombine` the OG library still available in this package 12 | 13 | ## Modern concurrency usage 14 | 15 | * `speak(String) -> Void` - call this method when you simply want to use the TTS with a simple String 16 | * `isSpeaking() -> AsyncStream` - to know when the utterance starts to be heard, and when it's stopped 17 | * `speakingProgress() -> AsyncStream` - to know the progress, from 0 to 1 18 | * `rateRatio() -> Float` - set the rate to slow down or accelerate the TTS engine 19 | * `setRateRatio(Float) -> Void` - set the rate to slow down or accelerate the TTS engine 20 | * `voice() -> AVSpeechSynthesisVoice?` - the voice of the TTS engine, by default, it's the voice for `en-GB` 21 | * `setVoice(AVSpeechSynthesisVoice) -> Void` - set the voice of the TTS engine 22 | 23 | ### Example 24 | 25 | ```swift 26 | import SwiftTTS 27 | 28 | let tts = SwiftTTS.live 29 | 30 | tts.speak("Hello World!") 31 | 32 | Task { 33 | for await isSpeaking in tts.isSpeaking() { 34 | print("TTS is currently \(isSpeaking ? "speaking" : "not speaking")") 35 | } 36 | } 37 | 38 | Task { 39 | for await progress in tts.speakingProgress() { 40 | print("Progress: \(Int(progress * 100))%") 41 | } 42 | } 43 | 44 | tts.setRateRatio(3/4) 45 | 46 | tts.speak("Hello World! But slower") 47 | ``` 48 | 49 | ## [Point-Free Dependencies](https://github.com/pointfreeco/swift-dependencies) usage 50 | 51 | Add `@Dependency(\.tts) var tts` in your `Reducer`, you will have access to all functions mentioned above. 52 | 53 | ### Example 54 | 55 | ```swift 56 | import ComposableArchitecture 57 | import Foundation 58 | import SwiftTTSDependency 59 | 60 | public struct TTS: ReducerProtocol { 61 | public struct State: Equatable { 62 | public var text = "" 63 | public var isSpeaking = false 64 | public var speakingProgress = 1.0 65 | public var rateRatio: Float = 1.0 66 | 67 | public init( 68 | text: String = "", 69 | isSpeaking: Bool = false, 70 | speakingProgress: Double = 1.0, 71 | rateRatio: Float = 1.0 72 | ) { 73 | self.text = text 74 | self.isSpeaking = isSpeaking 75 | self.speakingProgress = speakingProgress 76 | self.rateRatio = rateRatio 77 | } 78 | } 79 | 80 | public enum Action: Equatable { 81 | case changeRateRatio(Float) 82 | case speak 83 | case startSpeaking 84 | case stopSpeaking 85 | case changeSpeakingProgress(Double) 86 | } 87 | 88 | @Dependency(\.tts) var tts 89 | 90 | public init() {} 91 | 92 | public var body: some ReducerProtocol { 93 | Reduce { state, action in 94 | switch action { 95 | case let .changeRateRatio(rateRatio): 96 | state.rateRatio = rateRatio 97 | tts.setRateRatio(rateRatio) 98 | return .none 99 | case .speak: 100 | tts.speak(state.text) 101 | return .run { send in 102 | for await isSpeaking in tts.isSpeaking() { 103 | if isSpeaking { 104 | await send(.startSpeaking) 105 | } else { 106 | await send(.stopSpeaking) 107 | } 108 | } 109 | } 110 | case .startSpeaking: 111 | state.isSpeaking = true 112 | return .run { send in 113 | for await progress in tts.speakingProgress() { 114 | await send(.changeSpeakingProgress(progress)) 115 | } 116 | } 117 | case .stopSpeaking: 118 | state.isSpeaking = false 119 | return .none 120 | case let .changeSpeakingProgress(speakingProgress): 121 | state.speakingProgress = speakingProgress 122 | return .none 123 | } 124 | } 125 | } 126 | } 127 | 128 | ``` 129 | 130 | ## Combine Usage 131 | 132 | You can instantiate/inject `TTSEngine` object, it has this behavior 133 | 134 | * `func speak(string: String)`: call this method when you simply want to use the TTS with a simple String 135 | * subscribe to `isSpeakingPublisher` to know when the utterance starts to be heard, and when it's stopped 136 | * subscribe to `speakingProgressPublisher` to know the progress, from 0 to 1 137 | * `var rateRatio: Float`: set the rate to slow down or accelerate the TTS engine 138 | * `var voice: AVSpeechSynthesisVoice?`: set the voice of the TTS engine, by default, it's the voice for `en-GB` 139 | 140 | ### Example 141 | 142 | ```swift 143 | import Combine 144 | import SwiftTTSCombine 145 | 146 | let engine: TTSEngine = SwiftTTSCombine.Engine() 147 | var cancellables = Set() 148 | 149 | engine.speak(string: "Hello World!") 150 | 151 | engine.isSpeakingPublisher 152 | .sink { isSpeaking in 153 | print("TTS is currently \(isSpeaking ? "speaking" : "not speaking")") 154 | } 155 | .store(in: &cancellables) 156 | 157 | engine.speakingProgressPublisher 158 | .sink { progress in 159 | print("Progress: \(Int(progress * 100))%") 160 | } 161 | .store(in: &cancellables) 162 | 163 | engine.rateRatio = 3/4 164 | 165 | engine.speak(string: "Hello World! But slower") 166 | ``` 167 | 168 | ## Installation 169 | 170 | ### Xcode 171 | 172 | You can add SwiftTTS libs to an Xcode project by adding it as a package dependency. 173 | 174 | 1. From the **File** menu, select **Swift Packages › Add Package Dependency...** 175 | 2. Enter "https://github.com/renaudjenny/swift-tts" into the package repository URL test field 176 | 3. Select one of the three package that your are interested in. See [above](#swifttts) 177 | 178 | ### As package dependency 179 | 180 | Edit your `Package.swift` to add one of the library you want among the three available. 181 | 182 | ```swift 183 | let package = Package( 184 | ... 185 | dependencies: [ 186 | .package(url: "https://github.com/renaudjenny/swift-tts", from: "2.0.0"), 187 | ... 188 | ], 189 | targets: [ 190 | .target( 191 | name: "", 192 | dependencies: [ 193 | .product(name: "SwiftTTS", package: "swift-tts"), // <-- Modern concurrency 194 | .product(name: "SwiftTTSDependency", package: "swift-tts"), // <-- Point-Free Dependencies library wrapper 195 | .product(name: "SwiftTTSCombine", package: "swift-tts"), // <-- Combine wrapper 196 | ]), 197 | ... 198 | ] 199 | ) 200 | ``` 201 | 202 | ## App using this library 203 | 204 | * [📲 Tell Time UK](https://apps.apple.com/gb/app/tell-time-uk/id1496541173): https://github.com/renaudjenny/telltime 205 | -------------------------------------------------------------------------------- /Sources/SwiftTTS/SwiftTTS.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | #error("This library is not compatible with macOS") 3 | #endif 4 | 5 | import AVFoundation 6 | 7 | public struct SwiftTTS { 8 | public var rateRatio: () -> Float 9 | public var setRateRatio: (Float) -> Void 10 | public var voice: () -> AVSpeechSynthesisVoice? 11 | public var setVoice: (AVSpeechSynthesisVoice) -> Void 12 | public var speak: (String) -> Void 13 | public var isSpeaking: () -> AsyncStream 14 | public var speakingProgress: () -> AsyncStream 15 | 16 | public init( 17 | rateRatio: @escaping () -> Float, 18 | setRateRatio: @escaping (Float) -> Void, 19 | voice: @escaping () -> AVSpeechSynthesisVoice?, 20 | setVoice: @escaping (AVSpeechSynthesisVoice) -> Void, 21 | speak: @escaping (String) -> Void, 22 | isSpeaking: @escaping () -> AsyncStream, 23 | speakingProgress: @escaping () -> AsyncStream 24 | ) { 25 | self.rateRatio = rateRatio 26 | self.setRateRatio = setRateRatio 27 | self.voice = voice 28 | self.setVoice = setVoice 29 | self.speak = speak 30 | self.isSpeaking = isSpeaking 31 | self.speakingProgress = speakingProgress 32 | } 33 | } 34 | 35 | private final class Engine: NSObject, AVSpeechSynthesizerDelegate { 36 | 37 | var rateRatio: Float 38 | var voice: AVSpeechSynthesisVoice? 39 | var isSpeaking: ((Bool) -> Void)? 40 | var speakingProgress: ((Double) -> Void)? 41 | private let speechSynthesizer = AVSpeechSynthesizer() 42 | 43 | init( 44 | rateRatio: Float, 45 | voice: AVSpeechSynthesisVoice? 46 | ) { 47 | self.rateRatio = rateRatio 48 | self.voice = voice 49 | super.init() 50 | speechSynthesizer.delegate = self 51 | #if os(iOS) 52 | try? AVAudioSession.sharedInstance().setCategory(.playback) 53 | #endif 54 | } 55 | 56 | func speak(string: String) { 57 | let speechUtterance = AVSpeechUtterance(string: string) 58 | speechUtterance.voice = voice 59 | speechUtterance.rate *= rateRatio 60 | speechSynthesizer.speak(speechUtterance) 61 | isSpeaking?(true) 62 | } 63 | 64 | func speechSynthesizer( 65 | _ synthesizer: AVSpeechSynthesizer, 66 | didStart utterance: AVSpeechUtterance 67 | ) { 68 | speakingProgress?(0.0) 69 | } 70 | 71 | func speechSynthesizer( 72 | _ synthesizer: AVSpeechSynthesizer, 73 | didFinish utterance: AVSpeechUtterance 74 | ) { 75 | isSpeaking?(false) 76 | speakingProgress?(1.0) 77 | } 78 | 79 | func speechSynthesizer( 80 | _ synthesizer: AVSpeechSynthesizer, 81 | willSpeakRangeOfSpeechString characterRange: NSRange, 82 | utterance: AVSpeechUtterance 83 | ) { 84 | let total = Double(utterance.speechString.count) 85 | let averageBound = [Double(characterRange.lowerBound), Double(characterRange.upperBound)] 86 | .reduce(0, +)/2 87 | speakingProgress?(averageBound/total) 88 | } 89 | } 90 | 91 | public extension SwiftTTS { 92 | static var live: Self { 93 | let engine = Engine(rateRatio: 1.0, voice: AVSpeechSynthesisVoice(language: "en-GB")) 94 | 95 | let isSpeaking = AsyncStream { continuation in 96 | engine.isSpeaking = { 97 | if $0 { 98 | continuation.yield(true) 99 | } else { 100 | continuation.yield(false) 101 | } 102 | } 103 | } 104 | 105 | let speakingProgress = AsyncStream { continuation in 106 | engine.speakingProgress = { 107 | if $0 < 1.0 { 108 | continuation.yield($0) 109 | } else { 110 | continuation.yield(1) 111 | } 112 | } 113 | } 114 | 115 | return Self( 116 | rateRatio: { engine.rateRatio }, 117 | setRateRatio: { engine.rateRatio = $0 }, 118 | voice: { engine.voice }, 119 | setVoice: { engine.voice = $0 }, 120 | speak: engine.speak, 121 | isSpeaking: { isSpeaking }, 122 | speakingProgress: { speakingProgress } 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/SwiftTTSCombine/SwiftTTSCombine.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | #error("This library is not compatible with macOS") 3 | #endif 4 | 5 | import Foundation 6 | import Combine 7 | import AVFoundation 8 | 9 | public protocol TTSEngine: AnyObject { 10 | var rateRatio: Float { get set } 11 | var voice: AVSpeechSynthesisVoice? { get set } 12 | func speak(string: String) 13 | var isSpeakingPublisher: AnyPublisher { get } 14 | var speakingProgressPublisher: AnyPublisher { get } 15 | } 16 | 17 | public final class Engine: NSObject, ObservableObject { 18 | @Published private var isSpeaking: Bool = false 19 | @Published private var speakingProgress: Double = 0.0 20 | public var rateRatio: Float 21 | public var voice: AVSpeechSynthesisVoice? 22 | private let speechSynthesizer = AVSpeechSynthesizer() 23 | 24 | public init( 25 | rateRatio: Float = 1.0, 26 | voice: AVSpeechSynthesisVoice? = AVSpeechSynthesisVoice(language: "en-GB") 27 | ) { 28 | self.rateRatio = rateRatio 29 | self.voice = voice 30 | super.init() 31 | self.speechSynthesizer.delegate = self 32 | #if os(iOS) 33 | try? AVAudioSession.sharedInstance().setCategory(.playback) 34 | #endif 35 | } 36 | } 37 | 38 | extension Engine: AVSpeechSynthesizerDelegate { 39 | public func speechSynthesizer( 40 | _ synthesizer: AVSpeechSynthesizer, 41 | didStart utterance: AVSpeechUtterance 42 | ) { 43 | self.speakingProgress = 0.0 44 | } 45 | 46 | public func speechSynthesizer( 47 | _ synthesizer: AVSpeechSynthesizer, 48 | didFinish utterance: AVSpeechUtterance 49 | ) { 50 | self.isSpeaking = false 51 | self.speakingProgress = 1.0 52 | } 53 | 54 | public func speechSynthesizer( 55 | _ synthesizer: AVSpeechSynthesizer, 56 | willSpeakRangeOfSpeechString characterRange: NSRange, 57 | utterance: AVSpeechUtterance 58 | ) { 59 | let total = Double(utterance.speechString.count) 60 | let averageBound = [Double(characterRange.lowerBound), Double(characterRange.upperBound)] 61 | .reduce(0, +)/2 62 | self.speakingProgress = averageBound/total 63 | } 64 | } 65 | 66 | extension Engine: TTSEngine { 67 | public func speak(string: String) { 68 | let speechUtterance = AVSpeechUtterance(string: string) 69 | speechUtterance.voice = voice 70 | speechUtterance.rate *= rateRatio 71 | self.speechSynthesizer.speak(speechUtterance) 72 | self.isSpeaking = true 73 | } 74 | 75 | public var isSpeakingPublisher: AnyPublisher { 76 | $isSpeaking.eraseToAnyPublisher() 77 | } 78 | 79 | public var speakingProgressPublisher: AnyPublisher { 80 | $speakingProgress.eraseToAnyPublisher() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SwiftTTSDependency/SwiftTTSDependency.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | #error("This library is not compatible with macOS") 3 | #endif 4 | 5 | import AVFoundation 6 | import Dependencies 7 | import SwiftTTS 8 | import XCTestDynamicOverlay 9 | 10 | extension SwiftTTS { 11 | static let test = Self( 12 | rateRatio: unimplemented("SwiftTTS.rateRatio"), 13 | setRateRatio: unimplemented("SwiftTTS.setRateRatio"), 14 | voice: unimplemented("SwiftTTS.voice"), 15 | setVoice: unimplemented("SwiftTTS.setVoice"), 16 | speak: unimplemented("SwiftTTS.speak"), 17 | isSpeaking: unimplemented("SwiftTTS.isSpeaking"), 18 | speakingProgress: unimplemented("SwiftTTS.speakingProgress") 19 | ) 20 | static let preview = { 21 | var speakingCallbacks: [() -> Void] = [] 22 | let speak: (String) -> Void = { 23 | print("Spoken utterance: \($0)") 24 | for callback in speakingCallbacks { 25 | callback() 26 | } 27 | } 28 | 29 | let isSpeaking = AsyncStream { continuation in 30 | speakingCallbacks.append { 31 | continuation.yield(true) 32 | Task { 33 | try await Task.sleep(nanoseconds: 2_000_000_000) 34 | continuation.yield(false) 35 | } 36 | } 37 | } 38 | 39 | let speakingProgress = AsyncStream { continuation in 40 | speakingCallbacks.append { 41 | Task { 42 | for seconds in 0...4 { 43 | continuation.yield(Double(seconds) / 4) 44 | try await Task.sleep(nanoseconds: 500_000_000) 45 | } 46 | } 47 | } 48 | } 49 | 50 | return Self( 51 | rateRatio: { 1.0 }, 52 | setRateRatio: { _ in }, 53 | voice: { AVSpeechSynthesisVoice(language: "en-GB") }, 54 | setVoice: { _ in }, 55 | speak: speak, 56 | isSpeaking: { isSpeaking }, 57 | speakingProgress: { speakingProgress } 58 | ) 59 | }() 60 | } 61 | 62 | private enum SwiftTTSDependencyKey: DependencyKey { 63 | static let liveValue = SwiftTTS.live 64 | static let testValue = SwiftTTS.test 65 | static let previewValue = SwiftTTS.preview 66 | } 67 | 68 | public extension DependencyValues { 69 | var tts: SwiftTTS { 70 | get { self[SwiftTTSDependencyKey.self] } 71 | set { self[SwiftTTSDependencyKey.self] = newValue } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/SwiftTTSTests/SwiftTTSTests.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | #error("This library is not compatible with macOS") 3 | #endif 4 | 5 | import AVFoundation 6 | import SwiftTTS 7 | import XCTest 8 | 9 | @MainActor 10 | final class SwiftTTSTests: XCTestCase { 11 | func testLiveTTSWithoutCrashing() async { 12 | let tts = SwiftTTS.live 13 | tts.speak("Let's test that!") 14 | } 15 | 16 | func testTTSRateRatio() { 17 | let tts = SwiftTTS.live 18 | XCTAssertEqual(tts.rateRatio(), 1.0) 19 | tts.setRateRatio(0.5) 20 | XCTAssertEqual(tts.rateRatio(), 0.5) 21 | } 22 | 23 | func testTTSVoice() throws { 24 | let tts = SwiftTTS.live 25 | let britishVoice = try XCTUnwrap(AVSpeechSynthesisVoice(language: "en-GB")) 26 | let frenchVoice = try XCTUnwrap(AVSpeechSynthesisVoice(language: "fr-FR")) 27 | 28 | XCTAssertEqual(tts.voice(), britishVoice) 29 | tts.setVoice(frenchVoice) 30 | XCTAssertEqual(tts.voice(), frenchVoice) 31 | } 32 | 33 | func testTTSSpeak() { 34 | let tts = SwiftTTS.live 35 | 36 | let isSpeakingExpectation = expectation(description: "Expect is speaking to be true") 37 | let hasStoppedSpeakingExpectation = expectation(description: "Expect is speaking to be false after speaking") 38 | 39 | Task { 40 | var hasSpoken = false 41 | for await isSpeaking in tts.isSpeaking() { 42 | if isSpeaking { 43 | hasSpoken = true 44 | isSpeakingExpectation.fulfill() 45 | } 46 | 47 | if hasSpoken && !isSpeaking { 48 | hasStoppedSpeakingExpectation.fulfill() 49 | } 50 | } 51 | } 52 | 53 | let isProgressZeroExpectation = expectation(description: "Expect progress to start at 0.0") 54 | let isProgressReachHalfExpectation = expectation(description: "Expect progress to be greater than at 0.5") 55 | let isProgressFinishCompletelyExpectation = expectation(description: "Expect progress to finish at 1.0") 56 | 57 | Task { 58 | for await progress in tts.speakingProgress() { 59 | if progress == 0.0 { 60 | isProgressZeroExpectation.fulfill() 61 | } 62 | if progress > 0.5 && progress < 1.0 { 63 | isProgressReachHalfExpectation.fulfill() 64 | } 65 | if progress == 1.0 { 66 | isProgressFinishCompletelyExpectation.fulfill() 67 | } 68 | } 69 | } 70 | 71 | tts.speak("It's a test!") 72 | 73 | wait( 74 | for: [ 75 | isSpeakingExpectation, 76 | hasStoppedSpeakingExpectation, 77 | isProgressZeroExpectation, 78 | isProgressReachHalfExpectation, 79 | isProgressFinishCompletelyExpectation 80 | ], 81 | timeout: 2.0 82 | ) 83 | } 84 | } 85 | --------------------------------------------------------------------------------