{"slug": "show-hn-next-notes-local-dictation-meeting-notes-and-actions-app", "title": "Show HN: Next Notes – local dictation, meeting notes, and actions app", "summary": "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.", "body_md": "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.\n\n**Status:** the macOS app is in daily use. It supports Apple and Parakeet transcription,\ndeterministic or on-device LLM cleanup, per-app output formatting, personal dictionary bias\nand corrections, and an opt-in voice Command Mode for editing selected text. Dictated text\nreturns to the app it was started in, even if you switch away while the model is still\nworking. It also records meetings: the\nmicrophone and the system's own output are captured as two separate tracks and transcribed\nseparately, which is where the \"You\" and \"Others\" attribution in a meeting transcript comes\nfrom. A finished recording then walks itself the rest of the way — tell the speakers on the\nsystem track apart, write Granola-style notes with a local LLM, and offer follow-up actions\nin Gmail, Calendar, Drive and Docs that only happen if you approve them. Everything runs on\nthis Mac; the only network traffic is a model download, your own calendar, and a Workspace\naction you approved. The Windows app builds and is exercised in CI, but has not yet been\nused for a real microphone/key/injection session on Windows hardware.\n\n**Meetings record themselves by default.** Once Calendar access is granted, Next Notes reads\nyour calendars (Apple Calendar through EventKit, and optionally Google Calendar through its\nAPI), and any event that looks like a real meeting — a conference link or at least one other\nattendee, not all-day, not declined — is armed a minute before it starts and recorded when\nit does. It announces itself first with a notification carrying **Record now** and **Skip**,\nevery event has its own Record checkbox in the Upcoming list, and the whole behaviour is one\nswitch in Settings ▸ Meetings (*Record calendar meetings automatically*, with the lead time\nbeside it). Turn that off and meetings only record when you press the button.\n\n**A call nobody put on a calendar arms the same card.** Core Audio's process list says which\nprocesses hold the microphone and the speakers at once, which is what separates a call from\ndictation (microphone only) and from watching a video (speakers only). When one settles,\nNext Notes raises the same armed card a calendar meeting raises — **Record now** and **Skip**,\nunder the notch and in a notification — and records nothing until it is answered. Settings ▸\nMeetings ▸ **Calls** holds the switch (*Notice when I'm on a call*) and the choice between\n*Ask before recording* and *Start recording*; asking is the default on purpose, because a\ncalendar meeting was agreed to in advance, an ad-hoc call was not, and consent law for\nrecording one varies by jurisdiction. Under it, **every app that has actually held your\nmicrophone** gets its own *Always record* / *Ask first* / *Never* — a list built from what\nhas happened on this Mac, so no bundle identifier is ever typed in, and empty until something\nuses the microphone. A browser is only ever asked about: Chrome holding the microphone might\nbe a Meet call and might be any other tab, so *Always record* isn't offered for one, while\nMeet installed as a Chrome app has its own identity and is not restricted. A call that is\nalready covered by an armed or recording meeting attaches to it rather than starting a second\nrecording of the same conversation, and nothing is armed at all without the Microphone\ngrant.\n\n**Pressing Stop is not the end of a meeting.** The session writes the transcript and hands\nthe meeting to a pipeline that runs on its own: optionally identify the speakers on the\nsystem track, then write the notes, then — if the Workspace agent is enabled — read the\nnotes and propose what to do about them. Each stage has its own status in the meeting list\n(*Identifying speakers*, *Writing notes*), so the Record button comes back long before the\nNotes tab fills in.\n\n**The island.** On a MacBook with a notch, the Next Notes status lives in a small card\nhugging it — what is being dictated, a meeting about to start with **Record now** /\n**Skip**, the elapsed recording, notes being written, and an agent proposal with\n**Approve** / **Dismiss**. Hover expands it. On a display without a notch it is a floating\ncapsule under the menu bar, and Settings ▸ Dictation can put dictation back on the old\nbottom-of-screen HUD instead.\n\nThis 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:\n\n- **Bundle ID `ai.pivotstudio.nextnotes`** — TCC keys Accessibility and Microphone\ngrants to the bundle ID, so granting or revoking a permission here has no effect on any\nother app, and vice versa.\n- **Executable `NextNotes`** — one word, no space, so` pkill -x NextNotes` matches this\nbinary and nothing else. The bundle is`Next Notes.app` ; only the binary inside it is\nspelled as one word. The`Makefile` only ever targets`$(EXEC)` .\n- **Hotkey is configurable** (Right ⌥ / fn / Right ⌘) precisely because another tool may\nalready own the key you'd reach for first. The event tap inspects only its own keycode\nand passes everything else through untouched.\n\nIf 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.\n\n```\nmake install     # builds, bundles, signs, copies to /Applications, launches\n```\n\nThen grant these permissions — none is optional, and none can be requested silently:\n\n| Permission | Where | Needed for | \n|---|---|---|\n| **Accessibility** | System Settings ▸ Privacy & Security ▸ Accessibility | The `CGEventTap` that sees the hotkey, and the AX text insert | \n| **Microphone** | Prompted on first dictation | Audio capture | \n| **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 | \n| **Calendar** | Prompted from Settings ▸ Calendar, or the onboarding checklist | Reading which meetings are coming up, so they can record themselves | \n| **Notifications** | Prompted at first launch | The armed-meeting alert, \"notes are ready\", and agent proposals | \n\nAudio Recording is the odd one out: there is no API to ask whether it was granted, and a\ntap without it succeeds and returns pure silence rather than an error. So the Permissions\nchecklist shows that row as unanswerable, and a flat \"Others\" meter during a meeting is the\nonly symptom you will get. `--selftest-systemaudio` reports `SYSTEM_AUDIO_SILENT` for the\nsame reason, and `tccutil reset AudioCapture ai.pivotstudio.nextnotes` resets that one row.\n\nRestart Next Notes after granting Accessibility. Then hold **Right ⌥** and talk.\n\nTCC stores a *code-signing requirement* per entry, not just a path. An ad-hoc signature\nchanges on every build, so the rebuilt binary stops satisfying the stored requirement —\nand the symptom is nasty: the Accessibility toggle still **shows as on** while the app is\nreported untrusted, and flipping it changes nothing because the stale row is the problem.\n\nThe `Makefile` auto-detects a stable Developer ID through `security find-identity` and falls\nback to ad-hoc signing. Developer ID builds retain their grants across rebuilds. Ad-hoc builds\nneed a fresh Accessibility grant after each rebuild; Next Notes now detects that the event tap\ndid not arm, shows the repair action, and retries automatically after the grant is restored.\n\nIf a grant ever does get wedged, reset that one row and re-add — never toggle:\n\n```\ntccutil reset Accessibility ai.pivotstudio.nextnotes\ntccutil reset Microphone   ai.pivotstudio.nextnotes\n```\n\nAlways pass the bundle ID. A bare `tccutil reset Accessibility` wipes **every** app on the\nmachine. Then quit System Settings entirely (⌘Q) before reopening — that pane caches its\nlist and will otherwise show the row you just deleted.\n\n**Keep the build out of iCloud.** `~/Desktop` and `~/Documents` are file-provider synced\non this machine; the sync engine can materialize/dematerialize files inside an `.app` and\ncorrupt its signature. `make install` puts the running copy in `/Applications`.\n\nOther targets: `make app` (bundle only), `make run` (run in place), `make clean`.\n\n```\n hold key ─► HotkeyMonitor ──► DictationController ◄── Settings\n                                │\n                     ┌──────────┼──────────┐\n                     ▼          ▼          ▼\n              AudioCapture  HUDPanel   TranscriptionEngine\n                     │                      │\n                (AudioChunk) ──ordered──► AppleSpeechEngine\n                                            │\n                                       (transcript)\n                                            ▼\n                                      TextFormatter\n                                            ▼\n                                      TextInjector ─► origin app\n\n selected text ─► Command hotkey ─► speech command ─► Foundation Model\n                                                              │\n                                                              └─► guarded replacement\n\n calendar ─► CalendarService ─► MeetingScheduler ─► MeetingController\n                                                          │\n                                              ┌───────────┴───────────┐\n                                              ▼                       ▼\n                                        AudioCapture          SystemAudioCapture\n                                          (You)                    (Others)\n                                              │                       │\n                                        ChunkedTranscriber ──────────┘\n                                              │ (Parakeet, one queue)\n                                              ▼\n                                        MeetingPipeline\n                                              │\n                          ┌───────────────────┼───────────────────┐\n                          ▼                   ▼                   ▼\n                  DiarizationService    NotesService        AgentService\n                  (speaker labels)      (local LLM)      (proposals, approved\n                                                          one at a time)\n```\n\n**The HUD must never take focus.** `HUDPanel` is a `.nonactivatingPanel` with\n`canBecomeKey == false`. This is the load-bearing detail of the whole app: if the overlay\ntook key status, the user's text field would lose focus and there'd be nothing left to\ninject into. Everything else is replaceable; this isn't.\n\n**The hotkey needs a `CGEventTap`, not `NSEvent`.** `fn` and left/right modifier\ndiscrimination don't surface through `NSEvent.addGlobalMonitorForEvents` or the Carbon\nhotkey API. A session event tap is the only way to see them — which is why Accessibility\npermission is a hard requirement rather than a nicety.\n\n**Audio ordering is explicit.** `AudioCapture` yields into an `AsyncStream` drained by a\nsingle task. Spawning a `Task` per buffer would be simpler and would silently corrupt the\ntranscript, because unstructured tasks have no ordering guarantee.\n\n**Buffers are copied, never borrowed.** `AVAudioEngine` recycles the buffer it hands to a\ntap the instant the callback returns. `AudioChunk`'s `@unchecked Sendable` is only sound\nbecause `AudioCapture` always allocates fresh storage before handing off.\n\n**Three swappable seams.** `TranscriptionEngine`, `TextFormatter`, and\n`TextCommandProcessor` are protocols so speech recognition, transcript cleanup, and selected\ntext editing can change providers without rewiring capture or injection.\n\n```\nSources/NextNotes/\n├── NextNotesApp.swift              @main, AppDelegate, MenuBarExtra\n├── Core/\n│   ├── DictationController.swift   state machine, wires everything\n│   ├── HotkeyMonitor.swift         CGEventTap on .flagsChanged\n│   ├── AudioCapture.swift          AVAudioEngine tap on the microphone\n│   ├── SystemAudioCapture.swift    Core Audio process tap on everything the Mac plays\n│   ├── AudioConversion.swift       format conversion + RMS, shared by both captures\n│   └── TextInjector.swift          AX selection capture/insert, pasteboard+⌘V fallback\n├── Transcription/\n│   ├── TranscriptionEngine.swift   protocol + AudioChunk\n│   ├── AppleSpeechEngine.swift     SpeechAnalyzer / SpeechTranscriber\n│   └── ParakeetEngine.swift        local FluidAudio/CoreML batch ASR\n├── Context/\n│   ├── ScreenContext.swift         CandidateName + CandidateKind: what a harvest found\n│   ├── ScreenContextStore.swift    one walk per hold, started at key-down, awaited twice\n│   ├── AXHarvester.swift           the budgeted tree walk itself\n│   ├── AXAppAdapters.swift         the three editors, by bundle id, hand-tested\n│   └── ContextPrivacyFilter.swift  what is never read: secure fields, URL bars, finance apps\n├── Dictionary/\n│   └── DictionaryStore.swift       the user's own corrections, and the ASR bias list\n├── Formatting/\n│   ├── TextFormatter.swift         protocol + RuleBasedFormatter\n│   ├── FoundationModelFormatter.swift\n│   ├── S1MiniFormatter.swift       local llama.cpp cleanup\n│   ├── FoundationModelCommandProcessor.swift\n│   ├── CleanupInstructions.swift   the cleanup prompt, including the grounding block\n│   ├── Targets/                    OutputProfile (+PathReferenceStyle), OutputProfileStore,\n│   │                               OutputFormatInstructions, InstalledApps\n│   └── LLM/\n│       ├── LlamaBackend.swift      one llama.cpp backend for both local models\n│       ├── LlamaHelpers.swift      tokenize/detokenize/batch, shared\n│       ├── LLMProvider.swift       protocol + LLMProviderID, provider resolution\n│       ├── NotesModels.swift       the Qwen3.5-4B ModelSpec\n│       ├── NotesModelRuntime.swift the notes model, Metal-offloaded, self-unloading\n│       ├── LlamaLLMProvider.swift  Qwen behind the protocol\n│       └── FoundationModelLLMProvider.swift   Apple's on-device model behind it\n├── Calendar/\n│   ├── CalendarProvider.swift      MeetingEvent + the protocol both accounts implement\n│   ├── CalendarService.swift       every enabled calendar merged, polled, deduped\n│   ├── EventKitCalendarProvider.swift  the Mac's own calendars\n│   ├── ConferenceURLDetector.swift Zoom/Meet/Teams/Webex links in a location or note\n│   ├── FakeCalendarProvider.swift  --fake-calendar: one meeting 90 seconds out\n│   └── Google/                     OAuth (PKCE + loopback), Keychain token store,\n│                                   DTOs, and the Calendar API provider\n├── Meetings/\n│   ├── MeetingModels.swift         Meeting, MeetingStatus, TranscriptSegment, AudioSource\n│   ├── MeetingStore.swift          one directory per meeting under Application Support\n│   ├── ChunkedTranscriber.swift    cuts a live track into windows, one per audio source\n│   ├── MeetingAudioWriter.swift    stereo CAF, left = you, right = everyone else\n│   ├── MeetingSession.swift        one recording: both captures, both transcribers\n│   ├── MeetingScheduler.swift      arms, starts and stops calendar meetings on a 30 s tick\n│   ├── CallPolicy.swift            the pure rules: both flags, self, denylist, debounce\n│   ├── CallDetector.swift          watches Core Audio's process list for a live call\n│   ├── MeetingController.swift     the single place a meeting starts or stops\n│   ├── MeetingPipeline.swift       what happens after the last window: diarize, then notes\n│   ├── MeetingDiarizer.swift       FluidAudio clustering over the system track\n│   ├── DiarizationService.swift    owns the .diarizing → next transition, per meeting\n│   ├── NotesPrompts.swift          every prompt and the five headings\n│   ├── NotesGenerator.swift        single pass, or map/reduce when the transcript is long\n│   └── NotesService.swift          owns the .summarizing → .done transition\n├── Agent/\n│   ├── GoogleWorkspaceCLI.swift    locates `gws`, reads its auth state, runs it\n│   ├── WorkspaceTools.swift        the eleven-tool catalogue and its risk classes\n│   ├── WorkspaceToolRunner.swift   the only place a `gws` write is performed\n│   ├── AgentModels.swift           AgentRisk, AgentProposal, AgentActionRecord\n│   ├── AgentPrompts.swift, AgentToolCall.swift, LLMProviderTools.swift\n│   ├── MeetingAgent.swift          plans over notes + transcript, returns proposals\n│   ├── AgentService.swift          files, announces, and executes approved proposals\n│   └── WorkspaceInstaller.swift    writes the .command scripts Terminal opens\n├── UI/\n│   ├── DesignSystem.swift          every colour, size, radius, duration token\n│   ├── MainWindow.swift            NavigationSplitView shell\n│   ├── Sidebar.swift               section list, plus the live \"Recording\" row\n│   ├── HUDPanel.swift              non-activating floating panel\n│   ├── HUDView.swift               capsule: red dot + level bar + transcript, glass\n│   ├── Island/                     IslandGeometry (where the notch is), IslandPanel,\n│   │                               IslandState (what to show), IslandView\n│   ├── Components/                 LevelMeter (+LevelBar), RecordingIndicator,\n│   │                               ModelStatusRow, CopyButton, StatusChip, MarkdownView,\n│   │                               ProblemBanner, FlowLayout, ThinkingOrbs/,\n│   │                               OrbBackdrop, DottedField, GlassSurface,\n│   │                               LabeledOrb, SectionHeading, OrbUnavailableView\n│   ├── Dictation/                  DictationView, TranscriptionRow\n│   ├── Dictionary/                 DictionaryPanel\n│   ├── Comparison/                 ComparisonView\n│   ├── Meetings/                   MeetingsView, MeetingLiveView, MeetingDetailView,\n│   │                               TranscriptView, MeetingActionsView,\n│   │                               ProposalArgumentsSheet, SpeakerNamesSheet\n│   ├── Onboarding/                 PermissionsChecklist, OnboardingSheet\n│   └── Settings/                   SettingsWindow + one Form per tab: General, Dictation,\n│                                   Meetings, Calendar, Workspace, Models, Permissions\n└── Support/\n    ├── Settings.swift, LocalModelStore.swift, Permissions.swift, Log.swift\n    ├── ModelDownloader.swift       one ModelSpec download path with progress + SHA-256\n    ├── Notifications.swift         armed meetings, notes ready, agent proposals, and\n    │                               the action buttons on each\n    └── NavigationState.swift       which section is showing\n```\n\nEach 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:\n\n```\nS=\"/Applications/Next Notes.app/Contents/MacOS/NextNotes\"\n\n\"$S\" --selftest-s1                      # S1-mini cleanup through the shared llama.cpp backend\n\"$S\" --selftest-parakeet                # Parakeet loads and transcribes a silent second\n\"$S\" --selftest-systemaudio             # 3 s process tap: frames, format, peak, RMS\n\"$S\" --selftest-transcribe <wav>        # WAV → ChunkedTranscriber → segments JSON + RTF\n\"$S\" --selftest-calendar                # provider states, deduped events, auto-record rules\n\"$S\" --selftest-notes <wav> [--diarize] # transcribe → notes; prints tok/s and peak RSS\n\"$S\" --selftest-llm-metal               # a Metal runtime and a CPU runtime in one process\n\"$S\" --selftest-calls                   # who holds mic + speakers now, and every CallPolicy rule\n#                                         including arming: correlation, the grant guard, ask-first\n\"$S\" --selftest-island                  # island geometry per display, panel invariants, states\n\"$S\" --selftest-orb                     # the four ThinkingOrb modes at both sizes\n\"$S\" --selftest-gws                     # locate `gws`, read its version and auth state\n\"$S\" --selftest-agent <meeting-dir>     # proposals as JSON; executes nothing\n\"$S\" --selftest-dictation               # every way a hold can go wrong still ends at idle\n\"$S\" --selftest-context [bundle-id]     # harvest an editor's window: names, paths, ms,\n#                                         the grounding block, and what stopped the walk\n```\n\nEach prints a single `<NAME>_OK` or `<NAME>_FAILED` line last, so they can be read by a\nscript. Two are worth knowing about in detail:\n\n`--selftest-dictation` is the one that guards the tail. It drives `DictationController`\nwith a real microphone but a fake engine: an engine whose `finish()` never returns, one that\nleaves its transcript stream open, and one so slow to start that the key is released before\nit is ready. Each has to come back to `.idle` and say what went wrong. Unbounded — which is\nwhat the tail used to be — the first two park the controller in `.finishing`, and the HUD\nand the island both draw that as a live recording, which is what \"it looks stuck and it\nkeeps recording in the background\" is a description of.\n\n`--selftest-context` is the only way to find out whether the screen-name harvest works,\nbecause nothing else can: CI cannot build this target, the tests reach only the\nplatform-neutral scoring in `NextNotesDictionary`, and the equivalent log line needs a real\nhold with a microphone and grammar-repair cleanup. It defaults to Cursor and takes any\nbundle identifier with an adapter. **It fails on a stub tree**, which is the normal state of\na fresh VS Code fork: those editors expose nothing until `editor.accessibilitySupport` is\nset to `on`, and the failure line is the sentence that says so.\n\n`--selftest-calendar` prints each provider's state, what it would record out of the next\nday, and the result of running the auto-record rules over invented events — that last half\nneeds no account and no grant, so a change that starts recording declined invitations or\nall-day blocks fails it anywhere. `--selftest-agent` likewise checks the tool catalogue,\nthe `<tool_call>` parser and the risk gate without a Google account, so it is runnable on a\nmachine where `gws` was never signed in.\n\n`--fake-calendar` is not a self-test but a modifier: it replaces every real provider with\none invented meeting starting 90 seconds out, so the whole armed → notified → recording →\ndone path can be watched without waiting for a real meeting. It never runs alongside the\nreal calendars, so a test can't record something that is actually happening.\n\nA meeting is a directory under `~/Library/Application Support/Next Notes/Meetings/<uuid>/`\nholding `meeting.json`, `transcript.json`, `notes.md`, `proposals.json` and — while one is\nneeded — `audio.caf`. Nothing about a meeting lives only in memory, which is what lets the\napp be quit in the middle of one and repair it at the next launch.\n\n**Two tracks, never a mixdown.** The microphone and a Core Audio process tap on everything\nthe Mac plays are captured, transcribed and stored separately, and that is where \"You\" and\n\"Others\" come from. `ChunkedTranscriber` cuts each track into windows — the first pause\nafter 30 seconds, hard cut at 60 — and one `TranscriptionQueue` serialises Parakeet across\nboth. The known cost of using the built-in microphone with laptop speakers is that remote\nvoices bleed onto the mic track.\n\n**Speakers.** With *Tell the other speakers apart* on (Settings ▸ Meetings), FluidAudio's\noffline diarizer clusters the system track after the recording and the clusters are mapped\nonto transcript segments by overlap, giving *Speaker 1…n* — renamable, with the invite's\nattendees offered as suggestions. Labels land per sentence: a transcription window is split\non pauses and sentence endings before it is stored, so each turn carries its own speaker\nrather than the whole window taking whoever held most of it.\n\n**Notes.** `NotesGenerator` writes markdown under five fixed headings — Summary, Key\npoints, Decisions, Action items, Open questions — in one pass when the transcript fits the\nmodel's context, and otherwise by mapping chunks to attributed facts and reducing them.\nTwo providers are interchangeable and either can be picked per meeting from **Regenerate**:\n\n| Provider | Where it runs | Context | Notes | \n|---|---|---|---|\n| **Qwen3.5-4B Q4_K_M** (default) | bundled llama.cpp, Metal | up to 32K here | 2.74 GB download from Settings ▸ Models; frees itself ten minutes after the last generation | \n| **Apple Foundation Models** | the OS | 4096 tokens | no download; long transcripts always take the map/reduce path | \n\nNotes are written automatically when a recording finishes (*Write notes when a meeting\nends*), and **Regenerate** rewrites them with either provider afterwards.\n\nOptional, off until you turn it on, and it is a proposer rather than an actor. After a\nmeeting — and, if *Watch during the meeting* is on, every two minutes during one — the same\nlocal LLM reads the notes and transcript and returns proposals: create a Doc with the notes,\nemail the action items to the people who were on the invite, put a dated follow-up on the\ncalendar. They appear in the meeting's **Actions** tab, on the island, and as a notification.\n\nTools are performed by Google's [`gws` CLI](https://github.com/googleworkspace/cli), which\nSettings ▸ Workspace installs and signs in through Terminal — four states, each with the one\nnext step it needs. Nothing is silent: `gws` is never installed, authorised or signed into\nbehind your back, and the tab shows the whole catalogue.\n\nEvery tool is graded by what cannot be taken back, and the grade decides who presses the button:\n\n| Class | Tools | Behaviour | \n|---|---|---|\n| **read** | `search_email` ,`get_agenda` ,`find_drive_files` ,`read_doc` | Run by the agent itself while it plans, if *Let it look things up* is on | \n| **write** | `create_doc` ,`append_doc` ,`upload_to_drive` ,`create_event` ,`draft_email` | One approval each | \n| **send** | `send_email` ,`reply_email` | One approval each, with the full message shown first, and only from the Actions tab | \n\nArguments are editable before approval, results (a document link, an event, a message id) are recorded on the meeting, and an unanswered proposal survives a quit.\n\nDefault is Apple's **`SpeechAnalyzer` / `SpeechTranscriber`**, new in macOS 26: no\ndependency, no bundled model, no cloud path, real streaming with `.volatileResults` so\ntext appears while you're still talking. The OS downloads and manages model assets, so the\nfirst run for a locale may pause on `AssetInstallationRequest`.\n\nThe other built-in choice is **Parakeet v3** via FluidAudio and CoreML. Next Notes validates\nevery required model artifact before marking it ready and prepares it when selected. The\nencoder currently uses deterministic CPU placement because accelerator compilation can\nstall or wedge on macOS 26.\nBoth engines feed the same cleanup, dictionary, history, and injection pipeline.\n\n|  | Apple SpeechTranscriber | Parakeet v3 (FluidAudio) | \n|---|---|---|\n| Dependency | none | SwiftPM | \n| Model download | OS-managed | one-time, about 470 MB | \n| Processing | streaming | batch on key release | \n| Compute | OS-managed | local CoreML, CPU placement | \n\n- \n**Rule-based cleanup** removes common fillers, interprets spoken line/paragraph markers,\nfixes spacing, capitalizes sentences, and adds terminal punctuation.\n- \n**On-device cleanup** is selectable in Settings. Apple's Foundation Models formatter\nhandles false starts, spoken self-corrections, paragraphing, and list formatting. S1-mini\nby Superwhisper is an embedded open-weight transcript normalizer; Next Notes downloads its\n462 MiB Q4 model once, verifies its SHA-256 digest, and runs it through the bundled\nllama.cpp runtime with no network request during formatting. Both fall back to the\ndeterministic pass when unavailable or unsuccessful.\n- \n**Cleanup controls** expose five user-facing tone positions, list formatting, and a\ngeneral/email context. S1-mini natively has four controls, so Balanced maps to its\nsemi-formal control; the Apple formatter receives all five directly.\n- \n**Personal dictionary** entries are supplied to Apple Speech as short`AnalysisContext.contextualStrings` before audio arrives. Correction pairs then run\ndeterministically after cleanup on both macOS and Windows. This implements names and short\njargon; pronunciation-trained`SFCustomLanguageModelData` models are not built.\n- \n**Per-app output profiles** decide which formatting marks the cleanup pass is allowed to\nemit.`formatting.txt` in Application Support maps a bundle identifier to what that app can\nactually render — Slack takes bullets and fenced code but shows a pipe table as pipes;\nObsidian renders all of it; Terminal renders none — and Settings ▸ Formatting edits the same\ntable.**An app with no row gets plain prose** , deliberately: emitting`**bold**` into\nsomething that shows the asterisks is worse than emitting nothing. The profile is resolved\nfrom the app that was frontmost when the key went down, not the one frontmost when the text\nlands, and it overrides*Format spoken lists* — a list the target renders as literal hyphens\nis worse than the prose it replaced. S1-mini is the one engine this cannot reach, because it\ntakes no instructions at all; with grammar repair on, the second pass is a general-purpose\nmodel and honours it.\n- \n**Where the text goes** is the app you started dictating into. Transcription and cleanup take\nseconds and you are free to move on inside them, so the target is captured at key-down and\nreturned to at insertion. Settings ▸ Dictation chooses what happens when you*have* moved:\nswitch back and insert (the default), insert wherever you now are, or copy to the clipboard\nand disturb nothing. If the original app cannot be brought back — it quit — the text is left\non the clipboard and the HUD says so, rather than vanishing.\n- \n**Learning from your corrections.** Any past dictation in the list can be corrected in\nplace. The diff between what the engine wrote and what you changed it to is read by`CorrectionLearner` and proposed as dictionary rules —`Kajo` →`Kadjo` ,`cloud code` →`Claude Code` ,`vercel` →`Vercel` . Settings ▸ Dictation chooses whether to ask, file them\nsilently, or learn nothing. The original transcript is kept beside the edit rather than\noverwritten, because the pair is the evidence.Most of what the diff finds is thrown away, and that is the point: a rule fires on every future transcript, so learning \"I think\" → \"we should\" from someone rewriting a sentence is worse than learning nothing. Pairs must be one to three words a side, must not be a very common word, and must be similar enough to read as a mis-hearing rather than a rephrasing. An edit that yields more than five candidates was a rewrite, and yields none. `--selftest-learn` covers the rejections as well as the acceptances.**This deliberately does not watch the app the text landed in.** That was the first design,\nand`--selftest-axreadback` killed it: Cursor, Chrome, Terminal, Messages, ChatGPT and Claude\nexpose*zero* readable text elements, so it would have fired almost nowhere while reading the\nuser's text in every app. Reading a run back out of our own history works everywhere and\nwatches nothing.\n- \n**Command Mode** is opt-in. Select editable text, hold its independently configured second\nhotkey, and speak an instruction such as \"make this more formal.\" Next Notes snapshots the\nAX selection, applies the instruction with Apple's on-device model, and replaces it only if\nfocus and selection are unchanged. A timeout/model failure leaves the source text intact.\n\nThe site source is `site/` — Vite, React, TypeScript, Tailwind and Framer Motion. **There is\nno `build/` or `dist/` folder.** Vite is pointed at `docs/` instead, and `docs/` is what gets\nserved. Live at [https://next-notes.com](https://next-notes.com).\n\n**Production is DigitalOcean Apps, not GitHub Pages.** The app is `nextnotes`, a static site\nwhose `source_dir` is `docs`, with `deploy_on_push` on `main` and **no build command of its\nown** — it serves the committed `docs/` verbatim. That is why the build output has to be in\nversion control: it is not a convenience, it is the deployed artifact. It is also why\n`npm run deploy` finishes by polling [https://next-notes.com](https://next-notes.com) rather than a Pages URL.\n`doctl apps list-deployments e2366c03-b11d-4c56-8d07-fdea08b21cdc` shows what shipped.\nGitHub Pages served this site before the move and has been switched off, so there is exactly\none live copy and one URL to reason about.\n\n**`site/public/` is copied verbatim** and holds everything a crawler asks for by convention\nrather than by link: `robots.txt`, `sitemap.xml`, `llms.txt`, `favicon.ico`,\n`apple-touch-icon.png`, `og-image.png` and the demo GIF above.\n\nTwo rules about that metadata, both learned the hard way:\n\n- **`og:image`, `og:url` and the canonical must be absolute.** Open Graph consumers resolve\nthem server-side, with no page context to resolve a relative path against, so`./icon.png` was simply dropped and every link to the site previewed bare.`base` is`\"./\"` for the\nbundle, which makes the contrast easy to miss.\n- **The card must be at least 300px wide** for`twitter:card: summary_large_image` . It used to\npoint at the 256px app icon, which fails that minimum, so the card had no image even when\nthe URL resolved. It is now a 1200×630 render.\n\nThe page is a client-rendered React app, so a crawler that does not run JavaScript receives\n`<div id=\"root\"></div>` and nothing else. Search engines cope; the assistant crawlers this\nproject cares about mostly do not. The static `<noscript>` block and the JSON-LD in\n`index.html` exist for them and must keep saying what the rendered page says. Prerendering the\nroute at build time is the real fix and is not done.\n\n```\ncd site && npm install     # once\nnpm run dev                # local preview\nnpm run deploy             # build, commit docs/, push, and confirm it went live\n```\n\n`npm run deploy` is the whole sequence and the only one worth remembering. It takes an\noptional message — `npm run deploy -- \"Rewrite the hero\"` — and it:\n\n1. refuses unless you are on `main` , since that is the branch DigitalOcean watches;\n2. refuses if `origin` is the upstream repository this project was started from;\n3. builds;\n4. commits `docs/`**and only `docs/`** , so anything else half-staged in the tree is not\nswept into a \"Rebuild the site\" commit;\n5. pushes;\n6. fetches the live page and waits until it serves the bundle just built.\n\nStep 6 is the point. A push is not a deployment: App Platform rebuilds asynchronously and takes a minute or two, so the only honest confirmation is the live URL serving the new hash. The script exits non-zero if it never does.\n\n**Asset paths are relative (`base: \"./\"`), and must stay that way** unless you are certain\nthe site will only ever be served from a domain root. An absolute `/` base 404s every asset\nwhen the same build is served from a subpath — which is exactly what happened to the Pages\ncopy. `./` resolves against whatever URL the page was loaded from, so one build is correct in\nboth places.\n\n**Where the build goes, and why it is committed.** `docs/` is build output, tracked on\npurpose. Editing it by hand works right up until the next build silently discards the change\n— edit `site/src/` instead. Publishing is a commit, not a CI run: App Platform watches\n`main` and republishes `docs/` exactly as committed. No workflow, no Actions minutes, and\nwhatever was previewed locally is byte-for-byte what ships. The two workflows in\n`.github/workflows/` build the macOS and Windows apps and have nothing to do with the site.\n\nThe cost of that choice is build output in version control, which makes diffs noisy. The benefit is that a deploy can be verified locally before it ships, and there is no CI to be broken by something unrelated.\n\nTwo details in `site/vite.config.ts` that look like oversights and are not. `base` is `\"./\"`\nand not `/` for the reason just given — next-notes.com is a domain root, but the same build\nstill has to work under the old Pages subpath. And `emptyOutDir` is **false**: `docs/` also\nholds `PARAKEET-WINDOWS.md` and `S1-MINI-WINDOWS.md`, which are linked from this file,\n`AGENTS.md` and `windows/README.md`, so wiping the directory would delete them and break\nfour links. The build script clears `docs/assets` instead, which is the only part that\naccumulates stale hashed bundles.\n\nThe orb on the page is not a picture. `site/src/components/Orb.tsx` is the `listening`\ngeometry ported from `OrbGeometry.swift` with the same preset resolved at the same size, so\nthe sphere on the site and the sphere at the notch are the same object. It freezes on the\nsame frame the app does when Reduce Motion is on. There is no stock photography or video\nanywhere on the page, and nothing is hotlinked.\n\nThe page claims no download, because there is no signed release to download — it points at this repository instead. If a release ever ships, the call to action is the thing to change.\n\n1. **Claude cleanup/command provider.** The formatter and command processor have seams for a\nserver-backed higher-quality tier, but no credential storage, consent UI, or network path\nis present.\n2. **Windows local cleanup.** S1-mini by Superwhisper is a strong candidate; the integration\ndesign and constraints are in[`docs/S1-MINI-WINDOWS.md`](/spyhack225/next-notes/blob/main/docs/S1-MINI-WINDOWS.md) .\n3. **Notarization and Windows distribution signing.** Local macOS builds use a stable\nDeveloper ID when available, but neither platform has a complete distribution pipeline.\n4. **Meetings on Windows.** Everything from the process tap onwards is macOS-only; the\nWindows app is still dictation.\n\nEvery one of these compiles, has a self-test where a self-test is possible, and has never had the one real thing it needs:\n\n- \n**The system-audio tap with its grant.**`--selftest-systemaudio` has only ever reported`SYSTEM_AUDIO_SILENT` here, and no recording has yet contained an \"Others\" track.\n- \n**Returning the text to the app it came from.**`TextInjector.Origin` and the switch-away\nsetting are written and the state machine is covered by`--selftest-dictation` , but that\nharness stubs the insert seam. The activation path —`NSRunningApplication.activate()` , the`kAXFrontmostAttribute` fallback, and the polling behind both — has never run against a real\napp switch, because it needs a real hold and the Accessibility grant.`Log.inject` says which\nbranch was taken.\n- \n**Per-app output profiles reaching the model.** Wired from`captureTarget()` through to the\ncleanup prompt and verified by reading each link, but never observed end to end for the same\nreason.`output target: <app>` in the log at key-down is the proof when it runs.\n- \n**Apple Calendar (EventKit).**`--selftest-calendar` reports`eventKit: Not connected` .\nmacOS prompts exactly once, so a dismissed prompt is permanent until`tccutil reset Calendar ai.pivotstudio.nextnotes` puts it back to undecided.\n- \n**Workspace writes.**`gws` reports no credentials on this machine, so no proposal has\never been approved and no Doc, event or email has been created by the agent.\n\nDriven with a synthetic Right ⌥ hold (`scratchpad/ptt/ptt2.swift` posts `flagsChanged`\nevents) and confirmed via `/usr/bin/log show --predicate 'subsystem == \"ai.pivotstudio.nextnotes\"'`:\n\n- Builds clean under Swift 6 strict concurrency.\n- Signs with Developer ID when one is installed, and otherwise with the stable self-signed\n\"Next Notes Local Signing\" certificate that `make signing-cert` creates. That certificate is\nthe whole reason grants stick: two consecutive builds produce an identical designated\nrequirement, so macOS does not treat the rebuilt app as a different one. Genuinely ad-hoc\nbuilds — no certificate at all — do require a fresh Accessibility grant every rebuild.\n- Qwen3.5-4B downloaded, SHA-256 pinned, and running on Metal with real weights alongside S1-mini on the CPU in one process.\n- Google Calendar connected through the OAuth loopback flow, with the refresh token in the\nKeychain: `--selftest-calendar` reports`google: Connected` and returns real events.\n- The system-audio tap runs with its grant — `system audio started — tap 48000Hz → engine 16000Hz` . A recording containing an actual \"Others\" track is still unconfirmed.\n- Launches as a regular macOS app with its main window and menu bar item present.\n- Event tap arms on grant without a restart (the poller catches it).\n- Full state machine: `starting → listening → finishing → idle` , no errors.\n- `SpeechAnalyzer` starts; models already installed, no download stall.\n- Audio capture runs and converts native 48 kHz → 16 kHz for the engine.\n- HUD renders bottom-center, at the size `DS.Size.hud` names, without taking focus.\n- Silence produces an empty transcript and injects nothing.\n- A WAV goes through `ChunkedTranscriber` to segments, and those segments to notes with all\nfive headings (`--selftest-notes` ).\n- A Metal-offloaded llama.cpp runtime and a CPU one are alive and correct in one process\n(`--selftest-llm-metal` ) — the gate on sharing one backend between the two local models.\n- The island panel appears at the right frame on each attached display, never becomes key,\nand passes clicks through outside its own rectangle (`--selftest-island` ).\n- The auto-record rules over invented events, and the agent's tool catalogue, parser and\nrisk gate (`--selftest-calendar` ,`--selftest-agent` ) — both need no account.\n\n**Nobody has looked at the redesigned UI or the island on screen.** The self-tests prove\ngeometry and behaviour, not appearance: hover-to-expand, the growth out of the notch, the\nsidebar in light and dark, and the onboarding sheet are all unverified by eye.\n\n**Command Mode still needs a manual app-compatibility pass.** AX selection reading is only\navailable in editable accessibility text elements, and Electron/browser editors vary in how\nfaithfully they implement it. The implementation refuses to replace text when the captured\nselection cannot be revalidated.\n\n`log` is shadowed in this shell — use `/usr/bin/log` explicitly or it returns nothing.", "url": "https://wpnews.pro/news/show-hn-next-notes-local-dictation-meeting-notes-and-actions-app", "canonical_source": "https://github.com/spyhack225/next-notes", "published_at": "2026-09-12 02:27:44+00:00", "updated_at": "2026-09-12 02:57:25.655790+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "natural-language-processing", "large-language-models"], "entities": ["Next Notes", "macOS", "Apple", "Parakeet", "Wispr Flow", "Granola", "Gmail", "Google Calendar"], "alternates": {"html": "https://wpnews.pro/news/show-hn-next-notes-local-dictation-meeting-notes-and-actions-app", "markdown": "https://wpnews.pro/news/show-hn-next-notes-local-dictation-meeting-notes-and-actions-app.md", "text": "https://wpnews.pro/news/show-hn-next-notes-local-dictation-meeting-notes-and-actions-app.txt", "jsonld": "https://wpnews.pro/news/show-hn-next-notes-local-dictation-meeting-notes-and-actions-app.jsonld"}}