diff --git a/README.md b/README.md index b22399a..3b14916 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,20 @@ reasonably shouldn't, let me know (or fix it and then let me know!) - [x] joining a channel - [x] handling everyone's state -- [ ] any of the actual audio stuff -- [ ] noise suppression with rnnoise +- [x] any of the actual audio stuff +- [x] noise suppression with rnnoise +- [ ] chat with more than a singular other person, probably ## building hubbub requires go 1.23 to build. it might run on earlier versions, but i think `slices` mainlined in 1.23 and i can't be bothered to check. run `go build` and copy the binary wherever. +you also need to have the following headers: +- rnnoise +- portaudio +- opus + ## usage hubbub needs a TOML config file, either passed using `--config` or at `.config/hubbub/config.toml`: @@ -40,8 +46,9 @@ note that the nick is different than the username: if you're already logged onto irc you won't be able to use that nick. you can also pass a different server or port but you really don't have a reason to. -i'm writing this part about audio way in advance. i'll probably use alsa? either -alsa or pipewire if i can be bothered with pipewire. +for audio, this uses portaudio, so as long as you have it installed and you have +either pipewire or alsa or jack or pulseaudio, it'll work. JACK might spit out +some ugly stuff to stderr on launch, i'm working on fixing that. here's the controls: ``` diff --git a/audio/audio.go b/audio/audio.go new file mode 100644 index 0000000..70a7cb2 --- /dev/null +++ b/audio/audio.go @@ -0,0 +1,140 @@ +package audio + +import ( + "encoding/base64" + "log" + "math" + "unsafe" + + "github.com/ergochat/irc-go/ircevent" + "github.com/gordonklaus/portaudio" + "gopkg.in/hraban/opus.v2" +) + +/* +#cgo pkg-config: rnnoise +#include + +DenoiseState *rn_setup() { + return rnnoise_create(NULL); +} + +void rn_cleanup(DenoiseState *st) { + rnnoise_destroy(st); +} +*/ +import "C" + +const sampleRate = 24000 +const channels = 1 + +type Audio struct { + Muted bool + Deafened bool + OutBuffer chan string +} + +func NewAudio() Audio { + return Audio{ + Muted: false, + Deafened: false, + OutBuffer: make(chan string), + } +} + +func (au *Audio) ProcessInput(conn *ircevent.Connection, channel string) error { + st := C.rn_setup() + defer C.rn_cleanup(st) + + enc, err := opus.NewEncoder(sampleRate, channels, opus.AppVoIP) + if err != nil { + log.Printf("error creating opus encoder: %v", err) + return err + } + + dec, err := opus.NewDecoder(sampleRate, channels) + if err != nil { + log.Printf("error creating opus decoder: %v", err) + return err + } + + portaudio.Initialize() + defer portaudio.Terminate() + inChan := make(chan []float32, 8) + stream, err := portaudio.OpenDefaultStream(channels, channels, sampleRate, 480, func(in, out []float32) { + if !au.Muted { + frame := make([]float32, len(in)) + copy(frame, in) + + select { + case inChan <- frame: + default: + } + } + + empty := make([]float32, len(out)) + if !au.Deafened { + select { + case str := <-au.OutBuffer: + raw, err := base64.StdEncoding.DecodeString(str) + if err != nil { + break + } + if _, err = dec.DecodeFloat32(raw, out); err != nil { + break + } + default: + copy(out, empty) + } + } + }) + if err != nil { + log.Printf("error opening input stream: %v", err) + return err + } + + defer stream.Close() + if err = stream.Start(); err != nil { + log.Printf("error starting input stream: %v", err) + return err + } + defer stream.Stop() + + data := make([]byte, 480) + debounce := 0 + for frame := range inChan { + C.rnnoise_process_frame( + st, + (*C.float)(unsafe.Pointer(&frame[0])), + (*C.float)(unsafe.Pointer(&frame[0])), + ) + + if rms(frame) < 0.02 { + debounce++ + if debounce >= 10 { + continue + } + } else { + debounce = 0 + } + + n, err := enc.EncodeFloat32(frame, data) + if err != nil { + log.Printf("error encoding opus data: %v", err) + return err + } + + str := base64.StdEncoding.EncodeToString(data[:n]) + conn.Privmsg(channel, str) + } + + return nil +} + +func rms(frame []float32) float64 { + var sum float32 + for _, s := range frame { + sum += s * s + } + return math.Sqrt(float64(sum / float32(len(frame)))) +} diff --git a/go.mod b/go.mod index 080f398..3e3144d 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect ) require ( @@ -33,6 +34,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/ergochat/irc-go v0.4.0 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 118b359..9841bae 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b h1:WEuQWBxelOGHA6z9lABqaMLMrfwVyMdN3UgRLT+YUPo= +github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -80,3 +82,5 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= diff --git a/main.go b/main.go index 6457e38..68d397a 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.tilde.town/nbsp/hubbub/audio" "git.tilde.town/nbsp/hubbub/config" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -58,6 +59,7 @@ type model struct { mute bool deafen bool conn *Conn + au *audio.Audio } type Conn struct { @@ -80,6 +82,8 @@ func (m *model) setMute(is bool) { un = "UN" } m.conn.Privmsg(m.channel, un+"MUTE") + + m.au.Muted = m.mute || m.deafen } func (m *model) setDeafen(is bool) { m.deafen = is @@ -91,6 +95,9 @@ func (m *model) setDeafen(is bool) { un = "UN" } m.conn.Privmsg(m.channel, un+"DEAFEN") + + m.au.Muted = m.mute || m.deafen + m.au.Deafened = m.deafen } func (m model) Init() tea.Cmd { @@ -117,6 +124,7 @@ func (m model) Init() tea.Cmd { u.isMuted = false default: u.lastSpoke = time.Now() + m.au.OutBuffer <- message } m.users[e.Nick()] = u @@ -221,19 +229,19 @@ func (m model) View() (s string) { func start(c *cli.Context) error { config, err := config.LoadConfigFile(c.String("config")) if err != nil { - log.Fatalf("unable to read config file: %v", err) + log.Printf("unable to read config file: %v", err) return err } key, err := os.ReadFile(os.ExpandEnv(strings.Replace(config.Privkey, "~", "$HOME", 1))) if err != nil { - log.Fatalf("unable to read private key: %v", err) + log.Printf("unable to read private key: %v", err) return err } signer, err := ssh.ParsePrivateKey(key) if err != nil { - log.Fatalf("unable to parse private key: %v", err) + log.Printf("unable to parse private key: %v", err) return err } @@ -245,7 +253,7 @@ func start(c *cli.Context) error { HostKeyCallback: ssh.InsecureIgnoreHostKey(), // XXX }) if err != nil { - log.Fatalf("unable to connect to ssh: %v", err) + log.Printf("unable to connect to ssh: %v", err) return err } defer client.Close() @@ -260,14 +268,24 @@ func start(c *cli.Context) error { }, }, } + if err := conn.Connect(); err != nil { - log.Fatalf("unable to connect to irc: %v", err) + log.Printf("unable to connect to irc: %v", err) return err } + + au := audio.NewAudio() + go func() { + if err := au.ProcessInput(&conn.Connection, config.Channel); err != nil { + os.Exit(1) + } + }() + p := tea.NewProgram(model{ channel: config.Channel, nick: config.Nick, conn: conn, + au: &au, users: map[string]user{}, notifications: []notification{}, })