This commit is contained in:
aoife cassidy 2025-11-01 08:26:04 +01:00
parent d24aed44f5
commit 6592b13c6f
No known key found for this signature in database
GPG Key ID: 7184AC1C9835CE48
5 changed files with 180 additions and 9 deletions

View File

@ -17,14 +17,20 @@ reasonably shouldn't, let me know (or fix it and then let me know!)
- [x] joining a channel - [x] joining a channel
- [x] handling everyone's state - [x] handling everyone's state
- [ ] any of the actual audio stuff - [x] any of the actual audio stuff
- [ ] noise suppression with rnnoise - [x] noise suppression with rnnoise
- [ ] chat with more than a singular other person, probably
## building ## building
hubbub requires go 1.23 to build. it might run on earlier versions, but i think 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 `slices` mainlined in 1.23 and i can't be bothered to check. run `go build` and
copy the binary wherever. copy the binary wherever.
you also need to have the following headers:
- rnnoise
- portaudio
- opus
## usage ## usage
hubbub needs a TOML config file, either passed using `--config` or at hubbub needs a TOML config file, either passed using `--config` or at
`.config/hubbub/config.toml`: `.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 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. 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 for audio, this uses portaudio, so as long as you have it installed and you have
alsa or pipewire if i can be bothered with pipewire. 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: here's the controls:
``` ```

140
audio/audio.go Normal file
View File

@ -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 <rnnoise.h>
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))))
}

2
go.mod
View File

@ -23,6 +23,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect
) )
require ( require (
@ -33,6 +34,7 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ergochat/irc-go v0.4.0 github.com/ergochat/irc-go v0.4.0
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 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/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect

4
go.sum
View File

@ -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/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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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/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=

28
main.go
View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.tilde.town/nbsp/hubbub/audio"
"git.tilde.town/nbsp/hubbub/config" "git.tilde.town/nbsp/hubbub/config"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -58,6 +59,7 @@ type model struct {
mute bool mute bool
deafen bool deafen bool
conn *Conn conn *Conn
au *audio.Audio
} }
type Conn struct { type Conn struct {
@ -80,6 +82,8 @@ func (m *model) setMute(is bool) {
un = "UN" un = "UN"
} }
m.conn.Privmsg(m.channel, un+"MUTE") m.conn.Privmsg(m.channel, un+"MUTE")
m.au.Muted = m.mute || m.deafen
} }
func (m *model) setDeafen(is bool) { func (m *model) setDeafen(is bool) {
m.deafen = is m.deafen = is
@ -91,6 +95,9 @@ func (m *model) setDeafen(is bool) {
un = "UN" un = "UN"
} }
m.conn.Privmsg(m.channel, un+"DEAFEN") 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 { func (m model) Init() tea.Cmd {
@ -117,6 +124,7 @@ func (m model) Init() tea.Cmd {
u.isMuted = false u.isMuted = false
default: default:
u.lastSpoke = time.Now() u.lastSpoke = time.Now()
m.au.OutBuffer <- message
} }
m.users[e.Nick()] = u m.users[e.Nick()] = u
@ -221,19 +229,19 @@ func (m model) View() (s string) {
func start(c *cli.Context) error { func start(c *cli.Context) error {
config, err := config.LoadConfigFile(c.String("config")) config, err := config.LoadConfigFile(c.String("config"))
if err != nil { if err != nil {
log.Fatalf("unable to read config file: %v", err) log.Printf("unable to read config file: %v", err)
return err return err
} }
key, err := os.ReadFile(os.ExpandEnv(strings.Replace(config.Privkey, "~", "$HOME", 1))) key, err := os.ReadFile(os.ExpandEnv(strings.Replace(config.Privkey, "~", "$HOME", 1)))
if err != nil { if err != nil {
log.Fatalf("unable to read private key: %v", err) log.Printf("unable to read private key: %v", err)
return err return err
} }
signer, err := ssh.ParsePrivateKey(key) signer, err := ssh.ParsePrivateKey(key)
if err != nil { if err != nil {
log.Fatalf("unable to parse private key: %v", err) log.Printf("unable to parse private key: %v", err)
return err return err
} }
@ -245,7 +253,7 @@ func start(c *cli.Context) error {
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // XXX HostKeyCallback: ssh.InsecureIgnoreHostKey(), // XXX
}) })
if err != nil { if err != nil {
log.Fatalf("unable to connect to ssh: %v", err) log.Printf("unable to connect to ssh: %v", err)
return err return err
} }
defer client.Close() defer client.Close()
@ -260,14 +268,24 @@ func start(c *cli.Context) error {
}, },
}, },
} }
if err := conn.Connect(); err != nil { 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 return err
} }
au := audio.NewAudio()
go func() {
if err := au.ProcessInput(&conn.Connection, config.Channel); err != nil {
os.Exit(1)
}
}()
p := tea.NewProgram(model{ p := tea.NewProgram(model{
channel: config.Channel, channel: config.Channel,
nick: config.Nick, nick: config.Nick,
conn: conn, conn: conn,
au: &au,
users: map[string]user{}, users: map[string]user{},
notifications: []notification{}, notifications: []notification{},
}) })