Compare commits

...

18 Commits

Author SHA1 Message Date
Storm Dragon
e84cb67500 Noise suppression tweaks. 2026-02-21 02:08:55 -05:00
Storm Dragon
3db526f42b Noise suppression tweaks. 2026-02-21 02:08:30 -05:00
Storm Dragon
e3b6eac2a0 Support for stereo mic. 2026-02-09 22:33:17 -05:00
Storm Dragon
cfbefd3f7d Updated barnard to use openal for sound by default. This should bring with it pipewire or pulseaudio support. 2025-12-16 15:44:10 -05:00
Storm Dragon
9fe7d7ad87 Lol follow shellcheck suggestions too closely. The ui should work again. 2025-12-12 21:37:48 -05:00
Storm Dragon
0700264afe Work on supporting and using certificates. 2025-12-12 18:34:23 -05:00
Storm Dragon
331055ab18 Got too trigger happy with the deleting when I fixed the username stuff. Should be back to working now. 2025-12-12 17:07:18 -05:00
Storm Dragon
6d1944d155 Forgot to fix barnard-us, now usernames should work again. 2025-12-12 16:15:41 -05:00
Storm Dragon
e3f90a76c0 Fix problem with not being notified of disconnects. 2025-12-12 15:13:37 -05:00
Storm Dragon
dd425563ba Experimental changes, switched to toml instead of yaml. Hopefully everyone will like this as much as I do. 2025-12-10 20:52:37 -05:00
Storm Dragon
bb80b13c36 A minor tweak in volume. 2025-12-10 11:20:08 -05:00
Storm Dragon
01da54882e Boost microphone a bit so we get volumes more like actual mumble. 2025-12-10 11:14:52 -05:00
Storm Dragon
6191132620 Move time stamps to the end of the message instead of the beginning. 2025-11-30 20:41:44 -05:00
Storm Dragon
fae372bb78 Added /file and /stop commands. 2025-11-30 20:31:06 -05:00
Storm Dragon
f96cb1f79b Add real-time voice effects for outgoing audio
Implements 7 voice effects that can be cycled through with F12:
- None (default)
- Echo: Single repeating delay with feedback (250ms)
- Reverb: Multiple short delays without feedback
- High Pitch: Chipmunk voice using cubic interpolation
- Low Pitch: Deep voice effect
- Robot: Ring modulation for robotic sound
- Chorus: Layered voices with pitch variations

The effects are applied after noise suppression and AGC in the audio
pipeline. Selected effect is persisted to config file. Includes
comprehensive documentation in README.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 16:27:08 -04:00
Storm Dragon
82b308000d Add automatic gain control for outgoing microphone audio.
This addresses low microphone volume issues by automatically normalizing
outgoing audio levels with dynamic range compression and soft limiting.
The AGC is always enabled and applies voice-optimized parameters to
ensure consistent audio levels are sent to other users while preserving
manual volume control for incoming audio.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 15:33:18 -04:00
Storm Dragon
67d6ec2f37 Fix lock copying in handle_error function.
Changed handle_error to accept *Barnard pointer instead of copying struct by value, eliminating race condition warnings from mutex copying.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 22:52:35 -04:00
Storm Dragon
5ed445060c Show volume for users. 2025-08-28 22:41:08 -04:00
22 changed files with 2342 additions and 672 deletions

View File

@@ -18,13 +18,33 @@ 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 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. 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 ## Noise Suppression
Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds. Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
### Features ### Features
- **Real-time processing**: Noise suppression is applied during audio capture with minimal latency - **Real-time processing**: Noise suppression is applied during audio capture with minimal latency
- **Configurable threshold**: Adjustable noise gate threshold (default: 0.02) - **Configurable amount**: Adjustable suppression amount via threshold value (default: `0.08`)
- **Persistent settings**: Noise suppression preferences are saved in your configuration file - **Persistent settings**: Noise suppression preferences are saved in your configuration file
- **Multiple control methods**: Toggle via hotkey, command line flag, or FIFO commands - **Multiple control methods**: Toggle via hotkey, command line flag, or FIFO commands
@@ -32,15 +52,16 @@ Barnard includes real-time noise suppression for microphone input to filter out
- **F9 key**: Toggle noise suppression on/off (configurable hotkey) - **F9 key**: Toggle noise suppression on/off (configurable hotkey)
- **Command line**: Use `--noise-suppression` flag to enable at startup - **Command line**: Use `--noise-suppression` flag to enable at startup
- **FIFO command**: Send `noise` command to toggle during runtime - **FIFO command**: Send `noise` command to toggle during runtime
- **Configuration**: Set `noisesuppressionenabled` and `noisesuppressionthreshold` in `~/.barnard.yaml` - **Configuration**: Set `noisesuppressionenabled` and `noisesuppressionthreshold` in `~/.barnard.toml`
### Configuration Example ### Configuration Example
```yaml ```toml
noisesuppressionenabled: true noisesuppressionenabled = true
noisesuppressionthreshold: 0.02 noisesuppressionthreshold = 0.08
``` ```
The noise suppression algorithm uses a combination of high-pass filtering and noise gating to reduce unwanted background sounds while preserving voice quality. `noisesuppressionthreshold` accepts values from `0.0` to `1.0`, where higher values apply stronger suppression.
The noise suppression algorithm uses adaptive noise-floor tracking, transient suppression, and smoothed gain reduction to reduce background noise while preserving voice quality.
## FIFO Control ## FIFO Control
@@ -104,8 +125,8 @@ Our thanks go out to Tim Cooper for the massive amount of work put into this cli
## Config ## Config
By default, the file $HOME/.barnard.yaml will hold the configuration for Barnard. By default, the file $HOME/.barnard.toml will hold the configuration for Barnard.
You can have barnard read another file by using the -c option, like `./barnard -c ~/.anotherbarnard.yaml`. You can have barnard read another file by using the -c option, like `./barnard -c ~/.anotherbarnard.toml`.
It will be created automatically if it doesn't exist. It will be created automatically if it doesn't exist.
If you modify the config file while Barnard is running, your changes may be overwritten. If you modify the config file while Barnard is running, your changes may be overwritten.
@@ -121,6 +142,18 @@ Pass the -list_devices parameter to barnard to be given a list of audio input an
Copy lines from the above list into inputdevice and outputdevice as desired. Copy lines from the above list into inputdevice and outputdevice as desired.
To clear your inputdevice or outputdevice options and set them to defaults, set them to "" or delete them entirely. To clear your inputdevice or outputdevice options and set them to defaults, set them to "" or delete them entirely.
### Audio Backends (ALSA, PipeWire, PulseAudio)
Barnard uses OpenAL Soft for audio. By default it will pick the first available backend (often ALSA), but you can force a specific driver:
- Command line: `./barnard --audio-driver pipewire` (or `pulse`, `alsa`, `jack`)
- Config file: add `audiodriver = "pipewire"` to your `~/.barnard.toml`
- Environment: `ALSOFT_DRIVERS=pipewire ./barnard` (takes precedence over config)
If PipeWire or PulseAudio support is missing, install OpenAL Soft with the corresponding backend enabled (e.g., `libopenal1` or `openal-soft` packages built with PipeWire). After changing drivers, rerun with `--list_devices` to confirm the desired devices appear.
Leaving `audiodriver` empty in the config keeps the OpenAL default ordering (PipeWire/Pulse first if available, then ALSA).
## Keystrokes ## Keystrokes
You can see the below keystrokes in your config file. You can see the below keystrokes in your config file.
@@ -149,7 +182,7 @@ If Jim's volume is set to 0.1, and larry's volume is set to 0.9, lowering the ch
You can change the volume for a user once that user has spoken at least once during a session. You can change the volume for a user once that user has spoken at least once during a session.
Attempts to change the volume of a user who has not spoken will be ignored. Attempts to change the volume of a user who has not spoken will be ignored.
If you are unable to hear a user speaking, you can edit the .barnard.yaml file in your home directory, after closing Barnard, and set the volume parameter to 1.0 for a particular user. If you are unable to hear a user speaking, you can edit the .barnard.toml file in your home directory, after closing Barnard, and set the volume parameter to 1.0 for a particular user.
### Technical ### Technical
@@ -191,6 +224,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>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

159
audio/agc.go Normal file
View File

@@ -0,0 +1,159 @@
package audio
import (
"math"
)
// AGC (Automatic Gain Control) processor for voice normalization
type AGC struct {
targetLevel float32 // Target RMS level (0.0-1.0)
maxGain float32 // Maximum gain multiplier
minGain float32 // Minimum gain multiplier
attackTime float32 // Attack time coefficient
releaseTime float32 // Release time coefficient
currentGain float32 // Current gain value
envelope float32 // Signal envelope
enabled bool // Whether AGC is enabled
compThreshold float32 // Compression threshold
compRatio float32 // Compression ratio
}
// NewAGC creates a new AGC processor with sensible defaults for voice
func NewAGC() *AGC {
return &AGC{
targetLevel: 0.18, // Target 18% of max amplitude (balanced level)
maxGain: 8.0, // Maximum 8x gain (about 18dB)
minGain: 0.1, // Minimum 0.1x gain (-20dB)
attackTime: 0.005, // Fast attack (5ms)
releaseTime: 0.1, // Slower release (100ms)
currentGain: 1.0, // Start with unity gain
envelope: 0.0, // Start with zero envelope
enabled: true, // Enable by default
compThreshold: 0.7, // Compress signals above 70%
compRatio: 3.0, // 3:1 compression ratio
}
}
// ProcessSamples applies AGC processing to audio samples
func (agc *AGC) ProcessSamples(samples []int16) {
if !agc.enabled || len(samples) == 0 {
return
}
// Convert samples to float32 for processing
floatSamples := make([]float32, len(samples))
for i, sample := range samples {
floatSamples[i] = float32(sample) / 32768.0
}
// Calculate RMS level for gain control
rmsSum := float32(0.0)
for _, sample := range floatSamples {
rmsSum += sample * sample
}
rms := float32(math.Sqrt(float64(rmsSum / float32(len(floatSamples)))))
// Update envelope with peak detection for compression
peak := float32(0.0)
for _, sample := range floatSamples {
absample := float32(math.Abs(float64(sample)))
if absample > peak {
peak = absample
}
}
// Envelope following
if peak > agc.envelope {
agc.envelope += (peak - agc.envelope) * agc.attackTime
} else {
agc.envelope += (peak - agc.envelope) * agc.releaseTime
}
// Calculate desired gain based on RMS
var desiredGain float32
if rms > 0.001 { // Avoid division by zero for very quiet signals
desiredGain = agc.targetLevel / rms
} else {
desiredGain = agc.maxGain // Boost very quiet signals
}
// Apply gain limits
if desiredGain > agc.maxGain {
desiredGain = agc.maxGain
}
if desiredGain < agc.minGain {
desiredGain = agc.minGain
}
// Smooth gain changes
if desiredGain > agc.currentGain {
agc.currentGain += (desiredGain - agc.currentGain) * agc.attackTime
} else {
agc.currentGain += (desiredGain - agc.currentGain) * agc.releaseTime
}
// Apply AGC gain to samples
for i, sample := range floatSamples {
processed := sample * agc.currentGain
// Apply compression for loud signals
if agc.envelope > agc.compThreshold {
// Calculate compression amount
overage := agc.envelope - agc.compThreshold
compAmount := overage / agc.compRatio
compGain := (agc.compThreshold + compAmount) / agc.envelope
processed *= compGain
}
// Soft limiting to prevent clipping
if processed > 0.90 {
processed = 0.90 + (processed-0.90)*0.1
} else if processed < -0.90 {
processed = -0.90 + (processed+0.90)*0.1
}
// Convert back to int16
intSample := int32(processed * 32767.0)
if intSample > 32767 {
intSample = 32767
} else if intSample < -32767 {
intSample = -32767
}
samples[i] = int16(intSample)
}
}
// SetEnabled enables or disables AGC processing
func (agc *AGC) SetEnabled(enabled bool) {
agc.enabled = enabled
}
// IsEnabled returns whether AGC is enabled
func (agc *AGC) IsEnabled() bool {
return agc.enabled
}
// SetTargetLevel sets the target RMS level (0.0-1.0)
func (agc *AGC) SetTargetLevel(level float32) {
if level > 0.0 && level < 1.0 {
agc.targetLevel = level
}
}
// SetMaxGain sets the maximum gain multiplier
func (agc *AGC) SetMaxGain(gain float32) {
if gain > 1.0 && gain <= 10.0 {
agc.maxGain = gain
}
}
// GetCurrentGain returns the current gain being applied
func (agc *AGC) GetCurrentGain() float32 {
return agc.currentGain
}
// Reset resets the AGC state
func (agc *AGC) Reset() {
agc.currentGain = 1.0
agc.envelope = 0.0
}

