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 audio should drastically increase once you have hit the VolumeUp key over 10 times (from the silent/0 position).
|
||||||
The boost setting is saved per user, just like per user volume.
|
The boost setting is saved per user, just like per user volume.
|
||||||
|
|
||||||
|
## Voice Effects
|
||||||
|
|
||||||
|
Barnard includes real-time voice effects that can be applied to your outgoing microphone audio. Press F12 to cycle through the available effects.
|
||||||
|
|
||||||
|
### Available Effects
|
||||||
|
- **None**: No effect applied (default)
|
||||||
|
- **Echo**: Single repeating delay effect with feedback (250ms) - creates distinct repetitions that fade away
|
||||||
|
- **Reverb**: Multiple short delays (12.5ms, 20ms, 33ms) without feedback - adds thickness and fullness to your voice
|
||||||
|
- **High Pitch**: Chipmunk-style voice using pitch shifting
|
||||||
|
- **Low Pitch**: Deep voice using pitch shifting
|
||||||
|
- **Robot**: Ring modulation effect for robotic sound
|
||||||
|
- **Chorus**: Layered voices with slight pitch variations for a rich, ensemble sound
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
- **F12 key**: Cycle through voice effects (configurable hotkey)
|
||||||
|
- **Configuration**: Your selected effect is saved in `~/.barnard.toml`
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
Voice effects are applied to your outgoing audio in real-time, after noise suppression and automatic gain control. The effects use various digital signal processing techniques including delay lines, pitch shifting with cubic interpolation, and ring modulation.
|
||||||
|
|
||||||
## Noise Suppression
|
## Noise Suppression
|
||||||
|
|
||||||
Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
|
Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- **Real-time processing**: Noise suppression is applied during audio capture with minimal latency
|
- **Real-time processing**: Noise suppression is applied during audio capture with minimal latency
|
||||||
- **Configurable threshold**: Adjustable noise gate threshold (default: 0.02)
|
- **Configurable amount**: Adjustable suppression amount via threshold value (default: `0.08`)
|
||||||
- **Persistent settings**: Noise suppression preferences are saved in your configuration file
|
- **Persistent settings**: Noise suppression preferences are saved in your configuration file
|
||||||
- **Multiple control methods**: Toggle via hotkey, command line flag, or FIFO commands
|
- **Multiple control methods**: Toggle via hotkey, command line flag, or FIFO commands
|
||||||
|
|
||||||
@@ -32,15 +52,16 @@ Barnard includes real-time noise suppression for microphone input to filter out
|
|||||||
- **F9 key**: Toggle noise suppression on/off (configurable hotkey)
|
- **F9 key**: Toggle noise suppression on/off (configurable hotkey)
|
||||||
- **Command line**: Use `--noise-suppression` flag to enable at startup
|
- **Command line**: Use `--noise-suppression` flag to enable at startup
|
||||||
- **FIFO command**: Send `noise` command to toggle during runtime
|
- **FIFO command**: Send `noise` command to toggle during runtime
|
||||||
- **Configuration**: Set `noisesuppressionenabled` and `noisesuppressionthreshold` in `~/.barnard.yaml`
|
- **Configuration**: Set `noisesuppressionenabled` and `noisesuppressionthreshold` in `~/.barnard.toml`
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
```yaml
|
```toml
|
||||||
noisesuppressionenabled: true
|
noisesuppressionenabled = true
|
||||||
noisesuppressionthreshold: 0.02
|
noisesuppressionthreshold = 0.08
|
||||||
```
|
```
|
||||||
|
|
||||||
The noise suppression algorithm uses a combination of high-pass filtering and noise gating to reduce unwanted background sounds while preserving voice quality.
|
`noisesuppressionthreshold` accepts values from `0.0` to `1.0`, where higher values apply stronger suppression.
|
||||||
|
The noise suppression algorithm uses adaptive noise-floor tracking, transient suppression, and smoothed gain reduction to reduce background noise while preserving voice quality.
|
||||||
|
|
||||||
## FIFO Control
|
## FIFO Control
|
||||||
|
|
||||||
@@ -104,8 +125,8 @@ Our thanks go out to Tim Cooper for the massive amount of work put into this cli
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
By default, the file $HOME/.barnard.yaml will hold the configuration for Barnard.
|
By default, the file $HOME/.barnard.toml will hold the configuration for Barnard.
|
||||||
You can have barnard read another file by using the -c option, like `./barnard -c ~/.anotherbarnard.yaml`.
|
You can have barnard read another file by using the -c option, like `./barnard -c ~/.anotherbarnard.toml`.
|
||||||
It will be created automatically if it doesn't exist.
|
It will be created automatically if it doesn't exist.
|
||||||
If you modify the config file while Barnard is running, your changes may be overwritten.
|
If you modify the config file while Barnard is running, your changes may be overwritten.
|
||||||
|
|
||||||
@@ -121,6 +142,18 @@ Pass the -list_devices parameter to barnard to be given a list of audio input an
|
|||||||
Copy lines from the above list into inputdevice and outputdevice as desired.
|
Copy lines from the above list into inputdevice and outputdevice as desired.
|
||||||
To clear your inputdevice or outputdevice options and set them to defaults, set them to "" or delete them entirely.
|
To clear your inputdevice or outputdevice options and set them to defaults, set them to "" or delete them entirely.
|
||||||
|
|
||||||
|
### Audio Backends (ALSA, PipeWire, PulseAudio)
|
||||||
|
|
||||||
|
Barnard uses OpenAL Soft for audio. By default it will pick the first available backend (often ALSA), but you can force a specific driver:
|
||||||
|
|
||||||
|
- Command line: `./barnard --audio-driver pipewire` (or `pulse`, `alsa`, `jack`)
|
||||||
|
- Config file: add `audiodriver = "pipewire"` to your `~/.barnard.toml`
|
||||||
|
- Environment: `ALSOFT_DRIVERS=pipewire ./barnard` (takes precedence over config)
|
||||||
|
|
||||||
|
If PipeWire or PulseAudio support is missing, install OpenAL Soft with the corresponding backend enabled (e.g., `libopenal1` or `openal-soft` packages built with PipeWire). After changing drivers, rerun with `--list_devices` to confirm the desired devices appear.
|
||||||
|
|
||||||
|
Leaving `audiodriver` empty in the config keeps the OpenAL default ordering (PipeWire/Pulse first if available, then ALSA).
|
||||||
|
|
||||||
## Keystrokes
|
## Keystrokes
|
||||||
|
|
||||||
You can see the below keystrokes in your config file.
|
You can see the below keystrokes in your config file.
|
||||||
@@ -149,7 +182,7 @@ If Jim's volume is set to 0.1, and larry's volume is set to 0.9, lowering the ch
|
|||||||
|
|
||||||
You can change the volume for a user once that user has spoken at least once during a session.
|
You can change the volume for a user once that user has spoken at least once during a session.
|
||||||
Attempts to change the volume of a user who has not spoken will be ignored.
|
Attempts to change the volume of a user who has not spoken will be ignored.
|
||||||
If you are unable to hear a user speaking, you can edit the .barnard.yaml file in your home directory, after closing Barnard, and set the volume parameter to 1.0 for a particular user.
|
If you are unable to hear a user speaking, you can edit the .barnard.toml file in your home directory, after closing Barnard, and set the volume parameter to 1.0 for a particular user.
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
@@ -191,6 +224,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
|
|||||||
|
|
||||||
- <kbd>F1</kbd>: toggle voice transmission
|
- <kbd>F1</kbd>: toggle voice transmission
|
||||||
- <kbd>F9</kbd>: toggle noise suppression
|
- <kbd>F9</kbd>: toggle noise suppression
|
||||||
|
- <kbd>F12</kbd>: cycle through voice effects
|
||||||
- <kbd>Ctrl+L</kbd>: clear chat log
|
- <kbd>Ctrl+L</kbd>: clear chat log
|
||||||
- <kbd>Tab</kbd>: toggle focus between chat and user tree
|
- <kbd>Tab</kbd>: toggle focus between chat and user tree
|
||||||
- <kbd>Page Up</kbd>: scroll chat up
|
- <kbd>Page Up</kbd>: scroll chat up
|
||||||
|
|||||||
159
audio/agc.go
Normal file
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
|
# returns: selected tag
|
||||||
local i
|
local i
|
||||||
local menuList
|
local menuList
|
||||||
for i in $@ ; do
|
for i in "$@" ; do
|
||||||
menuList+=("$i" "$i")
|
menuList+=("$i" "$i")
|
||||||
done
|
done
|
||||||
dialog --backtitle "$(gettext "Use the up and down arrow keys to find the option you want, then press enter to select it.")" \
|
dialog --backtitle "$(gettext "Use the up and down arrow keys to find the option you want, then press enter to select it.")" \
|
||||||
@@ -135,10 +135,16 @@ connect() {
|
|||||||
if [[ -z "$serverName" || "$serverName" == "Go Back" ]]; then
|
if [[ -z "$serverName" || "$serverName" == "Go Back" ]]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
local username="$(grep '^username: .*$' ~/.barnard.yaml 2> /dev/null | cut -d ' ' -f2-)"
|
local username
|
||||||
|
username="$(grep -m 1 '^Username = ' ~/.barnard.toml 2> /dev/null | cut -d '=' -f2- | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;s/'//g")"
|
||||||
username="${username//[[:space:]]/_}"
|
username="${username//[[:space:]]/_}"
|
||||||
username="${username//\"/}"
|
username="${username:-${USER}-${HOSTNAME}}"
|
||||||
command barnard -username "${username:-${USER}-${HOSTNAME}}" -server ${mumbleServerList[$serverName]} --fifo ~/.config/barnard/cmd --buffers 16 |& log
|
local certArgs=()
|
||||||
|
if [[ -f "$certFile" ]]; then
|
||||||
|
certArgs=(-certificate "$certFile")
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
command barnard -username "$username" -server ${mumbleServerList[$serverName]} "${certArgs[@]}" --fifo ~/.config/barnard/cmd --buffers 16 |& log
|
||||||
}
|
}
|
||||||
|
|
||||||
remove-server() {
|
remove-server() {
|
||||||
@@ -159,9 +165,99 @@ remove-server() {
|
|||||||
msgbox "$(gettext "Removed server") $serverName"
|
msgbox "$(gettext "Removed server") $serverName"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Certificate configuration
|
||||||
|
certDir="$HOME/.config/barnard"
|
||||||
|
certFile="$certDir/barnard.pem"
|
||||||
|
|
||||||
|
generate-certificate() {
|
||||||
|
if [[ -f "$certFile" ]]; then
|
||||||
|
if [[ "$(yesno "$(gettext "A certificate already exists. Do you want to replace it? This may affect your registered identity on servers.")")" != "Yes" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local commonName
|
||||||
|
commonName="$(inputbox "$(gettext "Enter a name for your certificate (e.g., your username):")" "barnard")"
|
||||||
|
[[ $? -ne 0 ]] && return
|
||||||
|
[[ -z "$commonName" ]] && commonName="barnard"
|
||||||
|
|
||||||
|
if openssl req -x509 -newkey rsa:2048 -keyout "$certFile" -out "$certFile" -days 3650 -nodes -subj "/CN=$commonName" 2>/dev/null; then
|
||||||
|
chmod 600 "$certFile"
|
||||||
|
msgbox "$(gettext "Certificate generated successfully.")"
|
||||||
|
else
|
||||||
|
msgbox "$(gettext "Failed to generate certificate. Make sure openssl is installed.")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
view-certificate() {
|
||||||
|
if [[ ! -f "$certFile" ]]; then
|
||||||
|
msgbox "$(gettext "No certificate found.") $certFile"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local certInfo
|
||||||
|
certInfo=$(openssl x509 -in "$certFile" -noout -subject -dates -fingerprint 2>/dev/null)
|
||||||
|
if [[ -n "$certInfo" ]]; then
|
||||||
|
msgbox "$certInfo"
|
||||||
|
else
|
||||||
|
msgbox "$(gettext "Could not read certificate information.")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
import-certificate() {
|
||||||
|
local importPath
|
||||||
|
importPath="$(inputbox "$(gettext "Enter the full path to your certificate file (PEM format with certificate and private key):")")"
|
||||||
|
[[ $? -ne 0 ]] && return
|
||||||
|
[[ -z "$importPath" ]] && return
|
||||||
|
|
||||||
|
# Expand ~ if present
|
||||||
|
importPath="${importPath/#\~/$HOME}"
|
||||||
|
|
||||||
|
if [[ ! -f "$importPath" ]]; then
|
||||||
|
msgbox "$(gettext "File not found:") $importPath"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify it's a valid certificate
|
||||||
|
if ! openssl x509 -in "$importPath" -noout 2>/dev/null; then
|
||||||
|
msgbox "$(gettext "The file does not appear to be a valid PEM certificate.")"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify it contains a private key
|
||||||
|
if ! openssl rsa -in "$importPath" -check -noout 2>/dev/null && ! openssl ec -in "$importPath" -check -noout 2>/dev/null; then
|
||||||
|
msgbox "$(gettext "The file does not appear to contain a valid private key. The certificate file must contain both the certificate and private key.")"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$certFile" ]]; then
|
||||||
|
if [[ "$(yesno "$(gettext "A certificate already exists. Do you want to replace it?")")" != "Yes" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if cp "$importPath" "$certFile" && chmod 600 "$certFile"; then
|
||||||
|
msgbox "$(gettext "Certificate imported successfully.")"
|
||||||
|
else
|
||||||
|
msgbox "$(gettext "Failed to import certificate.")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
manage-certificate() {
|
||||||
|
while : ; do
|
||||||
|
local certAction
|
||||||
|
certAction="$(menulist "Generate" "View" "Import" "Go_Back")"
|
||||||
|
[[ $? -eq 1 ]] && return
|
||||||
|
case "$certAction" in
|
||||||
|
"Generate") generate-certificate ;;
|
||||||
|
"View") view-certificate ;;
|
||||||
|
"Import") import-certificate ;;
|
||||||
|
"Go_Back"|"") return ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# main menu
|
# main menu
|
||||||
while : ; do
|
while : ; do
|
||||||
action="$(menulist "Connect" "Add_server" "Remove_server")"
|
action="$(menulist "Connect" "Add_server" "Remove_server" "Manage_Certificate")"
|
||||||
[[ $? -eq 1 ]] && exit 0
|
[[ $? -eq 1 ]] && exit 0
|
||||||
action="${action,,}"
|
action="${action,,}"
|
||||||
action="${action//_/-}"
|
action="${action//_/-}"
|
||||||
|
|||||||
12
barnard.go
12
barnard.go
@@ -2,8 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/barnard/audio"
|
||||||
"git.stormux.org/storm/barnard/config"
|
"git.stormux.org/storm/barnard/config"
|
||||||
|
"git.stormux.org/storm/barnard/fileplayback"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
||||||
"git.stormux.org/storm/barnard/noise"
|
"git.stormux.org/storm/barnard/noise"
|
||||||
@@ -44,9 +47,16 @@ type Barnard struct {
|
|||||||
|
|
||||||
// Added for channel muting
|
// Added for channel muting
|
||||||
MutedChannels map[uint32]bool
|
MutedChannels map[uint32]bool
|
||||||
|
|
||||||
// Added for noise suppression
|
// Added for noise suppression
|
||||||
NoiseSuppressor *noise.Suppressor
|
NoiseSuppressor *noise.Suppressor
|
||||||
|
|
||||||
|
// Added for voice effects
|
||||||
|
VoiceEffects *audio.EffectsProcessor
|
||||||
|
|
||||||
|
// Added for file playback
|
||||||
|
FileStream *fileplayback.Player
|
||||||
|
FileStreamMutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Barnard) StopTransmission() {
|
func (b *Barnard) StopTransmission() {
|
||||||
|
|||||||
32
client.go
32
client.go
@@ -5,9 +5,11 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/barnard/fileplayback"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumbleutil"
|
"git.stormux.org/storm/barnard/gumble/gumbleutil"
|
||||||
|
"git.stormux.org/storm/barnard/gumble/opus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (b *Barnard) start() {
|
func (b *Barnard) start() {
|
||||||
@@ -50,6 +52,22 @@ func (b *Barnard) connect(reconnect bool) bool {
|
|||||||
b.Stream = stream
|
b.Stream = stream
|
||||||
b.Stream.AttachStream(b.Client)
|
b.Stream.AttachStream(b.Client)
|
||||||
b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
|
b.Stream.SetNoiseProcessor(b.NoiseSuppressor)
|
||||||
|
b.Stream.SetEffectsProcessor(b.VoiceEffects)
|
||||||
|
|
||||||
|
// Initialize stereo encoder for file playback
|
||||||
|
b.Client.AudioEncoderStereo = opus.NewStereoEncoder()
|
||||||
|
|
||||||
|
// Initialize file player
|
||||||
|
b.FileStreamMutex.Lock()
|
||||||
|
b.FileStream = fileplayback.New(b.Client)
|
||||||
|
b.FileStream.SetErrorFunc(func(err error) {
|
||||||
|
// Disable stereo when file finishes or errors
|
||||||
|
b.Client.DisableStereoEncoder()
|
||||||
|
b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
|
||||||
|
})
|
||||||
|
b.Stream.SetFilePlayer(b.FileStream)
|
||||||
|
b.FileStreamMutex.Unlock()
|
||||||
|
|
||||||
b.Connected = true
|
b.Connected = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -161,6 +179,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
|
|||||||
if e.Type.Has(gumble.UserChangeConnected) {
|
if e.Type.Has(gumble.UserChangeConnected) {
|
||||||
s = "joined"
|
s = "joined"
|
||||||
t = "join"
|
t = "join"
|
||||||
|
// Notify about users joining our channel
|
||||||
|
if e.User.Channel.Name == b.Client.Self.Channel.Name {
|
||||||
|
b.Notify(t, e.User.Name, e.User.Channel.Name)
|
||||||
|
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if e.Type.Has(gumble.UserChangeDisconnected) {
|
if e.Type.Has(gumble.UserChangeDisconnected) {
|
||||||
s = "left"
|
s = "left"
|
||||||
@@ -168,10 +191,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
|
|||||||
if e.User == b.selectedUser {
|
if e.User == b.selectedUser {
|
||||||
b.SetSelectedUser(nil)
|
b.SetSelectedUser(nil)
|
||||||
}
|
}
|
||||||
}
|
// Always notify about disconnects if user has channel info and was in our channel
|
||||||
if e.User.Channel.Name == b.Client.Self.Channel.Name {
|
if e.User.Channel != nil && e.User.Channel.Name == b.Client.Self.Channel.Name {
|
||||||
b.Notify(t, e.User.Name, e.User.Channel.Name)
|
b.Notify(t, e.User.Name, e.User.Channel.Name)
|
||||||
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
|
b.AddOutputLine(fmt.Sprintf("%s %s %s", e.User.Name, s, e.User.Channel.Name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
|
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
|
||||||
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
|
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ type Hotkeys struct {
|
|||||||
ScrollToTop *uiterm.Key
|
ScrollToTop *uiterm.Key
|
||||||
ScrollToBottom *uiterm.Key
|
ScrollToBottom *uiterm.Key
|
||||||
NoiseSuppressionToggle *uiterm.Key
|
NoiseSuppressionToggle *uiterm.Key
|
||||||
|
CycleVoiceEffect *uiterm.Key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,327 +1,379 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.stormux.org/storm/barnard/uiterm"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
"gopkg.in/yaml.v2"
|
"git.stormux.org/storm/barnard/uiterm"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
config *exportableConfig
|
config *exportableConfig
|
||||||
fn string
|
fn string
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportableConfig struct {
|
type exportableConfig struct {
|
||||||
Hotkeys *Hotkeys
|
Hotkeys *Hotkeys
|
||||||
MicVolume *float32
|
AudioDriver *string
|
||||||
InputDevice *string
|
MicVolume *float32
|
||||||
OutputDevice *string
|
InputDevice *string
|
||||||
Servers []*server
|
OutputDevice *string
|
||||||
DefaultServer *string
|
Servers []*server
|
||||||
Username *string
|
DefaultServer *string
|
||||||
NotifyCommand *string
|
Username *string
|
||||||
NoiseSuppressionEnabled *bool
|
NotifyCommand *string
|
||||||
NoiseSuppressionThreshold *float32
|
NoiseSuppressionEnabled *bool
|
||||||
|
NoiseSuppressionThreshold *float32
|
||||||
|
VoiceEffect *int
|
||||||
|
Certificate *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
Users []*eUser
|
Users []*eUser
|
||||||
}
|
}
|
||||||
|
|
||||||
type eUser struct {
|
type eUser struct {
|
||||||
Username string
|
Username string
|
||||||
Boost uint16
|
Boost uint16
|
||||||
Volume float32
|
Volume float32
|
||||||
LocallyMuted bool // Changed from Muted to LocallyMuted to match User struct
|
LocallyMuted bool // Changed from Muted to LocallyMuted to match User struct
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SaveConfig() {
|
func (c *Config) SaveConfig() {
|
||||||
var data []byte
|
var data []byte
|
||||||
data, err := yaml.Marshal(c.config)
|
data, err := toml.Marshal(c.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
err = ioutil.WriteFile(c.fn+".tmp", data, 0600)
|
err = ioutil.WriteFile(c.fn+".tmp", data, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
err = os.Rename(c.fn+".tmp", c.fn)
|
err = os.Rename(c.fn+".tmp", c.fn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func key(k uiterm.Key) *uiterm.Key {
|
func key(k uiterm.Key) *uiterm.Key {
|
||||||
return &k
|
return &k
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) LoadConfig() {
|
func (c *Config) LoadConfig() {
|
||||||
var jc exportableConfig
|
var jc exportableConfig
|
||||||
jc = exportableConfig{}
|
jc = exportableConfig{}
|
||||||
jc.Hotkeys = &Hotkeys{
|
jc.Hotkeys = &Hotkeys{
|
||||||
Talk: key(uiterm.KeyF1),
|
Talk: key(uiterm.KeyF1),
|
||||||
VolumeDown: key(uiterm.KeyF5),
|
VolumeDown: key(uiterm.KeyF5),
|
||||||
VolumeUp: key(uiterm.KeyF6),
|
VolumeUp: key(uiterm.KeyF6),
|
||||||
VolumeReset: key(uiterm.KeyF8),
|
VolumeReset: key(uiterm.KeyF8),
|
||||||
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
|
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
|
||||||
Exit: key(uiterm.KeyF10),
|
Exit: key(uiterm.KeyF10),
|
||||||
ToggleTimestamps: key(uiterm.KeyF3),
|
ToggleTimestamps: key(uiterm.KeyF3),
|
||||||
SwitchViews: key(uiterm.KeyTab),
|
SwitchViews: key(uiterm.KeyTab),
|
||||||
ScrollUp: key(uiterm.KeyPgup),
|
ScrollUp: key(uiterm.KeyPgup),
|
||||||
ScrollDown: key(uiterm.KeyPgdn),
|
ScrollDown: key(uiterm.KeyPgdn),
|
||||||
NoiseSuppressionToggle: key(uiterm.KeyF9),
|
NoiseSuppressionToggle: key(uiterm.KeyF9),
|
||||||
}
|
CycleVoiceEffect: key(uiterm.KeyF12),
|
||||||
if fileExists(c.fn) {
|
}
|
||||||
var data []byte
|
if fileExists(c.fn) {
|
||||||
data = readFile(c.fn)
|
var data []byte
|
||||||
if data != nil {
|
data = readFile(c.fn)
|
||||||
err := yaml.UnmarshalStrict(data, &jc)
|
if data != nil {
|
||||||
if err != nil {
|
err := toml.Unmarshal(data, &jc)
|
||||||
fmt.Fprintf(os.Stderr, "Error parsing \"%s\".\n%s\n", c.fn, err.Error())
|
if err != nil {
|
||||||
os.Exit(1)
|
fmt.Fprintf(os.Stderr, "Error parsing \"%s\".\n%s\n", c.fn, err.Error())
|
||||||
}
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.config = &jc
|
}
|
||||||
if c.config.MicVolume == nil {
|
c.config = &jc
|
||||||
micvol := float32(1.0)
|
if c.config.MicVolume == nil {
|
||||||
jc.MicVolume = &micvol
|
micvol := float32(1.0)
|
||||||
}
|
jc.MicVolume = &micvol
|
||||||
if c.config.InputDevice == nil {
|
}
|
||||||
idev := string("")
|
if c.config.AudioDriver == nil {
|
||||||
jc.InputDevice = &idev
|
driver := string("")
|
||||||
}
|
jc.AudioDriver = &driver
|
||||||
if c.config.OutputDevice == nil {
|
}
|
||||||
odev := string("")
|
if c.config.InputDevice == nil {
|
||||||
jc.OutputDevice = &odev
|
idev := string("")
|
||||||
}
|
jc.InputDevice = &idev
|
||||||
if c.config.DefaultServer == nil {
|
}
|
||||||
defaultServer := string("localhost:64738")
|
if c.config.OutputDevice == nil {
|
||||||
jc.DefaultServer = &defaultServer
|
odev := string("")
|
||||||
}
|
jc.OutputDevice = &odev
|
||||||
if c.config.Username == nil {
|
}
|
||||||
username := string("")
|
if c.config.DefaultServer == nil {
|
||||||
jc.Username = &username
|
defaultServer := string("localhost:64738")
|
||||||
}
|
jc.DefaultServer = &defaultServer
|
||||||
if c.config.NotifyCommand == nil {
|
}
|
||||||
ncmd := string("")
|
if c.config.Username == nil {
|
||||||
jc.NotifyCommand = &ncmd
|
username := string("")
|
||||||
}
|
jc.Username = &username
|
||||||
if c.config.NoiseSuppressionEnabled == nil {
|
}
|
||||||
enabled := false
|
if c.config.NotifyCommand == nil {
|
||||||
jc.NoiseSuppressionEnabled = &enabled
|
ncmd := string("/usr/share/barnard/barnard-sound.sh \"%event\" \"%who\" \"%what\"")
|
||||||
}
|
jc.NotifyCommand = &ncmd
|
||||||
if c.config.NoiseSuppressionThreshold == nil {
|
}
|
||||||
threshold := float32(0.02)
|
if c.config.NoiseSuppressionEnabled == nil {
|
||||||
jc.NoiseSuppressionThreshold = &threshold
|
enabled := false
|
||||||
}
|
jc.NoiseSuppressionEnabled = &enabled
|
||||||
|
}
|
||||||
|
if c.config.NoiseSuppressionThreshold == nil {
|
||||||
|
threshold := float32(0.08)
|
||||||
|
jc.NoiseSuppressionThreshold = &threshold
|
||||||
|
}
|
||||||
|
if c.config.VoiceEffect == nil {
|
||||||
|
effect := 0 // Default to EffectNone
|
||||||
|
jc.VoiceEffect = &effect
|
||||||
|
}
|
||||||
|
if c.config.Certificate == nil {
|
||||||
|
cert := string("")
|
||||||
|
jc.Certificate = &cert
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) findServer(address string) *server {
|
func (c *Config) findServer(address string) *server {
|
||||||
if c.config.Servers == nil {
|
if c.config.Servers == nil {
|
||||||
c.config.Servers = make([]*server, 0)
|
c.config.Servers = make([]*server, 0)
|
||||||
}
|
}
|
||||||
host, port := makeHostPort(address)
|
host, port := makeHostPort(address)
|
||||||
var t *server
|
var t *server
|
||||||
for _, s := range c.config.Servers {
|
for _, s := range c.config.Servers {
|
||||||
if s.Port == port && s.Host == host {
|
if s.Port == port && s.Host == host {
|
||||||
t = s
|
t = s
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if t == nil {
|
if t == nil {
|
||||||
t = &server{
|
t = &server{
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
}
|
}
|
||||||
c.config.Servers = append(c.config.Servers, t)
|
c.config.Servers = append(c.config.Servers, t)
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) findUser(address string, username string) *eUser {
|
func (c *Config) findUser(address string, username string) *eUser {
|
||||||
var s *server
|
var s *server
|
||||||
s = c.findServer(address)
|
s = c.findServer(address)
|
||||||
if s.Users == nil {
|
if s.Users == nil {
|
||||||
s.Users = make([]*eUser, 0)
|
s.Users = make([]*eUser, 0)
|
||||||
}
|
}
|
||||||
var t *eUser
|
var t *eUser
|
||||||
for _, u := range s.Users {
|
for _, u := range s.Users {
|
||||||
if u.Username == username {
|
if u.Username == username {
|
||||||
t = u
|
t = u
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if t == nil {
|
if t == nil {
|
||||||
t = &eUser{
|
t = &eUser{
|
||||||
Username: username,
|
Username: username,
|
||||||
Boost: uint16(1),
|
Boost: uint16(1),
|
||||||
Volume: 1.0,
|
Volume: 1.0,
|
||||||
LocallyMuted: false, // Initialize local mute state
|
LocallyMuted: false, // Initialize local mute state
|
||||||
}
|
}
|
||||||
s.Users = append(s.Users, t)
|
s.Users = append(s.Users, t)
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) ToggleMute(u *gumble.User) {
|
func (c *Config) ToggleMute(u *gumble.User) {
|
||||||
j := c.findUser(u.GetClient().Config.Address, u.Name)
|
j := c.findUser(u.GetClient().Config.Address, u.Name)
|
||||||
j.LocallyMuted = !j.LocallyMuted
|
j.LocallyMuted = !j.LocallyMuted
|
||||||
u.LocallyMuted = j.LocallyMuted
|
u.LocallyMuted = j.LocallyMuted
|
||||||
c.SaveConfig()
|
c.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetMicVolume(v float32) {
|
func (c *Config) SetMicVolume(v float32) {
|
||||||
t := float32(v)
|
t := float32(v)
|
||||||
c.config.MicVolume = &t
|
c.config.MicVolume = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetHotkeys() *Hotkeys {
|
func (c *Config) GetHotkeys() *Hotkeys {
|
||||||
return c.config.Hotkeys
|
return c.config.Hotkeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetNotifyCommand() *string {
|
func (c *Config) GetNotifyCommand() *string {
|
||||||
return c.config.NotifyCommand
|
return c.config.NotifyCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetAudioDriver() string {
|
||||||
|
if c.config.AudioDriver == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *c.config.AudioDriver
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetInputDevice() *string {
|
func (c *Config) GetInputDevice() *string {
|
||||||
return c.config.InputDevice
|
return c.config.InputDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetOutputDevice() *string {
|
func (c *Config) GetOutputDevice() *string {
|
||||||
return c.config.OutputDevice
|
return c.config.OutputDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetDefaultServer() *string {
|
func (c *Config) GetDefaultServer() *string {
|
||||||
return c.config.DefaultServer
|
return c.config.DefaultServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetUsername() *string {
|
func (c *Config) GetUsername() *string {
|
||||||
return c.config.Username
|
return c.config.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetCertificate() *string {
|
||||||
|
return c.config.Certificate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetNoiseSuppressionEnabled() bool {
|
func (c *Config) GetNoiseSuppressionEnabled() bool {
|
||||||
if c.config.NoiseSuppressionEnabled == nil {
|
if c.config.NoiseSuppressionEnabled == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return *c.config.NoiseSuppressionEnabled
|
return *c.config.NoiseSuppressionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetNoiseSuppressionEnabled(enabled bool) {
|
func (c *Config) SetNoiseSuppressionEnabled(enabled bool) {
|
||||||
c.config.NoiseSuppressionEnabled = &enabled
|
c.config.NoiseSuppressionEnabled = &enabled
|
||||||
c.SaveConfig()
|
c.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetNoiseSuppressionThreshold() float32 {
|
func (c *Config) GetNoiseSuppressionThreshold() float32 {
|
||||||
if c.config.NoiseSuppressionThreshold == nil {
|
if c.config.NoiseSuppressionThreshold == nil {
|
||||||
return 0.02
|
return 0.08
|
||||||
}
|
}
|
||||||
return *c.config.NoiseSuppressionThreshold
|
threshold := *c.config.NoiseSuppressionThreshold
|
||||||
|
if threshold < 0.0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
if threshold > 1.0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetNoiseSuppressionThreshold(threshold float32) {
|
func (c *Config) SetNoiseSuppressionThreshold(threshold float32) {
|
||||||
c.config.NoiseSuppressionThreshold = &threshold
|
if threshold < 0.0 {
|
||||||
c.SaveConfig()
|
threshold = 0.0
|
||||||
|
}
|
||||||
|
if threshold > 1.0 {
|
||||||
|
threshold = 1.0
|
||||||
|
}
|
||||||
|
c.config.NoiseSuppressionThreshold = &threshold
|
||||||
|
c.SaveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetVoiceEffect() int {
|
||||||
|
if c.config.VoiceEffect == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *c.config.VoiceEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SetVoiceEffect(effect int) {
|
||||||
|
c.config.VoiceEffect = &effect
|
||||||
|
c.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) UpdateUser(u *gumble.User) {
|
func (c *Config) UpdateUser(u *gumble.User) {
|
||||||
var j *eUser
|
var j *eUser
|
||||||
var uc *gumble.Client
|
var uc *gumble.Client
|
||||||
uc = u.GetClient()
|
uc = u.GetClient()
|
||||||
if uc != nil {
|
if uc != nil {
|
||||||
j = c.findUser(uc.Config.Address, u.Name)
|
j = c.findUser(uc.Config.Address, u.Name)
|
||||||
u.Boost = j.Boost
|
u.Boost = j.Boost
|
||||||
u.Volume = j.Volume
|
u.Volume = j.Volume
|
||||||
u.LocallyMuted = j.LocallyMuted // Update LocallyMuted state from config
|
u.LocallyMuted = j.LocallyMuted // Update LocallyMuted state from config
|
||||||
if u.Boost < 1 {
|
if u.Boost < 1 {
|
||||||
u.Boost = 1
|
u.Boost = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) UpdateConfig(u *gumble.User) {
|
func (c *Config) UpdateConfig(u *gumble.User) {
|
||||||
var j *eUser
|
var j *eUser
|
||||||
j = c.findUser(u.GetClient().Config.Address, u.Name)
|
j = c.findUser(u.GetClient().Config.Address, u.Name)
|
||||||
j.Boost = u.Boost
|
j.Boost = u.Boost
|
||||||
j.Volume = u.Volume
|
j.Volume = u.Volume
|
||||||
j.LocallyMuted = u.LocallyMuted // Save LocallyMuted state to config
|
j.LocallyMuted = u.LocallyMuted // Save LocallyMuted state to config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfig(fn *string) *Config {
|
func NewConfig(fn *string) *Config {
|
||||||
var c *Config
|
var c *Config
|
||||||
c = &Config{}
|
c = &Config{}
|
||||||
c.fn = resolvePath(*fn)
|
c.fn = resolvePath(*fn)
|
||||||
c.LoadConfig()
|
c.LoadConfig()
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func readFile(path string) []byte {
|
func readFile(path string) []byte {
|
||||||
if !fileExists(path) {
|
if !fileExists(path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dat, err := ioutil.ReadFile(path)
|
dat, err := ioutil.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return dat
|
return dat
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return !info.IsDir()
|
return !info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePath(path string) string {
|
func resolvePath(path string) string {
|
||||||
if strings.HasPrefix(path, "~/") || strings.Contains(path, "$HOME") {
|
if strings.HasPrefix(path, "~/") || strings.Contains(path, "$HOME") {
|
||||||
usr, err := user.Current()
|
usr, err := user.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
var hd = usr.HomeDir
|
var hd = usr.HomeDir
|
||||||
if strings.Contains(path, "$HOME") {
|
if strings.Contains(path, "$HOME") {
|
||||||
path = strings.Replace(path, "$HOME", hd, 1)
|
path = strings.Replace(path, "$HOME", hd, 1)
|
||||||
} else {
|
} else {
|
||||||
path = strings.Replace(path, "~", hd, 1)
|
path = strings.Replace(path, "~", hd, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeHostPort(addr string) (string, int) {
|
func makeHostPort(addr string) (string, int) {
|
||||||
parts := strings.Split(addr, ":")
|
parts := strings.Split(addr, ":")
|
||||||
host := parts[0]
|
host := parts[0]
|
||||||
port, err := strconv.Atoi(parts[1])
|
port, err := strconv.Atoi(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return host, port
|
return host, port
|
||||||
}
|
}
|
||||||
|
|
||||||
func Log(s string) {
|
func Log(s string) {
|
||||||
log(s)
|
log(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(s string) {
|
func log(s string) {
|
||||||
s += "\n"
|
s += "\n"
|
||||||
f, err := os.OpenFile("log.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile("log.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if _, err := f.Write([]byte(s)); err != nil {
|
if _, err := f.Write([]byte(s)); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
241
fileplayback/player.go
Normal file
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/golang/protobuf v1.5.3
|
||||||
github.com/kennygrant/sanitize v1.2.4
|
github.com/kennygrant/sanitize v1.2.4
|
||||||
github.com/nsf/termbox-go v1.1.1
|
github.com/nsf/termbox-go v1.1.1
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
6
go.sum
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/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
|
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
|
||||||
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
|
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
@@ -18,7 +20,3 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
|
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
package gumble
|
package gumble
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// AudioSampleRate is the audio sample rate (in hertz) for incoming and
|
// AudioSampleRate is the audio sample rate (in hertz) for incoming and
|
||||||
// outgoing audio.
|
// outgoing audio.
|
||||||
AudioSampleRate = 48000
|
AudioSampleRate = 48000
|
||||||
|
|
||||||
// AudioDefaultInterval is the default interval that audio packets are sent
|
// AudioDefaultInterval is the default interval that audio packets are sent
|
||||||
// at.
|
// at.
|
||||||
AudioDefaultInterval = 10 * time.Millisecond
|
AudioDefaultInterval = 10 * time.Millisecond
|
||||||
|
|
||||||
// AudioMonoChannels is the number of channels used for voice transmission
|
// AudioMonoChannels is the number of channels used for voice transmission
|
||||||
AudioMonoChannels = 1
|
AudioMonoChannels = 1
|
||||||
|
|
||||||
// AudioChannels is the number of channels used for playback
|
// AudioChannels is the number of channels used for playback
|
||||||
AudioChannels = 2
|
AudioChannels = 2
|
||||||
|
|
||||||
// AudioDefaultFrameSize is the number of audio frames that should be sent in
|
// AudioDefaultFrameSize is the number of audio frames that should be sent in
|
||||||
// a 10ms window (mono samples)
|
// a 10ms window (mono samples)
|
||||||
AudioDefaultFrameSize = AudioSampleRate / 100
|
AudioDefaultFrameSize = AudioSampleRate / 100
|
||||||
|
|
||||||
// AudioMaximumFrameSize is the maximum audio frame size from another user
|
// AudioMaximumFrameSize is the maximum audio frame size from another user
|
||||||
// that will be processed (accounting for stereo)
|
// that will be processed (accounting for stereo)
|
||||||
AudioMaximumFrameSize = (AudioSampleRate / 1000 * 60) * AudioChannels
|
AudioMaximumFrameSize = (AudioSampleRate / 1000 * 60) * AudioChannels
|
||||||
|
|
||||||
// AudioDefaultDataBytes is the default number of bytes that an audio frame
|
// AudioDefaultDataBytes is the default number of bytes that an audio frame
|
||||||
// can use.
|
// can use.
|
||||||
AudioDefaultDataBytes = 40
|
AudioDefaultDataBytes = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioListener is the interface that must be implemented by types wishing to
|
// AudioListener is the interface that must be implemented by types wishing to
|
||||||
@@ -39,48 +39,55 @@ const (
|
|||||||
// implementer's responsibility to continuously process AudioStreamEvent.C
|
// implementer's responsibility to continuously process AudioStreamEvent.C
|
||||||
// until it is closed.
|
// until it is closed.
|
||||||
type AudioListener interface {
|
type AudioListener interface {
|
||||||
OnAudioStream(e *AudioStreamEvent)
|
OnAudioStream(e *AudioStreamEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioStreamEvent is event that is passed to AudioListener.OnAudioStream.
|
// AudioStreamEvent is event that is passed to AudioListener.OnAudioStream.
|
||||||
type AudioStreamEvent struct {
|
type AudioStreamEvent struct {
|
||||||
Client *Client
|
Client *Client
|
||||||
User *User
|
User *User
|
||||||
C <-chan *AudioPacket
|
C <-chan *AudioPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioBuffer is a slice of PCM audio samples.
|
// AudioBuffer is a slice of PCM audio samples.
|
||||||
type AudioBuffer []int16
|
type AudioBuffer []int16
|
||||||
|
|
||||||
func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error {
|
func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error {
|
||||||
encoder := client.AudioEncoder
|
// Choose encoder based on whether buffer size indicates stereo or mono
|
||||||
if encoder == nil {
|
encoder := client.AudioEncoder
|
||||||
return nil
|
frameSize := client.Config.AudioFrameSize()
|
||||||
}
|
if len(a) == frameSize*AudioChannels && client.AudioEncoderStereo != nil {
|
||||||
dataBytes := client.Config.AudioDataBytes
|
encoder = client.AudioEncoderStereo
|
||||||
raw, err := encoder.Encode(a, len(a), dataBytes)
|
} else if client.IsStereoEncoderEnabled() && client.AudioEncoderStereo != nil {
|
||||||
if final {
|
encoder = client.AudioEncoderStereo
|
||||||
defer encoder.Reset()
|
}
|
||||||
}
|
if encoder == nil {
|
||||||
if err != nil {
|
return nil
|
||||||
return err
|
}
|
||||||
}
|
dataBytes := client.Config.AudioDataBytes
|
||||||
|
raw, err := encoder.Encode(a, len(a), dataBytes)
|
||||||
|
if final {
|
||||||
|
defer encoder.Reset()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var targetID byte
|
var targetID byte
|
||||||
if target := client.VoiceTarget; target != nil {
|
if target := client.VoiceTarget; target != nil {
|
||||||
targetID = byte(target.ID)
|
targetID = byte(target.ID)
|
||||||
}
|
}
|
||||||
return client.Conn.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil)
|
return client.Conn.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioPacket contains incoming audio samples and information.
|
// AudioPacket contains incoming audio samples and information.
|
||||||
type AudioPacket struct {
|
type AudioPacket struct {
|
||||||
Client *Client
|
Client *Client
|
||||||
Sender *User
|
Sender *User
|
||||||
Target *VoiceTarget
|
Target *VoiceTarget
|
||||||
|
|
||||||
AudioBuffer
|
AudioBuffer
|
||||||
|
|
||||||
HasPosition bool
|
HasPosition bool
|
||||||
X, Y, Z float32
|
X, Y, Z float32
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ type Client struct {
|
|||||||
ContextActions ContextActions
|
ContextActions ContextActions
|
||||||
|
|
||||||
// The audio encoder used when sending audio to the server.
|
// The audio encoder used when sending audio to the server.
|
||||||
AudioEncoder AudioEncoder
|
AudioEncoder AudioEncoder
|
||||||
audioCodec AudioCodec
|
AudioEncoderStereo AudioEncoder
|
||||||
|
audioCodec AudioCodec
|
||||||
|
useStereoEncoder bool
|
||||||
// To whom transmitted audio will be sent. The VoiceTarget must have already
|
// To whom transmitted audio will be sent. The VoiceTarget must have already
|
||||||
// been sent to the server for targeting to work correctly. Setting to nil
|
// been sent to the server for targeting to work correctly. Setting to nil
|
||||||
// will disable voice targeting (i.e. switch back to regular speaking).
|
// will disable voice targeting (i.e. switch back to regular speaking).
|
||||||
@@ -287,3 +289,24 @@ func (c *Client) Do(f func()) {
|
|||||||
func (c *Client) Send(message Message) {
|
func (c *Client) Send(message Message) {
|
||||||
message.writeMessage(c)
|
message.writeMessage(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableStereoEncoder switches to stereo encoding for file playback.
|
||||||
|
func (c *Client) EnableStereoEncoder() {
|
||||||
|
c.volatile.Lock()
|
||||||
|
defer c.volatile.Unlock()
|
||||||
|
c.useStereoEncoder = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableStereoEncoder switches back to mono encoding for voice.
|
||||||
|
func (c *Client) DisableStereoEncoder() {
|
||||||
|
c.volatile.Lock()
|
||||||
|
defer c.volatile.Unlock()
|
||||||
|
c.useStereoEncoder = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStereoEncoderEnabled returns true if stereo encoding is currently active.
|
||||||
|
func (c *Client) IsStereoEncoderEnabled() bool {
|
||||||
|
c.volatile.RLock()
|
||||||
|
defer c.volatile.RUnlock()
|
||||||
|
return c.useStereoEncoder
|
||||||
|
}
|
||||||
|
|||||||
@@ -1073,6 +1073,9 @@ func (c *Client) handleCodecVersion(buffer []byte) error {
|
|||||||
c.volatile.Lock()
|
c.volatile.Lock()
|
||||||
|
|
||||||
c.AudioEncoder = codec.NewEncoder()
|
c.AudioEncoder = codec.NewEncoder()
|
||||||
|
// Also create a stereo encoder for file playback
|
||||||
|
// Import the opus package to get NewStereoEncoder
|
||||||
|
c.AudioEncoderStereo = nil // Will be set when needed
|
||||||
|
|
||||||
c.volatile.Unlock()
|
c.volatile.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,345 +1,586 @@
|
|||||||
package gumbleopenal
|
package gumbleopenal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/audio"
|
||||||
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
||||||
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
|
"git.stormux.org/storm/barnard/noise"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NoiseProcessor interface for noise suppression
|
// NoiseProcessor interface for noise suppression
|
||||||
type NoiseProcessor interface {
|
type NoiseProcessor interface {
|
||||||
ProcessSamples(samples []int16)
|
ProcessSamples(samples []int16)
|
||||||
IsEnabled() bool
|
IsEnabled() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectsProcessor interface for voice effects
|
||||||
|
type EffectsProcessor interface {
|
||||||
|
ProcessSamples(samples []int16)
|
||||||
|
IsEnabled() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilePlayer interface for file playback
|
||||||
|
type FilePlayer interface {
|
||||||
|
GetAudioFrame() []int16
|
||||||
|
IsPlaying() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
|
maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrState = errors.New("gumbleopenal: invalid state")
|
ErrState = errors.New("gumbleopenal: invalid state")
|
||||||
ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured")
|
ErrMic = errors.New("gumbleopenal: microphone disconnected or misconfigured")
|
||||||
ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters")
|
ErrInputDevice = errors.New("gumbleopenal: invalid input device or parameters")
|
||||||
ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters")
|
ErrOutputDevice = errors.New("gumbleopenal: invalid output device or parameters")
|
||||||
)
|
)
|
||||||
|
|
||||||
func beep() {
|
func beep() {
|
||||||
cmd := exec.Command("beep")
|
cmd := exec.Command("beep")
|
||||||
cmdout, err := cmd.Output()
|
cmdout, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if cmdout != nil {
|
if cmdout != nil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stream struct {
|
type Stream struct {
|
||||||
client *gumble.Client
|
client *gumble.Client
|
||||||
link gumble.Detacher
|
link gumble.Detacher
|
||||||
|
|
||||||
deviceSource *openal.CaptureDevice
|
deviceSource *openal.CaptureDevice
|
||||||
sourceFrameSize int
|
sourceFormat openal.Format
|
||||||
micVolume float32
|
sourceChannels int
|
||||||
sourceStop chan bool
|
sourceFrameSize int
|
||||||
|
micVolume float32
|
||||||
|
sourceStop chan bool
|
||||||
|
|
||||||
deviceSink *openal.Device
|
deviceSink *openal.Device
|
||||||
contextSink *openal.Context
|
contextSink *openal.Context
|
||||||
|
|
||||||
noiseProcessor NoiseProcessor
|
noiseProcessor NoiseProcessor
|
||||||
|
noiseProcessorRight NoiseProcessor
|
||||||
|
micAGC *audio.AGC
|
||||||
|
micAGCRight *audio.AGC
|
||||||
|
effectsProcessor EffectsProcessor
|
||||||
|
effectsProcessorRight EffectsProcessor
|
||||||
|
filePlayer FilePlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
|
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
|
||||||
frmsz := 480
|
frmsz := 480
|
||||||
if !test {
|
if !test {
|
||||||
frmsz = client.Config.AudioFrameSize()
|
frmsz = client.Config.AudioFrameSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use mono for input device
|
inputFormat := openal.FormatStereo16
|
||||||
idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(frmsz))
|
sourceChannels := 2
|
||||||
if idev == nil {
|
idev := openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, inputFormat, uint32(frmsz))
|
||||||
return nil, ErrInputDevice
|
if idev == nil {
|
||||||
}
|
inputFormat = openal.FormatMono16
|
||||||
|
sourceChannels = 1
|
||||||
|
idev = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, inputFormat, uint32(frmsz))
|
||||||
|
}
|
||||||
|
if idev == nil {
|
||||||
|
return nil, ErrInputDevice
|
||||||
|
}
|
||||||
|
|
||||||
odev := openal.OpenDevice(*outputDevice)
|
odev := openal.OpenDevice(*outputDevice)
|
||||||
if odev == nil {
|
if odev == nil {
|
||||||
idev.CaptureCloseDevice()
|
idev.CaptureCloseDevice()
|
||||||
return nil, ErrOutputDevice
|
return nil, ErrOutputDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
if test {
|
if test {
|
||||||
idev.CaptureCloseDevice()
|
idev.CaptureCloseDevice()
|
||||||
odev.CloseDevice()
|
odev.CloseDevice()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Stream{
|
s := &Stream{
|
||||||
client: client,
|
client: client,
|
||||||
sourceFrameSize: frmsz,
|
sourceFormat: inputFormat,
|
||||||
micVolume: 1.0,
|
sourceChannels: sourceChannels,
|
||||||
}
|
sourceFrameSize: frmsz,
|
||||||
|
micVolume: 1.0,
|
||||||
|
micAGC: audio.NewAGC(), // Always enable AGC for outgoing mic
|
||||||
|
}
|
||||||
|
if sourceChannels == 2 {
|
||||||
|
s.micAGCRight = audio.NewAGC()
|
||||||
|
}
|
||||||
|
|
||||||
s.deviceSource = idev
|
s.deviceSource = idev
|
||||||
if s.deviceSource == nil {
|
if s.deviceSource == nil {
|
||||||
return nil, ErrInputDevice
|
return nil, ErrInputDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
s.deviceSink = odev
|
s.deviceSink = odev
|
||||||
if s.deviceSink == nil {
|
if s.deviceSink == nil {
|
||||||
return nil, ErrOutputDevice
|
return nil, ErrOutputDevice
|
||||||
}
|
}
|
||||||
s.contextSink = s.deviceSink.CreateContext()
|
s.contextSink = s.deviceSink.CreateContext()
|
||||||
if s.contextSink == nil {
|
if s.contextSink == nil {
|
||||||
s.Destroy()
|
s.Destroy()
|
||||||
return nil, ErrOutputDevice
|
return nil, ErrOutputDevice
|
||||||
}
|
}
|
||||||
s.contextSink.Activate()
|
s.contextSink.Activate()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) AttachStream(client *gumble.Client) {
|
func (s *Stream) AttachStream(client *gumble.Client) {
|
||||||
s.link = client.Config.AttachAudio(s)
|
s.link = client.Config.AttachAudio(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
|
func (s *Stream) SetNoiseProcessor(np NoiseProcessor) {
|
||||||
s.noiseProcessor = np
|
s.noiseProcessor = np
|
||||||
|
s.noiseProcessorRight = cloneNoiseProcessor(np)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetEffectsProcessor(ep EffectsProcessor) {
|
||||||
|
s.effectsProcessor = ep
|
||||||
|
s.effectsProcessorRight = cloneEffectsProcessor(ep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) GetEffectsProcessor() EffectsProcessor {
|
||||||
|
return s.effectsProcessor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetFilePlayer(fp FilePlayer) {
|
||||||
|
s.filePlayer = fp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) GetFilePlayer() FilePlayer {
|
||||||
|
return s.filePlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) Destroy() {
|
func (s *Stream) Destroy() {
|
||||||
if s.link != nil {
|
if s.link != nil {
|
||||||
s.link.Detach()
|
s.link.Detach()
|
||||||
}
|
}
|
||||||
if s.deviceSource != nil {
|
if s.deviceSource != nil {
|
||||||
s.StopSource()
|
s.StopSource()
|
||||||
s.deviceSource.CaptureCloseDevice()
|
s.deviceSource.CaptureCloseDevice()
|
||||||
s.deviceSource = nil
|
s.deviceSource = nil
|
||||||
}
|
}
|
||||||
if s.deviceSink != nil {
|
if s.deviceSink != nil {
|
||||||
s.contextSink.Destroy()
|
s.contextSink.Destroy()
|
||||||
s.deviceSink.CloseDevice()
|
s.deviceSink.CloseDevice()
|
||||||
s.contextSink = nil
|
s.contextSink = nil
|
||||||
s.deviceSink = nil
|
s.deviceSink = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) StartSource(inputDevice *string) error {
|
func (s *Stream) StartSource(inputDevice *string) error {
|
||||||
if s.sourceStop != nil {
|
if s.sourceStop != nil {
|
||||||
return ErrState
|
return ErrState
|
||||||
}
|
}
|
||||||
if s.deviceSource == nil {
|
if s.deviceSource == nil {
|
||||||
return ErrMic
|
return ErrMic
|
||||||
}
|
}
|
||||||
s.deviceSource.CaptureStart()
|
s.deviceSource.CaptureStart()
|
||||||
s.sourceStop = make(chan bool)
|
s.sourceStop = make(chan bool)
|
||||||
go s.sourceRoutine(inputDevice)
|
go s.sourceRoutine(inputDevice)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) StopSource() error {
|
func (s *Stream) StopSource() error {
|
||||||
if s.deviceSource == nil {
|
if s.deviceSource == nil {
|
||||||
return ErrMic
|
return ErrMic
|
||||||
}
|
}
|
||||||
s.deviceSource.CaptureStop()
|
s.deviceSource.CaptureStop()
|
||||||
if s.sourceStop == nil {
|
if s.sourceStop == nil {
|
||||||
return ErrState
|
return ErrState
|
||||||
}
|
}
|
||||||
close(s.sourceStop)
|
close(s.sourceStop)
|
||||||
s.sourceStop = nil
|
s.sourceStop = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) GetMicVolume() float32 {
|
func (s *Stream) GetMicVolume() float32 {
|
||||||
return s.micVolume
|
return s.micVolume
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) SetMicVolume(change float32, relative bool) {
|
func (s *Stream) SetMicVolume(change float32, relative bool) {
|
||||||
var val float32
|
var val float32
|
||||||
if relative {
|
if relative {
|
||||||
val = s.GetMicVolume() + change
|
val = s.GetMicVolume() + change
|
||||||
} else {
|
} else {
|
||||||
val = change
|
val = change
|
||||||
}
|
}
|
||||||
if val >= 1 {
|
if val >= 1 {
|
||||||
val = 1.0
|
val = 1.0
|
||||||
}
|
}
|
||||||
if val <= 0 {
|
if val <= 0 {
|
||||||
val = 0
|
val = 0
|
||||||
}
|
}
|
||||||
s.micVolume = val
|
s.micVolume = val
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
||||||
go func(e *gumble.AudioStreamEvent) {
|
go func(e *gumble.AudioStreamEvent) {
|
||||||
var source = openal.NewSource()
|
var source = openal.NewSource()
|
||||||
e.User.AudioSource = &source
|
e.User.AudioSource = &source
|
||||||
|
|
||||||
// Set initial gain based on volume and mute state
|
|
||||||
if e.User.LocallyMuted {
|
|
||||||
e.User.AudioSource.SetGain(0)
|
|
||||||
} else {
|
|
||||||
e.User.AudioSource.SetGain(e.User.Volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
bufferCount := e.Client.Config.Buffers
|
// Set initial gain based on volume and mute state
|
||||||
if bufferCount < 64 {
|
if e.User.LocallyMuted {
|
||||||
bufferCount = 64
|
e.User.AudioSource.SetGain(0)
|
||||||
}
|
} else {
|
||||||
emptyBufs := openal.NewBuffers(bufferCount)
|
e.User.AudioSource.SetGain(e.User.Volume)
|
||||||
|
}
|
||||||
reclaim := func() {
|
|
||||||
if n := source.BuffersProcessed(); n > 0 {
|
|
||||||
reclaimedBufs := make(openal.Buffers, n)
|
|
||||||
source.UnqueueBuffers(reclaimedBufs)
|
|
||||||
emptyBufs = append(emptyBufs, reclaimedBufs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw [maxBufferSize]byte
|
bufferCount := e.Client.Config.Buffers
|
||||||
|
if bufferCount < 64 {
|
||||||
for packet := range e.C {
|
bufferCount = 64
|
||||||
// Skip processing if user is locally muted
|
}
|
||||||
if e.User.LocallyMuted {
|
emptyBufs := openal.NewBuffers(bufferCount)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var boost uint16 = uint16(1)
|
reclaim := func() {
|
||||||
samples := len(packet.AudioBuffer)
|
if n := source.BuffersProcessed(); n > 0 {
|
||||||
if samples > cap(raw)/2 {
|
reclaimedBufs := make(openal.Buffers, n)
|
||||||
continue
|
source.UnqueueBuffers(reclaimedBufs)
|
||||||
}
|
emptyBufs = append(emptyBufs, reclaimedBufs...)
|
||||||
|
}
|
||||||
boost = e.User.Boost
|
}
|
||||||
|
|
||||||
// Check if sample count suggests stereo data
|
var raw [maxBufferSize]byte
|
||||||
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
|
|
||||||
format := openal.FormatMono16
|
|
||||||
if isStereo {
|
|
||||||
format = openal.FormatStereo16
|
|
||||||
samples = samples / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
rawPtr := 0
|
for packet := range e.C {
|
||||||
if isStereo {
|
// Skip processing if user is locally muted
|
||||||
// Process stereo samples as pairs
|
if e.User.LocallyMuted {
|
||||||
for i := 0; i < samples*2; i += 2 {
|
continue
|
||||||
// Process left channel with saturation protection
|
}
|
||||||
sample := packet.AudioBuffer[i]
|
|
||||||
if boost > 1 {
|
|
||||||
boosted := int32(sample) * int32(boost)
|
|
||||||
if boosted > 32767 {
|
|
||||||
sample = 32767
|
|
||||||
} else if boosted < -32767 {
|
|
||||||
sample = -32767
|
|
||||||
} else {
|
|
||||||
sample = int16(boosted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
|
||||||
rawPtr += 2
|
|
||||||
|
|
||||||
// Process right channel with saturation protection
|
var boost uint16 = uint16(1)
|
||||||
sample = packet.AudioBuffer[i+1]
|
samples := len(packet.AudioBuffer)
|
||||||
if boost > 1 {
|
if samples > cap(raw)/2 {
|
||||||
boosted := int32(sample) * int32(boost)
|
continue
|
||||||
if boosted > 32767 {
|
}
|
||||||
sample = 32767
|
|
||||||
} else if boosted < -32767 {
|
|
||||||
sample = -32767
|
|
||||||
} else {
|
|
||||||
sample = int16(boosted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
|
||||||
rawPtr += 2
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Process mono samples with saturation protection
|
|
||||||
for i := 0; i < samples; i++ {
|
|
||||||
sample := packet.AudioBuffer[i]
|
|
||||||
if boost > 1 {
|
|
||||||
boosted := int32(sample) * int32(boost)
|
|
||||||
if boosted > 32767 {
|
|
||||||
sample = 32767
|
|
||||||
} else if boosted < -32767 {
|
|
||||||
sample = -32767
|
|
||||||
} else {
|
|
||||||
sample = int16(boosted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
|
||||||
rawPtr += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reclaim()
|
boost = e.User.Boost
|
||||||
if len(emptyBufs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
last := len(emptyBufs) - 1
|
// Check if sample count suggests stereo data
|
||||||
buffer := emptyBufs[last]
|
isStereo := samples > gumble.AudioDefaultFrameSize && samples%2 == 0
|
||||||
emptyBufs = emptyBufs[:last]
|
format := openal.FormatMono16
|
||||||
|
if isStereo {
|
||||||
|
format = openal.FormatStereo16
|
||||||
|
samples = samples / 2
|
||||||
|
}
|
||||||
|
|
||||||
buffer.SetData(format, raw[:rawPtr], gumble.AudioSampleRate)
|
rawPtr := 0
|
||||||
source.QueueBuffer(buffer)
|
if isStereo {
|
||||||
|
// Process stereo samples as pairs
|
||||||
|
for i := 0; i < samples*2; i += 2 {
|
||||||
|
// Process left channel with saturation protection
|
||||||
|
sample := packet.AudioBuffer[i]
|
||||||
|
if boost > 1 {
|
||||||
|
boosted := int32(sample) * int32(boost)
|
||||||
|
if boosted > 32767 {
|
||||||
|
sample = 32767
|
||||||
|
} else if boosted < -32767 {
|
||||||
|
sample = -32767
|
||||||
|
} else {
|
||||||
|
sample = int16(boosted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
||||||
|
rawPtr += 2
|
||||||
|
|
||||||
if source.State() != openal.Playing {
|
// Process right channel with saturation protection
|
||||||
source.Play()
|
sample = packet.AudioBuffer[i+1]
|
||||||
}
|
if boost > 1 {
|
||||||
}
|
boosted := int32(sample) * int32(boost)
|
||||||
reclaim()
|
if boosted > 32767 {
|
||||||
emptyBufs.Delete()
|
sample = 32767
|
||||||
source.Delete()
|
} else if boosted < -32767 {
|
||||||
}(e)
|
sample = -32767
|
||||||
|
} else {
|
||||||
|
sample = int16(boosted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
||||||
|
rawPtr += 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Process mono samples with saturation protection
|
||||||
|
for i := 0; i < samples; i++ {
|
||||||
|
sample := packet.AudioBuffer[i]
|
||||||
|
if boost > 1 {
|
||||||
|
boosted := int32(sample) * int32(boost)
|
||||||
|
if boosted > 32767 {
|
||||||
|
sample = 32767
|
||||||
|
} else if boosted < -32767 {
|
||||||
|
sample = -32767
|
||||||
|
} else {
|
||||||
|
sample = int16(boosted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
|
||||||
|
rawPtr += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reclaim()
|
||||||
|
if len(emptyBufs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
last := len(emptyBufs) - 1
|
||||||
|
buffer := emptyBufs[last]
|
||||||
|
emptyBufs = emptyBufs[:last]
|
||||||
|
|
||||||
|
buffer.SetData(format, raw[:rawPtr], gumble.AudioSampleRate)
|
||||||
|
source.QueueBuffer(buffer)
|
||||||
|
|
||||||
|
if source.State() != openal.Playing {
|
||||||
|
source.Play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reclaim()
|
||||||
|
emptyBufs.Delete()
|
||||||
|
source.Delete()
|
||||||
|
}(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) sourceRoutine(inputDevice *string) {
|
func (s *Stream) sourceRoutine(inputDevice *string) {
|
||||||
interval := s.client.Config.AudioInterval
|
interval := s.client.Config.AudioInterval
|
||||||
frameSize := s.client.Config.AudioFrameSize()
|
frameSize := s.client.Config.AudioFrameSize()
|
||||||
|
|
||||||
if frameSize != s.sourceFrameSize {
|
if frameSize != s.sourceFrameSize {
|
||||||
s.deviceSource.CaptureCloseDevice()
|
s.deviceSource.CaptureCloseDevice()
|
||||||
s.sourceFrameSize = frameSize
|
s.sourceFrameSize = frameSize
|
||||||
// Always use mono for input
|
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, s.sourceFormat, uint32(s.sourceFrameSize))
|
||||||
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, openal.FormatMono16, uint32(s.sourceFrameSize))
|
if s.deviceSource == nil && s.sourceFormat == openal.FormatStereo16 {
|
||||||
}
|
s.sourceFormat = openal.FormatMono16
|
||||||
|
s.sourceChannels = 1
|
||||||
|
s.deviceSource = openal.CaptureOpenDevice(*inputDevice, gumble.AudioSampleRate, s.sourceFormat, uint32(s.sourceFrameSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.deviceSource == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
stop := s.sourceStop
|
stop := s.sourceStop
|
||||||
|
|
||||||
outgoing := s.client.AudioOutgoing()
|
outgoing := s.client.AudioOutgoing()
|
||||||
defer close(outgoing)
|
defer close(outgoing)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
buff := s.deviceSource.CaptureSamples(uint32(frameSize))
|
sampleCount := frameSize * s.sourceChannels
|
||||||
if len(buff) != frameSize*2 {
|
int16Buffer := make([]int16, sampleCount)
|
||||||
continue
|
|
||||||
}
|
// Capture microphone if available
|
||||||
int16Buffer := make([]int16, frameSize)
|
hasMicInput := false
|
||||||
for i := range int16Buffer {
|
buff := s.deviceSource.CaptureSamples(uint32(frameSize))
|
||||||
sample := int16(binary.LittleEndian.Uint16(buff[i*2:]))
|
if len(buff) == sampleCount*2 {
|
||||||
if s.micVolume != 1.0 {
|
hasMicInput = true
|
||||||
sample = int16(float32(sample) * s.micVolume)
|
for i := 0; i < sampleCount; i++ {
|
||||||
}
|
sample := int16(binary.LittleEndian.Uint16(buff[i*2:]))
|
||||||
int16Buffer[i] = sample
|
if s.micVolume != 1.0 {
|
||||||
}
|
sample = int16(float32(sample) * s.micVolume)
|
||||||
|
}
|
||||||
// Apply noise suppression if available and enabled
|
int16Buffer[i] = sample
|
||||||
if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() {
|
}
|
||||||
s.noiseProcessor.ProcessSamples(int16Buffer)
|
|
||||||
}
|
if s.sourceChannels == 1 {
|
||||||
|
s.processMonoSamples(int16Buffer)
|
||||||
outgoing <- gumble.AudioBuffer(int16Buffer)
|
} else {
|
||||||
}
|
s.processStereoSamples(int16Buffer, frameSize)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix with or use file audio if playing
|
||||||
|
hasFileAudio := false
|
||||||
|
var outputBuffer []int16
|
||||||
|
|
||||||
|
if s.filePlayer != nil && s.filePlayer.IsPlaying() {
|
||||||
|
fileAudio := s.filePlayer.GetAudioFrame()
|
||||||
|
if fileAudio != nil && len(fileAudio) > 0 {
|
||||||
|
hasFileAudio = true
|
||||||
|
// File audio is stereo - send as stereo when file is playing
|
||||||
|
// Create stereo buffer (frameSize * 2 channels)
|
||||||
|
outputBuffer = make([]int16, frameSize*2)
|
||||||
|
|
||||||
|
if hasMicInput {
|
||||||
|
if s.sourceChannels == 2 {
|
||||||
|
// Mix stereo mic with stereo file
|
||||||
|
for i := 0; i < frameSize; i++ {
|
||||||
|
idx := i * 2
|
||||||
|
if idx+1 < len(fileAudio) {
|
||||||
|
left := int32(int16Buffer[idx]) + int32(fileAudio[idx])
|
||||||
|
if left > 32767 {
|
||||||
|
left = 32767
|
||||||
|
} else if left < -32768 {
|
||||||
|
left = -32768
|
||||||
|
}
|
||||||
|
outputBuffer[idx] = int16(left)
|
||||||
|
|
||||||
|
right := int32(int16Buffer[idx+1]) + int32(fileAudio[idx+1])
|
||||||
|
if right > 32767 {
|
||||||
|
right = 32767
|
||||||
|
} else if right < -32768 {
|
||||||
|
right = -32768
|
||||||
|
}
|
||||||
|
outputBuffer[idx+1] = int16(right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mix mono mic with stereo file
|
||||||
|
for i := 0; i < frameSize; i++ {
|
||||||
|
idx := i * 2
|
||||||
|
if idx+1 < len(fileAudio) {
|
||||||
|
left := int32(int16Buffer[i]) + int32(fileAudio[idx])
|
||||||
|
if left > 32767 {
|
||||||
|
left = 32767
|
||||||
|
} else if left < -32768 {
|
||||||
|
left = -32768
|
||||||
|
}
|
||||||
|
outputBuffer[idx] = int16(left)
|
||||||
|
|
||||||
|
right := int32(int16Buffer[i]) + int32(fileAudio[idx+1])
|
||||||
|
if right > 32767 {
|
||||||
|
right = 32767
|
||||||
|
} else if right < -32768 {
|
||||||
|
right = -32768
|
||||||
|
}
|
||||||
|
outputBuffer[idx+1] = int16(right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use file audio only (already stereo)
|
||||||
|
copy(outputBuffer, fileAudio[:frameSize*2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine what to send
|
||||||
|
if hasFileAudio {
|
||||||
|
// Send stereo buffer when file is playing
|
||||||
|
outgoing <- gumble.AudioBuffer(outputBuffer)
|
||||||
|
} else if hasMicInput {
|
||||||
|
// Send mic when no file is playing
|
||||||
|
outgoing <- gumble.AudioBuffer(int16Buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) processMonoSamples(samples []int16) {
|
||||||
|
s.processChannel(samples, s.noiseProcessor, s.micAGC, s.effectsProcessor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) processStereoSamples(samples []int16, frameSize int) {
|
||||||
|
if frameSize == 0 || len(samples) < frameSize*2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ensureStereoProcessors()
|
||||||
|
s.syncStereoProcessors()
|
||||||
|
|
||||||
|
left := make([]int16, frameSize)
|
||||||
|
right := make([]int16, frameSize)
|
||||||
|
|
||||||
|
for i := 0; i < frameSize; i++ {
|
||||||
|
idx := i * 2
|
||||||
|
left[i] = samples[idx]
|
||||||
|
right[i] = samples[idx+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
s.processChannel(left, s.noiseProcessor, s.micAGC, s.effectsProcessor)
|
||||||
|
s.processChannel(right, s.noiseProcessorRight, s.micAGCRight, s.effectsProcessorRight)
|
||||||
|
|
||||||
|
for i := 0; i < frameSize; i++ {
|
||||||
|
idx := i * 2
|
||||||
|
samples[idx] = left[i]
|
||||||
|
samples[idx+1] = right[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) processChannel(samples []int16, noiseProcessor NoiseProcessor, micAGC *audio.AGC, effectsProcessor EffectsProcessor) {
|
||||||
|
if noiseProcessor != nil && noiseProcessor.IsEnabled() {
|
||||||
|
noiseProcessor.ProcessSamples(samples)
|
||||||
|
}
|
||||||
|
if micAGC != nil {
|
||||||
|
micAGC.ProcessSamples(samples)
|
||||||
|
}
|
||||||
|
if effectsProcessor != nil && effectsProcessor.IsEnabled() {
|
||||||
|
effectsProcessor.ProcessSamples(samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) ensureStereoProcessors() {
|
||||||
|
if s.micAGCRight == nil {
|
||||||
|
s.micAGCRight = audio.NewAGC()
|
||||||
|
}
|
||||||
|
if s.noiseProcessorRight == nil {
|
||||||
|
s.noiseProcessorRight = cloneNoiseProcessor(s.noiseProcessor)
|
||||||
|
}
|
||||||
|
if s.effectsProcessorRight == nil {
|
||||||
|
s.effectsProcessorRight = cloneEffectsProcessor(s.effectsProcessor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) syncStereoProcessors() {
|
||||||
|
leftSuppressor, leftOk := s.noiseProcessor.(*noise.Suppressor)
|
||||||
|
rightSuppressor, rightOk := s.noiseProcessorRight.(*noise.Suppressor)
|
||||||
|
if leftOk && rightOk {
|
||||||
|
if leftSuppressor.IsEnabled() != rightSuppressor.IsEnabled() {
|
||||||
|
rightSuppressor.SetEnabled(leftSuppressor.IsEnabled())
|
||||||
|
}
|
||||||
|
if leftSuppressor.GetThreshold() != rightSuppressor.GetThreshold() {
|
||||||
|
rightSuppressor.SetThreshold(leftSuppressor.GetThreshold())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leftEffects, leftOk := s.effectsProcessor.(*audio.EffectsProcessor)
|
||||||
|
rightEffects, rightOk := s.effectsProcessorRight.(*audio.EffectsProcessor)
|
||||||
|
if leftOk && rightOk {
|
||||||
|
if leftEffects.IsEnabled() != rightEffects.IsEnabled() {
|
||||||
|
rightEffects.SetEnabled(leftEffects.IsEnabled())
|
||||||
|
}
|
||||||
|
if leftEffects.GetCurrentEffect() != rightEffects.GetCurrentEffect() {
|
||||||
|
rightEffects.SetEffect(leftEffects.GetCurrentEffect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNoiseProcessor(np NoiseProcessor) NoiseProcessor {
|
||||||
|
if np == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if suppressor, ok := np.(*noise.Suppressor); ok {
|
||||||
|
clone := noise.NewSuppressor()
|
||||||
|
clone.SetEnabled(suppressor.IsEnabled())
|
||||||
|
clone.SetThreshold(suppressor.GetThreshold())
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEffectsProcessor(ep EffectsProcessor) EffectsProcessor {
|
||||||
|
if ep == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if processor, ok := ep.(*audio.EffectsProcessor); ok {
|
||||||
|
clone := audio.NewEffectsProcessor(gumble.AudioSampleRate)
|
||||||
|
clone.SetEnabled(processor.IsEnabled())
|
||||||
|
clone.SetEffect(processor.GetCurrentEffect())
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ func (*generator) NewEncoder() gumble.AudioEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewStereoEncoder creates a stereo encoder for file playback
|
||||||
|
func NewStereoEncoder() gumble.AudioEncoder {
|
||||||
|
// Create stereo encoder for file playback
|
||||||
|
e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio)
|
||||||
|
e.SetBitrateToMax()
|
||||||
|
return &Encoder{
|
||||||
|
e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (*generator) NewDecoder() gumble.AudioDecoder {
|
func (*generator) NewDecoder() gumble.AudioDecoder {
|
||||||
// Create decoder with stereo support
|
// Create decoder with stereo support
|
||||||
d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels)
|
d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels)
|
||||||
|
|||||||
49
main.go
49
main.go
@@ -15,14 +15,15 @@ import (
|
|||||||
//"github.com/google/shlex"
|
//"github.com/google/shlex"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"flag"
|
"flag"
|
||||||
"github.com/alessio/shellescape"
|
"git.stormux.org/storm/barnard/audio"
|
||||||
"git.stormux.org/storm/barnard/config"
|
"git.stormux.org/storm/barnard/config"
|
||||||
"git.stormux.org/storm/barnard/noise"
|
"git.stormux.org/storm/barnard/noise"
|
||||||
|
"github.com/alessio/shellescape"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
_ "git.stormux.org/storm/barnard/gumble/opus"
|
_ "git.stormux.org/storm/barnard/gumble/opus"
|
||||||
"git.stormux.org/storm/barnard/uiterm"
|
"git.stormux.org/storm/barnard/uiterm"
|
||||||
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func show_devs(name string, args []string) {
|
func show_devs(name string, args []string) {
|
||||||
@@ -107,7 +108,8 @@ func main() {
|
|||||||
password := flag.String("password", "", "the password of the server")
|
password := flag.String("password", "", "the password of the server")
|
||||||
insecure := flag.Bool("insecure", false, "skip server certificate verification")
|
insecure := flag.Bool("insecure", false, "skip server certificate verification")
|
||||||
certificate := flag.String("certificate", "", "PEM encoded certificate and private key")
|
certificate := flag.String("certificate", "", "PEM encoded certificate and private key")
|
||||||
cfgfn := flag.String("config", "~/.barnard.yaml", "Path to YAML formatted configuration file")
|
cfgfn := flag.String("config", "~/.barnard.toml", "Path to TOML formatted configuration file")
|
||||||
|
audioDriver := flag.String("audio-driver", "", "preferred OpenAL backend (pipewire, pulse, alsa, jack)")
|
||||||
list_devices := flag.Bool("list_devices", false, "do not connect; instead, list available audio devices and exit")
|
list_devices := flag.Bool("list_devices", false, "do not connect; instead, list available audio devices and exit")
|
||||||
fifo := flag.String("fifo", "", "path of a FIFO from which to read commands")
|
fifo := flag.String("fifo", "", "path of a FIFO from which to read commands")
|
||||||
serverSet := false
|
serverSet := false
|
||||||
@@ -126,12 +128,15 @@ func main() {
|
|||||||
|
|
||||||
userConfig := config.NewConfig(cfgfn)
|
userConfig := config.NewConfig(cfgfn)
|
||||||
|
|
||||||
|
certificateSet := false
|
||||||
flag.CommandLine.Visit(func(theFlag *flag.Flag) {
|
flag.CommandLine.Visit(func(theFlag *flag.Flag) {
|
||||||
switch theFlag.Name {
|
switch theFlag.Name {
|
||||||
case "server":
|
case "server":
|
||||||
serverSet = true
|
serverSet = true
|
||||||
case "username":
|
case "username":
|
||||||
usernameSet = true
|
usernameSet = true
|
||||||
|
case "certificate":
|
||||||
|
certificateSet = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,6 +146,22 @@ func main() {
|
|||||||
if !usernameSet {
|
if !usernameSet {
|
||||||
username = userConfig.GetUsername()
|
username = userConfig.GetUsername()
|
||||||
}
|
}
|
||||||
|
if !certificateSet {
|
||||||
|
certificate = userConfig.GetCertificate()
|
||||||
|
}
|
||||||
|
|
||||||
|
driver := strings.TrimSpace(*audioDriver)
|
||||||
|
if driver == "" {
|
||||||
|
// Environment variable takes precedence over config
|
||||||
|
if envDriver := os.Getenv("ALSOFT_DRIVERS"); envDriver != "" {
|
||||||
|
driver = envDriver
|
||||||
|
} else {
|
||||||
|
driver = strings.TrimSpace(userConfig.GetAudioDriver())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if driver != "" {
|
||||||
|
os.Setenv("ALSOFT_DRIVERS", driver)
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("ALSOFT_LOGLEVEL") == "" {
|
if os.Getenv("ALSOFT_LOGLEVEL") == "" {
|
||||||
os.Setenv("ALSOFT_LOGLEVEL", "0")
|
os.Setenv("ALSOFT_LOGLEVEL", "0")
|
||||||
@@ -157,17 +178,18 @@ func main() {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
b := Barnard{
|
b := Barnard{
|
||||||
Config: gumble.NewConfig(),
|
Config: gumble.NewConfig(),
|
||||||
UserConfig: userConfig,
|
UserConfig: userConfig,
|
||||||
Address: *server,
|
Address: *server,
|
||||||
MutedChannels: make(map[uint32]bool),
|
MutedChannels: make(map[uint32]bool),
|
||||||
NoiseSuppressor: noise.NewSuppressor(),
|
NoiseSuppressor: noise.NewSuppressor(),
|
||||||
|
VoiceEffects: audio.NewEffectsProcessor(gumble.AudioSampleRate),
|
||||||
}
|
}
|
||||||
b.Config.Buffers = *buffers
|
b.Config.Buffers = *buffers
|
||||||
|
|
||||||
b.Hotkeys = b.UserConfig.GetHotkeys()
|
b.Hotkeys = b.UserConfig.GetHotkeys()
|
||||||
b.UserConfig.SaveConfig()
|
b.UserConfig.SaveConfig()
|
||||||
|
|
||||||
// Configure noise suppression
|
// Configure noise suppression
|
||||||
enabled := b.UserConfig.GetNoiseSuppressionEnabled()
|
enabled := b.UserConfig.GetNoiseSuppressionEnabled()
|
||||||
if *noiseSuppressionEnabled {
|
if *noiseSuppressionEnabled {
|
||||||
@@ -176,7 +198,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
b.NoiseSuppressor.SetEnabled(enabled)
|
b.NoiseSuppressor.SetEnabled(enabled)
|
||||||
b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold())
|
b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold())
|
||||||
|
|
||||||
|
// Configure voice effects
|
||||||
|
b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect()))
|
||||||
|
|
||||||
b.Config.Username = *username
|
b.Config.Username = *username
|
||||||
b.Config.Password = *password
|
b.Config.Password = *password
|
||||||
|
|
||||||
@@ -196,12 +221,12 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b.exitMessage = err.Error()
|
b.exitMessage = err.Error()
|
||||||
b.exitStatus = 1
|
b.exitStatus = 1
|
||||||
handle_error(b)
|
handle_error(&b)
|
||||||
}
|
}
|
||||||
b.notifyChannel = setup_notify_runner(*b.UserConfig.GetNotifyCommand())
|
b.notifyChannel = setup_notify_runner(*b.UserConfig.GetNotifyCommand())
|
||||||
b.Ui = uiterm.New(&b)
|
b.Ui = uiterm.New(&b)
|
||||||
b.Ui.Run(reader)
|
b.Ui.Run(reader)
|
||||||
handle_error(b)
|
handle_error(&b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_raw_error(e error) {
|
func handle_raw_error(e error) {
|
||||||
@@ -209,7 +234,7 @@ func handle_raw_error(e error) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_error(b Barnard) {
|
func handle_error(b *Barnard) {
|
||||||
if b.exitMessage != "" {
|
if b.exitMessage != "" {
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", b.exitMessage)
|
fmt.Fprintf(os.Stderr, "%s\n", b.exitMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package noise
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure Suppressor implements the NoiseProcessor interface
|
// Ensure Suppressor implements the NoiseProcessor interface
|
||||||
@@ -12,129 +13,200 @@ var _ interface {
|
|||||||
|
|
||||||
// Suppressor handles noise suppression for audio samples
|
// Suppressor handles noise suppression for audio samples
|
||||||
type Suppressor struct {
|
type Suppressor struct {
|
||||||
enabled bool
|
mu sync.Mutex
|
||||||
threshold float32
|
|
||||||
gainFactor float32
|
enabled bool
|
||||||
|
threshold float32
|
||||||
// Simple high-pass filter state for DC removal
|
|
||||||
|
// High-pass filter state for low-frequency rumble/DC removal.
|
||||||
prevInput float32
|
prevInput float32
|
||||||
prevOutput float32
|
prevOutput float32
|
||||||
alpha float32
|
hpAlpha float32
|
||||||
|
|
||||||
// Click detection state
|
// Adaptive suppression state.
|
||||||
clickThreshold float32
|
envelope float32
|
||||||
clickDecay float32
|
noiseFloor float32
|
||||||
recentClickEnergy float32
|
suppressionGain float32
|
||||||
|
clickEnergy float32
|
||||||
|
|
||||||
|
// Tunables.
|
||||||
|
envelopeAttack float32
|
||||||
|
envelopeRelease float32
|
||||||
|
noiseAttack float32
|
||||||
|
noiseRelease float32
|
||||||
|
gainAttack float32
|
||||||
|
gainRelease float32
|
||||||
|
speechRatio float32
|
||||||
|
clickDecay float32
|
||||||
|
minNoiseFloor float32
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSuppressor creates a new noise suppressor
|
// NewSuppressor creates a new noise suppressor
|
||||||
func NewSuppressor() *Suppressor {
|
func NewSuppressor() *Suppressor {
|
||||||
return &Suppressor{
|
s := &Suppressor{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
threshold: 0.01, // Reduced noise threshold level for less aggressive filtering
|
threshold: 0.08,
|
||||||
gainFactor: 0.9, // Less aggressive gain reduction for noise
|
hpAlpha: 0.995,
|
||||||
alpha: 0.98, // More stable high-pass filter coefficient
|
envelopeAttack: 0.18,
|
||||||
clickThreshold: 0.15, // Threshold for detecting keyboard clicks
|
envelopeRelease: 0.02,
|
||||||
clickDecay: 0.95, // How quickly click energy decays
|
noiseAttack: 0.08,
|
||||||
recentClickEnergy: 0.0, // Tracks recent click activity
|
noiseRelease: 0.002,
|
||||||
|
gainAttack: 0.35,
|
||||||
|
gainRelease: 0.02,
|
||||||
|
speechRatio: 4.0,
|
||||||
|
clickDecay: 0.93,
|
||||||
|
minNoiseFloor: 0.0008,
|
||||||
|
suppressionGain: 1.0,
|
||||||
}
|
}
|
||||||
|
s.resetStateLocked()
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEnabled enables or disables noise suppression
|
// SetEnabled enables or disables noise suppression
|
||||||
func (s *Suppressor) SetEnabled(enabled bool) {
|
func (s *Suppressor) SetEnabled(enabled bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.enabled == enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
s.enabled = enabled
|
s.enabled = enabled
|
||||||
|
s.resetStateLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled returns whether noise suppression is enabled
|
// IsEnabled returns whether noise suppression is enabled
|
||||||
func (s *Suppressor) IsEnabled() bool {
|
func (s *Suppressor) IsEnabled() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
return s.enabled
|
return s.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetThreshold sets the noise threshold (0.0 to 1.0)
|
// SetThreshold sets the noise threshold (0.0 to 1.0)
|
||||||
func (s *Suppressor) SetThreshold(threshold float32) {
|
func (s *Suppressor) SetThreshold(threshold float32) {
|
||||||
if threshold >= 0.0 && threshold <= 1.0 {
|
s.mu.Lock()
|
||||||
s.threshold = threshold
|
defer s.mu.Unlock()
|
||||||
}
|
s.threshold = clampFloat32(threshold, 0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetThreshold returns the current noise threshold
|
// GetThreshold returns the current noise threshold
|
||||||
func (s *Suppressor) GetThreshold() float32 {
|
func (s *Suppressor) GetThreshold() float32 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
return s.threshold
|
return s.threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessSamples applies noise suppression to audio samples
|
// ProcessSamples applies noise suppression to audio samples
|
||||||
func (s *Suppressor) ProcessSamples(samples []int16) {
|
func (s *Suppressor) ProcessSamples(samples []int16) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if !s.enabled || len(samples) == 0 {
|
if !s.enabled || len(samples) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate frame energy for click detection
|
intensity := s.thresholdToIntensity()
|
||||||
var frameEnergy float32 = 0.0
|
minGain := 1.0 - (0.92 * intensity)
|
||||||
for _, sample := range samples {
|
eps := float32(1e-6)
|
||||||
floatSample := float32(sample) / 32767.0
|
|
||||||
frameEnergy += floatSample * floatSample
|
|
||||||
}
|
|
||||||
frameEnergy = float32(math.Sqrt(float64(frameEnergy / float32(len(samples)))))
|
|
||||||
|
|
||||||
// Detect sudden energy spikes (likely keyboard clicks)
|
|
||||||
energySpike := frameEnergy - s.recentClickEnergy
|
|
||||||
isClick := energySpike > s.clickThreshold && frameEnergy > 0.05
|
|
||||||
|
|
||||||
// Update recent click energy with decay
|
|
||||||
s.recentClickEnergy = s.recentClickEnergy*s.clickDecay + frameEnergy*(1.0-s.clickDecay)
|
|
||||||
|
|
||||||
// Improved noise suppression algorithm
|
|
||||||
for i, sample := range samples {
|
for i, sample := range samples {
|
||||||
// Convert to float for processing
|
floatSample := float32(sample) / 32768.0
|
||||||
floatSample := float32(sample) / 32767.0
|
filtered := s.highPassFilterLocked(floatSample)
|
||||||
|
absSample := float32(math.Abs(float64(filtered)))
|
||||||
// Apply high-pass filter for DC removal
|
|
||||||
filtered := s.highPassFilter(floatSample)
|
s.updateEnvelopeLocked(absSample)
|
||||||
|
s.updateNoiseFloorLocked()
|
||||||
// Calculate signal strength (RMS-like)
|
|
||||||
strength := float32(math.Abs(float64(filtered)))
|
snr := s.envelope / (s.noiseFloor + eps)
|
||||||
|
voicePresence := clampFloat32((snr-1.0)/(s.speechRatio-1.0), 0.0, 1.0)
|
||||||
// Apply noise gate with smooth transition
|
|
||||||
var gainReduction float32 = 1.0
|
targetGain := minGain + ((1.0 - minGain) * voicePresence)
|
||||||
|
targetGain = s.applyTransientSuppressionLocked(absSample, voicePresence, minGain, targetGain)
|
||||||
// If we detected a click, apply stronger suppression
|
|
||||||
if isClick {
|
s.applyGainSmoothingLocked(targetGain)
|
||||||
gainReduction = s.gainFactor * 0.3 // Much stronger reduction for clicks
|
|
||||||
} else if strength < s.threshold {
|
processed := filtered * s.suppressionGain
|
||||||
// Normal noise gate for low-level sounds
|
processed = clampFloat32(processed, -1.0, 1.0)
|
||||||
gainReduction = strength / s.threshold
|
samples[i] = int16(processed * 32767.0)
|
||||||
if gainReduction < s.gainFactor {
|
|
||||||
gainReduction = s.gainFactor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply gain reduction
|
|
||||||
processed := filtered * gainReduction
|
|
||||||
|
|
||||||
// Convert back to int16 with proper clipping
|
|
||||||
processedInt := processed * 32767.0
|
|
||||||
if processedInt > 32767 {
|
|
||||||
processedInt = 32767
|
|
||||||
} else if processedInt < -32767 {
|
|
||||||
processedInt = -32767
|
|
||||||
}
|
|
||||||
|
|
||||||
samples[i] = int16(processedInt)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// highPassFilter applies a simple high-pass filter to remove DC component
|
func (s *Suppressor) highPassFilterLocked(input float32) float32 {
|
||||||
func (s *Suppressor) highPassFilter(input float32) float32 {
|
|
||||||
// Simple high-pass filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1])
|
// Simple high-pass filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1])
|
||||||
output := s.alpha * (s.prevOutput + input - s.prevInput)
|
output := s.hpAlpha * (s.prevOutput + input - s.prevInput)
|
||||||
s.prevInput = input
|
s.prevInput = input
|
||||||
s.prevOutput = output
|
s.prevOutput = output
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) thresholdToIntensity() float32 {
|
||||||
|
// Keep lower legacy threshold values meaningful while allowing up to very aggressive suppression.
|
||||||
|
return 1.0 - float32(math.Exp(float64(-28.0*clampFloat32(s.threshold, 0.0, 1.0))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) updateEnvelopeLocked(absSample float32) {
|
||||||
|
if absSample > s.envelope {
|
||||||
|
s.envelope += s.envelopeAttack * (absSample - s.envelope)
|
||||||
|
} else {
|
||||||
|
s.envelope += s.envelopeRelease * (absSample - s.envelope)
|
||||||
|
}
|
||||||
|
if s.envelope < s.minNoiseFloor {
|
||||||
|
s.envelope = s.minNoiseFloor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) updateNoiseFloorLocked() {
|
||||||
|
coef := s.noiseRelease
|
||||||
|
if s.envelope < s.noiseFloor*2.2 {
|
||||||
|
coef = s.noiseAttack
|
||||||
|
}
|
||||||
|
s.noiseFloor += coef * (s.envelope - s.noiseFloor)
|
||||||
|
if s.noiseFloor < s.minNoiseFloor {
|
||||||
|
s.noiseFloor = s.minNoiseFloor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) applyTransientSuppressionLocked(absSample float32, voicePresence float32, minGain float32, targetGain float32) float32 {
|
||||||
|
s.clickEnergy = (s.clickEnergy * s.clickDecay) + (absSample * (1.0 - s.clickDecay))
|
||||||
|
transient := absSample - s.clickEnergy
|
||||||
|
transientThreshold := 0.04 + (0.08 * (1.0 - voicePresence))
|
||||||
|
if transient > transientThreshold && voicePresence < 0.65 {
|
||||||
|
clickGain := minGain * 0.55
|
||||||
|
if clickGain < targetGain {
|
||||||
|
targetGain = clickGain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clampFloat32(targetGain, 0.02, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) applyGainSmoothingLocked(targetGain float32) {
|
||||||
|
if targetGain < s.suppressionGain {
|
||||||
|
s.suppressionGain += s.gainAttack * (targetGain - s.suppressionGain)
|
||||||
|
} else {
|
||||||
|
s.suppressionGain += s.gainRelease * (targetGain - s.suppressionGain)
|
||||||
|
}
|
||||||
|
s.suppressionGain = clampFloat32(s.suppressionGain, 0.02, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Suppressor) resetStateLocked() {
|
||||||
|
s.prevInput = 0.0
|
||||||
|
s.prevOutput = 0.0
|
||||||
|
s.envelope = s.minNoiseFloor
|
||||||
|
s.noiseFloor = s.minNoiseFloor
|
||||||
|
s.suppressionGain = 1.0
|
||||||
|
s.clickEnergy = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampFloat32(value float32, min float32, max float32) float32 {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessSamplesAdvanced applies more sophisticated noise suppression
|
// ProcessSamplesAdvanced applies more sophisticated noise suppression
|
||||||
// This is a placeholder for future RNNoise integration
|
// Placeholder for future RNNoise integration.
|
||||||
func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) {
|
func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) {
|
||||||
// TODO: Integrate RNNoise or other advanced algorithms
|
|
||||||
s.ProcessSamples(samples)
|
s.ProcessSamples(samples)
|
||||||
}
|
}
|
||||||
|
|||||||
103
noise/suppression_test.go
Normal file
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/nsf/termbox-go"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
"git.stormux.org/storm/barnard/uiterm"
|
"git.stormux.org/storm/barnard/uiterm"
|
||||||
"github.com/kennygrant/sanitize"
|
"github.com/kennygrant/sanitize"
|
||||||
|
"github.com/nsf/termbox-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -70,7 +71,7 @@ func (b *Barnard) UpdateInputStatus(status string) {
|
|||||||
|
|
||||||
func (b *Barnard) AddOutputLine(line string) {
|
func (b *Barnard) AddOutputLine(line string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
b.UiOutput.AddLine(fmt.Sprintf("[%02d:%02d:%02d] %s", now.Hour(), now.Minute(), now.Second(), line))
|
b.UiOutput.AddLine(fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
|
func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
|
||||||
@@ -99,7 +100,7 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|||||||
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
||||||
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
|
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
|
||||||
b.NoiseSuppressor.SetEnabled(enabled)
|
b.NoiseSuppressor.SetEnabled(enabled)
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
b.UpdateGeneralStatus("Noise suppression: ON", false)
|
b.UpdateGeneralStatus("Noise suppression: ON", false)
|
||||||
} else {
|
} else {
|
||||||
@@ -107,6 +108,13 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Barnard) OnVoiceEffectCycle(ui *uiterm.Ui, key uiterm.Key) {
|
||||||
|
effect := b.VoiceEffects.CycleEffect()
|
||||||
|
b.UserConfig.SetVoiceEffect(int(effect))
|
||||||
|
b.UpdateGeneralStatus(fmt.Sprintf("Voice effect: %s", effect.String()), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
|
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
|
||||||
if notice {
|
if notice {
|
||||||
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
|
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
|
||||||
@@ -143,7 +151,7 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
|
|||||||
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
||||||
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
|
b.UserConfig.SetNoiseSuppressionEnabled(enabled)
|
||||||
b.NoiseSuppressor.SetEnabled(enabled)
|
b.NoiseSuppressor.SetEnabled(enabled)
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
b.AddOutputLine("Noise suppression enabled")
|
b.AddOutputLine("Noise suppression enabled")
|
||||||
} else {
|
} else {
|
||||||
@@ -151,6 +159,102 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
|
||||||
|
// cmd contains just the filename part (everything after "/file ")
|
||||||
|
filename := strings.TrimSpace(cmd)
|
||||||
|
if filename == "" {
|
||||||
|
b.AddOutputLine("Usage: /file <filename or URL>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a URL
|
||||||
|
isURL := strings.HasPrefix(filename, "http://") ||
|
||||||
|
strings.HasPrefix(filename, "https://") ||
|
||||||
|
strings.HasPrefix(filename, "ftp://") ||
|
||||||
|
strings.HasPrefix(filename, "rtmp://")
|
||||||
|
|
||||||
|
if !isURL {
|
||||||
|
// Expand ~ to home directory for local files
|
||||||
|
if strings.HasPrefix(filename, "~") {
|
||||||
|
homeDir := os.Getenv("HOME")
|
||||||
|
filename = strings.Replace(filename, "~", homeDir, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if local file exists
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !b.Connected {
|
||||||
|
b.AddOutputLine("Not connected to server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.FileStreamMutex.Lock()
|
||||||
|
defer b.FileStreamMutex.Unlock()
|
||||||
|
|
||||||
|
if b.FileStream != nil && b.FileStream.IsPlaying() {
|
||||||
|
b.AddOutputLine("Already playing a file. Use /stop first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.FileStream.PlayFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable stereo encoder for file playback
|
||||||
|
b.Client.EnableStereoEncoder()
|
||||||
|
|
||||||
|
// Auto-start transmission if not already transmitting
|
||||||
|
if !b.Tx {
|
||||||
|
err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
|
||||||
|
if err != nil {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error()))
|
||||||
|
b.FileStream.Stop()
|
||||||
|
b.Client.DisableStereoEncoder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.Tx = true
|
||||||
|
b.UpdateGeneralStatus(" File ", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isURL {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
|
||||||
|
} else {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
|
||||||
|
b.FileStreamMutex.Lock()
|
||||||
|
defer b.FileStreamMutex.Unlock()
|
||||||
|
|
||||||
|
if b.FileStream == nil || !b.FileStream.IsPlaying() {
|
||||||
|
b.AddOutputLine("No file playing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.FileStream.Stop()
|
||||||
|
if err != nil {
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable stereo encoder when file stops
|
||||||
|
b.Client.DisableStereoEncoder()
|
||||||
|
|
||||||
|
b.AddOutputLine("File playback stopped")
|
||||||
|
|
||||||
|
// Note: We keep transmission active even after file stops
|
||||||
|
// User can manually stop with talk key or it will stop when they're done talking
|
||||||
|
b.UpdateGeneralStatus(" Idle ", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
|
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
|
||||||
if b.Tx && val == 1 {
|
if b.Tx && val == 1 {
|
||||||
return
|
return
|
||||||
@@ -249,6 +353,43 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
|
|||||||
if text == "" {
|
if text == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a command (starts with /)
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
// Remove the leading slash and process as command
|
||||||
|
cmdText := strings.TrimPrefix(text, "/")
|
||||||
|
parts := strings.SplitN(cmdText, " ", 2)
|
||||||
|
cmdName := parts[0]
|
||||||
|
cmdArgs := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
cmdArgs = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle built-in commands
|
||||||
|
switch cmdName {
|
||||||
|
case "file":
|
||||||
|
b.CommandPlayFile(ui, cmdArgs)
|
||||||
|
case "stop":
|
||||||
|
b.CommandStopFile(ui, cmdArgs)
|
||||||
|
case "exit":
|
||||||
|
b.CommandExit(ui, cmdArgs)
|
||||||
|
case "status":
|
||||||
|
b.CommandStatus(ui, cmdArgs)
|
||||||
|
case "noise":
|
||||||
|
b.CommandNoiseSuppressionToggle(ui, cmdArgs)
|
||||||
|
case "micup":
|
||||||
|
b.CommandMicUp(ui, cmdArgs)
|
||||||
|
case "micdown":
|
||||||
|
b.CommandMicDown(ui, cmdArgs)
|
||||||
|
case "toggle", "talk":
|
||||||
|
b.CommandTalk(ui, cmdArgs)
|
||||||
|
default:
|
||||||
|
b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a command, send as chat message
|
||||||
if b.Client != nil && b.Client.Self != nil {
|
if b.Client != nil && b.Client.Self != nil {
|
||||||
if b.selectedUser != nil {
|
if b.selectedUser != nil {
|
||||||
b.selectedUser.Send(text)
|
b.selectedUser.Send(text)
|
||||||
@@ -317,10 +458,13 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
|
|||||||
b.Ui.AddCommandListener(b.CommandExit, "exit")
|
b.Ui.AddCommandListener(b.CommandExit, "exit")
|
||||||
b.Ui.AddCommandListener(b.CommandStatus, "status")
|
b.Ui.AddCommandListener(b.CommandStatus, "status")
|
||||||
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
|
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
|
||||||
|
b.Ui.AddCommandListener(b.CommandPlayFile, "file")
|
||||||
|
b.Ui.AddCommandListener(b.CommandStopFile, "stop")
|
||||||
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
|
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
|
||||||
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
|
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
|
||||||
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
|
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
|
||||||
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
|
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
|
||||||
|
b.Ui.AddKeyListener(b.OnVoiceEffectCycle, b.Hotkeys.CycleVoiceEffect)
|
||||||
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
|
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
|
||||||
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
|
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
|
||||||
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
|
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||||
"git.stormux.org/storm/barnard/uiterm"
|
"git.stormux.org/storm/barnard/uiterm"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -11,7 +12,10 @@ func (ti TreeItem) String() string {
|
|||||||
if ti.User.LocallyMuted {
|
if ti.User.LocallyMuted {
|
||||||
return "[MUTED] " + ti.User.Name
|
return "[MUTED] " + ti.User.Name
|
||||||
}
|
}
|
||||||
return ti.User.Name
|
// Calculate total volume as percentage
|
||||||
|
boostPercent := float32(ti.User.Boost-1) * 10
|
||||||
|
totalVolume := ti.User.Volume*100 + boostPercent
|
||||||
|
return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume)
|
||||||
}
|
}
|
||||||
if ti.Channel != nil {
|
if ti.Channel != nil {
|
||||||
return "#" + ti.Channel.Name
|
return "#" + ti.Channel.Name
|
||||||
|
|||||||
15
uiterm/key_toml.go
Normal file
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