From f7936610c97550fc610434ea0768f6cbbe703358 Mon Sep 17 00:00:00 2001 From: LeonardoTrapani Date: Thu, 7 Aug 2025 00:01:49 +0200 Subject: [PATCH] setup project with daemon, socket, cmd, air, notifying, state --- .air.toml | 46 +++++++++++++ .gitignore | 33 ++++++++++ README.md | 78 +++++++++++++++------- cmd/hyprvoice/main.go | 98 ++++++++++++++++++++++++++++ go.mod | 9 +++ go.sum | 10 +++ internal/bus/bus.go | 57 ++++++++++++++++ internal/bus/bus_test.go | 9 +++ internal/hotkeydaemon/daemon.go | 80 +++++++++++++++++++++++ internal/hotkeydaemon/deamon_test.go | 29 ++++++++ internal/notify/notify.go | 33 ++++++++++ 11 files changed, 457 insertions(+), 25 deletions(-) create mode 100644 .air.toml create mode 100644 .gitignore create mode 100644 cmd/hyprvoice/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bus/bus.go create mode 100644 internal/bus/bus_test.go create mode 100644 internal/hotkeydaemon/daemon.go create mode 100644 internal/hotkeydaemon/deamon_test.go create mode 100644 internal/notify/notify.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..3fe1e4f --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["serve"] + bin = "./tmp/hyprvoice" + cmd = "go install ./cmd/hyprvoice && go build -o ./tmp/hyprvoice ./cmd/hyprvoice" + delay = 100 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "mod", "sum"] + include_file = [] + kill_delay = "1s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = ["./tmp/hyprvoice stop 2>/dev/null || true"] + rerun = true + rerun_delay = 200 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f43e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ + +# Output binaries +hyprvoice +tmp/* diff --git a/README.md b/README.md index 106ba6f..b2e92d1 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,28 @@ # Hyprvoice -> **Voice‑powered typing for Wayland/Hyprland desktops** — press a global shortcut, speak, and watch words appear in whatever window you’re focused on. +> **Voice‑powered typing for Wayland/Hyprland desktops** — press a global shortcut, speak, and watch words appear in whatever window you're focused on. + +**🚧 Early Development Status:** This project is currently in early development. Not ready for production use yet. --- ## Why does this hyprvoice exist? -Typing is slow, repetitive strain injuries are real, and proprietary speech‑to‑text solutions leak your data. This project aims to give Wayland users a fast, privacy‑respecting alternative that works entirely on their own hardware _or_ any cloud ASR they trust. +Typing is slow, repetitive strain injuries are real. This project aims to give Wayland users a fast, privacy‑respecting alternative that works entirely on their own hardware _or_ any cloud ASR they trust. --- -## Key Features +## Current Implementation Status -| Category | Highlights | -| ------------------------- | ------------------------------------------------------------------------------------------------------------ | -| **Hot‑key daemon** | Background service triggered by a global shortcut (via `hyprctl dispatch exec …` or other compositor hooks). | -| **Live audio capture** | Low‑latency PipeWire input with optional VAD/noise gate. | -| **Pluggable ASR** | 🔌 Local Whisper (via `whisper.cpp`) or cloud APIs (OpenAI, Deepgram, AssemblyAI). Select at runtime. | -| **Optional LLM polish** | Feed raw transcript into a local Ollama model or GPT‑4o to add punctuation, change tone, etc. | -| **Text injection** | `wtype`/`ydotool` sends keystrokes to the focused surface; clipboard fallback for stubborn clients. | -| **Zero‑friction config** | Single `~/.config/hyprvoice/config.toml` with sensible defaults and live reload on SIGHUP. | -| **Cross‑platform binary** | Static Go build; no Python environment required. | +| Component | Status | Description | +| ------------------------- | ------- | ------------------------------------------------------------ | +| **Hot‑key daemon** | ✅ Done | Background service with Unix socket IPC for command handling | +| **Desktop notifications** | ✅ Done | Recording state changes via `notify-send` | +| **Service management** | ✅ Done | Install/remove systemd user service with embedded unit file | +| **Live audio capture** | 🔄 TODO | PipeWire input with VAD/noise gate | +| **ASR backends** | 🔄 TODO | Local Whisper or cloud APIs (OpenAI, Deepgram, AssemblyAI) | +| **Text injection** | 🔄 TODO | `wtype`/`ydotool` keystrokes to focused window | +| **Configuration** | 🔄 TODO | TOML config file with runtime reload | --- @@ -39,13 +41,15 @@ hyprvoice-install run --backend whispercpp systemctl --user enable --now hyprvoice.service # 4. Add a key binding in Hyprland conf -bind = $mod, R, exec, hyprvoice toggle +bind = SUPER, R, exec, hyprvoice toggle ``` --- ## Configuration file (`~/.config/hyprvoice/config.toml`) +**Note:** Configuration file support is not yet implemented. This shows the planned format: + ```toml [asr] backend = "whispercpp" # whispercpp | openai @@ -68,22 +72,46 @@ All options are documented in the sample config generated by `--init`. --- -## Architecture Overview +## Architecture ``` -┌──────────┐ PCM ┌──────────┐ chunks ┌──────────┐ text ┌──────────┐ -│ Hot‑key │────────▶│ Audio │────────▶│ ASR │──────▶│ Inject │ -│ Listener │ │ Capture │ │ Adapter │ │ (wtype) │ -└──────────┘ └──────────┘ └──────────┘ └──────────┘ - ▲ │ │ - │ stats / errors │ ▼ - └──────────────┬────────┘ ┌──────────┐ - │ │ Desktop │ - └──────────────────▶ UI │ - └──────────┘ +┌──────────────┐ Unix Socket ┌──────────────┐ PCM ┌──────────┐ +│ CLI Client │◄────────────────│ Hot‑key │────────▶│ Audio │ +│ (toggle/stop)│ │ Daemon │ │ Capture │ +└──────────────┘ │ (hotkeydaemon│ │ (pipewire│ + │ + bus) │ │ + VAD) │ + └──────────────┘ └──────────┘ + │ │ + │ notify-send │ chunks + ▼ ▼ + ┌──────────────┐ ┌──────────┐ + │ Desktop │ │ ASR │ + │ Notification │ │ Adapter │ + └──────────────┘ │(whisper/ │ + │ openai) │ + └──────────┘ + │ + │ text + ▼ + ┌──────────┐ + │ Inject │ + │ (wtype/ │ + │ ydotool) │ + └──────────┘ ``` -Each box is a Go package inside `internal/` so you can swap implementations without touching public APIs. +**Planned Components:** + +- `cmd/hyprvoice`: CLI with Cobra commands (existing: `serve`, `toggle`, `stop`) +- `internal/bus`: Unix socket IPC for daemon communication (✅ implemented) +- `internal/hotkeydaemon`: Background service managing recording state (✅ implemented) +- `internal/notify`: Desktop notifications via `notify-send` (✅ implemented) +- `internal/audiocapture`: PipeWire input with VAD/noise gate (🔄 TODO) +- `internal/asr`: ASR backends (Whisper, OpenAI, etc.) (🔄 TODO) +- `internal/inject`: Text injection via wtype/ydotool (🔄 TODO) +- `internal/config`: TOML configuration with runtime reload (🔄 TODO) + +Each box will be a Go package inside `internal/` so you can swap implementations without touching public APIs. --- diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go new file mode 100644 index 0000000..f315037 --- /dev/null +++ b/cmd/hyprvoice/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + + "github.com/leonardotrapani/hyprvoice/internal/bus" + "github.com/leonardotrapani/hyprvoice/internal/hotkeydaemon" + "github.com/spf13/cobra" +) + +func main() { + _ = rootCmd.Execute() +} + +var rootCmd = &cobra.Command{ + Use: "hyprvoice", + Short: "Voice-powered typing for Wayland/Hyprland", +} + +func init() { + rootCmd.AddCommand( + serveCmd(), + toggleCmd(), + statusCmd(), + versionCmd(), + stopCmd(), + ) +} + +func serveCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Run the hotkey daemon", + RunE: func(cmd *cobra.Command, args []string) error { + return (hotkeydaemon.New(nil)).Run() + }, + } +} + +func toggleCmd() *cobra.Command { + return &cobra.Command{ + Use: "toggle", + Short: "Toggle recording on/off", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := bus.SendCommand('t') + if err != nil { + return fmt.Errorf("failed to toggle: %w", err) + } + fmt.Print(resp) // print STATUS line + return nil + }, + } +} + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Get current recording status", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := bus.SendCommand('s') + if err != nil { + return fmt.Errorf("failed to get status: %w", err) + } + fmt.Print(resp) // print STATUS line + return nil + }, + } +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Get protocol version", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := bus.SendCommand('v') + if err != nil { + return fmt.Errorf("failed to get version: %w", err) + } + fmt.Print(resp) // print STATUS proto= line + return nil + }, + } +} + +func stopCmd() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the hotkey daemon", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := bus.SendCommand('q') + if err != nil { + return fmt.Errorf("failed to stop daemon: %w", err) + } + fmt.Print(resp) // print OK quitting + return nil + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..727fbf1 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/leonardotrapani/hyprvoice + +go 1.24.5 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffae55e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bus/bus.go b/internal/bus/bus.go new file mode 100644 index 0000000..d5d500b --- /dev/null +++ b/internal/bus/bus.go @@ -0,0 +1,57 @@ +package bus + +import ( + "bufio" + "net" + "os" + "path/filepath" +) + +const SockName = "control.sock" +const ProtoVer = "0.1" + +// ~/.cache/hyprvoice/control.sock +func SockPath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + hd := filepath.Join(dir, "hyprvoice") + return filepath.Join(hd, SockName), nil +} + +func Listen() (net.Listener, error) { + sp, err := SockPath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(sp), 0o700); err != nil { + return nil, err + } + _ = os.Remove(sp) // stale socket from last run + return net.Listen("unix", sp) +} + +func Dial() (net.Conn, error) { + sp, err := SockPath() + if err != nil { + return nil, err + } + return net.Dial("unix", sp) +} + +func SendCommand(cmd byte) (string, error) { + c, err := Dial() + if err != nil { + return "", err + } + defer c.Close() + + _, err = c.Write([]byte{cmd, '\n'}) + if err != nil { + return "", err + } + + resp, err := bufio.NewReader(c).ReadString('\n') + return resp, err +} diff --git a/internal/bus/bus_test.go b/internal/bus/bus_test.go new file mode 100644 index 0000000..e1a465a --- /dev/null +++ b/internal/bus/bus_test.go @@ -0,0 +1,9 @@ +package bus + +import "testing" + +func TestSockPath(t *testing.T) { + if _, err := SockPath(); err != nil { + t.Fatalf("SockPath: %v", err) + } +} diff --git a/internal/hotkeydaemon/daemon.go b/internal/hotkeydaemon/daemon.go new file mode 100644 index 0000000..9035ba9 --- /dev/null +++ b/internal/hotkeydaemon/daemon.go @@ -0,0 +1,80 @@ +package hotkeydaemon + +import ( + "bufio" + "fmt" + "net" + "sync" + "time" + + "github.com/leonardotrapani/hyprvoice/internal/bus" + "github.com/leonardotrapani/hyprvoice/internal/notify" +) + +type Daemon struct { + mu sync.Mutex + recording bool + notifier notify.Notifier +} + +func New(n notify.Notifier) *Daemon { + if n == nil { + n = notify.Desktop{} + } + return &Daemon{ + notifier: n, + } +} + +func (d *Daemon) Rec() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.recording +} + +func (d *Daemon) Run() error { + ln, err := bus.Listen() + if err != nil { + return err + } + for { + c, err := ln.Accept() + if err != nil { + continue + } + go d.handle(c) + } +} + +func (d *Daemon) handle(c net.Conn) { + defer c.Close() + + line, _ := bufio.NewReader(c).ReadString('\n') + if len(line) == 0 { + fmt.Fprint(c, "ERR empty\n") + return + } + cmd := line[0] + + d.mu.Lock() + defer d.mu.Unlock() + + switch cmd { + case 't': // toggle + d.recording = !d.recording + d.notifier.RecordingChanged(d.recording) + fmt.Fprintf(c, "STATUS recording=%t\n", d.recording) + case 's': // status + fmt.Fprintf(c, "STATUS recording=%t\n", d.recording) + case 'v': // protocol version + fmt.Fprintf(c, "STATUS proto=%s\n", bus.ProtoVer) + case 'q': // quit daemon + fmt.Fprint(c, "OK quitting\n") + go func() { + time.Sleep(100 * time.Millisecond) // give time for client to read + panic("daemon exit requested") + }() + default: + fmt.Fprintf(c, "ERR unknown=%q\n", cmd) + } +} diff --git a/internal/hotkeydaemon/deamon_test.go b/internal/hotkeydaemon/deamon_test.go new file mode 100644 index 0000000..dd4ca17 --- /dev/null +++ b/internal/hotkeydaemon/deamon_test.go @@ -0,0 +1,29 @@ +package hotkeydaemon + +import ( + "testing" + "time" + + "github.com/leonardotrapani/hyprvoice/internal/bus" + "github.com/leonardotrapani/hyprvoice/internal/notify" +) + +func TestToggle(t *testing.T) { + d := New(notify.Nop{}) + go d.Run() + time.Sleep(50 * time.Millisecond) // give listener time to start + defer bus.SendCommand('q') + + if out, _ := bus.SendCommand('t'); out != "STATUS recording=true\n" { + t.Fatalf("unexpected: %s", out) + } + if !d.Rec() { + t.Fatalf("state should be true after first toggle") + } + if out, _ := bus.SendCommand('t'); out != "STATUS recording=false\n" { + t.Fatalf("unexpected: %s", out) + } + if d.Rec() { + t.Fatalf("state should be false after second toggle") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..31a0e72 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,33 @@ +package notify + +import ( + "fmt" + "os/exec" +) + +type Notifier interface { + RecordingChanged(on bool) + Error(msg string) +} + +type Desktop struct{} + +func (Desktop) RecordingChanged(on bool) { + state := "Stopped" + if on { + state = "Started" + } + exec.Command("notify-send", "-a", "Hyprvoice", + fmt.Sprintf("Hyprvoice: %s Recording", state)).Run() +} + +func (Desktop) Error(msg string) { + exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", msg).Run() +} + +// Nop is a Notifier that does absolutely nothing. +// Useful in unit tests or headless builds. +type Nop struct{} + +func (Nop) RecordingChanged(on bool) {} +func (Nop) Error(msg string) {}