Add standards-aware recording

This commit is contained in:
Storm Dragon
2026-05-14 00:42:30 -04:00
parent e84cb67500
commit 69674a0dab
15 changed files with 1540 additions and 689 deletions
+20
View File
@@ -77,8 +77,27 @@ Current Commands:
* toggle: Toggle your transmission state.
* talk: Synonym for toggle.
* 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.
## 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
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>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree
- <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/gumbleopenal"
"git.stormux.org/storm/barnard/noise"
"git.stormux.org/storm/barnard/recording"
"git.stormux.org/storm/barnard/uiterm"
)
@@ -57,6 +58,12 @@ type Barnard struct {
// Added for file playback
FileStream *fileplayback.Player
FileStreamMutex sync.Mutex
// Added for recording
RecordingMutex sync.Mutex
Recorder *recording.Recorder
recordingStarting bool
recordingAllowed *bool
}
func (b *Barnard) StopTransmission() {
+16
View File
@@ -77,6 +77,10 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
// Reset muted channels state on connect
b.MutedChannels = make(map[uint32]bool)
b.RecordingMutex.Lock()
b.recordingAllowed = nil
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
@@ -104,7 +108,15 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
switch e.Type {
case gumble.DisconnectError:
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)
if reason == "" {
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 {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
}
if e.Type.Has(gumble.UserChangeRecording) {
b.HandleRecordingChange(e)
}
b.UiTree.Rebuild()
b.Ui.Refresh()
}
@@ -253,4 +268,5 @@ func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
}
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
b.HandleRecordingAllowed(e.RecordingAllowed)
}
+1
View File
@@ -10,6 +10,7 @@ type Hotkeys struct {
VolumeUp *uiterm.Key
VolumeReset *uiterm.Key
MuteToggle *uiterm.Key
RecordToggle *uiterm.Key
Exit *uiterm.Key
ToggleTimestamps *uiterm.Key
SwitchViews *uiterm.Key
+87
View File
@@ -31,6 +31,8 @@ type exportableConfig struct {
NoiseSuppressionThreshold *float32
VoiceEffect *int
Certificate *string
RecordingFormat *string
RecordingDirectory *string
}
type server struct {
@@ -75,6 +77,7 @@ func (c *Config) LoadConfig() {
VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8),
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab),
@@ -95,6 +98,7 @@ func (c *Config) LoadConfig() {
}
}
c.config = &jc
c.ensureHotkeys()
if c.config.MicVolume == nil {
micvol := float32(1.0)
jc.MicVolume = &micvol
@@ -139,6 +143,75 @@ func (c *Config) LoadConfig() {
cert := string("")
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 {
@@ -284,6 +357,20 @@ func (c *Config) SetVoiceEffect(effect int) {
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) {
var j *eUser
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.
It is generated from these files:
Mumble.proto
It has these top-level messages:
Version
UDPTunnel
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"`
// The maximum number of users allowed on the server.
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:"-"`
}
@@ -2121,6 +2125,13 @@ func (m *ServerConfig) GetMaxUsers() uint32 {
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
// specified by the server administrator.
type SuggestConfig struct {
+1
View File
@@ -210,6 +210,7 @@ type ServerConfigEvent struct {
MaximumMessageLength *int
MaximumImageMessageLength *int
MaximumUsers *int
RecordingAllowed *bool
CodecAlpha *int32
CodecBeta *int32
+48
View File
@@ -467,6 +467,7 @@ func (c *Client) handleUserRemove(buffer []byte) error {
event.Type |= UserChangeBanned
}
if event.User == c.Self {
c.disconnectEvent.String = event.String
if packet.Ban != nil && *packet.Ban {
c.disconnectEvent.Type = DisconnectBanned
} else {
@@ -1236,10 +1237,57 @@ func (c *Client) handleServerConfig(buffer []byte) error {
val := int(*packet.MaxUsers)
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)
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 {
var packet MumbleProto.SuggestConfig
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"
"errors"
"os/exec"
"sync"
"time"
"git.stormux.org/storm/barnard/audio"
@@ -30,6 +31,12 @@ type FilePlayer interface {
IsPlaying() bool
}
type Recorder interface {
RecordAudioFrame(source uint32, samples []int16)
}
const recorderOutgoingSource uint32 = ^uint32(0)
const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
)
@@ -72,6 +79,8 @@ type Stream struct {
effectsProcessor EffectsProcessor
effectsProcessorRight EffectsProcessor
filePlayer FilePlayer
recorderMu sync.RWMutex
recorder Recorder
}
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
}
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() {
if s.link != nil {
s.link.Detach()
@@ -265,6 +286,12 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
}
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
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
@@ -290,6 +317,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted)
}
}
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
@@ -305,6 +336,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted)
}
}
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
}
@@ -322,10 +357,19 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
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))
rawPtr += 2
}
}
if recorder != nil && recordPtr > 0 {
recorder.RecordAudioFrame(e.User.Session, recordBuffer[:recordPtr])
}
reclaim()
if len(emptyBufs) == 0 {
@@ -472,12 +516,29 @@ func (s *Stream) sourceRoutine(inputDevice *string) {
if hasFileAudio {
// Send stereo buffer when file is playing
outgoing <- gumble.AudioBuffer(outputBuffer)
if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer)
}
} else if hasMicInput {
// Send mic when no file is playing
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) {
+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)
}
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
if notice {
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
@@ -254,7 +253,6 @@ func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
b.UpdateGeneralStatus(" Idle ", false)
}
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
if b.Tx && val == 1 {
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) {
b.StopRecordingIfActive(true)
b.Client.Disconnect()
b.Ui.Close()
}
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
b.StopRecordingIfActive(true)
b.Client.Disconnect()
b.Ui.Close()
}
@@ -377,6 +377,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
b.CommandStatus(ui, cmdArgs)
case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "record":
b.CommandRecord(ui, cmdArgs)
case "micup":
b.CommandMicUp(ui, cmdArgs)
case "micdown":
@@ -460,11 +462,13 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddCommandListener(b.CommandRecord, "record")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
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.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)