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) }