Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ assembly stream -o text | assembly llm -f "summarize my to-dos as I talk"
assembly stream --system-audio --speaker-labels -o text
```

**Record meeting notes from one hotkey** — capture system audio + mic with speaker labels and have an LLM write a live, auto-named Markdown note. The [`examples/meeting-notes-hammerspoon`](examples/meeting-notes-hammerspoon) recipe pairs a [Hammerspoon](https://www.hammerspoon.org) toggle with a `just record`/`list`/`search` workflow:

```sh
assembly stream --system-audio --speaker-labels --auto-name \
--save-dir ~/meeting-notes --llm "summary, decisions, action items, open questions"
```

**Build a WisprFlow clone** — `assembly dictate` records on launch and prints the transcript on SIGTERM, so a hotkey tool can drive push-to-talk dictation that types into any app. The [`examples/dictation-hammerspoon`](examples/dictation-hammerspoon) recipe wires it up with [Hammerspoon](https://www.hammerspoon.org): hold a hotkey, speak, release, and the text lands at your cursor.

**Get pinged when your name comes up** in a live meeting:

```sh
Expand Down
81 changes: 81 additions & 0 deletions examples/dictation-hammerspoon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Push-to-talk dictation with Hammerspoon + `assembly dictate`

A ~100-line [Hammerspoon](https://www.hammerspoon.org) script that turns the
AssemblyAI CLI into a [WisprFlow](https://wisprflow.ai)-style dictation tool:
**hold a hotkey, speak, release, and the transcript is typed in at your
cursor** — in any app, no terminal in sight.

It works because `assembly dictate` is built for exactly this. It starts
recording immediately and prints the transcript to stdout when it receives
`SIGTERM`, so a hotkey tool can drive it as a background task:

```text
hotkey down -> launch `assembly dictate` (records the mic)
hotkey up -> task:terminate() (SIGTERM) -> dictate prints the transcript
-> Hammerspoon reads stdout and pastes it at the cursor
```

## Setup

1. **Install the AssemblyAI CLI** and sign in (the transcript needs an API key):

```sh
brew tap assemblyai/cli https://github.com/AssemblyAI/cli
brew install assembly
assembly login # or export ASSEMBLYAI_API_KEY=...
```

Confirm dictation works from the terminal first — say a few words and press
`Ctrl-C`'s gentler sibling, `kill -TERM`, from another shell, or just:

```sh
assembly dictate
# speak, then in another terminal: kill -TERM $(pgrep -f 'assembly dictate')
```

2. **Install Hammerspoon** and grant it Accessibility permission (System
Settings → Privacy & Security → Accessibility) so it can send keystrokes:

```sh
brew install --cask hammerspoon
```

3. **Drop the script in place.** Copy `dictation.lua` to `~/.hammerspoon/` and
load it from your `~/.hammerspoon/init.lua`:

```lua
require("dictation")
```

Then reload the config (Hammerspoon menubar → *Reload Config*).

## Use it

Hold **⌃⌥D** (control + option + D), speak, and release. A 🔴 appears in the
menubar while recording; on release the text lands at your cursor. macOS will
prompt once for microphone access.

## Customize

Edit the config block at the top of `dictation.lua`:

| Setting | What it does |
| --- | --- |
| `M.hotkey` | The push-to-talk chord (default `⌃⌥D`). Hammerspoon can't bind a bare `fn` key, so use a modifier+letter. |
| `M.dictateArgs` | Extra `assembly dictate` flags, e.g. `{ "--language", "es" }` to dictate in Spanish, or repeated `--word-boost` to bias tricky terms. |
| `M.insertMode` | `"paste"` (clipboard + ⌘V, fast, restores your clipboard) or `"type"` (simulated keystrokes, works everywhere). |
| `M.sounds` | Play the mic-open / done chimes for audible feedback. |

## Notes

- **PATH**: Hammerspoon launches with a minimal `PATH`, so the script resolves
`assembly` through a login shell (`command -v assembly`). If you installed the
CLI somewhere unusual, hard-code the path in `resolveAssembly()`.
- **Limits**: each utterance is capped at 120 s (the Sync STT API limit); pass
`--max-seconds` to stop sooner.
- **Toggle instead of hold**: prefer tap-to-start / tap-to-stop? Bind only the
pressed callback to a function that calls `startDictation()` when idle and
`stopDictation()` when a recording is in flight.
- **Pipe it somewhere else**: the same SIGTERM trick powers shell pipelines —
`assembly dictate | assembly llm "write a conventional commit"`. See
`assembly dictate --help` for more.
142 changes: 142 additions & 0 deletions examples/dictation-hammerspoon/dictation.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
-- Push-to-talk dictation anywhere on macOS (a WisprFlow-style clone), powered
-- by Hammerspoon (https://www.hammerspoon.org) and `assembly dictate`.
--
-- Hold the hotkey to record, release to transcribe. The text is inserted at the
-- cursor in whatever app has focus — a chat box, your editor, a search field.
-- `assembly dictate` records immediately and prints the transcript when it
-- receives SIGTERM (which `hs.task:terminate()` sends), so the whole flow is:
--
-- hotkey down -> launch `assembly dictate` as a background task
-- hotkey up -> terminate it (SIGTERM) -> read stdout -> type it at the cursor
--
-- Load it from your ~/.hammerspoon/init.lua with:
--
-- require("dictation") -- if this file is at ~/.hammerspoon/dictation.lua
--
-- then reload the Hammerspoon config (menubar -> Reload Config).

local M = {}

-- ---------------------------------------------------------------------------
-- Configuration — tweak these to taste.
-- ---------------------------------------------------------------------------

-- Push-to-talk hotkey. Default: hold ⌃⌥ (control+option) + D.
-- Hammerspoon can't bind a bare modifier like WisprFlow's fn key, so we use a
-- modifier+letter chord; pick anything that doesn't clash with your apps.
M.hotkey = { mods = { "ctrl", "alt" }, key = "d" }

-- Extra arguments passed to every `assembly dictate` run, e.g.:
-- { "--language", "es" } -- dictate in Spanish
-- { "--word-boost", "AssemblyAI", "--word-boost", "LeMUR" } -- bias terms
M.dictateArgs = {}

-- How to insert the transcript:
-- "type" — simulate keystrokes (works everywhere, slower for long text)
-- "paste" — stash it on the clipboard and ⌘V (fast, restores your clipboard)
M.insertMode = "paste"

-- Play the system mic-open / done sounds for audible feedback.
M.sounds = true

-- ---------------------------------------------------------------------------
-- Internals.
-- ---------------------------------------------------------------------------

-- Resolve the `assembly` binary through a login shell so Homebrew's PATH (e.g.
-- /opt/homebrew/bin) is on it — Hammerspoon itself starts with a minimal PATH.
local function resolveAssembly()
local path = hs.execute("command -v assembly", true)
path = path and path:gsub("%s+$", "") or ""
if path == "" then
hs.notify
.new({
title = "Dictation (Hammerspoon)",
informativeText = "`assembly` not found on PATH. Install the AssemblyAI CLI first.",
})
:send()
return nil
end
return path
end

local assemblyPath = resolveAssembly()
local task = nil -- the in-flight `assembly dictate` hs.task, or nil
local indicator = nil -- a menubar dot shown while recording

local function showRecording()
if not indicator then
indicator = hs.menubar.new()
end
if indicator then
indicator:setTitle("🔴")
indicator:setTooltip("Dictating… release the hotkey to transcribe")
end
if M.sounds then
hs.sound.getByName("Tink"):play()
end
end

local function hideRecording()
if indicator then
indicator:delete()
indicator = nil
end
end

local function insert(text)
text = text:gsub("%s+$", "") -- drop the trailing newline `dictate` prints
if text == "" then
return
end
if M.insertMode == "paste" then
local saved = hs.pasteboard.getContents()
hs.pasteboard.setContents(text)
hs.eventtap.keyStroke({ "cmd" }, "v")
-- Restore the previous clipboard after the paste lands.
hs.timer.doAfter(0.2, function()
hs.pasteboard.setContents(saved)
end)
else
hs.eventtap.keyStrokes(text)
end
end

-- Hotkey pressed: start recording (no-op if a run is already in flight).
local function startDictation()
if task or not assemblyPath then
return
end
local args = { "dictate" }
for _, a in ipairs(M.dictateArgs) do
args[#args + 1] = a
end
task = hs.task.new(assemblyPath, function(_, stdOut, stdErr)
task = nil
hideRecording()
if M.sounds then
hs.sound.getByName("Pop"):play()
end
if stdOut and stdOut ~= "" then
insert(stdOut)
elseif stdErr and stdErr ~= "" then
hs.notify.new({ title = "Dictation (Hammerspoon)", informativeText = stdErr }):send()
end
end, args)
if task:start() then
showRecording()
else
task = nil
end
end

-- Hotkey released: SIGTERM the recording so `dictate` transcribes and exits.
local function stopDictation()
if task and task:isRunning() then
task:terminate() -- sends SIGTERM, which `assembly dictate` treats as "done"
end
end

hs.hotkey.bind(M.hotkey.mods, M.hotkey.key, startDictation, stopDictation)

return M
86 changes: 86 additions & 0 deletions examples/meeting-notes-hammerspoon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Meeting notes with Hammerspoon + `assembly stream`

Record a meeting with **one hotkey** and get a live, auto-named Markdown note
out the other end — system audio *and* your mic, diarized by speaker, summarized
by an LLM as you talk. This is the [Hammerspoon](https://www.hammerspoon.org)
front-end to the [`justfile`](justfile) in this directory, so you can drive the
same workflow from a global hotkey or from the terminal.

It leans on one command:

```sh
assembly stream --system-audio --speaker-labels --auto-name \
--save-dir ~/meeting-notes --model claude-sonnet-4-6 \
--llm "Keep running meeting notes: summary, decisions, action items, open questions"
```

`--system-audio` mixes the meeting app's output with your mic as separate
diarized speakers; `--auto-name` names the file from its content and buckets it
under `<save-dir>/YYYY-MM-DD/`; `--llm` re-runs the prompt over the growing
transcript so the `.md` note updates live. The note is written on a clean stop —
Ctrl-C from the terminal, or `SIGTERM` from a hotkey tool (`stream` routes both
to the same save path).

## The justfile

If you live in the terminal, the `justfile` gives you three recipes (needs
[`just`](https://github.com/casey/just); `list` also wants `fzf` + `glow`):

```sh
just record # record a meeting + its note
just list # browse notes, preview rendered
just search "what did we decide on pricing?" # ask across all notes (LLM cites them)
```

## The Hammerspoon hotkey

For a global, no-terminal version, use `meeting_notes.lua`:

1. **Install the CLI** and sign in, then **install Hammerspoon** and grant it
Accessibility + Screen Recording permission (System Settings → Privacy &
Security) — system-audio capture needs Screen Recording:

```sh
brew tap assemblyai/cli https://github.com/AssemblyAI/cli
brew install assembly
assembly login
brew install --cask hammerspoon
```

2. **Drop the script in place.** Copy `meeting_notes.lua` to `~/.hammerspoon/`
and load it from your `~/.hammerspoon/init.lua`:

```lua
require("meeting_notes")
```

Then reload the config (Hammerspoon menubar → *Reload Config*).

3. **Use it.** Press **⌃⌥M** to start recording (a `🔴 REC` badge appears in the
menubar); press it again to stop and save the note. **⌃⌥⇧M** opens the notes
folder in Finder.

## Customize

Edit the config block at the top of `meeting_notes.lua` (it mirrors the
justfile's variables):

| Setting | What it does |
| --- | --- |
| `M.hotkey` | The toggle-recording chord (default `⌃⌥M`). |
| `M.openHotkey` | The "open notes folder" chord (default `⌃⌥⇧M`). |
| `M.transcriptsDir` | Where notes are saved (default `~/meeting-notes`). |
| `M.model` | The model used for naming and the live note. |
| `M.notePrompt` | The `--llm` instruction that shapes each note. |
| `M.sounds` | Play the start/stop chimes for audible feedback. |

## Notes

- **PATH**: Hammerspoon launches with a minimal `PATH`, so the script resolves
`assembly` through a login shell. Hard-code the path in `resolveAssembly()` if
the CLI lives somewhere unusual.
- **Permissions**: capturing system audio requires the Screen Recording
permission in addition to Accessibility; macOS prompts on first use.
- **Searching later**: the justfile's `search` recipe points `assembly llm` at
the notes directory — it recurses for `.md`/`.txt` files and answers with
citations, so your meeting history becomes queryable.
37 changes: 37 additions & 0 deletions examples/meeting-notes-hammerspoon/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Meeting notes powered by `assembly stream` — record, browse, and search.
#
# Run `just` (or `just default`) to list recipes. Needs the AssemblyAI CLI;
# `list` also needs fzf + glow, `record` uses an LLM for the live note.

# Where notes land. The CLI buckets each note under <dir>/YYYY-MM-DD/.
transcripts_dir := env_var_or_default("MEETING_NOTES_DIR", "~/meeting-notes")

# The live-note instruction handed to `assembly stream --llm`.
note_prompt := "Keep running meeting notes: a one-line summary, decisions, action items (with owners), and open questions. Update as we talk."

# List available recipes.
default:
@just --list

# The CLI auto-names the file from its content, buckets it under
# transcripts/YYYY-MM-DD/, and writes the .md note live via --llm (saved on
# Ctrl-C); [no-exit-message] hides the exit-130 that SIGINT returns.
# Record a meeting (system audio + mic, speaker labels) and its note in one pass.
[no-exit-message]
record:
assembly stream --system-audio --speaker-labels --auto-name --save-dir "{{transcripts_dir}}" --model claude-sonnet-4-6 --llm "{{note_prompt}}"

# Browse notes newest-first and read the selected one rendered (needs fzf + glow).
list:
#!/usr/bin/env zsh
setopt err_exit no_unset pipe_fail
notes=( {{transcripts_dir}}/**/*.md(.NOn) )
(( $#notes )) || { echo "No recordings yet — run 'just record'." >&2; exit 0; }
sel=$(print -l -- $notes | fzf --preview 'glow -s dark {}' --preview-window 'right,70%') || true
[[ -z "$sel" ]] || glow -s dark -p "$sel"

# Ask a question across all notes via the LLM Gateway; it cites the notes it used.
# `assembly llm` recurses the directory for .md/.txt files as context.
# Usage: just search "what did we decide about pricing?"
search query:
assembly llm --model claude-sonnet-4-6 "Answer using only the notes provided. Cite the relevant note name(s). Question: {{query}}" "{{transcripts_dir}}" -o text
Loading
Loading