Skip to content

Commit e74d559

Browse files
committed
feat(Concurrency): add modern concurrency version for this library
* Also rename the library `swift-tts` because it has now a more generic purpose than just the Combine version.
1 parent 4ddd805 commit e74d559

File tree

8 files changed

+184
-44
lines changed

8 files changed

+184
-44
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2020 Renaud Jenny
3+
Copyright (c) 2023 Renaud Jenny
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

Package.swift

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
1-
// swift-tools-version:5.2
1+
// swift-tools-version:5.7
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
55

66
let package = Package(
7-
name: "SwiftTTSCombine",
7+
name: "swift-tts",
88
platforms: [
9-
.iOS(.v13),
10-
.macOS(.v10_15),
9+
.iOS(.v15),
10+
.macOS(.v13),
1111
],
1212
products: [
13-
.library(
14-
name: "SwiftTTSCombine",
15-
targets: ["SwiftTTSCombine"]),
13+
.library(name: "SwiftTTS", targets: ["SwiftTTS"]),
14+
.library(name: "SwiftTTSCombine", targets: ["SwiftTTSCombine"]),
1615
],
1716
dependencies: [],
1817
targets: [
19-
.target(
20-
name: "SwiftTTSCombine",
21-
dependencies: []),
22-
.testTarget(
23-
name: "SwiftTTSCombineTests",
24-
dependencies: ["SwiftTTSCombine"]),
18+
.target(name: "SwiftTTS", dependencies: []),
19+
.testTarget(name: "SwiftTTSTests", dependencies: ["SwiftTTS"]),
20+
.target(name: "SwiftTTSCombine", dependencies: []),
2521
]
2622
)

Sources/SwiftTTS/SwiftTTS.swift

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import AVFoundation
2+
3+
public struct SwiftTTS {
4+
public var rateRatio: () -> Float
5+
public var setRateRatio: (Float) -> Void
6+
public var voice: () -> AVSpeechSynthesisVoice?
7+
public var setVoice: (AVSpeechSynthesisVoice) -> Void
8+
public var speak: (String) -> Void
9+
public var isSpeaking: () -> AsyncStream<Bool>
10+
public var speakingProgress: () -> AsyncStream<Double>
11+
}
12+
13+
private final class Engine: NSObject, AVSpeechSynthesizerDelegate {
14+
15+
var rateRatio: Float
16+
var voice: AVSpeechSynthesisVoice?
17+
var isSpeaking: ((Bool) -> Void)?
18+
var speakingProgress: ((Double) -> Void)?
19+
private let speechSynthesizer = AVSpeechSynthesizer()
20+
21+
init(
22+
rateRatio: Float,
23+
voice: AVSpeechSynthesisVoice?
24+
) {
25+
self.rateRatio = rateRatio
26+
self.voice = voice
27+
super.init()
28+
speechSynthesizer.delegate = self
29+
#if os(iOS)
30+
try? AVAudioSession.sharedInstance().setCategory(.playback)
31+
#endif
32+
}
33+
34+
func speak(string: String) {
35+
let speechUtterance = AVSpeechUtterance(string: string)
36+
speechUtterance.voice = voice
37+
speechUtterance.rate *= rateRatio
38+
speechSynthesizer.speak(speechUtterance)
39+
isSpeaking?(true)
40+
}
41+
42+
func speechSynthesizer(
43+
_ synthesizer: AVSpeechSynthesizer,
44+
didStart utterance: AVSpeechUtterance
45+
) {
46+
speakingProgress?(0.0)
47+
}
48+
49+
func speechSynthesizer(
50+
_ synthesizer: AVSpeechSynthesizer,
51+
didFinish utterance: AVSpeechUtterance
52+
) {
53+
isSpeaking?(false)
54+
speakingProgress?(1.0)
55+
}
56+
57+
func speechSynthesizer(
58+
_ synthesizer: AVSpeechSynthesizer,
59+
willSpeakRangeOfSpeechString characterRange: NSRange,
60+
utterance: AVSpeechUtterance
61+
) {
62+
let total = Double(utterance.speechString.count)
63+
let averageBound = [Double(characterRange.lowerBound), Double(characterRange.upperBound)]
64+
.reduce(0, +)/2
65+
speakingProgress?(averageBound/total)
66+
}
67+
}
68+
69+
public extension SwiftTTS {
70+
static let live = {
71+
let engine = Engine(rateRatio: 1.0, voice: AVSpeechSynthesisVoice(language: "en-GB"))
72+
73+
let isSpeaking = AsyncStream { continuation in
74+
engine.isSpeaking = { continuation.yield($0) }
75+
}
76+
77+
let speakingProgress = AsyncStream { continuation in
78+
engine.speakingProgress = { continuation.yield($0) }
79+
}
80+
81+
return Self(
82+
rateRatio: { engine.rateRatio },
83+
setRateRatio: { engine.rateRatio = $0 },
84+
voice: { engine.voice },
85+
setVoice: { engine.voice = $0 },
86+
speak: engine.speak,
87+
isSpeaking: { isSpeaking },
88+
speakingProgress: { speakingProgress }
89+
)
90+
}()
91+
}

