diff --git a/README.md b/README.md
index 2965159..16fbb2c 100644
--- a/README.md
+++ b/README.md
@@ -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)
- F1: toggle voice transmission
- F9: toggle noise suppression
- F12: cycle through voice effects
+- Ctrl+R: toggle recording
- Ctrl+L: clear chat log
- Tab: toggle focus between chat and user tree
- Page Up: scroll chat up
diff --git a/barnard.go b/barnard.go
index a1c4e12..3bb3bcd 100644
--- a/barnard.go
+++ b/barnard.go
@@ -1,167 +1,174 @@
package main
import (
- "crypto/tls"
- "sync"
+ "crypto/tls"
+ "sync"
- "git.stormux.org/storm/barnard/audio"
- "git.stormux.org/storm/barnard/config"
- "git.stormux.org/storm/barnard/fileplayback"
- "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/uiterm"
+ "git.stormux.org/storm/barnard/audio"
+ "git.stormux.org/storm/barnard/config"
+ "git.stormux.org/storm/barnard/fileplayback"
+ "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"
)
type TreeItem struct {
- User *gumble.User
- Channel *gumble.Channel
+ User *gumble.User
+ Channel *gumble.Channel
}
type Barnard struct {
- Config *gumble.Config
- UserConfig *config.Config
- Hotkeys *config.Hotkeys
- Client *gumble.Client
+ Config *gumble.Config
+ UserConfig *config.Config
+ Hotkeys *config.Hotkeys
+ Client *gumble.Client
- Address string
- TLSConfig tls.Config
+ Address string
+ TLSConfig tls.Config
- Stream *gumbleopenal.Stream
- Tx bool
- Connected bool
+ Stream *gumbleopenal.Stream
+ Tx bool
+ Connected bool
- Ui *uiterm.Ui
- UiOutput uiterm.Textview
- UiInput uiterm.Textbox
- UiStatus uiterm.Label
- UiTree uiterm.Tree
- UiInputStatus uiterm.Label
- SelectedChannel *gumble.Channel
- selectedUser *gumble.User
+ Ui *uiterm.Ui
+ UiOutput uiterm.Textview
+ UiInput uiterm.Textbox
+ UiStatus uiterm.Label
+ UiTree uiterm.Tree
+ UiInputStatus uiterm.Label
+ SelectedChannel *gumble.Channel
+ selectedUser *gumble.User
- notifyChannel chan []string
+ notifyChannel chan []string
- exitStatus int
- exitMessage string
+ exitStatus int
+ exitMessage string
- // Added for channel muting
- MutedChannels map[uint32]bool
+ // Added for channel muting
+ MutedChannels map[uint32]bool
- // Added for noise suppression
- NoiseSuppressor *noise.Suppressor
+ // Added for noise suppression
+ NoiseSuppressor *noise.Suppressor
- // Added for voice effects
- VoiceEffects *audio.EffectsProcessor
+ // Added for voice effects
+ VoiceEffects *audio.EffectsProcessor
- // Added for file playback
- FileStream *fileplayback.Player
- FileStreamMutex sync.Mutex
+ // 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() {
- if b.Tx {
- b.Notify("micdown", "me", "")
- b.Tx = false
- b.UpdateGeneralStatus(" Idle ", false)
- b.Stream.StopSource()
- }
+ if b.Tx {
+ b.Notify("micdown", "me", "")
+ b.Tx = false
+ b.UpdateGeneralStatus(" Idle ", false)
+ b.Stream.StopSource()
+ }
}
func (b *Barnard) TreeItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) {
}
func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) {
- treeItem := item.(TreeItem)
- if key == uiterm.KeyEnter {
- if treeItem.Channel != nil {
- b.Client.Self.Move(treeItem.Channel)
- b.SetSelectedUser(nil)
- b.GotoChat()
- }
- if treeItem.User != nil {
- if b.selectedUser == treeItem.User {
- b.SetSelectedUser(nil)
- b.GotoChat()
- } else {
- b.SetSelectedUser(treeItem.User)
- b.GotoChat()
- }
- }
- }
+ treeItem := item.(TreeItem)
+ if key == uiterm.KeyEnter {
+ if treeItem.Channel != nil {
+ b.Client.Self.Move(treeItem.Channel)
+ b.SetSelectedUser(nil)
+ b.GotoChat()
+ }
+ if treeItem.User != nil {
+ if b.selectedUser == treeItem.User {
+ b.SetSelectedUser(nil)
+ b.GotoChat()
+ } else {
+ b.SetSelectedUser(treeItem.User)
+ b.GotoChat()
+ }
+ }
+ }
- // Handle mute toggle
- if treeItem.Channel != nil {
- if key == *b.Hotkeys.MuteToggle {
- // Determine new channel mute state
- channelWillBeMuted := !b.MutedChannels[treeItem.Channel.ID]
-
- // Set all users in channel to the same mute state
- users := makeUsersArray(treeItem.Channel.Users)
- for _, u := range users {
- // Explicitly set user mute state to match channel state
- if channelWillBeMuted && !u.LocallyMuted {
- b.UserConfig.ToggleMute(u)
- } else if !channelWillBeMuted && u.LocallyMuted {
- b.UserConfig.ToggleMute(u)
- }
-
- if u.AudioSource != nil {
- if u.LocallyMuted {
- u.AudioSource.SetGain(0)
- } else {
- u.AudioSource.SetGain(u.Volume)
- }
- }
- }
+ // Handle mute toggle
+ if treeItem.Channel != nil {
+ if key == *b.Hotkeys.MuteToggle {
+ // Determine new channel mute state
+ channelWillBeMuted := !b.MutedChannels[treeItem.Channel.ID]
- // Update channel mute state
- if channelWillBeMuted {
- b.MutedChannels[treeItem.Channel.ID] = true
- // If this is the current channel, stop transmission
- if b.Client.Self.Channel.ID == treeItem.Channel.ID && b.Tx {
- b.StopTransmission()
- }
- } else {
- delete(b.MutedChannels, treeItem.Channel.ID)
- }
+ // Set all users in channel to the same mute state
+ users := makeUsersArray(treeItem.Channel.Users)
+ for _, u := range users {
+ // Explicitly set user mute state to match channel state
+ if channelWillBeMuted && !u.LocallyMuted {
+ b.UserConfig.ToggleMute(u)
+ } else if !channelWillBeMuted && u.LocallyMuted {
+ b.UserConfig.ToggleMute(u)
+ }
- b.UiTree.Rebuild()
- b.Ui.Refresh()
- }
- if key == *b.Hotkeys.VolumeDown {
- b.changeVolume(makeUsersArray(treeItem.Channel.Users), -0.1)
- }
- if key == *b.Hotkeys.VolumeUp {
- b.changeVolume(makeUsersArray(treeItem.Channel.Users), 0.1)
- }
- if key == *b.Hotkeys.VolumeReset {
- b.resetVolume(makeUsersArray(treeItem.Channel.Users))
- }
- }
+ if u.AudioSource != nil {
+ if u.LocallyMuted {
+ u.AudioSource.SetGain(0)
+ } else {
+ u.AudioSource.SetGain(u.Volume)
+ }
+ }
+ }
- if treeItem.User != nil {
- if key == *b.Hotkeys.MuteToggle {
- // Toggle mute for single user
- b.UserConfig.ToggleMute(treeItem.User)
- if treeItem.User.AudioSource != nil {
- if treeItem.User.LocallyMuted {
- treeItem.User.AudioSource.SetGain(0)
- } else {
- treeItem.User.AudioSource.SetGain(treeItem.User.Volume)
- }
- }
- b.UiTree.Rebuild()
- b.Ui.Refresh()
- }
- if key == *b.Hotkeys.VolumeDown {
- b.changeVolume([]*gumble.User{treeItem.User}, -0.1)
- }
- if key == *b.Hotkeys.VolumeUp {
- b.changeVolume([]*gumble.User{treeItem.User}, 0.1)
- }
- if key == *b.Hotkeys.VolumeReset {
- b.resetVolume([]*gumble.User{treeItem.User})
- }
- }
+ // Update channel mute state
+ if channelWillBeMuted {
+ b.MutedChannels[treeItem.Channel.ID] = true
+ // If this is the current channel, stop transmission
+ if b.Client.Self.Channel.ID == treeItem.Channel.ID && b.Tx {
+ b.StopTransmission()
+ }
+ } else {
+ delete(b.MutedChannels, treeItem.Channel.ID)
+ }
+
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
+ }
+ if key == *b.Hotkeys.VolumeDown {
+ b.changeVolume(makeUsersArray(treeItem.Channel.Users), -0.1)
+ }
+ if key == *b.Hotkeys.VolumeUp {
+ b.changeVolume(makeUsersArray(treeItem.Channel.Users), 0.1)
+ }
+ if key == *b.Hotkeys.VolumeReset {
+ b.resetVolume(makeUsersArray(treeItem.Channel.Users))
+ }
+ }
+
+ if treeItem.User != nil {
+ if key == *b.Hotkeys.MuteToggle {
+ // Toggle mute for single user
+ b.UserConfig.ToggleMute(treeItem.User)
+ if treeItem.User.AudioSource != nil {
+ if treeItem.User.LocallyMuted {
+ treeItem.User.AudioSource.SetGain(0)
+ } else {
+ treeItem.User.AudioSource.SetGain(treeItem.User.Volume)
+ }
+ }
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
+ }
+ if key == *b.Hotkeys.VolumeDown {
+ b.changeVolume([]*gumble.User{treeItem.User}, -0.1)
+ }
+ if key == *b.Hotkeys.VolumeUp {
+ b.changeVolume([]*gumble.User{treeItem.User}, 0.1)
+ }
+ if key == *b.Hotkeys.VolumeReset {
+ b.resetVolume([]*gumble.User{treeItem.User})
+ }
+ }
}
diff --git a/client.go b/client.go
index 5ffed9a..7e28510 100644
--- a/client.go
+++ b/client.go
@@ -1,246 +1,261 @@
package main
import (
- "fmt"
- "net"
- "time"
+ "fmt"
+ "net"
+ "time"
- "git.stormux.org/storm/barnard/fileplayback"
- "git.stormux.org/storm/barnard/gumble/gumble"
- "git.stormux.org/storm/barnard/gumble/gumbleopenal"
- "git.stormux.org/storm/barnard/gumble/gumbleutil"
- "git.stormux.org/storm/barnard/gumble/opus"
+ "git.stormux.org/storm/barnard/fileplayback"
+ "git.stormux.org/storm/barnard/gumble/gumble"
+ "git.stormux.org/storm/barnard/gumble/gumbleopenal"
+ "git.stormux.org/storm/barnard/gumble/gumbleutil"
+ "git.stormux.org/storm/barnard/gumble/opus"
)
func (b *Barnard) start() {
- b.Config.Attach(gumbleutil.AutoBitrate)
- b.Config.Attach(b)
- b.Config.Address = b.Address
- // test Audio
- _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
- if err != nil {
- b.exitWithError(err)
- return
- }
- //connect, not reconnect
- b.connect(false)
+ b.Config.Attach(gumbleutil.AutoBitrate)
+ b.Config.Attach(b)
+ b.Config.Address = b.Address
+ // test Audio
+ _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
+ if err != nil {
+ b.exitWithError(err)
+ return
+ }
+ //connect, not reconnect
+ b.connect(false)
}
func (b *Barnard) exitWithError(err error) {
- b.Ui.Close()
- b.exitStatus = 1
- b.exitMessage = err.Error()
+ b.Ui.Close()
+ b.exitStatus = 1
+ b.exitMessage = err.Error()
}
func (b *Barnard) connect(reconnect bool) bool {
- var err error
- _, err = gumble.DialWithDialer(new(net.Dialer), b.Config, &b.TLSConfig)
- if err != nil {
- if reconnect {
- b.Log(err.Error())
- } else {
- b.exitWithError(err)
- }
- return false
- }
+ var err error
+ _, err = gumble.DialWithDialer(new(net.Dialer), b.Config, &b.TLSConfig)
+ if err != nil {
+ if reconnect {
+ b.Log(err.Error())
+ } else {
+ b.exitWithError(err)
+ }
+ return false
+ }
- stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
- if err != nil {
- b.exitWithError(err)
- return false
- }
- b.Stream = stream
- b.Stream.AttachStream(b.Client)
- b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
- b.Stream.SetEffectsProcessor(b.VoiceEffects)
+ stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
+ if err != nil {
+ b.exitWithError(err)
+ return false
+ }
+ b.Stream = stream
+ b.Stream.AttachStream(b.Client)
+ b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
+ b.Stream.SetEffectsProcessor(b.VoiceEffects)
- // Initialize stereo encoder for file playback
- b.Client.AudioEncoderStereo = opus.NewStereoEncoder()
+ // Initialize stereo encoder for file playback
+ b.Client.AudioEncoderStereo = opus.NewStereoEncoder()
- // Initialize file player
- b.FileStreamMutex.Lock()
- b.FileStream = fileplayback.New(b.Client)
- b.FileStream.SetErrorFunc(func(err error) {
- // Disable stereo when file finishes or errors
- b.Client.DisableStereoEncoder()
- b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
- })
- b.Stream.SetFilePlayer(b.FileStream)
- b.FileStreamMutex.Unlock()
+ // Initialize file player
+ b.FileStreamMutex.Lock()
+ b.FileStream = fileplayback.New(b.Client)
+ b.FileStream.SetErrorFunc(func(err error) {
+ // Disable stereo when file finishes or errors
+ b.Client.DisableStereoEncoder()
+ b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
+ })
+ b.Stream.SetFilePlayer(b.FileStream)
+ b.FileStreamMutex.Unlock()
- b.Connected = true
- return true
+ b.Connected = true
+ return true
}
func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
- b.Client = e.Client
+ b.Client = e.Client
- // Reset muted channels state on connect
- b.MutedChannels = make(map[uint32]bool)
+ // 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()
- b.Ui.Refresh()
+ b.Ui.SetActive(uiViewInput)
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
- for _, u := range b.Client.Users {
- b.UserConfig.UpdateUser(u)
- }
+ for _, u := range b.Client.Users {
+ b.UserConfig.UpdateUser(u)
+ }
- b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name))
- b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr()))
- wmsg := ""
- if e.WelcomeMessage != nil {
- wmsg = esc(*e.WelcomeMessage)
- }
- b.Notify("connect", "me", wmsg)
- if wmsg != "" {
- b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
- }
- b.Ui.Refresh()
+ b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name))
+ b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr()))
+ wmsg := ""
+ if e.WelcomeMessage != nil {
+ wmsg = esc(*e.WelcomeMessage)
+ }
+ b.Notify("connect", "me", wmsg)
+ if wmsg != "" {
+ b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
+ }
+ b.Ui.Refresh()
}
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
- var reason string
- switch e.Type {
- case gumble.DisconnectError:
- reason = "connection error"
- }
- b.Notify("disconnect", "me", reason)
- if reason == "" {
- b.AddOutputLine("Disconnected")
- } else {
- b.AddOutputLine("Disconnected: " + reason)
- }
- b.Tx = false
- b.Connected = false
- b.UiTree.Rebuild()
- b.Ui.Refresh()
- go b.reconnectGoroutine()
+ var reason string
+ 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")
+ } else {
+ b.AddOutputLine("Disconnected: " + reason)
+ }
+ b.Tx = false
+ b.Connected = false
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
+ go b.reconnectGoroutine()
}
func (b *Barnard) reconnectGoroutine() {
- for {
- res := b.connect(true)
- if res == true {
- break
- }
- time.Sleep(15 * time.Second)
- }
+ for {
+ res := b.connect(true)
+ if res == true {
+ break
+ }
+ time.Sleep(15 * time.Second)
+ }
}
func (b *Barnard) Log(s string) {
- b.AddOutputMessage(nil, s)
+ b.AddOutputMessage(nil, s)
}
func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) {
- var public = false
- for _, c := range e.Channels {
- if c.Name == b.Client.Self.Channel.Name {
- public = true
- break
- }
- }
- if public {
- b.Notify("msg", e.Sender.Name, e.Message)
- b.AddOutputMessage(e.Sender, e.Message)
- } else {
- var sender string
- if e.Sender == nil {
- sender = "Server"
- } else {
- sender = e.Sender.Name
- }
- b.Notify("pm", sender, e.Message)
- b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message)
- }
+ var public = false
+ for _, c := range e.Channels {
+ if c.Name == b.Client.Self.Channel.Name {
+ public = true
+ break
+ }
+ }
+ if public {
+ b.Notify("msg", e.Sender.Name, e.Message)
+ b.AddOutputMessage(e.Sender, e.Message)
+ } else {
+ var sender string
+ if e.Sender == nil {
+ sender = "Server"
+ } else {
+ sender = e.Sender.Name
+ }
+ b.Notify("pm", sender, e.Message)
+ b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message)
+ }
}
func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
- if e.User != nil {
- b.UserConfig.UpdateUser(e.User)
-
- // Check if user is joining a muted channel
- if e.Type.Has(gumble.UserChangeConnected) || e.Type.Has(gumble.UserChangeChannel) {
- // If the channel is muted, ensure the user is muted
- if b.MutedChannels[e.User.Channel.ID] {
- // Only mute if not already muted
- if !e.User.LocallyMuted {
- b.UserConfig.ToggleMute(e.User)
- }
- if e.User.AudioSource != nil {
- e.User.AudioSource.SetGain(0)
- }
- }
- }
- }
+ if e.User != nil {
+ b.UserConfig.UpdateUser(e.User)
- var s = "unknown"
- var t = "unknown"
- if e.Type.Has(gumble.UserChangeConnected) {
- s = "joined"
- t = "join"
- // Notify about users joining our channel
- if e.User.Channel.Name == b.Client.Self.Channel.Name {
- b.Notify(t, e.User.Name, e.User.Channel.Name)
- b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
- }
- }
- if e.Type.Has(gumble.UserChangeDisconnected) {
- s = "left"
- t = "leave"
- if e.User == b.selectedUser {
- b.SetSelectedUser(nil)
- }
- // Always notify about disconnects if user has channel info and was in our channel
- if e.User.Channel != nil && e.User.Channel.Name == b.Client.Self.Channel.Name {
- b.Notify(t, e.User.Name, e.User.Channel.Name)
- b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
- }
- }
- if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
- b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
- }
- b.UiTree.Rebuild()
- b.Ui.Refresh()
+ // Check if user is joining a muted channel
+ if e.Type.Has(gumble.UserChangeConnected) || e.Type.Has(gumble.UserChangeChannel) {
+ // If the channel is muted, ensure the user is muted
+ if b.MutedChannels[e.User.Channel.ID] {
+ // Only mute if not already muted
+ if !e.User.LocallyMuted {
+ b.UserConfig.ToggleMute(e.User)
+ }
+ if e.User.AudioSource != nil {
+ e.User.AudioSource.SetGain(0)
+ }
+ }
+ }
+ }
+
+ var s = "unknown"
+ var t = "unknown"
+ if e.Type.Has(gumble.UserChangeConnected) {
+ s = "joined"
+ t = "join"
+ // Notify about users joining our channel
+ if e.User.Channel.Name == b.Client.Self.Channel.Name {
+ b.Notify(t, e.User.Name, e.User.Channel.Name)
+ b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
+ }
+ }
+ if e.Type.Has(gumble.UserChangeDisconnected) {
+ s = "left"
+ t = "leave"
+ if e.User == b.selectedUser {
+ b.SetSelectedUser(nil)
+ }
+ // Always notify about disconnects if user has channel info and was in our channel
+ if e.User.Channel != nil && e.User.Channel.Name == b.Client.Self.Channel.Name {
+ b.Notify(t, e.User.Name, e.User.Channel.Name)
+ b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
+ }
+ }
+ 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()
}
func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
- b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
- b.UiTree.Rebuild()
- b.Ui.Refresh()
+ b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
}
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
- var info string
- switch e.Type {
- case gumble.PermissionDeniedOther:
- info = e.String
- case gumble.PermissionDeniedPermission:
- info = "insufficient permissions"
- case gumble.PermissionDeniedSuperUser:
- info = "cannot modify SuperUser"
- case gumble.PermissionDeniedInvalidChannelName:
- info = "invalid channel name"
- case gumble.PermissionDeniedTextTooLong:
- info = "text too long"
- case gumble.PermissionDeniedTemporaryChannel:
- info = "temporary channel"
- case gumble.PermissionDeniedMissingCertificate:
- info = "missing certificate"
- case gumble.PermissionDeniedInvalidUserName:
- info = "invalid user name"
- case gumble.PermissionDeniedChannelFull:
- info = "channel full"
- case gumble.PermissionDeniedNestingLimit:
- info = "nesting limit"
- }
- b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info))
+ var info string
+ switch e.Type {
+ case gumble.PermissionDeniedOther:
+ info = e.String
+ case gumble.PermissionDeniedPermission:
+ info = "insufficient permissions"
+ case gumble.PermissionDeniedSuperUser:
+ info = "cannot modify SuperUser"
+ case gumble.PermissionDeniedInvalidChannelName:
+ info = "invalid channel name"
+ case gumble.PermissionDeniedTextTooLong:
+ info = "text too long"
+ case gumble.PermissionDeniedTemporaryChannel:
+ info = "temporary channel"
+ case gumble.PermissionDeniedMissingCertificate:
+ info = "missing certificate"
+ case gumble.PermissionDeniedInvalidUserName:
+ info = "invalid user name"
+ case gumble.PermissionDeniedChannelFull:
+ info = "channel full"
+ case gumble.PermissionDeniedNestingLimit:
+ info = "nesting limit"
+ }
+ b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info))
}
func (b *Barnard) OnUserList(e *gumble.UserListEvent) {
- //for _,u := range e.UserList {
- //b.UserConfig.UpdateUser(u)
- //}
+ //for _,u := range e.UserList {
+ //b.UserConfig.UpdateUser(u)
+ //}
}
func (b *Barnard) OnACL(e *gumble.ACLEvent) {
@@ -253,4 +268,5 @@ func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
}
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
+ b.HandleRecordingAllowed(e.RecordingAllowed)
}
diff --git a/config/hotkey_config.go b/config/hotkey_config.go
index 2f53c8d..b4c7713 100644
--- a/config/hotkey_config.go
+++ b/config/hotkey_config.go
@@ -1,23 +1,24 @@
package config
import (
- "git.stormux.org/storm/barnard/uiterm"
+ "git.stormux.org/storm/barnard/uiterm"
)
type Hotkeys struct {
- Talk *uiterm.Key
- VolumeDown *uiterm.Key
- VolumeUp *uiterm.Key
- VolumeReset *uiterm.Key
- MuteToggle *uiterm.Key
- Exit *uiterm.Key
- ToggleTimestamps *uiterm.Key
- SwitchViews *uiterm.Key
- ClearOutput *uiterm.Key
- ScrollUp *uiterm.Key
- ScrollDown *uiterm.Key
- ScrollToTop *uiterm.Key
- ScrollToBottom *uiterm.Key
- NoiseSuppressionToggle *uiterm.Key
- CycleVoiceEffect *uiterm.Key
+ Talk *uiterm.Key
+ VolumeDown *uiterm.Key
+ VolumeUp *uiterm.Key
+ VolumeReset *uiterm.Key
+ MuteToggle *uiterm.Key
+ RecordToggle *uiterm.Key
+ Exit *uiterm.Key
+ ToggleTimestamps *uiterm.Key
+ SwitchViews *uiterm.Key
+ ClearOutput *uiterm.Key
+ ScrollUp *uiterm.Key
+ ScrollDown *uiterm.Key
+ ScrollToTop *uiterm.Key
+ ScrollToBottom *uiterm.Key
+ NoiseSuppressionToggle *uiterm.Key
+ CycleVoiceEffect *uiterm.Key
}
diff --git a/config/user_config.go b/config/user_config.go
index 3a8704a..a4e6e7e 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -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
diff --git a/config/user_config_test.go b/config/user_config_test.go
new file mode 100644
index 0000000..1be6721
--- /dev/null
+++ b/config/user_config_test.go
@@ -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)
+ }
+}
diff --git a/gumble/gumble/MumbleProto/Mumble.pb.go b/gumble/gumble/MumbleProto/Mumble.pb.go
index 344ebbe..1a0fcf6 100644
--- a/gumble/gumble/MumbleProto/Mumble.pb.go
+++ b/gumble/gumble/MumbleProto/Mumble.pb.go
@@ -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
@@ -2070,8 +2072,10 @@ type ServerConfig struct {
// Maximum image message length.
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"`
- XXX_unrecognized []byte `json:"-"`
+ 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:"-"`
}
func (m *ServerConfig) Reset() { *m = ServerConfig{} }
@@ -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 {
diff --git a/gumble/gumble/event.go b/gumble/gumble/event.go
index f19c3c1..281b61c 100644
--- a/gumble/gumble/event.go
+++ b/gumble/gumble/event.go
@@ -210,6 +210,7 @@ type ServerConfigEvent struct {
MaximumMessageLength *int
MaximumImageMessageLength *int
MaximumUsers *int
+ RecordingAllowed *bool
CodecAlpha *int32
CodecBeta *int32
diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go
index 94f82d0..f53cb35 100644
--- a/gumble/gumble/handlers.go
+++ b/gumble/gumble/handlers.go
@@ -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 {
diff --git a/gumble/gumble/server_config_test.go b/gumble/gumble/server_config_test.go
new file mode 100644
index 0000000..b3f60ba
--- /dev/null
+++ b/gumble/gumble/server_config_test.go
@@ -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")
+ }
+}
diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go
index f075cd5..114eb2e 100644
--- a/gumble/gumbleopenal/stream.go
+++ b/gumble/gumbleopenal/stream.go
@@ -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,14 +516,31 @@ 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) {
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
}
diff --git a/recording/recorder.go b/recording/recorder.go
new file mode 100644
index 0000000..5346c01
--- /dev/null
+++ b/recording/recorder.go
@@ -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)
+}
diff --git a/recording/recorder_test.go b/recording/recorder_test.go
new file mode 100644
index 0000000..130d522
--- /dev/null
+++ b/recording/recorder_test.go
@@ -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])
+ }
+ }
+}
diff --git a/recording_control.go b/recording_control.go
new file mode 100644
index 0000000..45c1a69
--- /dev/null
+++ b/recording_control.go
@@ -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))
+ }
+ }
+}
diff --git a/ui.go b/ui.go
index 9dc0ebd..deee74a 100644
--- a/ui.go
+++ b/ui.go
@@ -1,494 +1,498 @@
package main
import (
- "fmt"
- "os"
- "os/exec"
- "strings"
- "time"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
- "git.stormux.org/storm/barnard/gumble/gumble"
- "git.stormux.org/storm/barnard/uiterm"
- "github.com/kennygrant/sanitize"
- "github.com/nsf/termbox-go"
+ "git.stormux.org/storm/barnard/gumble/gumble"
+ "git.stormux.org/storm/barnard/uiterm"
+ "github.com/kennygrant/sanitize"
+ "github.com/nsf/termbox-go"
)
const (
- uiViewLogo = "logo"
- uiViewTop = "top"
- uiViewStatus = "status"
- uiViewInput = "input"
- uiViewInputStatus = "inputstatus"
- uiViewOutput = "output"
- uiViewTree = "tree"
+ uiViewLogo = "logo"
+ uiViewTop = "top"
+ uiViewStatus = "status"
+ uiViewInput = "input"
+ uiViewInputStatus = "inputstatus"
+ uiViewOutput = "output"
+ uiViewTree = "tree"
)
func Beep() {
- cmd := exec.Command("beep")
- cmdout, err := cmd.Output()
- if err != nil {
- panic(err)
- }
- if cmdout != nil {
- }
+ cmd := exec.Command("beep")
+ cmdout, err := cmd.Output()
+ if err != nil {
+ panic(err)
+ }
+ if cmdout != nil {
+ }
}
func esc(str string) string {
- return sanitize.HTML(str)
+ return sanitize.HTML(str)
}
func (b *Barnard) Notify(event string, who string, what string) {
- b.notifyChannel <- []string{event, who, what}
+ b.notifyChannel <- []string{event, who, what}
}
func (b *Barnard) Beep() {
- Beep()
+ Beep()
}
func (b *Barnard) SetSelectedUser(user *gumble.User) {
- b.selectedUser = user
- if user == nil {
- if len(b.UiInput.Text) > 0 {
- }
- b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name))
- } else {
- b.UpdateInputStatus(fmt.Sprintf("[@%s]", user.Name))
- }
+ b.selectedUser = user
+ if user == nil {
+ if len(b.UiInput.Text) > 0 {
+ }
+ b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name))
+ } else {
+ b.UpdateInputStatus(fmt.Sprintf("[@%s]", user.Name))
+ }
}
func (b *Barnard) GetInputStatus() string {
- return b.UiInputStatus.Text
+ return b.UiInputStatus.Text
}
func (b *Barnard) UpdateInputStatus(status string) {
- if len(status) > 20 {
- status = status[:17] + "..." + "]"
- }
- b.UiInputStatus.Text = status
- b.UiTree.Rebuild()
- b.Ui.Refresh()
+ if len(status) > 20 {
+ status = status[:17] + "..." + "]"
+ }
+ b.UiInputStatus.Text = status
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
}
func (b *Barnard) AddOutputLine(line string) {
- now := time.Now()
- b.UiOutput.AddLine(fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second()))
+ now := time.Now()
+ b.UiOutput.AddLine(fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second()))
}
func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
- if sender == nil {
- b.AddOutputLine(message)
- } else {
- b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name, strings.TrimSpace(esc(message))))
- }
+ if sender == nil {
+ b.AddOutputLine(message)
+ } else {
+ b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name, strings.TrimSpace(esc(message))))
+ }
}
func (b *Barnard) AddOutputPrivateMessage(source *gumble.User, dest *gumble.User, message string) {
- var sender string
- if source == nil {
- sender = "Server"
- } else {
- sender = source.Name
- }
- b.AddOutputLine(fmt.Sprintf("pm/%s/%s: %s", sender, dest.Name, strings.TrimSpace(esc(message))))
+ var sender string
+ if source == nil {
+ sender = "Server"
+ } else {
+ sender = source.Name
+ }
+ b.AddOutputLine(fmt.Sprintf("pm/%s/%s: %s", sender, dest.Name, strings.TrimSpace(esc(message))))
}
func (b *Barnard) OnTimestampToggle(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.ToggleTimestamps()
+ b.UiOutput.ToggleTimestamps()
}
func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
- enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
- b.UserConfig.SetNoiseSuppressionEnabled(enabled)
- b.NoiseSuppressor.SetEnabled(enabled)
+ enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
+ b.UserConfig.SetNoiseSuppressionEnabled(enabled)
+ b.NoiseSuppressor.SetEnabled(enabled)
- if enabled {
- b.UpdateGeneralStatus("Noise suppression: ON", false)
- } else {
- b.UpdateGeneralStatus("Noise suppression: OFF", false)
- }
+ if enabled {
+ b.UpdateGeneralStatus("Noise suppression: ON", false)
+ } else {
+ b.UpdateGeneralStatus("Noise suppression: OFF", false)
+ }
}
func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) {
- effect := b.VoiceEffects.CycleEffect()
- b.UserConfig.SetVoiceEffect(int(effect))
- b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false)
+ effect := b.VoiceEffects.CycleEffect()
+ b.UserConfig.SetVoiceEffect(int(effect))
+ 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
- b.UiStatus.Bg = uiterm.ColorRed
- } else {
- b.UiStatus.Fg = uiterm.ColorBlack
- b.UiStatus.Bg = uiterm.ColorWhite
- }
- b.UiStatus.Text = text
- b.Ui.Refresh()
+ if notice {
+ b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
+ b.UiStatus.Bg = uiterm.ColorRed
+ } else {
+ b.UiStatus.Fg = uiterm.ColorBlack
+ b.UiStatus.Bg = uiterm.ColorWhite
+ }
+ b.UiStatus.Text = text
+ b.Ui.Refresh()
}
func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) {
- b.setTransmit(ui, 2)
+ b.setTransmit(ui, 2)
}
func (b *Barnard) CommandLog(ui *uiterm.Ui, cmd string) {
- b.AddOutputLine("command " + cmd)
+ b.AddOutputLine("command " + cmd)
}
func (b *Barnard) CommandTalk(ui *uiterm.Ui, cmd string) {
- b.setTransmit(ui, 2)
+ b.setTransmit(ui, 2)
}
func (b *Barnard) CommandMicUp(ui *uiterm.Ui, cmd string) {
- b.setTransmit(ui, 1)
+ b.setTransmit(ui, 1)
}
func (b *Barnard) CommandMicDown(ui *uiterm.Ui, cmd string) {
- b.setTransmit(ui, 0)
+ b.setTransmit(ui, 0)
}
func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
- enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
- b.UserConfig.SetNoiseSuppressionEnabled(enabled)
- b.NoiseSuppressor.SetEnabled(enabled)
+ enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
+ b.UserConfig.SetNoiseSuppressionEnabled(enabled)
+ b.NoiseSuppressor.SetEnabled(enabled)
- if enabled {
- b.AddOutputLine("Noise suppression enabled")
- } else {
- b.AddOutputLine("Noise suppression disabled")
- }
+ if enabled {
+ b.AddOutputLine("Noise suppression enabled")
+ } else {
+ b.AddOutputLine("Noise suppression disabled")
+ }
}
func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
- // cmd contains just the filename part (everything after "/file ")
- filename := strings.TrimSpace(cmd)
- if filename == "" {
- b.AddOutputLine("Usage: /file ")
- return
- }
+ // cmd contains just the filename part (everything after "/file ")
+ filename := strings.TrimSpace(cmd)
+ if filename == "" {
+ b.AddOutputLine("Usage: /file ")
+ return
+ }
- // Check if it's a URL
- isURL := strings.HasPrefix(filename, "http://") ||
- strings.HasPrefix(filename, "https://") ||
- strings.HasPrefix(filename, "ftp://") ||
- strings.HasPrefix(filename, "rtmp://")
+ // Check if it's a URL
+ isURL := strings.HasPrefix(filename, "http://") ||
+ strings.HasPrefix(filename, "https://") ||
+ strings.HasPrefix(filename, "ftp://") ||
+ strings.HasPrefix(filename, "rtmp://")
- if !isURL {
- // Expand ~ to home directory for local files
- if strings.HasPrefix(filename, "~") {
- homeDir := os.Getenv("HOME")
- filename = strings.Replace(filename, "~", homeDir, 1)
- }
+ if !isURL {
+ // Expand ~ to home directory for local files
+ if strings.HasPrefix(filename, "~") {
+ homeDir := os.Getenv("HOME")
+ filename = strings.Replace(filename, "~", homeDir, 1)
+ }
- // Check if local file exists
- if _, err := os.Stat(filename); os.IsNotExist(err) {
- b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
- return
- }
- }
+ // Check if local file exists
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
+ return
+ }
+ }
- if !b.Connected {
- b.AddOutputLine("Not connected to server")
- return
- }
+ if !b.Connected {
+ b.AddOutputLine("Not connected to server")
+ return
+ }
- b.FileStreamMutex.Lock()
- defer b.FileStreamMutex.Unlock()
+ b.FileStreamMutex.Lock()
+ defer b.FileStreamMutex.Unlock()
- if b.FileStream != nil && b.FileStream.IsPlaying() {
- b.AddOutputLine("Already playing a file. Use /stop first.")
- return
- }
+ if b.FileStream != nil && b.FileStream.IsPlaying() {
+ b.AddOutputLine("Already playing a file. Use /stop first.")
+ return
+ }
- err := b.FileStream.PlayFile(filename)
- if err != nil {
- b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
- return
- }
+ err := b.FileStream.PlayFile(filename)
+ if err != nil {
+ b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
+ return
+ }
- // Enable stereo encoder for file playback
- b.Client.EnableStereoEncoder()
+ // Enable stereo encoder for file playback
+ b.Client.EnableStereoEncoder()
- // Auto-start transmission if not already transmitting
- if !b.Tx {
- err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
- if err != nil {
- b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error()))
- b.FileStream.Stop()
- b.Client.DisableStereoEncoder()
- return
- }
- b.Tx = true
- b.UpdateGeneralStatus(" File ", true)
- }
+ // Auto-start transmission if not already transmitting
+ if !b.Tx {
+ err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
+ if err != nil {
+ b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error()))
+ b.FileStream.Stop()
+ b.Client.DisableStereoEncoder()
+ return
+ }
+ b.Tx = true
+ b.UpdateGeneralStatus(" File ", true)
+ }
- if isURL {
- b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
- } else {
- b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
- }
+ if isURL {
+ b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
+ } else {
+ b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
+ }
}
func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
- b.FileStreamMutex.Lock()
- defer b.FileStreamMutex.Unlock()
+ b.FileStreamMutex.Lock()
+ defer b.FileStreamMutex.Unlock()
- if b.FileStream == nil || !b.FileStream.IsPlaying() {
- b.AddOutputLine("No file playing")
- return
- }
+ if b.FileStream == nil || !b.FileStream.IsPlaying() {
+ b.AddOutputLine("No file playing")
+ return
+ }
- err := b.FileStream.Stop()
- if err != nil {
- b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
- return
- }
+ err := b.FileStream.Stop()
+ if err != nil {
+ b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
+ return
+ }
- // Disable stereo encoder when file stops
- b.Client.DisableStereoEncoder()
+ // Disable stereo encoder when file stops
+ b.Client.DisableStereoEncoder()
- b.AddOutputLine("File playback stopped")
+ b.AddOutputLine("File playback stopped")
- // Note: We keep transmission active even after file stops
- // User can manually stop with talk key or it will stop when they're done talking
- b.UpdateGeneralStatus(" Idle ", false)
+ // Note: We keep transmission active even after file stops
+ // User can manually stop with talk key or it will stop when they're done talking
+ b.UpdateGeneralStatus(" Idle ", false)
}
-
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
- if b.Tx && val == 1 {
- return
- }
- if b.Tx == false && val == 0 {
- return
- }
- if b.Tx {
- b.Notify("micdown", "me", "")
- b.Tx = false
- b.UpdateGeneralStatus(" Idle ", false)
- b.Stream.StopSource()
- } else if b.Connected == false {
- b.Notify("error", "me", "no tx while disconnected")
- b.Tx = false
- b.UpdateGeneralStatus("no tx while disconnected", true)
- } else if b.MutedChannels[b.Client.Self.Channel.ID] {
- // Check if current channel is muted
- b.Notify("error", "me", "cannot transmit in muted channel")
- b.Tx = false
- b.UpdateGeneralStatus("cannot transmit in muted channel", true)
- } else {
- b.Tx = true
- err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
- if err != nil {
- b.Notify("error", "me", err.Error())
- b.UpdateGeneralStatus(err.Error(), true)
- } else {
- b.Notify("micup", "me", "")
- b.UpdateGeneralStatus(" Tx ", true)
- }
- }
+ if b.Tx && val == 1 {
+ return
+ }
+ if b.Tx == false && val == 0 {
+ return
+ }
+ if b.Tx {
+ b.Notify("micdown", "me", "")
+ b.Tx = false
+ b.UpdateGeneralStatus(" Idle ", false)
+ b.Stream.StopSource()
+ } else if b.Connected == false {
+ b.Notify("error", "me", "no tx while disconnected")
+ b.Tx = false
+ b.UpdateGeneralStatus("no tx while disconnected", true)
+ } else if b.MutedChannels[b.Client.Self.Channel.ID] {
+ // Check if current channel is muted
+ b.Notify("error", "me", "cannot transmit in muted channel")
+ b.Tx = false
+ b.UpdateGeneralStatus("cannot transmit in muted channel", true)
+ } else {
+ b.Tx = true
+ err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
+ if err != nil {
+ b.Notify("error", "me", err.Error())
+ b.UpdateGeneralStatus(err.Error(), true)
+ } else {
+ b.Notify("micup", "me", "")
+ b.UpdateGeneralStatus(" Tx ", true)
+ }
+ }
}
func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) {
- b.Stream.SetMicVolume(-0.1, true)
- b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
+ b.Stream.SetMicVolume(-0.1, true)
+ b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
}
func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
- b.Stream.SetMicVolume(0.1, true)
- b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
+ b.Stream.SetMicVolume(0.1, true)
+ b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
}
func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) {
- b.Client.Disconnect()
- b.Ui.Close()
+ b.StopRecordingIfActive(true)
+ b.Client.Disconnect()
+ b.Ui.Close()
}
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
- b.Client.Disconnect()
- b.Ui.Close()
+ b.StopRecordingIfActive(true)
+ b.Client.Disconnect()
+ b.Ui.Close()
}
func (b *Barnard) CommandStatus(ui *uiterm.Ui, cmd string) {
- if b.Tx {
- b.Notify("status", "me", "transmitting")
- } else {
- b.Notify("status", "me", "not transmitting")
- }
+ if b.Tx {
+ b.Notify("status", "me", "transmitting")
+ } else {
+ b.Notify("status", "me", "not transmitting")
+ }
}
func (b *Barnard) OnClearPress(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.Clear()
+ b.UiOutput.Clear()
}
func (b *Barnard) OnScrollOutputUp(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.ScrollUp()
+ b.UiOutput.ScrollUp()
}
func (b *Barnard) OnScrollOutputDown(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.ScrollDown()
+ b.UiOutput.ScrollDown()
}
func (b *Barnard) OnScrollOutputTop(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.ScrollTop()
+ b.UiOutput.ScrollTop()
}
func (b *Barnard) OnScrollOutputBottom(ui *uiterm.Ui, key uiterm.Key) {
- b.UiOutput.ScrollBottom()
+ b.UiOutput.ScrollBottom()
}
func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
- active := b.Ui.Active()
- if active == uiViewInput {
- b.Ui.SetActive(uiViewTree)
- } else if active == uiViewTree {
- b.Ui.SetActive(uiViewInput)
- }
- width, height := termbox.Size()
- b.OnUiResize(ui, width, height)
- ui.Refresh()
+ active := b.Ui.Active()
+ if active == uiViewInput {
+ b.Ui.SetActive(uiViewTree)
+ } else if active == uiViewTree {
+ b.Ui.SetActive(uiViewInput)
+ }
+ width, height := termbox.Size()
+ b.OnUiResize(ui, width, height)
+ ui.Refresh()
}
func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) {
- if text == "" {
- return
- }
+ if text == "" {
+ return
+ }
- // Check if this is a command (starts with /)
- if strings.HasPrefix(text, "/") {
- // Remove the leading slash and process as command
- cmdText := strings.TrimPrefix(text, "/")
- parts := strings.SplitN(cmdText, " ", 2)
- cmdName := parts[0]
- cmdArgs := ""
- if len(parts) > 1 {
- cmdArgs = parts[1]
- }
+ // Check if this is a command (starts with /)
+ if strings.HasPrefix(text, "/") {
+ // Remove the leading slash and process as command
+ cmdText := strings.TrimPrefix(text, "/")
+ parts := strings.SplitN(cmdText, " ", 2)
+ cmdName := parts[0]
+ cmdArgs := ""
+ if len(parts) > 1 {
+ cmdArgs = parts[1]
+ }
- // Handle built-in commands
- switch cmdName {
- case "file":
- b.CommandPlayFile(ui, cmdArgs)
- case "stop":
- b.CommandStopFile(ui, cmdArgs)
- case "exit":
- b.CommandExit(ui, cmdArgs)
- case "status":
- b.CommandStatus(ui, cmdArgs)
- case "noise":
- b.CommandNoiseSuppressionToggle(ui, cmdArgs)
- case "micup":
- b.CommandMicUp(ui, cmdArgs)
- case "micdown":
- b.CommandMicDown(ui, cmdArgs)
- case "toggle", "talk":
- b.CommandTalk(ui, cmdArgs)
- default:
- b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
- }
- return
- }
+ // Handle built-in commands
+ switch cmdName {
+ case "file":
+ b.CommandPlayFile(ui, cmdArgs)
+ case "stop":
+ b.CommandStopFile(ui, cmdArgs)
+ case "exit":
+ b.CommandExit(ui, cmdArgs)
+ case "status":
+ b.CommandStatus(ui, cmdArgs)
+ case "noise":
+ b.CommandNoiseSuppressionToggle(ui, cmdArgs)
+ case "record":
+ b.CommandRecord(ui, cmdArgs)
+ case "micup":
+ b.CommandMicUp(ui, cmdArgs)
+ case "micdown":
+ b.CommandMicDown(ui, cmdArgs)
+ case "toggle", "talk":
+ b.CommandTalk(ui, cmdArgs)
+ default:
+ b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
+ }
+ return
+ }
- // Not a command, send as chat message
- if b.Client != nil && b.Client.Self != nil {
- if b.selectedUser != nil {
- b.selectedUser.Send(text)
- b.AddOutputPrivateMessage(b.Client.Self, b.selectedUser, text)
- } else {
- b.Client.Self.Channel.Send(text, false)
- b.AddOutputMessage(b.Client.Self, text)
- }
- }
+ // Not a command, send as chat message
+ if b.Client != nil && b.Client.Self != nil {
+ if b.selectedUser != nil {
+ b.selectedUser.Send(text)
+ b.AddOutputPrivateMessage(b.Client.Self, b.selectedUser, text)
+ } else {
+ b.Client.Self.Channel.Send(text, false)
+ b.AddOutputMessage(b.Client.Self, text)
+ }
+ }
}
func (b *Barnard) GotoChat() {
- b.OnFocusPress(b.Ui, uiterm.KeyTab)
+ b.OnFocusPress(b.Ui, uiterm.KeyTab)
}
func (b *Barnard) OnUiDoneInitialize(ui *uiterm.Ui) {
- b.start()
+ b.start()
}
func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
- ui.Add(uiViewLogo, &uiterm.Label{
- Text: "Barnard ",
- Fg: uiterm.ColorWhite | uiterm.AttrBold,
- Bg: uiterm.ColorMagenta,
- })
+ ui.Add(uiViewLogo, &uiterm.Label{
+ Text: "Barnard ",
+ Fg: uiterm.ColorWhite | uiterm.AttrBold,
+ Bg: uiterm.ColorMagenta,
+ })
- b.UiStatus = uiterm.Label{
- Text: " Idle ",
- Fg: uiterm.ColorBlack,
- Bg: uiterm.ColorWhite,
- }
- ui.Add(uiViewStatus, &b.UiStatus)
+ b.UiStatus = uiterm.Label{
+ Text: " Idle ",
+ Fg: uiterm.ColorBlack,
+ Bg: uiterm.ColorWhite,
+ }
+ ui.Add(uiViewStatus, &b.UiStatus)
- b.UiInput = uiterm.Textbox{
- Fg: uiterm.ColorWhite,
- Bg: uiterm.ColorBlack,
- Input: b.OnTextInput,
- }
- ui.Add(uiViewInput, &b.UiInput)
+ b.UiInput = uiterm.Textbox{
+ Fg: uiterm.ColorWhite,
+ Bg: uiterm.ColorBlack,
+ Input: b.OnTextInput,
+ }
+ ui.Add(uiViewInput, &b.UiInput)
- b.UiInputStatus = uiterm.Label{
- Fg: uiterm.ColorBlack,
- Bg: uiterm.ColorWhite,
- }
- ui.Add(uiViewInputStatus, &b.UiInputStatus)
+ b.UiInputStatus = uiterm.Label{
+ Fg: uiterm.ColorBlack,
+ Bg: uiterm.ColorWhite,
+ }
+ ui.Add(uiViewInputStatus, &b.UiInputStatus)
- b.UiOutput = uiterm.Textview{
- Fg: uiterm.ColorWhite,
- Bg: uiterm.ColorBlack,
- }
- ui.Add(uiViewOutput, &b.UiOutput)
+ b.UiOutput = uiterm.Textview{
+ Fg: uiterm.ColorWhite,
+ Bg: uiterm.ColorBlack,
+ }
+ ui.Add(uiViewOutput, &b.UiOutput)
- b.UiTree = uiterm.Tree{
- Generator: b.TreeItemBuild,
- KeyListener: b.TreeItemKeyPress,
- CharacterListener: b.TreeItemCharacter,
- Fg: uiterm.ColorWhite,
- Bg: uiterm.ColorBlack,
- }
- ui.Add(uiViewTree, &b.UiTree)
+ b.UiTree = uiterm.Tree{
+ Generator: b.TreeItemBuild,
+ KeyListener: b.TreeItemKeyPress,
+ CharacterListener: b.TreeItemCharacter,
+ Fg: uiterm.ColorWhite,
+ Bg: uiterm.ColorBlack,
+ }
+ ui.Add(uiViewTree, &b.UiTree)
- b.Ui.AddCommandListener(b.CommandMicUp, "micup")
- b.Ui.AddCommandListener(b.CommandMicDown, "micdown")
- b.Ui.AddCommandListener(b.CommandTalk, "toggle")
- b.Ui.AddCommandListener(b.CommandTalk, "talk")
- b.Ui.AddCommandListener(b.CommandExit, "exit")
- b.Ui.AddCommandListener(b.CommandStatus, "status")
- b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
- b.Ui.AddCommandListener(b.CommandPlayFile, "file")
- b.Ui.AddCommandListener(b.CommandStopFile, "stop")
- 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.OnQuitPress, b.Hotkeys.Exit)
- b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
- b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
- b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop)
- b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom)
- b.Ui.SetActive(uiViewInput)
- b.UiTree.Rebuild()
- b.Ui.Refresh()
+ b.Ui.AddCommandListener(b.CommandMicUp, "micup")
+ b.Ui.AddCommandListener(b.CommandMicDown, "micdown")
+ b.Ui.AddCommandListener(b.CommandTalk, "toggle")
+ b.Ui.AddCommandListener(b.CommandTalk, "talk")
+ b.Ui.AddCommandListener(b.CommandExit, "exit")
+ b.Ui.AddCommandListener(b.CommandStatus, "status")
+ 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)
+ b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop)
+ b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom)
+ b.Ui.SetActive(uiViewInput)
+ b.UiTree.Rebuild()
+ b.Ui.Refresh()
}
func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) {
- treeHeight := 0
- outputHeight := 0
- active := b.Ui.Active()
- if active == uiViewTree {
- treeHeight = height - 4
- outputHeight = 0
- } else {
- treeHeight = 0
- outputHeight = height - 4
- }
- ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1)
- ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1)
- ui.SetBounds(uiViewStatus, 0, height-2, width, height-1)
- ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height)
- ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height)
+ treeHeight := 0
+ outputHeight := 0
+ active := b.Ui.Active()
+ if active == uiViewTree {
+ treeHeight = height - 4
+ outputHeight = 0
+ } else {
+ treeHeight = 0
+ outputHeight = height - 4
+ }
+ ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1)
+ ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1)
+ ui.SetBounds(uiViewStatus, 0, height-2, width, height-1)
+ ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height)
+ ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height)
}