408
audio/effects.go Normal file
View File

@@ -0,0 +1,408 @@
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
}
}

View File

@@ -85,7 +85,7 @@ menulist() {
# returns: selected tag # returns: selected tag
local i local i
local menuList local menuList
for i in $@ ; do for i in "$@" ; do
menuList+=("$i" "$i") menuList+=("$i" "$i")
done done
dialog --backtitle "$(gettext "Use the up and down arrow keys to find the option you want, then press enter to select it.")" \ dialog --backtitle "$(gettext "Use the up and down arrow keys to find the option you want, then press enter to select it.")" \
@@ -135,10 +135,16 @@ connect() {
if [[ -z "$serverName" || "$serverName" == "Go Back" ]]; then if [[ -z "$serverName" || "$serverName" == "Go Back" ]]; then
return return
fi fi
local username="$(grep '^username: .*$' ~/.barnard.yaml 2> /dev/null | cut -d ' ' -f2-)" local username
username="$(grep -m 1 '^Username = ' ~/.barnard.toml 2> /dev/null | cut -d '=' -f2- | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;s/'//g")"
username="${username//[[:space:]]/_}" username="${username//[[:space:]]/_}"
username="${username//\"/}" username="${username:-${USER}-${HOSTNAME}}"
command barnard -username "${username:-${USER}-${HOSTNAME}}" -server ${mumbleServerList[$serverName]} --fifo ~/.config/barnard/cmd --buffers 16 |& log local certArgs=()
if [[ -f "$certFile" ]]; then
certArgs=(-certificate "$certFile")
fi
# shellcheck disable=SC2086
command barnard -username "$username" -server ${mumbleServerList[$serverName]} "${certArgs[@]}" --fifo ~/.config/barnard/cmd --buffers 16 |& log
} }
remove-server() { remove-server() {
@@ -159,9 +165,99 @@ remove-server() {
msgbox "$(gettext "Removed server") $serverName" msgbox "$(gettext "Removed server") $serverName"
} }
# Certificate configuration
certDir="$HOME/.config/barnard"
certFile="$certDir/barnard.pem"
generate-certificate() {
if [[ -f "$certFile" ]]; then
if [[ "$(yesno "$(gettext "A certificate already exists. Do you want to replace it? This may affect your registered identity on servers.")")" != "Yes" ]]; then
return
fi
fi
local commonName
commonName="$(inputbox "$(gettext "Enter a name for your certificate (e.g., your username):")" "barnard")"
[[ $? -ne 0 ]] && return
[[ -z "$commonName" ]] && commonName="barnard"
if openssl req -x509 -newkey rsa:2048 -keyout "$certFile" -out "$certFile" -days 3650 -nodes -subj "/CN=$commonName" 2>/dev/null; then
chmod 600 "$certFile"
msgbox "$(gettext "Certificate generated successfully.")"
else
msgbox "$(gettext "Failed to generate certificate. Make sure openssl is installed.")"
fi
}
view-certificate() {
if [[ ! -f "$certFile" ]]; then
msgbox "$(gettext "No certificate found.") $certFile"
return
fi
local certInfo
certInfo=$(openssl x509 -in "$certFile" -noout -subject -dates -fingerprint 2>/dev/null)
if [[ -n "$certInfo" ]]; then
msgbox "$certInfo"
else
msgbox "$(gettext "Could not read certificate information.")"
fi
}
import-certificate() {
local importPath
importPath="$(inputbox "$(gettext "Enter the full path to your certificate file (PEM format with certificate and private key):")")"
[[ $? -ne 0 ]] && return
[[ -z "$importPath" ]] && return
# Expand ~ if present
importPath="${importPath/#\~/$HOME}"
if [[ ! -f "$importPath" ]]; then
msgbox "$(gettext "File not found:") $importPath"
return
fi
# Verify it's a valid certificate
if ! openssl x509 -in "$importPath" -noout 2>/dev/null; then
msgbox "$(gettext "The file does not appear to be a valid PEM certificate.")"
return
fi
# Verify it contains a private key
if ! openssl rsa -in "$importPath" -check -noout 2>/dev/null && ! openssl ec -in "$importPath" -check -noout 2>/dev/null; then
msgbox "$(gettext "The file does not appear to contain a valid private key. The certificate file must contain both the certificate and private key.")"
return
fi
if [[ -f "$certFile" ]]; then
if [[ "$(yesno "$(gettext "A certificate already exists. Do you want to replace it?")")" != "Yes" ]]; then
return
fi
fi
if cp "$importPath" "$certFile" && chmod 600 "$certFile"; then
msgbox "$(gettext "Certificate imported successfully.")"
else
msgbox "$(gettext "Failed to import certificate.")"
fi
}
manage-certificate() {
while : ; do
local certAction
certAction="$(menulist "Generate" "View" "Import" "Go_Back")"
[[ $? -eq 1 ]] && return
case "$certAction" in
"Generate") generate-certificate ;;
"View") view-certificate ;;
"Import") import-certificate ;;
"Go_Back"|"") return ;;
esac
done
}
# main menu # main menu
while : ; do while : ; do
action="$(menulist "Connect" "Add_server" "Remove_server")" action="$(menulist "Connect" "Add_server" "Remove_server" "Manage_Certificate")"
[[ $? -eq 1 ]] && exit 0 [[ $? -eq 1 ]] && exit 0
action="${action,,}" action="${action,,}"
action="${action//_/-}" action="${action//_/-}"

View File

@@ -2,8 +2,11 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
"sync"
"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/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"
@@ -44,9 +47,16 @@ type Barnard struct {
// 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
VoiceEffects *audio.EffectsProcessor
// Added for file playback
FileStream *fileplayback.Player
FileStreamMutex sync.Mutex
} }
func (b *Barnard) StopTransmission() { func (b *Barnard) StopTransmission() {

View File

@@ -5,9 +5,11 @@ import (
"net" "net"
"time" "time"
"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"
) )
func (b *Barnard) start() { func (b *Barnard) start() {
@@ -50,6 +52,22 @@ func (b *Barnard) connect(reconnect bool) bool {
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)
// Initialize stereo encoder for file playback
b.Client.AudioEncoderStereo = opus.NewStereoEncoder()
// Initialize file player
b.FileStreamMutex.Lock()
b.FileStream = fileplayback.New(b.Client)
b.FileStream.SetErrorFunc(func(err error) {
// Disable stereo when file finishes or errors
b.Client.DisableStereoEncoder()
b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
})
b.Stream.SetFilePlayer(b.FileStream)
b.FileStreamMutex.Unlock()
b.Connected = true b.Connected = true
return true return true
} }
@@ -161,6 +179,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
if e.Type.Has(gumble.UserChangeConnected) { if e.Type.Has(gumble.UserChangeConnected) {
s = "joined" s = "joined"
t = "join" t = "join"
// Notify about users joining our channel
if e.User.Channel.Name == b.Client.Self.Channel.Name {
b.Notify(t, e.User.Name, e.User.Channel.Name)
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
}
} }
if e.Type.Has(gumble.UserChangeDisconnected) { if e.Type.Has(gumble.UserChangeDisconnected) {
s = "left" s = "left"
@@ -168,10 +191,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
if e.User == b.selectedUser { if e.User == b.selectedUser {
b.SetSelectedUser(nil) b.SetSelectedUser(nil)
} }
} // Always notify about disconnects if user has channel info and was in our channel
if e.User.Channel.Name == b.Client.Self.Channel.Name { 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.Notify(t, e.User.Name, e.User.Channel.Name)
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, 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 { if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name)) b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))

View File

@@ -19,4 +19,5 @@ type Hotkeys struct {
ScrollToTop *uiterm.Key ScrollToTop *uiterm.Key
ScrollToBottom *uiterm.Key ScrollToBottom *uiterm.Key
NoiseSuppressionToggle *uiterm.Key NoiseSuppressionToggle *uiterm.Key
CycleVoiceEffect *uiterm.Key
} }

View File

