package audio import ( "errors" "fmt" "math" "sync" "time" "voicesnap/internal/logger" "github.com/gen2brain/malgo" ) const ( sampleRate = 16000 channels = 1 bitsPerSample = 16 silenceThreshold = 0.05 maxRecordingSeconds = 15 * 60 maxPCMBufferBytes = sampleRate * channels * (bitsPerSample / 8) * maxRecordingSeconds MaxRecordingDuration = time.Duration(maxRecordingSeconds) * time.Second maxPendingDeviceStops = 2 deviceStartWatchDelay = 2 * time.Second ) var pendingDeviceStops = make(chan struct{}, maxPendingDeviceStops) var ErrMicrophonePermission = errors.New("microphone permission not granted") // InputDevice represents an audio input device. type InputDevice struct { Name string `json:"name"` IsDefault bool `json:"isDefault"` } type recorderAudioContext interface { devices(malgo.DeviceType) ([]malgo.DeviceInfo, error) initDevice(malgo.DeviceConfig, malgo.DeviceCallbacks) (recorderDevice, error) free() } type malgoRecorderContext struct { allocated *malgo.AllocatedContext } func (c *malgoRecorderContext) devices(deviceType malgo.DeviceType) ([]malgo.DeviceInfo, error) { return c.allocated.Context.Devices(deviceType) } func (c *malgoRecorderContext) initDevice(config malgo.DeviceConfig, callbacks malgo.DeviceCallbacks) (recorderDevice, error) { return malgo.InitDevice(c.allocated.Context, config, callbacks) } func (c *malgoRecorderContext) free() { c.allocated.Free() } type recorderDevice interface { Start() error Stop() error Uninit() } // Recorder captures audio from the default input device using malgo (miniaudio). type Recorder struct { mu sync.Mutex lifecycleMu sync.Mutex ctx recorderAudioContext device recorderDevice // PCM buffer (16-bit signed, little-endian) pcmBuf []byte // Preferred device name (empty = system default) preferredDevice string // State isRecording bool startRequested bool isStarting bool stopAfterStart bool bufferLimitHit bool closing bool maxVolume float64 currentVolume float64 volumeCallback func(float64) deviceChangeCb func(string) } // NewRecorder creates a new audio recorder backed by malgo. func NewRecorder() *Recorder { ctxConfig := malgo.ContextConfig{} ctx, err := malgo.InitContext(nil, ctxConfig, nil) if err != nil { logger.Error("Failed to init malgo context: %v", err) return &Recorder{} } return &Recorder{ctx: &malgoRecorderContext{allocated: ctx}} } // OnVolume registers a callback for real-time volume updates. func (r *Recorder) OnVolume(fn func(float64)) { r.mu.Lock() defer r.mu.Unlock() r.volumeCallback = fn } // OnDeviceChange registers a callback for audio device changes. func (r *Recorder) OnDeviceChange(fn func(string)) { r.mu.Lock() defer r.mu.Unlock() r.deviceChangeCb = fn } // ListInputDevices returns all available audio capture devices. func (r *Recorder) ListInputDevices() []InputDevice { r.lifecycleMu.Lock() defer r.lifecycleMu.Unlock() ctx := r.ctx if ctx == nil { return nil } infos, err := ctx.devices(malgo.Capture) if err != nil { logger.Error("Failed to list devices: %v", err) return nil } var result []InputDevice for i, info := range infos { result = append(result, InputDevice{ Name: info.Name(), IsDefault: i == 0, }) } return result } // SetPreferredDevice sets the preferred device by name. Empty string = system default. func (r *Recorder) SetPreferredDevice(name string) { r.mu.Lock() defer r.mu.Unlock() r.preferredDevice = name logger.Info("Preferred device set to: %q", name) } // Start begins recording from the preferred (or default) capture device. func (r *Recorder) Start() error { r.lifecycleMu.Lock() defer r.lifecycleMu.Unlock() r.mu.Lock() if r.closing { r.mu.Unlock() return fmt.Errorf("audio recorder is closed") } if r.isRecording || r.startRequested || r.isStarting { r.mu.Unlock() return nil } r.pcmBuf = nil r.bufferLimitHit = false r.maxVolume = 0 r.currentVolume = 0 r.startRequested = true r.stopAfterStart = false ctx := r.ctx preferredDevice := r.preferredDevice r.mu.Unlock() logger.Info("Audio recorder start requested sample_rate=%d channels=%d preferred_device=%q", sampleRate, channels, preferredDevice) auth := ensureMicrophoneAuthorization() if !auth.granted { r.mu.Lock() r.startRequested = false r.stopAfterStart = false r.isRecording = false r.mu.Unlock() logger.Error("Microphone permission not granted; audio recorder start blocked state=%s", auth.state) return fmt.Errorf("%w: %s", ErrMicrophonePermission, auth.state) } logger.Info("Microphone permission ready state=%s", auth.state) r.mu.Lock() stopBeforeAuthComplete := r.stopAfterStart || r.closing startWasClosed := r.closing if stopBeforeAuthComplete { r.startRequested = false r.stopAfterStart = false r.isRecording = false r.mu.Unlock() if startWasClosed { return fmt.Errorf("audio recorder closed during start") } return nil } r.mu.Unlock() if ctx == nil { r.mu.Lock() r.startRequested = false r.stopAfterStart = false r.mu.Unlock() return fmt.Errorf("audio context is not initialized") } deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture) deviceConfig.Capture.Format = malgo.FormatS16 deviceConfig.Capture.Channels = channels deviceConfig.SampleRate = sampleRate deviceConfig.PeriodSizeInMilliseconds = 50 // Use preferred device if set if preferredDevice != "" { infos, err := ctx.devices(malgo.Capture) if err == nil { for _, info := range infos { if info.Name() == preferredDevice { deviceConfig.Capture.DeviceID = info.ID.Pointer() break } } } } callbacks := malgo.DeviceCallbacks{ Data: func(outputSamples, inputSamples []byte, framecount uint32) { r.onData(inputSamples) }, } device, err := ctx.initDevice(deviceConfig, callbacks) if err != nil { r.mu.Lock() r.startRequested = false r.stopAfterStart = false r.mu.Unlock() return err } logger.Info("Audio device initialized; starting native device") r.mu.Lock() stopBeforeStart := r.stopAfterStart || r.closing startWasClosed = r.closing if r.isRecording || stopBeforeStart { r.startRequested = false r.stopAfterStart = false r.isRecording = false r.mu.Unlock() device.Uninit() if startWasClosed { return fmt.Errorf("audio recorder closed during start") } return nil } r.device = device r.isRecording = true r.startRequested = false r.isStarting = true r.stopAfterStart = false r.mu.Unlock() deviceStartBegin := time.Now() watchDone := make(chan struct{}) go func() { select { case <-watchDone: case <-time.After(deviceStartWatchDelay): logger.Error("Native audio device start still pending after %dms", deviceStartWatchDelay.Milliseconds()) } }() if err := device.Start(); err != nil { close(watchDone) r.mu.Lock() stillCurrent := r.device == device if stillCurrent { r.device = nil r.isRecording = false r.startRequested = false r.isStarting = false r.stopAfterStart = false } r.mu.Unlock() if stillCurrent { device.Uninit() } return err } close(watchDone) logger.Info("Native audio device start returned result=success start_ms=%d", time.Since(deviceStartBegin).Milliseconds()) r.mu.Lock() stillCurrent := r.device == device shouldStop := stillCurrent && (r.stopAfterStart || r.closing || !r.isRecording) startWasClosed = stillCurrent && r.closing if !stillCurrent && r.device == device { r.device = nil r.isRecording = false r.isStarting = false r.stopAfterStart = false } if stillCurrent { r.startRequested = false r.isStarting = false } if shouldStop { r.device = nil r.isRecording = false r.stopAfterStart = false } r.mu.Unlock() if !stillCurrent { stopAndUninitDevice(device) return fmt.Errorf("audio recorder closed during start") } if shouldStop { stopAndUninitDevice(device) if startWasClosed { return fmt.Errorf("audio recorder closed during start") } return nil } logger.Info("Recording started (16kHz/16-bit/mono)") return nil } // Stop stops recording and discards audio data. func (r *Recorder) Stop() { device, _ := r.detachDeviceAndPCM() stopAndUninitDeviceAsync(device) } // StopAndGetSamples stops recording and returns the captured audio as float32 samples. func (r *Recorder) StopAndGetSamples() []float32 { device, pcm := r.detachDeviceAndPCM() stopAndUninitDeviceAsync(device) if len(pcm) < 2 { return nil } samples := pcm16LEToFloat32(pcm) logger.Info("Recording stopped, %d samples captured", len(samples)) return samples } // ReadSamplesSince returns newly captured samples after sampleOffset and the // next offset to pass on the following call. It is safe to call while recording. func (r *Recorder) ReadSamplesSince(sampleOffset int) ([]float32, int) { r.mu.Lock() totalSamples := len(r.pcmBuf) / 2 if sampleOffset < 0 { sampleOffset = 0 } if sampleOffset > totalSamples { sampleOffset = totalSamples } startByte := sampleOffset * 2 pcm := append([]byte(nil), r.pcmBuf[startByte:]...) r.mu.Unlock() if len(pcm) < 2 { return nil, totalSamples } return pcm16LEToFloat32(pcm), totalSamples } // HasVoiceActivity returns true if the max volume exceeded the silence threshold. func (r *Recorder) HasVoiceActivity() bool { r.mu.Lock() defer r.mu.Unlock() return r.maxVolume > silenceThreshold } // GetDeviceName returns the name of the current capture device. func (r *Recorder) GetDeviceName() string { r.lifecycleMu.Lock() defer r.lifecycleMu.Unlock() ctx := r.ctx if ctx == nil { return "Default" } devices, err := ctx.devices(malgo.Capture) if err != nil || len(devices) == 0 { return "Default" } return devices[0].Name() } // Close releases all audio resources. func (r *Recorder) Close() { r.mu.Lock() r.closing = true r.mu.Unlock() r.lifecycleMu.Lock() defer r.lifecycleMu.Unlock() device := r.detachDevice() stopAndUninitDevice(device) r.mu.Lock() defer r.mu.Unlock() if r.ctx != nil { r.ctx.free() r.ctx = nil } } func (r *Recorder) detachDevice() recorderDevice { r.mu.Lock() defer r.mu.Unlock() device := r.device if r.startRequested && !r.isStarting { logger.Info("Recording stop requested before audio device was published; deferring startup cleanup") r.isRecording = false r.stopAfterStart = true return nil } if r.isStarting { logger.Info("Recording stop requested while audio device start is still pending; deferring native device cleanup") r.isRecording = false r.stopAfterStart = true return nil } r.device = nil r.isRecording = false return device } func (r *Recorder) detachDeviceAndPCM() (recorderDevice, []byte) { r.mu.Lock() defer r.mu.Unlock() device := r.device if r.startRequested && !r.isStarting { logger.Info("Recording samples requested before audio device was published; snapshotting buffered PCM and deferring startup cleanup") r.isRecording = false r.stopAfterStart = true pcm := append([]byte(nil), r.pcmBuf...) r.pcmBuf = nil return nil, pcm } if r.isStarting { logger.Info("Recording samples requested while audio device start is still pending; snapshotting buffered PCM and deferring native device cleanup") r.isRecording = false r.stopAfterStart = true pcm := append([]byte(nil), r.pcmBuf...) r.pcmBuf = nil return nil, pcm } r.device = nil r.isRecording = false pcm := append([]byte(nil), r.pcmBuf...) r.pcmBuf = nil return device, pcm } func stopAndUninitDeviceAsync(device recorderDevice) { if device == nil { return } select { case pendingDeviceStops <- struct{}{}: case <-time.After(100 * time.Millisecond): logger.Error("Audio device stop queue saturated; stopping synchronously") stopAndUninitDevice(device) return } done := make(chan struct{}) go func() { defer func() { <-pendingDeviceStops }() stopAndUninitDevice(device) close(done) }() go func() { select { case <-done: case <-time.After(2 * time.Second): logger.Error("Audio device stop is still pending; continuing without blocking UI") } }() } func stopAndUninitDevice(device recorderDevice) { if device == nil { return } logger.Info("Stopping audio device") device.Stop() logger.Info("Audio device stopped") device.Uninit() logger.Info("Audio device uninitialized") } func pcm16LEToFloat32(pcm []byte) []float32 { numSamples := len(pcm) / 2 if numSamples == 0 { return nil } samples := make([]float32, numSamples) for i := 0; i < numSamples; i++ { sample := int16(pcm[i*2]) | int16(pcm[i*2+1])<<8 samples[i] = float32(sample) / 32768.0 } return samples } func (r *Recorder) onData(input []byte) { r.mu.Lock() if !r.isRecording { r.mu.Unlock() return } // Append raw PCM data if len(r.pcmBuf) >= maxPCMBufferBytes { if !r.bufferLimitHit { logger.Error("Recording buffer limit reached; dropping additional audio max_seconds=%d", maxRecordingSeconds) r.bufferLimitHit = true } r.mu.Unlock() return } if len(r.pcmBuf)+len(input) > maxPCMBufferBytes { input = input[:maxPCMBufferBytes-len(r.pcmBuf)] if !r.bufferLimitHit { logger.Error("Recording buffer limit reached; truncating audio max_seconds=%d", maxRecordingSeconds) r.bufferLimitHit = true } } r.pcmBuf = append(r.pcmBuf, input...) // Calculate RMS volume numSamples := len(input) / 2 if numSamples == 0 { r.mu.Unlock() return } var sum float64 for i := 0; i < len(input)-1; i += 2 { sample := int16(input[i]) | int16(input[i+1])<<8 normalized := float64(sample) / 32768.0 sum += normalized * normalized } rms := math.Sqrt(sum / float64(numSamples)) volume := math.Min(1.0, rms*8) r.currentVolume = volume if volume > r.maxVolume { r.maxVolume = volume } cb := r.volumeCallback r.mu.Unlock() // Invoke callback outside lock to prevent deadlock with app mutex if cb != nil { cb(volume) } }