The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | ![Demo](https://github.com/user-attachments/assets/326de51d-5a58-4c96-9d6c-98b07e6d9e58)
 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 | 


--------------------------------------------------------------------------------