├── .gitignore ├── LICENSE ├── README.md ├── WhispererKeyboard.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── WhispererKeyboard.xcscheme │ └── keyboard.xcscheme ├── WhispererKeyboard ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Audio.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Transcription.swift ├── WhispererKeyboard.entitlements └── WhispererKeyboardApp.swift ├── example.jpg └── keyboard ├── Info.plist ├── KeyboardViewController.swift └── keyboard.entitlements /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | ## 3 | build/ 4 | DerivedData/ 5 | *.moved-aside 6 | *.xcuserstate 7 | 8 | # User interface state 9 | xcuserdata/ 10 | 11 | # Swift Package Manager 12 | .build/ 13 | 14 | # Carthage 15 | Carthage/Build 16 | 17 | # CocoaPods 18 | Pods/ 19 | Podfile.lock 20 | 21 | # Fastlane 22 | fastlane/report.xml 23 | fastlane/Preview.html 24 | fastlane/screenshots/**/*.png 25 | fastlane/test_output 26 | 27 | # Code Injection 28 | injected_container/ 29 | 30 | # macOS 31 | .DS_Store 32 | .AppleDouble 33 | .LSOverride 34 | 35 | # Other common ignores 36 | *.log 37 | *.swp 38 | *.bak 39 | *.tmp 40 | *~ 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Experimental project created for personal use. 2 | 3 | ### Custom iOS keyboard using OpenAI Whisperer API for speech-to-text conversion 4 | 5 | 1. Captures audio from microphone 6 | 2. sends to OpenAI API for transcription 7 | 3. Inserts result into the active text edit 8 | 9 | 10 | ### Example using in Notes, comparison with iOS built-in transcription 11 | 12 | 13 | -------------------------------------------------------------------------------- /WhispererKeyboard.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 5F5263092AB027D90087E46B /* WhispererKeyboardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5263082AB027D90087E46B /* WhispererKeyboardApp.swift */; }; 11 | 5F52630D2AB027DA0087E46B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5F52630C2AB027DA0087E46B /* Assets.xcassets */; }; 12 | 5F5263102AB027DA0087E46B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5F52630F2AB027DA0087E46B /* Preview Assets.xcassets */; }; 13 | 5F52631D2AB029F60087E46B /* KeyboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F52631C2AB029F60087E46B /* KeyboardViewController.swift */; }; 14 | 5F5263212AB029F60087E46B /* keyboard.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5F52631A2AB029F60087E46B /* keyboard.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 15 | 5F6738962ACB8BEF00F83932 /* Transcription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6738952ACB8BEF00F83932 /* Transcription.swift */; }; 16 | 5F6738982ACB8C8900F83932 /* Audio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6738972ACB8C8900F83932 /* Audio.swift */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXContainerItemProxy section */ 20 | 5F52631F2AB029F60087E46B /* PBXContainerItemProxy */ = { 21 | isa = PBXContainerItemProxy; 22 | containerPortal = 5F5262FD2AB027D90087E46B /* Project object */; 23 | proxyType = 1; 24 | remoteGlobalIDString = 5F5263192AB029F60087E46B; 25 | remoteInfo = keyboard; 26 | }; 27 | /* End PBXContainerItemProxy section */ 28 | 29 | /* Begin PBXCopyFilesBuildPhase section */ 30 | 5F5263252AB029F60087E46B /* Embed Foundation Extensions */ = { 31 | isa = PBXCopyFilesBuildPhase; 32 | buildActionMask = 2147483647; 33 | dstPath = ""; 34 | dstSubfolderSpec = 13; 35 | files = ( 36 | 5F5263212AB029F60087E46B /* keyboard.appex in Embed Foundation Extensions */, 37 | ); 38 | name = "Embed Foundation Extensions"; 39 | runOnlyForDeploymentPostprocessing = 0; 40 | }; 41 | /* End PBXCopyFilesBuildPhase section */ 42 | 43 | /* Begin PBXFileReference section */ 44 | 5F5263052AB027D90087E46B /* WhispererKeyboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WhispererKeyboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; 45 | 5F5263082AB027D90087E46B /* WhispererKeyboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhispererKeyboardApp.swift; sourceTree = ""; }; 46 | 5F52630C2AB027DA0087E46B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47 | 5F52630F2AB027DA0087E46B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 48 | 5F52631A2AB029F60087E46B /* keyboard.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = keyboard.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | 5F52631C2AB029F60087E46B /* KeyboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardViewController.swift; sourceTree = ""; }; 50 | 5F52631E2AB029F60087E46B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 51 | 5F5263342AB17A300087E46B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 52 | 5F5DF0812AB6398C00BAE883 /* WhispererKeyboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WhispererKeyboard.entitlements; sourceTree = ""; }; 53 | 5F5DF0822AB643FA00BAE883 /* keyboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = keyboard.entitlements; sourceTree = ""; }; 54 | 5F6738952ACB8BEF00F83932 /* Transcription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transcription.swift; sourceTree = ""; }; 55 | 5F6738972ACB8C8900F83932 /* Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Audio.swift; sourceTree = ""; }; 56 | /* End PBXFileReference section */ 57 | 58 | /* Begin PBXFrameworksBuildPhase section */ 59 | 5F5263022AB027D90087E46B /* Frameworks */ = { 60 | isa = PBXFrameworksBuildPhase; 61 | buildActionMask = 2147483647; 62 | files = ( 63 | ); 64 | runOnlyForDeploymentPostprocessing = 0; 65 | }; 66 | 5F5263172AB029F60087E46B /* Frameworks */ = { 67 | isa = PBXFrameworksBuildPhase; 68 | buildActionMask = 2147483647; 69 | files = ( 70 | ); 71 | runOnlyForDeploymentPostprocessing = 0; 72 | }; 73 | /* End PBXFrameworksBuildPhase section */ 74 | 75 | /* Begin PBXGroup section */ 76 | 5F5262FC2AB027D90087E46B = { 77 | isa = PBXGroup; 78 | children = ( 79 | 5F5263072AB027D90087E46B /* WhispererKeyboard */, 80 | 5F52631B2AB029F60087E46B /* keyboard */, 81 | 5F5263062AB027D90087E46B /* Products */, 82 | 5F5DF0862AB6589F00BAE883 /* Frameworks */, 83 | ); 84 | sourceTree = ""; 85 | }; 86 | 5F5263062AB027D90087E46B /* Products */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 5F5263052AB027D90087E46B /* WhispererKeyboard.app */, 90 | 5F52631A2AB029F60087E46B /* keyboard.appex */, 91 | ); 92 | name = Products; 93 | sourceTree = ""; 94 | }; 95 | 5F5263072AB027D90087E46B /* WhispererKeyboard */ = { 96 | isa = PBXGroup; 97 | children = ( 98 | 5F5DF0812AB6398C00BAE883 /* WhispererKeyboard.entitlements */, 99 | 5F5263342AB17A300087E46B /* Info.plist */, 100 | 5F5263082AB027D90087E46B /* WhispererKeyboardApp.swift */, 101 | 5F52630C2AB027DA0087E46B /* Assets.xcassets */, 102 | 5F52630E2AB027DA0087E46B /* Preview Content */, 103 | 5F6738952ACB8BEF00F83932 /* Transcription.swift */, 104 | 5F6738972ACB8C8900F83932 /* Audio.swift */, 105 | ); 106 | path = WhispererKeyboard; 107 | sourceTree = ""; 108 | }; 109 | 5F52630E2AB027DA0087E46B /* Preview Content */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | 5F52630F2AB027DA0087E46B /* Preview Assets.xcassets */, 113 | ); 114 | path = "Preview Content"; 115 | sourceTree = ""; 116 | }; 117 | 5F52631B2AB029F60087E46B /* keyboard */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 5F5DF0822AB643FA00BAE883 /* keyboard.entitlements */, 121 | 5F52631C2AB029F60087E46B /* KeyboardViewController.swift */, 122 | 5F52631E2AB029F60087E46B /* Info.plist */, 123 | ); 124 | path = keyboard; 125 | sourceTree = ""; 126 | }; 127 | 5F5DF0862AB6589F00BAE883 /* Frameworks */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | ); 131 | name = Frameworks; 132 | sourceTree = ""; 133 | }; 134 | /* End PBXGroup section */ 135 | 136 | /* Begin PBXNativeTarget section */ 137 | 5F5263042AB027D90087E46B /* WhispererKeyboard */ = { 138 | isa = PBXNativeTarget; 139 | buildConfigurationList = 5F5263132AB027DA0087E46B /* Build configuration list for PBXNativeTarget "WhispererKeyboard" */; 140 | buildPhases = ( 141 | 5F5263012AB027D90087E46B /* Sources */, 142 | 5F5263022AB027D90087E46B /* Frameworks */, 143 | 5F5263032AB027D90087E46B /* Resources */, 144 | 5F5263252AB029F60087E46B /* Embed Foundation Extensions */, 145 | ); 146 | buildRules = ( 147 | ); 148 | dependencies = ( 149 | 5F5263202AB029F60087E46B /* PBXTargetDependency */, 150 | ); 151 | name = WhispererKeyboard; 152 | packageProductDependencies = ( 153 | ); 154 | productName = WhispererKeyboard; 155 | productReference = 5F5263052AB027D90087E46B /* WhispererKeyboard.app */; 156 | productType = "com.apple.product-type.application"; 157 | }; 158 | 5F5263192AB029F60087E46B /* keyboard */ = { 159 | isa = PBXNativeTarget; 160 | buildConfigurationList = 5F5263222AB029F60087E46B /* Build configuration list for PBXNativeTarget "keyboard" */; 161 | buildPhases = ( 162 | 5F5263162AB029F60087E46B /* Sources */, 163 | 5F5263172AB029F60087E46B /* Frameworks */, 164 | 5F5263182AB029F60087E46B /* Resources */, 165 | ); 166 | buildRules = ( 167 | ); 168 | dependencies = ( 169 | ); 170 | name = keyboard; 171 | packageProductDependencies = ( 172 | ); 173 | productName = keyboard; 174 | productReference = 5F52631A2AB029F60087E46B /* keyboard.appex */; 175 | productType = "com.apple.product-type.app-extension"; 176 | }; 177 | /* End PBXNativeTarget section */ 178 | 179 | /* Begin PBXProject section */ 180 | 5F5262FD2AB027D90087E46B /* Project object */ = { 181 | isa = PBXProject; 182 | attributes = { 183 | BuildIndependentTargetsInParallel = 1; 184 | LastSwiftUpdateCheck = 1430; 185 | LastUpgradeCheck = 1500; 186 | TargetAttributes = { 187 | 5F5263042AB027D90087E46B = { 188 | CreatedOnToolsVersion = 14.3.1; 189 | }; 190 | 5F5263192AB029F60087E46B = { 191 | CreatedOnToolsVersion = 14.3.1; 192 | }; 193 | }; 194 | }; 195 | buildConfigurationList = 5F5263002AB027D90087E46B /* Build configuration list for PBXProject "WhispererKeyboard" */; 196 | compatibilityVersion = "Xcode 14.0"; 197 | developmentRegion = en; 198 | hasScannedForEncodings = 0; 199 | knownRegions = ( 200 | en, 201 | Base, 202 | ); 203 | mainGroup = 5F5262FC2AB027D90087E46B; 204 | packageReferences = ( 205 | ); 206 | productRefGroup = 5F5263062AB027D90087E46B /* Products */; 207 | projectDirPath = ""; 208 | projectRoot = ""; 209 | targets = ( 210 | 5F5263042AB027D90087E46B /* WhispererKeyboard */, 211 | 5F5263192AB029F60087E46B /* keyboard */, 212 | ); 213 | }; 214 | /* End PBXProject section */ 215 | 216 | /* Begin PBXResourcesBuildPhase section */ 217 | 5F5263032AB027D90087E46B /* Resources */ = { 218 | isa = PBXResourcesBuildPhase; 219 | buildActionMask = 2147483647; 220 | files = ( 221 | 5F5263102AB027DA0087E46B /* Preview Assets.xcassets in Resources */, 222 | 5F52630D2AB027DA0087E46B /* Assets.xcassets in Resources */, 223 | ); 224 | runOnlyForDeploymentPostprocessing = 0; 225 | }; 226 | 5F5263182AB029F60087E46B /* Resources */ = { 227 | isa = PBXResourcesBuildPhase; 228 | buildActionMask = 2147483647; 229 | files = ( 230 | ); 231 | runOnlyForDeploymentPostprocessing = 0; 232 | }; 233 | /* End PBXResourcesBuildPhase section */ 234 | 235 | /* Begin PBXSourcesBuildPhase section */ 236 | 5F5263012AB027D90087E46B /* Sources */ = { 237 | isa = PBXSourcesBuildPhase; 238 | buildActionMask = 2147483647; 239 | files = ( 240 | 5F6738982ACB8C8900F83932 /* Audio.swift in Sources */, 241 | 5F6738962ACB8BEF00F83932 /* Transcription.swift in Sources */, 242 | 5F5263092AB027D90087E46B /* WhispererKeyboardApp.swift in Sources */, 243 | ); 244 | runOnlyForDeploymentPostprocessing = 0; 245 | }; 246 | 5F5263162AB029F60087E46B /* Sources */ = { 247 | isa = PBXSourcesBuildPhase; 248 | buildActionMask = 2147483647; 249 | files = ( 250 | 5F52631D2AB029F60087E46B /* KeyboardViewController.swift in Sources */, 251 | ); 252 | runOnlyForDeploymentPostprocessing = 0; 253 | }; 254 | /* End PBXSourcesBuildPhase section */ 255 | 256 | /* Begin PBXTargetDependency section */ 257 | 5F5263202AB029F60087E46B /* PBXTargetDependency */ = { 258 | isa = PBXTargetDependency; 259 | target = 5F5263192AB029F60087E46B /* keyboard */; 260 | targetProxy = 5F52631F2AB029F60087E46B /* PBXContainerItemProxy */; 261 | }; 262 | /* End PBXTargetDependency section */ 263 | 264 | /* Begin XCBuildConfiguration section */ 265 | 5F5263112AB027DA0087E46B /* Debug */ = { 266 | isa = XCBuildConfiguration; 267 | buildSettings = { 268 | ALWAYS_SEARCH_USER_PATHS = NO; 269 | CLANG_ANALYZER_NONNULL = YES; 270 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 271 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 272 | CLANG_ENABLE_MODULES = YES; 273 | CLANG_ENABLE_OBJC_ARC = YES; 274 | CLANG_ENABLE_OBJC_WEAK = YES; 275 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 276 | CLANG_WARN_BOOL_CONVERSION = YES; 277 | CLANG_WARN_COMMA = YES; 278 | CLANG_WARN_CONSTANT_CONVERSION = YES; 279 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 280 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 281 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 282 | CLANG_WARN_EMPTY_BODY = YES; 283 | CLANG_WARN_ENUM_CONVERSION = YES; 284 | CLANG_WARN_INFINITE_RECURSION = YES; 285 | CLANG_WARN_INT_CONVERSION = YES; 286 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 287 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 288 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 289 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 290 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 291 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 292 | CLANG_WARN_STRICT_PROTOTYPES = YES; 293 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 294 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 295 | CLANG_WARN_UNREACHABLE_CODE = YES; 296 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 297 | COPY_PHASE_STRIP = NO; 298 | DEBUG_INFORMATION_FORMAT = dwarf; 299 | ENABLE_STRICT_OBJC_MSGSEND = YES; 300 | ENABLE_TESTABILITY = YES; 301 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 302 | GCC_C_LANGUAGE_STANDARD = gnu11; 303 | GCC_DYNAMIC_NO_PIC = NO; 304 | GCC_NO_COMMON_BLOCKS = YES; 305 | GCC_OPTIMIZATION_LEVEL = 0; 306 | GCC_PREPROCESSOR_DEFINITIONS = ( 307 | "DEBUG=1", 308 | "$(inherited)", 309 | ); 310 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 311 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 312 | GCC_WARN_UNDECLARED_SELECTOR = YES; 313 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 314 | GCC_WARN_UNUSED_FUNCTION = YES; 315 | GCC_WARN_UNUSED_VARIABLE = YES; 316 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 317 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 318 | MTL_FAST_MATH = YES; 319 | ONLY_ACTIVE_ARCH = YES; 320 | SDKROOT = iphoneos; 321 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 322 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 323 | }; 324 | name = Debug; 325 | }; 326 | 5F5263122AB027DA0087E46B /* Release */ = { 327 | isa = XCBuildConfiguration; 328 | buildSettings = { 329 | ALWAYS_SEARCH_USER_PATHS = NO; 330 | CLANG_ANALYZER_NONNULL = YES; 331 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 332 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 333 | CLANG_ENABLE_MODULES = YES; 334 | CLANG_ENABLE_OBJC_ARC = YES; 335 | CLANG_ENABLE_OBJC_WEAK = YES; 336 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 337 | CLANG_WARN_BOOL_CONVERSION = YES; 338 | CLANG_WARN_COMMA = YES; 339 | CLANG_WARN_CONSTANT_CONVERSION = YES; 340 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 341 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 342 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 343 | CLANG_WARN_EMPTY_BODY = YES; 344 | CLANG_WARN_ENUM_CONVERSION = YES; 345 | CLANG_WARN_INFINITE_RECURSION = YES; 346 | CLANG_WARN_INT_CONVERSION = YES; 347 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 348 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 349 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 350 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 351 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 352 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 353 | CLANG_WARN_STRICT_PROTOTYPES = YES; 354 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 355 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 356 | CLANG_WARN_UNREACHABLE_CODE = YES; 357 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 358 | COPY_PHASE_STRIP = NO; 359 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 360 | ENABLE_NS_ASSERTIONS = NO; 361 | ENABLE_STRICT_OBJC_MSGSEND = YES; 362 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 363 | GCC_C_LANGUAGE_STANDARD = gnu11; 364 | GCC_NO_COMMON_BLOCKS = YES; 365 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 366 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 367 | GCC_WARN_UNDECLARED_SELECTOR = YES; 368 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 369 | GCC_WARN_UNUSED_FUNCTION = YES; 370 | GCC_WARN_UNUSED_VARIABLE = YES; 371 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 372 | MTL_ENABLE_DEBUG_INFO = NO; 373 | MTL_FAST_MATH = YES; 374 | SDKROOT = iphoneos; 375 | SWIFT_COMPILATION_MODE = wholemodule; 376 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 377 | VALIDATE_PRODUCT = YES; 378 | }; 379 | name = Release; 380 | }; 381 | 5F5263142AB027DA0087E46B /* Debug */ = { 382 | isa = XCBuildConfiguration; 383 | buildSettings = { 384 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 385 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 386 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 387 | CODE_SIGN_ENTITLEMENTS = WhispererKeyboard/WhispererKeyboard.entitlements; 388 | CODE_SIGN_STYLE = Automatic; 389 | CURRENT_PROJECT_VERSION = 1; 390 | DEVELOPMENT_ASSET_PATHS = "\"WhispererKeyboard/Preview Content\""; 391 | DEVELOPMENT_TEAM = C9A7ZND9S4; 392 | ENABLE_PREVIEWS = YES; 393 | GENERATE_INFOPLIST_FILE = YES; 394 | INFOPLIST_FILE = WhispererKeyboard/Info.plist; 395 | INFOPLIST_KEY_NSMicrophoneUsageDescription = "Grant permission to access microphone"; 396 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 397 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 398 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 399 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 400 | LD_RUNPATH_SEARCH_PATHS = ( 401 | "$(inherited)", 402 | "@executable_path/Frameworks", 403 | ); 404 | MARKETING_VERSION = 1.0; 405 | PRODUCT_BUNDLE_IDENTIFIER = lcf.WhispererKeyboard; 406 | PRODUCT_NAME = "$(TARGET_NAME)"; 407 | SWIFT_EMIT_LOC_STRINGS = YES; 408 | SWIFT_VERSION = 5.0; 409 | TARGETED_DEVICE_FAMILY = "1,2"; 410 | }; 411 | name = Debug; 412 | }; 413 | 5F5263152AB027DA0087E46B /* Release */ = { 414 | isa = XCBuildConfiguration; 415 | buildSettings = { 416 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 417 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 418 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 419 | CODE_SIGN_ENTITLEMENTS = WhispererKeyboard/WhispererKeyboard.entitlements; 420 | CODE_SIGN_STYLE = Automatic; 421 | CURRENT_PROJECT_VERSION = 1; 422 | DEVELOPMENT_ASSET_PATHS = "\"WhispererKeyboard/Preview Content\""; 423 | DEVELOPMENT_TEAM = C9A7ZND9S4; 424 | ENABLE_PREVIEWS = YES; 425 | GENERATE_INFOPLIST_FILE = YES; 426 | INFOPLIST_FILE = WhispererKeyboard/Info.plist; 427 | INFOPLIST_KEY_NSMicrophoneUsageDescription = "Grant permission to access microphone"; 428 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 429 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 430 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 431 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 432 | LD_RUNPATH_SEARCH_PATHS = ( 433 | "$(inherited)", 434 | "@executable_path/Frameworks", 435 | ); 436 | MARKETING_VERSION = 1.0; 437 | PRODUCT_BUNDLE_IDENTIFIER = lcf.WhispererKeyboard; 438 | PRODUCT_NAME = "$(TARGET_NAME)"; 439 | SWIFT_EMIT_LOC_STRINGS = YES; 440 | SWIFT_VERSION = 5.0; 441 | TARGETED_DEVICE_FAMILY = "1,2"; 442 | }; 443 | name = Release; 444 | }; 445 | 5F5263232AB029F60087E46B /* Debug */ = { 446 | isa = XCBuildConfiguration; 447 | buildSettings = { 448 | CODE_SIGN_ENTITLEMENTS = keyboard/keyboard.entitlements; 449 | CODE_SIGN_STYLE = Automatic; 450 | CURRENT_PROJECT_VERSION = 1; 451 | DEVELOPMENT_TEAM = C9A7ZND9S4; 452 | GENERATE_INFOPLIST_FILE = YES; 453 | INFOPLIST_FILE = keyboard/Info.plist; 454 | INFOPLIST_KEY_CFBundleDisplayName = keyboard; 455 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 456 | LD_RUNPATH_SEARCH_PATHS = ( 457 | "$(inherited)", 458 | "@executable_path/Frameworks", 459 | "@executable_path/../../Frameworks", 460 | ); 461 | MARKETING_VERSION = 1.0; 462 | PRODUCT_BUNDLE_IDENTIFIER = lcf.WhispererKeyboard.keyboard; 463 | PRODUCT_NAME = "$(TARGET_NAME)"; 464 | SKIP_INSTALL = YES; 465 | SWIFT_EMIT_LOC_STRINGS = YES; 466 | SWIFT_VERSION = 5.0; 467 | TARGETED_DEVICE_FAMILY = "1,2"; 468 | }; 469 | name = Debug; 470 | }; 471 | 5F5263242AB029F60087E46B /* Release */ = { 472 | isa = XCBuildConfiguration; 473 | buildSettings = { 474 | CODE_SIGN_ENTITLEMENTS = keyboard/keyboard.entitlements; 475 | CODE_SIGN_STYLE = Automatic; 476 | CURRENT_PROJECT_VERSION = 1; 477 | DEVELOPMENT_TEAM = C9A7ZND9S4; 478 | GENERATE_INFOPLIST_FILE = YES; 479 | INFOPLIST_FILE = keyboard/Info.plist; 480 | INFOPLIST_KEY_CFBundleDisplayName = keyboard; 481 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 482 | LD_RUNPATH_SEARCH_PATHS = ( 483 | "$(inherited)", 484 | "@executable_path/Frameworks", 485 | "@executable_path/../../Frameworks", 486 | ); 487 | MARKETING_VERSION = 1.0; 488 | PRODUCT_BUNDLE_IDENTIFIER = lcf.WhispererKeyboard.keyboard; 489 | PRODUCT_NAME = "$(TARGET_NAME)"; 490 | SKIP_INSTALL = YES; 491 | SWIFT_EMIT_LOC_STRINGS = YES; 492 | SWIFT_VERSION = 5.0; 493 | TARGETED_DEVICE_FAMILY = "1,2"; 494 | }; 495 | name = Release; 496 | }; 497 | /* End XCBuildConfiguration section */ 498 | 499 | /* Begin XCConfigurationList section */ 500 | 5F5263002AB027D90087E46B /* Build configuration list for PBXProject "WhispererKeyboard" */ = { 501 | isa = XCConfigurationList; 502 | buildConfigurations = ( 503 | 5F5263112AB027DA0087E46B /* Debug */, 504 | 5F5263122AB027DA0087E46B /* Release */, 505 | ); 506 | defaultConfigurationIsVisible = 0; 507 | defaultConfigurationName = Release; 508 | }; 509 | 5F5263132AB027DA0087E46B /* Build configuration list for PBXNativeTarget "WhispererKeyboard" */ = { 510 | isa = XCConfigurationList; 511 | buildConfigurations = ( 512 | 5F5263142AB027DA0087E46B /* Debug */, 513 | 5F5263152AB027DA0087E46B /* Release */, 514 | ); 515 | defaultConfigurationIsVisible = 0; 516 | defaultConfigurationName = Release; 517 | }; 518 | 5F5263222AB029F60087E46B /* Build configuration list for PBXNativeTarget "keyboard" */ = { 519 | isa = XCConfigurationList; 520 | buildConfigurations = ( 521 | 5F5263232AB029F60087E46B /* Debug */, 522 | 5F5263242AB029F60087E46B /* Release */, 523 | ); 524 | defaultConfigurationIsVisible = 0; 525 | defaultConfigurationName = Release; 526 | }; 527 | /* End XCConfigurationList section */ 528 | }; 529 | rootObject = 5F5262FD2AB027D90087E46B /* Project object */; 530 | } 531 | -------------------------------------------------------------------------------- /WhispererKeyboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /WhispererKeyboard.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /WhispererKeyboard.xcodeproj/xcshareddata/xcschemes/WhispererKeyboard.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /WhispererKeyboard.xcodeproj/xcshareddata/xcschemes/keyboard.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 63 | 64 | 65 | 71 | 72 | 73 | 74 | 82 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /WhispererKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /WhispererKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /WhispererKeyboard/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WhispererKeyboard/Audio.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Audio.swift 3 | // WhispererKeyboard 4 | // 5 | // Created by Alexander Steshenko on 10/2/23. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | 12 | /// Records audio from microphone to a predefine file "recording.m4a". 13 | /// To get file name use function getFilename 14 | /// Use start() and stop() to operate the recorder 15 | class Audio { 16 | var recorder: AVAudioRecorder? 17 | 18 | class AudioRecorderDelegate: NSObject, AVAudioRecorderDelegate { 19 | var onError: ((Error?) -> Void)? 20 | 21 | func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { 22 | onError?(error) 23 | } 24 | } 25 | 26 | private let audioRecorderDelegate = AudioRecorderDelegate() 27 | 28 | let audioSettings: [String: Any] = [ 29 | AVFormatIDKey: Int(kAudioFormatMPEG4AAC), 30 | AVSampleRateKey: 44100, 31 | AVNumberOfChannelsKey: 1, 32 | AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue 33 | ] 34 | 35 | func stop() { 36 | recorder?.stop() 37 | } 38 | 39 | func start() { 40 | requestMicrophonePermission() // only requests permissions if not previously granted 41 | let audioSession = AVAudioSession.sharedInstance() 42 | do { 43 | try audioSession.setCategory(.record, mode: .default) 44 | try audioSession.setActive(true) 45 | } catch { 46 | print("Failed to set up audio session: \(error)") 47 | return 48 | } 49 | 50 | do { 51 | recorder = try AVAudioRecorder(url: getFilename(), settings: audioSettings) 52 | recorder?.delegate = audioRecorderDelegate 53 | recorder?.record() 54 | 55 | } catch { 56 | print("Could not start recording: \(error)") 57 | } 58 | } 59 | 60 | func getFilename() -> URL { 61 | return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("recording.m4a") 62 | } 63 | 64 | func requestMicrophonePermission() { 65 | let audioSession = AVAudioSession.sharedInstance() 66 | 67 | switch audioSession.recordPermission { 68 | case .granted: 69 | // Microphone permission already granted 70 | break 71 | case .denied: 72 | print("Microphone access has been denied.") 73 | case .undetermined: 74 | audioSession.requestRecordPermission { allowed in 75 | DispatchQueue.main.async { 76 | if !allowed { 77 | // Handle the denial. 78 | print("Microphone access was denied.") 79 | } 80 | } 81 | } 82 | @unknown default: 83 | print("Unknown microphone access status.") 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /WhispererKeyboard/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLSchemes 11 | 12 | WhispererKeyboardApp 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WhispererKeyboard/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WhispererKeyboard/Transcription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transcription.swift 3 | // WhispererKeyboard 4 | // 5 | // Created by Alexander Steshenko on 10/2/23. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Perform transcription of a given audio file using OpenAI Whisperer API 12 | /// Results are stored in shared group.WhispererKeyboardSharing storage 13 | /// 14 | /// Maintains internal "status" property to show status of transcription, useful when transcription takes a few seconds 15 | /// 16 | class Transcription : ObservableObject { 17 | 18 | enum TranscriptionStatus { 19 | case recording 20 | case transcribing 21 | case finished 22 | case error 23 | } 24 | 25 | // Default status, before transcription is called audio is recorded 26 | @Published var status: TranscriptionStatus = .recording 27 | 28 | // This shared container is necessary to pass data between the main app and the keyboard extension 29 | // Since it's not possible to access microphone from within the keyboard itself 30 | let sharedDefaults = UserDefaults(suiteName: "group.WhispererKeyboardSharing") 31 | 32 | func transcribe(_ audioFilename : URL) { 33 | self.status = .transcribing 34 | do { 35 | sendRequestToOpenAI(file: try Data(contentsOf: audioFilename)) { 36 | (result:Result) in 37 | switch result { 38 | case .success(let text): 39 | // On successful transcription using OpenAI Whisperer, store the results into shared storage 40 | // so that the Keyboard extension can find it and insert into the application under edit 41 | self.sharedDefaults?.set(text, forKey: "transcribedText") 42 | case .failure(let failure): 43 | print("\(failure.localizedDescription)") 44 | } 45 | DispatchQueue.main.async { 46 | self.status = .finished 47 | } 48 | } 49 | } catch { 50 | print(error) 51 | status = .error 52 | return 53 | } 54 | } 55 | 56 | // TODO: This should be moved to iOS secret management solution (not a real API key here) 57 | private let OPENAI_API_KEY = "s-kLYgLIU693MfwDxiEnX9TRlB3Fbk6dJzBCaPtuCI2I3kyoJu2" 58 | 59 | struct WhispererResponse: Codable { 60 | public let text: String 61 | } 62 | 63 | func sendRequestToOpenAI(file: Data, completion: @escaping (Result) -> Void) { 64 | let url = URL(string: "https://api.openai.com/v1/audio/transcriptions")! 65 | var request = URLRequest(url: url) 66 | 67 | request.httpMethod = "POST" 68 | request.addValue("application/json", forHTTPHeaderField: "Accept") 69 | request.addValue("multipart/form-data", forHTTPHeaderField: "Content-Type") 70 | request.setValue("Bearer \(OPENAI_API_KEY)", forHTTPHeaderField: "Authorization") 71 | request.addValue("gzip", forHTTPHeaderField: "Accept-Encoding") 72 | 73 | // Audio file is sent to OpenAI as multipart form data. There is probably an easier way to do this with a built-in library 74 | let boundary = UUID().uuidString 75 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 76 | 77 | var formData = Data() 78 | formData.append("--\(boundary)\r\n".data(using: .utf8)!) 79 | formData.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.m4a\"\r\n".data(using: .utf8)!) 80 | formData.append("\r\n".data(using: .utf8)!) 81 | formData.append(file) 82 | formData.append("\r\n".data(using: .utf8)!) 83 | 84 | // This specifies the model to use "whisper-1" 85 | formData.append("--\(boundary)\r\n".data(using: .utf8)!) 86 | formData.append("Content-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n".data(using: .utf8)!) 87 | 88 | formData.append("--\(boundary)--\r\n".data(using: .utf8)!) 89 | 90 | request.httpBody = formData 91 | 92 | // Below makes the http request and passes the resulting text to the callback function 93 | let task = URLSession.shared.dataTask(with: request) { (data, response, error) in 94 | do { 95 | let response = try JSONDecoder().decode(WhispererResponse.self, from: data!) 96 | completion(.success(response.text)) 97 | } catch let decodingError { 98 | 99 | completion(.failure(decodingError)) 100 | } 101 | } 102 | task.resume() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /WhispererKeyboard/WhispererKeyboard.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.WhispererKeyboardSharing 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /WhispererKeyboard/WhispererKeyboardApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhispererKeyboardApp.swift 3 | // WhispererKeyboard 4 | // 5 | // Created by Alexander Steshenko on 9/11/23. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | /// This is a full screen view that opens up when "Record audio" button is clicked in the keyboard extension 12 | /// When open, the app automatically begins recording. Once finished, the application requests transcriptiong using OpenAI Whisperer API 13 | /// The app then suggests the user to return to the app that had the keyboard open. Unfortunately found no way to return user automatically. 14 | @main 15 | struct WhispererKeyboardApp: App { 16 | 17 | // contains logic for capturing audio from the microphone and saving into a temporary file 18 | private var audio = Audio() 19 | 20 | // contains logic for sending data for transcription to OpenAI and storing results into shared app storage 21 | @StateObject private var transcription = Transcription() 22 | 23 | // Necessary to detect when application becomes active. Begin recording immediately 24 | @Environment(\.scenePhase) var scenePhase 25 | 26 | var body: some Scene { 27 | // Will show one clickable text at at the bottom of the screen to control recording 28 | // Positioned at the bottom so it's convenient to swipe back to the previous app 29 | WindowGroup { 30 | VStack { 31 | Spacer() 32 | Text(getTranscriptionStatusMessage()) 33 | .onTapGesture(count: 1, perform: { 34 | if self.transcription.status != .finished { 35 | // Request to transcribe is what stops the audio recording 36 | self.audio.stop() 37 | self.transcription.transcribe(audio.getFilename()) 38 | } 39 | }) 40 | } 41 | .padding() 42 | .onChange(of: scenePhase) { newPhase in 43 | if newPhase == .active { 44 | // When app loads, start recording immediately. 45 | // This auxiliary appliation is to workaround iOS restrictions to record audio from within keyboard extension 46 | transcription.status = .recording 47 | self.audio.start() 48 | } 49 | } 50 | } 51 | } 52 | 53 | func getTranscriptionStatusMessage() -> String { 54 | switch transcription.status { 55 | case .recording: 56 | return "Press to stop recording" 57 | case .transcribing: 58 | return "Transcribing ..." 59 | case .finished: 60 | return "Finished. Return to the application" 61 | case .error: 62 | return "Error. Try again later" 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcf/WhispererKeyboard/7d4bacdb944f2c1e4d8f4cc6a90b2608ba641237/example.jpg -------------------------------------------------------------------------------- /keyboard/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | IsASCIICapable 10 | 11 | PrefersRightToLeft 12 | 13 | PrimaryLanguage 14 | en-US 15 | RequestsOpenAccess 16 | 17 | 18 | NSExtensionPointIdentifier 19 | com.apple.keyboard-service 20 | NSExtensionPrincipalClass 21 | $(PRODUCT_MODULE_NAME).KeyboardViewController 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /keyboard/KeyboardViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardViewController.swift 3 | // keyboard 4 | // 5 | // Created by Alexander Steshenko on 9/11/23. 6 | // 7 | 8 | import UIKit 9 | import AVFoundation 10 | import SwiftUI 11 | 12 | /// Custom keyboard view. This keyboard only has one button in the center which begins recording once pressed 13 | /// Recording is performed by the main app "WhispererKeyboardApp" immediately when it opens 14 | /// After recording is processed by OpenAI, the keyboard inserts the text into the text edit field that is in focus 15 | class KeyboardViewController: UIInputViewController { 16 | 17 | // The following block is required to keep the height of the keyboard from fluctuating. 18 | var keyboardHeight: CGFloat = 155 19 | var KeyboardVCHeightConstraint: NSLayoutConstraint! 20 | var containerViewHeight: CGFloat = 0 21 | 22 | override func viewWillAppear(_ animated: Bool) { 23 | super.viewWillAppear(animated) 24 | self.view.removeConstraint(KeyboardVCHeightConstraint) 25 | self.view.addConstraint(self.KeyboardVCHeightConstraint) 26 | } 27 | 28 | // This shared container is necessary to pass data between the main app and the keyboard extension 29 | // Since it's not possible to access microphone from within the keyboard itself 30 | let sharedDefaults = UserDefaults(suiteName: "group.WhispererKeyboardSharing") 31 | 32 | // This function is called every time when keyboard becomes visible 33 | // It may be after transcription just finished in the main app 34 | // Keyboard extension checks if data is available in the shared container 35 | // and inserts it into the text edit field 36 | override func viewDidAppear(_ animated: Bool) { 37 | super.viewDidAppear(animated) 38 | 39 | // Whenever keyboard becomes visible on the screen, it may be after transcription has finished. 40 | if let transcribedText = sharedDefaults?.string(forKey: "transcribedText") { 41 | self.textDocumentProxy.insertText(transcribedText) 42 | sharedDefaults?.removeObject(forKey: "transcribedText") 43 | } 44 | } 45 | 46 | // The keyboard has the only button that opens the main app when clicked 47 | // Configuration of the button is in the viewDidLoad method 48 | let recordButton: UIButton = UIButton(type: .system) 49 | 50 | override func viewDidLoad() { 51 | super.viewDidLoad() 52 | // Keyboard height settings 53 | self.KeyboardVCHeightConstraint = NSLayoutConstraint( 54 | item: self.view!, attribute: .height, relatedBy: .equal, toItem: nil, 55 | attribute: .notAnAttribute, multiplier: 1, constant: keyboardHeight+containerViewHeight) 56 | 57 | // Add record button to the keyboard view 58 | self.view.addSubview(recordButton) 59 | recordButton.setTitle("Record audio", for: .normal) 60 | recordButton.sizeToFit() 61 | recordButton.translatesAutoresizingMaskIntoConstraints = false 62 | 63 | recordButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive=true 64 | recordButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive=true 65 | 66 | // Add tap gesture recognizer 67 | let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleButtonTap)) 68 | recordButton.addGestureRecognizer(tapGesture) 69 | 70 | 71 | } 72 | 73 | // The following block of functions was generated by ChatGPT. It opens the main application when the record button is tapped. 74 | 75 | // the Application scheme for interlinking is configered in Info.plist file or in XCode UI 76 | @objc func handleButtonTap() { 77 | self.openURL(url: NSURL(string:"WhispererKeyboardApp://")!) 78 | } 79 | 80 | func openURL(url: NSURL) -> Bool { 81 | do { 82 | let application = try self.sharedApplication() 83 | application.performSelector(inBackground: "openURL:", with: url) 84 | return true 85 | } 86 | catch { 87 | return false 88 | } 89 | } 90 | 91 | func sharedApplication() throws -> UIApplication { 92 | var responder: UIResponder? = self 93 | while responder != nil { 94 | if let application = responder as? UIApplication { 95 | return application 96 | } 97 | 98 | responder = responder?.next 99 | } 100 | 101 | throw NSError(domain: "UIInputViewController+sharedApplication.swift", code: 1, userInfo: nil) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /keyboard/keyboard.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.WhispererKeyboardSharing 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------