{"slug": "i-replaced-macos-say-with-local-neural-speech", "title": "I replaced macOS say with local neural speech", "summary": "Developer Lukas Overbryd released sayneat, a local-first text-to-speech command-line tool that replaces Apple's macOS `say` and his earlier 256-line shell script `saynice`, with OpenAI, ElevenLabs and four local engines selectable behind one Unix-style interface. The earlier `saynice` wrapped OpenAI's `/v1/audio/speech` endpoint with `gpt-4o-mini-tts` by default and streamed audio from `curl` into `ffplay`, but required a network connection, a paid API and the `curl`, `jq` and `ffplay` binaries for every message. sayneat moves input, output, playback and model selection into the command itself so the same short invocation works offline, with OpenAI reduced to one optional engine.", "body_md": "# I replaced macOS say with local neural speech\n\nI replaced macOS say with sayneat, a local text-to-speech CLI. Six voices handled the same stubborn build very differently.\n\n- Published\n- Reading time\n- 16 min read\n\nThere is a macOS terminal trick I have used for years:\n\n```\nsome-long-running-task; say \"task is done, exited $?\"\n```\n\nThe 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.\n\nI 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.\n\nApple’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.\n\nThe 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.\n\nThat small irritation produced two tools. The first was a shell script called `saynice`. The second became [sayneat](https://gitlab.com/parlant-co/sayneat), a local-first text-to-speech command that has now replaced both `say` and `saynice` in my terminal.\n\n## The first fix was 256 lines of shell\n\n[`saynice`](https://gitlab.com/Overbryd/dotfiles/-/blob/main/.bin/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](https://www.lukasrieder.com/blog/i-gave-my-terminal-a-voice-worth-listening-to/).\n\n```\nsome-long-running-task; saynice \"task is done, exited $?\"\n```\n\nThe 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.\n\nFor 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.\n\nIts 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.\n\nI 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.\n\nLooking 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.\n\nsayneat 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.\n\n## sayneat starts local and asks before it goes remote\n\n[sayneat](https://gitlab.com/parlant-co/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.\n\nThe current catalog has four local engines and two remote ones:\n\n- `kokoro` is the recommended local model. Its int8 weights take about 147 MB and provide 103 English and Chinese voices.\n- `kokoro-hq` uses full-precision Kokoro weights. The download is about 365 MB, and the output skips the high-frequency filter used to soften int8 hiss.\n- `kitten` is a compact English model with eight voices and a download of about 68 MB.\n- `supertonic` is a 44.1 kHz English model with ten voices and a download of about 129 MB.\n- `elevenlabs` uses account-owned or workspace voices and can return exact word timing.\n- `openai` uses OpenAI’s speech API and defaults to its`marin` voice.\n\nAfter 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.\n\nRemote 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.\n\n## Six ways to hear the same sentence\n\nI 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.\n\nAt 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.\n\nThe commands below use one shell variable:\n\n```\nTEXT='At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.'\n```\n\nI 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.\n\n### macOS say with Samantha\n\nThis 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.\n\n```\nsay -v Samantha -o macos-say-samantha.aiff \"$TEXT\"\n```\n\nThis 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.\n\n### saynice with OpenAI Alloy\n\n`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.\n\n```\nsaynice --voice alloy --model gpt-4o-mini-tts \\\n  -o saynice-alloy.mp3 \"$TEXT\"\n```\n\nThe 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.\n\n### sayneat with Kokoro int8\n\nKokoro 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.\n\n```\nsayneat --model kokoro --voice af_maple \\\n  -o kokoro-af-maple.mp3 \"$TEXT\"\n```\n\nThe 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.\n\n### sayneat with full-precision Kokoro\n\n`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.\n\n```\nsayneat --model kokoro-hq --voice af_maple \\\n  -o kokoro-hq-af-maple.mp3 \"$TEXT\"\n```\n\nThe 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.\n\n### sayneat with KittenTTS Mini\n\nKitten 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`.\n\n```\nsayneat --model kitten --voice expr-voice-5-m \\\n  -o kitten-expr-voice-5-m.mp3 \"$TEXT\"\n```\n\nKitten 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.\n\n### sayneat with Supertonic\n\nSupertonic 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`.\n\n```\nsayneat --model supertonic --voice F3 \\\n  -o supertonic-f3.mp3 \"$TEXT\"\n```\n\nAmong 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.\n\nVoice 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:\n\n```\nsayneat --voices | xargs -I{} \\\n  sayneat \"Hello, this is a demo for {}\" --voice {}\n```\n\nA 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.\n\n## What sayneat keeps from say\n\nI wanted a better synthesizer, not a new ritual. The common cases remain recognizable:\n\n```\nsay \"The deploy finished\"\nsayneat \"The deploy finished\"\n\npbpaste | say\npbpaste | sayneat\n\nsay -v Samantha -o message.aiff \"Keep this\"\nsayneat -v af_maple -o message.wav \"Keep this\"\n```\n\nBoth 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.\n\nsayneat 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:\n\n```\nsayneat < release-notes.txt\n```\n\nThe 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.\n\nThat 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.\n\n## It still behaves like a command-line tool\n\nSpeech quality was the reason to start sayneat. Its input and output behavior is why I kept it.\n\nInline 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.\n\n```\nsayneat The deploy finished\npbpaste | sayneat\nprintf '%s\\n' \"$(git log -1 --pretty=%B)\" | sayneat\n```\n\nWithout `--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:\n\n```\nsayneat -o - \"Archive this message\" | \\\n  ffmpeg -i pipe:0 archive.m4a\n```\n\nThat 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.\n\nPlayback 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.\n\nFor 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.\n\n```\npbpaste | sayneat --interactive\npbpaste | sayneat --interactive --play\n```\n\nI did not need a graphical application for any of this. I needed playback controls that survive a pipe and fit into one terminal line.\n\n## Long text cannot become one giant allocation\n\nA 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.\n\nsayneat 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.\n\nLocal 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.\n\nIt 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.\n\nThis 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.\n\n## Replacing the old habit\n\nThe source is public at [gitlab.com/parlant-co/sayneat](https://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.\n\n```\ngit clone https://gitlab.com/parlant-co/sayneat.git\ncd sayneat\ncargo test\ncargo build --release\n./target/release/sayneat --setup\n```\n\nThe 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.\n\nMy original shell suffix barely changes:\n\n```\nsome-long-running-task; sayneat \"task is done, exited $?\"\n```\n\nThat 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.\n\nIt has replaced both `say` and `saynice` for me. If terminal speech is already part of your work, [give sayneat a try](https://gitlab.com/parlant-co/sayneat). Feedback and bug reports belong in the project, especially reports about machines and voices different from mine.", "url": "https://wpnews.pro/news/i-replaced-macos-say-with-local-neural-speech", "canonical_source": "https://www.lukasrieder.com/blog/i-replaced-macos-say-with-local-neural-speech/", "published_at": "2026-11-01 03:59:48+00:00", "updated_at": "2026-11-01 04:21:05.712225+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai"], "entities": ["sayneat", "saynice", "Apple", "macOS", "OpenAI", "ElevenLabs", "gpt-4o-mini-tts", "Lukas Overbryd"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/i-replaced-macos-say-with-local-neural-speech", "markdown": "https://wpnews.pro/news/i-replaced-macos-say-with-local-neural-speech.md", "text": "https://wpnews.pro/news/i-replaced-macos-say-with-local-neural-speech.txt", "jsonld": "https://wpnews.pro/news/i-replaced-macos-say-with-local-neural-speech.jsonld"}}