@@ -1,327 +1,379 @@
package config package config
import ( import (
"fmt" "fmt"
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/gumble/gumble"
"gopkg.in/yaml.v2" "git.stormux.org/storm/barnard/uiterm"
"git.stormux.org/storm/barnard/gumble/gumble" "github.com/pelletier/go-toml/v2"
"io/ioutil" "io/ioutil"
"os" "os"
"os/user" "os/user"
"strconv" "strconv"
"strings" "strings"
) )
type Config struct { type Config struct {
config *exportableConfig config *exportableConfig
fn string fn string
} }
type exportableConfig struct { type exportableConfig struct {
Hotkeys *Hotkeys Hotkeys *Hotkeys
MicVolume *float32 AudioDriver *string
InputDevice *string MicVolume *float32
OutputDevice *string InputDevice *string
Servers []*server OutputDevice *string
DefaultServer *string Servers []*server
Username *string DefaultServer *string
NotifyCommand *string Username *string
NoiseSuppressionEnabled *bool NotifyCommand *string
NoiseSuppressionThreshold *float32 NoiseSuppressionEnabled *bool
NoiseSuppressionThreshold *float32
VoiceEffect *int
Certificate *string
} }
type server struct { type server struct {
Host string Host string
Port int Port int
Users []*eUser Users []*eUser
} }
type eUser struct { type eUser struct {
Username string Username string
Boost uint16 Boost uint16
Volume float32 Volume float32
LocallyMuted bool // Changed from Muted to LocallyMuted to match User struct LocallyMuted bool // Changed from Muted to LocallyMuted to match User struct
} }
func (c *Config) SaveConfig() { func (c *Config) SaveConfig() {
var data []byte var data []byte
data, err := yaml.Marshal(c.config) data, err := toml.Marshal(c.config)
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = ioutil.WriteFile(c.fn+".tmp", data, 0600) err = ioutil.WriteFile(c.fn+".tmp", data, 0600)
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = os.Rename(c.fn+".tmp", c.fn) err = os.Rename(c.fn+".tmp", c.fn)
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
func key(k uiterm.Key) *uiterm.Key { func key(k uiterm.Key) *uiterm.Key {
return &k return &k
} }
func (c *Config) LoadConfig() { func (c *Config) LoadConfig() {
var jc exportableConfig var jc exportableConfig
jc = exportableConfig{} jc = exportableConfig{}
jc.Hotkeys = &Hotkeys{ jc.Hotkeys = &Hotkeys{
Talk: key(uiterm.KeyF1), Talk: key(uiterm.KeyF1),
VolumeDown: key(uiterm.KeyF5), VolumeDown: key(uiterm.KeyF5),
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
Exit: key(uiterm.KeyF10), Exit: key(uiterm.KeyF10),
ToggleTimestamps: key(uiterm.KeyF3), ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab), SwitchViews: key(uiterm.KeyTab),
ScrollUp: key(uiterm.KeyPgup), ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn), ScrollDown: key(uiterm.KeyPgdn),
NoiseSuppressionToggle: key(uiterm.KeyF9), NoiseSuppressionToggle: key(uiterm.KeyF9),
} CycleVoiceEffect: key(uiterm.KeyF12),
if fileExists(c.fn) { }
var data []byte if fileExists(c.fn) {
data = readFile(c.fn) var data []byte
if data != nil { data = readFile(c.fn)
err := yaml.UnmarshalStrict(data, &jc) if data != nil {
if err != nil { err := toml.Unmarshal(data, &jc)
fmt.Fprintf(os.Stderr, "Error parsing \"%s\".\n%s\n", c.fn, err.Error()) if err != nil {
os.Exit(1) fmt.Fprintf(os.Stderr, "Error parsing \"%s\".\n%s\n", c.fn, err.Error())
} os.Exit(1)
} }
} }
c.config = &jc }
if c.config.MicVolume == nil { c.config = &jc
micvol := float32(1.0) if c.config.MicVolume == nil {
jc.MicVolume = &micvol micvol := float32(1.0)
} jc.MicVolume = &micvol
if c.config.InputDevice == nil { }
idev := string("") if c.config.AudioDriver == nil {
jc.InputDevice = &idev driver := string("")
} jc.AudioDriver = &driver
if c.config.OutputDevice == nil { }
odev := string("") if c.config.InputDevice == nil {
jc.OutputDevice = &odev idev := string("")
} jc.InputDevice = &idev
if c.config.DefaultServer == nil { }
defaultServer := string("localhost:64738") if c.config.OutputDevice == nil {
jc.DefaultServer = &defaultServer odev := string("")
} jc.OutputDevice = &odev
if c.config.Username == nil { }
username := string("") if c.config.DefaultServer == nil {
jc.Username = &username defaultServer := string("localhost:64738")
} jc.DefaultServer = &defaultServer
if c.config.NotifyCommand == nil { }
ncmd := string("") if c.config.Username == nil {
jc.NotifyCommand = &ncmd username := string("")
} jc.Username = &username
if c.config.NoiseSuppressionEnabled == nil { }
enabled := false if c.config.NotifyCommand == nil {
jc.NoiseSuppressionEnabled = &enabled ncmd := string("/usr/share/barnard/barnard-sound.sh \"%event\" \"%who\" \"%what\"")
} jc.NotifyCommand = &ncmd
if c.config.NoiseSuppressionThreshold == nil { }
threshold := float32(0.02) if c.config.NoiseSuppressionEnabled == nil {
jc.NoiseSuppressionThreshold = &threshold enabled := false
} jc.NoiseSuppressionEnabled = &enabled
}
if c.config.NoiseSuppressionThreshold == nil {
threshold := float32(0.08)
jc.NoiseSuppressionThreshold = &threshold
}
if c.config.VoiceEffect == nil {
effect := 0 // Default to EffectNone
jc.VoiceEffect = &effect
}
if c.config.Certificate == nil {
cert := string("")
jc.Certificate = &cert
}
} }
func (c *Config) findServer(address string) *server { func (c *Config) findServer(address string) *server {
if c.config.Servers == nil { if c.config.Servers == nil {
c.config.Servers = make([]*server, 0) c.config.Servers = make([]*server, 0)
} }
host, port := makeHostPort(address) host, port := makeHostPort(address)
var t *server var t *server
for _, s := range c.config.Servers { for _, s := range c.config.Servers {
if s.Port == port && s.Host == host { if s.Port == port && s.Host == host {
t = s t = s
break break
} }
} }
if t == nil { if t == nil {
t = &server{ t = &server{
Host: host, Host: host,
Port: port, Port: port,
} }
c.config.Servers = append(c.config.Servers, t) c.config.Servers = append(c.config.Servers, t)
} }
return t return t
} }
func (c *Config) findUser(address string, username string) *eUser { func (c *Config) findUser(address string, username string) *eUser {
var s *server var s *server
s = c.findServer(address) s = c.findServer(address)
if s.Users == nil { if s.Users == nil {
s.Users = make([]*eUser, 0) s.Users = make([]*eUser, 0)
} }
var t *eUser var t *eUser
for _, u := range s.Users { for _, u := range s.Users {
if u.Username == username { if u.Username == username {
t = u t = u
break break
} }
} }
if t == nil { if t == nil {
t = &eUser{ t = &eUser{
Username: username, Username: username,
Boost: uint16(1), Boost: uint16(1),
Volume: 1.0, Volume: 1.0,
LocallyMuted: false, // Initialize local mute state LocallyMuted: false, // Initialize local mute state
} }
s.Users = append(s.Users, t) s.Users = append(s.Users, t)
} }
return t return t
} }
func (c *Config) ToggleMute(u *gumble.User) { func (c *Config) ToggleMute(u *gumble.User) {
j := c.findUser(u.GetClient().Config.Address, u.Name) j := c.findUser(u.GetClient().Config.Address, u.Name)
j.LocallyMuted = !j.LocallyMuted j.LocallyMuted = !j.LocallyMuted
u.LocallyMuted = j.LocallyMuted u.LocallyMuted = j.LocallyMuted
c.SaveConfig() c.SaveConfig()
} }
func (c *Config) SetMicVolume(v float32) { func (c *Config) SetMicVolume(v float32) {
t := float32(v) t := float32(v)
c.config.MicVolume = &t c.config.MicVolume = &t
} }
func (c *Config) GetHotkeys() *Hotkeys { func (c *Config) GetHotkeys() *Hotkeys {
return c.config.Hotkeys return c.config.Hotkeys
} }
func (c *Config) GetNotifyCommand() *string { func (c *Config) GetNotifyCommand() *string {
return c.config.NotifyCommand return c.config.NotifyCommand
}
func (c *Config) GetAudioDriver() string {
if c.config.AudioDriver == nil {
return ""
}
return *c.config.AudioDriver
} }
func (c *Config) GetInputDevice() *string { func (c *Config) GetInputDevice() *string {
return c.config.InputDevice return c.config.InputDevice
} }
func (c *Config) GetOutputDevice() *string { func (c *Config) GetOutputDevice() *string {
return c.config.OutputDevice return c.config.OutputDevice
} }
func (c *Config) GetDefaultServer() *string { func (c *Config) GetDefaultServer() *string {
return c.config.DefaultServer return c.config.DefaultServer
} }
func (c *Config) GetUsername() *string { func (c *Config) GetUsername() *string {
return c.config.Username return c.config.Username
}
func (c *Config) GetCertificate() *string {
return c.config.Certificate
} }
func (c *Config) GetNoiseSuppressionEnabled() bool { func (c *Config) GetNoiseSuppressionEnabled() bool {
if c.config.NoiseSuppressionEnabled == nil { if c.config.NoiseSuppressionEnabled == nil {
return false return false
} }
return *c.config.NoiseSuppressionEnabled return *c.config.NoiseSuppressionEnabled
} }
func (c *Config) SetNoiseSuppressionEnabled(enabled bool) { func (c *Config) SetNoiseSuppressionEnabled(enabled bool) {
c.config.NoiseSuppressionEnabled = &enabled c.config.NoiseSuppressionEnabled = &enabled
c.SaveConfig() c.SaveConfig()
} }
func (c *Config) GetNoiseSuppressionThreshold() float32 { func (c *Config) GetNoiseSuppressionThreshold() float32 {
if c.config.NoiseSuppressionThreshold == nil { if c.config.NoiseSuppressionThreshold == nil {
return 0.02 return 0.08
} }
return *c.config.NoiseSuppressionThreshold threshold := *c.config.NoiseSuppressionThreshold
if threshold < 0.0 {
return 0.0
}
if threshold > 1.0 {
return 1.0
}
return threshold
} }
func (c *Config) SetNoiseSuppressionThreshold(threshold float32) { func (c *Config) SetNoiseSuppressionThreshold(threshold float32) {
c.config.NoiseSuppressionThreshold = &threshold if threshold < 0.0 {
c.SaveConfig() threshold = 0.0
}
if threshold > 1.0 {
threshold = 1.0
}
c.config.NoiseSuppressionThreshold = &threshold
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) 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
uc = u.GetClient() uc = u.GetClient()
if uc != nil { if uc != nil {
j = c.findUser(uc.Config.Address, u.Name) j = c.findUser(uc.Config.Address, u.Name)
u.Boost = j.Boost u.Boost = j.Boost
u.Volume = j.Volume u.Volume = j.Volume
u.LocallyMuted = j.LocallyMuted // Update LocallyMuted state from config u.LocallyMuted = j.LocallyMuted // Update LocallyMuted state from config
if u.Boost < 1 { if u.Boost < 1 {
u.Boost = 1 u.Boost = 1
} }
} }
} }
func (c *Config) UpdateConfig(u *gumble.User) { func (c *Config) UpdateConfig(u *gumble.User) {
var j *eUser var j *eUser
j = c.findUser(u.GetClient().Config.Address, u.Name) j = c.findUser(u.GetClient().Config.Address, u.Name)
j.Boost = u.Boost j.Boost = u.Boost
j.Volume = u.Volume j.Volume = u.Volume
j.LocallyMuted = u.LocallyMuted // Save LocallyMuted state to config j.LocallyMuted = u.LocallyMuted // Save LocallyMuted state to config
} }
func NewConfig(fn *string) *Config { func NewConfig(fn *string) *Config {
var c *Config var c *Config
c = &Config{} c = &Config{}
c.fn = resolvePath(*fn) c.fn = resolvePath(*fn)
c.LoadConfig() c.LoadConfig()
return c return c
} }
func readFile(path string) []byte { func readFile(path string) []byte {
if !fileExists(path) { if !fileExists(path) {
return nil return nil
} }
dat, err := ioutil.ReadFile(path) dat, err := ioutil.ReadFile(path)
if err != nil { if err != nil {
return nil return nil
} }
return dat return dat
} }
func fileExists(path string) bool { func fileExists(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return false return false
} }
return !info.IsDir() return !info.IsDir()
} }
func resolvePath(path string) string { func resolvePath(path string) string {
if strings.HasPrefix(path, "~/") || strings.Contains(path, "$HOME") { if strings.HasPrefix(path, "~/") || strings.Contains(path, "$HOME") {
usr, err := user.Current() usr, err := user.Current()
if err != nil { if err != nil {
panic(err) panic(err)
} }
var hd = usr.HomeDir var hd = usr.HomeDir
if strings.Contains(path, "$HOME") { if strings.Contains(path, "$HOME") {
path = strings.Replace(path, "$HOME", hd, 1) path = strings.Replace(path, "$HOME", hd, 1)
} else { } else {
path = strings.Replace(path, "~", hd, 1) path = strings.Replace(path, "~", hd, 1)
} }
} }
return path return path
} }
func makeHostPort(addr string) (string, int) { func makeHostPort(addr string) (string, int) {
parts := strings.Split(addr, ":") parts := strings.Split(addr, ":")
host := parts[0] host := parts[0]
port, err := strconv.Atoi(parts[1]) port, err := strconv.Atoi(parts[1])
if err != nil { if err != nil {
panic(err) panic(err)
} }
return host, port return host, port
} }
func Log(s string) { func Log(s string) {
log(s) log(s)
} }
func log(s string) { func log(s string) {
s += "\n" s += "\n"
f, err := os.OpenFile("log.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) f, err := os.OpenFile("log.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if _, err := f.Write([]byte(s)); err != nil { if _, err := f.Write([]byte(s)); err != nil {
panic(err) panic(err)
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
panic(err) panic(err)
} }
} }

241
fileplayback/player.go Normal file
View File

@@ -0,0 +1,241 @@
package fileplayback
import (
"encoding/binary"
"errors"
"io"
"os/exec"
"strconv"
"sync"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
)
// Player handles file playback and mixing with microphone audio
type Player struct {
client *gumble.Client
filename string
audioChan chan gumble.AudioBuffer
stopChan chan struct{}
mutex sync.Mutex
playing bool
errorFunc func(error)
// Local playback
localSource *openal.Source
localBuffers openal.Buffers
}
// New creates a new file player
func New(client *gumble.Client) *Player {
return &Player{
client: client,
audioChan: make(chan gumble.AudioBuffer, 100),
stopChan: make(chan struct{}),
}
}
// SetErrorFunc sets the error callback function
func (p *Player) SetErrorFunc(f func(error)) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.errorFunc = f
}
func (p *Player) reportError(err error) {
p.mutex.Lock()
errorFunc := p.errorFunc
p.mutex.Unlock()
if errorFunc != nil {
errorFunc(err)
}
}
// PlayFile starts playing a file
func (p *Player) PlayFile(filename string) error {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.playing {
return errors.New("file already playing")
}
p.filename = filename
// Initialize local playback
source := openal.NewSource()
p.localSource = &source
p.localSource.SetGain(1.0)
// Create buffers for local playback
p.localBuffers = openal.NewBuffers(64)
// Start the file reading goroutine
p.playing = true
p.stopChan = make(chan struct{})
go p.readFileAudio()
return nil
}
// Stop stops the currently playing file
func (p *Player) Stop() error {
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.playing {
return errors.New("no file playing")
}
close(p.stopChan)
p.playing = false
// Clean up local playback
if p.localSource != nil {
p.localSource.Stop()
p.localSource.Delete()
p.localSource = nil
}
if p.localBuffers != nil {
p.localBuffers.Delete()
p.localBuffers = nil
}
// Drain the audio channel
for len(p.audioChan) > 0 {
<-p.audioChan
}
return nil
}
// IsPlaying returns true if a file is currently playing
func (p *Player) IsPlaying() bool {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.playing
}
// GetAudioFrame returns the next audio frame from the file, or nil if no file is playing
func (p *Player) GetAudioFrame() []int16 {
select {
case frame := <-p.audioChan:
return []int16(frame)
default:
return nil
}
}
// playLocalAudio plays audio through the local OpenAL source
func (p *Player) playLocalAudio(data []byte) {
if p.localSource == nil {
return
}
// Reclaim processed buffers
if n := p.localSource.BuffersProcessed(); n > 0 {
reclaimedBufs := make(openal.Buffers, n)
p.localSource.UnqueueBuffers(reclaimedBufs)
p.localBuffers = append(p.localBuffers, reclaimedBufs...)
}
// If we have available buffers, queue more audio
if len(p.localBuffers) > 0 {
buffer := p.localBuffers[len(p.localBuffers)-1]
p.localBuffers = p.localBuffers[:len(p.localBuffers)-1]
// Set buffer data as stereo
buffer.SetData(openal.FormatStereo16, data, gumble.AudioSampleRate)
p.localSource.QueueBuffer(buffer)
// Start playing if not already
if p.localSource.State() != openal.Playing {
p.localSource.Play()
}
}
}
// readFileAudio reads audio from the file via ffmpeg
func (p *Player) readFileAudio() {
interval := p.client.Config.AudioInterval
frameSize := p.client.Config.AudioFrameSize()
// Use stereo output from ffmpeg to preserve stereo files
// Add -loglevel error to suppress info messages
args := []string{"-loglevel", "error", "-i", p.filename}
args = append(args, "-ac", "2", "-ar", strconv.Itoa(gumble.AudioSampleRate), "-f", "s16le", "-")
cmd := exec.Command("ffmpeg", args...)
pipe, err := cmd.StdoutPipe()
if err != nil {
p.mutex.Lock()
p.playing = false
p.mutex.Unlock()
p.reportError(errors.New("failed to create ffmpeg pipe: " + err.Error()))
return
}
if err := cmd.Start(); err != nil {
p.mutex.Lock()
p.playing = false
p.mutex.Unlock()
p.reportError(errors.New("failed to start ffmpeg: " + err.Error()))
return
}
// Stereo has 2 channels, so we need twice the buffer size
byteBuffer := make([]byte, frameSize*2*2) // frameSize * 2 channels * 2 bytes per sample
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-p.stopChan:
cmd.Process.Kill()
cmd.Wait()
return
case <-ticker.C:
n, err := io.ReadFull(pipe, byteBuffer)
if err != nil || n != len(byteBuffer) {
// File finished playing
p.mutex.Lock()
p.playing = false
// Clean up local playback
if p.localSource != nil {
p.localSource.Stop()
p.localSource.Delete()
p.localSource = nil
}
if p.localBuffers != nil {
p.localBuffers.Delete()
p.localBuffers = nil
}
p.mutex.Unlock()
cmd.Wait()
// Notify that file finished
p.reportError(errors.New("file playback finished"))
return
}
// Convert stereo bytes to int16 buffer
int16Buffer := make([]int16, frameSize*2) // stereo
for i := 0; i < len(int16Buffer); i++ {
int16Buffer[i] = int16(binary.LittleEndian.Uint16(byteBuffer[i*2 : (i+1)*2]))
}
// Play locally through OpenAL
p.playLocalAudio(byteBuffer[:n])
// Send to channel (non-blocking)
select {
case p.audioChan <- gumble.AudioBuffer(int16Buffer):
default:
// Channel full, skip this frame
}
}
}
}

