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] 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:
```

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/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

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/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=

28
main.go
View File

@ -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{},
})