diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 43ac506..3cdcc6d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,11 +22,6 @@ "kind": "build", "isDefault": true }, - "options": { - "env": { - "CGO_ENABLED": "0" - } - }, "problemMatcher": [] }, { @@ -39,11 +34,6 @@ "kind": "test", "isDefault": true }, - "options": { - "env": { - "CGO_ENABLED": "0" - } - }, "problemMatcher": [] } ], diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 6bf0077..17482bf 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -2,23 +2,22 @@ package main import ( "context" - "flag" "fmt" "os" "strings" "sync" + "github.com/holoplot/go-evdev" + flag "github.com/spf13/pflag" + "git.annabunches.net/annabunches/joyful/internal/config" "git.annabunches.net/annabunches/joyful/internal/logger" "git.annabunches.net/annabunches/joyful/internal/mappingrules" "git.annabunches.net/annabunches/joyful/internal/virtualdevice" - "github.com/holoplot/go-evdev" ) -func getConfigDir() string { - configFlag := flag.String("config", "~/.config/joyful", "Directory to read configuration from.") - flag.Parse() - configDir := strings.ReplaceAll(*configFlag, "~", "${HOME}") +func getConfigDir(dir string) string { + configDir := strings.ReplaceAll(dir, "~", "${HOME}") return os.ExpandEnv(configDir) } @@ -62,27 +61,46 @@ func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDev } 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 - configDir := getConfigDir() + configDir := getConfigDir(configFlag) config := readConfig(configDir) + // initialize TTS + tts, err := newTTS(ttsOps) + logger.LogIfError(err, "Failed to initialize TTS") + // Initialize virtual devices with event buffers vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) // Initialize physical devices pDevices := initPhysicalDevices(config) + // Load the rules rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) // initialize the mode variable 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.") if len(config.GetModes()) > 1 { logger.Logf("Initial mode set to '%s'", mode) } for { + lastMode := mode // Get an event (blocks if necessary) channelEvent := <-eventChannel @@ -124,6 +142,10 @@ func main() { rules, eventChannel, cancel, wg = loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) fmt.Println("Config re-loaded. Only rule changes applied. Device and Mode changes require restart.") } + + if lastMode != mode && tts != nil { + tts.Say(mode) + } } } diff --git a/cmd/joyful/tts.go b/cmd/joyful/tts.go new file mode 100644 index 0000000..99b709d --- /dev/null +++ b/cmd/joyful/tts.go @@ -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 +} diff --git a/docs/readme.md b/docs/readme.md index c6ffe75..84a8c74 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -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-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 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 diff --git a/go.mod b/go.mod index 26d806f..5007672 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,20 @@ module git.annabunches.net/annabunches/joyful go 1.24.4 require ( + github.com/ebitengine/oto/v3 v3.3.3 github.com/goccy/go-yaml v1.18.0 github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 github.com/jonboulle/clockwork v0.5.0 github.com/spf13/pflag v1.0.7 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 ( 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/stretchr/objx v0.5.2 // indirect + golang.org/x/sys v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index baba603..70e03cc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ 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/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/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 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-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 35fc11e..be04b74 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -5,6 +5,8 @@ import ( "os" ) +var IsDebugMode = false + func Log(msg string) { fmt.Println(msg) } @@ -13,6 +15,12 @@ func Logf(msg string, params ...interface{}) { 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) { if msg == "" { fmt.Printf("%s\n", err.Error()) diff --git a/readme.md b/readme.md index f77f0db..5c94306 100644 --- a/readme.md +++ b/readme.md @@ -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. * Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. * Define multiple modes with per-mode behavior. + * Text-to-speech engine that announces the current mode when it changes. ### 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 * HIDRAW support for more button options. * Sensitivity Curves. +* Packaged builds for Arch and possibly other distributions. ## Configuration @@ -44,19 +46,24 @@ After building (see below) and writing your configuration (see above), just run Pressing `` 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 -Joyful is written in golang, and uses evdev/uinput to manage devices. See `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`) +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. ### Contributing