feat: enhance Linux support with ydotool integration and improve logging

This commit is contained in:
Your Name
2026-06-09 00:25:05 -07:00
parent bc3d8cbce3
commit 05c8fbc474
7 changed files with 421 additions and 118 deletions
+37 -21
View File
@@ -9,7 +9,6 @@ import (
"sync"
"sync/atomic"
"math"
"unsafe"
"wis-free-v3/internal/logger"
@@ -202,14 +201,16 @@ func (r *AudioRecorder) Stop() error {
return nil
}
// Stop and release the capture device so Linux privacy indicators turn off
// between recordings. The next Start will initialize it again.
// Stop the capture device. We keep the device initialized between recordings
// to avoid the expensive re-initialization cycle. ALSA privacy indicators
// will still turn off because we've stopped the stream.
if r.device != nil {
if err := r.device.Stop(); err != nil {
logger.Error("Failed to stop audio device: %v", err)
}
r.device.Uninit()
r.device = nil
// Do NOT Uninit the device between recordings. Keeping it alive
// avoids expensive ALSA device re-probe and reduces CPU spikes.
// The device will be fully cleaned up in Cleanup().
}
// Finalize WAV file
@@ -262,25 +263,40 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
// Calculate volume if callback is set
if r.OnVolume != nil {
// S16LE: 2 bytes per sample
samples := len(inputSamples) / 2
var maxAmplitude float64
for i := 0; i < samples; i++ {
// Read as int16
val := int16(binary.LittleEndian.Uint16(inputSamples[i*2 : i*2+2]))
absVal := math.Abs(float64(val))
if absVal > maxAmplitude {
maxAmplitude = absVal
}
}
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
level := maxAmplitude / 32767.0
r.OnVolume(level)
r.calculateVolume(inputSamples[:n])
}
}
}
// calculateVolume processes audio samples to compute the current volume level.
// Extracted to avoid allocations in the hot audio callback path.
func (r *AudioRecorder) calculateVolume(samples []byte) {
// S16LE: 2 bytes per sample
sampleCount := len(samples) / 2
if sampleCount == 0 {
return
}
var maxAmplitude float64
// Use direct slice indexing to avoid per-sample allocations
for i := 0; i < sampleCount; i++ {
offset := i * 2
// Read as int16 via inlined LittleEndian to avoid function call overhead
val := int16(samples[offset]) | int16(samples[offset+1])<<8
absVal := float64(val)
if absVal < 0 {
absVal = -absVal
}
if absVal > maxAmplitude {
maxAmplitude = absVal
}
}
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
level := maxAmplitude / 32767.0
r.OnVolume(level)
}
// writeWAVHeader writes a standard RIFF WAV header to the output file.
func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error {
sampleRate := uint32(SampleRate)
@@ -310,4 +326,4 @@ func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error {
binary.Write(r.outputFile, binary.LittleEndian, dataSize)
return nil
}
}
+17 -5
View File
@@ -21,6 +21,7 @@ type Listener struct {
hk *xhk.Hotkey
stopModPoll chan struct{}
mu sync.RWMutex
eventLoopDone chan struct{}
}
// NewListener creates a new hotkey listener with the specified shortcut and callbacks.
@@ -144,11 +145,22 @@ func (l *Listener) stopListeningLocked() {
}
l.hk = nil
}
// Wait for event loop to fully terminate before allowing re-registration
if l.eventLoopDone != nil {
<-l.eventLoopDone
l.eventLoopDone = nil
}
l.isListening = false
}
// eventLoop runs the main keyboard event processing loop.
func (l *Listener) eventLoop(hk *xhk.Hotkey) {
done := make(chan struct{})
l.mu.Lock()
l.eventLoopDone = done
l.mu.Unlock()
defer close(done)
var isRecording bool
for {
@@ -159,7 +171,7 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
}
if !isRecording {
logger.Info("Shortcut activated: starting recording")
go l.startCallback()
l.startCallback()
isRecording = true
} else {
// We received a second Keydown while already recording.
@@ -171,9 +183,9 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
// Genuine second press. Toggle off.
logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
if l.stopCallback != nil {
go l.stopCallback()
l.stopCallback()
} else {
go l.startCallback()
l.startCallback()
}
isRecording = false
}
@@ -196,10 +208,10 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
case <-time.After(50 * time.Millisecond):
// Key was genuinely physically released
logger.Info("Shortcut released: stopping recording")
go l.stopCallback()
l.stopCallback()
isRecording = false
}
}
}
}
}
}
+9 -2
View File
@@ -76,6 +76,11 @@ func Error(format string, args ...interface{}) {
log("ERROR", format, args...)
}
// Debug logs a debug message with timestamp.
func Debug(format string, args ...interface{}) {
log("DEBUG", format, args...)
}
// log writes a formatted log message to the log file and console.
func log(level, format string, args ...interface{}) {
logMutex.Lock()
@@ -103,7 +108,9 @@ func log(level, format string, args ...interface{}) {
if logFile != nil {
logFile.WriteString(logLine)
logFile.Sync()
// Do NOT call Sync() on every write — that's a massive performance
// bottleneck (forces fsync to disk for every single log line).
// The kernel will flush buffered writes in its own time.
}
}
@@ -141,4 +148,4 @@ func getLogPath() (string, error) {
}
return filepath.Join(homeDir, ".wis-free-v3", "logs", time.Now().Format("2006-01-02")+".log"), nil
}
}
+35 -5
View File
@@ -338,6 +338,36 @@ func (hk *Hotkey) registerPortal() error {
return nil
}
// safeSendKeydown sends a keydown event to the hotkey channel, recovering from panic
// if the channel has been closed (e.g. during hotkey re-registration).
func (hk *Hotkey) safeSendKeydown() {
defer func() {
if r := recover(); r != nil {
logger.Debug("safeSendKeydown: recovered from panic: %v", r)
}
}()
select {
case hk.keydownIn <- Event{}:
default:
// Channel buffer is full or closed; skip.
}
}
// safeSendKeyup sends a keyup event to the hotkey channel, recovering from panic
// if the channel has been closed (e.g. during hotkey re-registration).
func (hk *Hotkey) safeSendKeyup() {
defer func() {
if r := recover(); r != nil {
logger.Debug("safeSendKeyup: recovered from panic: %v", r)
}
}()
select {
case hk.keyupIn <- Event{}:
default:
// Channel buffer is full or closed; skip.
}
}
func (hk *Hotkey) portalSignalLoop() {
defer close(hk.portalDone)
@@ -388,15 +418,15 @@ func (hk *Hotkey) portalSignalLoop() {
switch sig.Name {
case ifaceGlobalShortcuts + ".Activated":
go func() { hk.keydownIn <- Event{} }()
hk.safeSendKeydown()
case ifaceGlobalShortcuts + ".Deactivated":
go func() { hk.keyupIn <- Event{} }()
hk.safeSendKeyup()
default:
// Fallback for different bus routing names just in case
if strings.HasSuffix(sig.Name, ".Activated") {
go func() { hk.keydownIn <- Event{} }()
hk.safeSendKeydown()
} else if strings.HasSuffix(sig.Name, ".Deactivated") {
go func() { hk.keyupIn <- Event{} }()
hk.safeSendKeyup()
}
}
}
@@ -428,4 +458,4 @@ func (hk *Hotkey) cleanupPortal() error {
_ = conn.Close()
}
return nil
}
}