Compare commits
18 Commits
audio
...
e84cb67500
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e84cb67500 | ||
|
|
3db526f42b | ||
|
|
e3b6eac2a0 | ||
|
|
cfbefd3f7d | ||
|
|
9fe7d7ad87 | ||
|
|
0700264afe | ||
|
|
331055ab18 | ||
|
|
6d1944d155 | ||
|
|
e3f90a76c0 | ||
|
|
dd425563ba | ||
|
|
bb80b13c36 | ||
|
|
01da54882e | ||
|
|
6191132620 | ||
|
|
fae372bb78 | ||
|
|
f96cb1f79b | ||
|
|
82b308000d | ||
|
|
67d6ec2f37 | ||
|
|
5ed445060c |
52
README.md
52
README.md
@@ -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
159
audio/agc.go
Normal 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
408
audio/effects.go
Normal 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
|
||||
}
|
||||
}
|
||||
106
barnard-ui
106
barnard-ui
@@ -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//_/-}"
|
||||
|
||||
12
barnard.go
12
barnard.go
@@ -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() {
|
||||
|
||||
32
client.go
32
client.go
@@ -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))
|
||||
|
||||
@@ -19,4 +19,5 @@ type Hotkeys struct {
|
||||
ScrollToTop *uiterm.Key
|
||||
ScrollToBottom *uiterm.Key
|
||||
NoiseSuppressionToggle *uiterm.Key
|
||||
CycleVoiceEffect *uiterm.Key
|
||||
}
|
||||
|
||||
@@ -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
241
fileplayback/player.go
Normal 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
2
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
49
main.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
103
noise/suppression_test.go
Normal 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
152
ui.go
@@ -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)
|
||||
|
||||
@@ -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
15
uiterm/key_toml.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user