Skip to content

Commit

Permalink
[768] Encapsulate speech synthesizer, divorce from main queue
Browse files Browse the repository at this point in the history
  • Loading branch information
Clstroud committed May 22, 2024
1 parent 4d84bf3 commit d0a9613
Show file tree
Hide file tree
Showing 13 changed files with 234 additions and 159 deletions.
12 changes: 4 additions & 8 deletions Vocable.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
6B05020927F353DE000CFE5A /* CategoryOrderabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B05020827F353DE000CFE5A /* CategoryOrderabilityTests.swift */; };
6B1086732463662C00E729A8 /* v2_to_v3.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6B1086722463662C00E729A8 /* v2_to_v3.xcmappingmodel */; };
6B14DC732419590E0050C287 /* OutputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B14DC722419590E0050C287 /* OutputTextView.swift */; };
6B17CD7C23E9DFD50050BCB8 /* AVSpeechSynthesizer+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B17CD7B23E9DFD50050BCB8 /* AVSpeechSynthesizer+Shared.swift */; };
6B17CD8023EA045D0050BCB8 /* ViewControllerWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B17CD7F23EA045D0050BCB8 /* ViewControllerWrapperView.swift */; };
6B1C7F3223F5BB5200BB3FD9 /* HeadGazeTrackingInterpolator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1C7F3123F5BB5200BB3FD9 /* HeadGazeTrackingInterpolator.swift */; };
6B255181281328370046C2EC /* PresetsBuilders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B255180281328370046C2EC /* PresetsBuilders.swift */; };
Expand Down Expand Up @@ -268,7 +267,7 @@
B24F02392BEBEF4E003C6BF9 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B24F02382BEBEF4E003C6BF9 /* InfoPlist.xcstrings */; };
B277CDC02BF7AF55003BFACE /* UIFont+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B277CDBF2BF7AF55003BFACE /* UIFont+Helpers.swift */; };
B2BC6CA92BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CA82BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift */; };
B2BC6CAB2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CAA2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift */; };
B2BC6CAB2BD94FB500D459F6 /* VocableSpeechSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CAA2BD94FB500D459F6 /* VocableSpeechSynthesizer.swift */; };
B2BC6CAD2BD96CD000D459F6 /* VoiceProfilePreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CAC2BD96CD000D459F6 /* VoiceProfilePreviewController.swift */; };
B2BC6CAF2BD96D2700D459F6 /* VoiceProfilePreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CAE2BD96D2700D459F6 /* VoiceProfilePreviewDataSource.swift */; };
B2BC6CB12BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB02BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift */; };
Expand Down Expand Up @@ -388,7 +387,6 @@
6B1086712463102F00E729A8 /* Phrases v3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Phrases v3.xcdatamodel"; sourceTree = "<group>"; };
6B1086722463662C00E729A8 /* v2_to_v3.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = v2_to_v3.xcmappingmodel; sourceTree = "<group>"; };
6B14DC722419590E0050C287 /* OutputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputTextView.swift; sourceTree = "<group>"; };
6B17CD7B23E9DFD50050BCB8 /* AVSpeechSynthesizer+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVSpeechSynthesizer+Shared.swift"; sourceTree = "<group>"; };
6B17CD7F23EA045D0050BCB8 /* ViewControllerWrapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerWrapperView.swift; sourceTree = "<group>"; };
6B1C7F3123F5BB5200BB3FD9 /* HeadGazeTrackingInterpolator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadGazeTrackingInterpolator.swift; sourceTree = "<group>"; };
6B255180281328370046C2EC /* PresetsBuilders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsBuilders.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -544,7 +542,7 @@
B24F02382BEBEF4E003C6BF9 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
B277CDBF2BF7AF55003BFACE /* UIFont+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Helpers.swift"; sourceTree = "<group>"; };
B2BC6CA82BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VocableListContentConfiguration+VoiceProfileItem.swift"; sourceTree = "<group>"; };
B2BC6CAA2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceProfilePreviewSynthesizer.swift; sourceTree = "<group>"; };
B2BC6CAA2BD94FB500D459F6 /* VocableSpeechSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocableSpeechSynthesizer.swift; sourceTree = "<group>"; };
B2BC6CAC2BD96CD000D459F6 /* VoiceProfilePreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceProfilePreviewController.swift; sourceTree = "<group>"; };
B2BC6CAE2BD96D2700D459F6 /* VoiceProfilePreviewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceProfilePreviewDataSource.swift; sourceTree = "<group>"; };
B2BC6CB02BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VoiceProfilePreviewDataSource+Filter.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -801,7 +799,6 @@
B2BC6CB82BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift */,
B2BC6CA82BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift */,
B2BC6CB42BDAD1B500D459F6 /* VoiceProfileItem.swift */,
B2BC6CAA2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift */,
B2BC6CAE2BD96D2700D459F6 /* VoiceProfilePreviewDataSource.swift */,
B2BC6CB02BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift */,
B2BC6CAC2BD96CD000D459F6 /* VoiceProfilePreviewController.swift */,
Expand Down Expand Up @@ -1178,6 +1175,7 @@
6BE02F6628133E00002CCCED /* LaunchEnvironment.swift */,
A99B5323282C0D5D004FEFDA /* AnalyticsReportable.swift */,
A99B5325282C0E9D004FEFDA /* TimeInterval+AnalyticsReportable.swift */,
B2BC6CAA2BD94FB500D459F6 /* VocableSpeechSynthesizer.swift */,
);
path = Common;
sourceTree = "<group>";
Expand All @@ -1198,7 +1196,6 @@
children = (
A9DE170123F492B20094DB64 /* Array+Split.swift */,
B277CDBF2BF7AF55003BFACE /* UIFont+Helpers.swift */,
6B17CD7B23E9DFD50050BCB8 /* AVSpeechSynthesizer+Shared.swift */,
714407EC243CEEA1009D077B /* Category+Helpers.swift */,
B8DA9DF123F30BAF00FEBE19 /* Cell+NibLoading.swift */,
6B8440DE2448C391007A4DC6 /* CGSize+Helpers.swift */,
Expand Down Expand Up @@ -1453,7 +1450,6 @@
4EB6A59427FBF84B00F2BFB3 /* VocableGazeButtonStyle.swift in Sources */,
6B5C490F2460627B00A4433C /* NumericCategoryContentViewController.swift in Sources */,
6B9DFA6023E889DB0037673E /* Extensions.swift in Sources */,
6B17CD7C23E9DFD50050BCB8 /* AVSpeechSynthesizer+Shared.swift in Sources */,
9D15E0552271F0DC0058DF57 /* CursorView.swift in Sources */,
6B9DFA5C23E889DB0037673E /* HeadGazeWindow.swift in Sources */,
642F96A62BD808D50031E109 /* AccessibilityID+Settings+VoiceConfiguration.swift.swift in Sources */,
Expand Down Expand Up @@ -1530,7 +1526,7 @@
6BD84F9D2604F8F600D4CD3E /* ListenModeDebugView.swift in Sources */,
6B8A6E3324536085004B5220 /* UIHeadGazeCursorWindow.swift in Sources */,
6B8D3C14282C23CA00DDB9B7 /* AccessibilityID+Settings+SelectionMode.swift in Sources */,
B2BC6CAB2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift in Sources */,
B2BC6CAB2BD94FB500D459F6 /* VocableSpeechSynthesizer.swift in Sources */,
6B0501FE27EBA471000CFE5A /* VocableListCellAction.swift in Sources */,
A9DE170223F492B20094DB64 /* Array+Split.swift in Sources */,
6B9DFA5D23E889DB0037673E /* UIHeadGaze.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion Vocable/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
addObservers()

// Warm up the speech engine to prevent lag on first invocation
AVSpeechSynthesizer.shared.speak("", language: "en")
_ = VocableSpeechSynthesizer.shared

application.isIdleTimerDisabled = true

Expand Down
12 changes: 12 additions & 0 deletions Vocable/Common/PagingCarouselViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import UIKit
import Combine
import AVFoundation

@IBDesignable class PagingCarouselViewController: VocableViewController, UICollectionViewDelegate {

Expand Down Expand Up @@ -144,4 +145,15 @@ import Combine
NSLayoutConstraint.activate(constraints)
volatileConstraints = constraints
}

// Speaking + highlighting consolidation

func speak(
_ string: String,
forItemAt indexPath: IndexPath
) {
Task {
await VocableSpeechSynthesizer.shared.speak(string, language: AppConfig.activePreferredLanguageCode)
}
}
}
166 changes: 166 additions & 0 deletions Vocable/Common/VocableSpeechSynthesizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// VocableSpeechSynthesizer.swift
// Vocable
//
// Created by Chris Stroud on 4/24/24.
// Copyright © 2024 WillowTree. All rights reserved.
//

import Foundation
import AVFoundation
import UIKit
import Combine

@MainActor
protocol VocableSpeechSynthesizerDelegate: AnyObject {
func voiceProfilePreviewDidBegin(_: AVSpeechSynthesisVoice?)
func voiceProfilePreviewDidEnd()
}

actor VocableSpeechSynthesizer: NSObject, AVSpeechSynthesizerDelegate {

private let synthesizer = AVSpeechSynthesizer()

@MainActor
private(set) var activePreviewVoice: AVSpeechSynthesisVoice?

@MainActor
weak var delegate: VocableSpeechSynthesizerDelegate?

@MainActor
static let shared = VocableSpeechSynthesizer()

@MainActor
@Published
private(set) var isSpeaking: Bool = false

private var synthesisOperations = [AVSpeechUtterance: AsyncStream<NSRange>.Continuation]()

override init() {
super.init()
synthesizer.delegate = self

Task {
await speak("")
}
}

init(delegate: VocableSpeechSynthesizerDelegate? = nil) {
self.init()
Task { @MainActor in
self.delegate = delegate
}
}

private func setContinuation(
_ continuation: AsyncStream<NSRange>.Continuation?,
for utterance: AVSpeechUtterance
) {
synthesisOperations[utterance] = continuation
}

private func speak(utterance: AVSpeechUtterance) -> AsyncStream<NSRange> {
AsyncStream { [weak self] continuation in
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
continuation.finish()
return
}
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
await setContinuation(continuation, for: utterance)
synthesizer.speak(utterance)
}
}
}

@discardableResult
func speak(_ string: String, language: String = AppConfig.activePreferredLanguageCode) -> AsyncStream<NSRange> {
let utterance = AVSpeechUtterance(string: string)
if let selectedVoiceID = AppConfig.selectedVoiceIdentifier {
if let voice = AVSpeechSynthesisVoice(identifier: selectedVoiceID) {
let languageLocale = NSLocale(localeIdentifier: language)
let voiceLocale = NSLocale(localeIdentifier: voice.language)

// Check to be sure the user-provided voice can speak this language
if languageLocale.languageCode == voiceLocale.languageCode {
utterance.voice = voice
}
}
}

// fall back to previous behavior
if utterance.voice == nil {
utterance.voice = AVSpeechSynthesisVoice(language: language)
}

return speak(utterance: utterance)
}

@discardableResult
func playPreview(_ voice: AVSpeechSynthesisVoice) -> AsyncStream<NSRange> {
let format = String(localized: "voice_preview.sample_audio.introducion_format")
let localizedUtterance = String.localizedStringWithFormat(format, voice.name)
let utterance = AVSpeechUtterance(string: localizedUtterance)
utterance.voice = voice
utterance.rate = 0.5
return speak(utterance: utterance)
}

func stopPreview() {
synthesizer.stopSpeaking(at: .immediate)
}

private func synthesisOperationDidEnd(utterance: AVSpeechUtterance) {
synthesisOperations[utterance]?.finish()
setContinuation(nil, for: utterance)
}

private func synthesisOperationWillSpeak(utterance: AVSpeechUtterance, range: NSRange) {
synthesisOperations[utterance]?.yield(range)
}

nonisolated
public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor in
activePreviewVoice = utterance.voice
isSpeaking = true
delegate?.voiceProfilePreviewDidBegin(utterance.voice)
}
}

nonisolated
public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in
activePreviewVoice = nil
isSpeaking = false
await synthesisOperationDidEnd(utterance: utterance)
delegate?.voiceProfilePreviewDidEnd()
}
}

nonisolated
public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in
activePreviewVoice = nil
isSpeaking = false
await synthesisOperationDidEnd(utterance: utterance)
delegate?.voiceProfilePreviewDidEnd()
}
}

nonisolated
public func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor in
await synthesisOperationWillSpeak(
utterance: utterance,
range: characterRange
)
}
}
}
49 changes: 0 additions & 49 deletions Vocable/Extensions/AVSpeechSynthesizer+Shared.swift

This file was deleted.

11 changes: 7 additions & 4 deletions Vocable/Features/Keyboard/KeyboardViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import AVKit
import Combine

class KeyboardViewController: UICollectionViewController {

private var dataSource: UICollectionViewDiffableDataSource<Section, ItemWrapper>!

private var speechSynthesizer: VocableSpeechSynthesizer!
private var disposables = Set<AnyCancellable>()

private var _textTransaction = TextTransaction(text: "") {
Expand Down Expand Up @@ -58,7 +59,9 @@ class KeyboardViewController: UICollectionViewController {

override func viewDidLoad() {
super.viewDidLoad()


speechSynthesizer = VocableSpeechSynthesizer()

$attributedText.receive(on: DispatchQueue.main).sink { [weak self] (newAttributedText) in
guard let self = self, let newAttributedText = newAttributedText,
newAttributedText.string != self._textTransaction.text else { return }
Expand Down Expand Up @@ -193,8 +196,8 @@ class KeyboardViewController: UICollectionViewController {

Analytics.shared.track(.keyboardPhraseSpoken)
let utterance = textTransaction.text
DispatchQueue.global(qos: .userInitiated).async {
AVSpeechSynthesizer.shared.speak(utterance, language: AppConfig.activePreferredLanguageCode)
Task { [weak self] in
await self?.speechSynthesizer.speak(utterance)
}
case .clear:
setTextTransaction(TextTransaction(text: "", intent: .none))
Expand Down
5 changes: 1 addition & 4 deletions Vocable/Features/Root/CategoryDetailViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,7 @@ class CategoryDetailViewController: PagingCarouselViewController, NSFetchedResul
try? context.save()
}

// Dispatch to get off the main queue for performance
DispatchQueue.global(qos: .userInitiated).async {
AVSpeechSynthesizer.shared.speak(utterance, language: AppConfig.activePreferredLanguageCode)
}
speak(utterance, forItemAt: indexPath)
}
case .addNewPhrase:
addNewPhraseButtonSelected()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ class NumericCategoryContentViewController: PagingCarouselViewController {
guard let utterance = diffableDataSource.itemIdentifier(for: indexPath) else { return }
lastUtterance = utterance

DispatchQueue.global(qos: .userInitiated).async {
AVSpeechSynthesizer.shared.speak(utterance, language: AppConfig.activePreferredLanguageCode)
}
speak(utterance, forItemAt: indexPath)
}

func contentItems() -> [String] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import UIKit

extension VocableListContentConfiguration {

@MainActor
static func voiceProfileItem(
_ item: VoiceProfileItem,
controller: VoiceProfilePreviewController,
Expand Down Expand Up @@ -39,6 +40,7 @@ extension VocableListContentConfiguration {
)
}

@MainActor
static func voiceSelectionPreview(
_ item: VoiceProfileItem,
controller: VoiceProfilePreviewController
Expand Down
Loading

0 comments on commit d0a9613

Please sign in to comment.