返回资讯中心
外部精选
软件工程
#开发者实时流

I replaced macOS say with local neural speech

I replaced macOS say with sayneat, a local text-to-speech CLI. Six voices handled the same stubborn build very differently.

Hacker News 最新ankitg122026/11/1 · 5 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

I replaced macOS say with sayneat, a local text-to-speech CLI. Six voices handled the same stubborn build very differently.

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.

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 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:

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.

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.

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"

Browser cannot play this audio sample.

macOS say, Samantha voice. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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 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"

Browser cannot play this audio sample.

saynice using OpenAI gpt-4o-mini-tts, Alloy voice. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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.

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"

Browser cannot play this audio sample.

sayneat with Kokoro int8, af_maple voice. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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.

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"

Browser cannot play this audio sample.

sayneat with full-precision Kokoro, af_maple voice. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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.

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"

Browser cannot play this audio sample.

sayneat with KittenTTS Mini, expr-voice-5-m. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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.

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"

Browser cannot play this audio sample.

sayneat with Supertonic 3, F3 voice. Transcript: At 3:17, the stubborn build finally passed all 42 tests. Put the kettle on.

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.

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.

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

Wit

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →