diff --git a/README.md b/README.md index da939b7..1051086 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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). @@ -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 | | --- | --- | --- | --- | @@ -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:** @@ -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 ``` diff --git a/internal/app/command_completion.go b/internal/app/command_completion.go index 18d5e48..52b330d 100644 --- a/internal/app/command_completion.go +++ b/internal/app/command_completion.go @@ -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) @@ -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' \ @@ -129,7 +130,7 @@ _ttscli() { fi case "$words[2]" in - speak) + speak|say) _arguments -s $speak_flags ;; save) @@ -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" @@ -185,10 +187,10 @@ 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" @@ -196,6 +198,6 @@ complete -c ttscli -f -n "__fish_seen_subcommand_from save" -l voice -s v -d "Vo 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" ` } diff --git a/internal/app/command_completion_test.go b/internal/app/command_completion_test.go index 0d09cad..7ba4c61 100644 --- a/internal/app/command_completion_test.go +++ b/internal/app/command_completion_test.go @@ -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") { @@ -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:'") || @@ -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") || diff --git a/internal/app/command_profile.go b/internal/app/command_profile.go index b3414ab..9c40dbc 100644 --- a/internal/app/command_profile.go +++ b/internal/app/command_profile.go @@ -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 } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 996736d..5f79f92 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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."` @@ -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" @@ -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 { @@ -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") @@ -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) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b2d180c..75a73d4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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) diff --git a/internal/tts/client.go b/internal/tts/client.go index b78f014..37fc3b2 100644 --- a/internal/tts/client.go +++ b/internal/tts/client.go @@ -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 \n", langCode) + fmt.Fprintf(w, " ttscli say --text \"Hello\" --lang %s --voice \n", langCode) fmt.Fprintf(w, " ttscli save --text \"Hello\" --out speech.mp3 --lang %s --voice \n", langCode) }