2
go.mod
View File

@@ -7,7 +7,7 @@ require (
github.com/golang/protobuf v1.5.3 github.com/golang/protobuf v1.5.3
github.com/kennygrant/sanitize v1.2.4 github.com/kennygrant/sanitize v1.2.4
github.com/nsf/termbox-go v1.1.1 github.com/nsf/termbox-go v1.1.1
gopkg.in/yaml.v2 v2.4.0 github.com/pelletier/go-toml/v2 v2.2.4
) )
require ( require (

6
go.sum
View File

@@ -11,6 +11,8 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
@@ -18,7 +20,3 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@@ -1,35 +1,35 @@
package gumble package gumble
import ( import (
"time" "time"
) )
const ( const (
// AudioSampleRate is the audio sample rate (in hertz) for incoming and // AudioSampleRate is the audio sample rate (in hertz) for incoming and
// outgoing audio. // outgoing audio.
AudioSampleRate = 48000 AudioSampleRate = 48000
// AudioDefaultInterval is the default interval that audio packets are sent // AudioDefaultInterval is the default interval that audio packets are sent
// at. // at.
AudioDefaultInterval = 10 * time.Millisecond AudioDefaultInterval = 10 * time.Millisecond
// AudioMonoChannels is the number of channels used for voice transmission // AudioMonoChannels is the number of channels used for voice transmission
AudioMonoChannels = 1 AudioMonoChannels = 1
// AudioChannels is the number of channels used for playback // AudioChannels is the number of channels used for playback
AudioChannels = 2 AudioChannels = 2
// AudioDefaultFrameSize is the number of audio frames that should be sent in // AudioDefaultFrameSize is the number of audio frames that should be sent in
// a 10ms window (mono samples) // a 10ms window (mono samples)
AudioDefaultFrameSize = AudioSampleRate / 100 AudioDefaultFrameSize = AudioSampleRate / 100
// AudioMaximumFrameSize is the maximum audio frame size from another user // AudioMaximumFrameSize is the maximum audio frame size from another user
// that will be processed (accounting for stereo) // that will be processed (accounting for stereo)
AudioMaximumFrameSize = (AudioSampleRate / 1000 * 60) * AudioChannels AudioMaximumFrameSize = (AudioSampleRate / 1000 * 60) * AudioChannels
// AudioDefaultDataBytes is the default number of bytes that an audio frame // AudioDefaultDataBytes is the default number of bytes that an audio frame
// can use. // can use.
AudioDefaultDataBytes = 40 AudioDefaultDataBytes = 40
) )
// AudioListener is the interface that must be implemented by types wishing to // AudioListener is the interface that must be implemented by types wishing to
@@ -39,48 +39,55 @@ const (
// implementer's responsibility to continuously process AudioStreamEvent.C // implementer's responsibility to continuously process AudioStreamEvent.C
// until it is closed. // until it is closed.
type AudioListener interface { type AudioListener interface {
OnAudioStream(e *AudioStreamEvent) OnAudioStream(e *AudioStreamEvent)
} }
// AudioStreamEvent is event that is passed to AudioListener.OnAudioStream. // AudioStreamEvent is event that is passed to AudioListener.OnAudioStream.
type AudioStreamEvent struct { type AudioStreamEvent struct {
Client *Client Client *Client
User *User User *User
C <-chan *AudioPacket C <-chan *AudioPacket
} }
// AudioBuffer is a slice of PCM audio samples. // AudioBuffer is a slice of PCM audio samples.
type AudioBuffer []int16 type AudioBuffer []int16
func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error { func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error {
encoder := client.AudioEncoder // Choose encoder based on whether buffer size indicates stereo or mono
if encoder == nil { encoder := client.AudioEncoder
return nil frameSize := client.Config.AudioFrameSize()
} if len(a) == frameSize*AudioChannels && client.AudioEncoderStereo != nil {
dataBytes := client.Config.AudioDataBytes encoder = client.AudioEncoderStereo
raw, err := encoder.Encode(a, len(a), dataBytes) } else if client.IsStereoEncoderEnabled() && client.AudioEncoderStereo != nil {
if final { encoder = client.AudioEncoderStereo
defer encoder.Reset() }
} if encoder == nil {
if err != nil { return nil
return err }
} dataBytes := client.Config.AudioDataBytes
raw, err := encoder.Encode(a, len(a), dataBytes)
if final {
defer encoder.Reset()
}
if err != nil {
return err
}
var targetID byte var targetID byte
if target := client.VoiceTarget; target != nil { if target := client.VoiceTarget; target != nil {
targetID = byte(target.ID) targetID = byte(target.ID)
} }
return client.Conn.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil) return client.Conn.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil)
} }
// AudioPacket contains incoming audio samples and information. // AudioPacket contains incoming audio samples and information.
type AudioPacket struct { type AudioPacket struct {
Client *Client Client *Client
Sender *User Sender *User
Target *VoiceTarget Target *VoiceTarget
AudioBuffer AudioBuffer
HasPosition bool HasPosition bool
X, Y, Z float32 X, Y, Z float32
} }

View File

@@ -59,8 +59,10 @@ type Client struct {
ContextActions ContextActions ContextActions ContextActions
// The audio encoder used when sending audio to the server. // The audio encoder used when sending audio to the server.
AudioEncoder AudioEncoder AudioEncoder AudioEncoder
audioCodec AudioCodec AudioEncoderStereo AudioEncoder
audioCodec AudioCodec
useStereoEncoder bool
// To whom transmitted audio will be sent. The VoiceTarget must have already // To whom transmitted audio will be sent. The VoiceTarget must have already
// been sent to the server for targeting to work correctly. Setting to nil // been sent to the server for targeting to work correctly. Setting to nil
// will disable voice targeting (i.e. switch back to regular speaking). // will disable voice targeting (i.e. switch back to regular speaking).
@@ -287,3 +289,24 @@ func (c *Client) Do(f func()) {
func (c *Client) Send(message Message) { func (c *Client) Send(message Message) {
message.writeMessage(c) message.writeMessage(c)
} }
// EnableStereoEncoder switches to stereo encoding for file playback.
func (c *Client) EnableStereoEncoder() {
c.volatile.Lock()
defer c.volatile.Unlock()
c.useStereoEncoder = true
}
// DisableStereoEncoder switches back to mono encoding for voice.
func (c *Client) DisableStereoEncoder() {
c.volatile.Lock()
defer c.volatile.Unlock()
c.useStereoEncoder = false
}
// IsStereoEncoderEnabled returns true if stereo encoding is currently active.
func (c *Client) IsStereoEncoderEnabled() bool {
c.volatile.RLock()
defer c.volatile.RUnlock()
return c.useStereoEncoder
}

View File

@@ -1073,6 +1073,9 @@ func (c *Client) handleCodecVersion(buffer []byte) error {
c.volatile.Lock() c.volatile.Lock()
c.AudioEncoder = codec.NewEncoder() c.AudioEncoder = codec.NewEncoder()
// Also create a stereo encoder for file playback
// Import the opus package to get NewStereoEncoder
c.AudioEncoderStereo = nil // Will be set when needed
c.volatile.Unlock() c.volatile.Unlock()
} }

View File

@@ -1,345 +1,586 @@
package gumbleopenal package gumbleopenal
import ( import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"os/exec" "os/exec"
"time" "time"
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/gumble/go-openal/openal" "git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/noise"
) )
// NoiseProcessor interface for noise suppression // NoiseProcessor interface for noise suppression
type NoiseProcessor interface { type NoiseProcessor interface {
ProcessSamples(samples []int16) ProcessSamples(samples []int16)
IsEnabled() bool 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
IsPlaying() bool
} }
const ( const (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
) )
var ( var (
ErrState = errors.New("gumbleopenal: invalid state") ErrState = errors.New("gumbleopenal: invalid state")
ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured") ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured")
ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters") ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters")
ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters") ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters")
) )
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 {
} }
} }
type Stream struct { type Stream struct {
client *gumble.Client client *gumble.Client
link gumble.Detacher link gumble.Detacher
deviceSource *openal.CaptureDevice deviceSource *openal.CaptureDevice
sourceFrameSize int sourceFormat openal.Format
micVolume float32 sourceChannels int
sourceStop chan bool sourceFrameSize int
micVolume float32
sourceStop chan bool
deviceSink *openal.Device deviceSink *openal.Device
contextSink *openal.Context contextSink *openal.Context
noiseProcessor NoiseProcessor noiseProcessor NoiseProcessor
noiseProcessorRight NoiseProcessor
micAGC *audio.AGC
micAGCRight *audio.AGC
effectsProcessor EffectsProcessor
effectsProcessorRight EffectsProcessor
filePlayer FilePlayer
} }
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) {
frmsz := 480 frmsz := 480
if !test { if !test {
frmsz = client.Config.AudioFrameSize() frmsz = client.Config.AudioFrameSize()
} }
// Always use mono for input device inputFormat := openal.FormatStereo16
idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(frmsz)) sourceChannels := 2
if idev == nil { idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, inputFormat, uint32(frmsz))
return nil, ErrInputDevice if idev == nil {
} inputFormat = openal.FormatMono16
sourceChannels = 1
idev = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, inputFormat, uint32(frmsz))
}
if idev == nil {
return nil, ErrInputDevice
}
odev := openal.OpenDevice(*outputDevice) odev := openal.OpenDevice(*outputDevice)
if odev == nil { if odev == nil {
idev.CaptureCloseDevice() idev.CaptureCloseDevice()
return nil, ErrOutputDevice return nil, ErrOutputDevice
} }
if test { if test {
idev.CaptureCloseDevice() idev.CaptureCloseDevice()
odev.CloseDevice() odev.CloseDevice()
return nil, nil return nil, nil
} }
s := &Stream{ s := &Stream{
client: client, client: client,
sourceFrameSize: frmsz, sourceFormat: inputFormat,
micVolume: 1.0, sourceChannels: sourceChannels,
} sourceFrameSize: frmsz,
micVolume: 1.0,
micAGC: audio.NewAGC(), // Always enable AGC for outgoing mic
}
if sourceChannels == 2 {
s.micAGCRight = audio.NewAGC()
}
s.deviceSource = idev s.deviceSource = idev
if s.deviceSource == nil { if s.deviceSource == nil {
return nil, ErrInputDevice return nil, ErrInputDevice
} }
s.deviceSink = odev s.deviceSink = odev
if s.deviceSink == nil { if s.deviceSink == nil {
return nil, ErrOutputDevice return nil, ErrOutputDevice
} }
s.contextSink = s.deviceSink.CreateContext() s.contextSink = s.deviceSink.CreateContext()
if s.contextSink == nil { if s.contextSink == nil {
s.Destroy() s.Destroy()
return nil, ErrOutputDevice return nil, ErrOutputDevice
} }
s.contextSink.Activate() s.contextSink.Activate()
return s, nil return s, nil
} }
func (s *Stream) AttachStream(client *gumble.Client) { func (s *Stream) AttachStream(client *gumble.Client) {
s.link = client.Config.AttachAudio(s) s.link = client.Config.AttachAudio(s)
} }
func (s *Stream) SetNoiseProcessor(np NoiseProcessor) { func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
s.noiseProcessor = np s.noiseProcessor = np
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
}
func (s *Stream) GetFilePlayer() FilePlayer {
return s.filePlayer
} }
func (s *Stream) Destroy() { func (s *Stream) Destroy() {
if s.link != nil { if s.link != nil {
s.link.Detach() s.link.Detach()
} }
if s.deviceSource != nil { if s.deviceSource != nil {
s.StopSource() s.StopSource()
s.deviceSource.CaptureCloseDevice() s.deviceSource.CaptureCloseDevice()
s.deviceSource = nil s.deviceSource = nil
} }
if s.deviceSink != nil { if s.deviceSink != nil {
s.contextSink.Destroy() s.contextSink.Destroy()
s.deviceSink.CloseDevice() s.deviceSink.CloseDevice()
s.contextSink = nil s.contextSink = nil
s.deviceSink = nil s.deviceSink = nil
} }
} }
func (s *Stream) StartSource(inputDevice *string) error { func (s *Stream) StartSource(inputDevice *string) error {
if s.sourceStop != nil { if s.sourceStop != nil {
return ErrState return ErrState
} }
if s.deviceSource == nil { if s.deviceSource == nil {
return ErrMic return ErrMic
} }
s.deviceSource.CaptureStart() s.deviceSource.CaptureStart()
s.sourceStop = make(chan bool) s.sourceStop = make(chan bool)
go s.sourceRoutine(inputDevice) go s.sourceRoutine(inputDevice)
return nil return nil
} }
func (s *Stream) StopSource() error { func (s *Stream) StopSource() error {
if s.deviceSource == nil { if s.deviceSource == nil {
return ErrMic return ErrMic
} }
s.deviceSource.CaptureStop() s.deviceSource.CaptureStop()
if s.sourceStop == nil { if s.sourceStop == nil {
return ErrState return ErrState
} }
close(s.sourceStop) close(s.sourceStop)
s.sourceStop = nil s.sourceStop = nil
return nil return nil
} }
func (s *Stream) GetMicVolume() float32 { func (s *Stream) GetMicVolume() float32 {
return s.micVolume return s.micVolume
} }
func (s *Stream) SetMicVolume(change float32, relative bool) { func (s *Stream) SetMicVolume(change float32, relative bool) {
var val float32 var val float32
if relative { if relative {
val = s.GetMicVolume() + change val = s.GetMicVolume() + change
} else { } else {
val = change val = change
} }
if val >= 1 { if val >= 1 {
val = 1.0 val = 1.0
} }
if val <= 0 { if val <= 0 {
val = 0 val = 0
} }
s.micVolume = val s.micVolume = val
} }
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
go func(e *gumble.AudioStreamEvent) { go func(e *gumble.AudioStreamEvent) {
var source = openal.NewSource() var source = openal.NewSource()
e.User.AudioSource = &source e.User.AudioSource = &source
// Set initial gain based on volume and mute state
if e.User.LocallyMuted {
e.User.AudioSource.SetGain(0)
} else {
e.User.AudioSource.SetGain(e.User.Volume)
}
bufferCount := e.Client.Config.Buffers // Set initial gain based on volume and mute state
if bufferCount < 64 { if e.User.LocallyMuted {
bufferCount = 64 e.User.AudioSource.SetGain(0)
} } else {
emptyBufs := openal.NewBuffers(bufferCount) e.User.AudioSource.SetGain(e.User.Volume)
}
reclaim := func() {
if n := source.BuffersProcessed(); n > 0 {
reclaimedBufs := make(openal.Buffers, n)
source.UnqueueBuffers(reclaimedBufs)
emptyBufs = append(emptyBufs, reclaimedBufs...)
}
}
var raw [maxBufferSize]byte bufferCount := e.Client.Config.Buffers
if bufferCount < 64 {
for packet := range e.C { bufferCount = 64
// Skip processing if user is locally muted }
if e.User.LocallyMuted { emptyBufs := openal.NewBuffers(bufferCount)
continue
}
var boost uint16 = uint16(1) reclaim := func() {
samples := len(packet.AudioBuffer) if n := source.BuffersProcessed(); n > 0 {
if samples > cap(raw)/2 { reclaimedBufs := make(openal.Buffers, n)
continue source.UnqueueBuffers(reclaimedBufs)
} emptyBufs = append(emptyBufs, reclaimedBufs...)
}
boost = e.User.Boost }
// Check if sample count suggests stereo data var raw [maxBufferSize]byte
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
format := openal.FormatMono16
if isStereo {
format = openal.FormatStereo16
samples = samples / 2
}
rawPtr := 0 for packet := range e.C {
if isStereo { // Skip processing if user is locally muted
// Process stereo samples as pairs if e.User.LocallyMuted {
for i := 0; i < samples*2; i += 2 { continue
// Process left channel with saturation protection }
sample := packet.AudioBuffer[i]
if boost > 1 {
boosted := int32(sample) * int32(boost)
if boosted > 32767 {
sample = 32767
} else if boosted < -32767 {
sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
// Process right channel with saturation protection var boost uint16 = uint16(1)
sample = packet.AudioBuffer[i+1] samples := len(packet.AudioBuffer)
if boost > 1 { if samples > cap(raw)/2 {
boosted := int32(sample) * int32(boost) continue
if boosted > 32767 { }
sample = 32767
} else if boosted < -32767 {
sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
}
} else {
// Process mono samples with saturation protection
for i := 0; i < samples; i++ {
sample := packet.AudioBuffer[i]
if boost > 1 {
boosted := int32(sample) * int32(boost)
if boosted > 32767 {
sample = 32767
} else if boosted < -32767 {
sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
}
}
reclaim() boost = e.User.Boost
if len(emptyBufs) == 0 {
continue
}
last := len(emptyBufs) - 1 // Check if sample count suggests stereo data
buffer := emptyBufs[last] isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
emptyBufs = emptyBufs[:last] format := openal.FormatMono16
if isStereo {
format = openal.FormatStereo16
samples = samples / 2
}
buffer.SetData(format, raw[:rawPtr], gumble.AudioSampleRate) rawPtr := 0
source.QueueBuffer(buffer) if isStereo {
// Process stereo samples as pairs
for i := 0; i < samples*2; i += 2 {
// Process left channel with saturation protection
sample := packet.AudioBuffer[i]
if boost > 1 {
boosted := int32(sample) * int32(boost)
if boosted > 32767 {
sample = 32767
} else if boosted < -32767 {
sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
if source.State() != openal.Playing { // Process right channel with saturation protection
source.Play() sample = packet.AudioBuffer[i+1]
} if boost > 1 {
} boosted := int32(sample) * int32(boost)
reclaim() if boosted > 32767 {
emptyBufs.Delete() sample = 32767
source.Delete() } else if boosted < -32767 {
}(e) sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
}
} else {
// Process mono samples with saturation protection
for i := 0; i < samples; i++ {
sample := packet.AudioBuffer[i]
if boost > 1 {
boosted := int32(sample) * int32(boost)
if boosted > 32767 {
sample = 32767
} else if boosted < -32767 {
sample = -32767
} else {
sample = int16(boosted)
}
}
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
rawPtr += 2
}
}
reclaim()
if len(emptyBufs) == 0 {
continue
}
last := len(emptyBufs) - 1
buffer := emptyBufs[last]
emptyBufs = emptyBufs[:last]
buffer.SetData(format, raw[:rawPtr], gumble.AudioSampleRate)
source.QueueBuffer(buffer)
if source.State() != openal.Playing {
source.Play()
}
}
reclaim()
emptyBufs.Delete()
source.Delete()
}(e)
} }
func (s *Stream) sourceRoutine(inputDevice *string) { func (s *Stream) sourceRoutine(inputDevice *string) {
interval := s.client.Config.AudioInterval interval := s.client.Config.AudioInterval
frameSize := s.client.Config.AudioFrameSize() frameSize := s.client.Config.AudioFrameSize()
if frameSize != s.sourceFrameSize { if frameSize != s.sourceFrameSize {
s.deviceSource.CaptureCloseDevice() s.deviceSource.CaptureCloseDevice()
s.sourceFrameSize = frameSize s.sourceFrameSize = frameSize
// Always use mono for input s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, s.sourceFormat, uint32(s.sourceFrameSize))
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(s.sourceFrameSize)) if s.deviceSource == nil && s.sourceFormat == openal.FormatStereo16 {
} s.sourceFormat = openal.FormatMono16
s.sourceChannels = 1
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, s.sourceFormat, uint32(s.sourceFrameSize))
}
}
if s.deviceSource == nil {
return
}
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
stop := s.sourceStop stop := s.sourceStop
outgoing := s.client.AudioOutgoing() outgoing := s.client.AudioOutgoing()
defer close(outgoing) defer close(outgoing)
for { for {
select { select {
case <-stop: case <-stop:
return return
case <-ticker.C: case <-ticker.C:
buff := s.deviceSource.CaptureSamples(uint32(frameSize)) sampleCount := frameSize * s.sourceChannels
if len(buff) != frameSize*2 { int16Buffer := make([]int16, sampleCount)
continue
} // Capture microphone if available
int16Buffer := make([]int16, frameSize) hasMicInput := false
for i := range int16Buffer { buff := s.deviceSource.CaptureSamples(uint32(frameSize))
sample := int16(binary.LittleEndian.Uint16(buff[i*2:])) if len(buff) == sampleCount*2 {
if s.micVolume != 1.0 { hasMicInput = true
sample = int16(float32(sample) * s.micVolume) for i := 0; i < sampleCount; i++ {
} sample := int16(binary.LittleEndian.Uint16(buff[i*2:]))
int16Buffer[i] = sample if s.micVolume != 1.0 {
} sample = int16(float32(sample) * s.micVolume)
}
// Apply noise suppression if available and enabled int16Buffer[i] = sample
if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() { }
s.noiseProcessor.ProcessSamples(int16Buffer)
} if s.sourceChannels == 1 {
s.processMonoSamples(int16Buffer)
outgoing <- gumble.AudioBuffer(int16Buffer) } else {
} s.processStereoSamples(int16Buffer, frameSize)
} }
}
// Mix with or use file audio if playing
hasFileAudio := false
var outputBuffer []int16
if s.filePlayer != nil && s.filePlayer.IsPlaying() {
fileAudio := s.filePlayer.GetAudioFrame()
if fileAudio != nil && len(fileAudio) > 0 {
hasFileAudio = true
// File audio is stereo - send as stereo when file is playing
// Create stereo buffer (frameSize * 2 channels)
outputBuffer = make([]int16, frameSize*2)
if hasMicInput {
if s.sourceChannels == 2 {
// Mix stereo mic with stereo file
for i := 0; i < frameSize; i++ {
idx := i * 2
if idx+1 < len(fileAudio) {
left := int32(int16Buffer[idx]) + int32(fileAudio[idx])
if left > 32767 {
left = 32767
} else if left < -32768 {
left = -32768
}
outputBuffer[idx] = int16(left)
right := int32(int16Buffer[idx+1]) + int32(fileAudio[idx+1])
if right > 32767 {
right = 32767
} else if right < -32768 {
right = -32768
}
outputBuffer[idx+1] = int16(right)
}
}
} else {
// Mix mono mic with stereo file
for i := 0; i < frameSize; i++ {
idx := i * 2
if idx+1 < len(fileAudio) {
left := int32(int16Buffer[i]) + int32(fileAudio[idx])
if left > 32767 {
left = 32767
} else if left < -32768 {
left = -32768
}
outputBuffer[idx] = int16(left)
right := int32(int16Buffer[i]) + int32(fileAudio[idx+1])
if right > 32767 {
right = 32767
} else if right < -32768 {
right = -32768
}
outputBuffer[idx+1] = int16(right)
}
}
}
} else {
// Use file audio only (already stereo)
copy(outputBuffer, fileAudio[:frameSize*2])
}
}
}
// Determine what to send
if hasFileAudio {
// Send stereo buffer when file is playing
outgoing <- gumble.AudioBuffer(outputBuffer)
} else if hasMicInput {
// Send mic when no file is playing
outgoing <- gumble.AudioBuffer(int16Buffer)
}
}
}
}
func (s *Stream) processMonoSamples(samples []int16) {
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
}
func (s *Stream) processStereoSamples(samples []int16, frameSize int) {
if frameSize == 0 || len(samples) < frameSize*2 {
return
}
s.ensureStereoProcessors()
s.syncStereoProcessors()
left := make([]int16, frameSize)
right := make([]int16, frameSize)
for i := 0; i < frameSize; i++ {
idx := i * 2
left[i] = samples[idx]
right[i] = samples[idx+1]
}
s.processChannel(left, s.noiseProcessor, s.micAGC, s.effectsProcessor)
s.processChannel(right, s.noiseProcessorRight, s.micAGCRight, s.effectsProcessorRight)
for i := 0; i < frameSize; i++ {
idx := i * 2
samples[idx] = left[i]
samples[idx+1] = right[i]
}
}
func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor, micAGC *audio.AGC, effectsProcessor EffectsProcessor) {
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() {
if s.micAGCRight == nil {
s.micAGCRight = audio.NewAGC()
}
if s.noiseProcessorRight == nil {
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
}
if s.effectsProcessorRight == nil {
s.effectsProcessorRight = cloneEffectsProcessor(s.effectsProcessor)
}
}
func (s *Stream) syncStereoProcessors() {
leftSuppressor, leftOk := s.noiseProcessor.(*noise.Suppressor)
rightSuppressor, rightOk := s.noiseProcessorRight.(*noise.Suppressor)
if leftOk && rightOk {
if leftSuppressor.IsEnabled() != rightSuppressor.IsEnabled() {
rightSuppressor.SetEnabled(leftSuppressor.IsEnabled())
}
if leftSuppressor.GetThreshold() != rightSuppressor.GetThreshold() {
rightSuppressor.SetThreshold(leftSuppressor.GetThreshold())
}
}
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 {
if np == nil {
return nil
}
if suppressor, ok := np.(*noise.Suppressor); ok {
clone := noise.NewSuppressor()
clone.SetEnabled(suppressor.IsEnabled())
clone.SetThreshold(suppressor.GetThreshold())
return clone
}
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
} }

View File

@@ -35,6 +35,16 @@ func (*generator) NewEncoder() gumble.AudioEncoder {
} }
} }
// NewStereoEncoder creates a stereo encoder for file playback
func NewStereoEncoder() gumble.AudioEncoder {
// Create stereo encoder for file playback
e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio)
e.SetBitrateToMax()
return &Encoder{
e,
}
}
func (*generator) NewDecoder() gumble.AudioDecoder { func (*generator) NewDecoder() gumble.AudioDecoder {
// Create decoder with stereo support // Create decoder with stereo support
d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels) d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels)

49
main.go
View File

@@ -15,14 +15,15 @@ import (
//"github.com/google/shlex" //"github.com/google/shlex"
"crypto/tls" "crypto/tls"
"flag" "flag"
"github.com/alessio/shellescape" "git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config" "git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/noise" "git.stormux.org/storm/barnard/noise"
"github.com/alessio/shellescape"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumble"
_ "git.stormux.org/storm/barnard/gumble/opus" _ "git.stormux.org/storm/barnard/gumble/opus"
"git.stormux.org/storm/barnard/uiterm" "git.stormux.org/storm/barnard/uiterm"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
) )
func show_devs(name string, args []string) { func show_devs(name string, args []string) {
@@ -107,7 +108,8 @@ func main() {
password := flag.String("password", "", "the password of the server") password := flag.String("password", "", "the password of the server")
insecure := flag.Bool("insecure", false, "skip server certificate verification") insecure := flag.Bool("insecure", false, "skip server certificate verification")
certificate := flag.String("certificate", "", "PEM encoded certificate and private key") certificate := flag.String("certificate", "", "PEM encoded certificate and private key")
cfgfn := flag.String("config", "~/.barnard.yaml", "Path to YAML formatted configuration file") cfgfn := flag.String("config", "~/.barnard.toml", "Path to TOML formatted configuration file")
audioDriver := flag.String("audio-driver", "", "preferred OpenAL backend (pipewire, pulse, alsa, jack)")
list_devices := flag.Bool("list_devices", false, "do not connect; instead, list available audio devices and exit") list_devices := flag.Bool("list_devices", false, "do not connect; instead, list available audio devices and exit")
fifo := flag.String("fifo", "", "path of a FIFO from which to read commands") fifo := flag.String("fifo", "", "path of a FIFO from which to read commands")
serverSet := false serverSet := false
@@ -126,12 +128,15 @@ func main() {
userConfig := config.NewConfig(cfgfn) userConfig := config.NewConfig(cfgfn)
certificateSet := false
flag.CommandLine.Visit(func(theFlag *flag.Flag) { flag.CommandLine.Visit(func(theFlag *flag.Flag) {
switch theFlag.Name { switch theFlag.Name {
case "server": case "server":
serverSet = true serverSet = true
case "username": case "username":
usernameSet = true usernameSet = true
case "certificate":
certificateSet = true
} }
}) })
@@ -141,6 +146,22 @@ func main() {
if !usernameSet { if !usernameSet {
username = userConfig.GetUsername() username = userConfig.GetUsername()
} }
if !certificateSet {
certificate = userConfig.GetCertificate()
}
driver := strings.TrimSpace(*audioDriver)
if driver == "" {
// Environment variable takes precedence over config
if envDriver := os.Getenv("ALSOFT_DRIVERS"); envDriver != "" {
driver = envDriver
} else {
driver = strings.TrimSpace(userConfig.GetAudioDriver())
}
}
if driver != "" {
os.Setenv("ALSOFT_DRIVERS", driver)
}
if os.Getenv("ALSOFT_LOGLEVEL") == "" { if os.Getenv("ALSOFT_LOGLEVEL") == "" {
os.Setenv("ALSOFT_LOGLEVEL", "0") os.Setenv("ALSOFT_LOGLEVEL", "0")
@@ -157,17 +178,18 @@ func main() {
// Initialize // Initialize
b := Barnard{ b := Barnard{
Config: gumble.NewConfig(), Config: gumble.NewConfig(),
UserConfig: userConfig, UserConfig: userConfig,
Address: *server, Address: *server,
MutedChannels: make(map[uint32]bool), MutedChannels: make(map[uint32]bool),
NoiseSuppressor: noise.NewSuppressor(), NoiseSuppressor: noise.NewSuppressor(),
VoiceEffects: audio.NewEffectsProcessor(gumble.AudioSampleRate),
} }
b.Config.Buffers = *buffers b.Config.Buffers = *buffers
b.Hotkeys = b.UserConfig.GetHotkeys() b.Hotkeys = b.UserConfig.GetHotkeys()
b.UserConfig.SaveConfig() b.UserConfig.SaveConfig()
// Configure noise suppression // Configure noise suppression
enabled := b.UserConfig.GetNoiseSuppressionEnabled() enabled := b.UserConfig.GetNoiseSuppressionEnabled()
if *noiseSuppressionEnabled { if *noiseSuppressionEnabled {
@@ -176,7 +198,10 @@ func main() {
} }
b.NoiseSuppressor.SetEnabled(enabled) b.NoiseSuppressor.SetEnabled(enabled)
b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold()) b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold())
// Configure voice effects
b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect()))
b.Config.Username = *username b.Config.Username = *username
b.Config.Password = *password b.Config.Password = *password
@@ -196,12 +221,12 @@ func main() {
if err != nil { if err != nil {
b.exitMessage = err.Error() b.exitMessage = err.Error()
b.exitStatus = 1 b.exitStatus = 1
handle_error(b) handle_error(&b)
} }
b.notifyChannel = setup_notify_runner(*b.UserConfig.GetNotifyCommand()) b.notifyChannel = setup_notify_runner(*b.UserConfig.GetNotifyCommand())
b.Ui = uiterm.New(&b) b.Ui = uiterm.New(&b)
b.Ui.Run(reader) b.Ui.Run(reader)
handle_error(b) handle_error(&b)
} }
func handle_raw_error(e error) { func handle_raw_error(e error) {
@@ -209,7 +234,7 @@ func handle_raw_error(e error) {
os.Exit(1) os.Exit(1)
} }
func handle_error(b Barnard) { func handle_error(b *Barnard) {
if b.exitMessage != "" { if b.exitMessage != "" {
fmt.Fprintf(os.Stderr, "%s\n", b.exitMessage) fmt.Fprintf(os.Stderr, "%s\n", b.exitMessage)
} }

View File

@@ -2,6 +2,7 @@ package noise
import ( import (
"math" "math"
"sync"
) )
// Ensure Suppressor implements the NoiseProcessor interface // Ensure Suppressor implements the NoiseProcessor interface
@@ -12,129 +13,200 @@ var _ interface {
// Suppressor handles noise suppression for audio samples // Suppressor handles noise suppression for audio samples
type Suppressor struct { type Suppressor struct {
enabled bool mu sync.Mutex
threshold float32
gainFactor float32 enabled bool
threshold float32
// Simple high-pass filter state for DC removal
// High-pass filter state for low-frequency rumble/DC removal.
prevInput float32 prevInput float32
prevOutput float32 prevOutput float32
alpha float32 hpAlpha float32
// Click detection state // Adaptive suppression state.
clickThreshold float32 envelope float32
clickDecay float32 noiseFloor float32
recentClickEnergy float32 suppressionGain float32
clickEnergy float32
// Tunables.
envelopeAttack float32
envelopeRelease float32
noiseAttack float32
noiseRelease float32
gainAttack float32
gainRelease float32
speechRatio float32
clickDecay float32
minNoiseFloor float32
} }
// NewSuppressor creates a new noise suppressor // NewSuppressor creates a new noise suppressor
func NewSuppressor() *Suppressor { func NewSuppressor() *Suppressor {
return &Suppressor{ s := &Suppressor{
enabled: false, enabled: false,
threshold: 0.01, // Reduced noise threshold level for less aggressive filtering threshold: 0.08,
gainFactor: 0.9, // Less aggressive gain reduction for noise hpAlpha: 0.995,
alpha: 0.98, // More stable high-pass filter coefficient envelopeAttack: 0.18,
clickThreshold: 0.15, // Threshold for detecting keyboard clicks envelopeRelease: 0.02,
clickDecay: 0.95, // How quickly click energy decays noiseAttack: 0.08,
recentClickEnergy: 0.0, // Tracks recent click activity noiseRelease: 0.002,
gainAttack: 0.35,
gainRelease: 0.02,
speechRatio: 4.0,
clickDecay: 0.93,
minNoiseFloor: 0.0008,
suppressionGain: 1.0,
} }
s.resetStateLocked()
return s
} }
// SetEnabled enables or disables noise suppression // SetEnabled enables or disables noise suppression
func (s *Suppressor) SetEnabled(enabled bool) { func (s *Suppressor) SetEnabled(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.enabled == enabled {
return
}
s.enabled = enabled s.enabled = enabled
s.resetStateLocked()
} }
// IsEnabled returns whether noise suppression is enabled // IsEnabled returns whether noise suppression is enabled
func (s *Suppressor) IsEnabled() bool { func (s *Suppressor) IsEnabled() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.enabled return s.enabled
} }
// SetThreshold sets the noise threshold (0.0 to 1.0) // SetThreshold sets the noise threshold (0.0 to 1.0)
func (s *Suppressor) SetThreshold(threshold float32) { func (s *Suppressor) SetThreshold(threshold float32) {
if threshold >= 0.0 && threshold <= 1.0 { s.mu.Lock()
s.threshold = threshold defer s.mu.Unlock()
} s.threshold = clampFloat32(threshold, 0.0, 1.0)
} }
// GetThreshold returns the current noise threshold // GetThreshold returns the current noise threshold
func (s *Suppressor) GetThreshold() float32 { func (s *Suppressor) GetThreshold() float32 {
s.mu.Lock()
defer s.mu.Unlock()
return s.threshold return s.threshold
} }
// ProcessSamples applies noise suppression to audio samples // ProcessSamples applies noise suppression to audio samples
func (s *Suppressor) ProcessSamples(samples []int16) { func (s *Suppressor) ProcessSamples(samples []int16) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || len(samples) == 0 { if !s.enabled || len(samples) == 0 {
return return
} }
// Calculate frame energy for click detection intensity := s.thresholdToIntensity()
var frameEnergy float32 = 0.0 minGain := 1.0 - (0.92 * intensity)
for _, sample := range samples { eps := float32(1e-6)
floatSample := float32(sample) / 32767.0
frameEnergy += floatSample * floatSample
}
frameEnergy = float32(math.Sqrt(float64(frameEnergy / float32(len(samples)))))
// Detect sudden energy spikes (likely keyboard clicks)
energySpike := frameEnergy - s.recentClickEnergy
isClick := energySpike > s.clickThreshold && frameEnergy > 0.05
// Update recent click energy with decay
s.recentClickEnergy = s.recentClickEnergy*s.clickDecay + frameEnergy*(1.0-s.clickDecay)
// Improved noise suppression algorithm
for i, sample := range samples { for i, sample := range samples {
// Convert to float for processing floatSample := float32(sample) / 32768.0
floatSample := float32(sample) / 32767.0 filtered := s.highPassFilterLocked(floatSample)
absSample := float32(math.Abs(float64(filtered)))
// Apply high-pass filter for DC removal
filtered := s.highPassFilter(floatSample) s.updateEnvelopeLocked(absSample)
s.updateNoiseFloorLocked()
// Calculate signal strength (RMS-like)
strength := float32(math.Abs(float64(filtered))) snr := s.envelope / (s.noiseFloor + eps)
voicePresence := clampFloat32((snr-1.0)/(s.speechRatio-1.0), 0.0, 1.0)
// Apply noise gate with smooth transition
var gainReduction float32 = 1.0 targetGain := minGain + ((1.0 - minGain) * voicePresence)
targetGain = s.applyTransientSuppressionLocked(absSample, voicePresence, minGain, targetGain)
// If we detected a click, apply stronger suppression
if isClick { s.applyGainSmoothingLocked(targetGain)
gainReduction = s.gainFactor * 0.3 // Much stronger reduction for clicks
} else if strength < s.threshold { processed := filtered * s.suppressionGain
// Normal noise gate for low-level sounds processed = clampFloat32(processed, -1.0, 1.0)
gainReduction = strength / s.threshold samples[i] = int16(processed * 32767.0)
if gainReduction < s.gainFactor {
gainReduction = s.gainFactor
}
}
// Apply gain reduction
processed := filtered * gainReduction
// Convert back to int16 with proper clipping
processedInt := processed * 32767.0
if processedInt > 32767 {
processedInt = 32767
} else if processedInt < -32767 {
processedInt = -32767
}
samples[i] = int16(processedInt)
} }
} }
// highPassFilter applies a simple high-pass filter to remove DC component func (s *Suppressor) highPassFilterLocked(input float32) float32 {
func (s *Suppressor) highPassFilter(input float32) float32 {
// Simple high-pass filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1]) // Simple high-pass filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1])
output := s.alpha * (s.prevOutput + input - s.prevInput) output := s.hpAlpha * (s.prevOutput + input - s.prevInput)
s.prevInput = input s.prevInput = input
s.prevOutput = output s.prevOutput = output
return output return output
} }
func (s *Suppressor) thresholdToIntensity() float32 {
// Keep lower legacy threshold values meaningful while allowing up to very aggressive suppression.
return 1.0 - float32(math.Exp(float64(-28.0*clampFloat32(s.threshold, 0.0, 1.0))))
}
func (s *Suppressor) updateEnvelopeLocked(absSample float32) {
if absSample > s.envelope {
s.envelope += s.envelopeAttack * (absSample - s.envelope)
} else {
s.envelope += s.envelopeRelease * (absSample - s.envelope)
}
if s.envelope < s.minNoiseFloor {
s.envelope = s.minNoiseFloor
}
}
func (s *Suppressor) updateNoiseFloorLocked() {
coef := s.noiseRelease
if s.envelope < s.noiseFloor*2.2 {
coef = s.noiseAttack
}
s.noiseFloor += coef * (s.envelope - s.noiseFloor)
if s.noiseFloor < s.minNoiseFloor {
s.noiseFloor = s.minNoiseFloor
}
}
func (s *Suppressor) applyTransientSuppressionLocked(absSample float32, voicePresence float32, minGain float32, targetGain float32) float32 {
s.clickEnergy = (s.clickEnergy * s.clickDecay) + (absSample * (1.0 - s.clickDecay))
transient := absSample - s.clickEnergy
transientThreshold := 0.04 + (0.08 * (1.0 - voicePresence))
if transient > transientThreshold && voicePresence < 0.65 {
clickGain := minGain * 0.55
if clickGain < targetGain {
targetGain = clickGain
}
}
return clampFloat32(targetGain, 0.02, 1.0)
}
func (s *Suppressor) applyGainSmoothingLocked(targetGain float32) {
if targetGain < s.suppressionGain {
s.suppressionGain += s.gainAttack * (targetGain - s.suppressionGain)
} else {
s.suppressionGain += s.gainRelease * (targetGain - s.suppressionGain)
}
s.suppressionGain = clampFloat32(s.suppressionGain, 0.02, 1.0)
}
func (s *Suppressor) resetStateLocked() {
s.prevInput = 0.0
s.prevOutput = 0.0
s.envelope = s.minNoiseFloor
s.noiseFloor = s.minNoiseFloor
s.suppressionGain = 1.0
s.clickEnergy = 0.0
}
func clampFloat32(value float32, min float32, max float32) float32 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// ProcessSamplesAdvanced applies more sophisticated noise suppression // ProcessSamplesAdvanced applies more sophisticated noise suppression
// This is a placeholder for future RNNoise integration // Placeholder for future RNNoise integration.
func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) { func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) {
// TODO: Integrate RNNoise or other advanced algorithms
s.ProcessSamples(samples) s.ProcessSamples(samples)
} }

103
noise/suppression_test.go Normal file
View File

@@ -0,0 +1,103 @@
package noise
import (
"math"
"testing"
)
func TestSuppressorDisabledBypassesSamples(t *testing.T) {
suppressor := NewSuppressor()
samples := []int16{100, -200, 300, -400, 500}
original := append([]int16(nil), samples...)
suppressor.ProcessSamples(samples)
for i := range samples {
if samples[i] != original[i] {
t.Fatalf("expected sample %d to remain unchanged, got %d want %d", i, samples[i], original[i])
}
}
}
func TestSuppressorAttenuatesLowLevelNoise(t *testing.T) {
suppressor := NewSuppressor()
suppressor.SetEnabled(true)
suppressor.SetThreshold(0.08)
input := makeSineFrame(600, 700)
originalRMS := frameRMS(input)
processed := append([]int16(nil), input...)
suppressor.ProcessSamples(processed)
processedRMS := frameRMS(processed)
if processedRMS >= originalRMS*0.8 {
t.Fatalf("expected low-level noise attenuation, got RMS %.2f from %.2f", processedRMS, originalRMS)
}
}
func TestSuppressorPreservesSpeechLikeSignal(t *testing.T) {
suppressor := NewSuppressor()
suppressor.SetEnabled(true)
suppressor.SetThreshold(0.08)
voice := makeSineFrame(1000, 9000)
originalRMS := frameRMS(voice)
processed := append([]int16(nil), voice...)
suppressor.ProcessSamples(processed)
processedRMS := frameRMS(processed)
if processedRMS <= originalRMS*0.6 {
t.Fatalf("expected speech-like signal to be mostly preserved, got RMS %.2f from %.2f", processedRMS, originalRMS)
}
}
func TestHigherThresholdAppliesStrongerSuppression(t *testing.T) {
lowSuppressor := NewSuppressor()
lowSuppressor.SetEnabled(true)
lowSuppressor.SetThreshold(0.02)
highSuppressor := NewSuppressor()
highSuppressor.SetEnabled(true)
highSuppressor.SetThreshold(0.20)
noiseFrame := makeSineFrame(500, 700)
lowRMS := runFrameWarmup(lowSuppressor, noiseFrame, 8)
highRMS := runFrameWarmup(highSuppressor, noiseFrame, 8)
if highRMS >= lowRMS*0.8 {
t.Fatalf("expected stronger suppression at higher threshold, got low %.2f high %.2f", lowRMS, highRMS)
}
}
func runFrameWarmup(suppressor *Suppressor, frame []int16, repeats int) float64 {
var processed []int16
for i := 0; i < repeats; i++ {
processed = append([]int16(nil), frame...)
suppressor.ProcessSamples(processed)
}
return frameRMS(processed)
}
func makeSineFrame(frequency float64, amplitude float64) []int16 {
const sampleRate = 48000.0
const frameSize = 480
frame := make([]int16, frameSize)
for i := 0; i < frameSize; i++ {
value := math.Sin((2.0 * math.Pi * frequency * float64(i)) / sampleRate)
frame[i] = int16(value * amplitude)
}
return frame
}
func frameRMS(samples []int16) float64 {
if len(samples) == 0 {
return 0.0
}
var sumSquares float64
for _, sample := range samples {
normalized := float64(sample) / 32768.0
sumSquares += normalized * normalized
}
return math.Sqrt(sumSquares / float64(len(samples)))
}

152
ui.go
View File

@@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/nsf/termbox-go" "os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@@ -10,6 +10,7 @@ import (
"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"
) )
const ( const (
@@ -70,7 +71,7 @@ func (b *Barnard) UpdateInputStatus(status string) {
func (b *Barnard) AddOutputLine(line string) { func (b *Barnard) AddOutputLine(line string) {
now := time.Now() now := time.Now()
b.UiOutput.AddLine(fmt.Sprintf("[%02d:%02d:%02d] %s", now.Hour(), now.Minute(), now.Second(), line)) 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) {
@@ -99,7 +100,7 @@ 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 {
@@ -107,6 +108,13 @@ 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) { 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
@@ -143,7 +151,7 @@ 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 {
@@ -151,6 +159,102 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
} }
} }
func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
// cmd contains just the filename part (everything after "/file ")
filename := strings.TrimSpace(cmd)
if filename == "" {
b.AddOutputLine("Usage: /file <filename or URL>")
return
}
// Check if it's a URL
isURL := strings.HasPrefix(filename, "http://") ||
strings.HasPrefix(filename, "https://") ||
strings.HasPrefix(filename, "ftp://") ||
strings.HasPrefix(filename, "rtmp://")
if !isURL {
// Expand ~ to home directory for local files
if strings.HasPrefix(filename, "~") {
homeDir := os.Getenv("HOME")
filename = strings.Replace(filename, "~", homeDir, 1)
}
// Check if local file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
return
}
}
if !b.Connected {
b.AddOutputLine("Not connected to server")
return
}
b.FileStreamMutex.Lock()
defer b.FileStreamMutex.Unlock()
if b.FileStream != nil && b.FileStream.IsPlaying() {
b.AddOutputLine("Already playing a file. Use /stop first.")
return
}
err := b.FileStream.PlayFile(filename)
if err != nil {
b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
return
}
// Enable stereo encoder for file playback
b.Client.EnableStereoEncoder()
// Auto-start transmission if not already transmitting
if !b.Tx {
err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
if err != nil {
b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error()))
b.FileStream.Stop()
b.Client.DisableStereoEncoder()
return
}
b.Tx = true
b.UpdateGeneralStatus(" File ", true)
}
if isURL {
b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
} else {
b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
}
}
func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
b.FileStreamMutex.Lock()
defer b.FileStreamMutex.Unlock()
if b.FileStream == nil || !b.FileStream.IsPlaying() {
b.AddOutputLine("No file playing")
return
}
err := b.FileStream.Stop()
if err != nil {
b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
return
}
// Disable stereo encoder when file stops
b.Client.DisableStereoEncoder()
b.AddOutputLine("File playback stopped")
// Note: We keep transmission active even after file stops
// User can manually stop with talk key or it will stop when they're done talking
b.UpdateGeneralStatus(" Idle ", false)
}
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
@@ -249,6 +353,43 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
if text == "" { if text == "" {
return return
} }
// Check if this is a command (starts with /)
if strings.HasPrefix(text, "/") {
// Remove the leading slash and process as command
cmdText := strings.TrimPrefix(text, "/")
parts := strings.SplitN(cmdText, " ", 2)
cmdName := parts[0]
cmdArgs := ""
if len(parts) > 1 {
cmdArgs = parts[1]
}
// Handle built-in commands
switch cmdName {
case "file":
b.CommandPlayFile(ui, cmdArgs)
case "stop":
b.CommandStopFile(ui, cmdArgs)
case "exit":
b.CommandExit(ui, cmdArgs)
case "status":
b.CommandStatus(ui, cmdArgs)
case "noise":
b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "micup":
b.CommandMicUp(ui, cmdArgs)
case "micdown":
b.CommandMicDown(ui, cmdArgs)
case "toggle", "talk":
b.CommandTalk(ui, cmdArgs)
default:
b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
}
return
}
// 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)
@@ -317,10 +458,13 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
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.CommandStopFile, "stop")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit) b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp) b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown) b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"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"
"sort" "sort"
@@ -11,7 +12,10 @@ func (ti TreeItem) String() string {
if ti.User.LocallyMuted { if ti.User.LocallyMuted {
return "[MUTED] " + ti.User.Name return "[MUTED] " + ti.User.Name
} }
return ti.User.Name // Calculate total volume as percentage
boostPercent := float32(ti.User.Boost-1) * 10
totalVolume := ti.User.Volume*100 + boostPercent
return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume)
} }
if ti.Channel != nil { if ti.Channel != nil {
return "#" + ti.Channel.Name return "#" + ti.Channel.Name

15
uiterm/key_toml.go Normal file
View File

@@ -0,0 +1,15 @@
package uiterm
// MarshalText implements the encoding.TextMarshaler interface for Key
// This allows TOML to serialize Key values as strings
func (i Key) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for Key
// This allows TOML to deserialize strings back into Key values
func (i *Key) UnmarshalText(data []byte) error {
var err error
*i, err = KeyString(string(data))
return err
}