Add text-to-speech support. (#13)

Reviewed-on: #13
Co-authored-by: Anna Rose Wiggins <annabunches@gmail.com>
Co-committed-by: Anna Rose Wiggins <annabunches@gmail.com>
This commit is contained in:
Anna Rose Wiggins 2025-07-29 19:59:54 +00:00 committed by Anna Rose Wiggins
parent 3e4367f5e7
commit 9d262977f9
8 changed files with 203 additions and 32 deletions

10
.vscode/tasks.json vendored
View file

@ -22,11 +22,6 @@
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
}, },
"options": {
"env": {
"CGO_ENABLED": "0"
}
},
"problemMatcher": [] "problemMatcher": []
}, },
{ {
@ -39,11 +34,6 @@
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
}, },
"options": {
"env": {
"CGO_ENABLED": "0"
}
},
"problemMatcher": [] "problemMatcher": []
} }
], ],

View file

@ -2,23 +2,22 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"sync" "sync"
"github.com/holoplot/go-evdev"
flag "github.com/spf13/pflag"
"git.annabunches.net/annabunches/joyful/internal/config" "git.annabunches.net/annabunches/joyful/internal/config"
"git.annabunches.net/annabunches/joyful/internal/logger" "git.annabunches.net/annabunches/joyful/internal/logger"
"git.annabunches.net/annabunches/joyful/internal/mappingrules" "git.annabunches.net/annabunches/joyful/internal/mappingrules"
"git.annabunches.net/annabunches/joyful/internal/virtualdevice" "git.annabunches.net/annabunches/joyful/internal/virtualdevice"
"github.com/holoplot/go-evdev"
) )
func getConfigDir() string { func getConfigDir(dir string) string {
configFlag := flag.String("config", "~/.config/joyful", "Directory to read configuration from.") configDir := strings.ReplaceAll(dir, "~", "${HOME}")
flag.Parse()
configDir := strings.ReplaceAll(*configFlag, "~", "${HOME}")
return os.ExpandEnv(configDir) return os.ExpandEnv(configDir)
} }
@ -62,27 +61,46 @@ func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDev
} }
func main() { func main() {
// parse command-line
var configFlag string
flag.BoolVarP(&logger.IsDebugMode, "debug", "d", false, "Output very verbose debug messages.")
flag.StringVarP(&configFlag, "config", "c", "~/.config/joyful", "Directory to read configuration from.")
ttsOps := addTTSFlags()
flag.Parse()
// parse configs // parse configs
configDir := getConfigDir() configDir := getConfigDir(configFlag)
config := readConfig(configDir) config := readConfig(configDir)
// initialize TTS
tts, err := newTTS(ttsOps)
logger.LogIfError(err, "Failed to initialize TTS")
// Initialize virtual devices with event buffers // Initialize virtual devices with event buffers
vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) vBuffersByName, vBuffersByDevice := initVirtualBuffers(config)
// Initialize physical devices // Initialize physical devices
pDevices := initPhysicalDevices(config) pDevices := initPhysicalDevices(config)
// Load the rules
rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName))
// 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)
} }
for { for {
lastMode := mode
// Get an event (blocks if necessary) // Get an event (blocks if necessary)
channelEvent := <-eventChannel channelEvent := <-eventChannel
@ -124,6 +142,10 @@ func main() {
rules, eventChannel, cancel, wg = loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) rules, eventChannel, cancel, wg = loadRules(config, pDevices, getVirtualDevices(vBuffersByName))
fmt.Println("Config re-loaded. Only rule changes applied. Device and Mode changes require restart.") fmt.Println("Config re-loaded. Only rule changes applied. Device and Mode changes require restart.")
} }
if lastMode != mode && tts != nil {
tts.Say(mode)
}
} }
} }

135
cmd/joyful/tts.go Normal file
View file

@ -0,0 +1,135 @@
package main
import (
"bytes"
"fmt"
"os/exec"
"strconv"
"time"
"git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/ebitengine/oto/v3"
flag "github.com/spf13/pflag"
)
type TTSOptions struct {
Disabled bool
Voice string
Volume int
Pitch int
Range int
Speed int
}
type TTS struct {
options *TTSOptions
otoCtx *oto.Context
phrases map[string][]byte
}
const (
playbackCheckIntervalMs = 100
)
// TODO: make most of this configurable via file
func addTTSFlags() *TTSOptions {
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 makeOtoContext() (*oto.Context, error) {
op := &oto.NewContextOptions{
SampleRate: 22050,
ChannelCount: 1,
Format: oto.FormatSignedInt16LE,
}
otoCtx, readyChan, err := oto.NewContext(op)
if err != nil {
return nil, err
}
<-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{
options: ops,
otoCtx: context,
phrases: make(map[string][]byte),
}, nil
}
func (t *TTS) AddMessage(msg string) {
// TODO: need to get lots of input validation in here
// We execute `espeak-ng` directly because extant libraries produce terrible output
// 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,
)
wavData, err := cmd.Output()
if err != nil {
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)
}
for player.IsPlaying() {
time.Sleep(playbackCheckIntervalMs * time.Millisecond)
}
}(t.phrases[msg])
return nil
}

View file

@ -36,7 +36,7 @@ All `rules` must have a `type` parameter. Valid values for this parameter are:
* `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value * `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value
* `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events. * `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events.
Configuration options for each rule type vary. See <examples/ruletypes.yml> for an example of each type with all options specified. Configuration options for each rule type vary. See [examples/ruletypes.yml](examples/ruletypes.yml) for an example of each type with all options specified.
### Event Codes ### Event Codes

5
go.mod
View file

@ -3,17 +3,20 @@ module git.annabunches.net/annabunches/joyful
go 1.24.4 go 1.24.4
require ( require (
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
github.com/jonboulle/clockwork v0.5.0 github.com/jonboulle/clockwork v0.5.0
github.com/spf13/pflag v1.0.7 github.com/spf13/pflag v1.0.7
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
) )
require ( require (
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/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
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

10
go.sum
View file

@ -1,5 +1,9 @@
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/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Tatx+uUGOhah73jyNUrt7DmfDRXXJ5Xo= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Tatx+uUGOhah73jyNUrt7DmfDRXXJ5Xo=
@ -14,8 +18,10 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

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
@ -29,6 +30,7 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe
* Hat support * Hat support
* HIDRAW support for more button options. * HIDRAW support for more button options.
* Sensitivity Curves. * Sensitivity Curves.
* Packaged builds for Arch and possibly other distributions.
## Configuration ## Configuration
@ -44,19 +46,24 @@ After building (see below) and writing your configuration (see above), just run
Pressing `<enter>` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program. Pressing `<enter>` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program.
## Build & Install
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/ ./...
```
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`)
## Technical details ## Technical details
Joyful is written in golang, and uses evdev/uinput to manage devices. See `cmd/joyful/main.go` for the program's entry point. Joyful is written in golang, and uses `evdev`/`uinput` to manage devices, `piper` and `oto` for TTS. See [cmd/joyful/main.go](cmd/joyful/main.go) for the program's entry point.
### Build & Install
To build joyful, install `go` via your package manager, then run:
```
CGO_ENABLED=0 go build -o build/ ./...
```
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`)
### Contributing ### Contributing