From 6915274885132f1fc904f00ffdea99a4e5cb66a6 Mon Sep 17 00:00:00 2001 From: Guillermo Moraleda Date: Fri, 28 Apr 2023 11:08:10 +0200 Subject: [PATCH] Updated implementation --- Package.swift | 11 +- Sources/SwiftSpeech/Authorization.swift | 21 ++- Sources/SwiftSpeech/Demos.swift | 46 +++--- Sources/SwiftSpeech/Environments.swift | 15 +- Sources/SwiftSpeech/Extensions.swift | 102 ++++++------ Sources/SwiftSpeech/LibraryContent.swift | 12 +- Sources/SwiftSpeech/RecordButton.swift | 97 ++++++----- Sources/SwiftSpeech/Session.swift | 70 ++++---- Sources/SwiftSpeech/SpeechRecognizer.swift | 87 +++++----- Sources/SwiftSpeech/SwiftSpeech.swift | 10 +- Sources/SwiftSpeech/ViewModifiers.swift | 152 ++++++++---------- Tests/SwiftSpeechTests/SwiftSpeechTests.swift | 3 +- Tests/SwiftSpeechTests/XCTestManifests.swift | 10 +- 13 files changed, 301 insertions(+), 335 deletions(-) diff --git a/Package.swift b/Package.swift index 8c679a9..e41047b 100644 --- a/Package.swift +++ b/Package.swift @@ -5,12 +5,13 @@ import PackageDescription let package = Package( name: "SwiftSpeech", - platforms: [.iOS(.v13), .macOS(.v10_15)], + platforms: [.iOS(.v13)], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "SwiftSpeech", - targets: ["SwiftSpeech"]), + targets: ["SwiftSpeech"] + ), // .executable(name: "SwiftSpeechExample", targets: ["SwiftSpeechExample"]), ], dependencies: [ @@ -22,9 +23,11 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "SwiftSpeech", - dependencies: []), + dependencies: [] + ), .testTarget( name: "SwiftSpeechTests", - dependencies: ["SwiftSpeech"]), + dependencies: ["SwiftSpeech"] + ), ] ) diff --git a/Sources/SwiftSpeech/Authorization.swift b/Sources/SwiftSpeech/Authorization.swift index 9befdfa..1cb007a 100644 --- a/Sources/SwiftSpeech/Authorization.swift +++ b/Sources/SwiftSpeech/Authorization.swift @@ -1,23 +1,22 @@ // // Authorization.swift -// +// // // Created by Cay Zhang on 2020/7/22. // -import SwiftUI import Combine import Speech +import SwiftUI extension SwiftSpeech { - public static func requestSpeechRecognitionAuthorization() { AuthorizationCenter.shared.requestSpeechRecognitionAuthorization() } - + class AuthorizationCenter: ObservableObject { @Published var speechRecognitionAuthorizationStatus: SFSpeechRecognizerAuthorizationStatus = SFSpeechRecognizer.authorizationStatus() - + func requestSpeechRecognitionAuthorization() { // Asynchronously make the authorization request. SFSpeechRecognizer.requestAuthorization { authStatus in @@ -28,26 +27,26 @@ extension SwiftSpeech { } } } - + static let shared = AuthorizationCenter() } } @propertyWrapper public struct SpeechRecognitionAuthStatus: DynamicProperty { @ObservedObject var authCenter = SwiftSpeech.AuthorizationCenter.shared - + let trueValues: Set - + public var wrappedValue: SFSpeechRecognizerAuthorizationStatus { SwiftSpeech.AuthorizationCenter.shared.speechRecognitionAuthorizationStatus } - + public init(trueValues: Set = [.authorized]) { self.trueValues = trueValues } - + public var projectedValue: Bool { - self.trueValues.contains(SwiftSpeech.AuthorizationCenter.shared.speechRecognitionAuthorizationStatus) + trueValues.contains(SwiftSpeech.AuthorizationCenter.shared.speechRecognitionAuthorizationStatus) } } diff --git a/Sources/SwiftSpeech/Demos.swift b/Sources/SwiftSpeech/Demos.swift index 946f3fc..cba1c3c 100644 --- a/Sources/SwiftSpeech/Demos.swift +++ b/Sources/SwiftSpeech/Demos.swift @@ -1,54 +1,50 @@ // // Demos.swift -// +// // // Created by Cay Zhang on 2020/2/23. // -import SwiftUI import Combine import Speech +import SwiftUI public extension SwiftSpeech.Demos { - - struct Basic : View { - + struct Basic: View { var sessionConfiguration: SwiftSpeech.Session.Configuration - + @State private var text = "Tap to Speak" - + public init(sessionConfiguration: SwiftSpeech.Session.Configuration) { self.sessionConfiguration = sessionConfiguration } - + public init(locale: Locale = .current) { self.init(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale)) } - + public init(localeIdentifier: String) { self.init(locale: Locale(identifier: localeIdentifier)) } - + public var body: some View { VStack(spacing: 35.0) { Text(text) .font(.system(size: 25, weight: .bold, design: .default)) - SwiftSpeech.RecordButton() + RecordButton() .swiftSpeechToggleRecordingOnTap(sessionConfiguration: sessionConfiguration, animation: .spring(response: 0.3, dampingFraction: 0.5, blendDuration: 0)) .onRecognizeLatest(update: $text) - + }.onAppear { SwiftSpeech.requestSpeechRecognitionAuthorization() } } - } - - struct Colors : View { + struct Colors: View { @State private var text = "Hold and say a color!" - static let colorDictionary: [String : Color] = [ + static let colorDictionary: [String: Color] = [ "black": .black, "white": .white, "blue": .blue, @@ -58,7 +54,7 @@ public extension SwiftSpeech.Demos { "pink": .pink, "purple": .purple, "red": .red, - "yellow": .yellow + "yellow": .yellow, ] var color: Color? { @@ -69,14 +65,14 @@ public extension SwiftSpeech.Demos { .value } - public init() { } + public init() {} public var body: some View { VStack(spacing: 35.0) { Text(text) .font(.system(size: 25, weight: .bold, design: .default)) .foregroundColor(color) - SwiftSpeech.RecordButton() + RecordButton() .accentColor(color) .swiftSpeechRecordOnHold(locale: Locale(identifier: "en_US"), animation: .spring(response: 0.3, dampingFraction: 0.5, blendDuration: 0)) .onRecognizeLatest(update: $text) @@ -84,23 +80,21 @@ public extension SwiftSpeech.Demos { SwiftSpeech.requestSpeechRecognitionAuthorization() } } - } - struct List : View { - + struct List: View { var sessionConfiguration: SwiftSpeech.Session.Configuration @State var list: [(session: SwiftSpeech.Session, text: String)] = [] - + public init(sessionConfiguration: SwiftSpeech.Session.Configuration) { self.sessionConfiguration = sessionConfiguration } - + public init(locale: Locale = .current) { self.init(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale)) } - + public init(localeIdentifier: String) { self.init(locale: Locale(identifier: localeIdentifier)) } @@ -112,7 +106,7 @@ public extension SwiftSpeech.Demos { Text(pair.text) } }.overlay( - SwiftSpeech.RecordButton() + RecordButton() .swiftSpeechRecordOnHold( sessionConfiguration: sessionConfiguration, animation: .spring(response: 0.3, dampingFraction: 0.5, blendDuration: 0), diff --git a/Sources/SwiftSpeech/Environments.swift b/Sources/SwiftSpeech/Environments.swift index 5463694..d609981 100644 --- a/Sources/SwiftSpeech/Environments.swift +++ b/Sources/SwiftSpeech/Environments.swift @@ -5,45 +5,44 @@ // Created by Cay Zhang on 2020/2/16. // -import SwiftUI import Combine import Speech +import SwiftUI extension SwiftSpeech.EnvironmentKeys { struct SwiftSpeechState: EnvironmentKey { static let defaultValue: SwiftSpeech.State = .pending } - + struct ActionsOnStartRecording: EnvironmentKey { static let defaultValue: [(_ session: SwiftSpeech.Session) -> Void] = [] } - + struct ActionsOnStopRecording: EnvironmentKey { static let defaultValue: [(_ session: SwiftSpeech.Session) -> Void] = [] } - + struct ActionsOnCancelRecording: EnvironmentKey { static let defaultValue: [(_ session: SwiftSpeech.Session) -> Void] = [] } } public extension EnvironmentValues { - var swiftSpeechState: SwiftSpeech.State { get { self[SwiftSpeech.EnvironmentKeys.SwiftSpeechState.self] } set { self[SwiftSpeech.EnvironmentKeys.SwiftSpeechState.self] = newValue } } - + var actionsOnStartRecording: [(_ session: SwiftSpeech.Session) -> Void] { get { self[SwiftSpeech.EnvironmentKeys.ActionsOnStartRecording.self] } set { self[SwiftSpeech.EnvironmentKeys.ActionsOnStartRecording.self] = newValue } } - + var actionsOnStopRecording: [(_ session: SwiftSpeech.Session) -> Void] { get { self[SwiftSpeech.EnvironmentKeys.ActionsOnStopRecording.self] } set { self[SwiftSpeech.EnvironmentKeys.ActionsOnStopRecording.self] = newValue } } - + var actionsOnCancelRecording: [(_ session: SwiftSpeech.Session) -> Void] { get { self[SwiftSpeech.EnvironmentKeys.ActionsOnCancelRecording.self] } set { self[SwiftSpeech.EnvironmentKeys.ActionsOnCancelRecording.self] = newValue } diff --git a/Sources/SwiftSpeech/Extensions.swift b/Sources/SwiftSpeech/Extensions.swift index e9ef77e..282ef3a 100644 --- a/Sources/SwiftSpeech/Extensions.swift +++ b/Sources/SwiftSpeech/Extensions.swift @@ -1,32 +1,35 @@ // // Extensions.swift -// +// // // Created by Cay Zhang on 2020/2/16. // -import SwiftUI import Combine import Speech +import SwiftUI public extension View { func onStartRecording(appendAction actionToAppend: @escaping (_ session: SwiftSpeech.Session) -> Void) -> - ModifiedContent Void]>> { - self.transformEnvironment(\.actionsOnStartRecording) { actions in + ModifiedContent Void]>> + { + transformEnvironment(\.actionsOnStartRecording) { actions in actions.insert(actionToAppend, at: 0) } as! ModifiedContent Void]>> } - + func onStopRecording(appendAction actionToAppend: @escaping (_ session: SwiftSpeech.Session) -> Void) -> - ModifiedContent Void]>> { - self.transformEnvironment(\.actionsOnStopRecording) { actions in + ModifiedContent Void]>> + { + transformEnvironment(\.actionsOnStopRecording) { actions in actions.insert(actionToAppend, at: 0) } as! ModifiedContent Void]>> } - + func onCancelRecording(appendAction actionToAppend: @escaping (_ session: SwiftSpeech.Session) -> Void) -> - ModifiedContent Void]>> { - self.transformEnvironment(\.actionsOnCancelRecording) { actions in + ModifiedContent Void]>> + { + transformEnvironment(\.actionsOnCancelRecording) { actions in actions.insert(actionToAppend, at: 0) } as! ModifiedContent Void]>> } @@ -34,50 +37,49 @@ public extension View { public extension View { func onStartRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session { - self.onStartRecording { session in + onStartRecording { session in subject.send(session) } } - + func onStartRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session? { - self.onStartRecording { session in + onStartRecording { session in subject.send(session) } } - + func onStopRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session { - self.onStopRecording { session in + onStopRecording { session in subject.send(session) } } - + func onStopRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session? { - self.onStopRecording { session in + onStopRecording { session in subject.send(session) } } - + func onCancelRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session { - self.onCancelRecording { session in + onCancelRecording { session in subject.send(session) } } - + func onCancelRecording(sendSessionTo subject: S) -> ModifiedContent Void]>> where S.Output == SwiftSpeech.Session? { - self.onCancelRecording { session in + onCancelRecording { session in subject.send(session) } } } public extension View { - func swiftSpeechRecordOnHold( sessionConfiguration: SwiftSpeech.Session.Configuration = SwiftSpeech.Session.Configuration(), animation: Animation = SwiftSpeech.defaultAnimation, distanceToCancel: CGFloat = 50.0 ) -> ModifiedContent { - self.modifier( + modifier( SwiftSpeech.ViewModifiers.RecordOnHold( sessionConfiguration: sessionConfiguration, animation: animation, @@ -85,29 +87,29 @@ public extension View { ) ) } - + func swiftSpeechRecordOnHold( locale: Locale, animation: Animation = SwiftSpeech.defaultAnimation, distanceToCancel: CGFloat = 50.0 ) -> ModifiedContent { - self.swiftSpeechRecordOnHold(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale), animation: animation, distanceToCancel: distanceToCancel) + swiftSpeechRecordOnHold(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale), animation: animation, distanceToCancel: distanceToCancel) } - + func swiftSpeechToggleRecordingOnTap( sessionConfiguration: SwiftSpeech.Session.Configuration = SwiftSpeech.Session.Configuration(), animation: Animation = SwiftSpeech.defaultAnimation ) -> ModifiedContent { - self.modifier(SwiftSpeech.ViewModifiers.ToggleRecordingOnTap(sessionConfiguration: sessionConfiguration, animation: animation)) + modifier(SwiftSpeech.ViewModifiers.ToggleRecordingOnTap(sessionConfiguration: sessionConfiguration, animation: animation)) } - + func swiftSpeechToggleRecordingOnTap( locale: Locale = .autoupdatingCurrent, animation: Animation = SwiftSpeech.defaultAnimation ) -> ModifiedContent { - self.swiftSpeechToggleRecordingOnTap(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale), animation: animation) + swiftSpeechToggleRecordingOnTap(sessionConfiguration: SwiftSpeech.Session.Configuration(locale: locale), animation: animation) } - + func onRecognize( includePartialResults isPartialResultIncluded: Bool = true, handleResult resultHandler: @escaping (SwiftSpeech.Session, SFSpeechRecognitionResult) -> Void, @@ -122,7 +124,7 @@ public extension View { ) ) } - + func onRecognizeLatest( includePartialResults isPartialResultIncluded: Bool = true, handleResult resultHandler: @escaping (SwiftSpeech.Session, SFSpeechRecognitionResult) -> Void, @@ -137,7 +139,7 @@ public extension View { ) ) } - + func onRecognizeLatest( includePartialResults isPartialResultIncluded: Bool = true, handleResult resultHandler: @escaping (SFSpeechRecognitionResult) -> Void, @@ -149,7 +151,7 @@ public extension View { handleError: { _, error in errorHandler(error) } ) } - + func onRecognizeLatest( includePartialResults isPartialResultIncluded: Bool = true, update textBinding: Binding @@ -158,40 +160,36 @@ public extension View { textBinding.wrappedValue = result.bestTranscription.formattedString } handleError: { _ in } } - + func printRecognizedText( includePartialResults isPartialResultIncluded: Bool = true ) -> ModifiedContent { - onRecognize(includePartialResults: isPartialResultIncluded) { session, result in + onRecognize(includePartialResults: isPartialResultIncluded) { _, result in print("[SwiftSpeech] Recognized Text: \(result.bestTranscription.formattedString)") } handleError: { _, _ in } } } public extension Subject where Output == SpeechRecognizer.ID?, Failure == Never { - func mapResolved(_ transform: @escaping (SpeechRecognizer) -> T) -> Publishers.CompactMap { - return self - .compactMap { (id) -> T? in - if let recognizer = SpeechRecognizer.recognizer(withID: id) { - return transform(recognizer) - } else { - return nil - } + return compactMap { id -> T? in + if let recognizer = SpeechRecognizer.recognizer(withID: id) { + return transform(recognizer) + } else { + return nil } + } } - + func mapResolved(_ keyPath: KeyPath) -> Publishers.CompactMap { - return self - .compactMap { (id) -> T? in - if let recognizer = SpeechRecognizer.recognizer(withID: id) { - return recognizer[keyPath: keyPath] - } else { - return nil - } + return compactMap { id -> T? in + if let recognizer = SpeechRecognizer.recognizer(withID: id) { + return recognizer[keyPath: keyPath] + } else { + return nil } + } } - } public extension SwiftSpeech { diff --git a/Sources/SwiftSpeech/LibraryContent.swift b/Sources/SwiftSpeech/LibraryContent.swift index 18675a0..33acb98 100644 --- a/Sources/SwiftSpeech/LibraryContent.swift +++ b/Sources/SwiftSpeech/LibraryContent.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Cay Zhang on 2020/7/22. // @@ -12,26 +12,26 @@ struct LibraryContent: LibraryContentProvider { @LibraryContentBuilder var views: [LibraryItem] { LibraryItem( - SwiftSpeech.RecordButton(), + RecordButton(), title: "Record Button" ) - + LibraryItem( SwiftSpeech.Demos.Basic(locale: .current), title: "Demo - Basic" ) - + LibraryItem( SwiftSpeech.Demos.Colors(), title: "Demo - Colors" ) - + LibraryItem( SwiftSpeech.Demos.List(locale: .current), title: "Demos - List" ) } - + @LibraryContentBuilder func modifiers(base: AnyView) -> [LibraryItem] { LibraryItem( diff --git a/Sources/SwiftSpeech/RecordButton.swift b/Sources/SwiftSpeech/RecordButton.swift index e3883bf..4748bde 100644 --- a/Sources/SwiftSpeech/RecordButton.swift +++ b/Sources/SwiftSpeech/RecordButton.swift @@ -1,12 +1,12 @@ // // RecordButton.swift -// +// // // Created by Cay Zhang on 2020/2/16. // -import SwiftUI import Combine +import SwiftUI public extension SwiftSpeech { /** @@ -25,60 +25,53 @@ public extension SwiftSpeech { } } -public extension SwiftSpeech { - struct RecordButton : View { - - @Environment(\.swiftSpeechState) var state: SwiftSpeech.State - @SpeechRecognitionAuthStatus var authStatus - - public init() { } - - var backgroundColor: Color { - switch state { - case .pending: - return .accentColor - case .recording: - return .red - case .cancelling: - return .init(white: 0.1) - } +public struct RecordButton: View { + @Environment(\.swiftSpeechState) var state: SwiftSpeech.State + @SpeechRecognitionAuthStatus var authStatus + @State var scale: CGFloat = 1 + + var backgroundColor: Color { + switch state { + case .pending: + return .clear + case .recording: + return .accentColor.opacity(0.2) + case .cancelling: + return .init(white: 0.1) } - - var scale: CGFloat { - switch state { - case .pending: - return 1.0 - case .recording: - return 1.8 - case .cancelling: - return 1.4 - } + } + + public var body: some View { + ZStack { + background + + Image(systemName: state == .pending ? "mic.fill" : "square.fill") + .foregroundColor(.accentColor) + .padding(6) + .transition(.opacity) + .layoutPriority(2) + .zIndex(1) } - - public var body: some View { - - ZStack { - backgroundColor - .animation(.easeOut(duration: 0.2)) - .clipShape(Circle()) - .environment(\.isEnabled, $authStatus) // When isEnabled is false, the accent color is gray and all user interactions are disabled inside the view. - .zIndex(0) - - Image(systemName: state != .cancelling ? "waveform" : "xmark") - .font(.system(size: 30, weight: .medium, design: .default)) - .foregroundColor(.white) - .opacity(state == .recording ? 0.8 : 1.0) - .padding(20) - .transition(.opacity) - .layoutPriority(2) - .zIndex(1) - - } + } + + @ViewBuilder + var background: some View { + if state == .recording { + backgroundColor + .zIndex(0) + .clipShape(Circle()) + .environment(\.isEnabled, $authStatus) .scaleEffect(scale) - .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.2), radius: 5, x: 0, y: 3) - + .onAppear { + scale = 1 + withAnimation(Animation.easeInOut(duration: 0.7).repeatForever(autoreverses: true)) { + scale = 1.2 + } + } + + } else { + EmptyView() } - } } diff --git a/Sources/SwiftSpeech/Session.swift b/Sources/SwiftSpeech/Session.swift index 865eec9..36cc7da 100644 --- a/Sources/SwiftSpeech/Session.swift +++ b/Sources/SwiftSpeech/Session.swift @@ -1,6 +1,6 @@ // // Session.swift -// +// // // Created by Cay Zhang on 2020/7/31. // @@ -8,32 +8,31 @@ import Foundation import Speech -extension SwiftSpeech { - +public extension SwiftSpeech { /** A `Session` is a light-weight struct that essentially holds a weak reference to its underlying class whose lifespan is managed by the framework. If you are filling in a `(Session) -> Void` handler provided by the framework, you may want to check its `stringPublisher` and `resultPublisher` properties. - Note: You can only call `startRecording()` once on a `Session` and after it completes the recognition task, all of its properties will be `nil` and actions will take no effect. */ - @dynamicMemberLookup public struct Session : Identifiable { + @dynamicMemberLookup struct Session: Identifiable { public let id: UUID - + public subscript(dynamicMember keyPath: KeyPath) -> T? { return SpeechRecognizer.recognizer(withID: id)?[keyPath: keyPath] } - + public init(id: UUID = UUID(), configuration: Configuration) { self.id = id _ = SpeechRecognizer.new(id: id, sessionConfiguration: configuration) } - + public init(id: UUID = UUID(), locale: Locale = .current) { self.init(id: id, configuration: Configuration(locale: locale)) } - + /** Sets up the audio stuff automatically for you and start recording the user's voice. - + - Note: Avoid using this method twice. Start receiving the recognition results by subscribing to one of the publishers. - Throws: Errors can occur when: @@ -45,12 +44,12 @@ extension SwiftSpeech { guard let recognizer = SpeechRecognizer.recognizer(withID: id) else { return } recognizer.startRecording() } - + public func stopRecording() { guard let recognizer = SpeechRecognizer.recognizer(withID: id) else { return } recognizer.stopRecording() } - + /** Immediately halts the recognition process and invalidate the `Session`. */ @@ -58,7 +57,6 @@ extension SwiftSpeech { guard let recognizer = SpeechRecognizer.recognizer(withID: id) else { return } recognizer.cancel() } - } } @@ -67,60 +65,60 @@ public extension SwiftSpeech.Session { /** The locale representing the language you want to use for speech recognition. The default value is `.current`. - + To get a list of locales supported by SwiftSpeech, use `SwiftSpeech.supportedLocales()`. */ public var locale: Locale = .current - + /** A value that indicates the type of speech recognition being performed. The default value is `.unspecified`. - + `.unspecified` - An unspecified type of task. - + `.dictation` - A task that uses captured speech for text entry. - + `.search` - A task that uses captured speech to specify search terms. - + `.confirmation` - A task that uses captured speech for short, confirmation-style requests. */ public var taskHint: SFSpeechRecognitionTaskHint = .unspecified - + /// A Boolean value that indicates whether you want intermediate results returned for each utterance. /// The default value is `true`. public var shouldReportPartialResults: Bool = true - + /// A Boolean value that determines whether a request must keep its audio data on the device. public var requiresOnDeviceRecognition: Bool = false - + /** An array of phrases that should be recognized, even if they are not in the system vocabulary. The default value is `[]`. - + Use this property to specify short custom phrases that are unique to your app. You might include phrases with the names of characters, products, or places that are specific to your app. You might also include domain-specific terminology or unusual or made-up words. Assigning custom phrases to this property improves the likelihood of those phrases being recognized. - + Keep phrases relatively brief, limiting them to one or two words whenever possible. Lengthy phrases are less likely to be recognized. In addition, try to limit each phrase to something the user can say without pausing. - + Limit the total number of phrases to no more than 100. */ public var contextualStrings: [String] = [] - + /** A string that you use to identify sessions representing different types of interactions/speech recognition needs. The default value is `nil`. - + If one part of your app lets users speak phone numbers and another part lets users speak street addresses, consistently identifying the part of the app that makes a recognition request may help improve the accuracy of the results. */ public var interactionIdentifier: String? = nil - + /** A configuration for configuring/activating/deactivating your app's `AVAudioSession` at the appropriate time. The default value is `.recordOnly`, which activate/deactivate a **record only** audio session when a recording session starts/stops. - + See `SwiftSpeech.Session.AudioSessionConfiguration` for more options. */ public var audioSessionConfiguration: AudioSessionConfiguration = .recordOnly - + public init( locale: Locale = .current, taskHint: SFSpeechRecognitionTaskHint = .unspecified, @@ -143,10 +141,9 @@ public extension SwiftSpeech.Session { public extension SwiftSpeech.Session { struct AudioSessionConfiguration { - public var onStartRecording: (AVAudioSession) throws -> Void public var onStopRecording: (AVAudioSession) throws -> Void - + /** Create a configuration using two closures. */ @@ -154,10 +151,10 @@ public extension SwiftSpeech.Session { self.onStartRecording = onStartRecording self.onStopRecording = onStopRecording } - + /** A record only configuration that is activated/deactivated when a recording session starts/stops. - + During the recording session, virtually all output on the system is silenced. Audio from other apps can resume after the recording session stops. */ public static let recordOnly = AudioSessionConfiguration { audioSession in @@ -166,21 +163,20 @@ public extension SwiftSpeech.Session { } onStopRecording: { audioSession in try audioSession.setActive(false, options: .notifyOthersOnDeactivation) } - + /** A configuration that allows both play and record and is **NOT** deactivated when a recording session stops. You should manually deactivate your session. - + This configuration is non-mixable, meaning it will interrupt any ongoing audio session when it is activated. */ public static let playAndRecord = AudioSessionConfiguration { audioSession in try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP]) try audioSession.setActive(true, options: []) } onStopRecording: { _ in } - + /** A configuration that does nothing. Use this configuration when you want to configure, activate, and deactivate your app's audio session manually. */ public static let none = AudioSessionConfiguration { _ in } onStopRecording: { _ in } - } } diff --git a/Sources/SwiftSpeech/SpeechRecognizer.swift b/Sources/SwiftSpeech/SpeechRecognizer.swift index 05fdd56..8e564ec 100644 --- a/Sources/SwiftSpeech/SpeechRecognizer.swift +++ b/Sources/SwiftSpeech/SpeechRecognizer.swift @@ -5,67 +5,66 @@ // Created by Cay Zhang on 2019/10/19. // -import SwiftUI -import Speech import Combine +import Speech +import SwiftUI /// ⚠️ Warning: You should **never keep** a strong reference to a `SpeechRecognizer` instance. Instead, use its `id` property to keep track of it and /// use a `SwiftSpeech.Session` whenever it's possible. public class SpeechRecognizer { - static var instances = [SpeechRecognizer]() - + public typealias ID = UUID - + private var id: SpeechRecognizer.ID - + public var sessionConfiguration: SwiftSpeech.Session.Configuration - + private let speechRecognizer: SFSpeechRecognizer - + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - + private var recognitionTask: SFSpeechRecognitionTask? - + private let audioEngine = AVAudioEngine() - + private let resultSubject = PassthroughSubject() - + public var resultPublisher: AnyPublisher { resultSubject.eraseToAnyPublisher() } - + /// A convenience publisher that emits `result.bestTranscription.formattedString`. public var stringPublisher: AnyPublisher { resultSubject .map(\.bestTranscription.formattedString) .eraseToAnyPublisher() } - + public func startRecording() { do { // Cancel the previous task if it's running. recognitionTask?.cancel() - self.recognitionTask = nil - + recognitionTask = nil + // Configure the audio session for the app if it's on iOS/Mac Catalyst. #if canImport(UIKit) - try sessionConfiguration.audioSessionConfiguration.onStartRecording(AVAudioSession.sharedInstance()) + try sessionConfiguration.audioSessionConfiguration.onStartRecording(AVAudioSession.sharedInstance()) #endif - + let inputNode = audioEngine.inputNode // Create and configure the speech recognition request. recognitionRequest = SFSpeechAudioBufferRecognitionRequest() guard let recognitionRequest = recognitionRequest else { fatalError("Unable to create a SFSpeechAudioBufferRecognitionRequest object") } - + // Use `sessionConfiguration` to configure the recognition request recognitionRequest.shouldReportPartialResults = sessionConfiguration.shouldReportPartialResults recognitionRequest.requiresOnDeviceRecognition = sessionConfiguration.requiresOnDeviceRecognition recognitionRequest.taskHint = sessionConfiguration.taskHint recognitionRequest.contextualStrings = sessionConfiguration.contextualStrings recognitionRequest.interactionIdentifier = sessionConfiguration.interactionIdentifier - + // Create a recognition task for the speech recognition session. // Keep a reference to the task so that it can be cancelled. recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] result, error in @@ -87,68 +86,67 @@ public class SpeechRecognizer { // Configure the microphone input. let recordingFormat = inputNode.outputFormat(forBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, _: AVAudioTime) in self.recognitionRequest?.append(buffer) } - + audioEngine.prepare() try audioEngine.start() } catch { resultSubject.send(completion: .failure(error)) - SpeechRecognizer.remove(id: self.id) + SpeechRecognizer.remove(id: id) } } - + public func stopRecording() { - // Call this method explicitly to let the speech recognizer know that no more audio input is coming. - self.recognitionRequest?.endAudio() - + recognitionRequest?.endAudio() + // self.recognitionRequest = nil - + // For audio buffer–based recognition, recognition does not finish until this method is called, so be sure to call it when the audio source is exhausted. - self.recognitionTask?.finish() - + recognitionTask?.finish() + // self.recognitionTask = nil - - self.audioEngine.stop() - self.audioEngine.inputNode.removeTap(onBus: 0) - + + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + do { try sessionConfiguration.audioSessionConfiguration.onStopRecording(AVAudioSession.sharedInstance()) } catch { resultSubject.send(completion: .failure(error)) - SpeechRecognizer.remove(id: self.id) + SpeechRecognizer.remove(id: id) } - } - + /// Call this method to immediately stop recording AND the recognition task (i.e. stop recognizing & receiving results). /// This method will call `stopRecording()` first and then send a completion (`.finished`) event to the publishers. Finally, it will cancel the recognition task and dispose of the SpeechRecognizer instance. public func cancel() { stopRecording() resultSubject.send(completion: .finished) recognitionTask?.cancel() - SpeechRecognizer.remove(id: self.id) + SpeechRecognizer.remove(id: id) } - + // MARK: - Init + fileprivate init(id: ID, sessionConfiguration: SwiftSpeech.Session.Configuration) { self.id = id - self.speechRecognizer = SFSpeechRecognizer(locale: sessionConfiguration.locale) ?? SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! + speechRecognizer = SFSpeechRecognizer(locale: sessionConfiguration.locale) ?? SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! self.sessionConfiguration = sessionConfiguration } - + public static func new(id: ID, sessionConfiguration: SwiftSpeech.Session.Configuration) -> SpeechRecognizer { let recognizer = SpeechRecognizer(id: id, sessionConfiguration: sessionConfiguration) instances.append(recognizer) return recognizer } - + public static func recognizer(withID id: ID?) -> SpeechRecognizer? { return instances.first { $0.id == id } } - + @discardableResult public static func remove(id: ID?) -> SpeechRecognizer? { if let index = instances.firstIndex(where: { $0.id == id }) { @@ -159,11 +157,10 @@ public class SpeechRecognizer { return nil } } - + deinit { // print("Speech Recognizer: Deinit") self.recognitionTask = nil self.recognitionRequest = nil } - } diff --git a/Sources/SwiftSpeech/SwiftSpeech.swift b/Sources/SwiftSpeech/SwiftSpeech.swift index 69ff386..c6716e3 100644 --- a/Sources/SwiftSpeech/SwiftSpeech.swift +++ b/Sources/SwiftSpeech/SwiftSpeech.swift @@ -1,10 +1,10 @@ import SwiftUI -public struct SwiftSpeech { - public struct ViewModifiers { } - public struct Demos { } - internal struct EnvironmentKeys { } - +public enum SwiftSpeech { + public struct ViewModifiers {} + public struct Demos {} + internal struct EnvironmentKeys {} + /// Change this when the app starts to configure the default animation used for all record on hold functional components. public static var defaultAnimation: Animation = .interactiveSpring() } diff --git a/Sources/SwiftSpeech/ViewModifiers.swift b/Sources/SwiftSpeech/ViewModifiers.swift index 5553d07..2903fc9 100644 --- a/Sources/SwiftSpeech/ViewModifiers.swift +++ b/Sources/SwiftSpeech/ViewModifiers.swift @@ -1,86 +1,81 @@ // // ViewModifiers.swift -// +// // // Created by Cay Zhang on 2020/2/16. // -import SwiftUI import Combine import Speech +import SwiftUI public extension SwiftSpeech { struct FunctionalComponentDelegate: DynamicProperty { - @Environment(\.actionsOnStartRecording) var actionsOnStartRecording @Environment(\.actionsOnStopRecording) var actionsOnStopRecording @Environment(\.actionsOnCancelRecording) var actionsOnCancelRecording - - public init() { } - - mutating public func update() { + + public init() {} + + public mutating func update() { _actionsOnStartRecording.update() _actionsOnStopRecording.update() _actionsOnCancelRecording.update() } - + public func onStartRecording(session: SwiftSpeech.Session) { for action in actionsOnStartRecording { action(session) } } - + public func onStopRecording(session: SwiftSpeech.Session) { for action in actionsOnStopRecording { action(session) } } - + public func onCancelRecording(session: SwiftSpeech.Session) { for action in actionsOnCancelRecording { action(session) } } - } } // MARK: - Functional Components + public extension SwiftSpeech.ViewModifiers { - - struct RecordOnHold : ViewModifier { + struct RecordOnHold: ViewModifier { public init(sessionConfiguration: SwiftSpeech.Session.Configuration = SwiftSpeech.Session.Configuration(), animation: Animation = SwiftSpeech.defaultAnimation, distanceToCancel: CGFloat = 50.0) { self.sessionConfiguration = sessionConfiguration self.animation = animation self.distanceToCancel = distanceToCancel } - + var sessionConfiguration: SwiftSpeech.Session.Configuration var animation: Animation var distanceToCancel: CGFloat - + @SpeechRecognitionAuthStatus var authStatus - @State var recordingSession: SwiftSpeech.Session? = nil @State var viewComponentState: SwiftSpeech.State = .pending - + var delegate = SwiftSpeech.FunctionalComponentDelegate() - + var gesture: some Gesture { let longPress = LongPressGesture(minimumDuration: 60) .onChanged { _ in withAnimation(self.animation, self.startRecording) } - + let drag = DragGesture(minimumDistance: 0) .onChanged { value in - withAnimation(self.animation) { - if value.translation.height < -self.distanceToCancel { - self.viewComponentState = .cancelling - } else { - self.viewComponentState = .recording - } + if value.translation.height < -self.distanceToCancel { + self.viewComponentState = .cancelling + } else { + self.viewComponentState = .recording } } .onEnded { value in @@ -90,132 +85,129 @@ public extension SwiftSpeech.ViewModifiers { withAnimation(self.animation, self.endRecording) } } - + return longPress.simultaneously(with: drag) } - + public func body(content: Content) -> some View { content .gesture(gesture, including: $authStatus ? .gesture : .none) .environment(\.swiftSpeechState, viewComponentState) } - + fileprivate func startRecording() { let id = SpeechRecognizer.ID() let session = SwiftSpeech.Session(id: id, configuration: sessionConfiguration) // View update - self.viewComponentState = .recording - self.recordingSession = session + viewComponentState = .recording + recordingSession = session delegate.onStartRecording(session: session) session.startRecording() } - + fileprivate func cancelRecording() { guard let session = recordingSession else { preconditionFailure("recordingSession is nil in \(#function)") } session.cancel() delegate.onCancelRecording(session: session) - self.viewComponentState = .pending - self.recordingSession = nil + viewComponentState = .pending + recordingSession = nil } - + fileprivate func endRecording() { guard let session = recordingSession else { preconditionFailure("recordingSession is nil in \(#function)") } recordingSession?.stopRecording() delegate.onStopRecording(session: session) - self.viewComponentState = .pending - self.recordingSession = nil + viewComponentState = .pending + recordingSession = nil } - } - + /** `viewComponentState` will never be `.cancelling` here. */ - struct ToggleRecordingOnTap : ViewModifier { + struct ToggleRecordingOnTap: ViewModifier { public init(sessionConfiguration: SwiftSpeech.Session.Configuration = SwiftSpeech.Session.Configuration(), animation: Animation = SwiftSpeech.defaultAnimation) { self.sessionConfiguration = sessionConfiguration self.animation = animation } - + var sessionConfiguration: SwiftSpeech.Session.Configuration var animation: Animation - + let feedbackGenerator = UISelectionFeedbackGenerator() + @SpeechRecognitionAuthStatus var authStatus - @State var recordingSession: SwiftSpeech.Session? = nil @State var viewComponentState: SwiftSpeech.State = .pending - + var delegate = SwiftSpeech.FunctionalComponentDelegate() - + var gesture: some Gesture { TapGesture() .onEnded { - withAnimation(self.animation) { - if self.viewComponentState == .pending { // if not recording - self.startRecording() - } else { // if recording - self.endRecording() - } + if self.viewComponentState == .pending { // if not recording + self.startRecording() + } else { // if recording + self.endRecording() } } } - + public func body(content: Content) -> some View { content .gesture(gesture, including: $authStatus ? .gesture : .none) .environment(\.swiftSpeechState, viewComponentState) } - + fileprivate func startRecording() { - let id = SpeechRecognizer.ID() - let session = SwiftSpeech.Session(id: id, configuration: sessionConfiguration) - // View update - self.viewComponentState = .recording - self.recordingSession = session - delegate.onStartRecording(session: session) - session.startRecording() + feedbackGenerator.prepare() + feedbackGenerator.selectionChanged() + viewComponentState = .recording + AudioServicesPlaySystemSoundWithCompletion(1117) { + let session = SwiftSpeech.Session(id: SpeechRecognizer.ID(), configuration: sessionConfiguration) + recordingSession = session + delegate.onStartRecording(session: session) + session.startRecording() + } } - + fileprivate func endRecording() { guard let session = recordingSession else { preconditionFailure("recordingSession is nil in \(#function)") } + AudioServicesPlaySystemSound(1118) + feedbackGenerator.selectionChanged() recordingSession?.stopRecording() delegate.onStopRecording(session: session) - self.viewComponentState = .pending - self.recordingSession = nil + viewComponentState = .pending + recordingSession = nil } - } - } // MARK: - SwiftSpeech Modifiers + public extension SwiftSpeech.ViewModifiers { - - struct OnRecognize : ViewModifier { - + struct OnRecognize: ViewModifier { @State var model: Model - + init(isPartialResultIncluded: Bool, switchToLatest: Bool, resultHandler: @escaping (SwiftSpeech.Session, SFSpeechRecognitionResult) -> Void, - errorHandler: @escaping (SwiftSpeech.Session, Error) -> Void - ) { - self._model = State(initialValue: Model(isPartialResultIncluded: isPartialResultIncluded, switchToLatest: switchToLatest, resultHandler: resultHandler, errorHandler: errorHandler)) + errorHandler: @escaping (SwiftSpeech.Session, Error) -> Void) + { + _model = State(initialValue: Model(isPartialResultIncluded: isPartialResultIncluded, switchToLatest: switchToLatest, resultHandler: resultHandler, errorHandler: errorHandler)) } - + public func body(content: Content) -> some View { content .onStartRecording(sendSessionTo: model.sessionSubject) .onCancelRecording(sendSessionTo: model.cancelSubject) } - + class Model { - let sessionSubject = PassthroughSubject() let cancelSubject = PassthroughSubject() var cancelBag = Set() - + init( isPartialResultIncluded: Bool, switchToLatest: Bool, @@ -232,12 +224,12 @@ public extension SwiftSpeech.ViewModifiers { }.map { (session, $0) } .eraseToAnyPublisher() } - - let receiveValue = { (tuple: (SwiftSpeech.Session, SFSpeechRecognitionResult)) -> Void in + + let receiveValue = { (tuple: (SwiftSpeech.Session, SFSpeechRecognitionResult)) in let (session, result) = tuple resultHandler(session, result) } - + if switchToLatest { sessionSubject .compactMap(transform) @@ -255,10 +247,6 @@ public extension SwiftSpeech.ViewModifiers { .store(in: &cancelBag) } } - } - } - } - diff --git a/Tests/SwiftSpeechTests/SwiftSpeechTests.swift b/Tests/SwiftSpeechTests/SwiftSpeechTests.swift index 14039ed..21f2633 100644 --- a/Tests/SwiftSpeechTests/SwiftSpeechTests.swift +++ b/Tests/SwiftSpeechTests/SwiftSpeechTests.swift @@ -1,12 +1,11 @@ -import XCTest @testable import SwiftSpeech +import XCTest final class SwiftSpeechTests: XCTestCase { func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. - } static var allTests = [ diff --git a/Tests/SwiftSpeechTests/XCTestManifests.swift b/Tests/SwiftSpeechTests/XCTestManifests.swift index e3c9930..c946b2c 100644 --- a/Tests/SwiftSpeechTests/XCTestManifests.swift +++ b/Tests/SwiftSpeechTests/XCTestManifests.swift @@ -1,9 +1,9 @@ import XCTest #if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(SwiftSpeechTests.allTests), - ] -} + public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(SwiftSpeechTests.allTests), + ] + } #endif