say2 is a set of tools to access the high-quality Siri speech models programmatically on your Mac. You can access it three ways: a CLI, an OpenAI-compatible HTTP API, and a Swift SDK. It reaches neural Siri voices that Apple's public speech APIs exclude and can write WAV, CAF, or raw PCM; stream audio; and return native word timings.
| Audience | Start here | Interface |
|---|---|---|
| Command-line users and automation | Command-line users | Versioned CLI with stable flags, exit codes, and JSON schemas |
| Applications in any language | OpenAI-compatible HTTP API | Local JSON API for voice discovery and speech synthesis |
| Swift app developers | Swift SDK | Swift Package Manager library built on the local HTTP API |
The CLI, HTTP API, and Swift SDK are the supported integration surfaces.
$ say2 voices
NAME LANGUAGE VERSION ENGINE INSTALLED ASSET KEY
Aaron en-US 5030 siri yes en-US:natural:male:Aaron:premium:5030
$ say2 -v Aaron -o hello.wav "The good voice, from the terminal."
✓ Aaron · 2.91s · 48000 Hz · /path/to/hello.wav- macOS 15.6 or newer
- Apple silicon
- Xcode 26 or newer
- At least one Siri voice installed by macOS
Currently verified on macOS 26.6.2 (25G83); older releases are expected to work but have not been runtime-verified.
The CLI is the recommended starting point for people, scripts, and non-Swift applications.
brew tap CaptainCodeAU/say2 https://github.com/CaptainCodeAU/say2.git
brew install CaptainCodeAU/say2/say2Open a new terminal and verify the installation with:
say2 doctor
say2 voicesHomebrew also installs a man page — man say2 covers everything below, including the full exit-status contract, without needing this file. say2 --explain CODE and say2 --exit-codes [--json] cover the same ground from inside the binary itself.
# Generate "Hello." in Aaron's voice and save it as speech.wav
say2 synthesize --voice Aaron --language en-US -o speech.wav "Hello."
# Generate the text currently in the clipboard as article.wav
pbpaste | say2 synthesize -o article.wav
# Generate the text of chapter.txt and save it as chapter.caf
say2 -f chapter.txt --format caf -o chapter.cafWAV is the default file format. Raw PCM is signed little-endian 16-bit audio; Siri currently returns 48 kHz mono.
The common say surface is accepted, so existing scripts can often change only the command name:
alias say=say2
say2 -v Aaron -r 210 -o faster.wav "Two hundred ten words per minute."-v, -o, -f, -r, --file-format, --data-format, --quality, and --progress are supported. As with say, -r means words per minute; 175 WPM maps to the native --rate 1.0. --rate itself remains a multiplier. Lossless PCM has no encoder-quality setting, so valid --quality values are accepted only for command compatibility. Unsupported flags fail clearly instead of being ignored.
say2 synthesize --format pcm -o - "Audio starts before the sentence is finished." \
| your-audio-consumersay2 synthesize \
--voice Aaron \
--timings timings.json \
--json \
-o narration.wav \
"Highlight these words as they are spoken."The daemon supplies each word's start time and an NSRange into the source text. The JSON therefore identifies offsets as UTF-16, which keeps emoji, CJK, and other non-BMP text correct. End times are explicitly marked endDerived: true: they are derived from the next word's start, and the final word ends at the measured audio duration. If a voice does not support native timings, the tool returns an empty list and timingsSupported: false.
Every render starts with roughly 320 ms of silence before the first word, with no flag needed. This is a real, measured characteristic of the underlying engine, not a bug — it exists to stop the first word from clipping when the audio device has been idle, which matters for notification-style playback. say2 does not add or control this padding; do not add your own lead-in on top of it.
say2 does not implement or parse say-style embedded speech commands (e.g. [[slnc 500]]) — it passes text to Apple's engine byte-for-byte. [[slnc N]] for N up to about 400 ms is delivered accurately. Above that, Apple's engine itself silently delivers roughly 200 ms less than requested ([[slnc 500]] renders as ~300 ms, [[slnc 800]] as ~600 ms) — a bug in the underlying voice engine, not in say2, and outside this project's control to fix. If you need an exact, long pause, split it into several tags of 400 ms or less yourself (e.g. [[slnc 400]][[slnc 100]] for 500 ms) — that measured within about 3% of the requested total in testing, versus 25-40% short as a single large tag.
say2 voices
say2 voices --json
say2 voices --include-av
say2 voices --available
say2 voices --install "Siri Voice Name"
say2 voices --install "Siri Voice Name" --wait
say2 voices --purge "Siri Voice Name"
say2 voices --status "Siri Voice Name"
say2 voices --manage- --available lists downloadable premium Siri voices from Apple’s TTS catalog. If the catalog is unavailable, JSON is labeled installed-fallback and only installed voices are returned.
- --install adds a subscription to a voice.
- --wait polls until both the local UAF bundle and daemon voice exist; a timeout does not cancel the request.
- --status reports the current install status of a voice.
- --purge deletes the selected voice’s local asset. It does not change voice subscriptions, so macOS may download the voice again if an app or system service still subscribes to it.
Some voices (e.g. Aaron on en-US) appear as two separate rows with the same
display name but a different technology field in their asset key —
natural and neuralAX — and a large version-number gap (observed:
natural:...:5030 vs neuralAX:...:1370). Picking by name alone can select
either one with no indication they differ.
Measured on identical input, both at 48 kHz: the audio spectra match within
1.5 dB in every band up to 15 kHz — the difference is not loudness, clarity,
or bandwidth. natural took 31% longer to speak the same sentence, with
different pause and stress placement. In short: natural favors expressive
pacing, neuralAX favors speed. If one sounds noticeably better or worse to
you than the other, that's very likely why — pick by ear (--voice
accepts the exact asset key from say2 voices --json if you want to lock in
a specific one) rather than assuming same-named entries are interchangeable.
Unconfirmed but circumstantial: the naming and a roughly 4x on-disk size
difference between the same voice listed under Read & Speak vs VoiceOver
Utility in System Settings suggest neuralAX may be the VoiceOver-oriented
build (AX = Accessibility), optimized for intelligibility at high playback
rates rather than natural cadence. Apple documents neither name, so treat
this as a hypothesis, not a fact.
Start the local HTTP API server with:
say2 serve --port 8080It implements:
GET /v1/models— installed voicesPOST /v1/audio/speech—model,input,voice,response_format, andspeed
curl http://127.0.0.1:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "tts-1",
"input": "A local neural voice.",
"voice": "Aaron",
"response_format": "wav",
"speed": 1.0
}' \
--output speech.wavwav and pcm are the supported response formats.
say2 doctor
say2 doctor --jsonThe report includes the macOS product version and build, architecture, framework and daemon status, engine availability, installed voice assets, ANE compilation state, observed audio format, and a short real non-silent synthesis probe. doctor --json is the right attachment for a compatibility report.
This section documents every command, option, alias, accepted value, default, and combination rule currently implemented by the CLI. Option names are case-sensitive; values such as engine and format names are case-insensitive where noted.
| Invocation | Behavior |
|---|---|
say2 |
Print top-level help. To synthesize from standard input, invoke say2 synthesize or include at least one synthesis option. |
say2 TEXT |
Synthesize positional text without writing the synthesize command explicitly. Multiple positional arguments are joined with spaces. |
say2 synthesize [OPTIONS] [TEXT] |
Synthesize positional text, a UTF-8 input file, or UTF-8 standard input. |
say2 voices [OPTIONS] |
List, discover, install, inspect, or manage voices. |
say2 doctor [OPTIONS] |
Produce a compatibility report. |
say2 serve [OPTIONS] |
Start the local HTTP server. |
say2 -h, say2 --help, say2 help |
Print top-level help and exit successfully. |
say2 COMMAND -h, say2 COMMAND --help |
Print command-specific help and exit successfully. |
say2 -V, say2 --version |
Print the tool version and exit successfully. |
say2 -v ? |
say-compatible shortcut for listing installed Siri voices. |
say2 --explain CODE |
Print what an exit code means and what to do about it, without needing this README. |
say2 --exit-codes [--json] |
Print the complete exit-code reference table, human-readable or as JSON. |
say2 synthesize [OPTIONS] [TEXT...]
say2 [OPTIONS] TEXT...
echo TEXT | say2 synthesize [OPTIONS]
Positional text, -f/--input-file, and standard input are the three input modes. -- ends option parsing so text beginning with a hyphen can be spoken. Long synthesis options that take a value also accept --option=value as an alternative to --option value; the other commands require the separated form.
| Option | Accepted value and default | Behavior |
|---|---|---|
-v NAME, --voice NAME |
Display name or asset identifier; default: engine-selected voice | Select the voice case-insensitively. A catalog identifier is mapped to its installed version when possible. Without an explicit voice, Siri uses its first matching installed voice; AV uses the platform default for the requested language, or en-US when no language is given. |
-l TAG, --language TAG |
Language tag such as en-US; default: no restriction |
Restrict voice selection to an exact, case-insensitive language match. |
-o PATH, --output PATH |
File path, -, or omitted |
Write a file, stream raw PCM to standard output with -, or play through the default audio output when omitted. |
--no-output |
No value | Synthesize and discard the audio without writing or playing it. For measuring synthesis in isolation (e.g. benchmarking). Cannot be combined with -o/--output. Prefer this over -o /dev/null, which fails (see Exit codes) since /dev isn't a writable directory for say2's atomic-write staging. |
-f PATH, --input-file PATH |
UTF-8 text-file path | Read the complete synthesis input from a file. Cannot be combined with positional text. |
-r WPM |
Greater than 0 through 700; default: 175 | say-compatible words per minute. The CLI maps 175 WPM to native rate 1.0. |
--rate MULTIPLIER |
Greater than 0 through 4; default: 1.0 |
Set the native speaking-rate multiplier directly. If both rate forms are supplied, the last one wins. |
--pitch MULTIPLIER |
0.5 through 2.0; default: 1.0 |
Set native pitch. AV engine only — Apple's Siri engine accepts this field but does not honor it; say2 prints a warning to stderr and still produces output if you set it while using --engine siri (the default). |
--volume NUMBER |
0 through 1; default: 1.0 |
Set native volume. AV engine only, for the same reason as --pitch. |
--engine KIND |
siri, av, or auto; default: siri |
Use the private Siri engine, the public AV engine, or visible Siri-to-AV fallback. Values are case-insensitive. |
--format FORMAT |
wav, pcm, or caf; default: wav |
Select the output container. Values are case-insensitive. Raw PCM is signed little-endian 16-bit, 48 kHz, mono. |
--file-format FORMAT |
WAVE/WAV or CAFF/CAF |
say-compatible alias for selecting WAV or CAF. Use --format pcm for raw PCM. Values are case-insensitive. |
--data-format FORMAT |
LEI16@48000, LEI16, or S16LE |
Accept a say-compatible spelling for the fixed raw PCM representation. It does not change the 48 kHz mono Int16 output. Values are case-insensitive. |
--quality NUMBER |
Integer 0 through 127 |
Accepted for say compatibility. Output is lossless PCM, so the value is validated and otherwise ignored. |
--timings PATH |
JSON-file path | Atomically write native word timings to a separate JSON file. This requests timings even without --json. |
--json |
No value | Emit the synthesis result as JSON and request native word timings. |
--progress |
No value | Write start and completion messages to standard error. |
--prewarm |
No value; default | Explicitly enable Siri model prewarming before synthesis. |
--no-prewarm |
No value | Skip Siri model prewarming. |
--timeout SECONDS |
1 through 3600; default: 120 |
Set the engine timeout for each bounded synthesis render. |
-h, --help |
No value | Print synthesis help and exit successfully. |
-- |
No value | Treat all remaining arguments as positional text, including values beginning with -. |
Output rules:
- With no
-o/--output, synthesis is rendered to a temporary WAV file, played through the default audio device, and then removed. --no-outputskips writing or playing anything; nothing is staged to disk. Use it for measuring synthesis in isolation.-o -requires--format pcm. The engine must resolve tosiriorav; omitting--engineuses the defaultsiri, whileautois rejected because fallback after partial streaming could mix engines.- When PCM owns standard output, audio bytes go to standard output and JSON, progress, warnings, and errors go to standard error.
autofalls back toavfor daemon, engine, framework-unavailable, timeout, or audio failures. A missing or uninstalled requested voice and other permanent user errors do not silently fall back.- Long input is split at natural boundaries into bounded sequential renders. Each piece must preserve the same audio format, voice, and selected engine.
- WAV has an approximately 4 GB container limit. Use raw PCM for larger output.
say2 voices [--json] [--available] [--include-av]
say2 voices --install NAME [--wait] [--timeout SECONDS] [--json]
say2 voices --status NAME [--timeout SECONDS] [--json]
say2 voices --manage [--json]
| Option | Accepted value and default | Behavior |
|---|---|---|
--json |
No value | Emit the selected voice operation as JSON instead of the human-readable table or status. |
--available |
No value | List the complete downloadable Siri catalog when available. If the catalog is incompatible, warn and return installed Siri voices with scope installed-fallback. |
--include-av |
No value | Add public AVSpeechSynthesizer voices to a normal or available voice listing. |
--install NAME |
Display name or catalog/native asset key | Ask macOS to subscribe to one catalog voice. An acknowledgement means requested, not installed. |
--status NAME |
Display name or catalog/native asset key | Refresh installed inventory and report installed or not-installed. Absence does not prove that cached files were physically deleted. |
--wait |
No value | With --install, poll installed inventory until the voice appears or the wait times out. This does not expose fabricated percentage progress. |
--timeout SECONDS |
1 through 3600; default: 300 for install waiting and 10 for status |
Bound --install --wait polling or the --status inventory request. With --install, --timeout requires --wait. |
--manage |
No value | Open Accessibility → Read & Speak in System Settings for macOS-managed voice removal. |
-h, --help |
No value | Print voice help and exit successfully. |
--install, --status, and --manage are mutually exclusive actions. None can be combined with --available or --include-av. --wait requires --install; --timeout requires either --status or --install --wait. --available and --include-av may be combined when listing.
say2 doctor [--json] [--skip-probe]
| Option | Default | Behavior |
|---|---|---|
--json |
Off | Emit the complete doctor report as JSON. |
--skip-probe |
Off | Skip only the real audio synthesis probe. Framework, daemon, engine, installed-voice, and ANE compilation checks still run. A skipped probe leaves compatibility unverified. |
-h, --help |
— | Print doctor help and exit successfully. |
Without --skip-probe, doctor synthesizes unique text with Siri when reachable and otherwise uses AV, then validates that the result is non-silent and structurally plausible. A failed probe is recorded in the report; it does not make the doctor command itself exit unsuccessfully.
say2 serve [--host ADDRESS] [--port NUMBER] [--engine KIND] [--verbose] [--allow-remote]
| Option | Accepted value and default | Behavior |
|---|---|---|
--host ADDRESS |
IPv4 or IPv6 literal, or localhost; default: 127.0.0.1 |
Select the bind address. A non-loopback address requires --allow-remote; without it, serve refuses to start. IPv6 addresses are displayed in brackets. |
--port NUMBER |
1 through 65535; default: 8080 |
Select the listening port. |
--engine KIND |
siri, av, or auto; default: siri |
Select the engine used for both model listing and synthesis. Values are case-insensitive. |
--verbose |
Off | Log every request failure to standard error, not only unexpected internal ones, in addition to returning the JSON HTTP error response. |
--allow-remote |
Off | Confirm that binding a non-loopback --host is intentional. Required for any address other than 127.0.0.1, ::1, or localhost. |
-h, --help |
— | Print server help and exit successfully. |
The server handles one synthesis request at a time and always prints its listening URL to standard error. It has no authentication or TLS, so serve refuses to bind a non-loopback address unless --allow-remote is passed explicitly; anyone who can reach the port then has unauthenticated use of the endpoint. Errors from unexpected internal failures are returned to callers as a generic Internal server error message (the real detail is always logged to standard error); errors that are part of the documented API — bad input, missing voice, engine unavailable — are still returned with their specific message.
GET /v1/models returns an OpenAI-style model list. Each installed voice is represented as a model with id, object: "model", and owned_by: "local-macos". The selected server engine controls whether the list comes from Siri, AV, or Siri-with-AV-fallback.
POST /v1/audio/speech requires Content-Type: application/json and these fields:
| JSON field | Required | Accepted value and default |
|---|---|---|
model |
Yes | String accepted for OpenAI request compatibility; engine selection comes from say2 serve --engine. |
input |
Yes | Non-whitespace text. |
voice |
Yes | Voice display name or asset identifier. |
response_format |
No | wav or pcm; default: wav. |
speed |
No | 0.25 through 4.0; default: 1.0. |
A successful speech response is audio/wav or application/octet-stream and carries X-Say2-Engine: siri|av. Errors use {"error":{"message":"..."}} with an appropriate HTTP status. Unknown routes return 404.
CLI JSON is pretty-printed with sorted keys. The principal payloads are:
| Command | JSON payload |
|---|---|
synthesize --json |
schemaVersion, selected engine, optional fallbackFrom, voice, audio, timings, timingsSupported, elapsedSeconds, timeToFirstAudioSeconds (optional), and ignoredOptions. voice contains name, language, assetKey, version, engine, and installed. audio contains format, output, sample format, byte/sample counts, duration, and nonSilent. ignoredOptions lists options that were accepted but had no effect on this render (e.g. "pitch"/"volume" on the siri engine) — always present, empty when nothing was ignored, so a caller can assert on it without a nil check. |
synthesize --timings PATH |
schemaVersion, sourceRangeEncoding: "utf16", endTimes: "derived", and timings. Each timing contains text, start, derived end, utf16Location, utf16Length, and endDerived. |
voices --json |
schemaVersion, catalogScope (installed, available, or installed-fallback), and voices. |
voices --install NAME --json |
schemaVersion, requestedVoice, assetKey, and status: "requested". |
voices --status NAME --json |
Installation result containing identifier, optional assetKey, state, optional voice, and elapsedSeconds. This payload is currently unversioned. |
voices --install NAME --wait --json |
The same currently unversioned installation result; the final state is installed or timed-out. A timed-out wait emits the result and then exits with code 7. |
voices --manage --json |
schemaVersion, status: "opened-system-settings", removalScope: "managed-by-macos", and settingsURL. |
doctor --json |
schemaVersion, toolVersion, generatedAt, system version/build/architecture, framework and daemon status, engine reports, Siri voices with optional ANE state, optional live-probe results, and compatibility. |
Most versioned CLI payloads currently use schemaVersion: 1. The installation result returned by voices --status --json and voices --install --wait --json is the explicit unversioned exception. JSON normally goes to standard output; during raw PCM streaming, synthesis JSON goes to standard error so the audio stream remains clean.
Say2Client is the supported Swift SDK for listing voices and requesting WAV or PCM audio from a separately running say2 helper. The application imports a standard Swift package; the helper process owns the private macOS framework and can fail or restart without taking the host app down.
Your Swift app → Say2Client → localhost → say2 serve → macOS voice service
In Xcode, add this repository as a package dependency and select the Say2Client product. The equivalent Package.swift entry is:
dependencies: [
.package(
url: "https://github.com/CaptainCodeAU/say2.git",
from: "1.0.0"
),
]Then add .product(name: "Say2Client", package: "say2") to the application target. The SDK is intentionally independent of Say2Core, so a consuming app does not need Apple's private Swift interface in its build.
Install the CLI on the same Mac, then start its loopback-only server:
say2 serve --host 127.0.0.1 --port 8080The SDK connects to that address by default. A different helper port can be configured explicitly:
import Foundation
import Say2Client
let client = Say2Client(configuration: .init(
baseURL: URL(string: "http://127.0.0.1:9090")!
))Say2Client connects to an existing local helper. The host application starts and supervises the say2 serve process.
import Foundation
import Say2Client
let client = Say2Client()
let voices = try await client.voices()
let audio = try await client.synthesize(.init(
text: "A local neural voice from a Swift app.",
voice: voices.first?.id ?? "Aaron",
format: .wav,
speed: 1.0
))
try audio.data.write(
to: URL(fileURLWithPath: "speech.wav"),
options: .atomic
)For long articles or books, save the response directly to a file so the complete audio does not have to fit in the app's memory:
let output = URL(fileURLWithPath: "book.wav")
let audioFile = try await client.synthesize(.init(
text: bookText,
voice: voices.first?.id ?? "Aaron"
), to: output)The helper breaks long input into manageable pieces and writes each piece to disk as it finishes. Input length is therefore limited by available time and disk space, rather than by a fixed text-size cutoff. WAV itself has a roughly 4 GB file-size ceiling; use raw PCM for output beyond that format limit.
The response reports whether the helper used the siri or public av engine. Server errors, including a missing voice or unavailable engine, arrive as typed Swift errors.
The interface version is 1.0.0. It covers the documented CLI behavior and HTTP routes used by the Swift SDK. Most versioned CLI JSON payloads carry schemaVersion: 1; the installation-status payload exception is documented in JSON and timing payloads. Stable CLI exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 2 | Invalid invocation |
| 3 | No compatible engine |
| 4 | Voice not found |
| 5 | Daemon unreachable (present but not responding) — transient, worth retrying |
| 6 | Empty, silent, malformed, or implausibly short audio |
| 7 | Timed out waiting for a requested operation to become observable, including a bounded synthesis render |
| 8 | Voice exists in Apple's catalog but is not installed — say2 voices --install fixes it |
| 69 | The private Siri TTS framework is not present on this system — permanent until a say2/macOS update; fall back to --engine av rather than retry |
| 70 | Unexpected internal failure |
| 73 | Could not write audio to the requested output location (bad directory, unwritable path) |
| 130 | Cancelled |
Codes 5 and 69 look similar but call for different caller behavior: 5 means the framework exists but didn't answer in time (retrying is reasonable), 69 means the framework itself is gone (retrying will not help; switch engines).
To verify your own fallback logic against exit 69 without waiting for an actual macOS update to break something, set SAY2_FORCE_FRAMEWORK_UNAVAILABLE=1 in the environment for any say2 invocation — it forces the "framework not present" path deterministically, with no effect unless that variable is set.
make test # unit tests plus a clean external Swift-client package build
make smoke # real installed Siri voice, WAV, timing, and doctor checks
make live-test # Siri, AV, Unicode timing, and HTTP integration tests
make benchmark # time-to-first-audio and total-time CSVThe unit suite covers CLI validation and say translation, WAV headers, silence and frame validation, HTTP parsing and OpenAI request shapes, and UTF-16 timing ranges. Live tests use unique text and validate actual speech rather than treating a build or file write as proof. No test fuzzes Apple's daemon.
- Forked from siri-tts-cli by Maximilian Romer
- Project wouldn’t be possible without Apple’s excellent on-device Siri voice models