diff --git a/README.md b/README.md new file mode 100644 index 0000000..c523524 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Go Say + +You write it, we say it. + +## Learning goals + +- Use of "os/exec" to both run and manage an external Text to Speech program and to play the output from the host's audio card + - Use of os.Pipe +- Use of https://github.com/spf13/viper and https://github.com/spf13/cobra +- Queue structure for TTS +- Cancel context for os.CommandCtx operations +- Packaging a golang binary into a .deb archive for amd64 and arm64 + +## Resources +- https://www.dolthub.com/blog/2022-11-28-go-os-exec-patterns/ +- https://github.com/spf13/cobra/blob/v1.7.0/user_guide.md#user-guide +- Piper +- Aplay + +## CLI API design + +- `gosay "will say this text"`: Uses Piper TTS and aplay to read the argument aloud + - arg `-m` || `-model`: use provided model by name or path. Assumed model config resides next to model +- `echo "Will also say this text" | gosay`: Same as above but reading from stdin +- `gosay list models`: Prints a list of available models sourced from piper project. Should print an * for models already present in model directory +- `gosay list models --installed`: Prints a list of models present in the user's `GOSAY_MODELS` directory. Defaults to `${HOME}/.config/gosay/model` +`gosay download model `: Downloads the piper model and .config file from piper to `GOSAY_MODELS` directroy +`gosay set-default `: Sets model as default model and persists setting to `${HOME}/.config/gosay/config.json` +`gosay server -port 6543`: Starts an HTTP server on port (default 6543) +`gosay check`: Checks that all dependencies and configuration are setup. If an error occurs a helpful message is printed to the console for the user. + diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..72cdc4f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "gosay", + Short: "gosay is a cli tool for performing basic tts", + Long: `gosay is a cli tool for performing basic tts. +It uses Piper TTS and Aplay to perform speech to text from the cmdline. + +Usage: + gosay "Your message" # Say a message directly from an argument + echo "Another message | gosay # Pipe a message to gosay + gosay < input.txt # Redirect from file +`, + Args: cobra.MaximumNArgs(1), + Run: rootRun, +} + +// checkStdInSource checks if the source of stdin is a pipe, redirect or file. If true the source can be read. +func checkStdInSource() (bool, error) { + fileInfo, statErr := os.Stdin.Stat() + if statErr != nil { + return false, fmt.Errorf("could not determine stdin source. %s", statErr) + } + // perform a bitwise check on to check. True if os.Stdin is not a character device(ie. tty) + isPiped := (fileInfo.Mode() & os.ModeCharDevice) == 0 + return isPiped, nil +} + +// rootRun provides the default command of using gosay "your args" +func rootRun(cmd *cobra.Command, args []string) { + var err error + if !(len(args) > 0) { + isPiped, err := checkStdInSource() + if !isPiped || err != nil { + err = errors.New("Must use pipe, redirect from stdin, or provide direct argument") + cobra.CheckErr(err) + } + buf, err := io.ReadAll(os.Stdin) + cobra.CheckErr(err) + + err = Say(buf) + cobra.CheckErr(err) + } else { + // assume its input is comming from stdin + err = Say([]byte(args[0])) + } + cobra.CheckErr(err) +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "An error occurred while executing gosay: %s", err) + os.Exit(1) + } +} diff --git a/cmd/say.go b/cmd/say.go new file mode 100644 index 0000000..8a1630c --- /dev/null +++ b/cmd/say.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "strings" +) + +var rawPiperCmd = strings.Split("piper --model /home/toor/piper_models/en_US-ryan-medium.onnx --output-raw", " ") +var rawAplayCmd = strings.Split("aplay -r 22050 -f S16_LE -t raw -", " ") + +func Say(msg []byte) error { + buf := new(bytes.Buffer) + buf.Write(msg) + r, w, err := os.Pipe() + if err != nil { + return err + } + defer r.Close() + piperCmd := exec.Command(rawPiperCmd[0], rawPiperCmd[1:]...) + piperCmd.Stdin = buf + piperCmd.Stdout = w + err = piperCmd.Start() + if err != nil { + return err + } + w.Close() + defer piperCmd.Wait() + + aplayCmd := exec.Command(rawAplayCmd[0], rawAplayCmd[1:]...) + aplayCmd.Stdin = r + return aplayCmd.Run() +} diff --git a/go.mod b/go.mod index 7838314..eb892b0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module gosay go 1.24.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffae55e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 3260f90..09aea17 100644 --- a/main.go +++ b/main.go @@ -3,57 +3,25 @@ package main import ( - "bufio" - "bytes" - "fmt" - "log/slog" - "os" - "os/exec" - "strings" + "gosay/cmd" ) -var rawPiperCmd = strings.Split("piper --model /home/toor/piper_models/en_US-ryan-medium.onnx --output-raw", " ") -var rawAplayCmd = strings.Split("aplay -r 22050 -f S16_LE -t raw -", " ") - -func say(msg []byte) error { - buf := new(bytes.Buffer) - buf.Write(msg) - r, w, err := os.Pipe() - if err != nil { - return err - } - defer r.Close() - piperCmd := exec.Command(rawPiperCmd[0], rawPiperCmd[1:]...) - piperCmd.Stdin = buf - piperCmd.Stdout = w - err = piperCmd.Start() - if err != nil { - return err - } - w.Close() - defer piperCmd.Wait() - - aplayCmd := exec.Command(rawAplayCmd[0], rawAplayCmd[1:]...) - aplayCmd.Stdin = r - return aplayCmd.Run() -} - func main() { - fmt.Println("Go say!") - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Println("Give me something to say: ") - msg, err := reader.ReadString('\n') - if err != nil { - slog.Error("encoutered error reading from stdin", "error", err) - continue - } - contents := strings.TrimSpace(msg) - err = say([]byte(contents)) - if err != nil { - slog.Error("encountered error in say command", "error", err) - } - } + cmd.Execute() + //reader := bufio.NewReader(os.Stdin) + + //for { + // fmt.Println("Give me something to say: ") + // msg, err := reader.ReadString('\n') + // if err != nil { + // slog.Error("encoutered error reading from stdin", "error", err) + // continue + // } + // contents := strings.TrimSpace(msg) + // err = say([]byte(contents)) + // if err != nil { + // slog.Error("encountered error in say command", "error", err) + // } + // } }