Merge recording feature

This commit is contained in:
Storm Dragon
2026-05-14 00:43:04 -04:00
15 changed files with 1540 additions and 689 deletions
+20
View File
@@ -77,8 +77,27 @@ Current Commands:
* toggle: Toggle your transmission state. * toggle: Toggle your transmission state.
* talk: Synonym for toggle. * talk: Synonym for toggle.
* noise: Toggle noise suppression on/off for microphone input. * noise: Toggle noise suppression on/off for microphone input.
* record: Toggle recording. You may also use `record start` or `record stop`.
* exit: Exit Barnard, just like when you press your quit key. * exit: Exit Barnard, just like when you press your quit key.
## Recording
Barnard can record a single mixed file containing the audio you hear and the audio you transmit.
Recording uses the Mumble recording state so other users are notified by the server.
If the server reports that recording is not allowed, Barnard refuses to start and prints a clear message.
Recordings are saved in `~/Audio` by default. The directory is created if it does not exist.
The default file name is `barnard-recording-YYYYMMDD-HHMMSS.flac`.
Recording settings in `~/.barnard.toml`:
```toml
recordingformat = "flac"
recordingdirectory = "~/Audio"
```
`recordingformat` may be `flac` or `opus`. Barnard uses `ffmpeg` to encode recordings.
## Event Notification ## Event Notification
You can use the notifycommand parameter in your config file to run a program on certain events. You can use the notifycommand parameter in your config file to run a program on certain events.
@@ -225,6 +244,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission - <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression - <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: cycle through voice effects - <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log - <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree - <kbd>Tab</kbd>: toggle focus between chat and user tree
- <kbd>Page Up</kbd>: scroll chat up - <kbd>Page Up</kbd>: scroll chat up
+7
View File
@@ -10,6 +10,7 @@ import (
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal" "git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/noise" "git.stormux.org/storm/barnard/noise"
"git.stormux.org/storm/barnard/recording"
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/uiterm"
) )
@@ -57,6 +58,12 @@ type Barnard struct {
// Added for file playback // Added for file playback
FileStream *fileplayback.Player FileStream *fileplayback.Player
FileStreamMutex sync.Mutex FileStreamMutex sync.Mutex
// Added for recording
RecordingMutex sync.Mutex
Recorder *recording.Recorder
recordingStarting bool
recordingAllowed *bool
} }
func (b *Barnard) StopTransmission() { func (b *Barnard) StopTransmission() {
+16
View File
@@ -77,6 +77,10 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
// Reset muted channels state on connect // Reset muted channels state on connect
b.MutedChannels = make(map[uint32]bool) b.MutedChannels = make(map[uint32]bool)
b.RecordingMutex.Lock()
b.recordingAllowed = nil
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.Ui.SetActive(uiViewInput) b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild() b.UiTree.Rebuild()
@@ -104,7 +108,15 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
switch e.Type { switch e.Type {
case gumble.DisconnectError: case gumble.DisconnectError:
reason = "connection error" reason = "connection error"
case gumble.DisconnectKicked:
reason = "kicked"
case gumble.DisconnectBanned:
reason = "banned"
} }
if e.String != "" {
reason = e.String
}
b.stopRecordingForDisconnect()
b.Notify("disconnect", "me", reason) b.Notify("disconnect", "me", reason)
if reason == "" { if reason == "" {
b.AddOutputLine("Disconnected") b.AddOutputLine("Disconnected")
@@ -200,6 +212,9 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self { if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name)) b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
} }
if e.Type.Has(gumble.UserChangeRecording) {
b.HandleRecordingChange(e)
}
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
} }
@@ -253,4 +268,5 @@ func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
} }
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) { func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
b.HandleRecordingAllowed(e.RecordingAllowed)
} }
+1
View File
@@ -10,6 +10,7 @@ type Hotkeys struct {
VolumeUp *uiterm.Key VolumeUp *uiterm.Key
VolumeReset *uiterm.Key VolumeReset *uiterm.Key
MuteToggle *uiterm.Key MuteToggle *uiterm.Key
RecordToggle *uiterm.Key
Exit *uiterm.Key Exit *uiterm.Key
ToggleTimestamps *uiterm.Key ToggleTimestamps *uiterm.Key
SwitchViews *uiterm.Key SwitchViews *uiterm.Key
+87
View File
@@ -31,6 +31,8 @@ type exportableConfig struct {
NoiseSuppressionThreshold *float32 NoiseSuppressionThreshold *float32
VoiceEffect *int VoiceEffect *int
Certificate *string Certificate *string
RecordingFormat *string
RecordingDirectory *string
} }
type server struct { type server struct {
@@ -75,6 +77,7 @@ func (c *Config) LoadConfig() {
VolumeUp: key(uiterm.KeyF6), VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8), VolumeReset: key(uiterm.KeyF8),
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10), Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3), ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab), SwitchViews: key(uiterm.KeyTab),
@@ -95,6 +98,7 @@ func (c *Config) LoadConfig() {
} }
} }
c.config = &jc c.config = &jc
c.ensureHotkeys()
if c.config.MicVolume == nil { if c.config.MicVolume == nil {
micvol := float32(1.0) micvol := float32(1.0)
jc.MicVolume = &micvol jc.MicVolume = &micvol
@@ -139,6 +143,75 @@ func (c *Config) LoadConfig() {
cert := string("") cert := string("")
jc.Certificate = &cert jc.Certificate = &cert
} }
if c.config.RecordingFormat == nil {
format := string("flac")
jc.RecordingFormat = &format
}
if c.config.RecordingDirectory == nil {
dir := string("~/Audio")
jc.RecordingDirectory = &dir
}
}
func (c *Config) ensureHotkeys() {
if c.config.Hotkeys == nil {
c.config.Hotkeys = &Hotkeys{}
}
defaults := Hotkeys{
Talk: key(uiterm.KeyF1),
VolumeDown: key(uiterm.KeyF5),
VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8),
MuteToggle: key(uiterm.KeyF7),
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab),
ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil {
hotkeys.Talk = defaults.Talk
}
if hotkeys.VolumeDown == nil {
hotkeys.VolumeDown = defaults.VolumeDown
}
if hotkeys.VolumeUp == nil {
hotkeys.VolumeUp = defaults.VolumeUp
}
if hotkeys.VolumeReset == nil {
hotkeys.VolumeReset = defaults.VolumeReset
}
if hotkeys.MuteToggle == nil {
hotkeys.MuteToggle = defaults.MuteToggle
}
if hotkeys.RecordToggle == nil {
hotkeys.RecordToggle = defaults.RecordToggle
}
if hotkeys.Exit == nil {
hotkeys.Exit = defaults.Exit
}
if hotkeys.ToggleTimestamps == nil {
hotkeys.ToggleTimestamps = defaults.ToggleTimestamps
}
if hotkeys.SwitchViews == nil {
hotkeys.SwitchViews = defaults.SwitchViews
}
if hotkeys.ScrollUp == nil {
hotkeys.ScrollUp = defaults.ScrollUp
}
if hotkeys.ScrollDown == nil {
hotkeys.ScrollDown = defaults.ScrollDown
}
if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
}
if hotkeys.CycleVoiceEffect == nil {
hotkeys.CycleVoiceEffect = defaults.CycleVoiceEffect
}
} }
func (c *Config) findServer(address string) *server { func (c *Config) findServer(address string) *server {
@@ -284,6 +357,20 @@ func (c *Config) SetVoiceEffect(effect int) {
c.SaveConfig() c.SaveConfig()
} }
func (c *Config) GetRecordingFormat() string {
if c.config.RecordingFormat == nil {
return "flac"
}
return strings.ToLower(strings.TrimSpace(*c.config.RecordingFormat))
}
func (c *Config) GetRecordingDirectory() string {
if c.config.RecordingDirectory == nil {
return resolvePath("~/Audio")
}
return resolvePath(*c.config.RecordingDirectory)
}
func (c *Config) UpdateUser(u *gumble.User) { func (c *Config) UpdateUser(u *gumble.User) {
var j *eUser var j *eUser
var uc *gumble.Client var uc *gumble.Client
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.stormux.org/storm/barnard/uiterm"
)
func TestConfigBackfillsRecordingDefaults(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "barnard.toml")
if err := os.WriteFile(configPath, []byte("[hotkeys]\ntalk = \"f1\"\n"), 0600); err != nil {
t.Fatal(err)
}
cfg := NewConfig(&configPath)
if got := cfg.GetRecordingFormat(); got != "flac" {
t.Fatalf("expected default recording format flac, got %q", got)
}
if got := cfg.GetRecordingDirectory(); got != filepath.Join(os.Getenv("HOME"), "Audio") {
t.Fatalf("expected default recording directory ~/Audio, got %q", got)
}
if cfg.GetHotkeys().RecordToggle == nil {
t.Fatal("expected record toggle hotkey to be backfilled")
}
if got := *cfg.GetHotkeys().RecordToggle; got != uiterm.KeyCtrlR {
t.Fatalf("expected record toggle ctrl_r, got %s", got)
}
}
+11
View File
@@ -6,9 +6,11 @@
Package MumbleProto is a generated protocol buffer package. Package MumbleProto is a generated protocol buffer package.
It is generated from these files: It is generated from these files:
Mumble.proto Mumble.proto
It has these top-level messages: It has these top-level messages:
Version Version
UDPTunnel UDPTunnel
Authenticate Authenticate
@@ -2071,6 +2073,8 @@ type ServerConfig struct {
ImageMessageLength *uint32 `protobuf:"varint,5,opt,name=image_message_length,json=imageMessageLength" json:"image_message_length,omitempty"` ImageMessageLength *uint32 `protobuf:"varint,5,opt,name=image_message_length,json=imageMessageLength" json:"image_message_length,omitempty"`
// The maximum number of users allowed on the server. // The maximum number of users allowed on the server.
MaxUsers *uint32 `protobuf:"varint,6,opt,name=max_users,json=maxUsers" json:"max_users,omitempty"` MaxUsers *uint32 `protobuf:"varint,6,opt,name=max_users,json=maxUsers" json:"max_users,omitempty"`
// Whether using Mumble's recording feature is allowed on the server.
RecordingAllowed *bool `protobuf:"varint,7,opt,name=recording_allowed,json=recordingAllowed" json:"recording_allowed,omitempty"`
XXX_unrecognized []byte `json:"-"` XXX_unrecognized []byte `json:"-"`
} }
@@ -2121,6 +2125,13 @@ func (m *ServerConfig) GetMaxUsers() uint32 {
return 0 return 0
} }
func (m *ServerConfig) GetRecordingAllowed() bool {
if m != nil && m.RecordingAllowed != nil {
return *m.RecordingAllowed
}
return false
}
// Sent by the server to inform the clients of suggested client configuration // Sent by the server to inform the clients of suggested client configuration
// specified by the server administrator. // specified by the server administrator.
type SuggestConfig struct { type SuggestConfig struct {
+1
View File
@@ -210,6 +210,7 @@ type ServerConfigEvent struct {
MaximumMessageLength *int MaximumMessageLength *int
MaximumImageMessageLength *int MaximumImageMessageLength *int
MaximumUsers *int MaximumUsers *int
RecordingAllowed *bool
CodecAlpha *int32 CodecAlpha *int32
CodecBeta *int32 CodecBeta *int32
+48
View File
@@ -467,6 +467,7 @@ func (c *Client) handleUserRemove(buffer []byte) error {
event.Type |= UserChangeBanned event.Type |= UserChangeBanned
} }
if event.User == c.Self { if event.User == c.Self {
c.disconnectEvent.String = event.String
if packet.Ban != nil && *packet.Ban { if packet.Ban != nil && *packet.Ban {
c.disconnectEvent.Type = DisconnectBanned c.disconnectEvent.Type = DisconnectBanned
} else { } else {
@@ -1236,10 +1237,57 @@ func (c *Client) handleServerConfig(buffer []byte) error {
val := int(*packet.MaxUsers) val := int(*packet.MaxUsers)
event.MaximumUsers = &val event.MaximumUsers = &val
} }
if packet.RecordingAllowed != nil {
event.RecordingAllowed = packet.RecordingAllowed
} else if val := parseServerConfigRecordingAllowed(buffer); val != nil {
event.RecordingAllowed = val
}
c.Config.Listeners.onServerConfig(&event) c.Config.Listeners.onServerConfig(&event)
return nil return nil
} }
func parseServerConfigRecordingAllowed(buffer []byte) *bool {
for len(buffer) > 0 {
key, n := varint.Decode(buffer)
if n <= 0 {
return nil
}
buffer = buffer[n:]
field := key >> 3
wireType := key & 0x7
if field == 7 && wireType == 0 {
val, n := varint.Decode(buffer)
if n <= 0 {
return nil
}
allowed := val != 0
return &allowed
}
skip := 0
switch wireType {
case 0:
_, skip = varint.Decode(buffer)
case 1:
skip = 8
case 2:
length, n := varint.Decode(buffer)
if n <= 0 || length < 0 {
return nil
}
skip = n + int(length)
case 5:
skip = 4
default:
return nil
}
if skip <= 0 || skip > len(buffer) {
return nil
}
buffer = buffer[skip:]
}
return nil
}
func (c *Client) handleSuggestConfig(buffer []byte) error { func (c *Client) handleSuggestConfig(buffer []byte) error {
var packet MumbleProto.SuggestConfig var packet MumbleProto.SuggestConfig
if err := proto.Unmarshal(buffer, &packet); err != nil { if err := proto.Unmarshal(buffer, &packet); err != nil {
+35
View File
@@ -0,0 +1,35 @@
package gumble
import (
"testing"
)
type serverConfigListener struct {
EventListener
event *ServerConfigEvent
}
func (l *serverConfigListener) OnServerConfig(e *ServerConfigEvent) {
l.event = e
}
func TestHandleServerConfigRecordingAllowed(t *testing.T) {
data := []byte{56, 0} // field 7, varint false
client := &Client{Config: NewConfig()}
listener := &serverConfigListener{}
client.Config.Attach(listener)
if err := client.handleServerConfig(data); err != nil {
t.Fatal(err)
}
if listener.event == nil {
t.Fatal("expected server config event")
}
if listener.event.RecordingAllowed == nil {
t.Fatal("expected recording allowed value")
}
if *listener.event.RecordingAllowed {
t.Fatal("expected recording to be disallowed")
}
}
+61
View File
@@ -4,6 +4,7 @@ import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"os/exec" "os/exec"
"sync"
"time" "time"
"git.stormux.org/storm/barnard/audio" "git.stormux.org/storm/barnard/audio"
@@ -30,6 +31,12 @@ type FilePlayer interface {
IsPlaying() bool IsPlaying() bool
} }
type Recorder interface {
RecordAudioFrame(source uint32, samples []int16)
}
const recorderOutgoingSource uint32 = ^uint32(0)
const ( const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
) )
@@ -72,6 +79,8 @@ type Stream struct {
effectsProcessor EffectsProcessor effectsProcessor EffectsProcessor
effectsProcessorRight EffectsProcessor effectsProcessorRight EffectsProcessor
filePlayer FilePlayer filePlayer FilePlayer
recorderMu sync.RWMutex
recorder Recorder
} }
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) { func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
@@ -161,6 +170,18 @@ func (s *Stream) GetFilePlayer() FilePlayer {
return s.filePlayer return s.filePlayer
} }
func (s *Stream) SetRecorder(recorder Recorder) {
s.recorderMu.Lock()
defer s.recorderMu.Unlock()
s.recorder = recorder
}
func (s *Stream) getRecorder() Recorder {
s.recorderMu.RLock()
defer s.recorderMu.RUnlock()
return s.recorder
}
func (s *Stream) Destroy() { func (s *Stream) Destroy() {
if s.link != nil { if s.link != nil {
s.link.Detach() s.link.Detach()
@@ -265,6 +286,12 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
boost = e.User.Boost boost = e.User.Boost
recorder := s.getRecorder()
var recordBuffer []int16
recordPtr := 0
if recorder != nil {
recordBuffer = make([]int16, len(packet.AudioBuffer)*gumble.AudioChannels)
}
// Check if sample count suggests stereo data // Check if sample count suggests stereo data
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0 isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
@@ -290,6 +317,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
@@ -305,6 +336,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
} }
@@ -322,10 +357,19 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordSample := scaleForRecording(sample, e.User.Volume)
recordBuffer[recordPtr] = recordSample
recordBuffer[recordPtr+1] = recordSample
recordPtr += 2
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
} }
} }
if recorder != nil && recordPtr > 0 {
recorder.RecordAudioFrame(e.User.Session, recordBuffer[:recordPtr])
}
reclaim() reclaim()
if len(emptyBufs) == 0 { if len(emptyBufs) == 0 {
@@ -472,13 +516,30 @@ func (s *Stream) sourceRoutine(inputDevice *string) {
if hasFileAudio { if hasFileAudio {
// Send stereo buffer when file is playing // Send stereo buffer when file is playing
outgoing <- gumble.AudioBuffer(outputBuffer) outgoing <- gumble.AudioBuffer(outputBuffer)
if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer)
}
} else if hasMicInput { } else if hasMicInput {
// Send mic when no file is playing // Send mic when no file is playing
outgoing <- gumble.AudioBuffer(int16Buffer) outgoing <- gumble.AudioBuffer(int16Buffer)
if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, int16Buffer)
} }
} }
} }
} }
}
func scaleForRecording(sample int16, volume float32) int16 {
scaled := int32(float32(sample) * volume)
if scaled > 32767 {
return 32767
}
if scaled < -32768 {
return -32768
}
return int16(scaled)
}
func (s *Stream) processMonoSamples(samples []int16) { func (s *Stream) processMonoSamples(samples []int16) {
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor) s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
+263
View File
@@ -0,0 +1,263 @@
package recording
import (
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
)
const (
FormatFLAC = "flac"
FormatOpus = "opus"
)
type Recorder struct {
path string
format string
frameSize int
interval time.Duration
cmd *exec.Cmd
stdin io.WriteCloser
input chan sourceFrame
stop chan struct{}
done chan struct{}
once sync.Once
mu sync.Mutex
err error
}
type sourceFrame struct {
source uint32
samples []int16
}
func New(directory string, format string, now time.Time, frameSize int, interval time.Duration) (*Recorder, error) {
format = NormalizeFormat(format)
if format != FormatFLAC && format != FormatOpus {
return nil, fmt.Errorf("unsupported recording format %q", format)
}
if frameSize <= 0 {
return nil, errors.New("invalid recording frame size")
}
if interval <= 0 {
interval = gumble.AudioDefaultInterval
}
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, err
}
path := UniquePath(directory, now, format)
args := ffmpegArgs(format, path)
cmd := exec.Command("ffmpeg", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
recorder := &Recorder{
path: path,
format: format,
frameSize: frameSize,
interval: interval,
cmd: cmd,
stdin: stdin,
input: make(chan sourceFrame, 512),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go recorder.run()
return recorder, nil
}
func NormalizeFormat(format string) string {
format = strings.ToLower(strings.TrimSpace(format))
format = strings.TrimPrefix(format, ".")
if format == "" {
return FormatFLAC
}
return format
}
func UniquePath(directory string, now time.Time, format string) string {
base := fmt.Sprintf("barnard-recording-%s", now.Format("20060102-150405"))
path := filepath.Join(directory, base+"."+format)
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
for i := 2; ; i++ {
path = filepath.Join(directory, fmt.Sprintf("%s-%d.%s", base, i, format))
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
}
}
func (r *Recorder) Path() string {
return r.path
}
func (r *Recorder) RecordAudioFrame(source uint32, samples []int16) {
if r == nil || len(samples) == 0 {
return
}
if len(r.input) >= cap(r.input) {
return
}
frame := NormalizeStereoFrame(samples, r.frameSize)
select {
case r.input <- sourceFrame{source: source, samples: frame}:
default:
}
}
func (r *Recorder) Stop() error {
if r == nil {
return nil
}
r.once.Do(func() {
close(r.stop)
if r.stdin != nil {
r.stdin.Close()
}
})
select {
case <-r.done:
case <-time.After(2 * time.Second):
if r.cmd != nil && r.cmd.Process != nil {
r.cmd.Process.Kill()
}
<-r.done
}
r.mu.Lock()
defer r.mu.Unlock()
return r.err
}
func (r *Recorder) run() {
defer close(r.done)
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
queues := make(map[uint32][][]int16)
frame := make([]int16, r.frameSize*gumble.AudioChannels)
for {
select {
case <-r.stop:
r.closeEncoder()
return
case item := <-r.input:
queues[item.source] = append(queues[item.source], item.samples)
case <-ticker.C:
clear(frame)
for source, queue := range queues {
if len(queue) == 0 {
delete(queues, source)
continue
}
mix(frame, queue[0])
queue = queue[1:]
if len(queue) == 0 {
delete(queues, source)
} else {
queues[source] = queue
}
}
if err := writePCM(r.stdin, frame); err != nil {
r.setError(err)
r.closeEncoder()
return
}
}
}
}
func (r *Recorder) closeEncoder() {
if r.stdin != nil {
if err := r.stdin.Close(); err != nil {
if !errors.Is(err, os.ErrClosed) {
r.setError(err)
}
}
}
if r.cmd != nil {
if err := r.cmd.Wait(); err != nil {
r.setError(err)
}
}
}
func (r *Recorder) setError(err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.err == nil {
r.err = err
}
}
func NormalizeStereoFrame(samples []int16, frameSize int) []int16 {
out := make([]int16, frameSize*gumble.AudioChannels)
if len(samples) >= frameSize*gumble.AudioChannels && len(samples)%gumble.AudioChannels == 0 {
copy(out, samples[:frameSize*gumble.AudioChannels])
return out
}
limit := frameSize
if len(samples) < limit {
limit = len(samples)
}
for i := 0; i < limit; i++ {
out[i*2] = samples[i]
out[i*2+1] = samples[i]
}
return out
}
func mix(dst []int16, src []int16) {
limit := len(dst)
if len(src) < limit {
limit = len(src)
}
for i := 0; i < limit; i++ {
sum := int32(dst[i]) + int32(src[i])
if sum > 32767 {
sum = 32767
} else if sum < -32768 {
sum = -32768
}
dst[i] = int16(sum)
}
}
func writePCM(w io.Writer, samples []int16) error {
buf := make([]byte, len(samples)*2)
for i, sample := range samples {
binary.LittleEndian.PutUint16(buf[i*2:], uint16(sample))
}
_, err := w.Write(buf)
return err
}
func ffmpegArgs(format string, path string) []string {
args := []string{
"-loglevel", "error",
"-f", "s16le",
"-ar", fmt.Sprintf("%d", gumble.AudioSampleRate),
"-ac", fmt.Sprintf("%d", gumble.AudioChannels),
"-i", "pipe:0",
}
if format == FormatOpus {
args = append(args, "-c:a", "libopus")
}
return append(args, "-y", path)
}
+65
View File
@@ -0,0 +1,65 @@
package recording
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestNormalizeFormat(t *testing.T) {
tests := map[string]string{
"": "flac",
" FLAC ": "flac",
".opus": "opus",
}
for input, want := range tests {
if got := NormalizeFormat(input); got != want {
t.Fatalf("NormalizeFormat(%q) = %q, want %q", input, got, want)
}
}
}
func TestUniquePathAvoidsCollision(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 14, 12, 30, 0, 0, time.Local)
first := filepath.Join(dir, "barnard-recording-20260514-123000.flac")
if err := os.WriteFile(first, []byte{}, 0600); err != nil {
t.Fatal(err)
}
got := UniquePath(dir, now, "flac")
want := filepath.Join(dir, "barnard-recording-20260514-123000-2.flac")
if got != want {
t.Fatalf("UniquePath() = %q, want %q", got, want)
}
}
func TestNormalizeStereoFrame(t *testing.T) {
mono := NormalizeStereoFrame([]int16{1, -2}, 3)
wantMono := []int16{1, 1, -2, -2, 0, 0}
for i := range wantMono {
if mono[i] != wantMono[i] {
t.Fatalf("mono[%d] = %d, want %d", i, mono[i], wantMono[i])
}
}
stereo := NormalizeStereoFrame([]int16{1, 2, 3, 4, 5, 6}, 2)
wantStereo := []int16{1, 2, 3, 4}
for i := range wantStereo {
if stereo[i] != wantStereo[i] {
t.Fatalf("stereo[%d] = %d, want %d", i, stereo[i], wantStereo[i])
}
}
}
func TestMixSaturates(t *testing.T) {
dst := []int16{32000, -32000, 10}
mix(dst, []int16{2000, -2000, -20})
want := []int16{32767, -32768, -10}
for i := range want {
if dst[i] != want[i] {
t.Fatalf("dst[%d] = %d, want %d", i, dst[i], want[i])
}
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"fmt"
"strings"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/recording"
"git.stormux.org/storm/barnard/uiterm"
)
func (b *Barnard) OnRecordingToggle(ui *uiterm.Ui, key uiterm.Key) {
b.ToggleRecording()
}
func (b *Barnard) CommandRecord(ui *uiterm.Ui, cmd string) {
action := strings.ToLower(strings.TrimSpace(cmd))
switch action {
case "", "toggle":
b.ToggleRecording()
case "start", "on":
b.StartRecording()
case "stop", "off":
b.StopRecording(true)
default:
b.AddOutputLine("Usage: /record [start|stop|toggle]")
}
}
func (b *Barnard) ToggleRecording() {
b.RecordingMutex.Lock()
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if active {
b.StopRecording(true)
} else {
b.StartRecording()
}
}
func (b *Barnard) StartRecording() {
if b.Client == nil || b.Client.Self == nil {
b.AddOutputLine("Recording requires an active server connection")
return
}
b.RecordingMutex.Lock()
if b.recordingAllowed != nil && !*b.recordingAllowed {
b.RecordingMutex.Unlock()
b.AddOutputLine("Recording is not allowed by this server.")
return
}
if b.Recorder != nil || b.recordingStarting {
b.RecordingMutex.Unlock()
b.AddOutputLine("Recording is already active")
return
}
b.recordingStarting = true
b.RecordingMutex.Unlock()
b.Client.Self.SetRecording(true)
b.AddOutputLine("Recording start requested")
}
func (b *Barnard) StopRecording(notifyServer bool) {
recorder, path, wasPending := b.detachRecorder()
if recorder == nil {
if notifyServer && b.Client != nil && b.Client.Self != nil {
b.Client.Self.SetRecording(false)
}
if wasPending {
b.AddOutputLine("Recording start cancelled")
} else {
b.AddOutputLine("Recording is not active")
}
return
}
if err := recorder.Stop(); err != nil {
b.AddOutputLine(fmt.Sprintf("Recording stopped with error: %s", err))
} else {
b.AddOutputLine(fmt.Sprintf("Recording saved: %s", path))
}
if notifyServer && b.Client != nil && b.Client.Self != nil {
b.Client.Self.SetRecording(false)
}
}
func (b *Barnard) StopRecordingIfActive(notifyServer bool) {
b.RecordingMutex.Lock()
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if active {
b.StopRecording(notifyServer)
}
}
func (b *Barnard) HandleRecordingChange(e *gumble.UserChangeEvent) {
if e.User == nil {
return
}
if e.User == b.Client.Self {
if e.User.Recording {
b.finishRecordingStart()
} else {
b.RecordingMutex.Lock()
hadRecorder := b.Recorder != nil
b.RecordingMutex.Unlock()
if hadRecorder {
b.StopRecording(false)
}
}
return
}
if e.User.Recording {
b.AddOutputLine(fmt.Sprintf("%s started recording", e.User.Name))
} else {
b.AddOutputLine(fmt.Sprintf("%s stopped recording", e.User.Name))
}
}
func (b *Barnard) HandleRecordingAllowed(allowed *bool) {
if allowed == nil {
return
}
b.RecordingMutex.Lock()
value := *allowed
b.recordingAllowed = &value
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if !value && active {
b.AddOutputLine("Recording is not allowed by this server.")
b.StopRecording(true)
}
}
func (b *Barnard) finishRecordingStart() {
b.RecordingMutex.Lock()
if !b.recordingStarting || b.Recorder != nil {
b.RecordingMutex.Unlock()
return
}
b.RecordingMutex.Unlock()
recorder, err := recording.New(
b.UserConfig.GetRecordingDirectory(),
b.UserConfig.GetRecordingFormat(),
time.Now(),
b.Client.Config.AudioFrameSize(),
b.Client.Config.AudioInterval,
)
if err != nil {
b.RecordingMutex.Lock()
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.AddOutputLine(fmt.Sprintf("Could not start recording: %s", err))
b.Client.Self.SetRecording(false)
return
}
b.RecordingMutex.Lock()
if !b.recordingStarting {
b.RecordingMutex.Unlock()
recorder.Stop()
return
}
b.Recorder = recorder
b.recordingStarting = false
b.RecordingMutex.Unlock()
if b.Stream != nil {
b.Stream.SetRecorder(recorder)
}
b.AddOutputLine(fmt.Sprintf("Recording started: %s", recorder.Path()))
}
func (b *Barnard) detachRecorder() (*recording.Recorder, string, bool) {
b.RecordingMutex.Lock()
defer b.RecordingMutex.Unlock()
recorder := b.Recorder
wasPending := b.recordingStarting
path := ""
if recorder != nil {
path = recorder.Path()
}
b.Recorder = nil
b.recordingStarting = false
if b.Stream != nil {
b.Stream.SetRecorder(nil)
}
return recorder, path, wasPending
}
func (b *Barnard) stopRecordingForDisconnect() {
recorder, _, _ := b.detachRecorder()
if recorder != nil {
if err := recorder.Stop(); err != nil {
b.AddOutputLine(fmt.Sprintf("Recording stopped with error: %s", err))
}
}
}
+6 -2
View File
@@ -114,7 +114,6 @@ func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) {
b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false) b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false)
} }
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
if notice { if notice {
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
@@ -254,7 +253,6 @@ func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
b.UpdateGeneralStatus(" Idle ", false) b.UpdateGeneralStatus(" Idle ", false)
} }
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
if b.Tx && val == 1 { if b.Tx && val == 1 {
return return
@@ -300,11 +298,13 @@ func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
} }
func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) {
b.StopRecordingIfActive(true)
b.Client.Disconnect() b.Client.Disconnect()
b.Ui.Close() b.Ui.Close()
} }
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) { func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
b.StopRecordingIfActive(true)
b.Client.Disconnect() b.Client.Disconnect()
b.Ui.Close() b.Ui.Close()
} }
@@ -377,6 +377,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
b.CommandStatus(ui, cmdArgs) b.CommandStatus(ui, cmdArgs)
case "noise": case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs) b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "record":
b.CommandRecord(ui, cmdArgs)
case "micup": case "micup":
b.CommandMicUp(ui, cmdArgs) b.CommandMicUp(ui, cmdArgs)
case "micdown": case "micdown":
@@ -460,11 +462,13 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddCommandListener(b.CommandRecord, "record")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect) b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
b.Ui.AddKeyListener(b.OnRecordingToggle, b.Hotkeys.RecordToggle)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp) b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown) b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)