Show HN: Next Notes – local dictation, meeting notes, and actions app A developer released Next Notes, a native macOS dictation and meeting-notes app that runs fully on-device, supporting Apple and Parakeet transcription plus deterministic or on-device LLM cleanup. The app records calendar meetings automatically after a notification with Record now and Skip options, captures microphone and system output as two separate tracks for "You" and "Others" attribution, and offers follow-up actions in Gmail, Calendar, Drive and Docs only with user approval. A Windows build is exercised in CI but has not yet been used for a real microphone, key, or injection session on Windows hardware. Push-to-talk dictation for macOS. Hold a key, talk, release — cleaned-up text lands in the app you were already in. A Wispr Flow-shaped app, built native and fully on-device. Status: the macOS app is in daily use. It supports Apple and Parakeet transcription, deterministic or on-device LLM cleanup, per-app output formatting, personal dictionary bias and corrections, and an opt-in voice Command Mode for editing selected text. Dictated text returns to the app it was started in, even if you switch away while the model is still working. It also records meetings: the microphone and the system's own output are captured as two separate tracks and transcribed separately, which is where the "You" and "Others" attribution in a meeting transcript comes from. A finished recording then walks itself the rest of the way — tell the speakers on the system track apart, write Granola-style notes with a local LLM, and offer follow-up actions in Gmail, Calendar, Drive and Docs that only happen if you approve them. Everything runs on this Mac; the only network traffic is a model download, your own calendar, and a Workspace action you approved. The Windows app builds and is exercised in CI, but has not yet been used for a real microphone/key/injection session on Windows hardware. Meetings record themselves by default. Once Calendar access is granted, Next Notes reads your calendars Apple Calendar through EventKit, and optionally Google Calendar through its API , and any event that looks like a real meeting — a conference link or at least one other attendee, not all-day, not declined — is armed a minute before it starts and recorded when it does. It announces itself first with a notification carrying Record now and Skip , every event has its own Record checkbox in the Upcoming list, and the whole behaviour is one switch in Settings ▸ Meetings Record calendar meetings automatically , with the lead time beside it . Turn that off and meetings only record when you press the button. A call nobody put on a calendar arms the same card. Core Audio's process list says which processes hold the microphone and the speakers at once, which is what separates a call from dictation microphone only and from watching a video speakers only . When one settles, Next Notes raises the same armed card a calendar meeting raises — Record now and Skip , under the notch and in a notification — and records nothing until it is answered. Settings ▸ Meetings ▸ Calls holds the switch Notice when I'm on a call and the choice between Ask before recording and Start recording ; asking is the default on purpose, because a calendar meeting was agreed to in advance, an ad-hoc call was not, and consent law for recording one varies by jurisdiction. Under it, every app that has actually held your microphone gets its own Always record / Ask first / Never — a list built from what has happened on this Mac, so no bundle identifier is ever typed in, and empty until something uses the microphone. A browser is only ever asked about: Chrome holding the microphone might be a Meet call and might be any other tab, so Always record isn't offered for one, while Meet installed as a Chrome app has its own identity and is not restricted. A call that is already covered by an armed or recording meeting attaches to it rather than starting a second recording of the same conversation, and nothing is armed at all without the Microphone grant. Pressing Stop is not the end of a meeting. The session writes the transcript and hands the meeting to a pipeline that runs on its own: optionally identify the speakers on the system track, then write the notes, then — if the Workspace agent is enabled — read the notes and propose what to do about them. Each stage has its own status in the meeting list Identifying speakers , Writing notes , so the Record button comes back long before the Notes tab fills in. The island. On a MacBook with a notch, the Next Notes status lives in a small card hugging it — what is being dictated, a meeting about to start with Record now / Skip , the elapsed recording, notes being written, and an agent proposal with Approve / Dismiss . Hover expands it. On a display without a notch it is a floating capsule under the menu bar, and Settings ▸ Dictation can put dictation back on the old bottom-of-screen HUD instead. This app is built to run alongside other dictation tools without colliding with them, which is not automatic on macOS and is worth understanding before changing anything: - Bundle ID ai.pivotstudio.nextnotes — TCC keys Accessibility and Microphone grants to the bundle ID, so granting or revoking a permission here has no effect on any other app, and vice versa. - Executable NextNotes — one word, no space, so pkill -x NextNotes matches this binary and nothing else. The bundle is Next Notes.app ; only the binary inside it is spelled as one word. The Makefile only ever targets $ EXEC . - Hotkey is configurable Right ⌥ / fn / Right ⌘ precisely because another tool may already own the key you'd reach for first. The event tap inspects only its own keycode and passes everything else through untouched. If you run more than one dictation app, give each a different push-to-talk key. Two apps on the same key both record, and whichever injects text will fight the other. make install builds, bundles, signs, copies to /Applications, launches Then grant these permissions — none is optional, and none can be requested silently: | Permission | Where | Needed for | |---|---|---| | Accessibility | System Settings ▸ Privacy & Security ▸ Accessibility | The CGEventTap that sees the hotkey, and the AX text insert | | Microphone | Prompted on first dictation | Audio capture | | Audio Recording | System Settings ▸ Privacy & Security ▸ Audio Recording, after the first meeting | The process tap that records what the other people in a meeting say | | Calendar | Prompted from Settings ▸ Calendar, or the onboarding checklist | Reading which meetings are coming up, so they can record themselves | | Notifications | Prompted at first launch | The armed-meeting alert, "notes are ready", and agent proposals | Audio Recording is the odd one out: there is no API to ask whether it was granted, and a tap without it succeeds and returns pure silence rather than an error. So the Permissions checklist shows that row as unanswerable, and a flat "Others" meter during a meeting is the only symptom you will get. --selftest-systemaudio reports SYSTEM AUDIO SILENT for the same reason, and tccutil reset AudioCapture ai.pivotstudio.nextnotes resets that one row. Restart Next Notes after granting Accessibility. Then hold Right ⌥ and talk. TCC stores a code-signing requirement per entry, not just a path. An ad-hoc signature changes on every build, so the rebuilt binary stops satisfying the stored requirement — and the symptom is nasty: the Accessibility toggle still shows as on while the app is reported untrusted, and flipping it changes nothing because the stale row is the problem. The Makefile auto-detects a stable Developer ID through security find-identity and falls back to ad-hoc signing. Developer ID builds retain their grants across rebuilds. Ad-hoc builds need a fresh Accessibility grant after each rebuild; Next Notes now detects that the event tap did not arm, shows the repair action, and retries automatically after the grant is restored. If a grant ever does get wedged, reset that one row and re-add — never toggle: tccutil reset Accessibility ai.pivotstudio.nextnotes tccutil reset Microphone ai.pivotstudio.nextnotes Always pass the bundle ID. A bare tccutil reset Accessibility wipes every app on the machine. Then quit System Settings entirely ⌘Q before reopening — that pane caches its list and will otherwise show the row you just deleted. Keep the build out of iCloud. ~/Desktop and ~/Documents are file-provider synced on this machine; the sync engine can materialize/dematerialize files inside an .app and corrupt its signature. make install puts the running copy in /Applications . Other targets: make app bundle only , make run run in place , make clean . hold key ─► HotkeyMonitor ──► DictationController ◄── Settings │ ┌──────────┼──────────┐ ▼ ▼ ▼ AudioCapture HUDPanel TranscriptionEngine │ │ AudioChunk ──ordered──► AppleSpeechEngine │ transcript ▼ TextFormatter ▼ TextInjector ─► origin app selected text ─► Command hotkey ─► speech command ─► Foundation Model │ └─► guarded replacement calendar ─► CalendarService ─► MeetingScheduler ─► MeetingController │ ┌───────────┴───────────┐ ▼ ▼ AudioCapture SystemAudioCapture You Others │ │ ChunkedTranscriber ──────────┘ │ Parakeet, one queue ▼ MeetingPipeline │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ DiarizationService NotesService AgentService speaker labels local LLM proposals, approved one at a time The HUD must never take focus. HUDPanel is a .nonactivatingPanel with canBecomeKey == false . This is the load-bearing detail of the whole app: if the overlay took key status, the user's text field would lose focus and there'd be nothing left to inject into. Everything else is replaceable; this isn't. The hotkey needs a CGEventTap , not NSEvent . fn and left/right modifier discrimination don't surface through NSEvent.addGlobalMonitorForEvents or the Carbon hotkey API. A session event tap is the only way to see them — which is why Accessibility permission is a hard requirement rather than a nicety. Audio ordering is explicit. AudioCapture yields into an AsyncStream drained by a single task. Spawning a Task per buffer would be simpler and would silently corrupt the transcript, because unstructured tasks have no ordering guarantee. Buffers are copied, never borrowed. AVAudioEngine recycles the buffer it hands to a tap the instant the callback returns. AudioChunk 's @unchecked Sendable is only sound because AudioCapture always allocates fresh storage before handing off. Three swappable seams. TranscriptionEngine , TextFormatter , and TextCommandProcessor are protocols so speech recognition, transcript cleanup, and selected text editing can change providers without rewiring capture or injection. Sources/NextNotes/ ├── NextNotesApp.swift @main, AppDelegate, MenuBarExtra ├── Core/ │ ├── DictationController.swift state machine, wires everything │ ├── HotkeyMonitor.swift CGEventTap on .flagsChanged │ ├── AudioCapture.swift AVAudioEngine tap on the microphone │ ├── SystemAudioCapture.swift Core Audio process tap on everything the Mac plays │ ├── AudioConversion.swift format conversion + RMS, shared by both captures │ └── TextInjector.swift AX selection capture/insert, pasteboard+⌘V fallback ├── Transcription/ │ ├── TranscriptionEngine.swift protocol + AudioChunk │ ├── AppleSpeechEngine.swift SpeechAnalyzer / SpeechTranscriber │ └── ParakeetEngine.swift local FluidAudio/CoreML batch ASR ├── Context/ │ ├── ScreenContext.swift CandidateName + CandidateKind: what a harvest found │ ├── ScreenContextStore.swift one walk per hold, started at key-down, awaited twice │ ├── AXHarvester.swift the budgeted tree walk itself │ ├── AXAppAdapters.swift the three editors, by bundle id, hand-tested │ └── ContextPrivacyFilter.swift what is never read: secure fields, URL bars, finance apps ├── Dictionary/ │ └── DictionaryStore.swift the user's own corrections, and the ASR bias list ├── Formatting/ │ ├── TextFormatter.swift protocol + RuleBasedFormatter │ ├── FoundationModelFormatter.swift │ ├── S1MiniFormatter.swift local llama.cpp cleanup │ ├── FoundationModelCommandProcessor.swift │ ├── CleanupInstructions.swift the cleanup prompt, including the grounding block │ ├── Targets/ OutputProfile +PathReferenceStyle , OutputProfileStore, │ │ OutputFormatInstructions, InstalledApps │ └── LLM/ │ ├── LlamaBackend.swift one llama.cpp backend for both local models │ ├── LlamaHelpers.swift tokenize/detokenize/batch, shared │ ├── LLMProvider.swift protocol + LLMProviderID, provider resolution │ ├── NotesModels.swift the Qwen3.5-4B ModelSpec │ ├── NotesModelRuntime.swift the notes model, Metal-offloaded, self-unloading │ ├── LlamaLLMProvider.swift Qwen behind the protocol │ └── FoundationModelLLMProvider.swift Apple's on-device model behind it ├── Calendar/ │ ├── CalendarProvider.swift MeetingEvent + the protocol both accounts implement │ ├── CalendarService.swift every enabled calendar merged, polled, deduped │ ├── EventKitCalendarProvider.swift the Mac's own calendars │ ├── ConferenceURLDetector.swift Zoom/Meet/Teams/Webex links in a location or note │ ├── FakeCalendarProvider.swift --fake-calendar: one meeting 90 seconds out │ └── Google/ OAuth PKCE + loopback , Keychain token store, │ DTOs, and the Calendar API provider ├── Meetings/ │ ├── MeetingModels.swift Meeting, MeetingStatus, TranscriptSegment, AudioSource │ ├── MeetingStore.swift one directory per meeting under Application Support │ ├── ChunkedTranscriber.swift cuts a live track into windows, one per audio source │ ├── MeetingAudioWriter.swift stereo CAF, left = you, right = everyone else │ ├── MeetingSession.swift one recording: both captures, both transcribers │ ├── MeetingScheduler.swift arms, starts and stops calendar meetings on a 30 s tick │ ├── CallPolicy.swift the pure rules: both flags, self, denylist, debounce │ ├── CallDetector.swift watches Core Audio's process list for a live call │ ├── MeetingController.swift the single place a meeting starts or stops │ ├── MeetingPipeline.swift what happens after the last window: diarize, then notes │ ├── MeetingDiarizer.swift FluidAudio clustering over the system track │ ├── DiarizationService.swift owns the .diarizing → next transition, per meeting │ ├── NotesPrompts.swift every prompt and the five headings │ ├── NotesGenerator.swift single pass, or map/reduce when the transcript is long │ └── NotesService.swift owns the .summarizing → .done transition ├── Agent/ │ ├── GoogleWorkspaceCLI.swift locates gws , reads its auth state, runs it │ ├── WorkspaceTools.swift the eleven-tool catalogue and its risk classes │ ├── WorkspaceToolRunner.swift the only place a gws write is performed │ ├── AgentModels.swift AgentRisk, AgentProposal, AgentActionRecord │ ├── AgentPrompts.swift, AgentToolCall.swift, LLMProviderTools.swift │ ├── MeetingAgent.swift plans over notes + transcript, returns proposals │ ├── AgentService.swift files, announces, and executes approved proposals │ └── WorkspaceInstaller.swift writes the .command scripts Terminal opens ├── UI/ │ ├── DesignSystem.swift every colour, size, radius, duration token │ ├── MainWindow.swift NavigationSplitView shell │ ├── Sidebar.swift section list, plus the live "Recording" row │ ├── HUDPanel.swift non-activating floating panel │ ├── HUDView.swift capsule: red dot + level bar + transcript, glass │ ├── Island/ IslandGeometry where the notch is , IslandPanel, │ │ IslandState what to show , IslandView │ ├── Components/ LevelMeter +LevelBar , RecordingIndicator, │ │ ModelStatusRow, CopyButton, StatusChip, MarkdownView, │ │ ProblemBanner, FlowLayout, ThinkingOrbs/, │ │ OrbBackdrop, DottedField, GlassSurface, │ │ LabeledOrb, SectionHeading, OrbUnavailableView │ ├── Dictation/ DictationView, TranscriptionRow │ ├── Dictionary/ DictionaryPanel │ ├── Comparison/ ComparisonView │ ├── Meetings/ MeetingsView, MeetingLiveView, MeetingDetailView, │ │ TranscriptView, MeetingActionsView, │ │ ProposalArgumentsSheet, SpeakerNamesSheet │ ├── Onboarding/ PermissionsChecklist, OnboardingSheet │ └── Settings/ SettingsWindow + one Form per tab: General, Dictation, │ Meetings, Calendar, Workspace, Models, Permissions └── Support/ ├── Settings.swift, LocalModelStore.swift, Permissions.swift, Log.swift ├── ModelDownloader.swift one ModelSpec download path with progress + SHA-256 ├── Notifications.swift armed meetings, notes ready, agent proposals, and │ the action buttons on each └── NavigationState.swift which section is showing Each flag runs one thing and exits, so a subsystem can be answered from a terminal instead of by using the app. Run them from the installed bundle: S="/Applications/Next Notes.app/Contents/MacOS/NextNotes" "$S" --selftest-s1 S1-mini cleanup through the shared llama.cpp backend "$S" --selftest-parakeet Parakeet loads and transcribes a silent second "$S" --selftest-systemaudio 3 s process tap: frames, format, peak, RMS "$S" --selftest-transcribe