General
I Built a Mac Menu Bar App Because I Kept Saying "Wait, What?" in Every Meeting (Live Demo 🚀)
Varshith V Hegde DEV Community 周榜
2 views
Someone on a Zoom call said a URL yesterday. I was still writing down the previous bullet point. By the time I looked up, they had moved on. I muted myself, typed "sorry, can you repeat the link?" into chat, and waited.
Thirty seconds later they pasted it anyway.
I didn't need them to repeat it. I needed the last thirty seconds of audio sitting in memory, ready to replay. Not a screen recording I forgot to start. Not Otter in the cloud. Not scrubbing back through a two-hour lecture file.
So I built Huh?, a macOS menu bar app that holds the last 30 seconds to 2 minutes of whatever you're listening to in RAM, and replays it when you press ⌥⌘H.
I posted a short clip on X first. A few people asked if they'd pay $5 for it. This post is the longer answer: what it does, what broke while I was building it, and the parts I'm still working on before I'd call it properly shipped.
Demo
// Detect dark theme
var iframe = document.getElementById('tweet-2098771582688907506-266');
if (document.body.className.includes('dark-theme')) {
iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2098771582688907506&theme=dark"
}
That's the tweet that started it. Built a Mac app because I kept saying "wait, what did you just say?" in meetings, calls, podcasts, everything.
Full walkthrough on YouTube:
What the thing actually is
Huh? lives in your menu bar. It arms a rolling audio buffer, either system audio from one app, everything, or the microphone. You keep listening. When you miss something, you press ⌥⌘H. The last N seconds replay instantly. You can slow down to 0.75x or 0.5x without changing pitch. You can read an on-device transcript if what you missed was a name or a URL, not just the sound of it.
Nothing is written to disk unless you explicitly save a clip with ⌘S. Nothing is uploaded anywhere. The only network request the app can make is downloading a Whisper model, and only if you press Download in Settings.
That's the whole product loop. Everything else is engineering to make that loop feel instant and not creepy.
Why I didn't just use something that already exists
Before I wrote a line of Swift, I tried to talk myself out of building this. Here's what I actually considered.
Screen recording
Screen recording is a commitment. You start it before the moment. It writes to disk. It feels heavy for "I missed one sentence."
Huh? is retroactive by default. The buffer is already there. You're not deciding to record, you're deciding to replay.
Cloud transcription (Otter, etc.)
Great tools, wrong shape for this.
I didn't want to upload meeting audio. I didn't want another account. I didn't want a bot in my call. I wanted a hotkey that works when I'm alone watching a lecture at 1am.
Constraint became: on-device only. Transcription included, but nothing leaves the Mac unless I export a clip.
"Just pause and rewind"
YouTube has a rewind button. Zoom does not. Podcast apps do, badly. A menu bar utility follows you across apps. That's the point.
What you can actually do with it
Before the architecture diagram, here's what using it feels like.
Pick what to listen to. One app (Chrome, Zoom, whatever), everything playing through the system, or the mic. The buffer sizes to your setting, 30 seconds up to 2 minutes.
Replay with one hotkey. ⌥⌘H opens the HUD and starts playback from the end of the buffer, the moment you just missed. No file picker. No "where did I save that recording."
Slow it down without demons. 0.75x and 0.5x run through AVAudioUnitTimePitch so pitch stays natural. Useful when someone reads a URL at conference speed.
Read what you missed. Two on-device engines: Apple's SFSpeechRecognizer (no setup) or Whisper via WhisperKit (one-time model download, better on names and URLs). Click a word to seek. The transcript scrolls and highlights the active word during replay.
Save a clip if you need it. ⌘S exports AAC (with WAV fallback). That's the only path from the buffer to the filesystem. Deliberately.
Why I named it "Huh?"
Because that's the exact word my brain says right before my mouth says "sorry, can you repeat that?"
Every other name I tried sounded like enterprise software:
ReplayBuffer.app: accurate, dead on arrival
Audiograph: sounds like a $400/year dashboard
Last30: descriptive, forgettable
Huh? is a little stupid. That's why it stuck.
Now the technical part
Stack: Swift, SwiftUI in an NSPanel, CoreAudio process taps, AVAudioEngine for replay, optional WhisperKit. Built with Swift Package Manager and Command Line Tools, no Xcode project. build.sh assembles the .app bundle by hand.
Capture: a ring buffer on CoreAudio's realtime thread
Huh? uses a CoreAudio process tap (CATapDescription) on one app, on everything, or on the mic. Audio arrives as mono Float32 PCM and goes into a lock-free ring buffer.
Everything in RingBuffer.write runs on CoreAudio's IO thread. No locks, no allocation, no logging in that path. The storage is a raw pointer allocated once in init; the only shared state is atomics. Yes, there's an unowned(unsafe) sink on the IOProc.
/// Everything in `write(bufferList:)` runs on the realtime IO thread. A single
/// allocation, lock, `os_log` call or ARC retain in that path is an audible
/// glitch, not a style problem.
final class RingBuffer: @unchecked Sendable {
private let framesWritten = Atomic<Int>(0)
// ...
}
At 30 seconds of buffer you're looking at roughly 5.5 MB in RAM. At 2 minutes on a 48 kHz source, more like 22 MB. That's the number that matters on an 8 GB Mac.
Release builds are universal (arm64 + x86_64 via lipo). An arm64-only binary doesn't run slowly on Intel, it refuses to launch with a bad CPU type error. The site said "Apple silicon and Intel," so shipping a native-only build would've been a lie.
Replay: the Bluetooth headphones bug
Replay runs through AVAudioEngine → AVAudioUnitTimePitch.
The boring bug that ate a week: playback desync when the system audio device changes. Connect Bluetooth headphones mid-replay and the UI thinks you're at 0:18 while the audio is somewhere else entirely. Pause would sometimes "continue" while audio was still playing, especially with background system capture armed.
Fix: drive the playhead from the player's sample clock, not the wall clock. Use generation tokens so stale completion handlers can't fight the current playback. Use .dataPlayedBack for end-of-playback detection instead of assuming the node stopped when you told it to.
Transcripts: Apple's recognizer lies on long audio
This one made me angry.
SFSpeechRecognizer with requiresOnDeviceRecognition = true looks like it handles long buffers. It doesn't. Past about a minute it starts returning only the beginning, and the timestamps for later words are nonsense.
Measured on 107 seconds of continuous speech, it reported 0.00–30.03 and then 60.06–83.76. The second half was stacked on top of the first. Clicking a word near the end seeked to the wrong moment.
Since Huh? supports a two-minute buffer, that was a guaranteed broken feature.
Fix: chunk into ~40 second pieces, split at the quietest point near each boundary, transcribe sequentially, offset the timings. Completeness and seek accuracy came back together.
/// Past roughly a minute `SFSpeechRecognizer` starts returning only the
/// first part of what it heard, and the timestamps it reports for later
/// utterances stop meaning anything.
private static let chunkSeconds: Double = 40
Before either engine sees audio, AudioPrep runs a high-pass and normalizes gain. Quiet lecture audio peaking at 0.02 was returning half a transcript for reasons that weren't mystical, just quiet.
Apple
Whisper (WhisperKit)
Setup
none
one-time model download
186 s of speech (measured)
427 words, ~25 s
450 words, ~1.8 s on M-series
Intel Macs
yes
no, hidden in Settings
Whisper on the x86_64 slice didn't degrade gracefully. It segfaulted the instant it touched the model. Verified by running both slices of one universal binary: arm64 exit 0, x86_64 exit 139 at model load. A crash is worse than a missing menu item, so on Intel the engine picker doesn't show Whisper at all.
The signing bug that delivers perfect silence
If you codesign ad-hoc (codesign -s -), macOS will let your audio tap run and deliver silence. No prompt. No error. Just zeros.
state: armed
peak: 0.0000 # every sample zero
TCC ties permissions to a signing identity. Ad-hoc has none, so macOS never prompts, never records a decision, and the tap looks healthy while giving you nothing. I lost hours to this before I believed it.
For development, Tools/dev-signing-identity.sh creates a local cert. For strangers, I still need Developer ID and notarization, which is the current shipping blocker.
Memory, for something that stays in the menu bar all day
A menu bar app is resident all day. What it holds matters more than what it peaks at.
--memory-report walks the whole path and prints footprint at every step. On an M1 Pro, 16 GB, with a 120 s buffer:
State
Footprint
Launched, idle
~13 MB
120 s ring full
+16 MB
Apple transcript, settled
~46 MB
Whisper Base loaded
~200 MB
Whisper evicted after idle
~90 MB
Whisper pipelines are dropped ~150 seconds after last use. Keeping a 1.5 GB large model resident because you transcribed once at lunch isn't acceptable for a utility app.
I also killed two dumb copies: ReplayEngine used to hold a full AVAudioPCMBuffer of the whole snapshot and slice again on every seek. AudioPrep used to copy the whole window at 48 kHz before resampling to 16 kHz. Both now work from slices. Sounds obvious in retrospect. Wasn't obvious when --memory-report showed residual climbing across passes.
Licensing without phoning home
Keys are <base64 payload>.<base64 Ed25519 signature>, verified offline against a compiled-in public key. No activation server, that's the only way the "no network" promise stays true.
There is no trial. The app doesn't arm a tap until a key is entered. Asking for microphone access before someone can use the product felt wrong, so the welcome flow is: key first, then permissions, then pick a source.
The gate lives in CaptureController.arm, not only in the UI. An unlicensed copy shouldn't hold a microphone open or twenty megabytes of audio.
What I deliberately didn't build
No cloud account. Purchase flow goes through Dodo Payments; a webhook signs a key and emails it. The app never calls home to unlock.
No telemetry. No update check at launch. No analytics in the binary. (The marketing site has PostHog; the app does not.)
No "AI assistant." It's a rewind button, not a chatbot with opinions about your meetings.
What's left before I'd call it shipped
[ ] Apple notarization: works on my machine, needs Developer ID + notarytool for everyone else
[ ] Test on macOS 15 hardware: built against 15.x SDK, most of my dogfooding happened on newer builds
[ ] Sparkle or similar for updates
[ ] A landing page demo that matches the real HUD
I'd rather say these out loud now than have the first paying user hit Gatekeeper and bounce.
Would you pay $5?
That's what I asked on X, and I meant it.
Huh? isn't a weekend hack. It's months of chasing why pause desyncs on Bluetooth and why Apple's transcriber lies about timestamps. But it's also a menu bar utility, not a team plan. I don't want to price it like SaaS rent.
My current thinking:
$5 is an easy yes for early adopters who feel this problem weekly
$12 to $15 is probably where it lands once notarization and polish are done
The site copy says $24 today, which might be right if the audience is professionals who live in calls
If you've read this far: would you pay, and for what, lectures, meetings, podcasts? That's genuinely useful signal before I flip the buy button on.
Try it (soon)
X demo: twitter.com/VarshithVhegde1/status/2098771582688907506
If you want early access when it launches, drop a comment or email me at varshithvh@gmail.com. Tell me what you'd use it for.
Read original: https://dev.to/varshithvhegde/i-built-a-mac-menu-bar-app-because-i-kept-saying-wait-what-in-every-meeting-live-demo--3gkj
← Previous
Day 41: EXPOSE Does Not Publish, and a KMS Key Has No Name
Next →
Vibe Coding Isn't the Problem. Calling It Engineering Is
Related
Add AI search to existing application
General
2
DEV Community 周榜
NocoBase updates by primary key, not by your filter
General
2
DEV Community 周榜
Vibe Coding Isn't the Problem. Calling It Engineering Is
General
2
DEV Community 周榜
Day 41: EXPOSE Does Not Publish, and a KMS Key Has No Name
General
0
DEV Community 周榜
Comments0
No comments yet — be the first