├── .github ├── FUNDING.yml └── workflows │ └── Release.yml ├── .gitignore ├── .swiftformat ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── yap ├── Extensions └── AttributedString+Extensions.swift ├── OutputFormat.swift ├── Transcribe.swift └── Yap.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: finnvoor 2 | -------------------------------------------------------------------------------- /.github/workflows/Release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-15 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Build 14 | run: swift build -c release --arch arm64 --arch x86_64 --product yap 15 | - name: Compress 16 | run: tar -czf yap-${{ github.ref_name }}.tar.gz -C .build/apple/Products/Release yap 17 | - name: Release 18 | run: gh release create ${{ github.ref_name }} yap-${{ github.ref_name }}.tar.gz 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | homebrew: 22 | needs: release 23 | name: Bump Homebrew formula 24 | runs-on: ubuntu-latest 25 | environment: Env 26 | steps: 27 | - uses: mislav/bump-homebrew-formula-action@v3 28 | with: 29 | homebrew-tap: finnvoor/homebrew-tools 30 | download-url: https://github.com/finnvoor/yap/releases/download/${{ github.ref_name }}/yap-${{ github.ref_name }}.tar.gz 31 | env: 32 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 5.9 2 | --enable blankLineAfterImports 3 | --enable blankLinesBetweenImports 4 | --enable blockComments 5 | --enable docComments 6 | --enable isEmpty 7 | --enable markTypes 8 | --enable organizeDeclarations 9 | 10 | --disable numberFormatting 11 | --disable redundantNilInit 12 | --disable trailingCommas 13 | --disable wrapMultilineStatementBraces 14 | 15 | --ifdef no-indent 16 | --funcattributes same-line 17 | --typeattributes same-line 18 | --computedvarattrs same-line 19 | --storedvarattrs same-line 20 | --ranges no-space 21 | --header strip 22 | --selfrequired log,debug,info,notice,warning,trace,error,critical,fault 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | 123 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "faf209ad8d1ad39bb4730976bd4a097c64d1ff4d387edb79c87071e275c1be23", 3 | "pins" : [ 4 | { 5 | "identity" : "noora", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/tuist/Noora.git", 8 | "state" : { 9 | "revision" : "4858805eccada342572f7ccb0c5e4d78135c51f2", 10 | "version" : "0.40.1" 11 | } 12 | }, 13 | { 14 | "identity" : "path", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/tuist/path", 17 | "state" : { 18 | "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", 19 | "version" : "0.3.8" 20 | } 21 | }, 22 | { 23 | "identity" : "rainbow", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/onevcat/Rainbow", 26 | "state" : { 27 | "revision" : "0c627a4f8a39ef37eadec1ceec02e4a7f55561ac", 28 | "version" : "4.1.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-argument-parser", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-argument-parser.git", 35 | "state" : { 36 | "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", 37 | "version" : "1.5.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-log", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-log", 44 | "state" : { 45 | "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", 46 | "version" : "1.6.3" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "yap", 7 | platforms: [.macOS("26")], 8 | products: [ 9 | .executable(name: "yap", targets: ["yap"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), 13 | .package(url: "https://github.com/tuist/Noora.git", from: "0.40.1") 14 | ], 15 | targets: [ 16 | .executableTarget( 17 | name: "yap", 18 | dependencies: [ 19 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 20 | .product(name: "Noora", package: "Noora") 21 | ] 22 | ) 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗣️ yap 2 | 3 | A CLI for on-device speech transcription using [Speech.framework](https://developer.apple.com/documentation/speech) on macOS 26. 4 | 5 |  6 | 7 | ### Usage 8 | 9 | ``` 10 | USAGE: yap transcribe [--locale <locale>] [--censor] <input-file> [--txt] [--srt] [--output-file <output-file>] 11 | 12 | ARGUMENTS: 13 | <input-file> Path to an audio or video file to transcribe. 14 | 15 | OPTIONS: 16 | -l, --locale <locale> (default: current) 17 | --censor Replaces certain words and phrases with a redacted form. 18 | --txt/--srt Output format for the transcription. (default: --txt) 19 | -o, --output-file <output-file> 20 | Path to save the transcription output. If not provided, 21 | output will be printed to stdout. 22 | -h, --help Show help information. 23 | ``` 24 | 25 | ### Installation 26 | 27 | #### Homebrew 28 | 29 | ```bash 30 | brew install finnvoor/tools/yap 31 | ``` 32 | 33 | #### Mint 34 | 35 | ```bash 36 | mint install finnvoor/yap 37 | ``` 38 | 39 | ### Examples 40 | 41 | #### Transcribe a YouTube video using yap and [yt-dlp](https://github.com/yt-dlp/yt-dlp) 42 | 43 | ```bash 44 | yt-dlp "https://www.youtube.com/watch?v=ydejkIvyrJA" -x --exec yap 45 | ``` 46 | 47 | #### Summarize a video using yap and [llm](https://llm.datasette.io/en/stable) 48 | 49 | ```bash 50 | yap video.mp4 | uvx llm -m mlx-community/Llama-3.2-1B-Instruct-4bit 'Summarize this transcript:' 51 | ``` 52 | 53 | #### Create SRT captions for a video 54 | 55 | ```bash 56 | yap video.mp4 --srt -o captions.srt 57 | ``` 58 | -------------------------------------------------------------------------------- /Sources/yap/Extensions/AttributedString+Extensions.swift: -------------------------------------------------------------------------------- 1 | import CoreMedia 2 | import Foundation 3 | import NaturalLanguage 4 | 5 | extension AttributedString { 6 | func sentences(maxLength: Int? = nil) -> [AttributedString] { 7 | let tokenizer = NLTokenizer(unit: .sentence) 8 | let string = String(characters) 9 | tokenizer.string = string 10 | let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map { 11 | ( 12 | $0, 13 | AttributedString.Index($0.lowerBound, within: self)! 14 | ..< 15 | AttributedString.Index($0.upperBound, within: self)! 16 | ) 17 | } 18 | let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in 19 | let sentence = self[sentenceRange] 20 | guard let maxLength, sentence.characters.count > maxLength else { 21 | return [sentenceRange] 22 | } 23 | 24 | let wordTokenizer = NLTokenizer(unit: .word) 25 | wordTokenizer.string = string 26 | var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map { 27 | AttributedString.Index($0.lowerBound, within: self)! 28 | ..< 29 | AttributedString.Index($0.upperBound, within: self)! 30 | } 31 | guard !wordRanges.isEmpty else { return [sentenceRange] } 32 | wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound 33 | wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound 34 | 35 | var ranges: [Range<AttributedString.Index>] = [] 36 | for wordRange in wordRanges { 37 | if let lastRange = ranges.last, 38 | self[lastRange].characters.count + self[wordRange].characters.count <= maxLength { 39 | ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound 40 | } else { 41 | ranges.append(wordRange) 42 | } 43 | } 44 | 45 | return ranges 46 | } 47 | 48 | return ranges.compactMap { range in 49 | let audioTimeRanges = self[range].runs.filter { 50 | !String(self[$0.range].characters) 51 | .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 52 | }.compactMap(\.audioTimeRange) 53 | guard !audioTimeRanges.isEmpty else { return nil } 54 | let start = audioTimeRanges.first!.start 55 | let end = audioTimeRanges.last!.end 56 | var attributes = AttributeContainer() 57 | attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange( 58 | start: start, 59 | end: end 60 | ) 61 | return AttributedString(self[range].characters, attributes: attributes) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/yap/OutputFormat.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import CoreMedia 3 | import Foundation 4 | 5 | enum OutputFormat: String, EnumerableFlag { 6 | case txt 7 | case srt 8 | 9 | // MARK: Internal 10 | 11 | var needsAudioTimeRange: Bool { 12 | switch self { 13 | case .srt: true 14 | default: false 15 | } 16 | } 17 | 18 | func text(for transcript: AttributedString) -> String { 19 | switch self { 20 | case .txt: 21 | return String(transcript.characters) 22 | case .srt: 23 | func format(_ timeInterval: TimeInterval) -> String { 24 | let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000) 25 | let s = Int(timeInterval) % 60 26 | let m = (Int(timeInterval) / 60) % 60 27 | let h = Int(timeInterval) / 60 / 60 28 | return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms) 29 | } 30 | 31 | return transcript.sentences(maxLength: 40).compactMap { (sentence: AttributedString) -> (CMTimeRange, String)? in 32 | guard let timeRange = sentence.audioTimeRange else { return nil } 33 | return (timeRange, String(sentence.characters)) 34 | }.enumerated().map { index, run in 35 | let (timeRange, text) = run 36 | return """ 37 | 38 | \(index + 1) 39 | \(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds)) 40 | \(text.trimmingCharacters(in: .whitespacesAndNewlines)) 41 | 42 | """ 43 | }.joined().trimmingCharacters(in: .whitespacesAndNewlines) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/yap/Transcribe.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import NaturalLanguage 3 | @preconcurrency import Noora 4 | import Speech 5 | 6 | // MARK: - Transcribe 7 | 8 | @MainActor struct Transcribe: AsyncParsableCommand { 9 | @Option( 10 | name: .shortAndLong, 11 | help: "(default: current)", 12 | transform: Locale.init(identifier:) 13 | ) var locale: Locale = .init(identifier: Locale.current.identifier) 14 | 15 | @Flag( 16 | help: "Replaces certain words and phrases with a redacted form." 17 | ) var censor: Bool = false 18 | 19 | @Argument( 20 | help: "Path to an audio or video file to transcribe.", 21 | transform: URL.init(fileURLWithPath:) 22 | ) var inputFile: URL 23 | 24 | @Flag( 25 | help: "Output format for the transcription.", 26 | ) var outputFormat: OutputFormat = .txt 27 | 28 | @Option( 29 | name: .shortAndLong, 30 | help: "Path to save the transcription output. If not provided, output will be printed to stdout.", 31 | transform: URL.init(fileURLWithPath:) 32 | ) var outputFile: URL? 33 | 34 | mutating func run() async throws { 35 | let piped = isatty(STDOUT_FILENO) == 0 36 | struct DevNull: StandardPipelining { func write(content _: String) {} } 37 | let noora = if piped { 38 | Noora(standardPipelines: .init(output: DevNull())) 39 | } else { 40 | Noora() 41 | } 42 | 43 | let supported = await SpeechTranscriber.supportedLocales 44 | guard supported.map({ $0.identifier(.bcp47) }).contains(locale.identifier(.bcp47)) else { 45 | noora.error(.alert("Locale \"\(locale.identifier)\" is not supported. Supported locales:\n\(supported.map(\.identifier))")) 46 | throw Error.unsupportedLocale 47 | } 48 | 49 | for locale in await AssetInventory.allocatedLocales { 50 | await AssetInventory.deallocate(locale: locale) 51 | } 52 | try await AssetInventory.allocate(locale: locale) 53 | 54 | let transcriber = SpeechTranscriber( 55 | locale: locale, 56 | transcriptionOptions: censor ? [.etiquetteReplacements] : [], 57 | reportingOptions: [], 58 | attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [] 59 | ) 60 | let modules: [any SpeechModule] = [transcriber] 61 | let installed = await Set(SpeechTranscriber.installedLocales) 62 | if !installed.map({ $0.identifier(.bcp47) }).contains(locale.identifier(.bcp47)) { 63 | if let request = try await AssetInventory.assetInstallationRequest(supporting: modules) { 64 | try await noora.progressBarStep( 65 | message: "Downloading required assets…" 66 | ) { @Sendable progressCallback in 67 | struct ProgressCallback: @unchecked Sendable { 68 | let callback: (Double) -> Void 69 | } 70 | let progressCallback = ProgressCallback(callback: progressCallback) 71 | Task { 72 | while !request.progress.isFinished { 73 | progressCallback.callback(request.progress.fractionCompleted) 74 | try? await Task.sleep(for: .seconds(0.1)) 75 | } 76 | } 77 | try await request.downloadAndInstall() 78 | } 79 | } 80 | } 81 | 82 | let analyzer = SpeechAnalyzer(modules: modules) 83 | 84 | let audioFile = try AVAudioFile(forReading: inputFile) 85 | let audioFileDuration: TimeInterval = Double(audioFile.length) / audioFile.processingFormat.sampleRate 86 | try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true) 87 | 88 | var transcript: AttributedString = "" 89 | 90 | var w = winsize() 91 | let terminalColumns = if ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) == 0 { 92 | max(Int(w.ws_col), 9) 93 | } else { 64 } 94 | 95 | try await noora.progressStep( 96 | message: "Transcribing audio using locale: \"\(locale.identifier)\"…", 97 | successMessage: "Audio transcribed using locale: \"\(locale.identifier)\"", 98 | errorMessage: "Failed to transcribe audio using locale: \"\(locale.identifier)\"", 99 | showSpinner: true 100 | ) { @Sendable progressHandler in 101 | for try await result in transcriber.results { 102 | await MainActor.run { 103 | transcript += result.text 104 | } 105 | let progress = max(min(result.resultsFinalizationTime.seconds / audioFileDuration, 1), 0) 106 | var percent = progress.formatted(.percent.precision(.fractionLength(0))) 107 | let oneHundredPercent = 1.0.formatted(.percent.precision(.fractionLength(0))) 108 | percent = String(String(repeating: " ", count: max(oneHundredPercent.count - percent.count, 0))) + percent 109 | let message = "[\(percent)] \(String(result.text.characters).trimmingCharacters(in: .whitespaces).prefix(terminalColumns - "⠋ [\(oneHundredPercent)] ".count))" 110 | progressHandler(message) 111 | } 112 | } 113 | 114 | if let outputFile { 115 | try outputFormat.text(for: transcript).write( 116 | to: outputFile, 117 | atomically: false, 118 | encoding: .utf8 119 | ) 120 | noora.success(.alert("Transcription written to \(outputFile.path)")) 121 | } 122 | 123 | if piped || outputFile == nil { 124 | print(outputFormat.text(for: transcript)) 125 | } 126 | } 127 | } 128 | 129 | // MARK: Transcribe.Error 130 | 131 | extension Transcribe { 132 | enum Error: Swift.Error { 133 | case unsupportedLocale 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/yap/Yap.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import NaturalLanguage 3 | @preconcurrency import Noora 4 | import Speech 5 | 6 | // MARK: - yap 7 | 8 | @main struct Yap: AsyncParsableCommand { 9 | static let configuration = CommandConfiguration( 10 | abstract: "A CLI for on-device speech transcription.", 11 | subcommands: [ 12 | Transcribe.self 13 | ], 14 | defaultSubcommand: Transcribe.self 15 | ) 16 | } 17 | --------------------------------------------------------------------------------