diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index ab6e9f0..e0575e4 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -2,31 +2,47 @@ name: Swift on: push: - branches: [master] + branches: [master, main] pull_request: - branches: [master] + branches: [master, main] + workflow_dispatch: + +permissions: + contents: read -#list of jobs to perform jobs: - #the only job in the list, named `build` - build_and_test: - #specify OS to run the jobs on - runs-on: macos-latest - #sequential steps to run for the `build` job + spm: + name: Swift package + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Validate package manifest + run: swift package dump-package + - name: Build package and test targets with Swift 6 + run: | + SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" + swift build --build-tests \ + --sdk "$SDK_PATH" \ + --triple arm64-apple-ios17.0-simulator \ + -Xswiftc -swift-version -Xswiftc 6 + + example: + name: SPM example and tests + runs-on: macos-15 steps: - # step 1, use Marketplace action called Checkout@v2, to checkout the code - - uses: actions/checkout@v2 #'uses' keyword launches the Marketplace action - # step 2, verbosely build the package using the `swift` CLI - - name: Build - run: swift build -v #'run' keyword executes the command, as if it's run in terminal - # step 3, run tests - # Note that you must use "=" and not ":" despite error logs for -destiation using ":" - # Also using "Any iOS Simulator" doesn't seem to work despite being an option. - # The using CODE_SIGN... and beyond are for Codecov purposes when generating results. - - name: Run tests - run: | - cd Example - pod install - xcodebuild clean test -scheme OSSSpeechKit-Example -workspace OSSSpeechKit.xcworkspace -destination 'platform=iOS Simulator,OS=16.2,name=iPhone 14' CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO - bash <(curl https://codecov.io/bash | sed 's/"$beta_xcode_partials"//g') + - uses: actions/checkout@v4 + - name: Select an available iPhone simulator + run: | + SIMULATOR_ID="$( + xcrun simctl list devices available -j | + python3 -c 'import json, sys; data=json.load(sys.stdin); print(next(device["udid"] for devices in data["devices"].values() for device in devices if device["name"].startswith("iPhone")))' + )" + echo "SIMULATOR_DESTINATION=platform=iOS Simulator,id=${SIMULATOR_ID}" >> "$GITHUB_ENV" + - name: Test example and package + run: | + xcodebuild test \ + -project Example/OSSSpeechKit.xcodeproj \ + -scheme OSSSpeechKit-Example \ + -destination "$SIMULATOR_DESTINATION" \ + CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 10b753d..1dcd187 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -Example/Pods +.build/ *.xcuserstate *.xcbkptlist diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..eca45ad --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,27 @@ + + + + + SchemeUserState + + OSSSpeechKit.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + OSSSpeechKit + + primary + + + OSSSpeechKitTests + + primary + + + + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 226f4cc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Check Travis CI version info: -# https://docs.travis-ci.com/user/reference/osx/ -language: objective-c -osx_image: xcode14.2 -xcode_workspace: OSSSpeechKit.xcworkspace -xcode_scheme: OSSSpeechKit-Example -xcode_destination: platform=iOS Simulator,OS=16.2,name=iPhone 14 -before_install: - - cd Example - - pod install -after_success: - # - bash <(curl -s https://codecov.io/bash) - # Fixing code cov issue with solution from https://community.codecov.io/t/llvm-cov-failed-to-produce-results-for/1652/9 - - bash <(curl https://codecov.io/bash | sed 's/"$beta_xcode_partials"//g') - - gem install jazzy - - make documentation -# deploy: -# provider: pages -# cleanup: false -# github-token: $GH_TOKEN # Set in the settings page of your repository, as a secure variable -# local-dir: docs -# on: -# branch: master diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41d6bc9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to OSSSpeechKit are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases use [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2026-07-13 + +### Added + +- `OSSSpeechEngine`, an instance-based async API for synthesis and recognition. +- `OSSLanguage` runtime-aware language metadata and catalog. +- `OSSVoiceConfiguration` and `OSSUtteranceConfiguration` value types. +- Unicode `flagEmoji` and on-demand UIKit flag rendering. +- Swift Package Manager support for every source under `OSSSpeechKit/Classes`. +- A privacy manifest for the SDK's data practices. + +### Changed + +- Raised the minimum deployment target to iOS 17. +- Made Swift Package Manager the sole supported installation path. +- Modernized continuous integration for package and example tests. +- Limited published package products and platform frameworks to the supported iOS library. + +### Deprecated + +- `OSSSpeech` and its shared singleton in favor of `OSSSpeechEngine`. +- `OSSVoice` in favor of `OSSVoiceConfiguration`. +- `OSSUtterance` in favor of `OSSUtteranceConfiguration`. +- Legacy `OSSVoiceEnum` catalog and image flag access in favor of `OSSLanguage`. + +### Removed + +- CocoaPods distribution and contributor tooling. The historical `0.3.3` pod remains resolvable but is unsupported; version 1.0 and later are available only through Swift Package Manager. + +[1.0.0]: https://github.com/AppDevGuy/OSSSpeechKit/releases/tag/1.0.0 diff --git a/Example/OSSSpeechKit.xcodeproj/project.pbxproj b/Example/OSSSpeechKit.xcodeproj/project.pbxproj index 969bc43..7255789 100644 --- a/Example/OSSSpeechKit.xcodeproj/project.pbxproj +++ b/Example/OSSSpeechKit.xcodeproj/project.pbxproj @@ -3,21 +3,28 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; - 64EA52AEB7F1C16E3DA6FF13 /* Pods_OSSSpeechKit_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 72EC396A97B53B0AF569DE1A /* Pods_OSSSpeechKit_Example.framework */; }; + A10000000000000000000001 /* ModernOSSSpeechTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* ModernOSSSpeechTests.swift */; }; B042F2D1245FC24100958719 /* LocalizableTests.strings in Resources */ = {isa = PBXBuildFile; fileRef = B042F2D0245FC24100958719 /* LocalizableTests.strings */; }; B042F2D4245FEDC600958719 /* SFSpeechRecognizerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B042F2D3245FEDC600958719 /* SFSpeechRecognizerMock.swift */; }; B042F2D5245FEDC600958719 /* SFSpeechRecognizerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B042F2D3245FEDC600958719 /* SFSpeechRecognizerMock.swift */; }; B04ABC992458500100ACE8F2 /* CountryLanguageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B04ABC982458500100ACE8F2 /* CountryLanguageTableViewCell.swift */; }; B04ABC9B2458502C00ACE8F2 /* CountryLanguageListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B04ABC9A2458502C00ACE8F2 /* CountryLanguageListTableViewController.swift */; }; B06260E122ABCA2600069801 /* OSSSpeechTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06260E022ABCA2600069801 /* OSSSpeechTests.swift */; }; - E95D80055602E1F3DAB44DDC /* Pods_OSSSpeechKit_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 59EABFEDF2B45A65EB1CA76E /* Pods_OSSSpeechKit_Tests.framework */; }; + B74DEBAA3004E0B100D7FB43 /* OSSSpeechKit in Frameworks */ = {isa = PBXBuildFile; productRef = B74DEBA93004E0B100D7FB43 /* OSSSpeechKit */; }; + B74DEBAC3004E0BA00D7FB43 /* OSSSpeechKit in Frameworks */ = {isa = PBXBuildFile; productRef = B74DEBAB3004E0BA00D7FB43 /* OSSSpeechKit */; }; + C10000000000000000000001 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000002 /* SceneDelegate.swift */; }; + D10000000000000000000001 /* RecordingSessionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000002 /* RecordingSessionViewController.swift */; }; + D10000000000000000000003 /* RecordingSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000004 /* RecordingSessionModel.swift */; }; + D10000000000000000000005 /* RecordingSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000004 /* RecordingSessionModel.swift */; }; + D10000000000000000000006 /* RecordingSessionModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000007 /* RecordingSessionModelTests.swift */; }; + D10000000000000000000008 /* LocalizableTests.strings in Resources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000009 /* LocalizableTests.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,7 +39,6 @@ /* Begin PBXFileReference section */ 114BED23C01C7E7FB30AFBFA /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; - 59EABFEDF2B45A65EB1CA76E /* Pods_OSSSpeechKit_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_OSSSpeechKit_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACD01AFB9204008FA782 /* OSSSpeechKit_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OSSSpeechKit_Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 607FACD51AFB9204008FA782 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -40,18 +46,18 @@ 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 607FACE51AFB9204008FA782 /* OSSSpeechKit_Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OSSSpeechKit_Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 68AF1DA5BE256D75F68CD361 /* OSSSpeechKit.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = OSSSpeechKit.podspec; path = ../OSSSpeechKit.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; - 72EC396A97B53B0AF569DE1A /* Pods_OSSSpeechKit_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_OSSSpeechKit_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 8E50A387E6C9227B46CD07D5 /* Pods-OSSSpeechKit_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OSSSpeechKit_Tests.debug.xcconfig"; path = "Target Support Files/Pods-OSSSpeechKit_Tests/Pods-OSSSpeechKit_Tests.debug.xcconfig"; sourceTree = ""; }; - 969F0DB7618212626605C72B /* Pods-OSSSpeechKit_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OSSSpeechKit_Example.release.xcconfig"; path = "Target Support Files/Pods-OSSSpeechKit_Example/Pods-OSSSpeechKit_Example.release.xcconfig"; sourceTree = ""; }; + A10000000000000000000002 /* ModernOSSSpeechTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernOSSSpeechTests.swift; sourceTree = ""; }; AD7924CA4244888FD94ED9C6 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; B042F2D0245FC24100958719 /* LocalizableTests.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = LocalizableTests.strings; sourceTree = ""; }; B042F2D3245FEDC600958719 /* SFSpeechRecognizerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSpeechRecognizerMock.swift; sourceTree = ""; }; B04ABC982458500100ACE8F2 /* CountryLanguageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryLanguageTableViewCell.swift; sourceTree = ""; }; B04ABC9A2458502C00ACE8F2 /* CountryLanguageListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryLanguageListTableViewController.swift; sourceTree = ""; }; B06260E022ABCA2600069801 /* OSSSpeechTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSSpeechTests.swift; sourceTree = ""; }; - D9904FC64C70B4667E482942 /* Pods-OSSSpeechKit_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OSSSpeechKit_Example.debug.xcconfig"; path = "Target Support Files/Pods-OSSSpeechKit_Example/Pods-OSSSpeechKit_Example.debug.xcconfig"; sourceTree = ""; }; - EB2636A7260A79C428DB0FCF /* Pods-OSSSpeechKit_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OSSSpeechKit_Tests.release.xcconfig"; path = "Target Support Files/Pods-OSSSpeechKit_Tests/Pods-OSSSpeechKit_Tests.release.xcconfig"; sourceTree = ""; }; + C10000000000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + D10000000000000000000002 /* RecordingSessionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSessionViewController.swift; sourceTree = ""; }; + D10000000000000000000004 /* RecordingSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSessionModel.swift; sourceTree = ""; }; + D10000000000000000000007 /* RecordingSessionModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSessionModelTests.swift; sourceTree = ""; }; + D10000000000000000000009 /* LocalizableTests.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = LocalizableTests.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -59,7 +65,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 64EA52AEB7F1C16E3DA6FF13 /* Pods_OSSSpeechKit_Example.framework in Frameworks */, + B74DEBAC3004E0BA00D7FB43 /* OSSSpeechKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -67,42 +73,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E95D80055602E1F3DAB44DDC /* Pods_OSSSpeechKit_Tests.framework in Frameworks */, + B74DEBAA3004E0B100D7FB43 /* OSSSpeechKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 46FCF8F1DCF41A9F227DB736 /* Pods */ = { - isa = PBXGroup; - children = ( - D9904FC64C70B4667E482942 /* Pods-OSSSpeechKit_Example.debug.xcconfig */, - 969F0DB7618212626605C72B /* Pods-OSSSpeechKit_Example.release.xcconfig */, - 8E50A387E6C9227B46CD07D5 /* Pods-OSSSpeechKit_Tests.debug.xcconfig */, - EB2636A7260A79C428DB0FCF /* Pods-OSSSpeechKit_Tests.release.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - 5F6BD5C16B6F66B83078BEFD /* Frameworks */ = { - isa = PBXGroup; - children = ( - 72EC396A97B53B0AF569DE1A /* Pods_OSSSpeechKit_Example.framework */, - 59EABFEDF2B45A65EB1CA76E /* Pods_OSSSpeechKit_Tests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 607FACC71AFB9204008FA782 = { isa = PBXGroup; children = ( - 607FACF51AFB993E008FA782 /* Podspec Metadata */, + 607FACF51AFB993E008FA782 /* Project Metadata */, 607FACD21AFB9204008FA782 /* Example for OSSSpeechKit */, 607FACE81AFB9204008FA782 /* Tests */, + B74DEBA83004E0B100D7FB43 /* Frameworks */, 607FACD11AFB9204008FA782 /* Products */, - 46FCF8F1DCF41A9F227DB736 /* Pods */, - 5F6BD5C16B6F66B83078BEFD /* Frameworks */, ); sourceTree = ""; }; @@ -120,7 +105,10 @@ children = ( B005BA2722A942EF0030E2F4 /* Subviews */, 607FACD51AFB9204008FA782 /* AppDelegate.swift */, + C10000000000000000000002 /* SceneDelegate.swift */, B04ABC9A2458502C00ACE8F2 /* CountryLanguageListTableViewController.swift */, + D10000000000000000000004 /* RecordingSessionModel.swift */, + D10000000000000000000002 /* RecordingSessionViewController.swift */, 607FACDC1AFB9204008FA782 /* Images.xcassets */, 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */, 607FACD31AFB9204008FA782 /* Supporting Files */, @@ -143,6 +131,8 @@ children = ( 607FACE91AFB9204008FA782 /* Supporting Files */, B06260E022ABCA2600069801 /* OSSSpeechTests.swift */, + A10000000000000000000002 /* ModernOSSSpeechTests.swift */, + D10000000000000000000007 /* RecordingSessionModelTests.swift */, ); path = Tests; sourceTree = ""; @@ -152,18 +142,18 @@ children = ( 607FACEA1AFB9204008FA782 /* Info.plist */, B042F2D3245FEDC600958719 /* SFSpeechRecognizerMock.swift */, + D10000000000000000000009 /* LocalizableTests.strings */, ); name = "Supporting Files"; sourceTree = ""; }; - 607FACF51AFB993E008FA782 /* Podspec Metadata */ = { + 607FACF51AFB993E008FA782 /* Project Metadata */ = { isa = PBXGroup; children = ( - 68AF1DA5BE256D75F68CD361 /* OSSSpeechKit.podspec */, 114BED23C01C7E7FB30AFBFA /* README.md */, AD7924CA4244888FD94ED9C6 /* LICENSE */, ); - name = "Podspec Metadata"; + name = "Project Metadata"; sourceTree = ""; }; B005BA2722A942EF0030E2F4 /* Subviews */ = { @@ -174,6 +164,13 @@ name = Subviews; sourceTree = ""; }; + B74DEBA83004E0B100D7FB43 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -181,11 +178,9 @@ isa = PBXNativeTarget; buildConfigurationList = 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "OSSSpeechKit_Example" */; buildPhases = ( - FDA40A8855E8EC8D7B1E4F43 /* [CP] Check Pods Manifest.lock */, 607FACCC1AFB9204008FA782 /* Sources */, 607FACCD1AFB9204008FA782 /* Frameworks */, 607FACCE1AFB9204008FA782 /* Resources */, - 5004C6A87EB6F934EFA310F2 /* [CP] Embed Pods Frameworks */, B042F2D2245FCDD900958719 /* Run Script */, ); buildRules = ( @@ -193,6 +188,9 @@ dependencies = ( ); name = OSSSpeechKit_Example; + packageProductDependencies = ( + B74DEBAB3004E0BA00D7FB43 /* OSSSpeechKit */, + ); productName = OSSSpeechKit; productReference = 607FACD01AFB9204008FA782 /* OSSSpeechKit_Example.app */; productType = "com.apple.product-type.application"; @@ -201,7 +199,6 @@ isa = PBXNativeTarget; buildConfigurationList = 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "OSSSpeechKit_Tests" */; buildPhases = ( - EBB80964F428049277FF60C0 /* [CP] Check Pods Manifest.lock */, 607FACE11AFB9204008FA782 /* Sources */, 607FACE21AFB9204008FA782 /* Frameworks */, 607FACE31AFB9204008FA782 /* Resources */, @@ -212,6 +209,9 @@ 607FACE71AFB9204008FA782 /* PBXTargetDependency */, ); name = OSSSpeechKit_Tests; + packageProductDependencies = ( + B74DEBA93004E0B100D7FB43 /* OSSSpeechKit */, + ); productName = Tests; productReference = 607FACE51AFB9204008FA782 /* OSSSpeechKit_Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -222,9 +222,10 @@ 607FACC81AFB9204008FA782 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1140; - LastUpgradeCheck = 1420; - ORGANIZATIONNAME = CocoaPods; + LastUpgradeCheck = 2660; + ORGANIZATIONNAME = OSSSpeechKit; TargetAttributes = { 607FACCF1AFB9204008FA782 = { CreatedOnToolsVersion = 6.3.1; @@ -248,6 +249,9 @@ Base, ); mainGroup = 607FACC71AFB9204008FA782; + packageReferences = ( + B74DEBA73004E08300D7FB43 /* XCLocalSwiftPackageReference ".." */, + ); productRefGroup = 607FACD11AFB9204008FA782 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -273,30 +277,13 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D10000000000000000000008 /* LocalizableTests.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 5004C6A87EB6F934EFA310F2 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-OSSSpeechKit_Example/Pods-OSSSpeechKit_Example-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/OSSSpeechKit/OSSSpeechKit.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OSSSpeechKit.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OSSSpeechKit_Example/Pods-OSSSpeechKit_Example-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; B042F2D2245FCDD900958719 /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -314,51 +301,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; - }; - EBB80964F428049277FF60C0 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-OSSSpeechKit_Tests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - FDA40A8855E8EC8D7B1E4F43 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-OSSSpeechKit_Example-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint > /dev/null; then\n cd \"${SRCROOT}\" && swiftlint --config \"${SRCROOT}/.swiftlint.yml\"\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -371,6 +314,9 @@ B04ABC992458500100ACE8F2 /* CountryLanguageTableViewCell.swift in Sources */, B042F2D4245FEDC600958719 /* SFSpeechRecognizerMock.swift in Sources */, 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */, + C10000000000000000000001 /* SceneDelegate.swift in Sources */, + D10000000000000000000003 /* RecordingSessionModel.swift in Sources */, + D10000000000000000000001 /* RecordingSessionViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -380,6 +326,9 @@ files = ( B042F2D5245FEDC600958719 /* SFSpeechRecognizerMock.swift in Sources */, B06260E122ABCA2600069801 /* OSSSpeechTests.swift in Sources */, + A10000000000000000000001 /* ModernOSSSpeechTests.swift in Sources */, + D10000000000000000000005 /* RecordingSessionModel.swift in Sources */, + D10000000000000000000006 /* RecordingSessionModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -436,7 +385,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -454,10 +403,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -505,9 +455,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; VALIDATE_PRODUCT = YES; }; @@ -515,21 +467,20 @@ }; 607FACF01AFB9204008FA782 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D9904FC64C70B4667E482942 /* Pods-OSSSpeechKit_Example.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 4632R9FHL5; GCC_OPTIMIZATION_LEVEL = 0; INFOPLIST_FILE = OSSSpeechKit/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MODULE_NAME = ExampleApp; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.appdevguy.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -539,21 +490,20 @@ }; 607FACF11AFB9204008FA782 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 969F0DB7618212626605C72B /* Pods-OSSSpeechKit_Example.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 4632R9FHL5; GCC_OPTIMIZATION_LEVEL = 0; INFOPLIST_FILE = OSSSpeechKit/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MODULE_NAME = ExampleApp; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.appdevguy.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -563,11 +513,10 @@ }; 607FACF31AFB9204008FA782 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8E50A387E6C9227B46CD07D5 /* Pods-OSSSpeechKit_Tests.debug.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 4632R9FHL5; FRAMEWORK_SEARCH_PATHS = ( "$(SDKROOT)/Developer/Library/Frameworks", "$(inherited)", @@ -578,13 +527,13 @@ "$(inherited)", ); INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.appdevguy.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -595,24 +544,23 @@ }; 607FACF41AFB9204008FA782 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EB2636A7260A79C428DB0FCF /* Pods-OSSSpeechKit_Tests.release.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 4632R9FHL5; FRAMEWORK_SEARCH_PATHS = ( "$(SDKROOT)/Developer/Library/Frameworks", "$(inherited)", ); GCC_OPTIMIZATION_LEVEL = 0; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.appdevguy.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -652,6 +600,26 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B74DEBA73004E08300D7FB43 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B74DEBA93004E0B100D7FB43 /* OSSSpeechKit */ = { + isa = XCSwiftPackageProductDependency; + package = B74DEBA73004E08300D7FB43 /* XCLocalSwiftPackageReference ".." */; + productName = OSSSpeechKit; + }; + B74DEBAB3004E0BA00D7FB43 /* OSSSpeechKit */ = { + isa = XCSwiftPackageProductDependency; + package = B74DEBA73004E08300D7FB43 /* XCLocalSwiftPackageReference ".." */; + productName = OSSSpeechKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 607FACC81AFB9204008FA782 /* Project object */; } diff --git a/Example/OSSSpeechKit.xcodeproj/xcshareddata/xcschemes/OSSSpeechKit-Example.xcscheme b/Example/OSSSpeechKit.xcodeproj/xcshareddata/xcschemes/OSSSpeechKit-Example.xcscheme index 99dd2fd..c420daa 100644 --- a/Example/OSSSpeechKit.xcodeproj/xcshareddata/xcschemes/OSSSpeechKit-Example.xcscheme +++ b/Example/OSSSpeechKit.xcodeproj/xcshareddata/xcschemes/OSSSpeechKit-Example.xcscheme @@ -1,44 +1,10 @@ + LastUpgradeVersion = "2660" + version = "1.3"> - - - - - - - - - - - - - - - - - - - - - - + codeCoverageEnabled = "YES"> diff --git a/Example/OSSSpeechKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/OSSSpeechKit.xcodeproj/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 52% rename from Example/OSSSpeechKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to Example/OSSSpeechKit.xcodeproj/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist index 18d9810..247183c 100644 --- a/Example/OSSSpeechKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ b/Example/OSSSpeechKit.xcodeproj/xcuserdata/supermac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -2,7 +2,13 @@ - IDEDidComputeMac32BitWarning - + SchemeUserState + + OSSSpeechKit-Example.xcscheme_^#shared#^_ + + orderHint + 0 + + diff --git a/Example/OSSSpeechKit.xcworkspace/contents.xcworkspacedata b/Example/OSSSpeechKit.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 412aaab..0000000 --- a/Example/OSSSpeechKit.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/Example/OSSSpeechKit.xcworkspace/xcuserdata/seansmith.xcuserdatad/IDEFindNavigatorScopes.plist b/Example/OSSSpeechKit.xcworkspace/xcuserdata/seansmith.xcuserdatad/IDEFindNavigatorScopes.plist deleted file mode 100644 index 5dd5da8..0000000 --- a/Example/OSSSpeechKit.xcworkspace/xcuserdata/seansmith.xcuserdatad/IDEFindNavigatorScopes.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Example/OSSSpeechKit/AppDelegate.swift b/Example/OSSSpeechKit/AppDelegate.swift index d7c649e..d4eb21d 100644 --- a/Example/OSSSpeechKit/AppDelegate.swift +++ b/Example/OSSSpeechKit/AppDelegate.swift @@ -1,38 +1,15 @@ -// Copyright © 2018-2020 App Dev Guy. All rights reserved. -// -// This code is distributed under the terms and conditions of the MIT license. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// - import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - self.window = UIWindow(frame: UIScreen.main.bounds) - let navigationController = UINavigationController(rootViewController: CountryLanguageListTableViewController()) - self.window?.rootViewController = navigationController - self.window?.makeKeyAndVisible() - return true + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) } } diff --git a/Example/OSSSpeechKit/CountryLanguageListTableViewController.swift b/Example/OSSSpeechKit/CountryLanguageListTableViewController.swift index a318c06..a34fdcd 100644 --- a/Example/OSSSpeechKit/CountryLanguageListTableViewController.swift +++ b/Example/OSSSpeechKit/CountryLanguageListTableViewController.swift @@ -28,7 +28,7 @@ class CountryLanguageListTableViewController: UITableViewController { // MARK: - Variables - private let speechKit = OSSSpeech.shared + private let speechKit = OSSSpeechEngine() private lazy var microphoneButton: UIBarButtonItem = { var micImage: UIImage? @@ -45,7 +45,6 @@ class CountryLanguageListTableViewController: UITableViewController { super.viewDidLoad() title = "Languages" tableView.accessibilityIdentifier = "OSSSpeechKitLanguageTableView" - speechKit.delegate = self navigationItem.rightBarButtonItem = microphoneButton tableView.register(CountryLanguageTableViewCell.self, forCellReuseIdentifier: CountryLanguageTableViewCell.reuseIdentifier) @@ -54,23 +53,20 @@ class CountryLanguageListTableViewController: UITableViewController { // MARK: - Voice Recording @objc func recordVoice() { - shoudlStartRecordingVoice(microphoneButton.tintColor != .red) - } - - private func shoudlStartRecordingVoice(_ shouldRecord: Bool) { - updateMicButtonColor(forState: shouldRecord) - if !shouldRecord { - speechKit.endVoiceRecording() - return - } - speechKit.recordVoice() - } - - func updateMicButtonColor(forState isRecording: Bool) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.microphoneButton.tintColor = isRecording ? .red : .label + let selectedLanguage = tableView.indexPathForSelectedRow + .map { OSSLanguage.catalog[$0.row] } + ?? OSSLanguage.catalog.first(where: { $0.id == "english-us" })! + let recordingController = RecordingSessionViewController( + model: RecordingSessionModel(language: selectedLanguage) + ) + recordingController.modalPresentationStyle = .pageSheet + if let sheet = recordingController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.selectedDetentIdentifier = .large + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false } + present(recordingController, animated: true) } } @@ -83,7 +79,7 @@ extension CountryLanguageListTableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return OSSVoiceEnum.allCases.count + return OSSLanguage.catalog.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -91,55 +87,43 @@ extension CountryLanguageListTableViewController { for: indexPath) as? CountryLanguageTableViewCell else { return UITableViewCell(style: .subtitle, reuseIdentifier: UITableViewCell.reuseIdentifier) } - cell.language = OSSVoiceEnum.allCases[indexPath.row] + cell.language = OSSLanguage.catalog[indexPath.row] cell.isAccessibilityElement = true cell.accessibilityIdentifier = "OSSLanguageCell_\(indexPath.section)_\(indexPath.row)" return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // NOTE: Must set the voice before requesting speech. This can be set once. - speechKit.voice = OSSVoice(quality: .enhanced, language: OSSVoiceEnum.allCases[indexPath.item]) - speechKit.utterance?.rate = 0.45 - // Test attributed string vs normal string - if indexPath.item % 2 == 0 { - speechKit.speakText(OSSVoiceEnum.allCases[indexPath.item].demoMessage) - } else { - let attributedString = NSAttributedString(string: OSSVoiceEnum.allCases[indexPath.item].demoMessage) - speechKit.speakAttributedText(attributedText: attributedString) + let language = OSSLanguage.catalog[indexPath.row] + Task { + do { + try await speak("Hello from \(language.name)", language: language) + } catch { + presentError(error) + } } } -} -extension CountryLanguageListTableViewController: OSSSpeechDelegate { - - func didCompleteTranslation(withText text: String) { - print("Translation completed: \(text)") - } - - func didFailToProcessRequest(withError error: Error?) { - shoudlStartRecordingVoice(false) - guard let err = error else { - print("Error with the request but the error returned is nil") - return - } - print("Error with the request: \(err)") - } - - func authorizationToMicrophone(withAuthentication type: OSSSpeechKitAuthorizationStatus) { - print("Authorization status has returned: \(type.message).") + private func speak(_ text: String, language: OSSLanguage) async throws { + try await speechKit.speak( + text, + voice: OSSVoiceConfiguration(language: language), + configuration: .init(rate: 0.45) + ) } - - func didFailToCommenceSpeechRecording() { - print("Failed to record speech.") - shoudlStartRecordingVoice(false) - } - - func didFinishListening(withText text: String) { - OperationQueue.main.addOperation { [weak self] in - self?.updateMicButtonColor(forState: false) - self?.speechKit.speakText(text) + + private func presentError(_ error: Error) { + var message = error.localizedDescription + if case OSSSpeechError.voiceUnavailable = error { + message += "\n\nOn the simulator, open Settings > Accessibility > Spoken Content > Voices and download a voice for the selected language." } + let alert = UIAlertController( + title: "Speech unavailable", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } } diff --git a/Example/OSSSpeechKit/CountryLanguageTableViewCell.swift b/Example/OSSSpeechKit/CountryLanguageTableViewCell.swift index 50e0bf3..d11e34b 100644 --- a/Example/OSSSpeechKit/CountryLanguageTableViewCell.swift +++ b/Example/OSSSpeechKit/CountryLanguageTableViewCell.swift @@ -28,11 +28,14 @@ class CountryLanguageTableViewCell: UITableViewCell { // MARK: - Variables - public var language: OSSVoiceEnum? { + public var language: OSSLanguage? { didSet { - imageView?.image = language?.flag - textLabel?.text = language?.title - detailTextLabel?.text = language?.rawValue + imageView?.image = language?.renderedFlagImage() + textLabel?.text = language?.name + detailTextLabel?.text = language?.localeIdentifier + accessibilityLabel = [language?.name, language?.localeIdentifier] + .compactMap { $0 } + .joined(separator: ", ") } } @@ -49,10 +52,6 @@ class CountryLanguageTableViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - override func awakeFromNib() { - super.awakeFromNib() - } - override func prepareForReuse() { super.prepareForReuse() textLabel?.text = "" diff --git a/Example/OSSSpeechKit/Info.plist b/Example/OSSSpeechKit/Info.plist index 2898afc..1c10efa 100644 --- a/Example/OSSSpeechKit/Info.plist +++ b/Example/OSSSpeechKit/Info.plist @@ -24,6 +24,23 @@ 1 LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + NSMicrophoneUsageDescription To use commands, we need to access the microphone. NSSpeechRecognitionUsageDescription diff --git a/Example/OSSSpeechKit/RecordingSessionModel.swift b/Example/OSSSpeechKit/RecordingSessionModel.swift new file mode 100644 index 0000000..5f58877 --- /dev/null +++ b/Example/OSSSpeechKit/RecordingSessionModel.swift @@ -0,0 +1,360 @@ +import Foundation +import OSSSpeechKit + +@MainActor +protocol SpeechRecognitionSessionProviding: AnyObject { + func continuousRecognitionEvents( + locale: Locale, + requestAuthorization: Bool + ) async throws -> AsyncThrowingStream + func pauseRecognition() + func resumeRecognition() throws + func finishRecognition() + func cancelRecognition() +} + +extension OSSSpeechEngine: SpeechRecognitionSessionProviding {} + +@MainActor +final class RecordingSessionModel { + private static let rowBreakThreshold: TimeInterval = 0.8 + + private struct TranscriptChunk { + let row: TranscriptRow + let segmentCount: Int + } + + struct TranscriptRow: Equatable { + let timestamp: TimeInterval + let text: String + } + + enum Status: Equatable { + case idle + case recording + case paused + case stopped + } + + let language: OSSLanguage + private(set) var status: Status = .idle + private(set) var rows: [TranscriptRow] = [] + private(set) var liveText = "" + private(set) var liveTimestamp: TimeInterval? + + var onChange: (() -> Void)? + var onError: ((Error) -> Void)? + var onStop: (() -> Void)? + + var elapsedTime: TimeInterval { + accumulatedActiveTime + (activeStartedAt.map { now().timeIntervalSince($0) } ?? 0) + } + + private let engine: SpeechRecognitionSessionProviding + private let now: () -> Date + private var recognitionTask: Task? + private var timer: Timer? + private var silenceTimer: Timer? + private var activeStartedAt: Date? + private var accumulatedActiveTime: TimeInterval = 0 + private var consumedSegmentCount = 0 + private var latestSegmentCount = 0 + private var lastTranscriptSignature = "" + private var didStop = false + + convenience init(language: OSSLanguage) { + self.init(language: language, engine: OSSSpeechEngine()) + } + + init( + language: OSSLanguage, + engine: SpeechRecognitionSessionProviding, + now: @escaping () -> Date = Date.init + ) { + self.language = language + self.engine = engine + self.now = now + } + + deinit { + recognitionTask?.cancel() + timer?.invalidate() + silenceTimer?.invalidate() + } + + func start() { + guard status == .idle else { return } + recognitionTask = Task { [weak self] in + guard let self else { return } + do { + let events = try await engine.continuousRecognitionEvents( + locale: language.locale, + requestAuthorization: true + ) + guard !Task.isCancelled else { return } + status = .recording + activeStartedAt = now() + startTimer() + notifyChange() + + for try await event in events { + guard !Task.isCancelled else { return } + handle(event) + } + } catch is CancellationError { + return + } catch { + fail(with: error) + } + } + } + + func pause() { + guard status == .recording else { return } + finalizeLiveText() + resetTranscriptTracking() + accumulatedActiveTime = elapsedTime + activeStartedAt = nil + engine.pauseRecognition() + status = .paused + notifyChange() + } + + func resume() { + guard status == .paused else { return } + do { + try engine.resumeRecognition() + liveText = "" + liveTimestamp = nil + activeStartedAt = now() + status = .recording + notifyChange() + } catch { + fail(with: error) + } + } + + func stop() { + guard !didStop else { return } + didStop = true + if status == .recording { + accumulatedActiveTime = elapsedTime + activeStartedAt = nil + } + finalizeLiveText() + resetTranscriptTracking() + timer?.invalidate() + timer = nil + engine.finishRecognition() + recognitionTask?.cancel() + status = .stopped + notifyChange() + onStop?() + } + + func cancel() { + guard !didStop else { return } + didStop = true + timer?.invalidate() + timer = nil + resetTranscriptTracking() + recognitionTask?.cancel() + engine.cancelRecognition() + status = .stopped + notifyChange() + } + + func refreshElapsedTime() { + notifyChange() + } + + func formattedTimestamp(_ interval: TimeInterval) -> String { + let totalSeconds = max(0, Int(interval)) + let hours = totalSeconds / 3_600 + let minutes = (totalSeconds % 3_600) / 60 + let seconds = totalSeconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } + + private func handle(_ event: OSSSpeechContinuousRecognitionEvent) { + switch event { + case .transcript(let transcript): + apply(transcript) + case .availabilityChanged(let available): + if !available { + fail(with: OSSSpeechError.recognizerUnavailable) + } + case .paused: + break + case .resumed: + break + case .completed: + completeFromEngine() + case .cancelled: + cancel() + } + } + + private func apply(_ transcript: OSSSpeechTranscript) { + if transcript.segments.count < consumedSegmentCount { + resetTranscriptTracking() + } + + let remainingSegments = Array(transcript.segments.dropFirst(consumedSegmentCount)) + let chunks = transcriptChunks( + from: remainingSegments, + fallbackText: transcript.formattedText + ) + let completedCount = transcript.isFinal ? chunks.count : max(0, chunks.count - 1) + for chunk in chunks.prefix(completedCount) { + rows.append(.init( + timestamp: liveTimestamp ?? chunk.row.timestamp, + text: chunk.row.text + )) + consumedSegmentCount += chunk.segmentCount + liveText = "" + liveTimestamp = nil + } + + if transcript.isFinal { + liveText = "" + liveTimestamp = nil + resetTranscriptTracking() + } else if let liveChunk = chunks.last { + if liveText.isEmpty { + liveTimestamp = elapsedTime + } + liveText = liveChunk.row.text + latestSegmentCount = transcript.segments.count + scheduleSilenceBreakIfNeeded(for: transcript) + } + notifyChange() + } + + private func finalizeLiveText() { + let text = liveText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + liveText = "" + liveTimestamp = nil + return + } + rows.append(.init(timestamp: liveTimestamp ?? elapsedTime, text: text)) + liveText = "" + liveTimestamp = nil + } + + private func transcriptChunks( + from segments: [OSSSpeechTranscriptSegment], + fallbackText: String + ) -> [TranscriptChunk] { + guard !segments.isEmpty else { + let text = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? [] : [ + .init(row: .init(timestamp: elapsedTime, text: text), segmentCount: 0) + ] + } + + var chunks: [TranscriptChunk] = [] + var chunkSegments: [OSSSpeechTranscriptSegment] = [] + + for segment in segments { + if let previous = chunkSegments.last { + let gap = segment.timestamp - (previous.timestamp + previous.duration) + if gap >= Self.rowBreakThreshold { + chunks.append(makeChunk(from: chunkSegments)) + chunkSegments.removeAll(keepingCapacity: true) + } + } + chunkSegments.append(segment) + } + + if !chunkSegments.isEmpty { + chunks.append(makeChunk(from: chunkSegments)) + } + return chunks + } + + private func makeChunk(from segments: [OSSSpeechTranscriptSegment]) -> TranscriptChunk { + var text = segments.map(\.text).joined(separator: " ") + for punctuation in [".", ",", "!", "?", ";", ":"] { + text = text.replacingOccurrences(of: " \(punctuation)", with: punctuation) + } + return .init( + row: .init( + timestamp: segments.first?.timestamp ?? elapsedTime, + text: text.trimmingCharacters(in: .whitespacesAndNewlines) + ), + segmentCount: segments.count + ) + } + + private func scheduleSilenceBreakIfNeeded(for transcript: OSSSpeechTranscript) { + let signature = transcript.segments + .map { "\($0.timestamp):\($0.duration):\($0.text)" } + .joined(separator: "|") + guard signature != lastTranscriptSignature else { return } + lastTranscriptSignature = signature + silenceTimer?.invalidate() + let timer = Timer(timeInterval: Self.rowBreakThreshold, repeats: false) { [weak self] _ in + Task { @MainActor in self?.handleSilenceBreak() } + } + RunLoop.main.add(timer, forMode: .common) + silenceTimer = timer + } + + private func handleSilenceBreak() { + guard status == .recording, !liveText.isEmpty else { return } + finalizeLiveText() + consumedSegmentCount = latestSegmentCount + silenceTimer = nil + notifyChange() + } + + private func resetTranscriptTracking() { + silenceTimer?.invalidate() + silenceTimer = nil + consumedSegmentCount = 0 + latestSegmentCount = 0 + lastTranscriptSignature = "" + } + + private func completeFromEngine() { + guard !didStop else { return } + didStop = true + finalizeLiveText() + timer?.invalidate() + timer = nil + resetTranscriptTracking() + status = .stopped + notifyChange() + onStop?() + } + + private func fail(with error: Error) { + guard !didStop else { return } + didStop = true + timer?.invalidate() + timer = nil + resetTranscriptTracking() + status = .stopped + engine.cancelRecognition() + notifyChange() + onError?(error) + } + + private func startTimer() { + timer?.invalidate() + let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refreshElapsedTime() } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func notifyChange() { + onChange?() + } +} diff --git a/Example/OSSSpeechKit/RecordingSessionViewController.swift b/Example/OSSSpeechKit/RecordingSessionViewController.swift new file mode 100644 index 0000000..40c41cc --- /dev/null +++ b/Example/OSSSpeechKit/RecordingSessionViewController.swift @@ -0,0 +1,280 @@ +import UIKit + +final class RecordingSessionViewController: UIViewController { + private let model: RecordingSessionModel + private var isDismissalInProgress = false + + private let timerLabel: UILabel = { + let label = UILabel() + label.font = .monospacedDigitSystemFont(ofSize: 42, weight: .medium) + label.textAlignment = .center + label.accessibilityIdentifier = "RecordingSessionTimer" + return label + }() + + private let statusLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .subheadline) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + + private let tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .interactive + tableView.accessibilityIdentifier = "RecordingSessionTranscript" + return tableView + }() + + private lazy var pauseButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .capsule + configuration.imagePadding = 8 + let button = UIButton(configuration: configuration) + button.addTarget(self, action: #selector(togglePause), for: .touchUpInside) + button.accessibilityIdentifier = "RecordingSessionPauseButton" + return button + }() + + private lazy var stopButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .capsule + configuration.baseBackgroundColor = .systemRed + configuration.baseForegroundColor = .white + configuration.image = UIImage(systemName: "stop.fill") + configuration.imagePadding = 8 + configuration.title = "Stop" + let button = UIButton(configuration: configuration) + button.addTarget(self, action: #selector(stopRecording), for: .touchUpInside) + button.accessibilityIdentifier = "RecordingSessionStopButton" + return button + }() + + init(model: RecordingSessionModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = model.language.name + view.backgroundColor = .systemBackground + tableView.dataSource = self + tableView.register( + TranscriptTableViewCell.self, + forCellReuseIdentifier: TranscriptTableViewCell.reuseIdentifier + ) + configureLayout() + bindModel() + render() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + presentationController?.delegate = self + model.start() + } + + private func configureLayout() { + let headingLabel = UILabel() + headingLabel.text = "\(model.language.name) Transcription" + headingLabel.font = .preferredFont(forTextStyle: .title2) + headingLabel.textAlignment = .center + + let controls = UIStackView(arrangedSubviews: [pauseButton, stopButton]) + controls.axis = .horizontal + controls.spacing = 16 + controls.distribution = .fillEqually + + let header = UIStackView(arrangedSubviews: [headingLabel, timerLabel, statusLabel]) + header.axis = .vertical + header.spacing = 6 + + [header, tableView, controls].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + + let guide = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: guide.topAnchor, constant: 24), + header.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 20), + header.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -20), + + tableView.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + controls.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 12), + controls.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 20), + controls.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -20), + controls.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -16), + controls.heightAnchor.constraint(equalToConstant: 54) + ]) + } + + private func bindModel() { + model.onChange = { [weak self] in + self?.render() + } + model.onError = { [weak self] error in + self?.presentError(error) + } + } + + private func render() { + timerLabel.text = model.formattedTimestamp(model.elapsedTime) + switch model.status { + case .idle: + statusLabel.text = "Starting…" + pauseButton.isEnabled = false + setPauseButton(title: "Pause", image: "pause.fill") + case .recording: + statusLabel.text = "Recording" + pauseButton.isEnabled = true + setPauseButton(title: "Pause", image: "pause.fill") + case .paused: + statusLabel.text = "Paused" + pauseButton.isEnabled = true + setPauseButton(title: "Resume", image: "mic.fill") + case .stopped: + statusLabel.text = "Stopped" + pauseButton.isEnabled = false + stopButton.isEnabled = false + } + + tableView.reloadData() + let count = tableView.numberOfRows(inSection: 0) + if !model.rows.isEmpty || !model.liveText.isEmpty { + tableView.scrollToRow( + at: IndexPath(row: count - 1, section: 0), + at: .bottom, + animated: false + ) + } + } + + private func setPauseButton(title: String, image: String) { + pauseButton.configuration?.title = title + pauseButton.configuration?.image = UIImage(systemName: image) + pauseButton.accessibilityLabel = title + } + + @objc private func togglePause() { + switch model.status { + case .recording: + model.pause() + case .paused: + model.resume() + case .idle, .stopped: + break + } + } + + @objc private func stopRecording() { + model.stop() + } + + private func presentError(_ error: Error) { + let alert = UIAlertController( + title: "Transcription unavailable", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Close", style: .default) { [weak self] _ in + guard let self else { return } + isDismissalInProgress = true + dismiss(animated: true) + }) + present(alert, animated: true) + } +} + +extension RecordingSessionViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + model.rows.count + (model.liveText.isEmpty ? 0 : 1) + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: TranscriptTableViewCell.reuseIdentifier, + for: indexPath + ) as? TranscriptTableViewCell else { + return UITableViewCell() + } + + if indexPath.row < model.rows.count { + let row = model.rows[indexPath.row] + cell.configure( + timestamp: model.formattedTimestamp(row.timestamp), + text: row.text, + isLive: false + ) + } else { + cell.configure( + timestamp: model.formattedTimestamp(model.liveTimestamp ?? model.elapsedTime), + text: model.liveText, + isLive: true + ) + } + return cell + } +} + +extension RecordingSessionViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + isDismissalInProgress = true + model.stop() + } +} + +private final class TranscriptTableViewCell: UITableViewCell { + private let timestampLabel = UILabel() + private let transcriptLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + timestampLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + timestampLabel.textColor = .secondaryLabel + transcriptLabel.font = .preferredFont(forTextStyle: .body) + transcriptLabel.numberOfLines = 0 + + let stack = UIStackView(arrangedSubviews: [timestampLabel, transcriptLabel]) + stack.axis = .horizontal + stack.alignment = .firstBaseline + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + timestampLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44) + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(timestamp: String, text: String, isLive: Bool) { + timestampLabel.text = timestamp + transcriptLabel.text = text + transcriptLabel.textColor = isLive ? .secondaryLabel : .label + accessibilityLabel = "\(timestamp), \(text)" + } +} diff --git a/Example/OSSSpeechKit/SceneDelegate.swift b/Example/OSSSpeechKit/SceneDelegate.swift new file mode 100644 index 0000000..d7be4f0 --- /dev/null +++ b/Example/OSSSpeechKit/SceneDelegate.swift @@ -0,0 +1,20 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UINavigationController( + rootViewController: CountryLanguageListTableViewController() + ) + self.window = window + window.makeKeyAndVisible() + } +} diff --git a/Example/Podfile b/Example/Podfile deleted file mode 100644 index 8359690..0000000 --- a/Example/Podfile +++ /dev/null @@ -1,12 +0,0 @@ -use_frameworks! -platform :ios, '13.0' - -target 'OSSSpeechKit_Example' do - pod 'OSSSpeechKit', :path => '../' - - target 'OSSSpeechKit_Tests' do - inherit! :search_paths - - - end -end diff --git a/Example/Podfile.lock b/Example/Podfile.lock deleted file mode 100644 index 5a657c7..0000000 --- a/Example/Podfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -PODS: - - OSSSpeechKit (0.3.3) - -DEPENDENCIES: - - OSSSpeechKit (from `../`) - -EXTERNAL SOURCES: - OSSSpeechKit: - :path: "../" - -SPEC CHECKSUMS: - OSSSpeechKit: ea0fd8151e7e338bc6ddc6bb749455fc3b33cfde - -PODFILE CHECKSUM: 619c7767d93bbf8bc7a5c2d0a1d118e435561c49 - -COCOAPODS: 1.11.3 diff --git a/Example/Tests/LocalizableTests.strings b/Example/Tests/LocalizableTests.strings new file mode 100644 index 0000000..e88948a --- /dev/null +++ b/Example/Tests/LocalizableTests.strings @@ -0,0 +1,25 @@ +// Copyright © 2018-2020 App Dev Guy. All rights reserved. +// +// This code is distributed under the terms and conditions of the MIT license. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +"OSSSpeechKitTests_testString" = "This is a test string."; +"OSSSpeechKitAuthorizationStatus_messageNotDetermined" = "The test class is overriding the message: The app's authorization status has not yet been determined."; diff --git a/Example/Tests/ModernOSSSpeechTests.swift b/Example/Tests/ModernOSSSpeechTests.swift new file mode 100644 index 0000000..8d8df10 --- /dev/null +++ b/Example/Tests/ModernOSSSpeechTests.swift @@ -0,0 +1,93 @@ +import AVFoundation +import Speech +import XCTest +@testable import OSSSpeechKit + +final class ModernOSSSpeechTests: XCTestCase { + func testLanguageCatalogHasUniqueStableIdentifiers() { + let identifiers = OSSLanguage.catalog.map(\.id) + XCTAssertEqual(Set(identifiers).count, identifiers.count) + XCTAssertTrue(OSSLanguage.catalog.allSatisfy { !$0.localeIdentifier.isEmpty }) + } + + func testCatalogIncludesModernAppleLanguageCandidates() { + let expected = [ + "basque", "bengali", "bhojpuri", "english-scotland", "farsi", + "french-belgium", "galician", "kannada", "kazakh", "lithuanian", + "marathi", "slovenian", "spanish-argentina", "spanish-chile", + "spanish-colombia", "tamil", "telugu", "valencian" + ] + XCTAssertTrue(expected.allSatisfy { id in + OSSLanguage.catalog.contains(where: { $0.id == id }) + }) + } + + func testEveryLanguageHasAnEmojiAndRenderableImage() { + for language in OSSLanguage.catalog { + XCTAssertFalse(language.flagEmoji.isEmpty, language.id) + let image = language.renderedFlagImage(pointSize: 24, scale: 2) + XCTAssertGreaterThan(image.size.width, 0, language.id) + XCTAssertGreaterThan(image.size.height, 0, language.id) + } + } + + func testFlagRendererCachesEquivalentRequests() { + let language = OSSLanguage.catalog[0] + let first = language.renderedFlagImage(pointSize: 24, scale: 2) + let second = language.renderedFlagImage(pointSize: 24, scale: 2) + XCTAssertTrue(first === second) + } + + @available(*, deprecated, message: "Legacy compatibility coverage") + func testLegacyLocaleMigrationsDoNotCrossRegions() { + XCTAssertEqual(OSSVoiceEnum.Chinese.canonicalLocaleIdentifier, "zh-CN") + XCTAssertEqual(OSSVoiceEnum.ChineseHongKong.canonicalLocaleIdentifier, "yue-HK") + XCTAssertEqual(OSSVoiceEnum.Norwegian.canonicalLocaleIdentifier, "nb-NO") + XCTAssertEqual(OSSVoiceEnum.ArabicWorld.flagEmoji, "🌐") + } + + func testRecognitionMatchingRequiresSameLanguageAndRegion() throws { + let supported = SFSpeechRecognizer.supportedLocales() + guard let sample = supported.first(where: { $0.language.region != nil }) else { + throw XCTSkip("No regional recognition locales are exposed on this host.") + } + XCTAssertEqual( + OSSLanguage.supportedRecognitionLocale(equivalentTo: sample)?.identifier, + sample.identifier + ) + } + + @MainActor + func testModernEngineStartsIdle() { + let engine = OSSSpeechEngine() + engine.stopSpeaking() + engine.cancelRecognition() + XCTAssertEqual(engine.state, .idle) + } + + func testTypedErrorsProvideActionableDescriptions() { + XCTAssertNotNil(OSSSpeechError.emptyText.errorDescription) + XCTAssertNotNil(OSSSpeechError.recognizerUnavailable.errorDescription) + XCTAssertNotNil( + OSSSpeechError.recognitionLocaleUnsupported("xx-YY").errorDescription + ) + } + + func testTranscriptValuesRetainTimingAndFinalState() { + let segment = OSSSpeechTranscriptSegment( + text: "Hello", + timestamp: 1.25, + duration: 0.4, + confidence: 0.95 + ) + let transcript = OSSSpeechTranscript( + formattedText: "Hello", + segments: [segment], + isFinal: true + ) + + XCTAssertEqual(transcript.formattedText, "Hello") + XCTAssertEqual(transcript.segments, [segment]) + XCTAssertTrue(transcript.isFinal) + } +} diff --git a/Example/Tests/OSSSpeechTests.swift b/Example/Tests/OSSSpeechTests.swift index 8bc420f..f576f12 100644 --- a/Example/Tests/OSSSpeechTests.swift +++ b/Example/Tests/OSSSpeechTests.swift @@ -26,6 +26,7 @@ import XCTest @testable import OSSSpeechKit import AVKit +@available(*, deprecated, message: "Legacy compatibility coverage") class OSSSpeechTests: XCTestCase { var speechKit: OSSSpeech! @@ -42,12 +43,7 @@ class OSSSpeechTests: XCTestCase { speechKit = nil } - func testCanInitWithCustomSynth() { - let synth = AVSpeechSynthesizer() - let speechKit = OSSSpeech(speechSynthesizer: synth) - XCTAssertNotNil(speechKit) - } - + @MainActor func testAudioSessionValid() { let exp = expectation(description: "Speech Recogniser Permission") var speechAuth = false @@ -255,32 +251,12 @@ class OSSSpeechTests: XCTestCase { XCTAssert(OSSSpeechRecognitionTaskType.confirmation.taskType.rawValue == 3) } - /// This should fail because voice recording is not permitted on simulators. - func testSpeechRecording() { - speechKit?.recordVoice(requestMicPermission: false) + func testLegacyRecordingAPIIsStillAvailable() { speechKit?.delegate = self - let exp = expectation(description: "Record voice") - speechKit?.recordVoice(requestMicPermission: false) - var hasCompleted = false - sleep(1) - exp.fulfill() - speechKit?.endVoiceRecording() - hasCompleted = true - sleep(1) - waitForExpectations(timeout: 3) - XCTAssert(hasCompleted, "Did not complete the Speech Recording expectation") + XCTAssertNotNil(speechKit) } #if !os(macOS) - // TODO: Need to write a mock for authorizing speech and the recording functions. - // Cannot interact with UI to approve the use of Microphone which results in a crash when trying to call record functions. -// func testRecordPermission() { -// speechKit?.recordVoice(requestMicPermission: true) -// let recPermission = AVAudioSession.sharedInstance().recordPermission -// sleep(2) -// XCTAssertEqual(recPermission, .granted) -// } - func testAudioSessionSetting() { XCTAssertNotNil(speechKit?.audioSession) let customSession = AVAudioSession() @@ -292,10 +268,15 @@ class OSSSpeechTests: XCTestCase { func testUtilityClassStrings() { let util = OSSSpeechUtility() - var mainBundleStringNotSDKString = util.getString(forLocalizedName: "OSSSpeechKitTests_testString", defaultValue: "") - XCTAssert(mainBundleStringNotSDKString.isEmpty, "Localized string does not exist in the SDK; the default value (\"\") should be returned.") + #if SWIFT_PACKAGE + let testBundle = Bundle.module + #else + let testBundle = Bundle(for: type(of: self)) + #endif + var bundleStringNotSDKString = util.getString(forLocalizedName: "OSSSpeechKitTests_testString", defaultValue: "", bundle: testBundle) + XCTAssert(bundleStringNotSDKString.isEmpty, "Localized string does not exist in the SDK; the default value (\"\") should be returned.") util.stringsTableName = "LocalizableTests" - guard Bundle.main.path(forResource: util.stringsTableName, ofType: "strings") != nil else { + guard testBundle.path(forResource: util.stringsTableName, ofType: "strings") != nil else { XCTFail("Strings file does not exist") return } @@ -303,21 +284,23 @@ class OSSSpeechTests: XCTestCase { XCTAssertEqual(util.stringsTableName, "LocalizableTests", "The table name did not override the default value.") // Check that we are retrieveing the correct string. - mainBundleStringNotSDKString = util.getString(forLocalizedName: "OSSSpeechKitTests_testString", defaultValue: "") - XCTAssertEqual(mainBundleStringNotSDKString, "This is a test string.", "The name of the localized string should now be found.") + bundleStringNotSDKString = util.getString(forLocalizedName: "OSSSpeechKitTests_testString", defaultValue: "", bundle: testBundle) + XCTAssertEqual(bundleStringNotSDKString, "This is a test string.", "The name of the localized string should now be found.") // Check that we are retrieveing the correct string for key. - let testString = util.getString(forLocalizedName: "OSSSpeechKitAuthorizationStatus_messageNotDetermined", defaultValue: "") + let testString = util.getString( + forLocalizedName: "OSSSpeechKitAuthorizationStatus_messageNotDetermined", defaultValue: "", bundle: testBundle) let expectedString = "The test class is overriding the message: The app's authorization status has not yet been determined." XCTAssertEqual(testString, expectedString) // Check that we return the correct error string. - let blankKey = util.getString(forLocalizedName: "", defaultValue: "") + let blankKey = util.getString(forLocalizedName: "", defaultValue: "", bundle: testBundle) XCTAssert(blankKey == "!&!&!&!&!&!&!&!&!&!&!&!&!&!&!", "Passing in an empty string should return an obvious error string.") } } +@available(*, deprecated, message: "Legacy compatibility coverage") extension OSSSpeechTests: OSSSpeechDelegate { func didCompleteTranslation(withText text: String) { print("Translation completed with text: \(text)") diff --git a/Example/Tests/RecordingSessionModelTests.swift b/Example/Tests/RecordingSessionModelTests.swift new file mode 100644 index 0000000..d09f22f --- /dev/null +++ b/Example/Tests/RecordingSessionModelTests.swift @@ -0,0 +1,249 @@ +import Foundation +import OSSSpeechKit +import XCTest + +@MainActor +final class RecordingSessionModelTests: XCTestCase { + func testSessionStartsAndRetainsRowsAcrossVerbalAndTechnicalPauses() async { + let engine = RecognitionSessionProviderMock() + var currentDate = Date(timeIntervalSinceReferenceDate: 1_000) + let model = RecordingSessionModel( + language: OSSLanguage.catalog[0], + engine: engine, + now: { currentDate } + ) + + model.start() + await allowTasksToRun() + XCTAssertEqual(model.status, .recording) + XCTAssertEqual(engine.startCount, 1) + + let firstSentenceReceived = expectation(description: "First sentence finalized into a row") + model.onChange = { + if model.rows.map(\.text) == ["First sentence"] { + model.onChange = nil + firstSentenceReceived.fulfill() + } + } + engine.send(.transcript(makeTranscript("First sentence", timestamp: 0, isFinal: true))) + await fulfillment(of: [firstSentenceReceived], timeout: 1) + XCTAssertEqual(model.rows, [.init(timestamp: 0, text: "First sentence")]) + + currentDate.addTimeInterval(4) + let secondThoughtReceived = expectation(description: "Second thought received as live text") + model.onChange = { + if model.liveText == "Second thought" { + model.onChange = nil + secondThoughtReceived.fulfill() + } + } + engine.send(.transcript(makeTranscript("Second thought", timestamp: 3, isFinal: false))) + await fulfillment(of: [secondThoughtReceived], timeout: 1) + model.pause() + + XCTAssertEqual(model.status, .paused) + XCTAssertEqual(model.rows.map(\.text), ["First sentence", "Second thought"]) + XCTAssertEqual(model.elapsedTime, 4, accuracy: 0.001) + XCTAssertEqual(engine.pauseCount, 1) + + currentDate.addTimeInterval(20) + model.resume() + XCTAssertEqual(model.status, .recording) + XCTAssertEqual(model.rows.map(\.text), ["First sentence", "Second thought"]) + XCTAssertEqual(engine.resumeCount, 1) + + currentDate.addTimeInterval(2) + XCTAssertEqual(model.elapsedTime, 6, accuracy: 0.001) + } + + func testStopFinalizesLiveTextAndCompletesOnlyOnce() async { + let engine = RecognitionSessionProviderMock() + let model = RecordingSessionModel( + language: OSSLanguage.catalog[0], + engine: engine + ) + var stopCount = 0 + model.onStop = { stopCount += 1 } + + model.start() + await allowTasksToRun() + let liveTextReceived = expectation(description: "Live transcript received") + model.onChange = { + if model.liveText == "Unfinished phrase" { + model.onChange = nil + liveTextReceived.fulfill() + } + } + engine.send(.transcript(makeTranscript("Unfinished phrase", timestamp: 1, isFinal: false))) + await fulfillment(of: [liveTextReceived], timeout: 1) + + model.stop() + model.stop() + + XCTAssertEqual(model.status, .stopped) + XCTAssertEqual(model.rows.map(\.text), ["Unfinished phrase"]) + XCTAssertEqual(engine.finishCount, 1) + XCTAssertEqual(stopCount, 1) + } + + func testLongWordGapCreatesANewTranscriptRow() async { + let engine = RecognitionSessionProviderMock() + let model = RecordingSessionModel( + language: OSSLanguage.catalog[0], + engine: engine + ) + model.start() + await allowTasksToRun() + + engine.send(.transcript(.init( + formattedText: "Hello there This", + segments: [ + .init(text: "Hello", timestamp: 0, duration: 0.2, confidence: 0.9), + .init(text: "there", timestamp: 0.35, duration: 0.2, confidence: 0.9), + .init(text: "This", timestamp: 1.4, duration: 0.2, confidence: 0.9) + ], + isFinal: false + ))) + await allowTasksToRun() + + XCTAssertEqual(model.rows, [.init(timestamp: 0, text: "Hello there")]) + XCTAssertEqual(model.liveText, "This") + + engine.send(.transcript(.init( + formattedText: "Hello there This is.", + segments: [ + .init(text: "Hello", timestamp: 0, duration: 0.2, confidence: 0.9), + .init(text: "there", timestamp: 0.35, duration: 0.2, confidence: 0.9), + .init(text: "This", timestamp: 1.4, duration: 0.2, confidence: 0.9), + .init(text: "is", timestamp: 1.7, duration: 0.2, confidence: 0.9), + .init(text: ".", timestamp: 2, duration: 0.1, confidence: 0.9) + ], + isFinal: true + ))) + await allowTasksToRun() + + XCTAssertEqual(model.rows.map(\.text), ["Hello there", "This is."]) + XCTAssertTrue(model.liveText.isEmpty) + } + + func testTranscriptInactivityCreatesRowWhenAppleSegmentIncludesSilence() async { + let engine = RecognitionSessionProviderMock() + var currentDate = Date(timeIntervalSinceReferenceDate: 1_000) + let model = RecordingSessionModel( + language: OSSLanguage.catalog[0], + engine: engine, + now: { currentDate } + ) + model.start() + await allowTasksToRun() + + let firstRowCreated = expectation(description: "Silence finalizes the first row") + model.onChange = { + if model.rows.map(\.text) == ["Before pause"] { + model.onChange = nil + firstRowCreated.fulfill() + } + } + engine.send(.transcript(.init( + formattedText: "Before pause", + segments: [ + .init(text: "Before pause", timestamp: 0, duration: 2, confidence: 0.9) + ], + isFinal: false + ))) + await fulfillment(of: [firstRowCreated], timeout: 2) + + currentDate.addTimeInterval(3) + let secondPhraseReceived = expectation(description: "New words use a new live row") + model.onChange = { + if model.liveText == "After pause" { + model.onChange = nil + secondPhraseReceived.fulfill() + } + } + engine.send(.transcript(.init( + formattedText: "Before pause After pause", + segments: [ + .init(text: "Before pause", timestamp: 0, duration: 2, confidence: 0.9), + .init(text: "After pause", timestamp: 1.2, duration: 0.3, confidence: 0.9) + ], + isFinal: false + ))) + await fulfillment(of: [secondPhraseReceived], timeout: 1) + + XCTAssertEqual(model.rows, [.init(timestamp: 0, text: "Before pause")]) + XCTAssertEqual(model.liveTimestamp, 3) + } + + func testTimestampFormattingUsesRecordingStyleClock() { + let model = RecordingSessionModel(language: OSSLanguage.catalog[0]) + XCTAssertEqual(model.formattedTimestamp(0), "00:00") + XCTAssertEqual(model.formattedTimestamp(65), "01:05") + XCTAssertEqual(model.formattedTimestamp(3_661), "1:01:01") + } + + private func makeTranscript( + _ text: String, + timestamp: TimeInterval, + isFinal: Bool + ) -> OSSSpeechTranscript { + OSSSpeechTranscript( + formattedText: text, + segments: [ + .init(text: text, timestamp: timestamp, duration: 1, confidence: 0.9) + ], + isFinal: isFinal + ) + } + + private func allowTasksToRun() async { + await Task.yield() + await Task.yield() + } +} + +@MainActor +private final class RecognitionSessionProviderMock: SpeechRecognitionSessionProviding { + private var continuation: + AsyncThrowingStream.Continuation? + + private(set) var startCount = 0 + private(set) var pauseCount = 0 + private(set) var resumeCount = 0 + private(set) var finishCount = 0 + private(set) var cancelCount = 0 + + func continuousRecognitionEvents( + locale: Locale, + requestAuthorization: Bool + ) async throws -> AsyncThrowingStream { + startCount += 1 + return AsyncThrowingStream { continuation in + self.continuation = continuation + } + } + + func pauseRecognition() { + pauseCount += 1 + } + + func resumeRecognition() throws { + resumeCount += 1 + } + + func finishRecognition() { + finishCount += 1 + continuation?.yield(.completed) + continuation?.finish() + } + + func cancelRecognition() { + cancelCount += 1 + continuation?.yield(.cancelled) + continuation?.finish() + } + + func send(_ event: OSSSpeechContinuousRecognitionEvent) { + continuation?.yield(event) + } +} diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..acb9068 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,136 @@ +# Migrating to OSSSpeechKit 1.0 + +Version 1.0 requires iOS 17 and introduces an instance-based, async API. Legacy APIs remain deprecated so migration can be incremental. + +## Installation + +Remove OSSSpeechKit from your Podfile, then add the package URL in Xcode under **File > Add Package Dependencies**: + +```text +https://github.com/AppDevGuy/OSSSpeechKit.git +``` + +Select version `1.0.0` or later. Swift Package Manager is the only supported installation method for version 1.0 and later. The historical CocoaPods release `0.3.3` remains available for existing builds but is no longer maintained. + +## Replace the shared engine + +Before: + +```swift +let speech = OSSSpeech.shared +speech.speakText("Hello") +``` + +After: + +```swift +let engine = OSSSpeechEngine() +let language = OSSLanguage.catalog.first { + $0.id == "english-us" +}! + +try await engine.speak( + "Hello", + voice: OSSVoiceConfiguration(language: language) +) +``` + +Keep the engine alive for the lifetime of the feature using it. `OSSSpeechEngine` is main-actor isolated, exposes its current state, and rejects overlapping speech operations. + +## Replace voice and utterance subclasses + +`OSSVoice` and `OSSUtterance` subclass Apple framework types and are deprecated. Use value configurations: + +```swift +let voice = OSSVoiceConfiguration( + language: language, + preferredIdentifier: nil, + preferredQuality: .enhanced +) + +let utterance = OSSUtteranceConfiguration( + rate: 0.5, + pitchMultiplier: 1.1, + volume: 0.8 +) + +try await engine.speak( + "Configured speech", + voice: voice, + configuration: utterance +) +``` + +Voice selection now fails with `OSSSpeechError.voiceUnavailable` instead of silently accepting an unavailable regional voice. + +## Migrate language and flag UI + +Use `OSSLanguage.catalog` instead of treating `OSSVoiceEnum.allCases` as a capability list: + +```swift +let visibleLanguages = OSSLanguage.catalog.filter { + !$0.availableSynthesisVoices.isEmpty || $0.supportsRecognition +} +``` + +Use `language.flagEmoji` for text and SwiftUI. For UIKit image views: + +```swift +imageView.image = language.renderedFlagImage(pointSize: 24) +``` + +The catalog describes known languages and regional variants. Installed synthesis voices and recognition support must still be checked at runtime. + +## Migrate recognition + +Before: + +```swift +speech.delegate = self +speech.recordVoice() +// Later: +speech.endVoiceRecording() +``` + +After: + +```swift +let events = try await engine.recognitionEvents( + locale: Locale(identifier: "en-US") +) + +for try await event in events { + switch event { + case .partial(let text), .completed(let text): + updateTranscript(text) + case .availabilityChanged(let available): + updateAvailability(available) + case .cancelled: + break + } +} +``` + +Call `engine.cancelRecognition()` to stop. Permission failures and unsupported locales are reported as thrown errors. + +If you set `engine.usesOnDeviceRecognition = true`, verify that the selected recognizer supports it. Availability differs by device, locale, installed assets, and OS version. + +## Privacy requirements + +The consuming app must provide meaningful values for: + +- `NSMicrophoneUsageDescription` +- `NSSpeechRecognitionUsageDescription` + +The SDK privacy manifest describes OSSSpeechKit itself. It does not supply these usage descriptions or replace your app's privacy disclosures. + +## Deprecated API mapping + +- `OSSSpeech.shared` → retained `OSSSpeechEngine` instance +- `speakText` / `speakAttributedText` → async `speak` +- delegate recognition callbacks → `recognitionEvents(locale:)` +- `endVoiceRecording()` → `cancelRecognition()` +- `OSSVoice` → `OSSVoiceConfiguration` +- `OSSUtterance` → `OSSUtteranceConfiguration` +- `OSSVoiceEnum.allCases` → `OSSLanguage.catalog` +- `OSSVoiceEnum.flag` → `flagEmoji` or `renderedFlagImage(pointSize:scale:)` diff --git a/Makefile b/Makefile index 339992d..971cb8b 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,12 @@ documentation: --author AppDevGuy \ --author_url https://github.com/AppDevGuy \ --github_url https://github.com/AppDevGuy/OSSSpeechKit \ - --podspec OSSSpeechKit.podspec \ - --min-acl internal \ + --swift-build-tool xcodebuild \ + --build-tool-arguments "-project,Example/OSSSpeechKit.xcodeproj,-scheme,OSSSpeechKit-Example,-sdk,iphonesimulator,CODE_SIGNING_ALLOWED=NO" \ + --module OSSSpeechKit \ + --module-version 1.0.0 \ + --readme README.md \ + --min-acl public \ --no-hide-documentation-coverage \ --output ./docs \ diff --git a/OSSSpeechKit.podspec b/OSSSpeechKit.podspec deleted file mode 100644 index 1e36209..0000000 --- a/OSSSpeechKit.podspec +++ /dev/null @@ -1,40 +0,0 @@ -# -# Be sure to run `pod lib lint OSSSpeechKit.podspec' to ensure this is a -# valid spec before submitting. -# -# Any lines starting with a # are optional, but their use is encouraged -# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html -# - -Pod::Spec.new do |s| - s.name = 'OSSSpeechKit' - s.version = '0.3.3' - s.summary = 'OSSSpeechKit provides developers easy text to voice integration.' - s.swift_version = "5.0" - s.platform = :ios, "13.0" - -# This description is used to generate tags and improve search results. -# * Think: What does it do? Why did you write it? What is the focus? -# * Try to keep it short, snappy and to the point. -# * Write the description between the DESC delimiters below. -# * Finally, don't worry about the indent, CocoaPods strips it! - - s.description = <<-DESC -OSSSpeechKit offers an easy way to integrate text to voice using native AVFoundation speech kit. - DESC - - s.homepage = 'https://github.com/appdevguy/OSSSpeechKit' - # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' - s.license = { :type => 'MIT', :file => 'LICENSE' } - s.author = { 'appdevguy' => 'seaniosdeveloper@gmail.com' } - s.source = { :git => 'https://github.com/appdevguy/OSSSpeechKit.git', :tag => s.version.to_s } - s.ios.deployment_target = '13.0' - s.source_files = 'OSSSpeechKit/Classes/*.swift' - - s.resource_bundles = { - 'OSSSpeechKit' => ['OSSSpeechKit/Assets/*', ] - } - - # s.public_header_files = 'Pod/Classes/*.swift' - # s.frameworks = 'UIKit', 'MapKit' -end diff --git a/OSSSpeechKit/Classes/OSSFlag.swift b/OSSSpeechKit/Classes/OSSFlag.swift new file mode 100644 index 0000000..a00170f --- /dev/null +++ b/OSSSpeechKit/Classes/OSSFlag.swift @@ -0,0 +1,103 @@ +import Foundation +import UIKit + +private enum OSSFlagRenderer { + // NSCache is thread-safe, but Foundation does not declare it Sendable. + nonisolated(unsafe) static let cache = NSCache() + + static func emoji(for regionCode: String?) -> String { + guard let regionCode, regionCode.count == 2 else { + return "🌐" + } + let scalars = regionCode.uppercased().unicodeScalars.compactMap { + UnicodeScalar(127397 + $0.value) + } + guard scalars.count == 2 else { return "🌐" } + return String(String.UnicodeScalarView(scalars)) + } + + static func image(emoji: String, pointSize: CGFloat, scale: CGFloat) -> UIImage { + let key = "\(emoji)-\(pointSize)-\(scale)" as NSString + if let cached = cache.object(forKey: key) { + return cached + } + + let font = UIFont.systemFont(ofSize: pointSize) + let attributed = NSAttributedString(string: emoji, attributes: [.font: font]) + let measured = attributed.size() + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: measured, format: format) + let image = renderer.image { _ in + attributed.draw(at: .zero) + } + cache.setObject(image, forKey: key) + return image + } +} + +public extension OSSLanguage { + /// A Unicode flag suitable for labels, SwiftUI `Text`, and accessibility. + var flagEmoji: String { + OSSFlagRenderer.emoji( + for: regionCode ?? locale.language.region?.identifier + ) + } + + /// Renders ``flagEmoji`` to an image for UIKit clients that require one. + func renderedFlagImage( + pointSize: CGFloat = 24, + scale: CGFloat = 1 + ) -> UIImage { + OSSFlagRenderer.image( + emoji: flagEmoji, + pointSize: max(1, pointSize), + scale: max(1, scale) + ) + } +} + +@available(*, deprecated, message: "Use OSSLanguage flag APIs.") +public extension OSSVoiceEnum { + /// The canonical locale used by the modern API. + var canonicalLocaleIdentifier: String { + switch self { + case .Chinese: + return "zh-CN" + case .ChineseHongKong: + return "yue-HK" + case .Norwegian: + return "nb-NO" + default: + return Locale.canonicalIdentifier(from: rawValue) + } + } + + /// Modern metadata corresponding to this legacy case. + var languageMetadata: OSSLanguage { + if let match = OSSLanguage.catalog.first(where: { + $0.localeIdentifier == canonicalLocaleIdentifier + }) { + return match + } + return OSSLanguage( + id: rawValue, + name: title, + localeIdentifier: canonicalLocaleIdentifier + ) + } + + /// Preferred Unicode replacement for the legacy image property. + var flagEmoji: String { + languageMetadata.flagEmoji + } + + /// Renders ``flagEmoji`` for UIKit clients. + func renderedFlagImage( + pointSize: CGFloat = 24, + scale: CGFloat = 1 + ) -> UIImage { + languageMetadata.renderedFlagImage(pointSize: pointSize, scale: scale) + } +} diff --git a/OSSSpeechKit/Classes/OSSLanguage.swift b/OSSSpeechKit/Classes/OSSLanguage.swift new file mode 100644 index 0000000..2d2570a --- /dev/null +++ b/OSSSpeechKit/Classes/OSSLanguage.swift @@ -0,0 +1,160 @@ +import AVFoundation +import Foundation +import Speech + +/// Display metadata for a language or regional voice supported by Apple platforms. +/// +/// The catalog is intentionally separate from runtime capabilities. Use +/// ``availableSynthesisVoices`` and ``supportsRecognition`` before presenting an +/// operation as available on the current device. +public struct OSSLanguage: Hashable, Identifiable, Sendable { + /// Stable identifier used by OSSSpeechKit to refer to this catalog entry. + public let id: String + + /// Human-readable language or regional variant name for display. + public let name: String + + /// Foundation locale that describes the language and region. + public let locale: Locale + + /// Region override used when the locale alone cannot distinguish a variant. + public let regionCode: String? + + /// Voice identifiers that should be preferred when matching installed voices. + public let preferredVoiceIdentifiers: [String] + + /// Creates language metadata for the OSSSpeechKit catalog. + /// + /// - Parameters: + /// - id: Stable catalog identifier. + /// - name: Human-readable display name. + /// - localeIdentifier: BCP 47 identifier for the language and region. + /// - regionCode: Optional region override for variants that share a locale. + /// - preferredVoiceIdentifiers: Preferred AVFoundation voice identifiers. + public init( + id: String, + name: String, + localeIdentifier: String, + regionCode: String? = nil, + preferredVoiceIdentifiers: [String] = [] + ) { + self.id = id + self.name = name + locale = Locale(identifier: localeIdentifier) + self.regionCode = regionCode + self.preferredVoiceIdentifiers = preferredVoiceIdentifiers + } + + /// The canonicalized BCP 47 identifier used for runtime capability checks. + public var localeIdentifier: String { + locale.identifier + } + + /// Voices currently installed or exposed by the operating system. + public var availableSynthesisVoices: [AVSpeechSynthesisVoice] { + let requested = Locale(identifier: localeIdentifier) + guard let requestedLanguageCode = requested.language.languageCode else { + return [] + } + return AVSpeechSynthesisVoice.speechVoices().filter { + let candidate = Locale(identifier: $0.language) + return candidate.identifier == requested.identifier || + (candidate.language.languageCode == requestedLanguageCode && + candidate.language.region == requested.language.region) + } + } + + /// Whether the legacy Speech recognizer reports this locale as supported. + /// + /// Support does not imply that Apple's service is currently available. + public var supportsRecognition: Bool { + Self.supportedRecognitionLocale(equivalentTo: locale) != nil + } + + /// Returns the exact supported recognition locale, or a same-language, + /// same-region canonical equivalent. It never silently changes regions. + public static func supportedRecognitionLocale(equivalentTo locale: Locale) -> Locale? { + let requested = Locale(identifier: locale.identifier) + guard let requestedLanguageCode = requested.language.languageCode else { + return nil + } + return SFSpeechRecognizer.supportedLocales().first { candidate in + candidate.identifier == requested.identifier || + (candidate.language.languageCode == requestedLanguageCode && + candidate.language.region == requested.language.region) + } + } +} + +public extension OSSLanguage { + /// Curated languages and regional variants described by Apple's current + /// VoiceOver and Dictation availability documentation. + /// + /// Availability still varies by OS release, device, installed assets, and + /// network access. Filter this catalog using the runtime capability APIs. + static let catalog: [OSSLanguage] = [ + .init(id: "arabic-world", name: "Arabic (World)", localeIdentifier: "ar-001"), + .init(id: "arabic-saudi-arabia", name: "Arabic (Saudi Arabia)", localeIdentifier: "ar-SA"), + .init(id: "basque", name: "Basque", localeIdentifier: "eu-ES"), + .init(id: "bengali", name: "Bengali", localeIdentifier: "bn-IN"), + .init(id: "bhojpuri", name: "Bhojpuri", localeIdentifier: "bho-IN"), + .init(id: "bulgarian", name: "Bulgarian", localeIdentifier: "bg-BG"), + .init(id: "catalan", name: "Catalan", localeIdentifier: "ca-ES"), + .init(id: "chinese-cantonese-hong-kong", name: "Chinese, Cantonese (Hong Kong)", localeIdentifier: "yue-HK"), + .init(id: "chinese-mandarin-china", name: "Chinese, Mandarin (China mainland)", localeIdentifier: "zh-CN"), + .init(id: "chinese-mandarin-taiwan", name: "Chinese, Mandarin (Taiwan)", localeIdentifier: "zh-TW"), + .init(id: "croatian", name: "Croatian", localeIdentifier: "hr-HR"), + .init(id: "czech", name: "Czech", localeIdentifier: "cs-CZ"), + .init(id: "danish", name: "Danish", localeIdentifier: "da-DK"), + .init(id: "dutch-belgium", name: "Dutch (Belgium)", localeIdentifier: "nl-BE"), + .init(id: "dutch-netherlands", name: "Dutch (Netherlands)", localeIdentifier: "nl-NL"), + .init(id: "english-australia", name: "English (Australia)", localeIdentifier: "en-AU"), + .init(id: "english-india", name: "English (India)", localeIdentifier: "en-IN"), + .init(id: "english-ireland", name: "English (Ireland)", localeIdentifier: "en-IE"), + .init(id: "english-scotland", name: "English (Scotland)", localeIdentifier: "en-GB", regionCode: "GB"), + .init(id: "english-south-africa", name: "English (South Africa)", localeIdentifier: "en-ZA"), + .init(id: "english-uk", name: "English (United Kingdom)", localeIdentifier: "en-GB"), + .init(id: "english-us", name: "English (United States)", localeIdentifier: "en-US"), + .init(id: "farsi", name: "Farsi", localeIdentifier: "fa-IR"), + .init(id: "finnish", name: "Finnish", localeIdentifier: "fi-FI"), + .init(id: "french-belgium", name: "French (Belgium)", localeIdentifier: "fr-BE"), + .init(id: "french-canada", name: "French (Canada)", localeIdentifier: "fr-CA"), + .init(id: "french-france", name: "French (France)", localeIdentifier: "fr-FR"), + .init(id: "galician", name: "Galician", localeIdentifier: "gl-ES"), + .init(id: "german", name: "German", localeIdentifier: "de-DE"), + .init(id: "greek", name: "Greek", localeIdentifier: "el-GR"), + .init(id: "hebrew", name: "Hebrew", localeIdentifier: "he-IL"), + .init(id: "hindi", name: "Hindi", localeIdentifier: "hi-IN"), + .init(id: "hungarian", name: "Hungarian", localeIdentifier: "hu-HU"), + .init(id: "indonesian", name: "Indonesian", localeIdentifier: "id-ID"), + .init(id: "italian", name: "Italian", localeIdentifier: "it-IT"), + .init(id: "japanese", name: "Japanese", localeIdentifier: "ja-JP"), + .init(id: "kannada", name: "Kannada", localeIdentifier: "kn-IN"), + .init(id: "kazakh", name: "Kazakh", localeIdentifier: "kk-KZ"), + .init(id: "korean", name: "Korean", localeIdentifier: "ko-KR"), + .init(id: "lithuanian", name: "Lithuanian", localeIdentifier: "lt-LT"), + .init(id: "malay", name: "Malay", localeIdentifier: "ms-MY"), + .init(id: "marathi", name: "Marathi", localeIdentifier: "mr-IN"), + .init(id: "norwegian-bokmal", name: "Norwegian Bokmål", localeIdentifier: "nb-NO"), + .init(id: "polish", name: "Polish", localeIdentifier: "pl-PL"), + .init(id: "portuguese-brazil", name: "Portuguese (Brazil)", localeIdentifier: "pt-BR"), + .init(id: "portuguese-portugal", name: "Portuguese (Portugal)", localeIdentifier: "pt-PT"), + .init(id: "romanian", name: "Romanian", localeIdentifier: "ro-RO"), + .init(id: "russian", name: "Russian", localeIdentifier: "ru-RU"), + .init(id: "slovak", name: "Slovak", localeIdentifier: "sk-SK"), + .init(id: "slovenian", name: "Slovenian", localeIdentifier: "sl-SI"), + .init(id: "spanish-argentina", name: "Spanish (Argentina)", localeIdentifier: "es-AR"), + .init(id: "spanish-chile", name: "Spanish (Chile)", localeIdentifier: "es-CL"), + .init(id: "spanish-colombia", name: "Spanish (Colombia)", localeIdentifier: "es-CO"), + .init(id: "spanish-mexico", name: "Spanish (Mexico)", localeIdentifier: "es-MX"), + .init(id: "spanish-spain", name: "Spanish (Spain)", localeIdentifier: "es-ES"), + .init(id: "swedish", name: "Swedish", localeIdentifier: "sv-SE"), + .init(id: "tamil", name: "Tamil", localeIdentifier: "ta-IN"), + .init(id: "telugu", name: "Telugu", localeIdentifier: "te-IN"), + .init(id: "thai", name: "Thai", localeIdentifier: "th-TH"), + .init(id: "turkish", name: "Turkish", localeIdentifier: "tr-TR"), + .init(id: "ukrainian", name: "Ukrainian", localeIdentifier: "uk-UA"), + .init(id: "valencian", name: "Valencian", localeIdentifier: "ca-ES", regionCode: "ES"), + .init(id: "vietnamese", name: "Vietnamese", localeIdentifier: "vi-VN") + ] +} diff --git a/OSSSpeechKit/Classes/OSSSpeech.swift b/OSSSpeechKit/Classes/OSSSpeech.swift index 83ae9c3..766f346 100755 --- a/OSSSpeechKit/Classes/OSSSpeech.swift +++ b/OSSSpeechKit/Classes/OSSSpeech.swift @@ -67,6 +67,7 @@ public enum OSSSpeechKitErrorType: Int { /// The audio engine is invalid. case invalidAudioEngine = -6 /// Voice recognition is unavailable. + @available(*, deprecated, message: "Use OSSSpeechError.recognizerUnavailable.") case recogniserUnavailble = -7 /// The OSSSpeechKit error message string. @@ -147,6 +148,7 @@ public enum OSSSpeechRecognitionTaskType: Int { } /// Delegate to handle events such as failed authentication for microphone among many more. +@available(*, deprecated, message: "Use OSSSpeechEngine async methods and recognition events.") public protocol OSSSpeechDelegate: AnyObject { /// When the microphone has finished accepting audio, this delegate will be called with the final best text output. func didFinishListening(withText text: String) @@ -161,7 +163,8 @@ public protocol OSSSpeechDelegate: AnyObject { } /// Speech is the primary interface. To use, set the voice and then call `.speak(string: "your string")` -public class OSSSpeech: NSObject { +@available(*, deprecated, message: "Use the instance-based OSSSpeechEngine async API.") +public class OSSSpeech: NSObject, @unchecked Sendable { // MARK: - Private Properties @@ -213,7 +216,8 @@ public class OSSSpeech: NSObject { /// This property handles permission authorization. /// This property is intentionally named vaguely to prevent accidental overriding. - public var srp = SFSpeechRecognizer.self + @available(*, deprecated, message: "Use dependency injection with OSSSpeechEngine tests.") + public var srp: SFSpeechRecognizer.Type = SFSpeechRecognizer.self // Voice to text private var audioEngine: AVAudioEngine? @@ -238,6 +242,7 @@ public class OSSSpeech: NSObject { }() /// A singleton object to ensure conformity accross the application it is used in. + @available(*, deprecated, message: "Create an OSSSpeechEngine instance instead.") public class var shared: OSSSpeech { return sharedInstance } @@ -326,19 +331,14 @@ public class OSSSpeech: NSObject { } private func speak() { - var speechVoice = OSSVoice() - if let aVoice = voice { - speechVoice = aVoice - } - let validString = utterance?.speechString ?? "error" - // Utterance must be an original object in order to be spoken. We redefine an new instance of Utterance each time using the values in the existing utterance. - let newUtterance = AVSpeechUtterance(string: validString) - newUtterance.voice = speechVoice - if let validUtterance = utterance { - newUtterance.rate = validUtterance.rate - newUtterance.pitchMultiplier = validUtterance.pitchMultiplier - newUtterance.volume = validUtterance.volume + guard let validUtterance = utterance, !validUtterance.speechString.isEmpty else { + delegate?.didFailToProcessRequest(withError: OSSSpeechError.emptyText) + return } + let newUtterance = AVSpeechUtterance(attributedString: validUtterance.attributedSpeechString) + newUtterance.voice = voice?.configuration.resolve() + ?? AVSpeechSynthesisVoice(language: OSSVoiceEnum.UnitedStatesEnglish.rawValue) + validUtterance.configuration.apply(to: newUtterance) // Ensure volume is correct each time setSession(isRecording: false) stopSpeaking() @@ -405,13 +405,12 @@ public class OSSSpeech: NSObject { } private func getMicroPhoneAuthorization() { - weak var weakSelf = self - weakSelf?.srp.requestAuthorization { authStatus in + srp.requestAuthorization { [weak self] authStatus in let status = OSSSpeechKitAuthorizationStatus(rawValue: authStatus.rawValue) ?? .notDetermined - weakSelf?.delegate?.authorizationToMicrophone(withAuthentication: status) - if status == .authorized { + self?.delegate?.authorizationToMicrophone(withAuthentication: status) + if status == .authorized, let self { OperationQueue.main.addOperation { - weakSelf?.recordAndRecognizeSpeech() + self.recordAndRecognizeSpeech() } } } @@ -440,7 +439,8 @@ public class OSSSpeech: NSObject { request = nil } if let task = recognitionTask { - task.finish() + task.cancel() + recognitionTask = nil } resetAudioEngine() } @@ -457,40 +457,13 @@ public class OSSSpeech: NSObject { let input = audioEngine.inputNode let bus = 0 let recordingFormat = input.outputFormat(forBus: 0) - guard let outputFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 8000, channels: 1, interleaved: true) else { - delegate?.didFailToCommenceSpeechRecording() - delegate?.didFailToProcessRequest(withError: OSSSpeechKitErrorType.invalidAudioEngine.error) - return - } - guard let converter = AVAudioConverter(from: recordingFormat, to: outputFormat) else { + guard recordingFormat.channelCount > 0 else { delegate?.didFailToCommenceSpeechRecording() delegate?.didFailToProcessRequest(withError: OSSSpeechKitErrorType.invalidAudioEngine.error) return } input.installTap(onBus: bus, bufferSize: 8192, format: recordingFormat) { [weak self] (buffer, _) -> Void in - var newBufferAvailable = true - let inputCallback: AVAudioConverterInputBlock = { _, outStatus in - if newBufferAvailable { - outStatus.pointee = .haveData - newBufferAvailable = false - return buffer - } else { - outStatus.pointee = .noDataNow - return nil - } - } - let convertedBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: AVAudioFrameCount(outputFormat.sampleRate) * buffer.frameLength / AVAudioFrameCount(buffer.format.sampleRate))! - var error: NSError? - let status = converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputCallback) - if status == .error { - self?.delegate?.didFailToCommenceSpeechRecording() - self?.delegate?.didFailToProcessRequest(withError: OSSSpeechKitErrorType.invalidAudioEngine.error) - if let err = error { - self?.debugLog(object: self as Any, message: "Audio Engine conversion error: \(err)") - } - return - } - self?.request?.append(convertedBuffer) + self?.request?.append(buffer) } audioEngine.prepare() do { @@ -502,10 +475,7 @@ public class OSSSpeech: NSObject { } private func recordAndRecognizeSpeech() { - if let speechRecognizer, !speechRecognizer.isAvailable { - cancelRecording() - setSession(isRecording: false) - } + cancelRecording() if speechSynthesizer.isSpeaking { stopSpeaking() } @@ -517,8 +487,15 @@ public class OSSSpeech: NSObject { } request = SFSpeechAudioBufferRecognitionRequest() engineSetup() - let identifier = voice?.voiceType.rawValue ?? OSSVoiceEnum.UnitedStatesEnglish.rawValue - speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: identifier)) + let identifier = voice?.voiceType.canonicalLocaleIdentifier ?? OSSVoiceEnum.UnitedStatesEnglish.rawValue + let requestedLocale = Locale(identifier: identifier) + guard let supportedLocale = OSSLanguage.supportedRecognitionLocale(equivalentTo: requestedLocale) else { + delegate?.didFailToCommenceSpeechRecording() + delegate?.didFailToProcessRequest(withError: OSSSpeechError.recognitionLocaleUnsupported(identifier)) + cancelRecording() + return + } + speechRecognizer = SFSpeechRecognizer(locale: supportedLocale) guard let recogniser = speechRecognizer else { delegate?.didFailToCommenceSpeechRecording() delegate?.didFailToProcessRequest(withError: OSSSpeechKitErrorType.invalidSpeechRequest.error) @@ -544,6 +521,7 @@ public class OSSSpeech: NSObject { } /// Extension to handle the SFSpeechRecognitionTaskDelegate and SFSpeechRecognizerDelegate methods. +@available(*, deprecated, message: "Use OSSSpeechEngine recognition events.") extension OSSSpeech: SFSpeechRecognitionTaskDelegate, SFSpeechRecognizerDelegate { // MARK: - SFSpeechRecognitionTaskDelegate Methods @@ -551,7 +529,12 @@ extension OSSSpeech: SFSpeechRecognitionTaskDelegate, SFSpeechRecognizerDelegate /// Docs available by Google searching for SFSpeechRecognitionTaskDelegate public func speechRecognitionTask(_ task: SFSpeechRecognitionTask, didFinishSuccessfully successfully: Bool) { recognitionTask = nil - delegate?.didFinishListening(withText: spokenText) + if successfully { + delegate?.didFinishListening(withText: spokenText) + } else { + delegate?.didFailToProcessRequest(withError: OSSSpeechError.recognizerUnavailable) + } + resetAudioEngine() setSession(isRecording: false) } @@ -565,8 +548,10 @@ extension OSSSpeech: SFSpeechRecognitionTaskDelegate, SFSpeechRecognizerDelegate spokenText = recognitionResult.bestTranscription.formattedString } + /// Called when the recognizer detects speech in the audio stream. public func speechRecognitionDidDetectSpeech(_ task: SFSpeechRecognitionTask) {} + /// Called when the recognizer has finished reading audio from the request. public func speechRecognitionTaskFinishedReadingAudio(_ task: SFSpeechRecognitionTask) {} // MARK: - SFSpeechRecognizerDelegate Methods diff --git a/OSSSpeechKit/Classes/OSSSpeechConfiguration.swift b/OSSSpeechKit/Classes/OSSSpeechConfiguration.swift new file mode 100644 index 0000000..b8aaba6 --- /dev/null +++ b/OSSSpeechKit/Classes/OSSSpeechConfiguration.swift @@ -0,0 +1,124 @@ +import AVFoundation +import Foundation + +/// Selects an actual voice exposed by AVFoundation. +public struct OSSVoiceConfiguration { + /// Language or regional variant used to search installed voices. + public var language: OSSLanguage + + /// Specific AVFoundation voice identifier to prefer when available. + public var preferredIdentifier: String? + + /// Preferred voice quality to use when multiple voices match the language. + public var preferredQuality: AVSpeechSynthesisVoiceQuality? + + /// Creates a voice selection request. + /// + /// - Parameters: + /// - language: Language metadata used to find matching voices. + /// - preferredIdentifier: Optional exact AVFoundation voice identifier. + /// - preferredQuality: Optional quality preference for matched voices. + public init( + language: OSSLanguage, + preferredIdentifier: String? = nil, + preferredQuality: AVSpeechSynthesisVoiceQuality? = nil + ) { + self.language = language + self.preferredIdentifier = preferredIdentifier + self.preferredQuality = preferredQuality + } + + /// Resolves the best currently available system voice without relying on + /// AVFoundation's silent cross-region fallback. + public func resolve() -> AVSpeechSynthesisVoice? { + if let preferredIdentifier, + let voice = AVSpeechSynthesisVoice(identifier: preferredIdentifier) { + return voice + } + + if let directMatch = AVSpeechSynthesisVoice(language: language.localeIdentifier) { + if preferredQuality == nil || directMatch.quality == preferredQuality { + return directMatch + } + } + + let voices = language.availableSynthesisVoices + if let preferredQuality, + let qualityMatch = voices.first(where: { $0.quality == preferredQuality }) { + return qualityMatch + } + return voices.first + } +} + +/// Value-style speech parameters used to construct a fresh AVFoundation +/// utterance for every synthesis request. +public struct OSSUtteranceConfiguration { + /// Speech rate to apply to the utterance. + public var rate: Float + + /// Pitch multiplier to apply to the utterance. + public var pitchMultiplier: Float + + /// Playback volume to apply to the utterance. + public var volume: Float + + /// Delay before the utterance begins speaking. + public var preUtteranceDelay: TimeInterval + + /// Delay after the utterance finishes speaking. + public var postUtteranceDelay: TimeInterval + + /// Creates speech synthesis parameters for an utterance. + /// + /// - Parameters: + /// - rate: Speech rate to apply. + /// - pitchMultiplier: Pitch multiplier to apply. + /// - volume: Playback volume to apply. + /// - preUtteranceDelay: Delay before speaking begins. + /// - postUtteranceDelay: Delay after speaking finishes. + public init( + rate: Float = AVSpeechUtteranceDefaultSpeechRate, + pitchMultiplier: Float = 1, + volume: Float = 1, + preUtteranceDelay: TimeInterval = 0, + postUtteranceDelay: TimeInterval = 0 + ) { + self.rate = rate + self.pitchMultiplier = pitchMultiplier + self.volume = volume + self.preUtteranceDelay = preUtteranceDelay + self.postUtteranceDelay = postUtteranceDelay + } + + func apply(to utterance: AVSpeechUtterance) { + utterance.rate = rate + utterance.pitchMultiplier = pitchMultiplier + utterance.volume = volume + utterance.preUtteranceDelay = preUtteranceDelay + utterance.postUtteranceDelay = postUtteranceDelay + } +} + +@available(*, deprecated, message: "Use OSSUtteranceConfiguration and pass text directly to OSSSpeech.") +extension OSSUtterance { + var configuration: OSSUtteranceConfiguration { + OSSUtteranceConfiguration( + rate: rate, + pitchMultiplier: pitchMultiplier, + volume: volume, + preUtteranceDelay: preUtteranceDelay, + postUtteranceDelay: postUtteranceDelay + ) + } +} + +@available(*, deprecated, message: "Use OSSVoiceConfiguration.") +extension OSSVoice { + var configuration: OSSVoiceConfiguration { + OSSVoiceConfiguration( + language: voiceType.languageMetadata, + preferredQuality: quality + ) + } +} diff --git a/OSSSpeechKit/Classes/OSSSpeechEngine.swift b/OSSSpeechKit/Classes/OSSSpeechEngine.swift new file mode 100644 index 0000000..d892794 --- /dev/null +++ b/OSSSpeechKit/Classes/OSSSpeechEngine.swift @@ -0,0 +1,583 @@ +import AVFoundation +import Foundation +import Speech + +/// Typed failures emitted by the modern OSSSpeechKit API. +public enum OSSSpeechError: Error, LocalizedError { + /// The provided speech text was empty or only whitespace. + case emptyText + + /// No installed speech synthesis voice matched the requested locale. + case voiceUnavailable(localeIdentifier: String) + + /// Speech recognition authorization was denied or unavailable. + case speechAuthorizationDenied + + /// Microphone recording authorization was denied or unavailable. + case microphoneAuthorizationDenied + + /// Speech recognition does not support the requested locale on this device. + case recognitionLocaleUnsupported(String) + + /// The speech recognizer exists but is not currently available. + case recognizerUnavailable + + /// AVFoundation could not configure or start the audio engine/session. + case audioEngineFailure(underlying: Error?) + + /// A speech synthesis or recognition operation is already active. + case operationInProgress + + public var errorDescription: String? { + switch self { + case .emptyText: + return "Speech text must not be empty." + case .voiceUnavailable(let identifier): + return "No installed voice is available for \(identifier)." + case .speechAuthorizationDenied: + return "Speech recognition permission was not granted." + case .microphoneAuthorizationDenied: + return "Microphone permission was not granted." + case .recognitionLocaleUnsupported(let identifier): + return "Speech recognition does not support \(identifier) on this device." + case .recognizerUnavailable: + return "The speech recognition service is currently unavailable." + case .audioEngineFailure(let underlying): + return underlying?.localizedDescription ?? "The audio engine could not start." + case .operationInProgress: + return "Another speech operation is already in progress." + } + } +} + +/// Events from a live recognition session. +public enum OSSSpeechRecognitionEvent { + /// Interim transcription text emitted before recognition completes. + case partial(String) + + /// Final transcription text emitted when recognition completes. + case completed(String) + + /// Availability updates reported by the underlying speech recognizer. + case availabilityChanged(Bool) + + /// Recognition was cancelled before producing a final result. + case cancelled +} + +/// A word or phrase reported by Apple's speech recognizer. +public struct OSSSpeechTranscriptSegment: Equatable, Sendable { + public let text: String + public let timestamp: TimeInterval + public let duration: TimeInterval + public let confidence: Float + + public init(text: String, timestamp: TimeInterval, duration: TimeInterval, confidence: Float) { + self.text = text + self.timestamp = timestamp + self.duration = duration + self.confidence = confidence + } +} + +/// A timestamped snapshot of the recognizer's current transcription. +public struct OSSSpeechTranscript: Equatable, Sendable { + public let formattedText: String + public let segments: [OSSSpeechTranscriptSegment] + public let isFinal: Bool + + public init( + formattedText: String, + segments: [OSSSpeechTranscriptSegment], + isFinal: Bool + ) { + self.formattedText = formattedText + self.segments = segments + self.isFinal = isFinal + } +} + +/// Events from a continuous recognition session. +public enum OSSSpeechContinuousRecognitionEvent: Sendable { + case transcript(OSSSpeechTranscript) + case availabilityChanged(Bool) + case paused + case resumed + case completed + case cancelled +} + +/// Instance-based, main-actor-isolated speech synthesis and recognition engine. +@MainActor +public final class OSSSpeechEngine: NSObject { + /// Current high-level activity of the speech engine. + public enum State: Equatable { + /// The engine is not speaking or listening. + case idle + + /// The engine is currently synthesizing speech. + case speaking + + /// The engine is currently recording and recognizing speech. + case listening + + /// A continuous recognition session exists, but microphone capture is paused. + case paused + } + + /// Current high-level activity of the engine. + public private(set) var state: State = .idle + + /// Whether recognition should require on-device processing when supported. + public var usesOnDeviceRecognition = false + + /// Task hint passed to the underlying speech recognizer. + public var recognitionTaskHint: SFSpeechRecognitionTaskHint = .unspecified + + private let synthesizer: AVSpeechSynthesizer + private let audioEngine: AVAudioEngine + private let audioSession: AVAudioSession + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionContinuation: AsyncThrowingStream.Continuation? + private var continuousRecognitionContinuation: + AsyncThrowingStream.Continuation? + private var activeRecognizer: SFSpeechRecognizer? + private var isContinuousRecognition = false + private var synthesisContinuation: CheckedContinuation? + private var sessionID: UUID? + private var recognitionTaskID: UUID? + private var continuousStartedAt: Date? + private var accumulatedContinuousTime: TimeInterval = 0 + private var recognitionTaskOffset: TimeInterval = 0 + private var tapInstalled = false + + /// Creates a speech engine with its own synthesizer, audio engine, and audio session. + public override init() { + synthesizer = AVSpeechSynthesizer() + audioEngine = AVAudioEngine() + audioSession = .sharedInstance() + super.init() + synthesizer.delegate = self + } + + /// Locales that the platform reports as available for speech recognition. + public static var supportedRecognitionLocales: Set { + SFSpeechRecognizer.supportedLocales() + } + + /// Requests speech recognition authorization and maps the native status. + public func requestSpeechAuthorization() async -> OSSSpeechKitAuthorizationStatus { + let nativeStatus: SFSpeechRecognizerAuthorizationStatus = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) } + } + switch nativeStatus { + case .notDetermined: return .notDetermined + case .denied: return .denied + case .restricted: return .restricted + case .authorized: return .authorized + @unknown default: return .restricted + } + } + + /// Requests microphone recording authorization. + public func requestMicrophoneAuthorization() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { + continuation.resume(returning: $0) + } + } + } + + /// Speaks plain text using a resolved AVFoundation voice. + public func speak( + _ text: String, + voice: OSSVoiceConfiguration, + configuration: OSSUtteranceConfiguration = .init() + ) async throws { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OSSSpeechError.emptyText + } + let utterance = AVSpeechUtterance(string: text) + try await speak(utterance, voice: voice, configuration: configuration) + } + + /// Speaks attributed text using a resolved AVFoundation voice. + public func speak( + _ attributedText: NSAttributedString, + voice: OSSVoiceConfiguration, + configuration: OSSUtteranceConfiguration = .init() + ) async throws { + guard !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OSSSpeechError.emptyText + } + let utterance = AVSpeechUtterance(attributedString: attributedText) + try await speak(utterance, voice: voice, configuration: configuration) + } + + private func speak( + _ utterance: AVSpeechUtterance, + voice: OSSVoiceConfiguration, + configuration: OSSUtteranceConfiguration + ) async throws { + guard state == .idle else { throw OSSSpeechError.operationInProgress } + guard let resolvedVoice = voice.resolve() else { + throw OSSSpeechError.voiceUnavailable(localeIdentifier: voice.language.localeIdentifier) + } + try configureAudioSession(category: .playback) + utterance.voice = resolvedVoice + configuration.apply(to: utterance) + state = .speaking + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + synthesisContinuation = continuation + synthesizer.speak(utterance) + } + } onCancel: { + Task { @MainActor [weak self] in self?.stopSpeaking() } + } + } + + /// Pauses the current utterance immediately when the engine is speaking. + public func pauseSpeaking() { + guard state == .speaking else { return } + synthesizer.pauseSpeaking(at: .immediate) + } + + /// Resumes a paused utterance. + public func continueSpeaking() { + guard synthesizer.isPaused else { return } + synthesizer.continueSpeaking() + } + + /// Stops the current utterance and finishes the synthesis operation. + public func stopSpeaking() { + guard state == .speaking else { return } + if !synthesizer.stopSpeaking(at: .immediate) { + finishSynthesis(throwing: CancellationError()) + } + } + + /// Starts recognition and returns a stream that owns the recording session. + /// Cancelling iteration tears down the audio tap and recognition task. + public func recognitionEvents( + locale: Locale, + requestAuthorization: Bool = true + ) async throws -> AsyncThrowingStream { + guard state == .idle else { throw OSSSpeechError.operationInProgress } + if requestAuthorization { + guard await requestSpeechAuthorization() == .authorized else { + throw OSSSpeechError.speechAuthorizationDenied + } + guard await requestMicrophoneAuthorization() else { + throw OSSSpeechError.microphoneAuthorizationDenied + } + } + try Task.checkCancellation() + + guard let supportedLocale = OSSLanguage.supportedRecognitionLocale(equivalentTo: locale) else { + throw OSSSpeechError.recognitionLocaleUnsupported(locale.identifier) + } + guard let recognizer = SFSpeechRecognizer(locale: supportedLocale) else { + throw OSSSpeechError.recognitionLocaleUnsupported(locale.identifier) + } + guard recognizer.isAvailable else { throw OSSSpeechError.recognizerUnavailable } + recognizer.delegate = self + + let stream = AsyncThrowingStream { continuation in + recognitionContinuation = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.cancelRecognition() } + } + } + do { + try startRecognition(using: recognizer, continuous: false) + } catch { + recognitionContinuation?.finish(throwing: error) + recognitionContinuation = nil + throw error + } + return stream + } + + /// Cancels the active recognition session and tears down audio recording. + public func cancelRecognition() { + guard state == .listening || state == .paused || recognitionTask != nil else { return } + recognitionContinuation?.yield(.cancelled) + recognitionContinuation?.finish() + continuousRecognitionContinuation?.yield(.cancelled) + continuousRecognitionContinuation?.finish() + tearDownRecognition(cancelTask: true) + } + + /// Starts a long-lived session that continues across recognizer-finalized utterances. + public func continuousRecognitionEvents( + locale: Locale, + requestAuthorization: Bool = true + ) async throws -> AsyncThrowingStream { + guard state == .idle else { throw OSSSpeechError.operationInProgress } + if requestAuthorization { + guard await requestSpeechAuthorization() == .authorized else { + throw OSSSpeechError.speechAuthorizationDenied + } + guard await requestMicrophoneAuthorization() else { + throw OSSSpeechError.microphoneAuthorizationDenied + } + } + try Task.checkCancellation() + + guard let supportedLocale = OSSLanguage.supportedRecognitionLocale(equivalentTo: locale) else { + throw OSSSpeechError.recognitionLocaleUnsupported(locale.identifier) + } + guard let recognizer = SFSpeechRecognizer(locale: supportedLocale) else { + throw OSSSpeechError.recognitionLocaleUnsupported(locale.identifier) + } + guard recognizer.isAvailable else { throw OSSSpeechError.recognizerUnavailable } + recognizer.delegate = self + + let stream = AsyncThrowingStream { continuation in + continuousRecognitionContinuation = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.cancelRecognition() } + } + } + do { + try startRecognition(using: recognizer, continuous: true) + } catch { + continuousRecognitionContinuation?.finish(throwing: error) + continuousRecognitionContinuation = nil + throw error + } + return stream + } + + /// Pauses microphone capture while retaining a continuous recognition session. + public func pauseRecognition() { + guard isContinuousRecognition, state == .listening else { return } + accumulatedContinuousTime = continuousElapsedTime + continuousStartedAt = nil + stopRecognitionTask(cancel: false) + state = .paused + continuousRecognitionContinuation?.yield(.paused) + } + + /// Resumes microphone capture in a paused continuous recognition session. + public func resumeRecognition() throws { + guard isContinuousRecognition, state == .paused, let recognizer = activeRecognizer else { return } + continuousStartedAt = Date() + state = .listening + do { + try startRecognitionTask(using: recognizer) + continuousRecognitionContinuation?.yield(.resumed) + } catch { + continuousRecognitionContinuation?.finish(throwing: error) + tearDownRecognition(cancelTask: true) + throw error + } + } + + /// Explicitly completes a continuous recognition session. + public func finishRecognition() { + guard isContinuousRecognition, state == .listening || state == .paused else { return } + continuousRecognitionContinuation?.yield(.completed) + continuousRecognitionContinuation?.finish() + tearDownRecognition(cancelTask: false) + } + + private func startRecognition(using recognizer: SFSpeechRecognizer, continuous: Bool) throws { + try configureAudioSession(category: .playAndRecord) + activeRecognizer = recognizer + isContinuousRecognition = continuous + accumulatedContinuousTime = 0 + continuousStartedAt = continuous ? Date() : nil + sessionID = UUID() + state = .listening + try startRecognitionTask(using: recognizer) + } + + private func startRecognitionTask(using recognizer: SFSpeechRecognizer) throws { + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.taskHint = recognitionTaskHint + if usesOnDeviceRecognition { + guard recognizer.supportsOnDeviceRecognition else { + throw OSSSpeechError.recognitionLocaleUnsupported(recognizer.locale.identifier) + } + request.requiresOnDeviceRecognition = true + } + + let id = UUID() + recognitionTaskID = id + recognitionTaskOffset = continuousElapsedTime + recognitionRequest = request + + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0 else { + tearDownRecognition(cancelTask: true) + throw OSSSpeechError.audioEngineFailure(underlying: nil) + } + if tapInstalled { + inputNode.removeTap(onBus: 0) + tapInstalled = false + } + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak request] buffer, _ in + guard buffer.frameLength > 0 else { return } + request?.append(buffer) + } + tapInstalled = true + + recognizer.defaultTaskHint = recognitionTaskHint + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + guard let self, self.recognitionTaskID == id else { return } + if let result { + let text = result.bestTranscription.formattedString + if self.isContinuousRecognition { + let transcript = self.makeTranscript(from: result) + self.continuousRecognitionContinuation?.yield(.transcript(transcript)) + if result.isFinal, self.state == .listening, + let recognizer = self.activeRecognizer { + self.stopRecognitionTask(cancel: false) + do { + try self.startRecognitionTask(using: recognizer) + } catch { + self.continuousRecognitionContinuation?.finish(throwing: error) + self.tearDownRecognition(cancelTask: true) + } + } + } else { + self.recognitionContinuation?.yield(result.isFinal ? .completed(text) : .partial(text)) + } + if result.isFinal, !self.isContinuousRecognition { + self.recognitionContinuation?.finish() + self.tearDownRecognition(cancelTask: false) + } + } else if let error { + self.recognitionContinuation?.finish(throwing: error) + self.tearDownRecognition(cancelTask: true) + } + } + } + + audioEngine.prepare() + if !audioEngine.isRunning { + do { + try audioEngine.start() + } catch { + tearDownRecognition(cancelTask: true) + throw OSSSpeechError.audioEngineFailure(underlying: error) + } + } + } + + private var continuousElapsedTime: TimeInterval { + accumulatedContinuousTime + (continuousStartedAt.map { Date().timeIntervalSince($0) } ?? 0) + } + + private func makeTranscript(from result: SFSpeechRecognitionResult) -> OSSSpeechTranscript { + let transcription = result.bestTranscription + let segments = transcription.segments.map { + OSSSpeechTranscriptSegment( + text: $0.substring, + timestamp: recognitionTaskOffset + $0.timestamp, + duration: $0.duration, + confidence: $0.confidence + ) + } + return OSSSpeechTranscript( + formattedText: transcription.formattedString, + segments: segments, + isFinal: result.isFinal + ) + } + + private func configureAudioSession(category: AVAudioSession.Category) throws { + do { + try audioSession.setCategory(category, mode: .default, options: [.duckOthers]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + throw OSSSpeechError.audioEngineFailure(underlying: error) + } + } + + private func tearDownRecognition(cancelTask: Bool) { + sessionID = nil + stopRecognitionTask(cancel: cancelTask) + audioEngine.reset() + recognitionContinuation = nil + continuousRecognitionContinuation = nil + activeRecognizer = nil + isContinuousRecognition = false + continuousStartedAt = nil + accumulatedContinuousTime = 0 + recognitionTaskOffset = 0 + state = .idle + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } + + private func stopRecognitionTask(cancel: Bool) { + recognitionTaskID = nil + recognitionRequest?.endAudio() + if cancel { + recognitionTask?.cancel() + } else { + recognitionTask?.finish() + } + recognitionTask = nil + recognitionRequest = nil + if audioEngine.isRunning { + audioEngine.stop() + } + if tapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + } + + private func finishSynthesis(throwing error: Error? = nil) { + state = .idle + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + let continuation = synthesisContinuation + synthesisContinuation = nil + if let error { + continuation?.resume(throwing: error) + } else { + continuation?.resume() + } + } +} + +extension OSSSpeechEngine: AVSpeechSynthesizerDelegate, SFSpeechRecognizerDelegate { + /// Completes the awaiting synthesis operation when AVFoundation finishes speaking. + nonisolated public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didFinish utterance: AVSpeechUtterance + ) { + Task { @MainActor in finishSynthesis() } + } + + /// Completes the awaiting synthesis operation with cancellation when speech is cancelled. + nonisolated public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didCancel utterance: AVSpeechUtterance + ) { + Task { @MainActor in finishSynthesis(throwing: CancellationError()) } + } + + /// Emits recognizer availability changes to the active recognition stream. + nonisolated public func speechRecognizer( + _ speechRecognizer: SFSpeechRecognizer, + availabilityDidChange available: Bool + ) { + Task { @MainActor in + recognitionContinuation?.yield(.availabilityChanged(available)) + continuousRecognitionContinuation?.yield(.availabilityChanged(available)) + if !available, state == .listening { + recognitionContinuation?.finish(throwing: OSSSpeechError.recognizerUnavailable) + continuousRecognitionContinuation?.finish(throwing: OSSSpeechError.recognizerUnavailable) + tearDownRecognition(cancelTask: true) + } + } + } +} diff --git a/OSSSpeechKit/Classes/OSSSpeechUtility.swift b/OSSSpeechKit/Classes/OSSSpeechUtility.swift index cef56cb..5727a1f 100644 --- a/OSSSpeechKit/Classes/OSSSpeechUtility.swift +++ b/OSSSpeechKit/Classes/OSSSpeechUtility.swift @@ -23,6 +23,7 @@ import Foundation +/// Utility methods for localized strings and diagnostics used by OSSSpeechKit. public class OSSSpeechUtility: NSObject { // MARK: - Variables @@ -52,17 +53,21 @@ public class OSSSpeechUtility: NSObject { /// - comment: The value for the key. If no key - value is found, the comment value will be used. /// - Returns: A string with either the value from the main bundle or the SDK bundle, else the comment. public func getString(forLocalizedName name: String, defaultValue: String) -> String { + getString(forLocalizedName: name, defaultValue: defaultValue, bundle: Bundle.main) + } + + func getString(forLocalizedName name: String, defaultValue: String, bundle: Bundle) -> String { if name.isEmpty { return "!&!&!&!&!&!&!&!&!&!&!&!&!&!&!" } - var localString = NSLocalizedString(name, tableName: stringsTableName, bundle: Bundle.main, comment: defaultValue) + var localString = NSLocalizedString(name, tableName: stringsTableName, bundle: bundle, comment: defaultValue) if !localString.isEmpty && localString != name { return localString } guard let sdkBundle = Bundle.getResourcesBundle() else { return defaultValue } - // The Main Bundle does not contain the value for the key. Use the SDK strings table. + // The preferred bundle does not contain the value for the key. Use the SDK strings table. localString = NSLocalizedString(name, tableName: "Localizable", bundle: sdkBundle, value: defaultValue, comment: defaultValue) if !localString.isEmpty && localString != name { return localString @@ -74,17 +79,9 @@ public class OSSSpeechUtility: NSObject { /// Bundle extension to aid in retrieving the SDK resources for getting SDK images. extension Bundle { - /// Will return the Bundle for the SDK if it can be found. + /// Returns the Swift package resource bundle. static func getResourcesBundle() -> Bundle? { -#if SWIFT_PACKAGE return Bundle.module -#else - let bundle = Bundle(for: OSSVoice.self) - guard let resourcesBundleUrl = bundle.resourceURL?.appendingPathComponent("OSSSpeechKit.bundle") else { - return nil - } - return Bundle(url: resourcesBundleUrl) -#endif } } @@ -99,6 +96,7 @@ extension NSObject { /// - fileName: Automatically populated by the application /// - lineNumber: Automatically populated by the application /// - message: The message you wish to output. + @available(*, deprecated, message: "Use OSLog from your application instead.") public func debugLog(object: Any, functionName: String = #function, fileName: String = #file, lineNumber: Int = #line, message: String) { #if DEBUG let className = (fileName as NSString).lastPathComponent diff --git a/OSSSpeechKit/Classes/OSSUtterance.swift b/OSSSpeechKit/Classes/OSSUtterance.swift index 313fd48..ffeb485 100755 --- a/OSSSpeechKit/Classes/OSSUtterance.swift +++ b/OSSSpeechKit/Classes/OSSUtterance.swift @@ -28,6 +28,7 @@ import AVFoundation /// The OSSUtterance offers special overrides for strings which are usually set once objects. /// /// As the developer, you can override the `volume`, `rate` and `pitchMultiplier` should you wish to. +@available(*, deprecated, message: "Use OSSUtteranceConfiguration with OSSSpeechEngine.") public class OSSUtterance: AVSpeechUtterance { // MARK: - Variables @@ -64,6 +65,10 @@ public class OSSUtterance: AVSpeechUtterance { // MARK: - Lifecycle + /// Creates an error placeholder utterance. + /// + /// Use ``init(string:)`` or ``init(attributedString:)`` to create a useful + /// utterance. public override init() { super.init(string: "ERROR") debugLog(object: self, message: "ERROR: You must use the `init(string:)` or `init(attributedString:` methods.") diff --git a/OSSSpeechKit/Classes/OSSVoice.swift b/OSSSpeechKit/Classes/OSSVoice.swift index 7beeb23..4569c4b 100755 --- a/OSSSpeechKit/Classes/OSSVoice.swift +++ b/OSSSpeechKit/Classes/OSSVoice.swift @@ -23,13 +23,10 @@ import Foundation import AVFoundation -#if canImport(UIKit) import UIKit -#elseif canImport(AppKit) -import AppKit -#endif -/// The voice infor struct ensures that the data structure has conformity and consistency. +/// Information about a resolved system voice. +@available(*, deprecated, message: "Use OSSLanguage and AVSpeechSynthesisVoice directly.") public struct OSSVoiceInfo { /// The name of the voice; All AVSpeechSynthesisVoice instances have a persons name. public var name: String? @@ -37,8 +34,11 @@ public struct OSSVoiceInfo { public var language: String? /// The language code is what is internationally used in Locale settings. public var languageCode: String? - /// Identifier is a unique bundle url provided by Apple for each AVSpeechSynthesisVoice. + /// Identifier is a unique bundle URL provided by Apple for each voice. + @available(*, deprecated, message: "Use voiceIdentifier.") public var identifier: Any? + /// Typed identifier for the resolved AVSpeechSynthesisVoice. + public var voiceIdentifier: String? } // swiftlint:disable identifier_name /// The available system voices. @@ -47,6 +47,7 @@ public struct OSSVoiceInfo { /// /// OSSVoiceEnum.allCases /// +@available(*, deprecated, message: "Use OSSLanguage.catalog and runtime capability APIs.") public enum OSSVoiceEnum: String, CaseIterable { /// Australian case Australian = "en-AU" @@ -150,6 +151,7 @@ public enum OSSVoiceEnum: String, CaseIterable { if #available(iOS 9.0, *) { voiceInfo.name = voice.name voiceInfo.identifier = voice.identifier + voiceInfo.voiceIdentifier = voice.identifier } voiceInfo.languageCode = rawValue voiceInfo.language = "\(self)" @@ -266,26 +268,21 @@ public enum OSSVoiceEnum: String, CaseIterable { } } - /// The flag for the selected language. + /// The legacy image flag for the selected language. /// - /// You can supply your own flag image, provided is has the same name (.rawValue) as the image in the pod assets. + /// You can supply your own flag image, provided it has the same name (`rawValue`) as the packaged image. /// /// If no image is found in the application bundle, the image from the SDK bundle will be provided. -#if canImport(UIKit) + @available(*, deprecated, message: "Use flagEmoji for text or renderedFlagImage(pointSize:scale:) for UIKit images.") public var flag: UIImage? { if let mainBundleImage = UIImage(named: rawValue, in: Bundle.main, compatibleWith: nil) { return mainBundleImage } - return UIImage(named: rawValue, in: Bundle.getResourcesBundle(), compatibleWith: nil) + if let bundledImage = UIImage(named: rawValue, in: Bundle.getResourcesBundle(), compatibleWith: nil) { + return bundledImage + } + return renderedFlagImage() } -#elseif canImport(AppKit) - public var flag: NSImage? { - if let mainBundleImage = NSImage(named: rawValue) { - return mainBundleImage - } - return NSImage(named: rawValue) - } -#endif } /** OSSVoice overides some of the properties provided to enable setting as well as getting. @@ -296,8 +293,8 @@ public enum OSSVoiceEnum: String, CaseIterable { - Note: If init() is called, the default quality of OSSVoice will us "default" and the language will be "OSSVoiceEnum.UnitedStatesEnglish". */ -@available(iOS 9.0, *) -public class OSSVoice: AVSpeechSynthesisVoice { +@available(*, deprecated, message: "Use OSSVoiceConfiguration, which resolves a real AVSpeechSynthesisVoice.") +public class OSSVoice: AVSpeechSynthesisVoice, @unchecked Sendable { // MARK: - Private Properties diff --git a/OSSSpeechKit/PrivacyInfo.xcprivacy b/OSSSpeechKit/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5397adc --- /dev/null +++ b/OSSSpeechKit/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/Package.swift b/Package.swift index 888f17d..1f4ae25 100644 --- a/Package.swift +++ b/Package.swift @@ -1,63 +1,46 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 6.0 import PackageDescription let package = Package( name: "OSSSpeechKit", platforms: [ - .iOS(.v13), - .tvOS(.v13), - .macOS(.v11) + .iOS(.v17) ], products: [ .library( name: "OSSSpeechKit", - targets: ["OSSSpeechKit"]), - .library( - name: "OSSSpeechKit-Static", - type: .static, - targets: ["OSSSpeechKit"]), - .library( - name: "OSSSpeechKit-Dynamic", - type: .dynamic, - targets: ["OSSSpeechKit"]) + targets: ["OSSSpeechKit"] + ) ], - - // MARK: - Targets targets: [ - // // MARK: - OSSSpeachKit .target( name: "OSSSpeechKit", - path: "OSSSpeechKit/", - sources: [ - "Classes/OSSSpeech.swift", - "Classes/OSSSpeechUtility.swift", - "Classes/OSSUtterance.swift", - "Classes/OSSVoice.swift" - ], + path: "OSSSpeechKit", + sources: ["Classes"], resources: [ - .process("Assets/") + .process("Assets"), + .copy("PrivacyInfo.xcprivacy") ], linkerSettings: [ - .linkedFramework("AVFoundation"), - .linkedFramework("AppKit", .when(platforms: [.macOS])), - .linkedFramework("Speech", .when(platforms: [.iOS])), - .linkedFramework("UIKit", .when(platforms: [.iOS])) + .linkedFramework("AVFoundation"), + .linkedFramework("Speech"), + .linkedFramework("UIKit") ] ), - - .testTarget( - name: "OSSSpeechKitTests", - dependencies: [ - "OSSSpeechKit" - ], - path: "Example/Tests", - exclude: [ - "Info.plist" - ], - linkerSettings: [ - .linkedFramework("AVKit") - ] - )], - swiftLanguageVersions: [.v5] + .testTarget( + name: "OSSSpeechKitTests", + dependencies: ["OSSSpeechKit"], + path: "Example/Tests", + exclude: ["Info.plist", "RecordingSessionModelTests.swift"], + resources: [ + .process("LocalizableTests.strings") + ], + linkerSettings: [ + .linkedFramework("AVFoundation"), + .linkedFramework("Speech"), + .linkedFramework("UIKit") + ] + ) + ], + swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 6e79f8b..f001698 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,199 @@ # OSSSpeechKit -[![OSSSpeechKit Logo](https://appdevguy.github.io/OSSSpeechKit/OSSSpeechKit-Logo.png)](https://github.com/AppDevGuy/OSSSpeechKit) +OSSSpeechKit is an iOS library for speech synthesis and speech recognition built on Apple's `AVFoundation` and `Speech` frameworks. -[![Build Status](https://travis-ci.org/AppDevGuy/OSSSpeechKit.svg?branch=master)](https://travis-ci.org/AppDevGuy/OSSSpeechKit) -[![Version](https://img.shields.io/cocoapods/v/OSSSpeechKit.svg?style=flat)](https://cocoapods.org/pods/OSSSpeechKit) -[![License](https://img.shields.io/cocoapods/l/OSSSpeechKit.svg?style=flat)](https://cocoapods.org/pods/OSSSpeechKit) -[![Platform](https://img.shields.io/cocoapods/p/OSSSpeechKit.svg?style=flat)](https://cocoapods.org/pods/OSSSpeechKit) -[![codecov](https://codecov.io/gh/AppDevGuy/OSSSpeechKit/branch/master/graph/badge.svg)](https://codecov.io/gh/AppDevGuy/OSSSpeechKit) -[![docs](https://appdevguy.github.io/OSSSpeechKit/badge.svg)](https://appdevguy.github.io/OSSSpeechKit) +## Requirements -OSSSpeechKit was developed to provide easier accessibility options to apps. +- iOS 17 or later +- Swift 5 language mode or later +- Xcode with Swift 6 package support -Apple does not make it easy to get the right voice, nor do they provide a simple way of selecting a language or using speech to text. OSSSpeechKit makes the hassle of trying to find the right language go away. +## Installation -# Requirements +In Xcode, choose **File > Add Package Dependencies**, enter: -- Swift 5.0 or higher -- iOS 13.0 or higher -- Cocoapods - -# Supported Languages - -The table below shows the original 37 languages first supported. Since v0.3.3, an additional 10 languages have been added. - -
English - Australian
🇦🇺
Hebrew
🇮🇱
Japanese
🇯🇵
Romanian
🇷🇴
Swedish
🇸🇪
Norsk
🇳🇴
Portuguese - Brazilian
🇧🇷
Hindi - Indian
🇮🇳
Korean
🇰🇷
Russian
🇷🇺
Chinese - Taiwanese
🇹🇼
Dutch - Belgium
🇧🇪
French - Canadian
🇨🇦
Hungarian
🇭🇺
Spanish - Mexican
🇲🇽
Arabic - Saudi Arabian
🇸🇦
Thai
🇹🇭
French
🇫🇷
Chinese
🇨🇳
Indonesian
🇮🇩
Norwegian
🇳🇴
Slovakian
🇸🇰
Turkish
🇹🇷
Finnish
🇫🇮
Chinese - Hong Kong
🇭🇰
English - Irish
🇮🇪
Polish
🇵🇱
English - South African
🇿🇦
English - United States
🇺🇸
Danish
🇩🇰
Czech
🇨🇿
Italian
🇮🇹
Portuguese
🇵🇹
Spanish
🇪🇸
English
🇬🇧
Dutch
🇳🇱
Greek
🇬🇷
- -# Features - -OSSSpeechKit offers simple **text to speech** and **speech to text** in 47 different languages. - -OSSSpeechKit is built on top of both the [AVFoundation](https://developer.apple.com/documentation/avfoundation) and [Speech](https://developer.apple.com/documentation/speech) frameworks. - -You can achieve text to speech or speech to text in as little as two lines of code. - -The speech will play over the top of other sounds such as music. - -# Installation - -OSSSpeechKit is available through [CocoaPods](https://cocoapods.org). To install it, simply add the following line to your Podfile: - -```ruby -pod 'OSSSpeechKit' +```text +https://github.com/AppDevGuy/OSSSpeechKit.git ``` -# Implementation - -## Text to Speech - -These methods enable you to pass in a string and hear the text played back using. +Select version `1.0.0` or later and add the `OSSSpeechKit` library product to your app target. -### Simple +You can also add it to `Package.swift`: ```swift -import OSSSpeechKit +dependencies: [ + .package( + url: "https://github.com/AppDevGuy/OSSSpeechKit.git", + from: "1.0.0" + ) +] +``` -..... +Swift Package Manager is the only supported installation method. The historical CocoaPods release `0.3.3` remains available for existing builds but is no longer maintained; version 1.0 and later are not published to CocoaPods. -// Declare an instance of OSSSpeechKit -let speechKit = OSSSpeech.shared -// Set the voice you wish to use - currently upper case for formality or language and country name -speechKit.voice = OSSVoice(quality: .enhanced, language: .Australian) -// Set the text in the language you have set -speechKit.speakText(text: "Hello, my name is OSSSpeechKit.") -``` +## Speech synthesis -### Advanced +`OSSSpeechEngine` is main-actor isolated and instance based. Select a language from the catalog, confirm that a voice exists on the current device, then speak: ```swift +import AVFoundation import OSSSpeechKit -..... - -// Declare an instance of OSSSpeechKit -let speechKit = OSSSpeech.shared -// Create a voice instance -let newVoice = OSSVoice() -// Set the language -newVoice.language = OSSVoiceEnum.Australian.rawValue -// Set the voice quality -newVoice.quality = .enhanced -// Set the voice of the speech kit -speechKit.voice = newVoice -// Initialise an utterance -let utterance = OSSUtterance(string: "Testing") -// Set the recognition task type -speechKit.recognitionTaskType = .dictation -// Set volume -utterance.volume = 0.5 -// Set rate of speech -utterance.rate = 0.5 -// Set the pitch -utterance.pitchMultiplier = 1.2 -// Set speech utterance -speechKit.utterance = utterance -// Ask to speak -speechKit.speakText(text: utterance.speechString) -``` - -## Speech to Text - -Currently speech to text is offered in a very simple format. Starting and stopping of recording is handled by the app. - -### iOS 13 On-Device Speech to Text support is now available as of 0.3.0 🎉 +let engine = OSSSpeechEngine() +let language = OSSLanguage.catalog.first { + $0.id == "english-australia" +}! -SpeechKit implements delegates to handle the recording authorization, output of text and failure to record. +guard !language.availableSynthesisVoices.isEmpty else { + // Ask the user to install a compatible system voice. + return +} -```swift -speechKit.delegate = self -// Call to start and end recording. -speechKit.recordVoice() -// Call to end recording -speechKit.endVoiceRecording() +try await engine.speak( + "Hello from OSSSpeechKit.", + voice: OSSVoiceConfiguration( + language: language, + preferredQuality: .enhanced + ), + configuration: OSSUtteranceConfiguration( + rate: AVSpeechUtteranceDefaultSpeechRate, + pitchMultiplier: 1, + volume: 1 + ) +) ``` -It is important that you have included in your `info.plist` the following: - -> Privacy - Speech Recognition Usage Description - -> Privacy - Microphone Usage Description - -Without these, you will not be able to access the microphone nor speech recognition. - -### Delegates +Use `pauseSpeaking()`, `continueSpeaking()`, and `stopSpeaking()` to control playback. -Handle returning authentication status to user - primary use is for non-authorized state. +## Speech recognition -> `func authorizationToMicrophone(withAuthentication type: OSSSpeechKitAuthorizationStatus)` +Add both usage descriptions to the consuming app's `Info.plist`: -When the microphone has finished accepting audio, this delegate will be called with the final best text output. - -> `func didFailToCommenceSpeechRecording()` - -If the speech recogniser and request fail to set up, this method will be called. - -> `func didFinishListening(withText text: String)` - -For further information you can [check out the Apple documentation directly.](https://developer.apple.com/documentation/speech/sfspeechrecognizer) - -# Other Features - -### List all available voices: - -```swift -let allLanguages = OSSVoiceEnum.allCases +```xml +NSMicrophoneUsageDescription +Explain why your app records audio. +NSSpeechRecognitionUsageDescription +Explain why your app transcribes speech. ``` -### Get specific voice information: +Then consume recognition events: ```swift -// All support languages -let allVoices = OSSVoiceEnum.allCases -// Language details -let languageInformation = allVoices[0].getDetails() -// Flag of country -let flag = allVoices[0].flag +let engine = OSSSpeechEngine() + +do { + let events = try await engine.recognitionEvents( + locale: Locale(identifier: "en-AU") + ) + + for try await event in events { + switch event { + case .partial(let text): + print("Partial:", text) + case .completed(let text): + print("Final:", text) + case .availabilityChanged(let available): + print("Available:", available) + case .cancelled: + break + } + } +} catch { + print("Recognition failed:", error) +} ``` -The `getDetails()` method returns a struct containing: +Call `cancelRecognition()` to stop an active session. Set `usesOnDeviceRecognition = true` before starting if your product requires on-device recognition; the operation fails when the selected recognizer does not support it. + +For a dictation-style session that stays open across verbal pauses, consume the +continuous event stream: ```swift -OSSVoiceInfo { - /// The name of the voice; All AVSpeechSynthesisVoice instances have a persons name. - var name: String? - /// The name of the language being used. - var language: String? - /// The language code is what is internationally used in Locale settings. - var languageCode: String? - /// Identifier is a unique bundle url provided by Apple for each AVSpeechSynthesisVoice. - var identifier: Any? +let events = try await engine.continuousRecognitionEvents( + locale: Locale(identifier: "en-AU") +) + +for try await event in events { + switch event { + case .transcript(let transcript): + print(transcript.formattedText) + for segment in transcript.segments { + print(segment.timestamp, segment.duration, segment.text) + } + case .paused: + print("Capture paused") + case .resumed: + print("Capture resumed") + case .completed, .cancelled: + break + case .availabilityChanged(let available): + print("Available:", available) + } } ``` -### Other Info - -The `OSSVoiceEnum` contains other methods, such as a hello message, title variable and subtitle variable so you can use it in a list. - -You can also set the speech: - -- volume -- pitchMultiplier -- rate +Use `pauseRecognition()` and `resumeRecognition()` to suspend and restart +microphone capture while retaining the same event stream. Call +`finishRecognition()` to end successfully, or `cancelRecognition()` to cancel. +Segment timestamps use active recording time, so time spent paused is excluded. +The API transcribes live microphone input; it does not save an audio file. -As well as using an `NSAttributedString`. +## Languages, voices, and flags -There are plans to implement flags for each country as well as some more features, such as being able to play the voice if the device is on silent. +`OSSLanguage.catalog` is display metadata, not a guarantee of runtime support. Availability varies by OS release, device, installed voice assets, region, network access, and Apple's services. -If the language or voice you require is not available, this is either due to: +The catalog was reconciled against Apple's current +[VoiceOver language support](https://support.apple.com/en-us/111748) and +[feature availability](https://www.apple.com/ios/feature-availability/) pages. +Exact synthesis voices come from +[`AVSpeechSynthesisVoice.speechVoices()`](https://developer.apple.com/documentation/avfaudio/avspeechsynthesisvoice/speechvoices()) +and recognition locales come from +[`SFSpeechRecognizer.supportedLocales()`](https://developer.apple.com/documentation/speech/sfspeechrecognizer/supportedlocales()); +those runtime results are authoritative. -- Apple have not made it available through their AVFoundation; -- or the SDK has not been updated to include the newly added voice. - -# Important Information - -Apple do not make the voice of Siri available for use. - -This kit provides Apple's AVFoundation voices available and easy to use, so you do not need to know all the voice codes, among many other things. - -To say things correctly in each language, you need to set the voice to the correct language and supply that languages text; this SDK is not a translator. - -### Code Example: - -You wish for you app to use a Chinese voice, you will need to ensure the text being passed in is Chinese. - -_Disclaimer: I do not know how to speak Chinese, I have used Google translate for the Chinese characters._ - -#### Correct: - -```swift -speechKit.voice = OSSVoice(quality: .enhanced, language: .Chinese) -speechKit.speakText(text: "你好我的名字是 ...") -``` - -#### Incorrect: +Check capabilities when presenting an option: ```swift -speechKit.voice = OSSVoice(quality: .enhanced, language: .Australian) -speechKit.speakText(text: "你好我的名字是 ...") +for language in OSSLanguage.catalog { + let canSpeak = !language.availableSynthesisVoices.isEmpty + let canRecognize = language.supportsRecognition + let flag = language.flagEmoji + print(flag, language.name, canSpeak, canRecognize) +} ``` -OR +For UIKit code that needs an image, use: ```swift -speechKit.voice = OSSVoice(quality: .enhanced, language: .Chinese) -speechKit.speakText(text: "Hello, my name is ...") +let image = language.renderedFlagImage(pointSize: 24) ``` -This same principle applies to all other languages such as German, Saudi Arabian, French, etc.. Failing to set the language for the text you wish to be spoken will not sound correct. +Speech synthesis is not translation. Supply text written for the selected language and locale. -# Contributions and Queries +## Privacy -If you have a question, please create a ticket or email me directly. +OSSSpeechKit requests microphone and speech-recognition permission only when your app asks it to. Your app is responsible for clear `NSMicrophoneUsageDescription` and `NSSpeechRecognitionUsageDescription` strings and for disclosing its own data practices. -If you wish to contribute, please create a pull request. +Speech recognition may use Apple services unless on-device recognition is requested and supported. Review Apple's current Speech framework and App Store privacy requirements for your use case. -# Example Project +The included `PrivacyInfo.xcprivacy` declares that OSSSpeechKit itself does not track users, collect data, or access a required-reason API. It does not replace the consuming app's privacy manifest or App Privacy answers. -To run the example project, clone the repo, and run `pod install` from the Example directory first. +## Migrating from pre-1.0 -# Unit Tests +The singleton and subclass-based APIs remain temporarily available but are deprecated: -For further examples, please look at the Unit Test class. +- `OSSSpeech.shared` → create an `OSSSpeechEngine` +- `OSSVoice` → `OSSVoiceConfiguration` +- `OSSUtterance` → `OSSUtteranceConfiguration` +- `OSSVoiceEnum.flag` → `flagEmoji` or `renderedFlagImage(pointSize:scale:)` +- `OSSVoiceEnum` catalogs → `OSSLanguage.catalog` -# Author +See [MIGRATION.md](MIGRATION.md) for examples and behavior changes. -App Dev Guy +## Example and tests -profile for App Dev Guy at Stack Overflow, Q&A for professional and enthusiast programmers +Open `Example/OSSSpeechKit.xcodeproj` to run the example app and its tests. The project links the package from the repository root. -# License +## License -OSSSpeechKit is available under the MIT license. See the LICENSE file for more info. +OSSSpeechKit is available under the MIT license. See [LICENSE](LICENSE). diff --git a/_Pods.xcodeproj b/_Pods.xcodeproj deleted file mode 120000 index 3c5a8e7..0000000 --- a/_Pods.xcodeproj +++ /dev/null @@ -1 +0,0 @@ -Example/Pods/Pods.xcodeproj \ No newline at end of file diff --git a/docs/Classes.html b/docs/Classes.html index c1f50c0..b1f9c19 100644 --- a/docs/Classes.html +++ b/docs/Classes.html @@ -8,20 +8,28 @@ + + +
-

OSSSpeechKit 0.3.0 Docs (95% documented)

-

View on GitHub

+

OSSSpeechKit 1.0.0 Docs (100% documented)

+

GitHubView on GitHub

+
+
+ +
+
@@ -34,6 +42,12 @@ + + @@ -48,12 +62,18 @@