I gave my terminal a voice worth listening to
A 256-line Bash wrapper keeps the best part of macOS say, but replaces its voice with OpenAI speech that sounds startlingly human.
- Published
- Reading time
- 11 min read
- Series
- Dotfiles
One of my favorite macOS commands is also one of its smallest:
say "The build is done"
It becomes much more useful after a long-running command:
some-long-running-task; say "task is done, exited $?"
I can start a build in a tmux window, move to something else, and wait for the computer to call me back. The shell expands $? after the first command exits, so the message can include its status. A semicolon announces success or failure. If I only care about success, && does that too.
This is better than checking the same pane every minute. It is also better than a notification I might miss behind another screen. Sound reaches me while I am reading, sketching on paper, or looking at a different machine.
There is only one problem. I do not enjoy the voice.
The macOS synthesizer is clear, fast, local, and dependable. It also sounds unmistakably synthetic. After listening to current generated speech, I know a short status message can sound less like an accessibility system reading a dialog box and more like another person in the room.
So I wrote saynice, a Bash command that preserves the convenient shape of say and replaces its speech engine with OpenAI’s text-to-speech API.
some-long-running-task; saynice "task is done, exited $?"
That one-word change is the whole point.
The interface was already right
Apple’s say has accumulated many options, but its ordinary use follows a compact contract. Words on the command line become speech. No words means read stdin. -v selects a voice. -o saves audio instead of playing it. -f reads a file. -r controls the speaking rate.
I did not want a speech application with projects, a library, and an export screen. I wanted that contract with a better voice.
saynice therefore accepts the forms I already reach for:
saynice "The deploy finished"
saynice The deploy finished
printf '%s\n' "The deploy finished" | saynice
saynice -f release-notes.txt
saynice -v coral "The deploy finished"
saynice -o message.mp3 "Keep this message"
Unquoted positional words are joined with spaces, just as they are by say. If there are no positional words and no -f, the command reads stdin until EOF. An explicit file and positional text are rejected because choosing silently between two deliberate sources would hide a mistake.
The voice flag also keeps its familiar short form:
saynice -v alloy "Hello"
saynice --voice onyx "Hello"
saynice -v '?'
saynice --list-voices
The known list currently contains alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, and verse. OpenAI can add voices independently of this script, so an unknown name produces a warning rather than an immediate refusal. The API gets the final say.
I had once used obsidian when I meant onyx, so the wrapper maps that name and prints a note. This is exactly the kind of tiny personal accommodation that belongs in dotfiles. It costs almost nothing and prevents a repeated interruption.
The -r option reveals where compatibility stops. OpenAI’s current speech endpoint does not accept the same words-per-minute control as the macOS synthesizer. saynice -r 180 accepts the argument and explains that it is ignored. Existing shell snippets do not fail because of the flag, but the command does not pretend it changed the audio.
The whole request is visible
The default model is gpt-4o-mini-tts, and the default voice is alloy. Both can be changed for one invocation:
saynice --model gpt-4o-mini-tts --voice coral \
"Production is healthy again"
They can also be changed through environment variables:
export SAYNICE_MODEL=gpt-4o-mini-tts
export SAYNICE_VOICE=coral
The API key comes from OPENAI_API_KEY or SAYNICE_API_KEY. saynice never writes it to a configuration file. It also passes OPENAI_ORGANIZATION_KEY and OPENAI_PROJECT_KEY as headers when those variables exist.
The implementation builds one JSON object with jq:
{
"model": "gpt-4o-mini-tts",
"voice": "alloy",
"input": "The build is done",
"response_format": "mp3"
}
Then curl sends it to /v1/audio/speech with the bearer token and content type. There is no SDK between the command and the endpoint. If OpenAI changes the request, I have one payload and one URL to inspect.
SAYNICE_BASE_URL and OPENAI_BASE_URL can replace https://api.openai.com. I added that mostly because API-compatible gateways are common enough that hardcoding the host would be needless. The wrapper still assumes the /v1/audio/speech route and OpenAI’s request shape.
This directness is why Bash was a reasonable choice. The job is argument parsing, a JSON request, one HTTP call, and either a pipe or a file move. curl, jq, and ffplay already do the hard parts. A separate runtime or package dependency would make this particular command harder to carry between my machines.
Playback should begin before the request ends
The normal path is spoken output, not a saved file. In that mode, curl uses --no-buffer and writes the response directly to ffplay:
curl ... | ffplay -nodisp -autoexit -loglevel error -i pipe:0
ffplay opens no video window, exits when playback finishes, and keeps its normal diagnostic chatter out of the terminal. Audio can begin as bytes arrive instead of waiting for a complete MP3 in a temporary file.
This path also gives the pipeline useful failure behavior. The script starts with set -euo pipefail, and curl uses --fail. A failed request or failed player makes the command fail rather than returning success because the last process happened to exit cleanly.
Saving audio needs a different route. The extension chooses the response format:
.mp3requests MP3..wavrequests WAV..flacrequests FLAC..opusrequests Opus..aacrequests AAC..pcmand.rawrequest PCM.
The default remains MP3 when the filename has another extension. I can override that with SAYNICE_AUDIO_FORMAT if I genuinely want an unusual filename.
For file output, curl writes the response to a private temporary file and records the HTTP status separately. A successful response moves into the requested destination. An error response stays temporary long enough for jq to extract error.message or message, then a trap removes it.
This split matters because an HTTP error body is JSON, not audio. Writing it directly to message.mp3 would leave a plausible-looking file that fails later for a confusing reason. saynice instead prints the provider’s explanation while it still has the response available.
Small commands still need boundaries
This script is personal, but it is not a loose alias around curl. It has to fail in ways that make sense when called from another shell command or by an automated tool.
A missing option value prints usage and exits 64. Supplying both -f and positional text also uses exit 64, the conventional command-line usage error. A missing input file exits 66. A missing API key exits 78. Empty text is rejected before any paid request leaves the machine.
Dependencies are checked at the point they become necessary. Every request needs curl and jq. Playback also needs ffplay, but file output does not. Someone saving a WAV should not be blocked because the audio player is absent.
Unknown options are errors. -- ends option parsing, so text beginning with a hyphen remains possible:
saynice -- "--force would be a bad idea here"
These checks account for much of the script’s 256 lines. The successful HTTP request is short. Most of the code protects the command-line boundary, reports provider errors, and cleans up after interrupted requests.
That is not wasted bulk. A tool becomes reusable when failure is as predictable as success.
I gave the command to my agents
A voice notification is especially useful now that coding agents can work in terminal sessions for several minutes without intervention. I can ask an agent to investigate, switch to another task, and hear when it reaches a decision that needs me.
I added saynice to my agent instructions on the same evening I wrote it:
`saynice` is used to get the attention of the user by sending a TTS message.
Prefer `saynice` over `say`.
I also attached it to an important Terraform boundary. An agent may run terraform validate and terraform plan, but it must ask before applying changes and must never add -auto-approve. The instruction tells it to use saynice when that confirmation is waiting.
This is a small change in human-computer coordination. The agent does not need a notification service, desktop permission, webhook, or chat integration. It executes the same command I use. The message travels through the speakers, which is where my attention already is.
It also makes the quality difference more noticeable. Hearing the old system voice once after a thirty-minute build is tolerable. Hearing it throughout a day of agent work is grating. OpenAI’s voice gives the interruption enough warmth that it feels less like an alarm.
The trade is explicit
saynice is not a private or offline command. It sends every spoken string to an API. It requires a working network connection, consumes paid service usage, and depends on OpenAI continuing to support the selected model and voice.
I will not pipe secrets, private client data, or arbitrary logs into it. A fixed sentence such as “the test suite failed” is easy to assess. Reading a whole command result aloud deserves more care because the content may contain paths, names, tokens, or customer information.
Latency is another trade. Streaming prevents an unnecessary wait after generation begins, but a remote request still has connection and synthesis time that local say does not. For brief notifications on a normal connection, I prefer the better voice. When offline, the original command remains installed.
This is why I kept the wrapper small. It does not hide where the speech comes from or claim to replace the operating system. It makes one remote service feel like a Unix command, with enough compatibility that my existing habit survives.
It lives with the rest of my small tools
The source is in my dotfiles repository, alongside the other commands I keep in ~/.bin. The complete script is .bin/saynice.
Setup needs the three command-line dependencies and an API key:
export OPENAI_API_KEY=...
saynice "There is now a person inside the computer"
I wrote and committed the command on March 3. A week later, I have not needed to change its implementation. More tellingly, I have already changed my agent instructions to prefer it. The useful threshold for a dotfile is not novelty. It is reaching for the command without thinking about how it works.
The original terminal trick remains intact:
some-long-running-task; saynice "task is done, exited $?"
My tmux windows can still call me back. They finally sound like someone I want to hear.