[{"data":1,"prerenderedAt":813},["ShallowReactive",2],{"profile":3,"projects:page:\u002Fprojects\u002Fopenwisper\u002Fhotkey":22,"projects:docs:\u002Fprojects\u002Fopenwisper":779,"structured-data:current-stack":799},{"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":764,"extension":765,"featured":766,"kind":767,"links":768,"meta":769,"navigation":770,"order":772,"path":773,"period":774,"preview":774,"seo":775,"stack":776,"stem":777,"year":774,"__hash__":778},"projects\u002Fprojects\u002Fopenwisper\u002F1.hotkey.md","One key, two gestures",{"type":26,"value":27,"toc":753},"minimark",[28,37,54,61,64,69,83,102,109,113,135,208,211,216,219,226,230,240,250,254,263,273,284,287,589,592,596,599,610,705,708,712,719,734,738,741,749],[29,30,31,32,36],"p",{},"OpenWisper has one hotkey (",[33,34,35],"code",{},"fn"," by default) and two ways to use it:",[38,39,40,48],"ul",{},[41,42,43,47],"li",{},[44,45,46],"strong",{},"Hold it"," while you talk, and let go when you're done. That's push-to-talk, the one you'd use for a quick message.",[41,49,50,53],{},[44,51,52],{},"Double-tap it"," to start a hands-free session. You can take your hands off the keyboard and talk for as long as you like. One more tap stops it.",[29,55,56,57,60],{},"On top of that, ",[33,58,59],{},"Esc"," cancels at any point, and a single accidental tap should do nothing at all.",[29,62,63],{},"Most dictation apps make you choose one of these modes in the settings. I wanted both on the same key, without the app ever asking which one you meant. It sounds like a small detail, but it's where most of the tricky logic in the app lives. It's also a good example of a problem that gets much simpler once you model it as a state machine.",[65,66,68],"h2",{"id":67},"why-a-few-booleans-arent-enough","Why a few booleans aren't enough",[29,70,71,72,75,76,75,79,82],{},"The first instinct for something like this is a handful of variables: ",[33,73,74],{},"isRecording",", ",[33,77,78],{},"isLocked",[33,80,81],{},"lastTapTime",", maybe a timer. Each event handler checks some combination of them and flips others. That covers the happy path. Then the edge cases arrive:",[38,84,85,93,96],{},[41,86,87,88,92],{},"When you tap to ",[89,90,91],"em",{},"stop"," a hands-free session, the key also comes back up a moment later. What stops that release from being read as the end of a push-to-talk?",[41,94,95],{},"The second tap of a double-tap also has a release. Why doesn't that one stop the session it just started?",[41,97,98,99,101],{},"What happens if ",[33,100,59],{}," arrives between a key press and its release?",[29,103,104,105,108],{},"With loose booleans, each of these questions turns into another ",[33,106,107],{},"if"," somewhere, and it gets hard to answer \"what can happen next?\" by reading the code. A state machine answers that question directly. The app is always in exactly one named state, and each state lists the events it reacts to and where they lead. Any event that isn't on the list is ignored, on purpose.",[65,110,112],{"id":111},"the-states","The states",[29,114,115,116,119,120,123,124,127,128,130,131,134],{},"This is the machine behind the default mode, which is called ",[33,117,118],{},"flow"," in the config. (There are also plain ",[33,121,122],{},"hold"," and ",[33,125,126],{},"toggle"," modes, for people who only want one gesture.) ",[33,129,59],{}," from any recording state goes straight back to ",[33,132,133],{},"idle","; I left those arrows out to keep the diagram readable.",[136,137,138],"mermaid",{},[139,140,144],"pre",{"className":141,"code":142,"language":136,"meta":143,"style":143},"language-mermaid shiki shiki-themes vitesse-light vitesse-dark","stateDiagram-v2\n  [*] --> idle\n  idle --> pressed: key down \u002F start recording\n  pressed --> idle: key up after ≥ 250 ms \u002F transcribe\n  pressed --> tapWait: key up before 250 ms\n  tapWait --> lockedKeyDown: key down within 300 ms\n  tapWait --> idle: 300 ms pass \u002F discard\n  lockedKeyDown --> locked: key up (ignored)\n  locked --> drainUp: key down \u002F transcribe\n  drainUp --> idle: key up (ignored)\n","",[33,145,146,154,160,166,172,178,184,190,196,202],{"__ignoreMap":143},[147,148,151],"span",{"class":149,"line":150},"line",1,[147,152,153],{},"stateDiagram-v2\n",[147,155,157],{"class":149,"line":156},2,[147,158,159],{},"  [*] --> idle\n",[147,161,163],{"class":149,"line":162},3,[147,164,165],{},"  idle --> pressed: key down \u002F start recording\n",[147,167,169],{"class":149,"line":168},4,[147,170,171],{},"  pressed --> idle: key up after ≥ 250 ms \u002F transcribe\n",[147,173,175],{"class":149,"line":174},5,[147,176,177],{},"  pressed --> tapWait: key up before 250 ms\n",[147,179,181],{"class":149,"line":180},6,[147,182,183],{},"  tapWait --> lockedKeyDown: key down within 300 ms\n",[147,185,187],{"class":149,"line":186},7,[147,188,189],{},"  tapWait --> idle: 300 ms pass \u002F discard\n",[147,191,193],{"class":149,"line":192},8,[147,194,195],{},"  lockedKeyDown --> locked: key up (ignored)\n",[147,197,199],{"class":149,"line":198},9,[147,200,201],{},"  locked --> drainUp: key down \u002F transcribe\n",[147,203,205],{"class":149,"line":204},10,[147,206,207],{},"  drainUp --> idle: key up (ignored)\n",[29,209,210],{},"Let's go through the decisions that shaped it.",[212,213,215],"h3",{"id":214},"start-recording-before-you-know-what-the-press-means","Start recording before you know what the press means",[29,217,218],{},"The first key press turns the microphone on straight away, before the app knows whether this will be a hold or a tap. That seems backwards at first, but it's what makes the double-tap safe. If you double-tap and start talking immediately, whatever you said between the first and second tap is already in the recording. The recording runs from the very first press to the end, as one continuous clip, so locking the session never cuts anything off.",[29,220,221,222,225],{},"The cost is that a stray tap briefly opens the microphone. That's what ",[33,223,224],{},"tapWait"," is for: if no second tap arrives within 300 ms, the audio is thrown away without being transcribed, and it's as if nothing happened. As a second safety net, any clip shorter than 0.4 seconds is discarded, however it was produced.",[212,227,229],{"id":228},"hold-or-tap-measure-the-release","Hold or tap? Measure the release",[29,231,232,233,236,237,239],{},"When the key comes back up in ",[33,234,235],{},"pressed",", the controller checks how long it was held. 250 ms or more means you were holding it to talk, so the recording stops and goes off to be transcribed, exactly like a classic push-to-talk. Anything shorter was a tap, so the machine moves to ",[33,238,224],{}," and gives you 300 ms to tap again.",[29,241,242,243,123,246,249],{},"Both numbers are configurable (",[33,244,245],{},"minHoldMs",[33,247,248],{},"doubleTapWindowMs","). The double-tap window is capped at five seconds in code, because the microphone is open for as long as that window is. A typo in a config file shouldn't be able to leave it recording indefinitely.",[212,251,253],{"id":252},"two-states-whose-only-job-is-to-ignore-something","Two states whose only job is to ignore something",[29,255,256,123,259,262],{},[33,257,258],{},"lockedKeyDown",[33,260,261],{},"drainUp"," are my favorite part of this machine, because they look pointless until you see what breaks without them.",[29,264,265,266,269,270,272],{},"Think about the second tap of a double-tap. The key goes down, which locks the session, and a moment later it comes back up. Without a dedicated state, that release would land in ",[33,267,268],{},"locked",", and then what? If it were treated like the end of a hold, it would stop the session you had just started. So ",[33,271,258],{}," means \"locked, but the key that locked it is still physically down\". Its only job is to swallow the next key-up.",[29,274,275,277,278,280,281,283],{},[33,276,261],{}," is the same idea on the way out. When you tap to stop a hands-free session, the press stops it, and the release that follows must not start anything new. ",[33,279,261],{}," absorbs that release and then goes back to ",[33,282,133],{},".",[29,285,286],{},"Here's the release handler, slightly trimmed. Once the state carries the context, there's very little left for it to decide:",[139,288,293],{"className":289,"code":290,"filename":291,"language":292,"meta":143,"style":143},"language-swift shiki shiki-themes vitesse-light vitesse-dark","private func flowHotkeyUp() {\n    switch flowPhase {\n    case .pressed:\n        let heldMs = (Date.timeIntervalSinceReferenceDate - recordingStartedAt) * 1000\n        if heldMs >= Double(config.hotkey.minHoldMs) {\n            flowPhase = .idle\n            finishRecording(enforceMinimumHold: true)   \u002F\u002F push-to-talk\n        } else {\n            flowPhase = .tapWait\n            armTapWaitWindow()                          \u002F\u002F maybe a double-tap\n        }\n\n    case .lockedKeyDown:\n        flowPhase = .locked   \u002F\u002F the locking tap's own release: ignore it\n\n    case .drainUp:\n        flowPhase = .idle     \u002F\u002F the stopping tap's own release: ignore it\n\n    case .idle, .tapWait, .locked:\n        return                \u002F\u002F no key was down as far as we know\n    }\n}\n","Sources\u002FSpine\u002FDictationController.swift","swift",[33,294,295,316,328,342,378,409,421,443,453,464,474,480,487,498,513,518,529,543,548,568,577,583],{"__ignoreMap":143},[147,296,297,301,305,309,313],{"class":149,"line":150},[147,298,300],{"class":299},"sTPum","private",[147,302,304],{"class":303},"s5TCs"," func",[147,306,308],{"class":307},"s_xSY"," flowHotkeyUp",[147,310,312],{"class":311},"si6no","()",[147,314,315],{"class":311}," {\n",[147,317,318,321,325],{"class":149,"line":156},[147,319,320],{"class":299},"    switch",[147,322,324],{"class":323},"s8w-G"," flowPhase ",[147,326,327],{"class":311},"{\n",[147,329,330,333,336,339],{"class":149,"line":162},[147,331,332],{"class":299},"    case",[147,334,335],{"class":323}," .",[147,337,235],{"class":338},"s9nN2",[147,340,341],{"class":303},":\n",[147,343,344,347,350,353,356,359,362,365,368,371,374],{"class":149,"line":168},[147,345,346],{"class":299},"        let",[147,348,349],{"class":323}," heldMs ",[147,351,352],{"class":303},"=",[147,354,355],{"class":311}," (",[147,357,358],{"class":323},"Date.",[147,360,361],{"class":338},"timeIntervalSinceReferenceDate",[147,363,364],{"class":303}," -",[147,366,367],{"class":323}," recordingStartedAt",[147,369,370],{"class":311},")",[147,372,373],{"class":303}," *",[147,375,377],{"class":376},"sqbOQ"," 1000\n",[147,379,380,383,385,388,392,395,398,401,403,405,407],{"class":149,"line":174},[147,381,382],{"class":299},"        if",[147,384,349],{"class":323},[147,386,387],{"class":303},">=",[147,389,391],{"class":390},"sHLBJ"," Double",[147,393,394],{"class":311},"(",[147,396,397],{"class":323},"config.",[147,399,400],{"class":338},"hotkey",[147,402,283],{"class":323},[147,404,245],{"class":338},[147,406,370],{"class":311},[147,408,315],{"class":311},[147,410,411,414,416,418],{"class":149,"line":180},[147,412,413],{"class":323},"            flowPhase ",[147,415,352],{"class":303},[147,417,335],{"class":323},[147,419,420],{"class":338},"idle\n",[147,422,423,426,428,431,434,437,439],{"class":149,"line":186},[147,424,425],{"class":390},"            finishRecording",[147,427,394],{"class":311},[147,429,430],{"class":390},"enforceMinimumHold",[147,432,433],{"class":311},":",[147,435,436],{"class":299}," true",[147,438,370],{"class":311},[147,440,442],{"class":441},"snYqZ","   \u002F\u002F push-to-talk\n",[147,444,445,448,451],{"class":149,"line":192},[147,446,447],{"class":311},"        }",[147,449,450],{"class":299}," else",[147,452,315],{"class":311},[147,454,455,457,459,461],{"class":149,"line":198},[147,456,413],{"class":323},[147,458,352],{"class":303},[147,460,335],{"class":323},[147,462,463],{"class":338},"tapWait\n",[147,465,466,469,471],{"class":149,"line":204},[147,467,468],{"class":390},"            armTapWaitWindow",[147,470,312],{"class":311},[147,472,473],{"class":441},"                          \u002F\u002F maybe a double-tap\n",[147,475,477],{"class":149,"line":476},11,[147,478,479],{"class":311},"        }\n",[147,481,483],{"class":149,"line":482},12,[147,484,486],{"emptyLinePlaceholder":485},true,"\n",[147,488,490,492,494,496],{"class":149,"line":489},13,[147,491,332],{"class":299},[147,493,335],{"class":323},[147,495,258],{"class":338},[147,497,341],{"class":303},[147,499,501,504,506,508,510],{"class":149,"line":500},14,[147,502,503],{"class":323},"        flowPhase ",[147,505,352],{"class":303},[147,507,335],{"class":323},[147,509,268],{"class":338},[147,511,512],{"class":441},"   \u002F\u002F the locking tap's own release: ignore it\n",[147,514,516],{"class":149,"line":515},15,[147,517,486],{"emptyLinePlaceholder":485},[147,519,521,523,525,527],{"class":149,"line":520},16,[147,522,332],{"class":299},[147,524,335],{"class":323},[147,526,261],{"class":338},[147,528,341],{"class":303},[147,530,532,534,536,538,540],{"class":149,"line":531},17,[147,533,503],{"class":323},[147,535,352],{"class":303},[147,537,335],{"class":323},[147,539,133],{"class":338},[147,541,542],{"class":441},"     \u002F\u002F the stopping tap's own release: ignore it\n",[147,544,546],{"class":149,"line":545},18,[147,547,486],{"emptyLinePlaceholder":485},[147,549,551,553,555,557,560,562,564,566],{"class":149,"line":550},19,[147,552,332],{"class":299},[147,554,335],{"class":323},[147,556,133],{"class":338},[147,558,559],{"class":323},", .",[147,561,224],{"class":338},[147,563,559],{"class":323},[147,565,268],{"class":338},[147,567,341],{"class":303},[147,569,571,574],{"class":149,"line":570},20,[147,572,573],{"class":299},"        return",[147,575,576],{"class":441},"                \u002F\u002F no key was down as far as we know\n",[147,578,580],{"class":149,"line":579},21,[147,581,582],{"class":311},"    }\n",[147,584,586],{"class":149,"line":585},22,[147,587,588],{"class":311},"}\n",[29,590,591],{},"Each case is one or two lines. When something behaves strangely, I can trace the diagram with a finger and find the transition that's wrong, instead of reasoning about four booleans at once.",[65,593,595],{"id":594},"a-timer-you-can-take-back","A timer you can take back",[29,597,598],{},"The 300 ms window is a timer, and timers come with a classic bug: you cancel one, but it had already fired and its callback is queued to run anyway. Here, that would mean a double-tap that correctly locks the session, followed a split second later by the expired window throwing the recording away.",[29,600,601,602,605,606,609],{},"The window is a Swift ",[33,603,604],{},"Task"," that sleeps and then calls back, and it's guarded in two ways. Cancelling the task covers the normal case. For the race, a counter called ",[33,607,608],{},"tapWaitGeneration"," goes up by one every time a window is armed or cancelled. Each callback remembers the number it was created with, and if that number isn't the current one when it finally runs, it does nothing:",[139,611,613],{"className":289,"code":612,"filename":291,"language":292,"meta":143,"style":143},"private func tapWaitWindowExpired(generation: Int) {\n    guard generation == tapWaitGeneration else { return }   \u002F\u002F a stale window\n    guard case .tapWait = flowPhase else { return }\n    cancelRecording()   \u002F\u002F no second tap: it was an accidental press\n}\n",[33,614,615,639,668,691,701],{"__ignoreMap":143},[147,616,617,619,621,624,626,629,632,635,637],{"class":149,"line":150},[147,618,300],{"class":299},[147,620,304],{"class":303},[147,622,623],{"class":307}," tapWaitWindowExpired",[147,625,394],{"class":311},[147,627,628],{"class":307},"generation",[147,630,631],{"class":323},": ",[147,633,634],{"class":390},"Int",[147,636,370],{"class":311},[147,638,315],{"class":311},[147,640,641,644,647,650,653,656,659,662,665],{"class":149,"line":156},[147,642,643],{"class":299},"    guard",[147,645,646],{"class":323}," generation ",[147,648,649],{"class":303},"==",[147,651,652],{"class":323}," tapWaitGeneration ",[147,654,655],{"class":299},"else",[147,657,658],{"class":311}," {",[147,660,661],{"class":299}," return",[147,663,664],{"class":311}," }",[147,666,667],{"class":441},"   \u002F\u002F a stale window\n",[147,669,670,672,675,678,680,682,684,686,688],{"class":149,"line":162},[147,671,643],{"class":299},[147,673,674],{"class":299}," case",[147,676,677],{"class":323}," .tapWait ",[147,679,352],{"class":303},[147,681,324],{"class":323},[147,683,655],{"class":299},[147,685,658],{"class":311},[147,687,661],{"class":299},[147,689,690],{"class":311}," }\n",[147,692,693,696,698],{"class":149,"line":168},[147,694,695],{"class":390},"    cancelRecording",[147,697,312],{"class":311},[147,699,700],{"class":441},"   \u002F\u002F no second tap: it was an accidental press\n",[147,702,703],{"class":149,"line":174},[147,704,588],{"class":311},[29,706,707],{},"It's a small pattern, and you'll find a use for it anywhere a delayed callback can outlive the reason it was scheduled: debounced search requests, toasts that hide themselves, retries.",[65,709,711],{"id":710},"keep-the-listener-dumb","Keep the listener dumb",[29,713,714,715,718],{},"One design choice made all of this testable. The keyboard listener knows almost nothing. It uses a macOS event tap (",[33,716,717],{},"CGEventTap",") to watch key events across the whole system, and all it reports is \"down\", \"up\" or \"Esc\". It has no idea about modes, timings or recordings.",[29,720,721,722,725,726,75,729,75,732,283],{},"Every decision is made in ",[33,723,724],{},"DictationController",", and the controller only talks to the things around it (the recorder, the transcriber, the text inserter, the on-screen indicator) through small Swift protocols. So the unit tests can drive the state machine with fake key events and a fake recorder, and check what happened, with no microphone, keyboard or model involved. In a test, a double-tap is just ",[33,727,728],{},"hotkeyDown()",[33,730,731],{},"hotkeyUp()",[33,733,728],{},[65,735,737],{"id":736},"a-ceiling-for-hands-free-mode","A ceiling for hands-free mode",[29,739,740],{},"A hands-free session has no key release coming, so it needs another way to end if you forget about it. The recorder has a hard cap, 10 minutes by default. When it's reached, the session stops and transcribes what it caught, exactly as if you had tapped the key yourself. Walking away from your desk mid-sentence shouldn't leave a microphone on for the rest of the afternoon.",[742,743,746],"callout",{"title":744,"type":745},"The takeaway","tip",[29,747,748],{},"If you catch yourself adding a third boolean to track which mode you're in, it's usually time to write the states down by name. Once they're named, \"what happens if the user does X here?\" becomes a question you can answer by looking at one diagram.",[750,751,752],"style",{},"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 .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_xSY, html code.shiki .s_xSY{--shiki-default:#59873A;--shiki-dark:#80A665}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 .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}html pre.shiki code .sHLBJ, html code.shiki .sHLBJ{--shiki-default:#998418;--shiki-dark:#B8A965}html pre.shiki code .snYqZ, html code.shiki .snYqZ{--shiki-default:#A0ADA0;--shiki-dark:#758575DD}",{"title":143,"searchDepth":156,"depth":162,"links":754},[755,756,761,762,763],{"id":67,"depth":156,"text":68},{"id":111,"depth":156,"text":112,"children":757},[758,759,760],{"id":214,"depth":162,"text":215},{"id":228,"depth":162,"text":229},{"id":252,"depth":162,"text":253},{"id":594,"depth":156,"text":595},{"id":710,"depth":156,"text":711},{"id":736,"depth":156,"text":737},"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.","md",false,"personal",{},{},{"title":771},"The hotkey",999,"\u002Fprojects\u002Fopenwisper\u002Fhotkey",null,{"title":24,"description":764},[],"projects\u002Fopenwisper\u002F1.hotkey","DF8tfMDxoidbeb2Ez5NYTIB4_tsgyk_SWegVCylJNJk",[780,790,792],{"path":781,"title":782,"stem":783,"navigation":485,"description":784,"preview":785},"\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":786,"srcDark":787,"alt":788,"og":789},"\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":773,"title":24,"stem":777,"navigation":791,"description":764,"preview":774},{"title":771},{"path":793,"title":794,"stem":795,"navigation":796,"description":798,"preview":774},"\u002Fprojects\u002Fopenwisper\u002Fpipeline","Never losing what you said","projects\u002Fopenwisper\u002F2.pipeline",{"title":797},"The pipeline","Local transcription, a cleanup step that's allowed to fail, and a paste that puts your clipboard back the way it was.",[800,801,802,803,804,805,806,807,808,809,810,811,812],"TypeScript","Vue 3","Nuxt","Pinia","Tailwind CSS","Python","FastAPI","SQLAlchemy","Pydantic","PostgreSQL","Supabase","GCP","Firebase",1790471332073]