Skip to content

Commit

Permalink
Use cobra in setup & more tests
Browse files Browse the repository at this point in the history
  • Loading branch information
sgerhardt committed Jul 24, 2024
1 parent 23573c9 commit e346922
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 61 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Eleven Labs Client
==================
[![Go Report Card](https://goreportcard.com/badge/github.com/sgerhardt/chatter)](https://goreportcard.com/report/github.com/sgerhardt/chatter)

[![codecov](https://codecov.io/github/sgerhardt/chatter/graph/badge.svg?token=JFOAE30XNQ)](https://codecov.io/github/sgerhardt/chatter)

1. Have an eleven labs account
2. Setup a .env file with the following content
Expand Down
31 changes: 1 addition & 30 deletions cmd/chatter/main.go
Original file line number Diff line number Diff line change
@@ -1,41 +1,12 @@
package main

import (
"github.com/sgerhardt/chatter/internal/client"
"github.com/sgerhardt/chatter/internal/setup"
"github.com/spf13/cobra"
"log"
)

func main() {
run()
}

func run() {
if err := rootCmd.Execute(); err != nil {
if err := setup.RootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

var rootCmd = &cobra.Command{
Use: "app",
Short: "an eleven labs client for text to voice",
Run: func(_ *cobra.Command, _ []string) {
cfg, c, err := setup.New(".env", voiceID, textInput, siteInput)
if err != nil {
log.Fatal(err)
}
client.New(cfg, c).Run()
},
}
var (
voiceID string
textInput string
siteInput string
)

func init() {
rootCmd.PersistentFlags().StringVarP(&textInput, "text", "t", "", "Text to convert to voice")
rootCmd.PersistentFlags().StringVarP(&siteInput, "site", "s", "", "Website to read text from")
rootCmd.PersistentFlags().StringVarP(&voiceID, "voice", "v", "", "Voice ID to use")
}
30 changes: 8 additions & 22 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,43 +75,29 @@ func New(cfg *config.AppConfig, httpClient HTTP) *ElevenLabs {
}
}

func (c *ElevenLabs) Run() {
if c.Config.TextInput != "" {
c.handleText()
}

if c.Config.WebsiteURL != "" {
c.handleSite()
}
}

func (c *ElevenLabs) handleText() {
func (c *ElevenLabs) ProcessText() error {
fromText, err := c.FromText(c.Config.TextInput, c.Config.VoiceID)
if err != nil {
log.Fatal(err)
return err
}
_, err = c.write(fromText)
if err != nil {
log.Fatal(err)
}
return err
}

func (c *ElevenLabs) handleSite() {
func (c *ElevenLabs) ProcessSite() error {
texts, err := c.FromWebsite(c.Config.WebsiteURL)
if err != nil {
log.Fatal(err)
return err
}
for _, text := range texts {
fromText, tErr := c.FromText(text, c.Config.VoiceID)
if tErr != nil {
log.Fatal(tErr)
return tErr
}
_, err = c.write(fromText)
if err != nil {
log.Fatal(err)
}
return err

Check failure on line 98 in internal/client/client.go

View workflow job for this annotation

GitHub Actions / lint-and-test

SA4004: the surrounding loop is unconditionally terminated (staticcheck)
}

return nil
}

func (c *ElevenLabs) FromText(text string, voiceID string) ([]byte, error) {
Expand Down
146 changes: 140 additions & 6 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package client
package client_test

import (
"bytes"
"github.com/sgerhardt/chatter/internal/client"
"github.com/sgerhardt/chatter/internal/client/mocks"
"github.com/sgerhardt/chatter/internal/config"
"os"

"errors"
"github.com/stretchr/testify/assert"
Expand All @@ -14,7 +16,7 @@ import (
"testing"
)

func TestClient_GenerateVoiceFromText(t *testing.T) {
func TestClient_ProcessText(t *testing.T) {
t.Parallel()

type fields struct {
Expand Down Expand Up @@ -43,8 +45,11 @@ func TestClient_GenerateVoiceFromText(t *testing.T) {
mockSetup: func(_ *mocks.HTTP) {},
},
{
name: "marshals a payload to json",
fields: fields{},
name: "Sends text to eleven labs and writes the response to a file",
fields: fields{
apiKey: "123",
outputFilePath: t.TempDir(),
},
args: args{
text: "testing",
voiceID: "stephen_hawking",
Expand Down Expand Up @@ -80,15 +85,144 @@ func TestClient_GenerateVoiceFromText(t *testing.T) {
OutputDir: tt.fields.outputFilePath,
APIKey: tt.fields.apiKey,
VoiceID: tt.args.voiceID,
TextInput: tt.args.text,
}
c := client.New(cfg, mockClient)

err := c.ProcessText()
if tt.error != nil {
assert.EqualError(t, err, tt.error.Error())
return
}

require.NoError(t, err)
assert.DirExists(t, tt.fields.outputFilePath)
files, err := os.ReadDir(tt.fields.outputFilePath)
assert.NoError(t, err)
assert.NotEmpty(t, files, "no files found in the temporary directory")
require.Len(t, files, 1)
// verify the contents of the file
file, err := os.ReadFile(tt.fields.outputFilePath + string(os.PathSeparator) + files[0].Name())
require.NoError(t, err)
assert.Equal(t, "bytes representing the mp3 file...", string(file))

mockClient.AssertExpectations(t)
})
}
}

func TestClient_ProcessSite(t *testing.T) {
t.Parallel()

type fields struct {
apiKey string
outputFilePath string
}
type args struct {
url string
voiceID string
}

tests := []struct {
name string
fields fields
args args
error error
mockSetup func(client *mocks.HTTP)
}{
{
name: "errors if voice id is not populated",
args: args{
url: "https://example.com",
voiceID: "",
},
error: errors.New("voice ID is required"),
mockSetup: func(client *mocks.HTTP) {
mockURLResponse := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("<html><body><h1>testing</h1></body></html>"))),
}

mockURLResponse.StatusCode = http.StatusOK
mockURLResponse.Body = io.NopCloser(bytes.NewReader([]byte("bytes representing the mp3 file...")))
mockElevenLabsResp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("bytes representing the mp3 file..."))),
}
mockElevenLabsResp.StatusCode = http.StatusOK
mockElevenLabsResp.Body = io.NopCloser(bytes.NewReader([]byte("bytes representing the mp3 file...")))
client.On("Do", mock.AnythingOfType("*http.Request")).Return(mockURLResponse, nil).Once()
},
},
{
name: "Reads text from a website, sends it to eleven labs, and writes the response to a file",
fields: fields{
apiKey: "123",
outputFilePath: t.TempDir(),
},
args: args{
url: "https://example.com",
voiceID: "stephen_hawking",
},

mockSetup: func(client *mocks.HTTP) {
// First fetch the website
mockURLResponse := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("<html><body><h1>testing</h1></body></html>"))),
}
client.On("Do", mock.MatchedBy(func(req *http.Request) bool {
return req.URL.String() == "https://example.com"
})).Return(mockURLResponse, nil).Once()

// Make the request to eleven labs
mockElevenLabsResp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("bytes representing the mp3 file..."))),
}
mockElevenLabsResp.StatusCode = http.StatusOK
mockElevenLabsResp.Body = io.NopCloser(bytes.NewReader([]byte("bytes representing the mp3 file...")))
client.On("Do", mock.AnythingOfType("*http.Request")).Return(mockElevenLabsResp, nil).Run(func(args mock.Arguments) {
req := args.Get(0).(*http.Request)
// Verify the body of the request is the expected json
body := new(bytes.Buffer)
_, err := body.ReadFrom(req.Body)
require.NoError(t, err)
assert.Equal(t, body.String(), `{"text":"testing\n","model_id":"eleven_monolingual_v1","voice_settings":{"stability":0,"similarity_boost":0}}`)
}).Once()
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockClient := mocks.NewHTTP(t)
tt.mockSetup(mockClient)
cfg := &config.AppConfig{
CharacterRequestLimit: 100,
OutputDir: tt.fields.outputFilePath,
APIKey: tt.fields.apiKey,
VoiceID: tt.args.voiceID,
WebsiteURL: tt.args.url,
}
c := New(cfg, mockClient)
_, err := c.FromText(tt.args.text, tt.args.voiceID)
c := client.New(cfg, mockClient)

err := c.ProcessSite()
if tt.error != nil {
assert.EqualError(t, err, tt.error.Error())
return
}

require.NoError(t, err)
assert.DirExists(t, tt.fields.outputFilePath)
files, err := os.ReadDir(tt.fields.outputFilePath)
assert.NoError(t, err)
assert.NotEmpty(t, files, "no files found in the temporary directory")
require.Len(t, files, 1)
// verify the contents of the file
file, err := os.ReadFile(tt.fields.outputFilePath + string(os.PathSeparator) + files[0].Name())
require.NoError(t, err)
assert.Equal(t, "bytes representing the mp3 file...", string(file))

mockClient.AssertExpectations(t)
})
Expand Down
5 changes: 3 additions & 2 deletions internal/client/web_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client
package client_test

