Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## Unreleased
### Added
- Documented 60db provider support via `/default-voices`, `/myvoices`, `/tts-stream`, and `/tts-synthesize`, with strict provider selection and response validation. (#20, thanks @manishEMS47)
### Changed
- Release archives now include target-specific macOS and Linux assets for Homebrew and aqua installers.

Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# sag 🗣️ — “Mac-style speech with ElevenLabs”
# sag 🗣️ — “Mac-style speech with ElevenLabs or 60db

One-liner TTS that works like `say`: stream to speakers by default, list voices, or save audio files.

Expand All @@ -24,9 +24,28 @@ sudo apt install build-essential pkg-config libasound2-dev
```

## 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`

`sag` supports two TTS providers and auto-selects one from your configured credentials:

- **ElevenLabs** — `ELEVENLABS_API_KEY` (or `--api-key`, or `--api-key-file` / `ELEVENLABS_API_KEY_FILE` / `SAG_API_KEY_FILE`)
- **60db** (`api.60db.ai`) — `SIXTYDB_API_KEY` (or `SIXTYDB_API_KEY_FILE`)

Selection rules:
- Only one key set → that provider is used.
- Both keys set → error; unset one provider key and retry.
- Neither set → error.

Optional ElevenLabs defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`. Override the active provider host with `--base-url`.

60db support is intentionally narrow and follows the documented HTTP API:
- voice discovery merges `GET /default-voices` and `GET /myvoices`
- default MP3 streaming uses `POST /tts-stream`
- explicit file formats and full downloads use `POST /tts-synthesize`
- the `success` envelope is validated even on HTTP 200 responses

Shared speak flags that work on both providers include `--voice`, `--speed` / `--rate`, `--stability`, `--similarity`, `--format`, `--stream`, `--play`, `--output`, `--timeout`, and `--metrics`.

ElevenLabs-only speak flags fail fast on 60db: `--model-id`, `--style`, `--speaker-boost` / `--no-speaker-boost`, `--seed`, `--normalize`, `--lang`, and `--latency-tier`. `--stability` / `--similarity` still use the CLI's `0..1` range and are scaled to 60db's documented `0..100` API values. See [docs/providers.md](docs/providers.md) for provider-specific details.

## Usage

Expand Down Expand Up @@ -61,6 +80,7 @@ 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"
SIXTYDB_API_KEY=... sag speak -v Aria --output out.wav --no-play "60db WAV output"
```

Key flags (subset):
Expand Down Expand Up @@ -149,6 +169,6 @@ ffprobe -v quiet -show_entries format=duration -of csv=p=0 long.mp3
- Build: `go build ./cmd/sag`

## Limitations
- ElevenLabs account and API key required.
- One provider API key is required.
- 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.
40 changes: 28 additions & 12 deletions cmd/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,39 @@ import (
"strings"
)

func ensureAPIKey() error {
if cfg.APIKey == "" {
key, err := resolveAPIKeyFromFile()
if err != nil {
return err
}
cfg.APIKey = key
// resolveElevenLabsKey resolves the ElevenLabs API key without erroring when
// absent (returns ""). Order: --api-key, key file, ELEVENLABS_API_KEY,
// SAG_API_KEY.
func resolveElevenLabsKey() (string, error) {
if cfg.APIKey != "" {
return cfg.APIKey, nil
}
key, err := resolveAPIKeyFromFile()
if err != nil {
return "", err
}
if key != "" {
return key, nil
}
if v := os.Getenv("ELEVENLABS_API_KEY"); v != "" {
return v, nil
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("ELEVENLABS_API_KEY")
if v := os.Getenv("SAG_API_KEY"); v != "" {
return v, nil
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("SAG_API_KEY")
return "", nil
}

// ensureAPIKey resolves and stores the ElevenLabs API key, erroring if missing.
func ensureAPIKey() error {
key, err := resolveElevenLabsKey()
if err != nil {
return err
}
if cfg.APIKey == "" {
if key == "" {
return fmt.Errorf("missing ElevenLabs API key (set --api-key, --api-key-file, or ELEVENLABS_API_KEY)")
}
cfg.APIKey = key
return nil
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/prompting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
)

func TestPromptingCommandOutputsGuide(t *testing.T) {
resetRootCommandState()

restore, read := captureStdout(t)
defer restore()

Expand Down
102 changes: 102 additions & 0 deletions cmd/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/steipete/sag/internal/elevenlabs"
"github.com/steipete/sag/internal/sixtydb"
"github.com/steipete/sag/internal/tts"
)

const (
providerElevenLabs = "elevenlabs"
providerSixtyDB = "60db"
)

type activeProvider struct {
name string
voices tts.VoiceCatalog

elevenlabs *elevenlabs.Client
sixtydb *sixtydb.Client
}

// resolveSixtyDBKey resolves the 60db API key from its dedicated env vars.
// Order: SIXTYDB_API_KEY, then SIXTYDB_API_KEY_FILE.
func resolveSixtyDBKey() (string, error) {
if key := strings.TrimSpace(os.Getenv("SIXTYDB_API_KEY")); key != "" {
return key, nil
}
if path := strings.TrimSpace(os.Getenv("SIXTYDB_API_KEY_FILE")); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read 60db api key file: %w", err)
}
key := strings.TrimSpace(string(data))
if key == "" {
return "", fmt.Errorf("60db api key file %q is empty", path)
}
return key, nil
}
return "", nil
}

func ensureProviderConfigured() error {
_, err := selectProvider()
return err
}

func selectProvider() (activeProvider, error) {
elKey, err := resolveElevenLabsKey()
if err != nil {
return activeProvider{}, err
}
sdKey, err := resolveSixtyDBKey()
if err != nil {
return activeProvider{}, err
}

switch {
case elKey != "" && sdKey != "":
return activeProvider{}, fmt.Errorf("ambiguous provider configuration: both ElevenLabs and 60db keys are set; unset one provider key and retry")
case elKey != "":
client := elevenlabs.NewClient(elKey, cfg.BaseURL)
return activeProvider{
name: providerElevenLabs,
voices: client,
elevenlabs: client,
}, nil
case sdKey != "":
client := sixtydb.NewClient(sdKey, cfg.BaseURL)
return activeProvider{
name: providerSixtyDB,
voices: client,
sixtydb: client,
}, nil
default:
return activeProvider{}, fmt.Errorf("missing API key (set ELEVENLABS_API_KEY or SIXTYDB_API_KEY)")
}
}

var sixtyDBUnsupportedFlags = []string{
"model-id",
"style",
"speaker-boost",
"no-speaker-boost",
"seed",
"normalize",
"lang",
"latency-tier",
}

func changedSixtyDBUnsupportedFlags(changed func(string) bool) []string {
var unsupported []string
for _, name := range sixtyDBUnsupportedFlags {
if changed(name) {
unsupported = append(unsupported, "--"+name)
}
}
return unsupported
}
80 changes: 80 additions & 0 deletions cmd/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"strings"
"testing"
)

// resetProviderEnv neutralizes every key source so each case starts clean.
// Setting an env var to "" makes the resolvers treat it as absent.
func resetProviderEnv(t *testing.T) {
t.Helper()
cfg.APIKey = ""
cfg.APIKeyFile = ""
cfg.BaseURL = ""
t.Cleanup(func() { cfg.APIKey = ""; cfg.APIKeyFile = ""; cfg.BaseURL = "" })
for _, k := range []string{
"ELEVENLABS_API_KEY", "SAG_API_KEY",
"ELEVENLABS_API_KEY_FILE", "SAG_API_KEY_FILE",
"SIXTYDB_API_KEY", "SIXTYDB_API_KEY_FILE",
} {
t.Setenv(k, "")
}
}

func TestSelectProvider_ElevenLabsOnly(t *testing.T) {
resetProviderEnv(t)
t.Setenv("ELEVENLABS_API_KEY", "el-key")

provider, err := selectProvider()
if err != nil {
t.Fatalf("selectProvider error: %v", err)
}
if provider.name != providerElevenLabs || provider.elevenlabs == nil || provider.voices == nil {
t.Fatalf("unexpected provider: %+v", provider)
}
}

func TestSelectProvider_SixtyDBOnly(t *testing.T) {
resetProviderEnv(t)
t.Setenv("SIXTYDB_API_KEY", "sd-key")

provider, err := selectProvider()
if err != nil {
t.Fatalf("selectProvider error: %v", err)
}
if provider.name != providerSixtyDB || provider.sixtydb == nil || provider.voices == nil {
t.Fatalf("unexpected provider: %+v", provider)
}
}

func TestSelectProvider_BothKeysError(t *testing.T) {
resetProviderEnv(t)
t.Setenv("ELEVENLABS_API_KEY", "el-key")
t.Setenv("SIXTYDB_API_KEY", "sd-key")

_, err := selectProvider()
if err == nil || !strings.Contains(err.Error(), "ambiguous provider configuration") {
t.Fatalf("expected ambiguity error, got %v", err)
}
}

func TestSelectProvider_NeitherErrors(t *testing.T) {
resetProviderEnv(t)

_, err := selectProvider()
if err == nil {
t.Fatal("expected error when no API key is set")
}
}

func TestEnsureProviderConfigured(t *testing.T) {
resetProviderEnv(t)
if err := ensureProviderConfigured(); err == nil {
t.Fatal("expected error with no keys")
}
t.Setenv("SIXTYDB_API_KEY", "sd-key")
if err := ensureProviderConfigured(); err != nil {
t.Fatalf("expected 60db key to satisfy configuration, got %v", err)
}
}
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ var (
versionFlag bool
rootCmd = &cobra.Command{
Use: "sag",
Short: "🗣️ ElevenLabs speech, mac-style ease",
Long: "Command-line ElevenLabs TTS with macOS playback. Call it like macOS 'say': if you skip the subcommand, text args are passed to 'speak' (e.g. `sag \"Hello\"`).\n\nTip: run `sag prompting` for model-specific prompting tips.\nModels: `eleven_v3` (default), `eleven_multilingual_v2` (stable), `eleven_flash_v2_5` (fast/cheap), `eleven_turbo_v2_5` (balanced).",
Short: "🗣️ TTS speech, mac-style ease",
Long: "Command-line TTS with macOS-style playback and voice flags. Call it like macOS 'say': if you skip the subcommand, text args are passed to 'speak' (e.g. `sag \"Hello\"`).\n\nTip: run `sag prompting` for ElevenLabs prompting tips. Provider selection is automatic: configure exactly one of ElevenLabs or 60db.",
Example: " sag \"Hi Peter\"\n echo 'piped input' | sag\n sag speak -v Roger --rate 200 \"Faster speech\"\n sag prompting",
Version: "0.3.0",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
Expand All @@ -45,7 +45,7 @@ func Execute() {
func init() {
rootCmd.PersistentFlags().StringVar(&cfg.APIKey, "api-key", "", "ElevenLabs API key (or ELEVENLABS_API_KEY)")
rootCmd.PersistentFlags().StringVar(&cfg.APIKeyFile, "api-key-file", "", "Read ElevenLabs API key from file (or ELEVENLABS_API_KEY_FILE)")
rootCmd.PersistentFlags().StringVar(&cfg.BaseURL, "base-url", "https://api.elevenlabs.io", "Override ElevenLabs API base URL")
rootCmd.PersistentFlags().StringVar(&cfg.BaseURL, "base-url", "", "Override the provider API base URL (empty = provider default)")
rootCmd.PersistentFlags().BoolVarP(&versionFlag, "version", "V", false, "Print version and exit")
}

Expand Down
Loading
Loading