Merge recording feature

This commit is contained in:
Storm Dragon
2026-05-14 00:43:04 -04:00
15 changed files with 1540 additions and 689 deletions
+20
View File
@@ -77,8 +77,27 @@ Current Commands:
* toggle: Toggle your transmission state.
* 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
+141 -134
View File
@@ -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})
}
}
}
+210 -194
View File
@@ -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)
}
+17 -16
View File
@@ -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
}
+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)
}
}
+13 -2
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
@@ -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 {
+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,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)
}
+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))
}
}
}
+347 -343
View File
@@ -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 <filename or URL>")
return
}
// cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd)
if filename == "" {
b.AddOutputLine("Usage: /file <filename or URL>")
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)
}