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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This tool allows you to easily synthesize speech, save it to an MP3 file, or pla
2. **Cloud Provider API Key:** You need an API key for your chosen TTS provider.
- **Google Cloud:** Go to Google Cloud Console > APIs & Services > Credentials. Create an API Key and restrict it to the "Cloud Text-to-Speech API".
- **Other providers:** Support coming soon.
3. **Audio Player (Linux and Windows):** The `speak` command plays MP3 audio and needs an external player. macOS ships with `afplay` by default, so no install is needed there.
3. **Audio Player (Linux and Windows):** The `speak` command (alias: `say`) plays MP3 audio and needs an external player. macOS ships with `afplay` by default, so no install is needed there.
- **Linux:** install one of `mpg123`, `paplay`, or `ffplay`.
- Ubuntu/Debian: `sudo apt install mpg123`
- **Windows:** install one of `mpg123` or `ffplay` (ships with [ffmpeg](https://ffmpeg.org/)) and make sure the binary is on your `Path`.
Expand Down Expand Up @@ -235,7 +235,7 @@ Prefer managing profiles via the `ttscli profile` subcommands (see [Command Refe

### Command Reference

- `ttscli speak`: Synthesize text to speech and play it immediately.
- `ttscli speak` (alias: `ttscli say`): Synthesize text to speech and play it immediately.
- `ttscli save`: Synthesize text to speech and save MP3 output.
- `ttscli voices`: List available voices (optionally filter with `--lang`).
- `ttscli setup`: Interactive first-run setup (creates a GCP profile).
Expand All @@ -250,7 +250,7 @@ Prefer managing profiles via the `ttscli profile` subcommands (see [Command Refe
- `ttscli --version`: Print build metadata.
- `ttscli --help`: Show top-level help.

### `speak` Flags
### `speak` Flags (also applies to the `say` alias)

| Flag | Type | Default | Notes |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -344,6 +344,9 @@ ttscli speak --text "Hello world, this is a test."

# Using specific profile
ttscli speak --text "Hello world, this is a test." --profile gcp:work

# "say" is an alias for "speak"
ttscli say --text "Hello world, this is a test."
```

**6. Save audio to a file:**
Expand All @@ -368,6 +371,7 @@ ttscli voices --lang en-GB --profile gcp:work

```bash
ttscli speak -t "Hello world, this is a test."
ttscli say -t "Hello world, this is a test."
ttscli save -t "Save this to a file." -o output.mp3
ttscli voices -l en-GB
```
Expand Down
18 changes: 10 additions & 8 deletions internal/app/command_completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ _ttscli_completion() {
cur="${COMP_WORDS[COMP_CWORD]}"

if [[ ${cword} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "speak save voices setup doctor completion profile --version --help" -- "${cur}") )
COMPREPLY=( $(compgen -W "speak say save voices setup doctor completion profile --version --help" -- "${cur}") )
return
fi

case "${words[1]}" in
speak)
speak|say)
COMPREPLY=( $(compgen -W "--text -t --lang -l --voice -v --profile -p --help" -- "${cur}") )
;;
save)
Expand Down Expand Up @@ -117,6 +117,7 @@ _ttscli() {
if (( CURRENT == 2 )); then
_describe 'command' \
'speak:Synthesize speech' \
'say:Synthesize speech (alias for speak)' \
'save:Synthesize and save MP3' \
'voices:List available voices' \
'setup:Run first-time setup' \
Expand All @@ -129,7 +130,7 @@ _ttscli() {
fi

case "$words[2]" in
speak)
speak|say)
_arguments -s $speak_flags
;;
save)
Expand Down Expand Up @@ -162,6 +163,7 @@ _ttscli "$@"
func fishCompletionScript() string {
return `# fish completion for ttscli
complete -c ttscli -f -n "__fish_use_subcommand" -a speak -d "Synthesize speech"
complete -c ttscli -f -n "__fish_use_subcommand" -a say -d "Synthesize speech (alias for speak)"
complete -c ttscli -f -n "__fish_use_subcommand" -a save -d "Synthesize and save MP3"
complete -c ttscli -f -n "__fish_use_subcommand" -a voices -d "List available voices"
complete -c ttscli -f -n "__fish_use_subcommand" -a setup -d "Run first-time setup"
Expand All @@ -185,17 +187,17 @@ complete -c ttscli -f -n "__fish_seen_subcommand_from create" -l name -s n -d "P
complete -c ttscli -f -n "__fish_seen_subcommand_from create" -l api-key -s k -d "API key"
complete -c ttscli -f -n "__fish_seen_subcommand_from create" -l voice -s v -d "Default voice"

complete -c ttscli -f -n "__fish_seen_subcommand_from speak" -l text -s t -d "Text to convert to speech"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak" -l lang -s l -d "Language code"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak" -l voice -s v -d "Voice name"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak" -l profile -s p -d "Profile to use"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak say" -l text -s t -d "Text to convert to speech"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak say" -l lang -s l -d "Language code"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak say" -l voice -s v -d "Voice name"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak say" -l profile -s p -d "Profile to use"
complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l text -s t -d "Text to convert to speech"
complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l out -s o -d "Path to save MP3 output"
complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l lang -s l -d "Language code"
complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l voice -s v -d "Voice name"
complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l profile -s p -d "Profile to use"
complete -c ttscli -f -n "__fish_seen_subcommand_from voices" -l lang -s l -d "Language code"
complete -c ttscli -f -n "__fish_seen_subcommand_from voices" -l profile -s p -d "Profile to use"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak save voices" -l help -d "Show help"
complete -c ttscli -f -n "__fish_seen_subcommand_from speak say save voices" -l help -d "Show help"
`
}
4 changes: 3 additions & 1 deletion internal/app/command_completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestRunCompletionBash(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "complete -F _ttscli_completion ttscli") ||
!strings.Contains(out, "speak save voices setup doctor completion profile") ||
!strings.Contains(out, "speak say save voices setup doctor completion profile") ||
!strings.Contains(out, "--text -t --lang -l --voice -v --profile -p --help") ||
!strings.Contains(out, "list create delete use get") ||
!strings.Contains(out, "--provider -P --name -n --api-key -k --voice -v") {
Expand All @@ -45,6 +45,7 @@ func TestRunCompletionZsh(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "#compdef ttscli") ||
!strings.Contains(out, "say:Synthesize speech (alias for speak)") ||
!strings.Contains(out, "completion:Generate shell completions") ||
!strings.Contains(out, "profile:Manage TTS provider profiles") ||
!strings.Contains(out, "'-t[Text to convert to speech]:text:'") ||
Expand All @@ -68,6 +69,7 @@ func TestRunCompletionFish(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "complete -c ttscli") ||
!strings.Contains(out, "-a say -d \"Synthesize speech (alias for speak)\"") ||
!strings.Contains(out, "-a completion") ||
!strings.Contains(out, "-a profile") ||
!strings.Contains(out, "-l text -s t") ||
Expand Down
1 change: 1 addition & 0 deletions internal/app/command_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ func runProfileGet(cfg cli.Config, stdout io.Writer) error {
fmt.Fprintln(stdout)
fmt.Fprintln(stdout, "Usage:")
fmt.Fprintf(stdout, " ttscli speak --text \"Hello\" --profile %s\n", profileKey)
fmt.Fprintf(stdout, " ttscli say --text \"Hello\" --profile %s\n", profileKey)
fmt.Fprintf(stdout, " ttscli save --text \"Hello\" --out speech.mp3 --profile %s\n", profileKey)
return nil
}
7 changes: 5 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
helpTitle = "ttscli - Multi-Provider Text-to-Speech CLI"
helpDescription = "Convert text to speech using cloud TTS providers (Google Cloud, AWS, Azure, and more)."
helpUsageSpeak = ` ttscli speak --text "Hello world"`
helpUsageSayAlias = ` ttscli say --text "Hello world"`
helpUsageSave = ` ttscli save --text "Hello world" --out output.mp3`
helpUsageVoices = " ttscli voices --lang en-GB"
helpExampleSpeak = ` ttscli speak --text "Hello world, this is a test."`
Expand All @@ -25,6 +26,7 @@ const (
helpExampleSpeakAlias = ` ttscli speak -t "Quick test" -l en-GB -v en-GB-Neural2-B`
helpExampleSaveAlias = ` ttscli save -t "Save this" -o speech.mp3`
ModeSpeak = "speak"
ModeSayAlias = "say"
ModeSave = "save"
ModeVoices = "voices"
ModeSetup = "setup"
Expand Down Expand Up @@ -75,7 +77,7 @@ func ParseArgs(args []string, stderr io.Writer) (Config, error) {
return Config{}, flag.ErrHelp
}

if len(args) > 0 && args[0] == ModeSpeak {
if len(args) > 0 && (args[0] == ModeSpeak || args[0] == ModeSayAlias) {
return parseSpeakCommand(args[1:], stderr)
}
if len(args) > 0 && args[0] == ModeSave {
Expand Down Expand Up @@ -239,7 +241,7 @@ func printHelp(stderr io.Writer) {
fmt.Fprintln(stderr, helpDescription)
fmt.Fprintln(stderr)
fmt.Fprintln(stderr, "Commands:")
fmt.Fprintln(stderr, " speak Synthesize text and play audio immediately")
fmt.Fprintln(stderr, " speak Synthesize text and play audio immediately (alias: say)")
fmt.Fprintln(stderr, " save Synthesize text and save to MP3 file")
fmt.Fprintln(stderr, " voices List available voices for a language")
fmt.Fprintln(stderr, " setup Run interactive first-time setup")
Expand All @@ -252,6 +254,7 @@ func printHelp(stderr io.Writer) {
fmt.Fprintln(stderr)
fmt.Fprintln(stderr, "Examples:")
fmt.Fprintln(stderr, helpUsageSpeak)
fmt.Fprintln(stderr, helpUsageSayAlias)
fmt.Fprintln(stderr, helpUsageSave)
fmt.Fprintln(stderr, helpUsageVoices)
fmt.Fprintln(stderr)
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,34 @@ func TestParseCLIArgsSpeakShorthandFlags(t *testing.T) {
}
}

func TestParseCLIArgsSayIsAliasForSpeak(t *testing.T) {
var stderr bytes.Buffer
cfg, err := ParseArgs([]string{"say", "--text", "hello"}, &stderr)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if cfg.Mode != ModeSpeak {
t.Fatalf("expected mode %q, got %+v", ModeSpeak, cfg)
}
if cfg.Text != "hello" {
t.Fatalf("expected text hello, got %q", cfg.Text)
}
if !cfg.Play {
t.Fatalf("expected Play=true for say, got %+v", cfg)
}
}

func TestParseCLIArgsSayShorthandFlags(t *testing.T) {
var stderr bytes.Buffer
cfg, err := ParseArgs([]string{"say", "-t", "hello", "-v", "en-US-Chirp3-HD-Achernar", "-l", "en-US"}, &stderr)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if cfg.Voice != "en-US-Chirp3-HD-Achernar" || cfg.Lang != "en-US" {
t.Fatalf("unexpected voice/lang values: %+v", cfg)
}
}

func TestParseCLIArgsSaveMissingText(t *testing.T) {
var stderr bytes.Buffer
_, err := ParseArgs([]string{"save", "--out", "out.mp3"}, &stderr)
Expand Down
1 change: 1 addition & 0 deletions internal/tts/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,5 +267,6 @@ func PrintVoices(w io.Writer, langCode string, voices []Voice) {
fmt.Fprintln(w)
fmt.Fprintln(w, "Usage:")
fmt.Fprintf(w, " ttscli speak --text \"Hello\" --lang %s --voice <VOICE_NAME>\n", langCode)
fmt.Fprintf(w, " ttscli say --text \"Hello\" --lang %s --voice <VOICE_NAME>\n", langCode)
fmt.Fprintf(w, " ttscli save --text \"Hello\" --out speech.mp3 --lang %s --voice <VOICE_NAME>\n", langCode)
}