Remove voice effects

This commit is contained in:
Storm Dragon
2026-07-14 18:41:50 -04:00
parent e1ae5abab9
commit 9bc514e74f
9 changed files with 11 additions and 526 deletions
-21
View File
@@ -18,26 +18,6 @@ If a user is too soft to hear, you can boost their audio.
The audio should drastically increase once you have hit the VolumeUp key over 10 times (from the silent/0 position).
The boost setting is saved per user, just like per user volume.
## Voice Effects
Barnard includes real-time voice effects that can be applied to your outgoing microphone audio. Press F12 to cycle through the available effects.
### Available Effects
- **None**: No effect applied (default)
- **Echo**: Single repeating delay effect with feedback (250ms) - creates distinct repetitions that fade away
- **Reverb**: Multiple short delays (12.5ms, 20ms, 33ms) without feedback - adds thickness and fullness to your voice
- **High Pitch**: Chipmunk-style voice using pitch shifting
- **Low Pitch**: Deep voice using pitch shifting
- **Robot**: Ring modulation effect for robotic sound
- **Chorus**: Layered voices with slight pitch variations for a rich, ensemble sound
### Controls
- **F12 key**: Cycle through voice effects (configurable hotkey)
- **Configuration**: Your selected effect is saved in `~/.barnard.toml`
### How It Works
Voice effects are applied to your outgoing audio in real-time, after noise suppression and automatic gain control. The effects use various digital signal processing techniques including delay lines, pitch shifting with cubic interpolation, and ring modulation.
## Noise Suppression
Barnard includes RNNoise-based real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
@@ -265,7 +245,6 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F11</kbd>: open actions menu for the focused tree item
- <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree
-408
View File
@@ -1,408 +0,0 @@
package audio
import (
"math"
)
// VoiceEffect represents different voice effect types
type VoiceEffect int
const (
EffectNone VoiceEffect = iota
EffectEcho
EffectReverb
EffectHighPitch
EffectLowPitch
EffectRobot
EffectChorus
EffectCount // Keep this last for cycling
)
// String returns the name of the effect
func (e VoiceEffect) String() string {
switch e {
case EffectNone:
return "None"
case EffectEcho:
return "Echo"
case EffectReverb:
return "Reverb"
case EffectHighPitch:
return "High Pitch"
case EffectLowPitch:
return "Low Pitch"
case EffectRobot:
return "Robot"
case EffectChorus:
return "Chorus"
default:
return "Unknown"
}
}
// EffectsProcessor handles voice effects processing
type EffectsProcessor struct {
currentEffect VoiceEffect
enabled bool
// Echo parameters
echoDelay int // Delay in samples
echoFeedback float32 // Echo feedback amount (0-1)
echoMix float32 // Mix of echo with original (0-1)
echoBuffer []int16 // Circular buffer for echo
echoPosition int // Current position in echo buffer
// Reverb buffer
reverseInputBuffer []int16 // Delay line for reverb
reverseInputPos int // Write position in buffer
// Pitch shift parameters
pitchRatio float32 // Pitch shift ratio
pitchBuffer []int16 // Buffer for pitch shifting
pitchPhase float32 // Phase accumulator for resampling
// Robot voice parameters
robotFreq float32 // Modulation frequency
robotPhase float32 // Phase accumulator
sampleRate float32 // Audio sample rate
// Chorus parameters
chorusDelays []int // Multiple delay times
chorusBuffers [][]int16 // Multiple delay buffers
chorusPositions []int // Positions in chorus buffers
chorusRates []float32 // LFO rates for each chorus voice
chorusPhases []float32 // LFO phases
}
// NewEffectsProcessor creates a new voice effects processor
func NewEffectsProcessor(sampleRate int) *EffectsProcessor {
echoDelay := sampleRate / 4 // 250ms delay
return &EffectsProcessor{
currentEffect: EffectNone,
enabled: true,
sampleRate: float32(sampleRate),
// Echo setup
echoDelay: echoDelay,
echoFeedback: 0.4,
echoMix: 0.5,
echoBuffer: make([]int16, echoDelay),
echoPosition: 0,
// Reverb setup - 100ms buffer for delay lines
reverseInputBuffer: make([]int16, sampleRate/10), // 100ms buffer
reverseInputPos: 0,
// Pitch shift setup
pitchRatio: 1.0,
pitchBuffer: make([]int16, 4096),
pitchPhase: 0.0,
// Robot voice setup
robotFreq: 30.0, // 30 Hz modulation
robotPhase: 0.0,
// Chorus setup (3 voices)
chorusDelays: []int{sampleRate/50, sampleRate/40, sampleRate/35}, // ~20-30ms
chorusBuffers: make([][]int16, 3),
chorusPositions: make([]int, 3),
chorusRates: []float32{1.5, 2.0, 2.3}, // LFO rates in Hz
chorusPhases: make([]float32, 3),
}
}
// GetCurrentEffect returns the current effect
func (ep *EffectsProcessor) GetCurrentEffect() VoiceEffect {
return ep.currentEffect
}
// SetEffect sets the current effect
func (ep *EffectsProcessor) SetEffect(effect VoiceEffect) {
if effect >= 0 && effect < EffectCount {
ep.currentEffect = effect
ep.resetBuffers()
}
}
// CycleEffect cycles to the next effect
func (ep *EffectsProcessor) CycleEffect() VoiceEffect {
ep.currentEffect = (ep.currentEffect + 1) % EffectCount
ep.resetBuffers()
return ep.currentEffect
}
// SetEnabled enables or disables effects processing
func (ep *EffectsProcessor) SetEnabled(enabled bool) {
ep.enabled = enabled
}
// IsEnabled returns whether effects are enabled
func (ep *EffectsProcessor) IsEnabled() bool {
return ep.enabled
}
// ProcessSamples applies the current voice effect to audio samples
func (ep *EffectsProcessor) ProcessSamples(samples []int16) {
if !ep.enabled || ep.currentEffect == EffectNone || len(samples) == 0 {
return
}
switch ep.currentEffect {
case EffectEcho:
ep.processEcho(samples)
case EffectReverb:
ep.processReverb(samples)
case EffectHighPitch:
ep.processPitchShift(samples, 1.5)
case EffectLowPitch:
ep.processPitchShift(samples, 0.75)
case EffectRobot:
ep.processRobot(samples)
case EffectChorus:
ep.processChorus(samples)
}
}
// processEcho applies echo effect
func (ep *EffectsProcessor) processEcho(samples []int16) {
for i := range samples {
// Get delayed sample
delayedSample := ep.echoBuffer[ep.echoPosition]
// Mix original with echo
outputSample := float32(samples[i])*(1.0-ep.echoMix) +
float32(delayedSample)*ep.echoMix
// Create new echo sample (current + feedback)
newEchoSample := float32(samples[i]) + float32(delayedSample)*ep.echoFeedback
// Store in buffer with clipping
if newEchoSample > 32767 {
newEchoSample = 32767
} else if newEchoSample < -32767 {
newEchoSample = -32767
}
ep.echoBuffer[ep.echoPosition] = int16(newEchoSample)
// Advance buffer position
ep.echoPosition = (ep.echoPosition + 1) % len(ep.echoBuffer)
// Apply to output with clipping
if outputSample > 32767 {
outputSample = 32767
} else if outputSample < -32767 {
outputSample = -32767
}
samples[i] = int16(outputSample)
}
}
// processReverb applies reverb effect - like echo but with multiple short delays
func (ep *EffectsProcessor) processReverb(samples []int16) {
bufLen := len(ep.reverseInputBuffer)
// Three quick echoes instead of one long repeating echo
delays := []int{
bufLen / 8, // ~12.5ms
bufLen / 5, // ~20ms
bufLen / 3, // ~33ms
}
gains := []float32{0.3, 0.2, 0.15}
for i := range samples {
// Store current sample
ep.reverseInputBuffer[ep.reverseInputPos] = samples[i]
// Add the three quick echoes
reverbSample := float32(0)
for j := 0; j < len(delays); j++ {
readPos := (ep.reverseInputPos - delays[j] + bufLen) % bufLen
reverbSample += float32(ep.reverseInputBuffer[readPos]) * gains[j]
}
// Mix dry and wet signal
outputSample := float32(samples[i])*0.7 + reverbSample
// Advance position
ep.reverseInputPos = (ep.reverseInputPos + 1) % bufLen
// Apply with clipping
if outputSample > 32767 {
outputSample = 32767
} else if outputSample < -32767 {
outputSample = -32767
}
samples[i] = int16(outputSample)
}
}
// processPitchShift applies pitch shifting using cubic interpolation
func (ep *EffectsProcessor) processPitchShift(samples []int16, ratio float32) {
if ratio == 1.0 {
return
}
bufLen := len(ep.pitchBuffer)
// Copy samples to pitch buffer (maintaining history)
copy(ep.pitchBuffer[bufLen-len(samples):], samples)
// Resample using cubic interpolation for smoother output
for i := range samples {
// Calculate source position
srcPos := float32(bufLen-len(samples)) + float32(i)*ratio
// Bounds check with extra padding for cubic interpolation
if srcPos >= float32(bufLen-2) {
srcPos = float32(bufLen - 3)
}
if srcPos < 1 {
srcPos = 1
}
// Cubic interpolation (Hermite interpolation)
idx := int(srcPos)
frac := srcPos - float32(idx)
// Get 4 samples around the target position
y0 := float32(ep.pitchBuffer[idx-1])
y1 := float32(ep.pitchBuffer[idx])
y2 := float32(ep.pitchBuffer[idx+1])
y3 := float32(ep.pitchBuffer[idx+2])
// Cubic Hermite interpolation
c0 := y1
c1 := 0.5 * (y2 - y0)
c2 := y0 - 2.5*y1 + 2.0*y2 - 0.5*y3
c3 := 0.5*(y3-y0) + 1.5*(y1-y2)
interpolated := c0 + c1*frac + c2*frac*frac + c3*frac*frac*frac
// Soft clipping to reduce harshness
if interpolated > 32767 {
interpolated = 32767
} else if interpolated < -32767 {
interpolated = -32767
}
samples[i] = int16(interpolated)
}
// Shift buffer for next frame
copy(ep.pitchBuffer, ep.pitchBuffer[len(samples):])
}
// processRobot applies ring modulation for robot voice
func (ep *EffectsProcessor) processRobot(samples []int16) {
phaseIncrement := 2.0 * math.Pi * ep.robotFreq / ep.sampleRate
for i := range samples {
// Generate carrier wave (sine wave)
carrier := float32(math.Sin(float64(ep.robotPhase)))
// Ring modulation: multiply signal by carrier
modulated := float32(samples[i]) * (0.5 + carrier*0.5)
// Advance phase
ep.robotPhase += phaseIncrement
if ep.robotPhase >= 2.0*math.Pi {
ep.robotPhase -= 2.0 * math.Pi
}
// Apply with clipping
if modulated > 32767 {
modulated = 32767
} else if modulated < -32767 {
modulated = -32767
}
samples[i] = int16(modulated)
}
}
// processChorus applies chorus effect with multiple delayed voices
func (ep *EffectsProcessor) processChorus(samples []int16) {
// Initialize chorus buffers if needed
for j := range ep.chorusBuffers {
if len(ep.chorusBuffers[j]) == 0 {
ep.chorusBuffers[j] = make([]int16, ep.chorusDelays[j])
}
}
for i := range samples {
output := float32(samples[i]) * 0.4 // Original signal at 40%
// Add multiple chorus voices
for j := 0; j < len(ep.chorusDelays); j++ {
// LFO modulation for slight pitch variation
lfoPhaseInc := 2.0 * math.Pi * ep.chorusRates[j] / ep.sampleRate
lfo := float32(math.Sin(float64(ep.chorusPhases[j])))
ep.chorusPhases[j] += lfoPhaseInc
if ep.chorusPhases[j] >= 2.0*math.Pi {
ep.chorusPhases[j] -= 2.0 * math.Pi
}
// Get delayed sample with LFO modulation
modDelay := int(float32(ep.chorusDelays[j]) * (1.0 + lfo*0.03))
if modDelay >= len(ep.chorusBuffers[j]) {
modDelay = len(ep.chorusBuffers[j]) - 1
}
readPos := (ep.chorusPositions[j] - modDelay + len(ep.chorusBuffers[j])) % len(ep.chorusBuffers[j])
delayedSample := ep.chorusBuffers[j][readPos]
// Add this voice to output (20% each)
output += float32(delayedSample) * 0.2
// Store current sample in buffer
ep.chorusBuffers[j][ep.chorusPositions[j]] = samples[i]
ep.chorusPositions[j] = (ep.chorusPositions[j] + 1) % len(ep.chorusBuffers[j])
}
// Apply with clipping
if output > 32767 {
output = 32767
} else if output < -32767 {
output = -32767
}
samples[i] = int16(output)
}
}
// resetBuffers clears all effect buffers
func (ep *EffectsProcessor) resetBuffers() {
// Clear echo buffer
for i := range ep.echoBuffer {
ep.echoBuffer[i] = 0
}
ep.echoPosition = 0
// Clear reverb buffer
for i := range ep.reverseInputBuffer {
ep.reverseInputBuffer[i] = 0
}
ep.reverseInputPos = 0
// Clear pitch buffer
for i := range ep.pitchBuffer {
ep.pitchBuffer[i] = 0
}
ep.pitchPhase = 0
// Reset robot phase
ep.robotPhase = 0
// Clear chorus buffers
for j := range ep.chorusBuffers {
if len(ep.chorusBuffers[j]) > 0 {
for i := range ep.chorusBuffers[j] {
ep.chorusBuffers[j][i] = 0
}
}
ep.chorusPositions[j] = 0
ep.chorusPhases[j] = 0
}
}
-4
View File
@@ -4,7 +4,6 @@ import (
"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"
@@ -59,9 +58,6 @@ type Barnard struct {
// Added for noise suppression
NoiseSuppressor *noise.Suppressor
// Added for voice effects
VoiceEffects *audio.EffectsProcessor
// Added for file playback
FileStream *fileplayback.Player
FileStreamMutex sync.Mutex
-1
View File
@@ -53,7 +53,6 @@ func (b *Barnard) connect(reconnect bool) bool {
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()
-1
View File
@@ -21,5 +21,4 @@ type Hotkeys struct {
ScrollToBottom *uiterm.Key
AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
CycleVoiceEffect *uiterm.Key
}
-22
View File
@@ -27,7 +27,6 @@ type exportableConfig struct {
Username *string
NotifyCommand *string
NoiseSuppressionEnabled *bool
VoiceEffect *int
Certificate *string
RecordingFormat *string
RecordingDirectory *string
@@ -83,7 +82,6 @@ func (c *Config) LoadConfig() {
ScrollDown: key(uiterm.KeyPgdn),
AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
if fileExists(c.fn) {
var data []byte
@@ -130,10 +128,6 @@ func (c *Config) LoadConfig() {
enabled := false
jc.NoiseSuppressionEnabled = &enabled
}
if c.config.VoiceEffect == nil {
effect := 0 // Default to EffectNone
jc.VoiceEffect = &effect
}
if c.config.Certificate == nil {
cert := string("")
jc.Certificate = &cert
@@ -166,7 +160,6 @@ func (c *Config) ensureHotkeys() {
ScrollDown: key(uiterm.KeyPgdn),
AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil {
@@ -208,9 +201,6 @@ func (c *Config) ensureHotkeys() {
if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
}
if hotkeys.CycleVoiceEffect == nil {
hotkeys.CycleVoiceEffect = defaults.CycleVoiceEffect
}
}
func (c *Config) findServer(address string) *server {
@@ -319,18 +309,6 @@ func (c *Config) SetNoiseSuppressionEnabled(enabled bool) {
c.SaveConfig()
}
func (c *Config) GetVoiceEffect() int {
if c.config.VoiceEffect == nil {
return 0
}
return *c.config.VoiceEffect
}
func (c *Config) SetVoiceEffect(effect int) {
c.config.VoiceEffect = &effect
c.SaveConfig()
}
func (c *Config) GetRecordingFormat() string {
if c.config.RecordingFormat == nil {
return "flac"
+11 -57
View File
@@ -19,12 +19,6 @@ type NoiseProcessor interface {
IsEnabled() bool
}
// EffectsProcessor interface for voice effects
type EffectsProcessor interface {
ProcessSamples(samples []int16)
IsEnabled() bool
}
// FilePlayer interface for file playback
type FilePlayer interface {
GetAudioFrame() []int16
@@ -72,15 +66,13 @@ type Stream struct {
deviceSink *openal.Device
contextSink *openal.Context
noiseProcessor NoiseProcessor
noiseProcessorRight NoiseProcessor
micAGC *audio.AGC
micAGCRight *audio.AGC
effectsProcessor EffectsProcessor
effectsProcessorRight EffectsProcessor
filePlayer FilePlayer
recorderMu sync.RWMutex
recorder Recorder
noiseProcessor NoiseProcessor
noiseProcessorRight NoiseProcessor
micAGC *audio.AGC
micAGCRight *audio.AGC
filePlayer FilePlayer
recorderMu sync.RWMutex
recorder Recorder
}
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
@@ -153,15 +145,6 @@ func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
s.noiseProcessorRight = cloneNoiseProcessor(np)
}
func (s *Stream) SetEffectsProcessor(ep EffectsProcessor) {
s.effectsProcessor = ep
s.effectsProcessorRight = cloneEffectsProcessor(ep)
}
func (s *Stream) GetEffectsProcessor() EffectsProcessor {
return s.effectsProcessor
}
func (s *Stream) SetFilePlayer(fp FilePlayer) {
s.filePlayer = fp
}
@@ -542,7 +525,7 @@ func scaleForRecording(sample int16, volume float32) int16 {
}
func (s *Stream) processMonoSamples(samples []int16) {
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
s.processChannel(samples, s.noiseProcessor, s.micAGC)
}
func (s *Stream) processStereoSamples(samples []int16, frameSize int) {
@@ -562,8 +545,8 @@ func (s *Stream) processStereoSamples(samples []int16, frameSize int) {
right[i] = samples[idx+1]
}
s.processChannel(left, s.noiseProcessor, s.micAGC, s.effectsProcessor)
s.processChannel(right, s.noiseProcessorRight, s.micAGCRight, s.effectsProcessorRight)
s.processChannel(left, s.noiseProcessor, s.micAGC)
s.processChannel(right, s.noiseProcessorRight, s.micAGCRight)
for i := 0; i < frameSize; i++ {
idx := i * 2
@@ -572,16 +555,13 @@ func (s *Stream) processStereoSamples(samples []int16, frameSize int) {
}
}
func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor, micAGC *audio.AGC, effectsProcessor EffectsProcessor) {
func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor, micAGC *audio.AGC) {
if noiseProcessor != nil && noiseProcessor.IsEnabled() {
noiseProcessor.ProcessSamples(samples)
}
if micAGC != nil {
micAGC.ProcessSamples(samples)
}
if effectsProcessor != nil && effectsProcessor.IsEnabled() {
effectsProcessor.ProcessSamples(samples)
}
}
func (s *Stream) ensureStereoProcessors() {
@@ -591,9 +571,6 @@ func (s *Stream) ensureStereoProcessors() {
if s.noiseProcessorRight == nil {
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
}
if s.effectsProcessorRight == nil {
s.effectsProcessorRight = cloneEffectsProcessor(s.effectsProcessor)
}
}
func (s *Stream) syncStereoProcessors() {
@@ -605,16 +582,6 @@ func (s *Stream) syncStereoProcessors() {
}
}
leftEffects, leftOk := s.effectsProcessor.(*audio.EffectsProcessor)
rightEffects, rightOk := s.effectsProcessorRight.(*audio.EffectsProcessor)
if leftOk && rightOk {
if leftEffects.IsEnabled() != rightEffects.IsEnabled() {
rightEffects.SetEnabled(leftEffects.IsEnabled())
}
if leftEffects.GetCurrentEffect() != rightEffects.GetCurrentEffect() {
rightEffects.SetEffect(leftEffects.GetCurrentEffect())
}
}
}
func cloneNoiseProcessor(np NoiseProcessor) NoiseProcessor {
@@ -628,16 +595,3 @@ func cloneNoiseProcessor(np NoiseProcessor) NoiseProcessor {
}
return nil
}
func cloneEffectsProcessor(ep EffectsProcessor) EffectsProcessor {
if ep == nil {
return nil
}
if processor, ok := ep.(*audio.EffectsProcessor); ok {
clone := audio.NewEffectsProcessor(gumble.AudioSampleRate)
clone.SetEnabled(processor.IsEnabled())
clone.SetEffect(processor.GetCurrentEffect())
return clone
}
return nil
}
-5
View File
@@ -15,7 +15,6 @@ import (
"strings"
"syscall"
"git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/gumble/gumble"
@@ -181,7 +180,6 @@ func main() {
Address: *server,
MutedChannels: make(map[uint32]bool),
NoiseSuppressor: noise.NewSuppressor(),
VoiceEffects: audio.NewEffectsProcessor(gumble.AudioSampleRate),
}
b.Config.Buffers = *buffers
@@ -196,9 +194,6 @@ func main() {
}
b.NoiseSuppressor.SetEnabled(enabled)
// Configure voice effects
b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect()))
b.Config.Username = *username
b.Config.Password = *password
-7
View File
@@ -109,12 +109,6 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
}
}
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)
}
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
b.statusText = text
b.statusNotice = notice
@@ -504,7 +498,6 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
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)