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)
|
}
|
}
|