Merge recording feature

This commit is contained in:
Storm Dragon
2026-05-14 00:43:04 -04:00
15 changed files with 1540 additions and 689 deletions
+20
View File
@@ -77,8 +77,27 @@ Current Commands:
* toggle: Toggle your transmission state. * toggle: Toggle your transmission state.
* talk: Synonym for toggle. * talk: Synonym for toggle.
* noise: Toggle noise suppression on/off for microphone input. * noise: Toggle noise suppression on/off for microphone input.
* record: Toggle recording. You may also use `record start` or `record stop`.
* exit: Exit Barnard, just like when you press your quit key. * exit: Exit Barnard, just like when you press your quit key.
## Recording
Barnard can record a single mixed file containing the audio you hear and the audio you transmit.
Recording uses the Mumble recording state so other users are notified by the server.
If the server reports that recording is not allowed, Barnard refuses to start and prints a clear message.
Recordings are saved in `~/Audio` by default. The directory is created if it does not exist.
The default file name is `barnard-recording-YYYYMMDD-HHMMSS.flac`.
Recording settings in `~/.barnard.toml`:
```toml
recordingformat = "flac"
recordingdirectory = "~/Audio"
```
`recordingformat` may be `flac` or `opus`. Barnard uses `ffmpeg` to encode recordings.
## Event Notification ## Event Notification
You can use the notifycommand parameter in your config file to run a program on certain events. You can use the notifycommand parameter in your config file to run a program on certain events.
@@ -225,6 +244,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission - <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression - <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: cycle through voice effects - <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log - <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree - <kbd>Tab</kbd>: toggle focus between chat and user tree
- <kbd>Page Up</kbd>: scroll chat up - <kbd>Page Up</kbd>: scroll chat up
+141 -134
View File
@@ -1,167 +1,174 @@
package main package main
import ( import (
"crypto/tls" "crypto/tls"
"sync" "sync"
"git.stormux.org/storm/barnard/audio" "git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config" "git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/fileplayback" "git.stormux.org/storm/barnard/fileplayback"
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal" "git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/noise" "git.stormux.org/storm/barnard/noise"
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/recording"
"git.stormux.org/storm/barnard/uiterm"
) )
type TreeItem struct { type TreeItem struct {
User *gumble.User User *gumble.User
Channel *gumble.Channel Channel *gumble.Channel
} }
type Barnard struct { type Barnard struct {
Config *gumble.Config Config *gumble.Config
UserConfig *config.Config UserConfig *config.Config
Hotkeys *config.Hotkeys Hotkeys *config.Hotkeys
Client *gumble.Client Client *gumble.Client
Address string Address string
TLSConfig tls.Config TLSConfig tls.Config
Stream *gumbleopenal.Stream Stream *gumbleopenal.Stream
Tx bool Tx bool
Connected bool Connected bool
Ui *uiterm.Ui Ui *uiterm.Ui
UiOutput uiterm.Textview UiOutput uiterm.Textview
UiInput uiterm.Textbox UiInput uiterm.Textbox
UiStatus uiterm.Label UiStatus uiterm.Label
UiTree uiterm.Tree UiTree uiterm.Tree
UiInputStatus uiterm.Label UiInputStatus uiterm.Label
SelectedChannel *gumble.Channel SelectedChannel *gumble.Channel
selectedUser *gumble.User selectedUser *gumble.User
notifyChannel chan []string notifyChannel chan []string
exitStatus int exitStatus int
exitMessage string exitMessage string
// Added for channel muting // Added for channel muting
MutedChannels map[uint32]bool MutedChannels map[uint32]bool
// Added for noise suppression // Added for noise suppression
NoiseSuppressor *noise.Suppressor NoiseSuppressor *noise.Suppressor
// Added for voice effects // Added for voice effects
VoiceEffects *audio.EffectsProcessor VoiceEffects *audio.EffectsProcessor
// Added for file playback // Added for file playback
FileStream *fileplayback.Player FileStream *fileplayback.Player
FileStreamMutex sync.Mutex FileStreamMutex sync.Mutex
// Added for recording
RecordingMutex sync.Mutex
Recorder *recording.Recorder
recordingStarting bool
recordingAllowed *bool
} }
func (b *Barnard) StopTransmission() { func (b *Barnard) StopTransmission() {
if b.Tx { if b.Tx {
b.Notify("micdown", "me", "") b.Notify("micdown", "me", "")
b.Tx = false b.Tx = false
b.UpdateGeneralStatus(" Idle ", false) b.UpdateGeneralStatus(" Idle ", false)
b.Stream.StopSource() b.Stream.StopSource()
} }
} }
func (b *Barnard) TreeItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) { 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) { func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) {
treeItem := item.(TreeItem) treeItem := item.(TreeItem)
if key == uiterm.KeyEnter { if key == uiterm.KeyEnter {
if treeItem.Channel != nil { if treeItem.Channel != nil {
b.Client.Self.Move(treeItem.Channel) b.Client.Self.Move(treeItem.Channel)
b.SetSelectedUser(nil) b.SetSelectedUser(nil)
b.GotoChat() b.GotoChat()
} }
if treeItem.User != nil { if treeItem.User != nil {
if b.selectedUser == treeItem.User { if b.selectedUser == treeItem.User {
b.SetSelectedUser(nil) b.SetSelectedUser(nil)
b.GotoChat() b.GotoChat()
} else { } else {
b.SetSelectedUser(treeItem.User) b.SetSelectedUser(treeItem.User)
b.GotoChat() b.GotoChat()
} }
} }
} }
// Handle mute toggle // Handle mute toggle
if treeItem.Channel != nil { if treeItem.Channel != nil {
if key == *b.Hotkeys.MuteToggle { if key == *b.Hotkeys.MuteToggle {
// Determine new channel mute state // Determine new channel mute state
channelWillBeMuted := !b.MutedChannels[treeItem.Channel.ID] 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)
}
}
}
// Update channel mute state // Set all users in channel to the same mute state
if channelWillBeMuted { users := makeUsersArray(treeItem.Channel.Users)
b.MutedChannels[treeItem.Channel.ID] = true for _, u := range users {
// If this is the current channel, stop transmission // Explicitly set user mute state to match channel state
if b.Client.Self.Channel.ID == treeItem.Channel.ID && b.Tx { if channelWillBeMuted && !u.LocallyMuted {
b.StopTransmission() b.UserConfig.ToggleMute(u)
} } else if !channelWillBeMuted && u.LocallyMuted {
} else { b.UserConfig.ToggleMute(u)
delete(b.MutedChannels, treeItem.Channel.ID) }
}
b.UiTree.Rebuild() if u.AudioSource != nil {
b.Ui.Refresh() if u.LocallyMuted {
} u.AudioSource.SetGain(0)
if key == *b.Hotkeys.VolumeDown { } else {
b.changeVolume(makeUsersArray(treeItem.Channel.Users), -0.1) u.AudioSource.SetGain(u.Volume)
} }
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 { // Update channel mute state
if key == *b.Hotkeys.MuteToggle { if channelWillBeMuted {
// Toggle mute for single user b.MutedChannels[treeItem.Channel.ID] = true
b.UserConfig.ToggleMute(treeItem.User) // If this is the current channel, stop transmission
if treeItem.User.AudioSource != nil { if b.Client.Self.Channel.ID == treeItem.Channel.ID && b.Tx {
if treeItem.User.LocallyMuted { b.StopTransmission()
treeItem.User.AudioSource.SetGain(0) }
} else { } else {
treeItem.User.AudioSource.SetGain(treeItem.User.Volume) delete(b.MutedChannels, treeItem.Channel.ID)
} }
}
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
} }
if key == *b.Hotkeys.VolumeDown { if key == *b.Hotkeys.VolumeDown {
b.changeVolume([]*gumble.User{treeItem.User}, -0.1) b.changeVolume(makeUsersArray(treeItem.Channel.Users), -0.1)
} }
if key == *b.Hotkeys.VolumeUp { if key == *b.Hotkeys.VolumeUp {
b.changeVolume([]*gumble.User{treeItem.User}, 0.1) b.changeVolume(makeUsersArray(treeItem.Channel.Users), 0.1)
} }
if key == *b.Hotkeys.VolumeReset { if key == *b.Hotkeys.VolumeReset {
b.resetVolume([]*gumble.User{treeItem.User}) 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 package main
import ( import (
"fmt" "fmt"
"net" "net"
"time" "time"
"git.stormux.org/storm/barnard/fileplayback" "git.stormux.org/storm/barnard/fileplayback"
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal" "git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/gumble/gumbleutil" "git.stormux.org/storm/barnard/gumble/gumbleutil"
"git.stormux.org/storm/barnard/gumble/opus" "git.stormux.org/storm/barnard/gumble/opus"
) )
func (b *Barnard) start() { func (b *Barnard) start() {
b.Config.Attach(gumbleutil.AutoBitrate) b.Config.Attach(gumbleutil.AutoBitrate)
b.Config.Attach(b) b.Config.Attach(b)
b.Config.Address = b.Address b.Config.Address = b.Address
// test Audio // test Audio
_, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true) _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
if err != nil { if err != nil {
b.exitWithError(err) b.exitWithError(err)
return return
} }
//connect, not reconnect //connect, not reconnect
b.connect(false) b.connect(false)
} }
func (b *Barnard) exitWithError(err error) { func (b *Barnard) exitWithError(err error) {
b.Ui.Close() b.Ui.Close()
b.exitStatus = 1 b.exitStatus = 1
b.exitMessage = err.Error() b.exitMessage = err.Error()
} }
func (b *Barnard) connect(reconnect bool) bool { func (b *Barnard) connect(reconnect bool) bool {
var err error var err error
_, err = gumble.DialWithDialer(new(net.Dialer), b.Config, &b.TLSConfig) _, err = gumble.DialWithDialer(new(net.Dialer), b.Config, &b.TLSConfig)
if err != nil { if err != nil {
if reconnect { if reconnect {
b.Log(err.Error()) b.Log(err.Error())
} else { } else {
b.exitWithError(err) b.exitWithError(err)
} }
return false return false
} }
stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false) stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
if err != nil { if err != nil {
b.exitWithError(err) b.exitWithError(err)
return false return false
} }
b.Stream = stream b.Stream = stream
b.Stream.AttachStream(b.Client) b.Stream.AttachStream(b.Client)
b.Stream.SetNoiseProcessor(b.NoiseSuppressor) b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
b.Stream.SetEffectsProcessor(b.VoiceEffects) b.Stream.SetEffectsProcessor(b.VoiceEffects)
// Initialize stereo encoder for file playback // Initialize stereo encoder for file playback
b.Client.AudioEncoderStereo = opus.NewStereoEncoder() b.Client.AudioEncoderStereo = opus.NewStereoEncoder()
// Initialize file player // Initialize file player
b.FileStreamMutex.Lock() b.FileStreamMutex.Lock()
b.FileStream = fileplayback.New(b.Client) b.FileStream = fileplayback.New(b.Client)
b.FileStream.SetErrorFunc(func(err error) { b.FileStream.SetErrorFunc(func(err error) {
// Disable stereo when file finishes or errors // Disable stereo when file finishes or errors
b.Client.DisableStereoEncoder() b.Client.DisableStereoEncoder()
b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
}) })
b.Stream.SetFilePlayer(b.FileStream) b.Stream.SetFilePlayer(b.FileStream)
b.FileStreamMutex.Unlock() b.FileStreamMutex.Unlock()
b.Connected = true b.Connected = true
return true return true
} }
func (b *Barnard) OnConnect(e *gumble.ConnectEvent) { func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
b.Client = e.Client b.Client = e.Client
// Reset muted channels state on connect // Reset muted channels state on connect
b.MutedChannels = make(map[uint32]bool) b.MutedChannels = make(map[uint32]bool)
b.RecordingMutex.Lock()
b.recordingAllowed = nil
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.Ui.SetActive(uiViewInput) b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
for _, u := range b.Client.Users { for _, u := range b.Client.Users {
b.UserConfig.UpdateUser(u) b.UserConfig.UpdateUser(u)
} }
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name)) b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name))
b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr())) b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr()))
wmsg := "" wmsg := ""
if e.WelcomeMessage != nil { if e.WelcomeMessage != nil {
wmsg = esc(*e.WelcomeMessage) wmsg = esc(*e.WelcomeMessage)
} }
b.Notify("connect", "me", wmsg) b.Notify("connect", "me", wmsg)
if wmsg != "" { if wmsg != "" {
b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg)) b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
} }
b.Ui.Refresh() b.Ui.Refresh()
} }
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) { func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
var reason string var reason string
switch e.Type { switch e.Type {
case gumble.DisconnectError: case gumble.DisconnectError:
reason = "connection error" reason = "connection error"
} case gumble.DisconnectKicked:
b.Notify("disconnect", "me", reason) reason = "kicked"
if reason == "" { case gumble.DisconnectBanned:
b.AddOutputLine("Disconnected") reason = "banned"
} else { }
b.AddOutputLine("Disconnected: " + reason) if e.String != "" {
} reason = e.String
b.Tx = false }
b.Connected = false b.stopRecordingForDisconnect()
b.UiTree.Rebuild() b.Notify("disconnect", "me", reason)
b.Ui.Refresh() if reason == "" {
go b.reconnectGoroutine() 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() { func (b *Barnard) reconnectGoroutine() {
for { for {
res := b.connect(true) res := b.connect(true)
if res == true { if res == true {
break break
} }
time.Sleep(15 * time.Second) time.Sleep(15 * time.Second)
} }
} }
func (b *Barnard) Log(s string) { func (b *Barnard) Log(s string) {
b.AddOutputMessage(nil, s) b.AddOutputMessage(nil, s)
} }
func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) { func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) {
var public = false var public = false
for _, c := range e.Channels { for _, c := range e.Channels {
if c.Name == b.Client.Self.Channel.Name { if c.Name == b.Client.Self.Channel.Name {
public = true public = true
break break
} }
} }
if public { if public {
b.Notify("msg", e.Sender.Name, e.Message) b.Notify("msg", e.Sender.Name, e.Message)
b.AddOutputMessage(e.Sender, e.Message) b.AddOutputMessage(e.Sender, e.Message)
} else { } else {
var sender string var sender string
if e.Sender == nil { if e.Sender == nil {
sender = "Server" sender = "Server"
} else { } else {
sender = e.Sender.Name sender = e.Sender.Name
} }
b.Notify("pm", sender, e.Message) b.Notify("pm", sender, e.Message)
b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message) b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message)
} }
} }
func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
if e.User != nil { if e.User != nil {
b.UserConfig.UpdateUser(e.User) 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)
}
}
}
}
var s = "unknown" // Check if user is joining a muted channel
var t = "unknown" if e.Type.Has(gumble.UserChangeConnected) || e.Type.Has(gumble.UserChangeChannel) {
if e.Type.Has(gumble.UserChangeConnected) { // If the channel is muted, ensure the user is muted
s = "joined" if b.MutedChannels[e.User.Channel.ID] {
t = "join" // Only mute if not already muted
// Notify about users joining our channel if !e.User.LocallyMuted {
if e.User.Channel.Name == b.Client.Self.Channel.Name { b.UserConfig.ToggleMute(e.User)
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.User.AudioSource != nil {
} e.User.AudioSource.SetGain(0)
} }
if e.Type.Has(gumble.UserChangeDisconnected) { }
s = "left" }
t = "leave" }
if e.User == b.selectedUser {
b.SetSelectedUser(nil) var s = "unknown"
} var t = "unknown"
// Always notify about disconnects if user has channel info and was in our channel if e.Type.Has(gumble.UserChangeConnected) {
if e.User.Channel != nil && e.User.Channel.Name == b.Client.Self.Channel.Name { s = "joined"
b.Notify(t, e.User.Name, e.User.Channel.Name) t = "join"
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name)) // 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)
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self { b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name)) }
} }
b.UiTree.Rebuild() if e.Type.Has(gumble.UserChangeDisconnected) {
b.Ui.Refresh() 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) { func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name)) b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
} }
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) { func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
var info string var info string
switch e.Type { switch e.Type {
case gumble.PermissionDeniedOther: case gumble.PermissionDeniedOther:
info = e.String info = e.String
case gumble.PermissionDeniedPermission: case gumble.PermissionDeniedPermission:
info = "insufficient permissions" info = "insufficient permissions"
case gumble.PermissionDeniedSuperUser: case gumble.PermissionDeniedSuperUser:
info = "cannot modify SuperUser" info = "cannot modify SuperUser"
case gumble.PermissionDeniedInvalidChannelName: case gumble.PermissionDeniedInvalidChannelName:
info = "invalid channel name" info = "invalid channel name"
case gumble.PermissionDeniedTextTooLong: case gumble.PermissionDeniedTextTooLong:
info = "text too long" info = "text too long"
case gumble.PermissionDeniedTemporaryChannel: case gumble.PermissionDeniedTemporaryChannel:
info = "temporary channel" info = "temporary channel"
case gumble.PermissionDeniedMissingCertificate: case gumble.PermissionDeniedMissingCertificate:
info = "missing certificate" info = "missing certificate"
case gumble.PermissionDeniedInvalidUserName: case gumble.PermissionDeniedInvalidUserName:
info = "invalid user name" info = "invalid user name"
case gumble.PermissionDeniedChannelFull: case gumble.PermissionDeniedChannelFull:
info = "channel full" info = "channel full"
case gumble.PermissionDeniedNestingLimit: case gumble.PermissionDeniedNestingLimit:
info = "nesting limit" info = "nesting limit"
} }
b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info)) b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info))
} }
func (b *Barnard) OnUserList(e *gumble.UserListEvent) { func (b *Barnard) OnUserList(e *gumble.UserListEvent) {
//for _,u := range e.UserList { //for _,u := range e.UserList {
//b.UserConfig.UpdateUser(u) //b.UserConfig.UpdateUser(u)
//} //}
} }
func (b *Barnard) OnACL(e *gumble.ACLEvent) { 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) { func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
b.HandleRecordingAllowed(e.RecordingAllowed)
} }
+17 -16
View File
@@ -1,23 +1,24 @@
package config package config
import ( import (
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/uiterm"
) )
type Hotkeys struct { type Hotkeys struct {
Talk *uiterm.Key Talk *uiterm.Key
VolumeDown *uiterm.Key VolumeDown *uiterm.Key
VolumeUp *uiterm.Key VolumeUp *uiterm.Key
VolumeReset *uiterm.Key VolumeReset *uiterm.Key
MuteToggle *uiterm.Key MuteToggle *uiterm.Key
Exit *uiterm.Key RecordToggle *uiterm.Key
ToggleTimestamps *uiterm.Key Exit *uiterm.Key
SwitchViews *uiterm.Key ToggleTimestamps *uiterm.Key
ClearOutput *uiterm.Key SwitchViews *uiterm.Key
ScrollUp *uiterm.Key ClearOutput *uiterm.Key
ScrollDown *uiterm.Key ScrollUp *uiterm.Key
ScrollToTop *uiterm.Key ScrollDown *uiterm.Key
ScrollToBottom *uiterm.Key ScrollToTop *uiterm.Key
NoiseSuppressionToggle *uiterm.Key ScrollToBottom *uiterm.Key
CycleVoiceEffect *uiterm.Key NoiseSuppressionToggle *uiterm.Key
CycleVoiceEffect *uiterm.Key
} }
+87
View File
@@ -31,6 +31,8 @@ type exportableConfig struct {
NoiseSuppressionThreshold *float32 NoiseSuppressionThreshold *float32
VoiceEffect *int VoiceEffect *int
Certificate *string Certificate *string
RecordingFormat *string
RecordingDirectory *string
} }
type server struct { type server struct {
@@ -75,6 +77,7 @@ func (c *Config) LoadConfig() {
VolumeUp: key(uiterm.KeyF6), VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8), VolumeReset: key(uiterm.KeyF8),
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10), Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3), ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab), SwitchViews: key(uiterm.KeyTab),
@@ -95,6 +98,7 @@ func (c *Config) LoadConfig() {
} }
} }
c.config = &jc c.config = &jc
c.ensureHotkeys()
if c.config.MicVolume == nil { if c.config.MicVolume == nil {
micvol := float32(1.0) micvol := float32(1.0)
jc.MicVolume = &micvol jc.MicVolume = &micvol
@@ -139,6 +143,75 @@ func (c *Config) LoadConfig() {
cert := string("") cert := string("")
jc.Certificate = &cert jc.Certificate = &cert
} }
if c.config.RecordingFormat == nil {
format := string("flac")
jc.RecordingFormat = &format
}
if c.config.RecordingDirectory == nil {
dir := string("~/Audio")
jc.RecordingDirectory = &dir
}
}
func (c *Config) ensureHotkeys() {
if c.config.Hotkeys == nil {
c.config.Hotkeys = &Hotkeys{}
}
defaults := Hotkeys{
Talk: key(uiterm.KeyF1),
VolumeDown: key(uiterm.KeyF5),
VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8),
MuteToggle: key(uiterm.KeyF7),
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab),
ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil {
hotkeys.Talk = defaults.Talk
}
if hotkeys.VolumeDown == nil {
hotkeys.VolumeDown = defaults.VolumeDown
}
if hotkeys.VolumeUp == nil {
hotkeys.VolumeUp = defaults.VolumeUp
}
if hotkeys.VolumeReset == nil {
hotkeys.VolumeReset = defaults.VolumeReset
}
if hotkeys.MuteToggle == nil {
hotkeys.MuteToggle = defaults.MuteToggle
}
if hotkeys.RecordToggle == nil {
hotkeys.RecordToggle = defaults.RecordToggle
}
if hotkeys.Exit == nil {
hotkeys.Exit = defaults.Exit
}
if hotkeys.ToggleTimestamps == nil {
hotkeys.ToggleTimestamps = defaults.ToggleTimestamps
}
if hotkeys.SwitchViews == nil {
hotkeys.SwitchViews = defaults.SwitchViews
}
if hotkeys.ScrollUp == nil {
hotkeys.ScrollUp = defaults.ScrollUp
}
if hotkeys.ScrollDown == nil {
hotkeys.ScrollDown = defaults.ScrollDown
}
if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
}
if hotkeys.CycleVoiceEffect == nil {
hotkeys.CycleVoiceEffect = defaults.CycleVoiceEffect
}
} }
func (c *Config) findServer(address string) *server { func (c *Config) findServer(address string) *server {
@@ -284,6 +357,20 @@ func (c *Config) SetVoiceEffect(effect int) {
c.SaveConfig() c.SaveConfig()
} }
func (c *Config) GetRecordingFormat() string {
if c.config.RecordingFormat == nil {
return "flac"
}
return strings.ToLower(strings.TrimSpace(*c.config.RecordingFormat))
}
func (c *Config) GetRecordingDirectory() string {
if c.config.RecordingDirectory == nil {
return resolvePath("~/Audio")
}
return resolvePath(*c.config.RecordingDirectory)
}
func (c *Config) UpdateUser(u *gumble.User) { func (c *Config) UpdateUser(u *gumble.User) {
var j *eUser var j *eUser
var uc *gumble.Client var uc *gumble.Client
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.stormux.org/storm/barnard/uiterm"
)
func TestConfigBackfillsRecordingDefaults(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "barnard.toml")
if err := os.WriteFile(configPath, []byte("[hotkeys]\ntalk = \"f1\"\n"), 0600); err != nil {
t.Fatal(err)
}
cfg := NewConfig(&configPath)
if got := cfg.GetRecordingFormat(); got != "flac" {
t.Fatalf("expected default recording format flac, got %q", got)
}
if got := cfg.GetRecordingDirectory(); got != filepath.Join(os.Getenv("HOME"), "Audio") {
t.Fatalf("expected default recording directory ~/Audio, got %q", got)
}
if cfg.GetHotkeys().RecordToggle == nil {
t.Fatal("expected record toggle hotkey to be backfilled")
}
if got := *cfg.GetHotkeys().RecordToggle; got != uiterm.KeyCtrlR {
t.Fatalf("expected record toggle ctrl_r, got %s", got)
}
}
+13 -2
View File
@@ -6,9 +6,11 @@
Package MumbleProto is a generated protocol buffer package. Package MumbleProto is a generated protocol buffer package.
It is generated from these files: It is generated from these files:
Mumble.proto Mumble.proto
It has these top-level messages: It has these top-level messages:
Version Version
UDPTunnel UDPTunnel
Authenticate Authenticate
@@ -2070,8 +2072,10 @@ type ServerConfig struct {
// Maximum image message length. // Maximum image message length.
ImageMessageLength *uint32 `protobuf:"varint,5,opt,name=image_message_length,json=imageMessageLength" json:"image_message_length,omitempty"` ImageMessageLength *uint32 `protobuf:"varint,5,opt,name=image_message_length,json=imageMessageLength" json:"image_message_length,omitempty"`
// The maximum number of users allowed on the server. // The maximum number of users allowed on the server.
MaxUsers *uint32 `protobuf:"varint,6,opt,name=max_users,json=maxUsers" json:"max_users,omitempty"` MaxUsers *uint32 `protobuf:"varint,6,opt,name=max_users,json=maxUsers" json:"max_users,omitempty"`
XXX_unrecognized []byte `json:"-"` // 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{} } func (m *ServerConfig) Reset() { *m = ServerConfig{} }
@@ -2121,6 +2125,13 @@ func (m *ServerConfig) GetMaxUsers() uint32 {
return 0 return 0
} }
func (m *ServerConfig) GetRecordingAllowed() bool {
if m != nil && m.RecordingAllowed != nil {
return *m.RecordingAllowed
}
return false
}
// Sent by the server to inform the clients of suggested client configuration // Sent by the server to inform the clients of suggested client configuration
// specified by the server administrator. // specified by the server administrator.
type SuggestConfig struct { type SuggestConfig struct {
+1
View File
@@ -210,6 +210,7 @@ type ServerConfigEvent struct {
MaximumMessageLength *int MaximumMessageLength *int
MaximumImageMessageLength *int MaximumImageMessageLength *int
MaximumUsers *int MaximumUsers *int
RecordingAllowed *bool
CodecAlpha *int32 CodecAlpha *int32
CodecBeta *int32 CodecBeta *int32
+48
View File
@@ -467,6 +467,7 @@ func (c *Client) handleUserRemove(buffer []byte) error {
event.Type |= UserChangeBanned event.Type |= UserChangeBanned
} }
if event.User == c.Self { if event.User == c.Self {
c.disconnectEvent.String = event.String
if packet.Ban != nil && *packet.Ban { if packet.Ban != nil && *packet.Ban {
c.disconnectEvent.Type = DisconnectBanned c.disconnectEvent.Type = DisconnectBanned
} else { } else {
@@ -1236,10 +1237,57 @@ func (c *Client) handleServerConfig(buffer []byte) error {
val := int(*packet.MaxUsers) val := int(*packet.MaxUsers)
event.MaximumUsers = &val event.MaximumUsers = &val
} }
if packet.RecordingAllowed != nil {
event.RecordingAllowed = packet.RecordingAllowed
} else if val := parseServerConfigRecordingAllowed(buffer); val != nil {
event.RecordingAllowed = val
}
c.Config.Listeners.onServerConfig(&event) c.Config.Listeners.onServerConfig(&event)
return nil return nil
} }
func parseServerConfigRecordingAllowed(buffer []byte) *bool {
for len(buffer) > 0 {
key, n := varint.Decode(buffer)
if n <= 0 {
return nil
}
buffer = buffer[n:]
field := key >> 3
wireType := key & 0x7
if field == 7 && wireType == 0 {
val, n := varint.Decode(buffer)
if n <= 0 {
return nil
}
allowed := val != 0
return &allowed
}
skip := 0
switch wireType {
case 0:
_, skip = varint.Decode(buffer)
case 1:
skip = 8
case 2:
length, n := varint.Decode(buffer)
if n <= 0 || length < 0 {
return nil
}
skip = n + int(length)
case 5:
skip = 4
default:
return nil
}
if skip <= 0 || skip > len(buffer) {
return nil
}
buffer = buffer[skip:]
}
return nil
}
func (c *Client) handleSuggestConfig(buffer []byte) error { func (c *Client) handleSuggestConfig(buffer []byte) error {
var packet MumbleProto.SuggestConfig var packet MumbleProto.SuggestConfig
if err := proto.Unmarshal(buffer, &packet); err != nil { if err := proto.Unmarshal(buffer, &packet); err != nil {
+35
View File
@@ -0,0 +1,35 @@
package gumble
import (
"testing"
)
type serverConfigListener struct {
EventListener
event *ServerConfigEvent
}
func (l *serverConfigListener) OnServerConfig(e *ServerConfigEvent) {
l.event = e
}
func TestHandleServerConfigRecordingAllowed(t *testing.T) {
data := []byte{56, 0} // field 7, varint false
client := &Client{Config: NewConfig()}
listener := &serverConfigListener{}
client.Config.Attach(listener)
if err := client.handleServerConfig(data); err != nil {
t.Fatal(err)
}
if listener.event == nil {
t.Fatal("expected server config event")
}
if listener.event.RecordingAllowed == nil {
t.Fatal("expected recording allowed value")
}
if *listener.event.RecordingAllowed {
t.Fatal("expected recording to be disallowed")
}
}
+61
View File
@@ -4,6 +4,7 @@ import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"os/exec" "os/exec"
"sync"
"time" "time"
"git.stormux.org/storm/barnard/audio" "git.stormux.org/storm/barnard/audio"
@@ -30,6 +31,12 @@ type FilePlayer interface {
IsPlaying() bool IsPlaying() bool
} }
type Recorder interface {
RecordAudioFrame(source uint32, samples []int16)
}
const recorderOutgoingSource uint32 = ^uint32(0)
const ( const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
) )
@@ -72,6 +79,8 @@ type Stream struct {
effectsProcessor EffectsProcessor effectsProcessor EffectsProcessor
effectsProcessorRight EffectsProcessor effectsProcessorRight EffectsProcessor
filePlayer FilePlayer filePlayer FilePlayer
recorderMu sync.RWMutex
recorder Recorder
} }
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) { func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
@@ -161,6 +170,18 @@ func (s *Stream) GetFilePlayer() FilePlayer {
return s.filePlayer return s.filePlayer
} }
func (s *Stream) SetRecorder(recorder Recorder) {
s.recorderMu.Lock()
defer s.recorderMu.Unlock()
s.recorder = recorder
}
func (s *Stream) getRecorder() Recorder {
s.recorderMu.RLock()
defer s.recorderMu.RUnlock()
return s.recorder
}
func (s *Stream) Destroy() { func (s *Stream) Destroy() {
if s.link != nil { if s.link != nil {
s.link.Detach() s.link.Detach()
@@ -265,6 +286,12 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
boost = e.User.Boost boost = e.User.Boost
recorder := s.getRecorder()
var recordBuffer []int16
recordPtr := 0
if recorder != nil {
recordBuffer = make([]int16, len(packet.AudioBuffer)*gumble.AudioChannels)
}
// Check if sample count suggests stereo data // Check if sample count suggests stereo data
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0 isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
@@ -290,6 +317,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
@@ -305,6 +336,10 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume)
recordPtr++
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
} }
@@ -322,10 +357,19 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
sample = int16(boosted) sample = int16(boosted)
} }
} }
if recorder != nil {
recordSample := scaleForRecording(sample, e.User.Volume)
recordBuffer[recordPtr] = recordSample
recordBuffer[recordPtr+1] = recordSample
recordPtr += 2
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2 rawPtr += 2
} }
} }
if recorder != nil && recordPtr > 0 {
recorder.RecordAudioFrame(e.User.Session, recordBuffer[:recordPtr])
}
reclaim() reclaim()
if len(emptyBufs) == 0 { if len(emptyBufs) == 0 {
@@ -472,14 +516,31 @@ func (s *Stream) sourceRoutine(inputDevice *string) {
if hasFileAudio { if hasFileAudio {
// Send stereo buffer when file is playing // Send stereo buffer when file is playing
outgoing <- gumble.AudioBuffer(outputBuffer) outgoing <- gumble.AudioBuffer(outputBuffer)
if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer)
}
} else if hasMicInput { } else if hasMicInput {
// Send mic when no file is playing // Send mic when no file is playing
outgoing <- gumble.AudioBuffer(int16Buffer) outgoing <- gumble.AudioBuffer(int16Buffer)
if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, int16Buffer)
}
} }
} }
} }
} }
func scaleForRecording(sample int16, volume float32) int16 {
scaled := int32(float32(sample) * volume)
if scaled > 32767 {
return 32767
}
if scaled < -32768 {
return -32768
}
return int16(scaled)
}
func (s *Stream) processMonoSamples(samples []int16) { func (s *Stream) processMonoSamples(samples []int16) {
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor) s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
} }
+263
View File
@@ -0,0 +1,263 @@
package recording
import (
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
)
const (
FormatFLAC = "flac"
FormatOpus = "opus"
)
type Recorder struct {
path string
format string
frameSize int
interval time.Duration
cmd *exec.Cmd
stdin io.WriteCloser
input chan sourceFrame
stop chan struct{}
done chan struct{}
once sync.Once
mu sync.Mutex
err error
}
type sourceFrame struct {
source uint32
samples []int16
}
func New(directory string, format string, now time.Time, frameSize int, interval time.Duration) (*Recorder, error) {
format = NormalizeFormat(format)
if format != FormatFLAC && format != FormatOpus {
return nil, fmt.Errorf("unsupported recording format %q", format)
}
if frameSize <= 0 {
return nil, errors.New("invalid recording frame size")
}
if interval <= 0 {
interval = gumble.AudioDefaultInterval
}
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, err
}
path := UniquePath(directory, now, format)
args := ffmpegArgs(format, path)
cmd := exec.Command("ffmpeg", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
recorder := &Recorder{
path: path,
format: format,
frameSize: frameSize,
interval: interval,
cmd: cmd,
stdin: stdin,
input: make(chan sourceFrame, 512),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go recorder.run()
return recorder, nil
}
func NormalizeFormat(format string) string {
format = strings.ToLower(strings.TrimSpace(format))
format = strings.TrimPrefix(format, ".")
if format == "" {
return FormatFLAC
}
return format
}
func UniquePath(directory string, now time.Time, format string) string {
base := fmt.Sprintf("barnard-recording-%s", now.Format("20060102-150405"))
path := filepath.Join(directory, base+"."+format)
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
for i := 2; ; i++ {
path = filepath.Join(directory, fmt.Sprintf("%s-%d.%s", base, i, format))
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
}
}
func (r *Recorder) Path() string {
return r.path
}
func (r *Recorder) RecordAudioFrame(source uint32, samples []int16) {
if r == nil || len(samples) == 0 {
return
}
if len(r.input) >= cap(r.input) {
return
}
frame := NormalizeStereoFrame(samples, r.frameSize)
select {
case r.input <- sourceFrame{source: source, samples: frame}:
default:
}
}
func (r *Recorder) Stop() error {
if r == nil {
return nil
}
r.once.Do(func() {
close(r.stop)
if r.stdin != nil {
r.stdin.Close()
}
})
select {
case <-r.done:
case <-time.After(2 * time.Second):
if r.cmd != nil && r.cmd.Process != nil {
r.cmd.Process.Kill()
}
<-r.done
}
r.mu.Lock()
defer r.mu.Unlock()
return r.err
}
func (r *Recorder) run() {
defer close(r.done)
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
queues := make(map[uint32][][]int16)
frame := make([]int16, r.frameSize*gumble.AudioChannels)
for {
select {
case <-r.stop:
r.closeEncoder()
return
case item := <-r.input:
queues[item.source] = append(queues[item.source], item.samples)
case <-ticker.C:
clear(frame)
for source, queue := range queues {
if len(queue) == 0 {
delete(queues, source)
continue
}
mix(frame, queue[0])
queue = queue[1:]
if len(queue) == 0 {
delete(queues, source)
} else {
queues[source] = queue
}
}
if err := writePCM(r.stdin, frame); err != nil {
r.setError(err)
r.closeEncoder()
return
}
}
}
}
func (r *Recorder) closeEncoder() {
if r.stdin != nil {
if err := r.stdin.Close(); err != nil {
if !errors.Is(err, os.ErrClosed) {
r.setError(err)
}
}
}
if r.cmd != nil {
if err := r.cmd.Wait(); err != nil {
r.setError(err)
}
}
}
func (r *Recorder) setError(err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.err == nil {
r.err = err
}
}
func NormalizeStereoFrame(samples []int16, frameSize int) []int16 {
out := make([]int16, frameSize*gumble.AudioChannels)
if len(samples) >= frameSize*gumble.AudioChannels && len(samples)%gumble.AudioChannels == 0 {
copy(out, samples[:frameSize*gumble.AudioChannels])
return out
}
limit := frameSize
if len(samples) < limit {
limit = len(samples)
}
for i := 0; i < limit; i++ {
out[i*2] = samples[i]
out[i*2+1] = samples[i]
}
return out
}
func mix(dst []int16, src []int16) {
limit := len(dst)
if len(src) < limit {
limit = len(src)
}
for i := 0; i < limit; i++ {
sum := int32(dst[i]) + int32(src[i])
if sum > 32767 {
sum = 32767
} else if sum < -32768 {
sum = -32768
}
dst[i] = int16(sum)
}
}
func writePCM(w io.Writer, samples []int16) error {
buf := make([]byte, len(samples)*2)
for i, sample := range samples {
binary.LittleEndian.PutUint16(buf[i*2:], uint16(sample))
}
_, err := w.Write(buf)
return err
}
func ffmpegArgs(format string, path string) []string {
args := []string{
"-loglevel", "error",
"-f", "s16le",
"-ar", fmt.Sprintf("%d", gumble.AudioSampleRate),
"-ac", fmt.Sprintf("%d", gumble.AudioChannels),
"-i", "pipe:0",
}
if format == FormatOpus {
args = append(args, "-c:a", "libopus")
}
return append(args, "-y", path)
}
+65
View File
@@ -0,0 +1,65 @@
package recording
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestNormalizeFormat(t *testing.T) {
tests := map[string]string{
"": "flac",
" FLAC ": "flac",
".opus": "opus",
}
for input, want := range tests {
if got := NormalizeFormat(input); got != want {
t.Fatalf("NormalizeFormat(%q) = %q, want %q", input, got, want)
}
}
}
func TestUniquePathAvoidsCollision(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 14, 12, 30, 0, 0, time.Local)
first := filepath.Join(dir, "barnard-recording-20260514-123000.flac")
if err := os.WriteFile(first, []byte{}, 0600); err != nil {
t.Fatal(err)
}
got := UniquePath(dir, now, "flac")
want := filepath.Join(dir, "barnard-recording-20260514-123000-2.flac")
if got != want {
t.Fatalf("UniquePath() = %q, want %q", got, want)
}
}
func TestNormalizeStereoFrame(t *testing.T) {
mono := NormalizeStereoFrame([]int16{1, -2}, 3)
wantMono := []int16{1, 1, -2, -2, 0, 0}
for i := range wantMono {
if mono[i] != wantMono[i] {
t.Fatalf("mono[%d] = %d, want %d", i, mono[i], wantMono[i])
}
}
stereo := NormalizeStereoFrame([]int16{1, 2, 3, 4, 5, 6}, 2)
wantStereo := []int16{1, 2, 3, 4}
for i := range wantStereo {
if stereo[i] != wantStereo[i] {
t.Fatalf("stereo[%d] = %d, want %d", i, stereo[i], wantStereo[i])
}
}
}
func TestMixSaturates(t *testing.T) {
dst := []int16{32000, -32000, 10}
mix(dst, []int16{2000, -2000, -20})
want := []int16{32767, -32768, -10}
for i := range want {
if dst[i] != want[i] {
t.Fatalf("dst[%d] = %d, want %d", i, dst[i], want[i])
}
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"fmt"
"strings"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/recording"
"git.stormux.org/storm/barnard/uiterm"
)
func (b *Barnard) OnRecordingToggle(ui *uiterm.Ui, key uiterm.Key) {
b.ToggleRecording()
}
func (b *Barnard) CommandRecord(ui *uiterm.Ui, cmd string) {
action := strings.ToLower(strings.TrimSpace(cmd))
switch action {
case "", "toggle":
b.ToggleRecording()
case "start", "on":
b.StartRecording()
case "stop", "off":
b.StopRecording(true)
default:
b.AddOutputLine("Usage: /record [start|stop|toggle]")
}
}
func (b *Barnard) ToggleRecording() {
b.RecordingMutex.Lock()
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if active {
b.StopRecording(true)
} else {
b.StartRecording()
}
}
func (b *Barnard) StartRecording() {
if b.Client == nil || b.Client.Self == nil {
b.AddOutputLine("Recording requires an active server connection")
return
}
b.RecordingMutex.Lock()
if b.recordingAllowed != nil && !*b.recordingAllowed {
b.RecordingMutex.Unlock()
b.AddOutputLine("Recording is not allowed by this server.")
return
}
if b.Recorder != nil || b.recordingStarting {
b.RecordingMutex.Unlock()
b.AddOutputLine("Recording is already active")
return
}
b.recordingStarting = true
b.RecordingMutex.Unlock()
b.Client.Self.SetRecording(true)
b.AddOutputLine("Recording start requested")
}
func (b *Barnard) StopRecording(notifyServer bool) {
recorder, path, wasPending := b.detachRecorder()
if recorder == nil {
if notifyServer && b.Client != nil && b.Client.Self != nil {
b.Client.Self.SetRecording(false)
}
if wasPending {
b.AddOutputLine("Recording start cancelled")
} else {
b.AddOutputLine("Recording is not active")
}
return
}
if err := recorder.Stop(); err != nil {
b.AddOutputLine(fmt.Sprintf("Recording stopped with error: %s", err))
} else {
b.AddOutputLine(fmt.Sprintf("Recording saved: %s", path))
}
if notifyServer && b.Client != nil && b.Client.Self != nil {
b.Client.Self.SetRecording(false)
}
}
func (b *Barnard) StopRecordingIfActive(notifyServer bool) {
b.RecordingMutex.Lock()
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if active {
b.StopRecording(notifyServer)
}
}
func (b *Barnard) HandleRecordingChange(e *gumble.UserChangeEvent) {
if e.User == nil {
return
}
if e.User == b.Client.Self {
if e.User.Recording {
b.finishRecordingStart()
} else {
b.RecordingMutex.Lock()
hadRecorder := b.Recorder != nil
b.RecordingMutex.Unlock()
if hadRecorder {
b.StopRecording(false)
}
}
return
}
if e.User.Recording {
b.AddOutputLine(fmt.Sprintf("%s started recording", e.User.Name))
} else {
b.AddOutputLine(fmt.Sprintf("%s stopped recording", e.User.Name))
}
}
func (b *Barnard) HandleRecordingAllowed(allowed *bool) {
if allowed == nil {
return
}
b.RecordingMutex.Lock()
value := *allowed
b.recordingAllowed = &value
active := b.Recorder != nil || b.recordingStarting
b.RecordingMutex.Unlock()
if !value && active {
b.AddOutputLine("Recording is not allowed by this server.")
b.StopRecording(true)
}
}
func (b *Barnard) finishRecordingStart() {
b.RecordingMutex.Lock()
if !b.recordingStarting || b.Recorder != nil {
b.RecordingMutex.Unlock()
return
}
b.RecordingMutex.Unlock()
recorder, err := recording.New(
b.UserConfig.GetRecordingDirectory(),
b.UserConfig.GetRecordingFormat(),
time.Now(),
b.Client.Config.AudioFrameSize(),
b.Client.Config.AudioInterval,
)
if err != nil {
b.RecordingMutex.Lock()
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.AddOutputLine(fmt.Sprintf("Could not start recording: %s", err))
b.Client.Self.SetRecording(false)
return
}
b.RecordingMutex.Lock()
if !b.recordingStarting {
b.RecordingMutex.Unlock()
recorder.Stop()
return
}
b.Recorder = recorder
b.recordingStarting = false
b.RecordingMutex.Unlock()
if b.Stream != nil {
b.Stream.SetRecorder(recorder)
}
b.AddOutputLine(fmt.Sprintf("Recording started: %s", recorder.Path()))
}
func (b *Barnard) detachRecorder() (*recording.Recorder, string, bool) {
b.RecordingMutex.Lock()
defer b.RecordingMutex.Unlock()
recorder := b.Recorder
wasPending := b.recordingStarting
path := ""
if recorder != nil {
path = recorder.Path()
}
b.Recorder = nil
b.recordingStarting = false
if b.Stream != nil {
b.Stream.SetRecorder(nil)
}
return recorder, path, wasPending
}
func (b *Barnard) stopRecordingForDisconnect() {
recorder, _, _ := b.detachRecorder()
if recorder != nil {
if err := recorder.Stop(); err != nil {
b.AddOutputLine(fmt.Sprintf("Recording stopped with error: %s", err))
}
}
}
+347 -343
View File
@@ -1,494 +1,498 @@
package main package main
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/uiterm"
"github.com/kennygrant/sanitize" "github.com/kennygrant/sanitize"
"github.com/nsf/termbox-go" "github.com/nsf/termbox-go"
) )
const ( const (
uiViewLogo = "logo" uiViewLogo = "logo"
uiViewTop = "top" uiViewTop = "top"
uiViewStatus = "status" uiViewStatus = "status"
uiViewInput = "input" uiViewInput = "input"
uiViewInputStatus = "inputstatus" uiViewInputStatus = "inputstatus"
uiViewOutput = "output" uiViewOutput = "output"
uiViewTree = "tree" uiViewTree = "tree"
) )
func Beep() { func Beep() {
cmd := exec.Command("beep") cmd := exec.Command("beep")
cmdout, err := cmd.Output() cmdout, err := cmd.Output()
if err != nil { if err != nil {
panic(err) panic(err)
} }
if cmdout != nil { if cmdout != nil {
} }
} }
func esc(str string) string { func esc(str string) string {
return sanitize.HTML(str) return sanitize.HTML(str)
} }
func (b *Barnard) Notify(event string, who string, what string) { 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() { func (b *Barnard) Beep() {
Beep() Beep()
} }
func (b *Barnard) SetSelectedUser(user *gumble.User) { func (b *Barnard) SetSelectedUser(user *gumble.User) {
b.selectedUser = user b.selectedUser = user
if user == nil { if user == nil {
if len(b.UiInput.Text) > 0 { if len(b.UiInput.Text) > 0 {
} }
b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name)) b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name))
} else { } else {
b.UpdateInputStatus(fmt.Sprintf("[@%s]", user.Name)) b.UpdateInputStatus(fmt.Sprintf("[@%s]", user.Name))
} }
} }
func (b *Barnard) GetInputStatus() string { func (b *Barnard) GetInputStatus() string {
return b.UiInputStatus.Text return b.UiInputStatus.Text
} }
func (b *Barnard) UpdateInputStatus(status string) { func (b *Barnard) UpdateInputStatus(status string) {
if len(status) > 20 { if len(status) > 20 {
status = status[:17] + "..." + "]" status = status[:17] + "..." + "]"
} }
b.UiInputStatus.Text = status b.UiInputStatus.Text = status
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
} }
func (b *Barnard) AddOutputLine(line string) { func (b *Barnard) AddOutputLine(line string) {
now := time.Now() now := time.Now()
b.UiOutput.AddLine(fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second())) 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) { func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
if sender == nil { if sender == nil {
b.AddOutputLine(message) b.AddOutputLine(message)
} else { } else {
b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name, strings.TrimSpace(esc(message)))) b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name, strings.TrimSpace(esc(message))))
} }
} }
func (b *Barnard) AddOutputPrivateMessage(source *gumble.User, dest *gumble.User, message string) { func (b *Barnard) AddOutputPrivateMessage(source *gumble.User, dest *gumble.User, message string) {
var sender string var sender string
if source == nil { if source == nil {
sender = "Server" sender = "Server"
} else { } else {
sender = source.Name sender = source.Name
} }
b.AddOutputLine(fmt.Sprintf("pm/%s/%s: %s", sender, dest.Name, strings.TrimSpace(esc(message)))) 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) { 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) { func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
enabled := !b.UserConfig.GetNoiseSuppressionEnabled() enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
b.UserConfig.SetNoiseSuppressionEnabled(enabled) b.UserConfig.SetNoiseSuppressionEnabled(enabled)
b.NoiseSuppressor.SetEnabled(enabled) b.NoiseSuppressor.SetEnabled(enabled)
if enabled { if enabled {
b.UpdateGeneralStatus("Noise suppression: ON", false) b.UpdateGeneralStatus("Noise suppression: ON", false)
} else { } else {
b.UpdateGeneralStatus("Noise suppression: OFF", false) b.UpdateGeneralStatus("Noise suppression: OFF", false)
} }
} }
func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) {
effect := b.VoiceEffects.CycleEffect() effect := b.VoiceEffects.CycleEffect()
b.UserConfig.SetVoiceEffect(int(effect)) b.UserConfig.SetVoiceEffect(int(effect))
b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false) b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false)
} }
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) { func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
if notice { if notice {
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
b.UiStatus.Bg = uiterm.ColorRed b.UiStatus.Bg = uiterm.ColorRed
} else { } else {
b.UiStatus.Fg = uiterm.ColorBlack b.UiStatus.Fg = uiterm.ColorBlack
b.UiStatus.Bg = uiterm.ColorWhite b.UiStatus.Bg = uiterm.ColorWhite
} }
b.UiStatus.Text = text b.UiStatus.Text = text
b.Ui.Refresh() b.Ui.Refresh()
} }
func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) { 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) { 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) { 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) { 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) { 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) { func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
enabled := !b.UserConfig.GetNoiseSuppressionEnabled() enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
b.UserConfig.SetNoiseSuppressionEnabled(enabled) b.UserConfig.SetNoiseSuppressionEnabled(enabled)
b.NoiseSuppressor.SetEnabled(enabled) b.NoiseSuppressor.SetEnabled(enabled)
if enabled { if enabled {
b.AddOutputLine("Noise suppression enabled") b.AddOutputLine("Noise suppression enabled")
} else { } else {
b.AddOutputLine("Noise suppression disabled") b.AddOutputLine("Noise suppression disabled")
} }
} }
func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) { func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
// cmd contains just the filename part (everything after "/file ") // cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd) filename := strings.TrimSpace(cmd)
if filename == "" { if filename == "" {
b.AddOutputLine("Usage: /file <filename or URL>") b.AddOutputLine("Usage: /file <filename or URL>")
return return
} }
// Check if it's a URL // Check if it's a URL
isURL := strings.HasPrefix(filename, "http://") || isURL := strings.HasPrefix(filename, "http://") ||
strings.HasPrefix(filename, "https://") || strings.HasPrefix(filename, "https://") ||
strings.HasPrefix(filename, "ftp://") || strings.HasPrefix(filename, "ftp://") ||
strings.HasPrefix(filename, "rtmp://") strings.HasPrefix(filename, "rtmp://")
if !isURL { if !isURL {
// Expand ~ to home directory for local files // Expand ~ to home directory for local files
if strings.HasPrefix(filename, "~") { if strings.HasPrefix(filename, "~") {
homeDir := os.Getenv("HOME") homeDir := os.Getenv("HOME")
filename = strings.Replace(filename, "~", homeDir, 1) filename = strings.Replace(filename, "~", homeDir, 1)
} }
// Check if local file exists // Check if local file exists
if _, err := os.Stat(filename); os.IsNotExist(err) { if _, err := os.Stat(filename); os.IsNotExist(err) {
b.AddOutputLine(fmt.Sprintf("File not found: %s", filename)) b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
return return
} }
} }
if !b.Connected { if !b.Connected {
b.AddOutputLine("Not connected to server") b.AddOutputLine("Not connected to server")
return return
} }
b.FileStreamMutex.Lock() b.FileStreamMutex.Lock()
defer b.FileStreamMutex.Unlock() defer b.FileStreamMutex.Unlock()
if b.FileStream != nil && b.FileStream.IsPlaying() { if b.FileStream != nil && b.FileStream.IsPlaying() {
b.AddOutputLine("Already playing a file. Use /stop first.") b.AddOutputLine("Already playing a file. Use /stop first.")
return return
} }
err := b.FileStream.PlayFile(filename) err := b.FileStream.PlayFile(filename)
if err != nil { if err != nil {
b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
return return
} }
// Enable stereo encoder for file playback // Enable stereo encoder for file playback
b.Client.EnableStereoEncoder() b.Client.EnableStereoEncoder()
// Auto-start transmission if not already transmitting // Auto-start transmission if not already transmitting
if !b.Tx { if !b.Tx {
err := b.Stream.StartSource(b.UserConfig.GetInputDevice()) err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
if err != nil { if err != nil {
b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error()))
b.FileStream.Stop() b.FileStream.Stop()
b.Client.DisableStereoEncoder() b.Client.DisableStereoEncoder()
return return
} }
b.Tx = true b.Tx = true
b.UpdateGeneralStatus(" File ", true) b.UpdateGeneralStatus(" File ", true)
} }
if isURL { if isURL {
b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename)) b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
} else { } else {
b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename)) b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
} }
} }
func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) { func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
b.FileStreamMutex.Lock() b.FileStreamMutex.Lock()
defer b.FileStreamMutex.Unlock() defer b.FileStreamMutex.Unlock()
if b.FileStream == nil || !b.FileStream.IsPlaying() { if b.FileStream == nil || !b.FileStream.IsPlaying() {
b.AddOutputLine("No file playing") b.AddOutputLine("No file playing")
return return
} }
err := b.FileStream.Stop() err := b.FileStream.Stop()
if err != nil { if err != nil {
b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error())) b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
return return
} }
// Disable stereo encoder when file stops // Disable stereo encoder when file stops
b.Client.DisableStereoEncoder() b.Client.DisableStereoEncoder()
b.AddOutputLine("File playback stopped") b.AddOutputLine("File playback stopped")
// Note: We keep transmission active even after file stops // 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 // User can manually stop with talk key or it will stop when they're done talking
b.UpdateGeneralStatus(" Idle ", false) b.UpdateGeneralStatus(" Idle ", false)
} }
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
if b.Tx && val == 1 { if b.Tx && val == 1 {
return return
} }
if b.Tx == false && val == 0 { if b.Tx == false && val == 0 {
return return
} }
if b.Tx { if b.Tx {
b.Notify("micdown", "me", "") b.Notify("micdown", "me", "")
b.Tx = false b.Tx = false
b.UpdateGeneralStatus(" Idle ", false) b.UpdateGeneralStatus(" Idle ", false)
b.Stream.StopSource() b.Stream.StopSource()
} else if b.Connected == false { } else if b.Connected == false {
b.Notify("error", "me", "no tx while disconnected") b.Notify("error", "me", "no tx while disconnected")
b.Tx = false b.Tx = false
b.UpdateGeneralStatus("no tx while disconnected", true) b.UpdateGeneralStatus("no tx while disconnected", true)
} else if b.MutedChannels[b.Client.Self.Channel.ID] { } else if b.MutedChannels[b.Client.Self.Channel.ID] {
// Check if current channel is muted // Check if current channel is muted
b.Notify("error", "me", "cannot transmit in muted channel") b.Notify("error", "me", "cannot transmit in muted channel")
b.Tx = false b.Tx = false
b.UpdateGeneralStatus("cannot transmit in muted channel", true) b.UpdateGeneralStatus("cannot transmit in muted channel", true)
} else { } else {
b.Tx = true b.Tx = true
err := b.Stream.StartSource(b.UserConfig.GetInputDevice()) err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
if err != nil { if err != nil {
b.Notify("error", "me", err.Error()) b.Notify("error", "me", err.Error())
b.UpdateGeneralStatus(err.Error(), true) b.UpdateGeneralStatus(err.Error(), true)
} else { } else {
b.Notify("micup", "me", "") b.Notify("micup", "me", "")
b.UpdateGeneralStatus(" Tx ", true) b.UpdateGeneralStatus(" Tx ", true)
} }
} }
} }
func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) {
b.Stream.SetMicVolume(-0.1, true) b.Stream.SetMicVolume(-0.1, true)
b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
} }
func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
b.Stream.SetMicVolume(0.1, true) b.Stream.SetMicVolume(0.1, true)
b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
} }
func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) {
b.Client.Disconnect() b.StopRecordingIfActive(true)
b.Ui.Close() b.Client.Disconnect()
b.Ui.Close()
} }
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) { func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
b.Client.Disconnect() b.StopRecordingIfActive(true)
b.Ui.Close() b.Client.Disconnect()
b.Ui.Close()
} }
func (b *Barnard) CommandStatus(ui *uiterm.Ui, cmd string) { func (b *Barnard) CommandStatus(ui *uiterm.Ui, cmd string) {
if b.Tx { if b.Tx {
b.Notify("status", "me", "transmitting") b.Notify("status", "me", "transmitting")
} else { } else {
b.Notify("status", "me", "not transmitting") b.Notify("status", "me", "not transmitting")
} }
} }
func (b *Barnard) OnClearPress(ui *uiterm.Ui, key uiterm.Key) { 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) { 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) { 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) { 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) { 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) { func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
active := b.Ui.Active() active := b.Ui.Active()
if active == uiViewInput { if active == uiViewInput {
b.Ui.SetActive(uiViewTree) b.Ui.SetActive(uiViewTree)
} else if active == uiViewTree { } else if active == uiViewTree {
b.Ui.SetActive(uiViewInput) b.Ui.SetActive(uiViewInput)
} }
width, height := termbox.Size() width, height := termbox.Size()
b.OnUiResize(ui, width, height) b.OnUiResize(ui, width, height)
ui.Refresh() ui.Refresh()
} }
func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) { func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) {
if text == "" { if text == "" {
return return
} }
// Check if this is a command (starts with /) // Check if this is a command (starts with /)
if strings.HasPrefix(text, "/") { if strings.HasPrefix(text, "/") {
// Remove the leading slash and process as command // Remove the leading slash and process as command
cmdText := strings.TrimPrefix(text, "/") cmdText := strings.TrimPrefix(text, "/")
parts := strings.SplitN(cmdText, " ", 2) parts := strings.SplitN(cmdText, " ", 2)
cmdName := parts[0] cmdName := parts[0]
cmdArgs := "" cmdArgs := ""
if len(parts) > 1 { if len(parts) > 1 {
cmdArgs = parts[1] cmdArgs = parts[1]
} }
// Handle built-in commands // Handle built-in commands
switch cmdName { switch cmdName {
case "file": case "file":
b.CommandPlayFile(ui, cmdArgs) b.CommandPlayFile(ui, cmdArgs)
case "stop": case "stop":
b.CommandStopFile(ui, cmdArgs) b.CommandStopFile(ui, cmdArgs)
case "exit": case "exit":
b.CommandExit(ui, cmdArgs) b.CommandExit(ui, cmdArgs)
case "status": case "status":
b.CommandStatus(ui, cmdArgs) b.CommandStatus(ui, cmdArgs)
case "noise": case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs) b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "micup": case "record":
b.CommandMicUp(ui, cmdArgs) b.CommandRecord(ui, cmdArgs)
case "micdown": case "micup":
b.CommandMicDown(ui, cmdArgs) b.CommandMicUp(ui, cmdArgs)
case "toggle", "talk": case "micdown":
b.CommandTalk(ui, cmdArgs) b.CommandMicDown(ui, cmdArgs)
default: case "toggle", "talk":
b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName)) b.CommandTalk(ui, cmdArgs)
} default:
return b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
} }
return
}
// Not a command, send as chat message // Not a command, send as chat message
if b.Client != nil && b.Client.Self != nil { if b.Client != nil && b.Client.Self != nil {
if b.selectedUser != nil { if b.selectedUser != nil {
b.selectedUser.Send(text) b.selectedUser.Send(text)
b.AddOutputPrivateMessage(b.Client.Self, b.selectedUser, text) b.AddOutputPrivateMessage(b.Client.Self, b.selectedUser, text)
} else { } else {
b.Client.Self.Channel.Send(text, false) b.Client.Self.Channel.Send(text, false)
b.AddOutputMessage(b.Client.Self, text) b.AddOutputMessage(b.Client.Self, text)
} }
} }
} }
func (b *Barnard) GotoChat() { func (b *Barnard) GotoChat() {
b.OnFocusPress(b.Ui, uiterm.KeyTab) b.OnFocusPress(b.Ui, uiterm.KeyTab)
} }
func (b *Barnard) OnUiDoneInitialize(ui *uiterm.Ui) { func (b *Barnard) OnUiDoneInitialize(ui *uiterm.Ui) {
b.start() b.start()
} }
func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
ui.Add(uiViewLogo, &uiterm.Label{ ui.Add(uiViewLogo, &uiterm.Label{
Text: "Barnard ", Text: "Barnard ",
Fg: uiterm.ColorWhite | uiterm.AttrBold, Fg: uiterm.ColorWhite | uiterm.AttrBold,
Bg: uiterm.ColorMagenta, Bg: uiterm.ColorMagenta,
}) })
b.UiStatus = uiterm.Label{ b.UiStatus = uiterm.Label{
Text: " Idle ", Text: " Idle ",
Fg: uiterm.ColorBlack, Fg: uiterm.ColorBlack,
Bg: uiterm.ColorWhite, Bg: uiterm.ColorWhite,
} }
ui.Add(uiViewStatus, &b.UiStatus) ui.Add(uiViewStatus, &b.UiStatus)
b.UiInput = uiterm.Textbox{ b.UiInput = uiterm.Textbox{
Fg: uiterm.ColorWhite, Fg: uiterm.ColorWhite,
Bg: uiterm.ColorBlack, Bg: uiterm.ColorBlack,
Input: b.OnTextInput, Input: b.OnTextInput,
} }
ui.Add(uiViewInput, &b.UiInput) ui.Add(uiViewInput, &b.UiInput)
b.UiInputStatus = uiterm.Label{ b.UiInputStatus = uiterm.Label{
Fg: uiterm.ColorBlack, Fg: uiterm.ColorBlack,
Bg: uiterm.ColorWhite, Bg: uiterm.ColorWhite,
} }
ui.Add(uiViewInputStatus, &b.UiInputStatus) ui.Add(uiViewInputStatus, &b.UiInputStatus)
b.UiOutput = uiterm.Textview{ b.UiOutput = uiterm.Textview{
Fg: uiterm.ColorWhite, Fg: uiterm.ColorWhite,
Bg: uiterm.ColorBlack, Bg: uiterm.ColorBlack,
} }
ui.Add(uiViewOutput, &b.UiOutput) ui.Add(uiViewOutput, &b.UiOutput)
b.UiTree = uiterm.Tree{ b.UiTree = uiterm.Tree{
Generator: b.TreeItemBuild, Generator: b.TreeItemBuild,
KeyListener: b.TreeItemKeyPress, KeyListener: b.TreeItemKeyPress,
CharacterListener: b.TreeItemCharacter, CharacterListener: b.TreeItemCharacter,
Fg: uiterm.ColorWhite, Fg: uiterm.ColorWhite,
Bg: uiterm.ColorBlack, Bg: uiterm.ColorBlack,
} }
ui.Add(uiViewTree, &b.UiTree) ui.Add(uiViewTree, &b.UiTree)
b.Ui.AddCommandListener(b.CommandMicUp, "micup") b.Ui.AddCommandListener(b.CommandMicUp, "micup")
b.Ui.AddCommandListener(b.CommandMicDown, "micdown") b.Ui.AddCommandListener(b.CommandMicDown, "micdown")
b.Ui.AddCommandListener(b.CommandTalk, "toggle") b.Ui.AddCommandListener(b.CommandTalk, "toggle")
b.Ui.AddCommandListener(b.CommandTalk, "talk") b.Ui.AddCommandListener(b.CommandTalk, "talk")
b.Ui.AddCommandListener(b.CommandExit, "exit") b.Ui.AddCommandListener(b.CommandExit, "exit")
b.Ui.AddCommandListener(b.CommandStatus, "status") b.Ui.AddCommandListener(b.CommandStatus, "status")
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) b.Ui.AddCommandListener(b.CommandRecord, "record")
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect) b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp) b.Ui.AddKeyListener(b.OnRecordingToggle, b.Hotkeys.RecordToggle)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop) b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom) b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
b.Ui.SetActive(uiViewInput) b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop)
b.UiTree.Rebuild() b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom)
b.Ui.Refresh() b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
b.Ui.Refresh()
} }
func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) { func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) {
treeHeight := 0 treeHeight := 0
outputHeight := 0 outputHeight := 0
active := b.Ui.Active() active := b.Ui.Active()
if active == uiViewTree { if active == uiViewTree {
treeHeight = height - 4 treeHeight = height - 4
outputHeight = 0 outputHeight = 0
} else { } else {
treeHeight = 0 treeHeight = 0
outputHeight = height - 4 outputHeight = height - 4
} }
ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1) ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1)
ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1) ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1)
ui.SetBounds(uiViewStatus, 0, height-2, width, height-1) ui.SetBounds(uiViewStatus, 0, height-2, width, height-1)
ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height) ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height)
ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height) ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height)
} }