Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sag 🗣️ — “Mac-style speech with ElevenLabs”
# sag 🗣️ — “Mac-style speech with ElevenLabs and MiniMax

One-liner TTS that works like `say`: stream to speakers by default, list voices, or save audio files.
One-liner TTS that works like `say`: stream to speakers by default, list voices, or save audio files. Defaults to ElevenLabs, with MiniMax available via `speech-*` model IDs.

## Install
Homebrew (macOS):
Expand All @@ -15,18 +15,20 @@ go install ./cmd/sag
Requires Go 1.24+.

## Configuration
- `ELEVENLABS_API_KEY` (required)
- `--api-key-file` or `ELEVENLABS_API_KEY_FILE`/`SAG_API_KEY_FILE` to load the key from a file
- Optional defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
- ElevenLabs: `ELEVENLABS_API_KEY` (or `SAG_API_KEY`)
- MiniMax: `MINIMAX_API_KEY` (or `SAG_API_KEY`)
- `--api-key-file` or `ELEVENLABS_API_KEY_FILE`/`MINIMAX_API_KEY_FILE`/`SAG_API_KEY_FILE` to load the key from a file
- Optional defaults: `ELEVENLABS_VOICE_ID`, `MINIMAX_VOICE_ID`, or `SAG_VOICE_ID`
- Optional: `MINIMAX_API_HOST` or `MINIMAX_BASE_URL` to override the MiniMax base URL

## Usage

Features:
- macOS `say`-style default: `sag "Hello"` routes to `speak` automatically.
- Streaming playback to speakers with optional file output.
- Voice discovery via `sag voices` and `-v ?`.
- Voice discovery via `sag voices` (ElevenLabs) and `-v ?` (provider-specific).
- Speed/rate controls, latency tiers, and format inference from output extension.
- Model selection via `--model-id` (defaults to `eleven_v3`; use `eleven_multilingual_v2` for a stable baseline).
- Model selection via `--model-id` (defaults to `eleven_v3`; use `eleven_multilingual_v2` for a stable baseline, `speech-*` for MiniMax).

Speak (streams audio):
```bash
Expand All @@ -52,6 +54,8 @@ sag speak -v Roger --stream --latency-tier 3 "Faster start"
sag speak -v Roger --speed 1.2 "Talk a bit faster"
sag speak -v Roger --model-id eleven_multilingual_v2 "Use stable v2 baseline"
sag speak -v Roger --output out.wav --format pcm_44100 "Wave output"
sag speak --model-id speech-01 -v ? "List MiniMax voices"
sag speak --model-id speech-01 --output out.flac --stream=false "MiniMax file output"
```

Key flags (subset):
Expand Down Expand Up @@ -94,7 +98,11 @@ Highlights:

## Models / engines

`sag` supports any ElevenLabs `model_id` via `--model-id` (we pass it through). Practical defaults + common IDs:
Provider selection:
- ElevenLabs (default): any ElevenLabs `model_id` via `--model-id` (we pass it through).
- MiniMax: use a `speech-*` model ID to route requests to MiniMax. Streaming/playback is MP3-only; use `--stream=false` for WAV/FLAC output.

Practical defaults + common ElevenLabs IDs:

| Engine | `--model-id` | Prompting style | Best for |
|---|---|---|---|
Expand Down Expand Up @@ -123,6 +131,6 @@ Notes:
- Build: `go build ./cmd/sag`

## Limitations
- ElevenLabs account and API key required.
- ElevenLabs or MiniMax account and API key required (per provider).
- Voice defaults to first available if not provided.
- Non-mac platforms: playback still works via `go-mp3` + `oto`, but device selection flags are no-ops.
49 changes: 49 additions & 0 deletions cmd/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ func ensureAPIKey() error {
return nil
}

func ensureAPIKeyForProvider(provider string) error {
if provider == "minimax" {
return ensureMiniMaxAPIKey()
}
return ensureAPIKey()
}

func ensureMiniMaxAPIKey() error {
if cfg.APIKey == "" {
key, err := resolveMiniMaxAPIKeyFromFile()
if err != nil {
return err
}
cfg.APIKey = key
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("MINIMAX_API_KEY")
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("SAG_API_KEY")
}
if cfg.APIKey == "" {
return fmt.Errorf("missing MiniMax API key (set --api-key, --api-key-file, or MINIMAX_API_KEY)")
}
return nil
}

func resolveAPIKeyFromFile() (string, error) {
path := cfg.APIKeyFile
if path == "" {
Expand All @@ -47,3 +74,25 @@ func resolveAPIKeyFromFile() (string, error) {
}
return key, nil
}

func resolveMiniMaxAPIKeyFromFile() (string, error) {
path := cfg.APIKeyFile
if path == "" {
path = os.Getenv("MINIMAX_API_KEY_FILE")
}
if path == "" {
path = os.Getenv("SAG_API_KEY_FILE")
}
if path == "" {
return "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read api key file: %w", err)
}
key := strings.TrimSpace(string(data))
if key == "" {
return "", fmt.Errorf("api key file %q is empty", path)
}
return key, nil
}
Loading