| | |
| | | package audio |
| | | |
| | | import ( |
| | | "errors" |
| | | "fmt" |
| | | "math" |
| | | "sync" |
| | | "time" |
| | |
| | | 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 { |
| | |
| | | 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 |
| | | mu sync.Mutex |
| | | lifecycleMu sync.Mutex |
| | | |
| | | ctx *malgo.AllocatedContext |
| | | device *malgo.Device |
| | | ctx recorderAudioContext |
| | | device recorderDevice |
| | | |
| | | // PCM buffer (16-bit signed, little-endian) |
| | | pcmBuf []byte |
| | |
| | | |
| | | // State |
| | | isRecording bool |
| | | startRequested bool |
| | | isStarting bool |
| | | stopAfterStart bool |
| | | bufferLimitHit bool |
| | | closing bool |
| | | maxVolume float64 |
| | | currentVolume float64 |
| | | volumeCallback func(float64) |
| | |
| | | logger.Error("Failed to init malgo context: %v", err) |
| | | return &Recorder{} |
| | | } |
| | | return &Recorder{ctx: ctx} |
| | | return &Recorder{ctx: &malgoRecorderContext{allocated: ctx}} |
| | | } |
| | | |
| | | // OnVolume registers a callback for real-time volume updates. |
| | |
| | | |
| | | // ListInputDevices returns all available audio capture devices. |
| | | func (r *Recorder) ListInputDevices() []InputDevice { |
| | | if r.ctx == nil { |
| | | r.lifecycleMu.Lock() |
| | | defer r.lifecycleMu.Unlock() |
| | | |
| | | ctx := r.ctx |
| | | if ctx == nil { |
| | | return nil |
| | | } |
| | | infos, err := r.ctx.Context.Devices(malgo.Capture) |
| | | infos, err := ctx.devices(malgo.Capture) |
| | | if err != nil { |
| | | logger.Error("Failed to list devices: %v", err) |
| | | return nil |
| | |
| | | |
| | | // Start begins recording from the preferred (or default) capture device. |
| | | func (r *Recorder) Start() error { |
| | | r.mu.Lock() |
| | | defer r.mu.Unlock() |
| | | r.lifecycleMu.Lock() |
| | | defer r.lifecycleMu.Unlock() |
| | | |
| | | if r.isRecording { |
| | | 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.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.PeriodSizeInMilliseconds = 50 |
| | | |
| | | // Use preferred device if set |
| | | if r.preferredDevice != "" { |
| | | infos, err := r.ctx.Context.Devices(malgo.Capture) |
| | | if preferredDevice != "" { |
| | | infos, err := ctx.devices(malgo.Capture) |
| | | if err == nil { |
| | | for _, info := range infos { |
| | | if info.Name() == r.preferredDevice { |
| | | if info.Name() == preferredDevice { |
| | | deviceConfig.Capture.DeviceID = info.ID.Pointer() |
| | | break |
| | | } |
| | |
| | | }, |
| | | } |
| | | |
| | | device, err := malgo.InitDevice(r.ctx.Context, deviceConfig, callbacks) |
| | | 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") |
| | | |
| | | if err := device.Start(); err != nil { |
| | | 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() |
| | | return err |
| | | 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 |
| | | } |
| | |
| | | |
| | | // GetDeviceName returns the name of the current capture device. |
| | | func (r *Recorder) GetDeviceName() string { |
| | | if r.ctx == nil { |
| | | r.lifecycleMu.Lock() |
| | | defer r.lifecycleMu.Unlock() |
| | | |
| | | ctx := r.ctx |
| | | if ctx == nil { |
| | | return "Default" |
| | | } |
| | | devices, err := r.ctx.Context.Devices(malgo.Capture) |
| | | devices, err := ctx.devices(malgo.Capture) |
| | | if err != nil || len(devices) == 0 { |
| | | return "Default" |
| | | } |
| | |
| | | |
| | | // 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.free() |
| | | r.ctx = nil |
| | | } |
| | | } |
| | | |
| | | func (r *Recorder) detachDevice() *malgo.Device { |
| | | 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() (*malgo.Device, []byte) { |
| | | 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...) |
| | |
| | | return device, pcm |
| | | } |
| | | |
| | | func stopAndUninitDeviceAsync(device *malgo.Device) { |
| | | func stopAndUninitDeviceAsync(device recorderDevice) { |
| | | if device == nil { |
| | | return |
| | | } |
| | |
| | | }() |
| | | } |
| | | |
| | | func stopAndUninitDevice(device *malgo.Device) { |
| | | func stopAndUninitDevice(device recorderDevice) { |
| | | if device == nil { |
| | | return |
| | | } |