Add text-to-speech support. #13

Merged
anna merged 2 commits from tts into main 2025-07-29 19:59:55 +00:00
7 changed files with 116 additions and 122 deletions
Showing only changes of commit cd43d98fb6 - Show all commits

View file

@ -63,21 +63,18 @@ func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDev
func main() { func main() {
// parse command-line // parse command-line
var configFlag string var configFlag string
var ttsVoiceFlag string flag.BoolVarP(&logger.IsDebugMode, "debug", "d", false, "Output very verbose debug messages.")
var ttsFlag bool
flag.StringVarP(&configFlag, "config", "c", "~/.config/joyful", "Directory to read configuration from.") flag.StringVarP(&configFlag, "config", "c", "~/.config/joyful", "Directory to read configuration from.")
addTTSFlags(&ttsFlag, &ttsVoiceFlag) ttsOps := addTTSFlags()
flag.Parse() flag.Parse()
// parse configs // parse configs
configDir := getConfigDir(configFlag) configDir := getConfigDir(configFlag)
config := readConfig(configDir) config := readConfig(configDir)
tts, err := newTTS(ttsFlag, ttsVoiceFlag) // initialize TTS
tts, err := newTTS(ttsOps)
logger.LogIfError(err, "Failed to initialize TTS") logger.LogIfError(err, "Failed to initialize TTS")
if tts != nil {
defer tts.Cleanup()
}
// Initialize virtual devices with event buffers // Initialize virtual devices with event buffers
vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) vBuffersByName, vBuffersByDevice := initVirtualBuffers(config)
@ -91,6 +88,12 @@ func main() {
// initialize the mode variable // initialize the mode variable
mode := config.GetModes()[0] mode := config.GetModes()[0]
// initialize TTS phrases for modes
for _, m := range config.GetModes() {
tts.AddMessage(m)
logger.LogDebugf("Added TTS message '%s'", m)
}
fmt.Println("Joyful Running! Press Ctrl+C to quit. Press Enter to reload rules.") fmt.Println("Joyful Running! Press Ctrl+C to quit. Press Enter to reload rules.")
if len(config.GetModes()) > 1 { if len(config.GetModes()) > 1 {
logger.Logf("Initial mode set to '%s'", mode) logger.Logf("Initial mode set to '%s'", mode)

View file

@ -1,65 +1,51 @@
//go:build !notts
package main package main
import ( import (
"bytes" "bytes"
"io" "fmt"
"os" "os/exec"
"strconv"
"time" "time"
"git.annabunches.net/annabunches/joyful/internal/logger" "git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/amitybell/piper"
asset "github.com/amitybell/piper-asset"
alan "github.com/amitybell/piper-voice-alan"
jenny "github.com/amitybell/piper-voice-jenny"
"github.com/ebitengine/oto/v3" "github.com/ebitengine/oto/v3"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
) )
type TTSOptions struct {
Disabled bool
Voice string
Volume int
Pitch int
Range int
Speed int
}
type TTS struct { type TTS struct {
piper.TTS options *TTSOptions
dataDir string
otoCtx *oto.Context otoCtx *oto.Context
phrases map[string][]byte
} }
const ( const (
playbackCheckIntervalMs = 250 playbackCheckIntervalMs = 100
playbackSeekOffsetBytes = 1024
) )
func addTTSFlags(ttsFlag *bool, ttsVoiceFlag *string) { // TODO: make most of this configurable via file
flag.BoolVar(ttsFlag, "notts", false, "Disable text-to-speech on mode change.") func addTTSFlags() *TTSOptions {
flag.StringVar(ttsVoiceFlag, "voice", "alan", "Which voice to use for TTS; must be 'alan' or 'jenny'") ops := &TTSOptions{}
flag.BoolVar(&ops.Disabled, "no-tts", false, "Disable text-to-speech.")
flag.StringVar(&ops.Voice, "tts-voice", "en", "Which voice to use for TTS; see 'espeak --voices' for a full list of options.")
flag.IntVar(&ops.Volume, "tts-volume", 100, "Text to speech volume")
flag.IntVar(&ops.Pitch, "tts-pitch", 50, "Text to speech volume")
flag.IntVar(&ops.Range, "tts-range", 50, "Text to speech volume")
flag.IntVar(&ops.Range, "tts-speed", 175, "Text to speech speaking speed (in words per minute)")
return ops
} }
func newTTS(disable bool, voice string) (*TTS, error) { func makeOtoContext() (*oto.Context, error) {
if disable {
return nil, nil
}
dataDir, err := os.MkdirTemp("", "joyful-piper.")
if err != nil {
return nil, err
}
var ass asset.Asset
switch voice {
case "jenny":
ass = jenny.Asset
case "alan":
ass = alan.Asset
default:
ass = alan.Asset
}
pTTS, err := piper.NewEmbedded(dataDir, ass)
if err != nil {
return nil, err
}
op := &oto.NewContextOptions{ op := &oto.NewContextOptions{
SampleRate: 22050, SampleRate: 22050,
ChannelCount: 1, ChannelCount: 1,
@ -72,35 +58,78 @@ func newTTS(disable bool, voice string) (*TTS, error) {
} }
<-readyChan // wait for initialization <-readyChan // wait for initialization
return otoCtx, nil
}
func newTTS(ops *TTSOptions) (*TTS, error) {
if ops.Disabled {
return nil, nil
}
context, err := makeOtoContext()
if err != nil {
return nil, err
}
return &TTS{ return &TTS{
TTS: *pTTS, options: ops,
dataDir: dataDir, otoCtx: context,
otoCtx: otoCtx, phrases: make(map[string][]byte),
}, nil }, nil
} }
// "Say" generates TTS audio and plays it in a go routine func (t *TTS) AddMessage(msg string) {
func (t *TTS) Say(msg string) { // TODO: need to get lots of input validation in here
go func() { // We execute `espeak-ng` directly because extant libraries produce terrible output
wav, err := t.Synthesize(msg) // compared to the command-line utility. This also gives us a chance to
cmd := exec.Command(
"espeak-ng", "--stdout",
"-v", t.options.Voice,
"-a", strconv.Itoa(t.options.Volume),
"-p", strconv.Itoa(t.options.Pitch),
"-P", strconv.Itoa(t.options.Range),
"-s", strconv.Itoa(t.options.Speed),
msg,
)
if err != nil { wavData, err := cmd.Output()
logger.LogError(err, "") if err != nil {
return logger.LogError(err, "Failed to create TTS data")
return
}
t.phrases[msg] = wavData
}
// "Say" generates TTS audio and plays it in a go routine
func (t *TTS) Say(msg string) error {
if _, ok := t.phrases[msg]; !ok {
return fmt.Errorf("tried to play non-buffered phrase '%s'", msg)
}
go func(buf []byte) {
buffer := bytes.NewBuffer(buf)
player := t.otoCtx.NewPlayer(buffer)
volume := 0.0
player.SetVolume(volume)
player.Play()
// Gradually ramp up the volume to avoid harsh clicks
for player.Volume() < 1.0 {
volume += 0.01
if volume > 1.0 {
volume = 1.0
}
player.SetVolume(volume)
time.Sleep(1 * time.Millisecond)
} }
wavReader := bytes.NewReader(wav)
player := t.otoCtx.NewPlayer(wavReader)
// We seek some bytes into the generated audio because there's a click
// and a long delay at the beginning of the data.
player.Seek(playbackSeekOffsetBytes, io.SeekStart)
player.Play()
for player.IsPlaying() { for player.IsPlaying() {
time.Sleep(playbackCheckIntervalMs * time.Millisecond) time.Sleep(playbackCheckIntervalMs * time.Millisecond)
} }
}() }(t.phrases[msg])
}
func (t *TTS) Cleanup() { return nil
os.RemoveAll(t.dataDir)
} }

View file

@ -1,16 +0,0 @@
//go:build notts
package main
type Speaker interface {
Say(string)
Cleanup()
}
func newTTS(_ bool, _ string) (Speaker, error) {
return nil, nil
}
func addTTSFlags(ttsFlag *bool, ttsVoiceFlag *string) {
return
}

8
go.mod
View file

@ -3,10 +3,6 @@ module git.annabunches.net/annabunches/joyful
go 1.24.4 go 1.24.4
require ( require (
github.com/amitybell/piper v0.0.0-20250621082041-2bb74e3a4a55
github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd
github.com/amitybell/piper-voice-alan v0.0.0-20231118093148-059963c24dbd
github.com/amitybell/piper-voice-jenny v0.0.0-20231118093224-dcf0d49e46b7
github.com/ebitengine/oto/v3 v3.3.3 github.com/ebitengine/oto/v3 v3.3.3
github.com/goccy/go-yaml v1.18.0 github.com/goccy/go-yaml v1.18.0
github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1
@ -17,12 +13,8 @@ require (
) )
require ( require (
github.com/adrg/xdg v0.5.3 // indirect
github.com/amitybell/piper-bin-linux v0.0.0-20250621082830-f5d5d85fa076 // indirect
github.com/amitybell/piper-bin-windows v0.0.0-20231118093113-cc2cef2f6b74 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect github.com/ebitengine/purego v0.8.4 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/sys v0.34.0 // indirect

18
go.sum
View file

@ -1,19 +1,3 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/amitybell/piper v0.0.0-20250621082041-2bb74e3a4a55 h1:8MKEDgDBbYKAphlRUcAEHT1Uam7xBjA5E/SmGHhNH10=
github.com/amitybell/piper v0.0.0-20250621082041-2bb74e3a4a55/go.mod h1:y0aDZdCM3erPmpX+rDGoF0O2ZdCqZvAxNjYUPrK/O7U=
github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd h1:4MLHn2cCVhzhPLlPO6946h1S0yk3o7Ry1831DEa5EcE=
github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd/go.mod h1:MiDKnt4NenfcrsVxYAxQW0nu4zjFYQPjGzzLB5MvOz8=
github.com/amitybell/piper-bin-linux v0.0.0-20250621082830-f5d5d85fa076 h1:aST7iEpuMr507piwgx0WNDezW6ycWIE+ejtnXXaMgI0=
github.com/amitybell/piper-bin-linux v0.0.0-20250621082830-f5d5d85fa076/go.mod h1:dVR33O0l/AFgQNmZfywfgNZ6qlpCKPhLnn9UpeMMLdM=
github.com/amitybell/piper-bin-windows v0.0.0-20231118093113-cc2cef2f6b74 h1:T5hXX0Z2JaE5gtZ7LScjG0r0BmDk0+FWlzyZ2b1nboo=
github.com/amitybell/piper-bin-windows v0.0.0-20231118093113-cc2cef2f6b74/go.mod h1:5Ea0Pc0QdO8FeriIXcqZtHViM2fi589jtFubrjaAk6w=
github.com/amitybell/piper-voice-alan v0.0.0-20231118093148-059963c24dbd h1:DsXuiWSHsbBkVNL7cBAdXD95kNwrE0Ck05OasSeUZ4g=
github.com/amitybell/piper-voice-alan v0.0.0-20231118093148-059963c24dbd/go.mod h1:5ghO6mSctWNXfDoh3r46HQEMIcPr5DqE5TMYfp5hskY=
github.com/amitybell/piper-voice-jenny v0.0.0-20231030195502-2afb5ebf3c45 h1:V/HZAQuprvdo0xXToxAuTLSwD3YrqRpDZLVBOOD+2aE=
github.com/amitybell/piper-voice-jenny v0.0.0-20231030195502-2afb5ebf3c45/go.mod h1:eKG2Bo69QGTVKKKKApafZr+4v4zk40jYNijh0s8/PzU=
github.com/amitybell/piper-voice-jenny v0.0.0-20231118093224-dcf0d49e46b7 h1:GMYJcgP1OKBMBuQfP7r0aRk4PS0AaviHVTERtdt/e/o=
github.com/amitybell/piper-voice-jenny v0.0.0-20231118093224-dcf0d49e46b7/go.mod h1:eKG2Bo69QGTVKKKKApafZr+4v4zk40jYNijh0s8/PzU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/oto/v3 v3.3.3 h1:m6RV69OqoXYSWCDsHXN9rc07aDuDstGHtait7HXSM7g= github.com/ebitengine/oto/v3 v3.3.3 h1:m6RV69OqoXYSWCDsHXN9rc07aDuDstGHtait7HXSM7g=
@ -26,8 +10,6 @@ github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Ta
github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=

View file

@ -5,6 +5,8 @@ import (
"os" "os"
) )
var IsDebugMode = false
func Log(msg string) { func Log(msg string) {
fmt.Println(msg) fmt.Println(msg)
} }
@ -13,6 +15,12 @@ func Logf(msg string, params ...interface{}) {
fmt.Printf(msg+"\n", params...) fmt.Printf(msg+"\n", params...)
} }
func LogDebugf(msg string, params ...interface{}) {
if IsDebugMode {
fmt.Printf("DEBUG: %s\n", fmt.Sprintf(msg, params...))
}
}
func LogError(err error, msg string) { func LogError(err error, msg string) {
if msg == "" { if msg == "" {
fmt.Printf("%s\n", err.Error()) fmt.Printf("%s\n", err.Error())

View file

@ -20,6 +20,7 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe
* Axis -> Relative Axis mapping, for converting a joystick axis to mouse movement and scrollwheel events. * Axis -> Relative Axis mapping, for converting a joystick axis to mouse movement and scrollwheel events.
* Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. * Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones.
* Define multiple modes with per-mode behavior. * Define multiple modes with per-mode behavior.
* Text-to-speech engine that announces the current mode when it changes.
### Possible Future Features ### Possible Future Features
@ -47,23 +48,18 @@ Pressing `<enter>` in the running terminal window will reload the `rules` sectio
## Build & Install ## Build & Install
To build joyful, first use your distribution's package manager to install `go` and `alsa-lib` (this may be `libasound2-dev` or `libasound2-devel` depending on your distribution), then run: To build joyful, first use your distribution's package manager to install the following packages:
* `go`
* `alsa-lib` - this may be `libasound2-dev` or `libasound2-devel` depending on your distribution
* `espeak-ng` - if you want text-to-speech to announce mode changes
Then, run:
``` ```
go build -o build/ ./... go build -o build/ ./...
``` ```
Next, copy the binaries in the `build/` directory to somewhere in your `$PATH`. (details depend on your setup, but typically somewhere like `/usr/local/bin` or `~/bin`) Finally, copy the files in the `build/` directory to somewhere in your `$PATH`. (details depend on your setup, but typically somewhere like `/usr/local/bin` or `~/bin`)
### Machine Learning Disclosure
Joyful's text-to-speech support is dependent on [Piper](https://github.com/rhasspy/piper), which uses an offline Machine Learning (ML) model for speech synthesis. The project authors are extremely skeptical of ML/AI technologies in general, but consider speech synthesis, especially offline/local speech synthesis, to be one of the most defensible use cases for it. Since it is very difficult to find text-to-speech systems that don't use ML under the hood (especially that have extant golang wrappers or bindings), this is considered a necessary tradeoff.
However, if you don't want any ML running on your system, you can optionally choose to skip TTS support at compile-time by building with this command:
```
go build -o build -tags notts ./...
```
## Technical details ## Technical details