[{"data":1,"prerenderedAt":996},["ShallowReactive",2],{"profile":3,"projects:page:\u002Fprojects\u002Fopenwisper\u002Fpipeline":22,"projects:docs:\u002Fprojects\u002Fopenwisper":962,"structured-data:current-stack":982},{"id":4,"alias":5,"current":6,"email":10,"extension":11,"headline":12,"links":13,"location":17,"meta":18,"name":19,"stem":20,"__hash__":21},"profile\u002Fprofile.yml","readonly",{"role":7,"company":8,"companyUrl":9},"Fullstack Engineer — Tech Lead","DataCose","https:\u002F\u002Fdatacose.com","redonemini18@gmail.com","yml","Fullstack Engineer",{"github":14,"linkedin":15,"twitch":16},"https:\u002F\u002Fgithub.com\u002FRedoni18","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fredon-emini\u002F","https:\u002F\u002Fwww.twitch.tv\u002Fr3adonly","Pristina, Kosovo",{},"Redon Emini","profile","Fbb1IAp4nuUOOtgXdwpzcqSmG0CUKtZ4D0ifPqdK5QU",{"id":23,"title":24,"body":25,"description":947,"extension":948,"featured":949,"kind":950,"links":951,"meta":952,"navigation":953,"order":955,"path":956,"period":957,"preview":957,"seo":958,"stack":959,"stem":960,"year":957,"__hash__":961},"projects\u002Fprojects\u002Fopenwisper\u002F2.pipeline.md","Never losing what you said",{"type":26,"value":27,"toc":940},"minimark",[28,32,40,45,60,154,157,163,285,291,295,298,301,304,532,535,539,546,560,575,582,684,687,690,717,721,724,730,740,881,887,891,894,911,928,936],[29,30,31],"p",{},"Once you let go of the key, OpenWisper has an audio clip in memory and needs to turn it into text at your cursor. That takes three steps: transcribe, clean up, insert. Each one can fail in its own way. The model file might be missing, a cloud API might time out, or the paste might land in a window that isn't accepting text.",[29,33,34,35,39],{},"I built all of it around one rule: ",[36,37,38],"strong",{},"once you've spoken, no failure is allowed to lose your words."," A dictation can come out less polished than it should, but it should never vanish. This post goes through how each step keeps that promise.",[41,42,44],"h2",{"id":43},"step-1-transcription-local-by-default","Step 1: transcription, local by default",[29,46,47,48,52,53,55,56,59],{},"While you talk, an ",[49,50,51],"code",{},"AVAudioEngine"," tap collects 16 kHz mono samples into memory. (",[49,54,51],{}," is Apple's audio API, and a \"tap\" is a callback that receives the microphone's buffers as they arrive.) Nothing is written to disk. When you let go, the buffer goes to a ",[49,57,58],{},"Transcriber",":",[61,62,68],"pre",{"className":63,"code":64,"filename":65,"language":66,"meta":67,"style":67},"language-swift shiki shiki-themes vitesse-light vitesse-dark","public protocol Transcriber {\n    var name: String { get }\n    func transcribe(_ clip: AudioClip) async throws -> String\n}\n","Sources\u002FOpenWisperCore\u002FProtocols.swift","swift","",[49,69,70,91,114,148],{"__ignoreMap":67},[71,72,75,79,83,87],"span",{"class":73,"line":74},"line",1,[71,76,78],{"class":77},"sTPum","public",[71,80,82],{"class":81},"s5TCs"," protocol",[71,84,86],{"class":85},"s_NWU"," Transcriber",[71,88,90],{"class":89},"si6no"," {\n",[71,92,94,97,101,105,108,111],{"class":73,"line":93},2,[71,95,96],{"class":77},"    var",[71,98,100],{"class":99},"s8w-G"," name: ",[71,102,104],{"class":103},"sHLBJ","String",[71,106,107],{"class":89}," {",[71,109,110],{"class":81}," get",[71,112,113],{"class":89}," }\n",[71,115,117,120,124,127,130,133,136,139,142,145],{"class":73,"line":116},3,[71,118,119],{"class":81},"    func",[71,121,123],{"class":122},"s_xSY"," transcribe",[71,125,126],{"class":89},"(",[71,128,129],{"class":122},"_",[71,131,132],{"class":99}," clip: AudioClip",[71,134,135],{"class":89},")",[71,137,138],{"class":81}," async",[71,140,141],{"class":81}," throws",[71,143,144],{"class":81}," ->",[71,146,147],{"class":103}," String\n",[71,149,151],{"class":73,"line":150},4,[71,152,153],{"class":89},"}\n",[29,155,156],{},"There are two real implementations. The local one wraps whisper.cpp: it's compiled into the app, uses Metal to run on the GPU, and keeps the model loaded between dictations, so the only wait is the transcription itself. The cloud one sends the clip to Groq's or OpenAI's transcription API, for when you want a bigger model than your laptop can comfortably run.",[29,158,159,160,59],{},"Because the rest of the app only knows about the protocol, switching engines from the menu bar just swaps the object behind it. There's a subtlety, though. If you switch engines while an earlier dictation is still being processed, that dictation should finish with the engine it started with. So each dictation takes a snapshot of the engine, the cleaner and the settings it needs the moment it starts, before its first ",[49,161,162],{},"await",[61,164,167],{"className":63,"code":165,"filename":166,"language":66,"meta":67,"style":67},"private func runPipeline(clip: AudioClip, token: Int) async {\n    \u002F\u002F Snapshot before the first suspension: a live engine swap\n    \u002F\u002F must not change this utterance underneath us.\n    let transcriber = self.transcriber\n    let cleaner = self.cleaner\n    let cleanupTimeout = config.cleanup.timeoutSeconds\n    \u002F\u002F ...\n}\n","Sources\u002FSpine\u002FDictationController.swift",[49,168,169,203,209,214,236,253,274,280],{"__ignoreMap":67},[71,170,171,174,177,180,182,185,188,191,194,197,199,201],{"class":73,"line":74},[71,172,173],{"class":77},"private",[71,175,176],{"class":81}," func",[71,178,179],{"class":122}," runPipeline",[71,181,126],{"class":89},[71,183,184],{"class":122},"clip",[71,186,187],{"class":99},": AudioClip, ",[71,189,190],{"class":122},"token",[71,192,193],{"class":99},": ",[71,195,196],{"class":103},"Int",[71,198,135],{"class":89},[71,200,138],{"class":81},[71,202,90],{"class":89},[71,204,205],{"class":73,"line":93},[71,206,208],{"class":207},"snYqZ","    \u002F\u002F Snapshot before the first suspension: a live engine swap\n",[71,210,211],{"class":73,"line":116},[71,212,213],{"class":207},"    \u002F\u002F must not change this utterance underneath us.\n",[71,215,216,219,222,225,229,232],{"class":73,"line":150},[71,217,218],{"class":77},"    let",[71,220,221],{"class":99}," transcriber ",[71,223,224],{"class":81},"=",[71,226,228],{"class":227},"sfsYZ"," self",[71,230,231],{"class":99},".",[71,233,235],{"class":234},"s9nN2","transcriber\n",[71,237,239,241,244,246,248,250],{"class":73,"line":238},5,[71,240,218],{"class":77},[71,242,243],{"class":99}," cleaner ",[71,245,224],{"class":81},[71,247,228],{"class":227},[71,249,231],{"class":99},[71,251,252],{"class":234},"cleaner\n",[71,254,256,258,261,263,266,269,271],{"class":73,"line":255},6,[71,257,218],{"class":77},[71,259,260],{"class":99}," cleanupTimeout ",[71,262,224],{"class":81},[71,264,265],{"class":99}," config.",[71,267,268],{"class":234},"cleanup",[71,270,231],{"class":99},[71,272,273],{"class":234},"timeoutSeconds\n",[71,275,277],{"class":73,"line":276},7,[71,278,279],{"class":207},"    \u002F\u002F ...\n",[71,281,283],{"class":73,"line":282},8,[71,284,153],{"class":89},[29,286,287,288,290],{},"This is worth remembering for async code in any language: after an ",[49,289,162],{},", the world may have moved on. Anything you read from shared state after the first suspension point might not be what it was when the work began.",[41,292,294],{"id":293},"step-2-cleanup-thats-allowed-to-fail","Step 2: cleanup that's allowed to fail",[29,296,297],{},"Raw Whisper output is accurate, but it reads like speech: filler words, false starts, \"new paragraph\" spelled out. The optional cleanup step sends it through a short, strict LLM prompt that fixes punctuation, removes the filler, and turns spoken formatting into real formatting. The prompt tells the model never to rephrase what you said.",[29,299,300],{},"It's also the step most likely to go wrong. It's a network call to a third party, so it can be slow, it can error, or there may be no API key configured at all. That's why cleanup is treated as a bonus and never as a requirement. It runs against a timeout (6 seconds by default), and anything other than a clean, non-empty result means the raw transcript gets pasted instead.",[29,302,303],{},"The timeout is a race between two child tasks, using Swift's structured concurrency. Whichever finishes first wins, and the other one is cancelled:",[61,305,307],{"className":63,"code":306,"filename":166,"language":66,"meta":67,"style":67},"let winner = await withTaskGroup(of: String?.self) { group -> String? in\n    group.addTask {\n        try? await cleaner.clean(raw)          \u002F\u002F nil on any error\n    }\n    group.addTask {\n        try? await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000))\n        return nil                             \u002F\u002F timed out\n    }\n    let first = await group.next() ?? nil      \u002F\u002F first finisher decides\n    group.cancelAll()                          \u002F\u002F also cancels the HTTP request\n    return first\n}\nreturn winner ?? raw\n",[49,308,309,360,370,395,400,408,447,458,462,491,504,513,518],{"__ignoreMap":67},[71,310,311,314,317,319,322,325,327,330,332,335,338,340,343,345,347,350,353,355,357],{"class":73,"line":74},[71,312,313],{"class":77},"let",[71,315,316],{"class":99}," winner ",[71,318,224],{"class":81},[71,320,321],{"class":77}," await",[71,323,324],{"class":103}," withTaskGroup",[71,326,126],{"class":89},[71,328,329],{"class":103},"of",[71,331,59],{"class":89},[71,333,334],{"class":103}," String",[71,336,337],{"class":81},"?",[71,339,231],{"class":99},[71,341,342],{"class":77},"self",[71,344,135],{"class":89},[71,346,107],{"class":89},[71,348,349],{"class":99}," group ",[71,351,352],{"class":81},"->",[71,354,334],{"class":103},[71,356,337],{"class":81},[71,358,359],{"class":77}," in\n",[71,361,362,365,368],{"class":73,"line":93},[71,363,364],{"class":99},"    group.",[71,366,367],{"class":103},"addTask",[71,369,90],{"class":89},[71,371,372,375,377,379,382,385,387,390,392],{"class":73,"line":116},[71,373,374],{"class":77},"        try",[71,376,337],{"class":81},[71,378,321],{"class":77},[71,380,381],{"class":99}," cleaner.",[71,383,384],{"class":103},"clean",[71,386,126],{"class":89},[71,388,389],{"class":99},"raw",[71,391,135],{"class":89},[71,393,394],{"class":207},"          \u002F\u002F nil on any error\n",[71,396,397],{"class":73,"line":150},[71,398,399],{"class":89},"    }\n",[71,401,402,404,406],{"class":73,"line":238},[71,403,364],{"class":99},[71,405,367],{"class":103},[71,407,90],{"class":89},[71,409,410,412,414,416,419,422,424,427,429,432,434,437,440,444],{"class":73,"line":255},[71,411,374],{"class":77},[71,413,337],{"class":81},[71,415,321],{"class":77},[71,417,418],{"class":99}," Task.",[71,420,421],{"class":103},"sleep",[71,423,126],{"class":89},[71,425,426],{"class":103},"nanoseconds",[71,428,59],{"class":89},[71,430,431],{"class":103}," UInt64",[71,433,126],{"class":89},[71,435,436],{"class":99},"budget ",[71,438,439],{"class":81},"*",[71,441,443],{"class":442},"sqbOQ"," 1_000_000_000",[71,445,446],{"class":89},"))\n",[71,448,449,452,455],{"class":73,"line":276},[71,450,451],{"class":77},"        return",[71,453,454],{"class":77}," nil",[71,456,457],{"class":207},"                             \u002F\u002F timed out\n",[71,459,460],{"class":73,"line":282},[71,461,399],{"class":89},[71,463,465,467,470,472,474,477,480,483,486,488],{"class":73,"line":464},9,[71,466,218],{"class":77},[71,468,469],{"class":99}," first ",[71,471,224],{"class":81},[71,473,321],{"class":77},[71,475,476],{"class":99}," group.",[71,478,479],{"class":103},"next",[71,481,482],{"class":89},"()",[71,484,485],{"class":81}," ??",[71,487,454],{"class":77},[71,489,490],{"class":207},"      \u002F\u002F first finisher decides\n",[71,492,494,496,499,501],{"class":73,"line":493},10,[71,495,364],{"class":99},[71,497,498],{"class":103},"cancelAll",[71,500,482],{"class":89},[71,502,503],{"class":207},"                          \u002F\u002F also cancels the HTTP request\n",[71,505,507,510],{"class":73,"line":506},11,[71,508,509],{"class":77},"    return",[71,511,512],{"class":99}," first\n",[71,514,516],{"class":73,"line":515},12,[71,517,153],{"class":89},[71,519,521,524,526,529],{"class":73,"line":520},13,[71,522,523],{"class":77},"return",[71,525,316],{"class":99},[71,527,528],{"class":81},"??",[71,530,531],{"class":99}," raw\n",[29,533,534],{},"I've trimmed this a little. The real version logs which failure happened and also treats an empty result as a failure. The line that matters is the last one: there's no path through this function that ends with neither a cleaned transcript nor the raw one.",[41,536,538],{"id":537},"step-3-pasting-without-wrecking-your-clipboard","Step 3: pasting without wrecking your clipboard",[29,540,541,542,545],{},"There are two ways to get text into another app on macOS. You can simulate typing it one character at a time, or you can put it on the clipboard and simulate ",[49,543,544],{},"⌘V",". Pasting is fast and works almost everywhere, so it's the default. Typing is there as a config option.",[29,547,548,549,551,552,555,556,559],{},"Pasting has a side effect, though: it replaces whatever you had copied. By default, OpenWisper simply leaves the transcript on the clipboard afterwards, which doubles as a safety net, because if the paste went nowhere, ",[49,550,544],{}," brings it back. If you'd rather keep your clipboard, you can set ",[49,553,554],{},"insert.copyToClipboard"," to ",[49,557,558],{},"false",", and then it has to be more careful:",[561,562,563,567,572],"ol",{},[564,565,566],"li",{},"Take a snapshot of everything on the clipboard: every item, in every format it offers.",[564,568,569,570,231],{},"Write the transcript and send ",[49,571,544],{},[564,573,574],{},"About 600 ms later, put the snapshot back.",[29,576,577,578,581],{},"Step 3 hides a trap. What if you copied something else during those 600 ms? Restoring the old snapshot would quietly throw away the thing you just copied. macOS gives the pasteboard a ",[49,579,580],{},"changeCount"," that goes up with every write, so the fix is to remember the count right after our own write and only restore if it hasn't moved:",[61,583,586],{"className":63,"code":584,"filename":585,"language":66,"meta":67,"style":67},"static func restoreIfUntouched(_ snapshot: PasteboardSnapshot, expectedChangeCount: Int) {\n    let pasteboard = NSPasteboard.general\n    guard pasteboard.changeCount == expectedChangeCount else {\n        \u002F\u002F Another app has written to the clipboard since our paste. Leave it alone.\n        return\n    }\n    pasteboard.clearContents()\n    \u002F\u002F ... write each snapshotted item back, in all of its formats\n}\n","Sources\u002FInsertIO\u002FPasteboardInserter.swift",[49,587,588,616,631,650,655,660,664,675,680],{"__ignoreMap":67},[71,589,590,593,595,598,600,602,605,608,610,612,614],{"class":73,"line":74},[71,591,592],{"class":81},"static",[71,594,176],{"class":81},[71,596,597],{"class":122}," restoreIfUntouched",[71,599,126],{"class":89},[71,601,129],{"class":122},[71,603,604],{"class":99}," snapshot: PasteboardSnapshot, ",[71,606,607],{"class":122},"expectedChangeCount",[71,609,193],{"class":99},[71,611,196],{"class":103},[71,613,135],{"class":89},[71,615,90],{"class":89},[71,617,618,620,623,625,628],{"class":73,"line":93},[71,619,218],{"class":77},[71,621,622],{"class":99}," pasteboard ",[71,624,224],{"class":81},[71,626,627],{"class":99}," NSPasteboard.",[71,629,630],{"class":234},"general\n",[71,632,633,636,639,642,645,648],{"class":73,"line":116},[71,634,635],{"class":77},"    guard",[71,637,638],{"class":99}," pasteboard.changeCount ",[71,640,641],{"class":81},"==",[71,643,644],{"class":99}," expectedChangeCount ",[71,646,647],{"class":77},"else",[71,649,90],{"class":89},[71,651,652],{"class":73,"line":150},[71,653,654],{"class":207},"        \u002F\u002F Another app has written to the clipboard since our paste. Leave it alone.\n",[71,656,657],{"class":73,"line":238},[71,658,659],{"class":77},"        return\n",[71,661,662],{"class":73,"line":255},[71,663,399],{"class":89},[71,665,666,669,672],{"class":73,"line":276},[71,667,668],{"class":99},"    pasteboard.",[71,670,671],{"class":103},"clearContents",[71,673,674],{"class":89},"()\n",[71,676,677],{"class":73,"line":282},[71,678,679],{"class":207},"    \u002F\u002F ... write each snapshotted item back, in all of its formats\n",[71,681,682],{"class":73,"line":464},[71,683,153],{"class":89},[29,685,686],{},"If you've used optimistic locking in a database (only update the row if its version is still the one you read), this is the same idea applied to the clipboard. When in doubt, it does nothing, because doing nothing is the one option that can't destroy anybody's data.",[29,688,689],{},"A few smaller details from the same file, the kind you only learn by shipping:",[691,692,693,704,714],"ul",{},[564,694,695,696,700,701,703],{},"The inserter checks for the Accessibility permission ",[697,698,699],"em",{},"before"," touching the clipboard. Without it, the synthetic ",[49,702,544],{}," silently does nothing, and there's no point replacing someone's clipboard for a paste that can't happen.",[564,705,706,707,709,710,713],{},"The clipboard is shared between processes, so the code waits 80 ms after writing before sending ",[49,708,544],{},". Paste too quickly and some apps read the ",[697,711,712],{},"previous"," contents.",[564,715,716],{},"The synthetic key is held down for 20 ms, because some apps ignore a key press that lasts zero milliseconds.",[41,718,720],{"id":719},"the-last-safety-net-history-written-first","The last safety net: history, written first",[29,722,723],{},"There's one failure the app can't detect. The paste \"works\", but nothing had keyboard focus, so the text went nowhere. From OpenWisper's side, everything succeeded.",[29,725,726,727,729],{},"For that case, every transcript is saved to a local history file ",[697,728,699],{}," the paste is attempted. If the paste lands nowhere, the words are still in the History window, one click from the clipboard.",[29,731,732,733,736,737,739],{},"This is done with a decorator: a ",[49,734,735],{},"TextInserter"," that wraps another ",[49,738,735],{},", saves the text, and then hands off to the real one.",[61,741,744],{"className":63,"code":742,"filename":743,"language":66,"meta":67,"style":67},"public final class HistoryRecordingInserter: TextInserter {\n    private let wrapped: TextInserter\n    private let store: TranscriptHistoryStore\n\n    public func insert(_ text: String) async throws {\n        store.append(text: text, engine: engineName())   \u002F\u002F save first...\n        try await wrapped.insert(text)                   \u002F\u002F ...then paste\n    }\n}\n","Sources\u002FOpenWisperCore\u002FTranscriptHistory.swift",[49,745,746,767,778,787,793,820,852,873,877],{"__ignoreMap":67},[71,747,748,750,753,756,759,761,764],{"class":73,"line":74},[71,749,78],{"class":77},[71,751,752],{"class":81}," final",[71,754,755],{"class":81}," class",[71,757,758],{"class":85}," HistoryRecordingInserter",[71,760,59],{"class":89},[71,762,763],{"class":122}," TextInserter ",[71,765,766],{"class":89},"{\n",[71,768,769,772,775],{"class":73,"line":93},[71,770,771],{"class":77},"    private",[71,773,774],{"class":77}," let",[71,776,777],{"class":99}," wrapped: TextInserter\n",[71,779,780,782,784],{"class":73,"line":116},[71,781,771],{"class":77},[71,783,774],{"class":77},[71,785,786],{"class":99}," store: TranscriptHistoryStore\n",[71,788,789],{"class":73,"line":150},[71,790,792],{"emptyLinePlaceholder":791},true,"\n",[71,794,795,798,800,803,805,807,810,812,814,816,818],{"class":73,"line":238},[71,796,797],{"class":77},"    public",[71,799,176],{"class":81},[71,801,802],{"class":122}," insert",[71,804,126],{"class":89},[71,806,129],{"class":122},[71,808,809],{"class":99}," text: ",[71,811,104],{"class":103},[71,813,135],{"class":89},[71,815,138],{"class":81},[71,817,141],{"class":81},[71,819,90],{"class":89},[71,821,822,825,828,830,833,835,838,841,843,846,849],{"class":73,"line":255},[71,823,824],{"class":99},"        store.",[71,826,827],{"class":103},"append",[71,829,126],{"class":89},[71,831,832],{"class":103},"text",[71,834,59],{"class":89},[71,836,837],{"class":99}," text, ",[71,839,840],{"class":103},"engine",[71,842,59],{"class":89},[71,844,845],{"class":103}," engineName",[71,847,848],{"class":89},"())",[71,850,851],{"class":207},"   \u002F\u002F save first...\n",[71,853,854,856,858,861,864,866,868,870],{"class":73,"line":276},[71,855,374],{"class":77},[71,857,321],{"class":77},[71,859,860],{"class":99}," wrapped.",[71,862,863],{"class":103},"insert",[71,865,126],{"class":89},[71,867,832],{"class":99},[71,869,135],{"class":89},[71,871,872],{"class":207},"                   \u002F\u002F ...then paste\n",[71,874,875],{"class":73,"line":282},[71,876,399],{"class":89},[71,878,879],{"class":73,"line":464},[71,880,153],{"class":89},[29,882,883,884,886],{},"The dictation controller has no idea history exists. It calls ",[49,885,863],{},", and that's all. This keeps a feature like history out of the state machine, and neither the paste inserter nor the typing inserter needed a single change to get it.",[41,888,890],{"id":889},"privacy-concretely","Privacy, concretely",[29,892,893],{},"\"Privacy-first\" is easy to put on a landing page, so here's what it actually means in this app:",[691,895,896,899,902,905,908],{},[564,897,898],{},"No accounts, no analytics, no telemetry.",[564,900,901],{},"Audio lives in memory for the length of one dictation and never touches the disk.",[564,903,904],{},"With the local engine and no API keys, the only network request is the model download you start on first launch.",[564,906,907],{},"Transcript history is a local JSON file that only your user account can read. It's capped at 200 entries, and you can turn it off or clear it at any time.",[564,909,910],{},"Logs never contain transcript text.",[912,913,914,922],"gallery",{},[915,916],"figure",{"alt":917,"caption":918,"height":919,"src":920,"src-dark":921,"width":919},"The Permissions window showing live status for Microphone, Input Monitoring, and Accessibility","The three macOS permissions it needs, with their live status.",1200,"\u002Fprojects\u002Fopenwisper\u002Fwindow-permissions-light.png","\u002Fprojects\u002Fopenwisper\u002Fwindow-permissions-dark.png",[915,923],{"alt":924,"caption":925,"height":919,"src":926,"src-dark":927,"width":919},"The Model window showing the local, Groq, OpenAI, and Auto transcription engine options","Choosing between the local model and a cloud engine.","\u002Fprojects\u002Fopenwisper\u002Fwindow-model-light.png","\u002Fprojects\u002Fopenwisper\u002Fwindow-model-dark.png",[929,930,933],"callout",{"title":931,"type":932},"The takeaway","tip",[29,934,935],{},"For every step that can fail, decide up front what the fallback is, and make it one that keeps the user's data. Cleanup falls back to the raw transcript. Restoring the clipboard falls back to leaving it alone. A paste that lands nowhere falls back to history. None of these are clever on their own, but together they mean the worst case is \"less polished\", never \"gone\".",[937,938,939],"style",{},"html pre.shiki code .sTPum, html code.shiki .sTPum{--shiki-default:#1E754F;--shiki-dark:#4D9375}html pre.shiki code .s5TCs, html code.shiki .s5TCs{--shiki-default:#AB5959;--shiki-dark:#CB7676}html pre.shiki code .s_NWU, html code.shiki .s_NWU{--shiki-default:#2E8F82;--shiki-dark:#5DA994}html pre.shiki code .si6no, html code.shiki .si6no{--shiki-default:#999999;--shiki-dark:#666666}html pre.shiki code .s8w-G, html code.shiki .s8w-G{--shiki-default:#393A34;--shiki-dark:#DBD7CAEE}html pre.shiki code .sHLBJ, html code.shiki .sHLBJ{--shiki-default:#998418;--shiki-dark:#B8A965}html pre.shiki code .s_xSY, html code.shiki .s_xSY{--shiki-default:#59873A;--shiki-dark:#80A665}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .snYqZ, html code.shiki .snYqZ{--shiki-default:#A0ADA0;--shiki-dark:#758575DD}html pre.shiki code .sfsYZ, html code.shiki .sfsYZ{--shiki-default:#A65E2B;--shiki-dark:#C99076}html pre.shiki code .s9nN2, html code.shiki .s9nN2{--shiki-default:#B07D48;--shiki-dark:#BD976A}html pre.shiki code .sqbOQ, html code.shiki .sqbOQ{--shiki-default:#2F798A;--shiki-dark:#4C9A91}",{"title":67,"searchDepth":93,"depth":116,"links":941},[942,943,944,945,946],{"id":43,"depth":93,"text":44},{"id":293,"depth":93,"text":294},{"id":537,"depth":93,"text":538},{"id":719,"depth":93,"text":720},{"id":889,"depth":93,"text":890},"Local transcription, a cleanup step that's allowed to fail, and a paste that puts your clipboard back the way it was.","md",false,"personal",{},{},{"title":954},"The pipeline",999,"\u002Fprojects\u002Fopenwisper\u002Fpipeline",null,{"title":24,"description":947},[],"projects\u002Fopenwisper\u002F2.pipeline","H7xFNQ19dAaNbBaDKOD0eR0fpEJcRQnlezg2Lcf_U9U",[963,973,980],{"path":964,"title":965,"stem":966,"navigation":791,"description":967,"preview":968},"\u002Fprojects\u002Fopenwisper","OpenWisper","projects\u002Fopenwisper\u002Findex","Local, offline-first dictation for macOS. Hold a key, talk, and the cleaned-up text lands wherever your cursor is.",{"src":969,"srcDark":970,"alt":971,"og":972},"\u002Fprojects\u002Fopenwisper\u002Fpreview.webp","\u002Fprojects\u002Fopenwisper\u002Fpreview-dark.webp","OpenWisper's landing page: the app icon above the headline 'Talk. It types. Nothing leaves your Mac.'","\u002Fprojects\u002Fopenwisper\u002Fog.jpg",{"path":974,"title":975,"stem":976,"navigation":977,"description":979,"preview":957},"\u002Fprojects\u002Fopenwisper\u002Fhotkey","One key, two gestures","projects\u002Fopenwisper\u002F1.hotkey",{"title":978},"The hotkey","How a single hotkey handles both push-to-talk and hands-free dictation, and why the answer is a small state machine instead of a handful of booleans.",{"path":956,"title":24,"stem":960,"navigation":981,"description":947,"preview":957},{"title":954},[983,984,985,986,987,988,989,990,991,992,993,994,995],"TypeScript","Vue 3","Nuxt","Pinia","Tailwind CSS","Python","FastAPI","SQLAlchemy","Pydantic","PostgreSQL","Supabase","GCP","Firebase",1790471332074]