From 2e9e7a93fb0c3466cd91c6436adc0165bcdad6cc Mon Sep 17 00:00:00 2001 From: shinshin86 Date: Mon, 2 Dec 2024 07:34:20 +0900 Subject: [PATCH] restructure project to support both CLI and library usage --- .gitignore | 2 +- README.md | 132 ++++++++++++++++++++++++++-- cmd/vpeak/main.go | 72 ++++++++++++++++ main.go | 215 ---------------------------------------------- vpeak.go | 161 ++++++++++++++++++++++++++++++++++ 5 files changed, 358 insertions(+), 224 deletions(-) create mode 100644 cmd/vpeak/main.go delete mode 100644 main.go create mode 100644 vpeak.go diff --git a/.gitignore b/.gitignore index 8cab234..18475e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -vpeak +/vpeak .DS_Store diff --git a/README.md b/README.md index e73ab19..4cb24c4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,25 @@ # vpeak -CLI tool to touch [VOICEPEAK](https://www.ah-soft.com/voice/6nare/) from the command line. +`vpeak` is a tool that allows you to interact with [VOICEPEAK](https://www.ah-soft.com/voice/6nare/) from the command line or within your Go applications. -## Usage +## Features -Execute the following command to speak the string passed as an argument. +- **CLI Tool**: Use `vpeak` from the command line to generate speech audio. +- **Go Library**: Import `vpeak` into your Go projects to generate speech programmatically. + +--- +## CLI Usage + +Execute the following command to have VOICEPEAK speak the string passed as an argument: ```sh +# # not option specfied (narrator: Japanese Female Child, emotion: natural) vpeak こんにちは! # option (narrator: Japanese Female 1, emotion: happy) vpeak -n f1 -e happy "こんにちは" # option (narrator: Japanese Female 1, emotion: happy, output path: ./hello.wav) +# (An audio file will only be generated if the output option is specified, and it will be saved at the designated location.) vpeak -n f1 -e happy -o ./hello.wav "こんにちは" ``` @@ -29,7 +37,7 @@ vpeak -n f1 -e happy -o your-dir-2 -d your-dir ### Silent mode -With the `-silent` option, no voice reading is performed. It also does not automatically delete the generated files. This option is useful for only generating audio files. +When the `-silent` option is used, no voice playback is performed, and the generated files are not automatically deleted. This option is useful if you only want to generate audio files. ``` vpeak -silent "こんにちは" @@ -47,16 +55,124 @@ Run the `help` command for more information. vpeak -h ``` +--- +## Library Usage + +You can also use `vpeak` as a Go library in your own applications. + +### Installation + +To install the library, run: + +```sh +go get github.com/shinshin86/vpeak@latest +``` + +### Importing the Library + +In your Go code, import the `vpeak` package: + +```go +import "github.com/shinshin86/vpeak" +``` + +### Example Usage + +Here's an example of how to use `vpeak` in your Go program: + +```go +package main + +import ( + "fmt" + "log" + + "github.com/shinshin86/vpeak" +) + +func main() { + text := "こんにちは" + opts := vpeak.Options{ + Narrator: "f1", // Narrator option (e.g., "f1", "m1") + Emotion: "happy", // Emotion option (e.g., "happy", "sad") + Output: "hello.wav",// Output file path + Silent: false, // Silent mode (true or false) + } + + if err := vpeak.GenerateSpeech(text, opts); err != nil { + log.Fatalf("Failed to generate speech: %v", err) + } + + fmt.Println("Speech generated successfully.") +} +``` + +### Options + +- `Narrator`: Choose the narrator's voice. Available options: + - `f1`: Japanese Female 1 + - `f2`: Japanese Female 2 + - `f3`: Japanese Female 3 + - `m1`: Japanese Male 1 + - `m2`: Japanese Male 2 + - `m3`: Japanese Male 3 + - `c`: Japanese Female Child +- `Emotion`: Choose the emotion. Available options: + - `happy` + - `fun` + - `angry` + - `sad` + - If no option is specified, it will be `natural`. +- `Output`: Specify the output file path. If not set, defaults to `output.wav`. +- `Silent`: Set to `true` to disable voice playback. + +### Processing Text Files in a Directory + +You can also process all text files in a directory: + +```go +package main + +import ( + "fmt" + "log" + + "github.com/shinshin86/vpeak" +) + +func main() { + dir := "your-dir" + opts := vpeak.Options{ + Narrator: "f1", + Emotion: "happy", + Output: "your-dir-2", // Output directory + Silent: true, + } + + if err := vpeak.ProcessTextFiles(dir, opts); err != nil { + log.Fatalf("Failed to process text files: %v", err) + } + + fmt.Println("Text files processed successfully.") +} +``` + +--- + ## Support -Tested only under the following conditions. +vpeak is currently tested under the following conditions. Compatibility with other environments is not guaranteed. ### OS -Currently only **M1 or later(arm64) mac** are supported +- M1 or later (arm64) Macs only. ### VOICEPEAK -VOICEPEAK must be updated to the latest version in order to use vpeak. -I am testing with 1.2.7. +- VOICEPEAK must be updated to the latest version. +- Tested with version **1.2.7**. + +## Notes +- Ensure that VOICEPEAK is installed at the path specified in the code (`/Applications/voicepeak.app/Contents/MacOS/voicepeak`). If it is installed elsewhere, you may need to adjust the `VoicepeakPath` constant in the code. +- The library functions provide error handling by returning errors, allowing you to handle them appropriately in your application. ## License [MIT](./LICENSE) diff --git a/cmd/vpeak/main.go b/cmd/vpeak/main.go new file mode 100644 index 0000000..2373a16 --- /dev/null +++ b/cmd/vpeak/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/shinshin86/vpeak" +) + +func main() { + var ( + dirOpt = flag.String("d", "", "Directory to read files from") + outputOpt = flag.String("o", "", "Output file path (Specify the name of the output directory if reading by directory (-d option))") + narratorOpt = flag.String("n", "", "Specify the narrator. See below for options.") + emotionOpt = flag.String("e", "", "Specify the emotion. See below for options.") + silentOpt = flag.Bool("silent", false, "Silent mode (no sound)") + ) + + flag.Usage = func() { + fmt.Printf("Usage: %s [OPTIONS] \n", os.Args[0]) + fmt.Println("Options:") + flag.PrintDefaults() + fmt.Println("\nNarrator options:") + fmt.Println(" f1: Japanese Female 1") + fmt.Println(" f2: Japanese Female 2") + fmt.Println(" f3: Japanese Female 3") + fmt.Println(" m1: Japanese Male 1") + fmt.Println(" m2: Japanese Male 2") + fmt.Println(" m3: Japanese Male 3") + fmt.Println(" c: Japanese Female Child") + fmt.Println("\nEmotion options:") + fmt.Println(" happy") + fmt.Println(" fun") + fmt.Println(" angry") + fmt.Println(" sad") + } + + help := flag.Bool("help", false, "Show help") + + flag.Parse() + + if *help { + flag.Usage() + os.Exit(0) + } + + if len(flag.Args()) == 0 && *dirOpt == "" { + log.Fatalf("Usage: %s [-n] ", os.Args[0]) + } + + opts := vpeak.Options{ + Narrator: *narratorOpt, + Emotion: *emotionOpt, + Output: *outputOpt, + Silent: *silentOpt, + } + + if *dirOpt == "" { + text := flag.Args()[0] + if err := vpeak.GenerateSpeech(text, opts); err != nil { + log.Fatalf("Error: %v", err) + } + } else { + if err := vpeak.ProcessTextFiles(*dirOpt, opts); err != nil { + log.Fatalf("Error: %v", err) + } + } + + fmt.Println("Commands executed successfully") +} diff --git a/main.go b/main.go deleted file mode 100644 index 2f0178d..0000000 --- a/main.go +++ /dev/null @@ -1,215 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "io/ioutil" - "log" - "os" - "os/exec" - "path/filepath" - "strings" -) - -const ( - VoicepeakPath = "/Applications/voicepeak.app/Contents/MacOS/voicepeak" - WavName = "output.wav" -) - -var ( - dirOpt = flag.String("d", "", "Directory to read files from") - outputOpt = flag.String("o", WavName, "Output file path (Specify the name of the output directory if reading by directory (-d option))") - narratorOpt = flag.String("n", "", "Specify the narrator. See below for options.") - emotionOpt = flag.String("e", "", "Specify the emotion. See below for options.") - silentOpt = flag.Bool("silent", false, "Silent mode (no sound)") - narratorMap = map[string]string{ - "f1": "Japanese Female 1", - "f2": "Japanese Female 2", - "f3": "Japanese Female 3", - "m1": "Japanese Male 1", - "m2": "Japanese Male 2", - "m3": "Japanese Male 3", - "c": "Japanese Female Child", - } - emotionMap = map[string]string{ - "happy": "happy=100", - "fun": "fun=100", - "angry": "angry=100", - "sad": "sad=100", - } -) - -func playCmd(wavName string) *exec.Cmd { - return exec.Command("afplay", wavName) -} - -func vpCmd(options []string) *exec.Cmd { - _, err := exec.LookPath(VoicepeakPath) - if err != nil { - log.Fatalf("Command not found: %v", err) - } - - return exec.Command(VoicepeakPath, options...) -} - -func convertWavExt(filename string) string { - oldExt := filepath.Ext(filename) - return strings.TrimSuffix(filename, oldExt) + ".wav" -} - -func processOptions() []string { - text := flag.Args()[0] - options := []string{"-s", text} - - narrator, ok := narratorMap[*narratorOpt] - - if !ok && *narratorOpt != "" { - log.Fatalf("Invalid narrator option: %s", *narratorOpt) - } - if ok { - options = append([]string{"--narrator", narrator}, options...) - } - - emotion, ok := emotionMap[*emotionOpt] - if !ok && *emotionOpt != "" { - log.Fatalf("Invalid emotion option: %s", *emotionOpt) - } - if ok { - options = append([]string{"--emotion", emotion}, options...) - } - - if *outputOpt != "" { - options = append([]string{"-o", *outputOpt}, options...) - } - - return options -} - -func executeCommands(options []string, output string) { - cmd1 := vpCmd(options) - handleError(cmd1.Run(), "voicepeak command failed") - - if !*silentOpt { - cmd2 := playCmd(output) - handleError(cmd2.Run(), "wav file play failed") - - if output == WavName { - handleError(os.Remove(WavName), fmt.Sprintf("Failed to delete %s", WavName)) - } - } -} - -func handleError(err error, message string) { - if err != nil { - log.Fatalf("%s: %v", message, err) - } -} - -func readTextFile(filePath string) (string, error) { - content, err := ioutil.ReadFile(filePath) - if err != nil { - return "", err - } - return string(content), nil -} - -func processTextFiles(dir string) { - files, err := ioutil.ReadDir(dir) - handleError(err, "Error reading directory") - - for _, file := range files { - if !file.IsDir() && filepath.Ext(file.Name()) == ".txt" { - filePath := filepath.Join(dir, file.Name()) - content, err := readTextFile(filePath) - if err != nil { - log.Printf("Error reading file (%s): %v", file.Name(), err) - continue - } - - outputName := convertWavExt(file.Name()) - outputPath := filepath.Join(dir, outputName) - options := buildOptions(content, outputPath) - - executeCommands(options, outputPath) - } - } -} - -func buildOptions(content, outputPath string) []string { - if *outputOpt != "" { - // Override directory output - outputPath = filepath.Join(*outputOpt, convertWavExt(filepath.Base(outputPath))) - } - options := []string{"-s", content, "-o", outputPath} - - addOption := func(key string, value string) { - if value != "" { - options = append([]string{key, value}, options...) - } - } - - narrator, ok := narratorMap[*narratorOpt] - if !ok && *narratorOpt != "" { - log.Fatalf("Invalid narrator option: %s", *narratorOpt) - } - if ok { - addOption("--narrator", narrator) - } - - emotion, ok := emotionMap[*emotionOpt] - if !ok && *emotionOpt != "" { - log.Fatalf("Invalid emotion option: %s", *emotionOpt) - } - if ok { - addOption("--emotion", emotion) - } - - return options -} - -func main() { - flag.Usage = func() { - fmt.Printf("Usage: %s [OPTIONS] \n", os.Args[0]) - fmt.Println("Options:") - flag.PrintDefaults() - fmt.Println("\nNarrator options:") - fmt.Println(" f1: Japanese Female 1") - fmt.Println(" f2: Japanese Female 2") - fmt.Println(" f3: Japanese Female 3") - fmt.Println(" m1: Japanese Male 1") - fmt.Println(" m2: Japanese Male 2") - fmt.Println(" m3: Japanese Male 3") - fmt.Println(" c: Japanese Female Child") - fmt.Println("\nEmotion options:") - fmt.Println(" happy") - fmt.Println(" fun") - fmt.Println(" angry") - fmt.Println(" sad") - } - - help := flag.Bool("help", false, "Show help") - - flag.Parse() - - if *help { - flag.Usage() - os.Exit(0) - } - - if len(os.Args) < 2 { - log.Fatalf("Usage: %s [-n] ", os.Args[0]) - } - - if *dirOpt == "" { - options := processOptions() - output := WavName - if *outputOpt != "" { - output = *outputOpt - } - executeCommands(options, output) - } else { - processTextFiles(*dirOpt) - } - - fmt.Println("Commands executed successfully") -} diff --git a/vpeak.go b/vpeak.go new file mode 100644 index 0000000..4e29d0e --- /dev/null +++ b/vpeak.go @@ -0,0 +1,161 @@ +package vpeak + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + VoicepeakPath = "/Applications/voicepeak.app/Contents/MacOS/voicepeak" + WavName = "output.wav" +) + +var ( + narratorMap = map[string]string{ + "f1": "Japanese Female 1", + "f2": "Japanese Female 2", + "f3": "Japanese Female 3", + "m1": "Japanese Male 1", + "m2": "Japanese Male 2", + "m3": "Japanese Male 3", + "c": "Japanese Female Child", + } + emotionMap = map[string]string{ + "happy": "happy=100", + "fun": "fun=100", + "angry": "angry=100", + "sad": "sad=100", + } +) + +// Options struct holds the settings for speech generation +type Options struct { + Narrator string + Emotion string + Output string + Silent bool +} + +// GenerateSpeech generates speech audio from the given text and options +func GenerateSpeech(text string, opts Options) error { + options := buildOptions(text, opts) + output := opts.Output + if output == "" { + output = WavName + } + + cmd1 := vpCmd(options) + if err := cmd1.Run(); err != nil { + return fmt.Errorf("voicepeak command failed: %v", err) + } + + if !opts.Silent { + if err := PlayAudio(output); err != nil { + return err + } + + // if the output is not specified, delete the generated wav file + if output == WavName { + if err := os.Remove(WavName); err != nil { + return fmt.Errorf("failed to delete %s: %v", WavName, err) + } + } + } + + return nil +} + +// PlayAudio plays the specified audio file +func PlayAudio(wavName string) error { + cmd := exec.Command("afplay", wavName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("wav file play failed: %v", err) + } + return nil +} + +// ProcessTextFiles processes text files in a directory and generates audio files +func ProcessTextFiles(dir string, opts Options) error { + files, err := ioutil.ReadDir(dir) + if err != nil { + return fmt.Errorf("error reading directory: %v", err) + } + + for _, file := range files { + if !file.IsDir() && filepath.Ext(file.Name()) == ".txt" { + filePath := filepath.Join(dir, file.Name()) + content, err := readTextFile(filePath) + if err != nil { + log.Printf("Error reading file (%s): %v", file.Name(), err) + continue + } + + outputName := convertWavExt(file.Name()) + outputPath := filepath.Join(dir, outputName) + + if opts.Output != "" { + // Override directory output + outputPath = filepath.Join(opts.Output, outputName) + } + + localOpts := opts + localOpts.Output = outputPath + + if err := GenerateSpeech(content, localOpts); err != nil { + log.Printf("Error generating speech for file (%s): %v", file.Name(), err) + continue + } + } + } + + return nil +} + +func vpCmd(options []string) *exec.Cmd { + _, err := exec.LookPath(VoicepeakPath) + if err != nil { + log.Fatalf("Command not found: %v", err) + } + + return exec.Command(VoicepeakPath, options...) +} + +func convertWavExt(filename string) string { + oldExt := filepath.Ext(filename) + return strings.TrimSuffix(filename, oldExt) + ".wav" +} + +func buildOptions(text string, opts Options) []string { + options := []string{"-s", text} + + if narrator, ok := narratorMap[opts.Narrator]; ok { + options = append([]string{"--narrator", narrator}, options...) + } else if opts.Narrator != "" { + log.Fatalf("Invalid narrator option: %s", opts.Narrator) + } + + if emotion, ok := emotionMap[opts.Emotion]; ok { + options = append([]string{"--emotion", emotion}, options...) + } else if opts.Emotion != "" { + log.Fatalf("Invalid emotion option: %s", opts.Emotion) + } + + if opts.Output != "" { + options = append([]string{"-o", opts.Output}, options...) + } + + return options +} + +func readTextFile(filePath string) (string, error) { + content, err := ioutil.ReadFile(filePath) + if err != nil { + return "", err + } + return string(content), nil +}