import (
"github.com/sgerhardt/chatter/internal/client"
"github.com/sgerhardt/chatter/internal/client/mocks"
"github.com/sgerhardt/chatter/internal/config"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -56,7 +57,7 @@ func TestWebReader(t *testing.T) {
VoiceID: "testvoice",
WebsiteURL: "https://test.com",
}
c := New(appConfig, mockClient)
c := client.New(appConfig, mockClient)
texts, err := c.FromWebsite("https://test.com")
if tt.error != nil {
assert.EqualError(t, err, tt.error.Error())
Expand Down
53 changes: 53 additions & 0 deletions internal/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,66 @@ import (
"github.com/joho/godotenv"
"github.com/sgerhardt/chatter/internal/client"
"github.com/sgerhardt/chatter/internal/config"
"github.com/spf13/cobra"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)

var (
voiceID string
textInput string
siteInput string
)

var RootCmd = &cobra.Command{
Use: "chatter -v <voiceID> {-t <text> | -s <url>}",
Short: "An Eleven Labs client for text to voice",
Long: `Chatter is a command-line client for Eleven Labs text-to-voice service.
Usage:
chatter -v <voiceID> -t <text> (Provide text to convert to voice)
chatter -v <voiceID> -s <url> (Provide a URL to read text from)
Either --text or --site is required, but not both.`,
PreRunE: func(cmd *cobra.Command, args []string) error {

Check failure on line 34 in internal/setup/setup.go

View workflow job for this annotation

GitHub Actions / lint-and-test

unused-parameter: parameter 'cmd' seems to be unused, consider removing or renaming it as _ (revive)
if voiceID == "" {
return errors.New("voice is required")
}
if textInput == "" && siteInput == "" {
return errors.New("text or site is required")
}
if textInput != "" && siteInput != "" {
return errors.New("only one of text or site can be provided")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {

Check failure on line 46 in internal/setup/setup.go

View workflow job for this annotation

GitHub Actions / lint-and-test

unused-parameter: parameter 'cmd' seems to be unused, consider removing or renaming it as _ (revive)
cfg, c, err := New(".env", voiceID, textInput, siteInput)
if err != nil {
return err
}
if textInput != "" {
return client.New(cfg, c).ProcessText()
} else if siteInput != "" {
return client.New(cfg, c).ProcessSite()
}
return errors.New("text or site is required")
},
}

func init() {
RootCmd.Flags().StringVarP(&textInput, "text", "t", "", "Text to convert to voice")
RootCmd.Flags().StringVarP(&siteInput, "site", "s", "", "Website to read text from!!")
RootCmd.Flags().StringVarP(&voiceID, "voice", "v", "", "Voice ID to use")
if err := RootCmd.MarkFlagRequired("voice"); err != nil {
log.Fatal(err)
}
}

func readEnvFile(filename string) (string, string, error) {
err := godotenv.Load(filename)
if err != nil {
Expand Down
Loading

0 comments on commit e346922

Please sign in to comment.