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 boost setting is saved per user, just like per user volume.
## Voice Effects
Barnard includes real-time voice effects that can be applied to your outgoing microphone audio. Press F12 to cycle through the available effects.
### Available Effects
- **None**: No effect applied (default)
- **Echo**: Single repeating delay effect with feedback (250ms) - creates distinct repetitions that fade away
- **Reverb**: Multiple short delays (12.5ms, 20ms, 33ms) without feedback - adds thickness and fullness to your voice
- **High Pitch**: Chipmunk-style voice using pitch shifting
- **Low Pitch**: Deep voice using pitch shifting
- **Robot**: Ring modulation effect for robotic sound
- **Chorus**: Layered voices with slight pitch variations for a rich, ensemble sound
### Controls
- **F12 key**: Cycle through voice effects (configurable hotkey)
- **Configuration**: Your selected effect is saved in `~/.barnard.toml`
### How It Works
Voice effects are applied to your outgoing audio in real-time, after noise suppression and automatic gain control. The effects use various digital signal processing techniques including delay lines, pitch shifting with cubic interpolation, and ring modulation.
## Noise Suppression
Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
### Features
- **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
- **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)
- **Command line**: Use `--noise-suppression` flag to enable at startup
- **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
```yaml
noisesuppressionenabled: true
noisesuppressionthreshold: 0.02
```toml
noisesuppressionenabled = true
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
@@ -104,8 +125,8 @@ Our thanks go out to Tim Cooper for the massive amount of work put into this cli
## Config
By default, the file $HOME/.barnard.yaml will hold the configuration for Barnard.
You can have barnard read another file by using the -c option, like `./barnard -c ~/.anotherbarnard.yaml`.
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.toml`.
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.
@@ -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.
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
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.
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
@@ -191,6 +224,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree
- <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
local i
local menuList
for i in $@ ; do
for i in "$@" ; do
menuList+=("$i" "$i")
done
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
return
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//\"/}"
command barnard -username "${username:-${USER}-${HOSTNAME}}" -server ${mumbleServerList[$serverName]} --fifo ~/.config/barnard/cmd --buffers 16 |& log
username="${username:-${USER}-${HOSTNAME}}"
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() {
@@ -159,9 +165,99 @@ remove-server() {
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
while : ; do
action="$(menulist "Connect" "Add_server" "Remove_server")"
action="$(menulist "Connect" "Add_server" "Remove_server" "Manage_Certificate")"
[[ $? -eq 1 ]] && exit 0
action="${action,,}"
action="${action//_/-}"

View File

@@ -2,8 +2,11 @@ package main
import (
"crypto/tls"
"sync"
"git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/fileplayback"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/noise"
@@ -44,9 +47,16 @@ type Barnard struct {
// Added for channel muting
MutedChannels map[uint32]bool
// Added for noise suppression
NoiseSuppressor *noise.Suppressor
// Added for voice effects
VoiceEffects *audio.EffectsProcessor
// Added for file playback
FileStream *fileplayback.Player
FileStreamMutex sync.Mutex
}
func (b *Barnard) StopTransmission() {

View File

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

View File

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

View File

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

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

View File

@@ -59,8 +59,10 @@ type Client struct {
ContextActions ContextActions
// The audio encoder used when sending audio to the server.
AudioEncoder AudioEncoder
audioCodec AudioCodec
AudioEncoder AudioEncoder
AudioEncoderStereo AudioEncoder
audioCodec AudioCodec
useStereoEncoder bool
// 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
// 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) {
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.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()
}

View File

@@ -1,345 +1,586 @@
package gumbleopenal
import (
"encoding/binary"
"errors"
"os/exec"
"time"
"encoding/binary"
"errors"
"os/exec"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/audio"
"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
type NoiseProcessor interface {
ProcessSamples(samples []int16)
IsEnabled() bool
ProcessSamples(samples []int16)
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 (
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
)
var (
ErrState = errors.New("gumbleopenal: invalid state")
ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured")
ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters")
ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters")
ErrState = errors.New("gumbleopenal: invalid state")
ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured")
ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters")
ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters")
)
func beep() {
cmd := exec.Command("beep")
cmdout, err := cmd.Output()
if err != nil {
panic(err)
}
if cmdout != nil {
}
cmd := exec.Command("beep")
cmdout, err := cmd.Output()
if err != nil {
panic(err)
}
if cmdout != nil {
}
}
type Stream struct {
client *gumble.Client
link gumble.Detacher
client *gumble.Client
link gumble.Detacher
deviceSource *openal.CaptureDevice
sourceFrameSize int
micVolume float32
sourceStop chan bool
deviceSource *openal.CaptureDevice
sourceFormat openal.Format
sourceChannels int
sourceFrameSize int
micVolume float32
sourceStop chan bool
deviceSink *openal.Device
contextSink *openal.Context
noiseProcessor NoiseProcessor
deviceSink *openal.Device
contextSink *openal.Context
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) {
frmsz := 480
if !test {
frmsz = client.Config.AudioFrameSize()
}
frmsz := 480
if !test {
frmsz = client.Config.AudioFrameSize()
}
// Always use mono for input device
idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(frmsz))
if idev == nil {
return nil, ErrInputDevice
}
inputFormat := openal.FormatStereo16
sourceChannels := 2
idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, inputFormat, uint32(frmsz))
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)
if odev == nil {
idev.CaptureCloseDevice()
return nil, ErrOutputDevice
}
odev := openal.OpenDevice(*outputDevice)
if odev == nil {
idev.CaptureCloseDevice()
return nil, ErrOutputDevice
}
if test {
idev.CaptureCloseDevice()
odev.CloseDevice()
return nil, nil
}
if test {
idev.CaptureCloseDevice()
odev.CloseDevice()
return nil, nil
}
s := &Stream{
client: client,
sourceFrameSize: frmsz,
micVolume: 1.0,
}
s := &Stream{
client: client,
sourceFormat: inputFormat,
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
if s.deviceSource == nil {
return nil, ErrInputDevice
}
s.deviceSource = idev
if s.deviceSource == nil {
return nil, ErrInputDevice
}
s.deviceSink = odev
if s.deviceSink == nil {
return nil, ErrOutputDevice
}
s.contextSink = s.deviceSink.CreateContext()
if s.contextSink == nil {
s.Destroy()
return nil, ErrOutputDevice
}
s.contextSink.Activate()
s.deviceSink = odev
if s.deviceSink == nil {
return nil, ErrOutputDevice
}
s.contextSink = s.deviceSink.CreateContext()
if s.contextSink == nil {
s.Destroy()
return nil, ErrOutputDevice
}
s.contextSink.Activate()
return s, nil
return s, nil
}
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) {
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() {
if s.link != nil {
s.link.Detach()
}
if s.deviceSource != nil {
s.StopSource()
s.deviceSource.CaptureCloseDevice()
s.deviceSource = nil
}
if s.deviceSink != nil {
s.contextSink.Destroy()
s.deviceSink.CloseDevice()
s.contextSink = nil
s.deviceSink = nil
}
if s.link != nil {
s.link.Detach()
}
if s.deviceSource != nil {
s.StopSource()
s.deviceSource.CaptureCloseDevice()
s.deviceSource = nil
}
if s.deviceSink != nil {
s.contextSink.Destroy()
s.deviceSink.CloseDevice()
s.contextSink = nil
s.deviceSink = nil
}
}
func (s *Stream) StartSource(inputDevice *string) error {
if s.sourceStop != nil {
return ErrState
}
if s.deviceSource == nil {
return ErrMic
}
s.deviceSource.CaptureStart()
s.sourceStop = make(chan bool)
go s.sourceRoutine(inputDevice)
return nil
if s.sourceStop != nil {
return ErrState
}
if s.deviceSource == nil {
return ErrMic
}
s.deviceSource.CaptureStart()
s.sourceStop = make(chan bool)
go s.sourceRoutine(inputDevice)
return nil
}
func (s *Stream) StopSource() error {
if s.deviceSource == nil {
return ErrMic
}
s.deviceSource.CaptureStop()
if s.sourceStop == nil {
return ErrState
}
close(s.sourceStop)
s.sourceStop = nil
return nil
if s.deviceSource == nil {
return ErrMic
}
s.deviceSource.CaptureStop()
if s.sourceStop == nil {
return ErrState
}
close(s.sourceStop)
s.sourceStop = nil
return nil
}
func (s *Stream) GetMicVolume() float32 {
return s.micVolume
return s.micVolume
}
func (s *Stream) SetMicVolume(change float32, relative bool) {
var val float32
if relative {
val = s.GetMicVolume() + change
} else {
val = change
}
if val >= 1 {
val = 1.0
}
if val <= 0 {
val = 0
}
s.micVolume = val
var val float32
if relative {
val = s.GetMicVolume() + change
} else {
val = change
}
if val >= 1 {
val = 1.0
}
if val <= 0 {
val = 0
}
s.micVolume = val
}
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
go func(e *gumble.AudioStreamEvent) {
var source = openal.NewSource()
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)
}
go func(e *gumble.AudioStreamEvent) {
var source = openal.NewSource()
e.User.AudioSource = &source
bufferCount := e.Client.Config.Buffers
if bufferCount < 64 {
bufferCount = 64
}
emptyBufs := openal.NewBuffers(bufferCount)
reclaim := func() {
if n := source.BuffersProcessed(); n > 0 {
reclaimedBufs := make(openal.Buffers, n)
source.UnqueueBuffers(reclaimedBufs)
emptyBufs = append(emptyBufs, reclaimedBufs...)
}
}
// 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)
}
var raw [maxBufferSize]byte
for packet := range e.C {
// Skip processing if user is locally muted
if e.User.LocallyMuted {
continue
}
bufferCount := e.Client.Config.Buffers
if bufferCount < 64 {
bufferCount = 64
}
emptyBufs := openal.NewBuffers(bufferCount)
var boost uint16 = uint16(1)
samples := len(packet.AudioBuffer)
if samples > cap(raw)/2 {
continue
}
boost = e.User.Boost
reclaim := func() {
if n := source.BuffersProcessed(); n > 0 {
reclaimedBufs := make(openal.Buffers, n)
source.UnqueueBuffers(reclaimedBufs)
emptyBufs = append(emptyBufs, reclaimedBufs...)
}
}
// Check if sample count suggests stereo data
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
format := openal.FormatMono16
if isStereo {
format = openal.FormatStereo16
samples = samples / 2
}
var raw [maxBufferSize]byte
rawPtr := 0
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
for packet := range e.C {
// Skip processing if user is locally muted
if e.User.LocallyMuted {
continue
}
// Process right channel with saturation protection
sample = packet.AudioBuffer[i+1]
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
}
} 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
}
}
var boost uint16 = uint16(1)
samples := len(packet.AudioBuffer)
if samples > cap(raw)/2 {
continue
}
reclaim()
if len(emptyBufs) == 0 {
continue
}
boost = e.User.Boost
last := len(emptyBufs) - 1
buffer := emptyBufs[last]
emptyBufs = emptyBufs[:last]
// Check if sample count suggests stereo data
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
format := openal.FormatMono16
if isStereo {
format = openal.FormatStereo16
samples = samples / 2
}
buffer.SetData(format, raw[:rawPtr], gumble.AudioSampleRate)
source.QueueBuffer(buffer)
rawPtr := 0
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 {
source.Play()
}
}
reclaim()
emptyBufs.Delete()
source.Delete()
}(e)
// Process right channel with saturation protection
sample = packet.AudioBuffer[i+1]
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
}
} 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) {
interval := s.client.Config.AudioInterval
frameSize := s.client.Config.AudioFrameSize()
interval := s.client.Config.AudioInterval
frameSize := s.client.Config.AudioFrameSize()
if frameSize != s.sourceFrameSize {
s.deviceSource.CaptureCloseDevice()
s.sourceFrameSize = frameSize
// Always use mono for input
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(s.sourceFrameSize))
}
if frameSize != s.sourceFrameSize {
s.deviceSource.CaptureCloseDevice()
s.sourceFrameSize = frameSize
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, s.sourceFormat, 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)
defer ticker.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
stop := s.sourceStop
stop := s.sourceStop
outgoing := s.client.AudioOutgoing()
defer close(outgoing)
outgoing := s.client.AudioOutgoing()
defer close(outgoing)
for {
select {
case <-stop:
return
case <-ticker.C:
buff := s.deviceSource.CaptureSamples(uint32(frameSize))
if len(buff) != frameSize*2 {
continue
}
int16Buffer := make([]int16, frameSize)
for i := range int16Buffer {
sample := int16(binary.LittleEndian.Uint16(buff[i*2:]))
if s.micVolume != 1.0 {
sample = int16(float32(sample) * s.micVolume)
}
int16Buffer[i] = sample
}
// Apply noise suppression if available and enabled
if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() {
s.noiseProcessor.ProcessSamples(int16Buffer)
}
outgoing <- gumble.AudioBuffer(int16Buffer)
}
}
for {
select {
case <-stop:
return
case <-ticker.C:
sampleCount := frameSize * s.sourceChannels
int16Buffer := make([]int16, sampleCount)
// Capture microphone if available
hasMicInput := false
buff := s.deviceSource.CaptureSamples(uint32(frameSize))
if len(buff) == sampleCount*2 {
hasMicInput = true
for i := 0; i < sampleCount; i++ {
sample := int16(binary.LittleEndian.Uint16(buff[i*2:]))
if s.micVolume != 1.0 {
sample = int16(float32(sample) * s.micVolume)
}
int16Buffer[i] = sample
}
if s.sourceChannels == 1 {
s.processMonoSamples(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 {
// Create decoder with stereo support
d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels)

49
main.go
View File

@@ -15,14 +15,15 @@ import (
//"github.com/google/shlex"
"crypto/tls"
"flag"
"github.com/alessio/shellescape"
"git.stormux.org/storm/barnard/audio"
"git.stormux.org/storm/barnard/config"
"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/opus"
"git.stormux.org/storm/barnard/uiterm"
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
)
func show_devs(name string, args []string) {
@@ -107,7 +108,8 @@ func main() {
password := flag.String("password", "", "the password of the server")
insecure := flag.Bool("insecure", false, "skip server certificate verification")
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")
fifo := flag.String("fifo", "", "path of a FIFO from which to read commands")
serverSet := false
@@ -126,12 +128,15 @@ func main() {
userConfig := config.NewConfig(cfgfn)
certificateSet := false
flag.CommandLine.Visit(func(theFlag *flag.Flag) {
switch theFlag.Name {
case "server":
serverSet = true
case "username":
usernameSet = true
case "certificate":
certificateSet = true
}
})
@@ -141,6 +146,22 @@ func main() {
if !usernameSet {
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") == "" {
os.Setenv("ALSOFT_LOGLEVEL", "0")
@@ -157,17 +178,18 @@ func main() {
// Initialize
b := Barnard{
Config: gumble.NewConfig(),
UserConfig: userConfig,
Address: *server,
MutedChannels: make(map[uint32]bool),
Config: gumble.NewConfig(),
UserConfig: userConfig,
Address: *server,
MutedChannels: make(map[uint32]bool),
NoiseSuppressor: noise.NewSuppressor(),
VoiceEffects: audio.NewEffectsProcessor(gumble.AudioSampleRate),
}
b.Config.Buffers = *buffers
b.Hotkeys = b.UserConfig.GetHotkeys()
b.UserConfig.SaveConfig()
// Configure noise suppression
enabled := b.UserConfig.GetNoiseSuppressionEnabled()
if *noiseSuppressionEnabled {
@@ -176,7 +198,10 @@ func main() {
}
b.NoiseSuppressor.SetEnabled(enabled)
b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold())
// Configure voice effects
b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect()))
b.Config.Username = *username
b.Config.Password = *password
@@ -196,12 +221,12 @@ func main() {
if err != nil {
b.exitMessage = err.Error()
b.exitStatus = 1
handle_error(b)
handle_error(&b)
}
b.notifyChannel = setup_notify_runner(*b.UserConfig.GetNotifyCommand())
b.Ui = uiterm.New(&b)
b.Ui.Run(reader)
handle_error(b)
handle_error(&b)
}
func handle_raw_error(e error) {
@@ -209,7 +234,7 @@ func handle_raw_error(e error) {
os.Exit(1)
}
func handle_error(b Barnard) {
func handle_error(b *Barnard) {
if b.exitMessage != "" {
fmt.Fprintf(os.Stderr, "%s\n", b.exitMessage)
}

View File

@@ -2,6 +2,7 @@ package noise
import (
"math"
"sync"
)
// Ensure Suppressor implements the NoiseProcessor interface
@@ -12,129 +13,200 @@ var _ interface {
// Suppressor handles noise suppression for audio samples
type Suppressor struct {
enabled bool
threshold float32
gainFactor float32
// Simple high-pass filter state for DC removal
mu sync.Mutex
enabled bool
threshold float32
// High-pass filter state for low-frequency rumble/DC removal.
prevInput float32
prevOutput float32
alpha float32
// Click detection state
clickThreshold float32
clickDecay float32
recentClickEnergy float32
hpAlpha float32
// Adaptive suppression state.
envelope float32
noiseFloor 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
func NewSuppressor() *Suppressor {
return &Suppressor{
enabled: false,
threshold: 0.01, // Reduced noise threshold level for less aggressive filtering
gainFactor: 0.9, // Less aggressive gain reduction for noise
alpha: 0.98, // More stable high-pass filter coefficient
clickThreshold: 0.15, // Threshold for detecting keyboard clicks
clickDecay: 0.95, // How quickly click energy decays
recentClickEnergy: 0.0, // Tracks recent click activity
s := &Suppressor{
enabled: false,
threshold: 0.08,
hpAlpha: 0.995,
envelopeAttack: 0.18,
envelopeRelease: 0.02,
noiseAttack: 0.08,
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
func (s *Suppressor) SetEnabled(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.enabled == enabled {
return
}
s.enabled = enabled
s.resetStateLocked()
}
// IsEnabled returns whether noise suppression is enabled
func (s *Suppressor) IsEnabled() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.enabled
}
// SetThreshold sets the noise threshold (0.0 to 1.0)
func (s *Suppressor) SetThreshold(threshold float32) {
if threshold >= 0.0 && threshold <= 1.0 {
s.threshold = threshold
}
s.mu.Lock()
defer s.mu.Unlock()
s.threshold = clampFloat32(threshold, 0.0, 1.0)
}
// GetThreshold returns the current noise threshold
func (s *Suppressor) GetThreshold() float32 {
s.mu.Lock()
defer s.mu.Unlock()
return s.threshold
}
// ProcessSamples applies noise suppression to audio samples
func (s *Suppressor) ProcessSamples(samples []int16) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || len(samples) == 0 {
return
}
// Calculate frame energy for click detection
var frameEnergy float32 = 0.0
for _, sample := range samples {
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
intensity := s.thresholdToIntensity()
minGain := 1.0 - (0.92 * intensity)
eps := float32(1e-6)
for i, sample := range samples {
// Convert to float for processing
floatSample := float32(sample) / 32767.0
// Apply high-pass filter for DC removal
filtered := s.highPassFilter(floatSample)
// Calculate signal strength (RMS-like)
strength := float32(math.Abs(float64(filtered)))
// Apply noise gate with smooth transition
var gainReduction float32 = 1.0
// If we detected a click, apply stronger suppression
if isClick {
gainReduction = s.gainFactor * 0.3 // Much stronger reduction for clicks
} else if strength < s.threshold {
// Normal noise gate for low-level sounds
gainReduction = strength / s.threshold
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)
floatSample := float32(sample) / 32768.0
filtered := s.highPassFilterLocked(floatSample)
absSample := float32(math.Abs(float64(filtered)))
s.updateEnvelopeLocked(absSample)
s.updateNoiseFloorLocked()
snr := s.envelope / (s.noiseFloor + eps)
voicePresence := clampFloat32((snr-1.0)/(s.speechRatio-1.0), 0.0, 1.0)
targetGain := minGain + ((1.0 - minGain) * voicePresence)
targetGain = s.applyTransientSuppressionLocked(absSample, voicePresence, minGain, targetGain)
s.applyGainSmoothingLocked(targetGain)
processed := filtered * s.suppressionGain
processed = clampFloat32(processed, -1.0, 1.0)
samples[i] = int16(processed * 32767.0)
}
}
// highPassFilter applies a simple high-pass filter to remove DC component
func (s *Suppressor) highPassFilter(input float32) float32 {
func (s *Suppressor) highPassFilterLocked(input float32) float32 {
// 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.prevOutput = 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
// This is a placeholder for future RNNoise integration
// Placeholder for future RNNoise integration.
func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) {
// TODO: Integrate RNNoise or other advanced algorithms
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 (
"fmt"
"github.com/nsf/termbox-go"
"os"
"os/exec"
"strings"
"time"
@@ -10,6 +10,7 @@ import (
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm"
"github.com/kennygrant/sanitize"
"github.com/nsf/termbox-go"
)
const (
@@ -70,7 +71,7 @@ func (b *Barnard) UpdateInputStatus(status string) {
func (b *Barnard) AddOutputLine(line string) {
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) {
@@ -99,7 +100,7 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
b.NoiseSuppressor.SetEnabled(enabled)
if enabled {
b.UpdateGeneralStatus("Noise suppression: ON", false)
} 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) {
if notice {
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
@@ -143,7 +151,7 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
b.NoiseSuppressor.SetEnabled(enabled)
if enabled {
b.AddOutputLine("Noise suppression enabled")
} 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) {
if b.Tx && val == 1 {
return
@@ -249,6 +353,43 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
if text == "" {
return
}
// Check if this is a command (starts with /)
if strings.HasPrefix(text, "/") {
// Remove the leading slash and process as command
cmdText := strings.TrimPrefix(text, "/")
parts := strings.SplitN(cmdText, " ", 2)
cmdName := parts[0]
cmdArgs := ""
if len(parts) > 1 {
cmdArgs = parts[1]
}
// 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.selectedUser != nil {
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.CommandStatus, "status")
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm"
"sort"
@@ -11,7 +12,10 @@ func (ti TreeItem) String() string {
if ti.User.LocallyMuted {
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 {
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
}