Sources/SwiftTTSCombine/SwiftTTSCombine.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import Combine
33
import AVFoundation
44

5-
public protocol TTSEngine: class {
5+
public protocol TTSEngine: AnyObject {
66
var rateRatio: Float { get set }
77
var voice: AVSpeechSynthesisVoice? { get set }
88
func speak(string: String)
@@ -25,7 +25,9 @@ public final class Engine: NSObject, ObservableObject {
2525
self.voice = voice
2626
super.init()
2727
self.speechSynthesizer.delegate = self
28+
#if os(iOS)
2829
try? AVAudioSession.sharedInstance().setCategory(.playback)
30+
#endif
2931
}
3032
}
3133

Tests/LinuxMain.swift

Lines changed: 0 additions & 7 deletions
This file was deleted.

Tests/SwiftTTSCombineTests/SwiftTTSCombineTests.swift

Lines changed: 0 additions & 13 deletions
This file was deleted.

Tests/SwiftTTSCombineTests/XCTestManifests.swift

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import AVFoundation
2+
import SwiftTTS
3+
import XCTest
4+
5+
@MainActor
6+
final class SwiftTTSTests: XCTestCase {
7+
func testLiveTTSWithoutCrashing() {
8+
let tts = SwiftTTS.live
9+
tts.speak("Let's test that!")
10+
}
11+
12+
func testTTSRateRatio() {
13+
let tts = SwiftTTS.live
14+
XCTAssertEqual(tts.rateRatio(), 1.0)
15+
tts.setRateRatio(0.5)
16+
XCTAssertEqual(tts.rateRatio(), 0.5)
17+
}
18+
19+
func testTTSVoice() throws {
20+
let tts = SwiftTTS.live
21+
let britishVoice = try XCTUnwrap(AVSpeechSynthesisVoice(language: "en-GB"))
22+
let frenchVoice = try XCTUnwrap(AVSpeechSynthesisVoice(language: "fr-FR"))
23+
24+
XCTAssertEqual(tts.voice(), britishVoice)
25+
tts.setVoice(frenchVoice)
26+
XCTAssertEqual(tts.voice(), frenchVoice)
27+
}
28+
29+
func testTTSSpeak() {
30+
let tts = SwiftTTS.live
31+
32+
let isSpeakingExpectation = expectation(description: "Expect is speaking to be true")
33+
let hasStoppedSpeakingExpectation = expectation(description: "Expect is speaking to be false after speaking")
34+
35+
Task {
36+
var hasSpoken = false
37+
for await isSpeaking in tts.isSpeaking() {
38+
if isSpeaking {
39+
hasSpoken = true
40+
isSpeakingExpectation.fulfill()
41+
}
42+
43+
if hasSpoken && !isSpeaking {
44+
hasStoppedSpeakingExpectation.fulfill()
45+
}
46+
}
47+
}
48+
49+
let isProgressZeroExpectation = expectation(description: "Expect progress to start at 0.0")
50+
let isProgressReachHalfExpectation = expectation(description: "Expect progress to be greater than at 0.5")
51+
let isProgressFinishCompletelyExpectation = expectation(description: "Expect progress to finish at 1.0")
52+
53+
Task {
54+
for await progress in tts.speakingProgress() {
55+
if progress == 0.0 {
56+
isProgressZeroExpectation.fulfill()
57+
}
58+
if progress > 0.5 && progress < 1.0 {
59+
isProgressReachHalfExpectation.fulfill()
60+
}
61+
if progress == 1.0 {
62+
isProgressFinishCompletelyExpectation.fulfill()
63+
}
64+
}
65+
}
66+
67+
tts.speak("It's a test!")
68+
69+
wait(
70+
for: [
71+
isSpeakingExpectation,
72+
hasStoppedSpeakingExpectation,
73+
isProgressZeroExpectation,
74+
isProgressReachHalfExpectation,
75+
isProgressFinishCompletelyExpectation
76+
],
77+
timeout: 2.0
78+
)
79+
}
80+
}

0 commit comments

Comments
 (0)