Why my terminal now sounds like a person
A one-line macOS notification led to an OpenAI wrapper, then back offline. I put six voices through the same stubborn build to hear what changed.
- Published
- Reading time
- 16 min read
There is a macOS terminal trick I have used for years:
some-long-running-task; say "task is done, exited $?"
The shell starts say after the task exits, and the expanded $? tells me whether it worked. The command can run in a tmux window buried beneath several sessions. I do not have to keep checking it or trust that I will notice a terminal badge. My computer speaks when it needs me.
I use this often enough that I also gave the habit to my coding agents. If an agent reaches a point where it needs my attention, it can speak instead of waiting silently in another terminal.
Apple’s say is old in the useful Unix sense. It joins unquoted arguments into a sentence, reads stdin when no words are supplied, accepts -v for a voice and -o for a file, and returns a meaningful exit status. It uses the macOS Speech Synthesis Manager, needs no network, and has been stable enough to become muscle memory.
The mechanism is excellent. The voice is not. Even Apple’s better downloadable voices retain the cadence of an older accessibility synthesizer, especially after hearing current speech models from OpenAI or ElevenLabs. A status message does not need to pass for an audiobook, but the difference becomes irritating once you know how natural generated speech can sound.
That small irritation produced two tools. The first was a shell script called saynice. The second became sayneat, a local-first text-to-speech command that has now replaced both say and saynice in my terminal.
The first fix was 256 lines of shell
saynice scratched the itch quickly. It wrapped OpenAI’s /v1/audio/speech endpoint, used gpt-4o-mini-tts by default, and streamed the response from curl into ffplay. Its command-line shape stayed close enough to say that changing an existing notification was easy. I wrote about that first version at the time in I gave my terminal a voice worth listening to.
some-long-running-task; saynice "task is done, exited $?"
The result changed how the computer felt. A short spoken message no longer sounded like a system alert assembled from phonemes. It sounded like somebody inside the machine had noticed the build finish and told me about it.
For a small personal script, saynice was good. It accepted inline text or stdin, offered OpenAI’s voices, and could save audio instead of playing it. I started using it everywhere, including the instructions I gave agents.
Its limits were equally plain. Every message left my computer. Every message needed a network connection and a paid API. Playback also depended on curl, jq, and ffplay. If I was offline, handling text that should remain local, or working on a machine without the right collection of command-line tools, the friendly voice disappeared.
I did not want to return to the old voice. I wanted the same quality and the same Unix-shaped interface without making a remote request the default.
Looking back, saynice proved two parts of the idea. Neural speech was good enough to hear throughout a working day, and preserving the shape of say mattered more than exposing every provider option. Its limitation was structural. OpenAI was not one engine behind the command; the HTTP request, voice list, error format, dependencies, and playback path were the command.
sayneat keeps the lesson and reverses that dependency. The command owns input, output, playback, and model selection. OpenAI is one optional engine beside ElevenLabs and four local choices. That distinction is what lets the same short invocation work offline without pretending remote providers are unnecessary.
sayneat starts local and asks before it goes remote
sayneat is a Rust program built around that decision. On its first run, it inspects available CPU threads and memory, displays every supported engine, and asks which one to install. It does not quietly choose a paid service.
The current catalog has four local engines and two remote ones:
kokorois the recommended local model. Its int8 weights take about 147 MB and provide 103 English and Chinese voices.kokoro-hquses full-precision Kokoro weights. The download is about 365 MB, and the output skips the high-frequency filter used to soften int8 hiss.kittenis a compact English model with eight voices and a download of about 68 MB.supertonicis a 44.1 kHz English model with ten voices and a download of about 129 MB.elevenlabsuses account-owned or workspace voices and can return exact word timing.openaiuses OpenAI’s speech API and defaults to itsmarinvoice.
After selection, sayneat downloads local weights into the operating system’s application cache, verifies their SHA-256 hashes, and stores the default model and voice in its configuration. The program itself remains one native binary. Model weights stay separate, which keeps updates and model changes practical.
Remote engines remain available because they are useful. They require ELEVENLABS_API_KEY or OPENAI_API_KEY in the environment, and sayneat never writes those keys into its config. Selecting one sends the spoken text to that provider and may cost money. That boundary is visible instead of being hidden behind an apparently local command.
Six ways to hear the same sentence
I wanted a comparison that resembled an actual notification but gave each synthesizer more work than “done.” The time and test count exercise number normalization. The first sentence is long enough to expose pacing, while the short second sentence tests whether the voice can change cadence without prompting.
At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.
The commands below use one shell variable:
TEXT='At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.'
I generated every sample on the same Mac on August 24, 2026. These are complete first attempts. I changed no speed or pronunciation settings. saynice and sayneat wrote the MP3 files directly. macOS say wrote AIFF, which I converted to MP3 for consistent browser playback.
macOS say with Samantha
This is the baseline. say delegates to the speech system already installed on the Mac. It starts without a model download, an account, another binary, or a network request. I chose Samantha so the sample does not depend on whichever system voice happens to be configured on a reader’s Mac.
say -v Samantha -o macos-say-samantha.aiff "$TEXT"
This voice does the job. It also explains why I started tinkering. The timing is regular, words do not influence one another much, and the second sentence still sounds like an operating-system announcement.
saynice with OpenAI Alloy
saynice represents my first answer to that problem. It sends the text to OpenAI’s gpt-4o-mini-tts model and streams the returned audio through ffplay. Alloy was my default voice when I wrote the wrapper.
saynice --voice alloy --model gpt-4o-mini-tts \
-o saynice-alloy.mp3 "$TEXT"
The jump from say is the reason saynice survived beyond one experiment. The catch is outside the recording. Producing it required an API key, an internet connection, several local programs, and sending the notification text to OpenAI.
sayneat with Kokoro int8
Kokoro int8 is sayneat’s default recommendation on a machine with at least two CPU threads and 2 GB of memory. I used its American English af_maple voice.
sayneat --model kokoro --voice af_maple \
-o kokoro-af-maple.mp3 "$TEXT"
The 147 MB int8 package is the compromise I expect most people to choose. sayneat applies a gentle 9.5 kHz low-pass filter because quantization can leave hiss near the model’s Nyquist frequency.
sayneat with full-precision Kokoro
kokoro-hq uses the same voice family at full precision. I kept af_maple selected so the sample isolates the model package rather than switching speaker and weights together.
sayneat --model kokoro-hq --voice af_maple \
-o kokoro-hq-af-maple.mp3 "$TEXT"
The download grows from about 147 MB to 365 MB. sayneat leaves this output unfiltered. Whether that difference deserves the extra disk space is easier to decide by listening than by reading model descriptions.
sayneat with KittenTTS Mini
Kitten is the smallest local option in the catalog. Its package is about 68 MB and contains eight English voices. I selected expr-voice-5-m.
sayneat --model kitten --voice expr-voice-5-m \
-o kitten-expr-voice-5-m.mp3 "$TEXT"
Kitten takes longer over this sentence than the other local choices. That is useful information for a notification voice. Model size, synthesis speed, speaking pace, and voice preference are separate decisions.
sayneat with Supertonic
Supertonic differs technically as well as vocally. It produces 44.1 kHz audio while the other local engines produce 24 kHz audio. Its package is about 129 MB and offers five female and five male voices. This sample uses F3.
sayneat --model supertonic --voice F3 \
-o supertonic-f3.mp3 "$TEXT"
Among the local models, Kokoro HQ and Supertonic are the clear winners for my taste. OpenAI and ElevenLabs still sound a little more natural to me, but the gap is no longer large enough to justify sending everyday terminal messages off the machine. With these two local options, I am happy to keep routine notifications offline.
Voice discovery stays scriptable. sayneat --voices prints one accepted value per line for the configured model. sayneat --voices --model kitten inspects another local model without downloading it. This makes a complete voice audition a normal pipeline:
sayneat --voices | xargs -I{} \
sayneat "Hello, this is a demo for {}" --voice {}
A one-off --model or --voice override never changes saved configuration. I can try another voice and return to my normal one on the next command.
What sayneat keeps from say
I wanted a better synthesizer, not a new ritual. The common cases remain recognizable:
say "The deploy finished"
sayneat "The deploy finished"
pbpaste | say
pbpaste | sayneat
say -v Samantha -o message.aiff "Keep this"
sayneat -v af_maple -o message.wav "Keep this"
Both commands join positional words with spaces, fall back to stdin, use -v for a voice, use -o for output, and play speech when no output file is present. Both also have an interactive mode that follows the spoken text. The exact interface differs because sayneat can pause, replay, and display a waveform after generation.
sayneat is intentionally not a flag-for-flag clone. Apple’s -r controls the words per minute of its synthesizer; local neural engines do not share one reliable rate control. Apple’s -a and -n route audio to devices and network services; sayneat leaves that job to ordinary audio tools. Apple’s -f reads a file, while sayneat uses the shell syntax that already works everywhere:
sayneat < release-notes.txt
The additions describe choices that say never needed: --model, --models, --voices, --setup, and --play. They do not affect the short form. Once setup is complete, sayneat hello remains the whole command.
That restraint distinguishes sayneat from saynice too. saynice imitated some say options, but its implementation was a chain of curl, jq, and ffplay around one remote provider. sayneat owns model setup, local inference, output encoding, playback, and terminal controls in one native program. It can still use OpenAI or ElevenLabs, but neither provider defines the command.
It still behaves like a command-line tool
Speech quality was the reason to start sayneat. Its input and output behavior is why I kept it.
Inline arguments work quoted or unquoted. Without arguments, sayneat reads stdin until EOF. Positional text wins if both exist, which avoids accidentally speaking an unrelated pipe when a command supplies an explicit message.
sayneat The deploy finished
pbpaste | sayneat
printf '%s\n' "$(git log -1 --pretty=%B)" | sayneat
Without --out, sayneat plays the result. A .wav, .mp3, or .flac suffix selects the encoder when saving. -o - writes WAV bytes to stdout while diagnostics stay on stderr, so the audio can enter another program without log lines corrupting it:
sayneat -o - "Archive this message" | \
ffmpeg -i pipe:0 archive.m4a
That separation sounds minor until a command enters a pipeline. Clean stdout is the difference between a Unix command and an interactive application that happens to launch from a terminal.
Playback also accounts for terminal work. Ctrl-T pauses or resumes ordinary playback. The control input comes from the controlling terminal, not stdin, so it still works when the spoken text arrives through a pipe.
For longer or less disposable speech, --interactive generates first and opens a paused one-line player. --play starts as soon as generation finishes. The interface shows exact current-word highlighting when an engine returns alignment; otherwise it renders a live waveform around the playback position. Space pauses, resumes, or replays. Escape stops and rewinds. Ctrl-C exits.
pbpaste | sayneat --interactive
pbpaste | sayneat --interactive --play
I did not need a graphical application for any of this. I needed playback controls that survive a pipe and fit into one terminal line.
Long text cannot become one giant allocation
A task notification is tiny, but stdin invites larger input. Reading a document aloud exposed a different engineering problem: speech engines have input limits, and generated audio can consume a surprising amount of memory.
sayneat splits long input at paragraphs and complete sentences when possible. It packs chunks up to each engine’s safe budget, then falls back to clauses or words for an oversized sentence. ElevenLabs requests receive adjacent text as context so prosody does not reset blindly at every boundary. Alignment offsets accumulate across chunks, which keeps highlighted words synchronized with the combined output.
Local generation shares one thread-safe sherpa-onnx session across ordered parallel batches. Up to four workers synthesize chunks, while full-precision Kokoro uses at most two. sayneat always appends completed results in source order.
It also refuses to hold an hour of finished PCM in memory. Each completed batch becomes 16-bit mono audio in a private temporary file. Playback and encoders reopen that file and stream fixed-size blocks. At 24 kHz, temporary audio uses about 173 MB per hour, so available temporary disk space becomes the practical limit instead of RAM. sayneat removes the file on normal completion or error.
This machinery is invisible for “build done,” as it should be. It matters when the same small command reads an article or converts a long document to FLAC.
Replacing the old habit
The source is public at gitlab.com/parlant-co/sayneat. Building currently requires Rust 1.89 or newer, CMake, and a C or C++ compiler. Linux also needs ALSA development headers.
git clone https://gitlab.com/parlant-co/sayneat.git
cd sayneat
cargo test
cargo build --release
./target/release/sayneat --setup
The release workflow targets Apple Silicon and Intel macOS, x86-64 Linux, and x86-64 Windows. The local model licenses differ, so the README records them: Kokoro and KittenTTS weights use Apache-2.0, while Supertonic weights use OpenRAIL-M and retain its restrictions.
My original shell suffix barely changes:
some-long-running-task; sayneat "task is done, exited $?"
That is the outcome I wanted. The good part of say was never its synthesizer. It was the ability to add speech to any shell command with a semicolon. sayneat keeps that habit, gives it voices I enjoy hearing, and lets the default path work without a network request.
It has replaced both say and saynice for me. If terminal speech is already part of your work, give sayneat a try. Feedback and bug reports belong in the project, especially reports about machines and voices different from mine.