// Package recorder provides audio capture functionality using the miniaudio library. // It records audio from the system's default capture device to WAV files. package recorder import ( "encoding/binary" "fmt" "os" "sync" "math" "wis-free-v3/internal/logger" "github.com/gen2brain/malgo" ) // Audio recording configuration const ( SampleRate = 16000 // Hz - optimal for speech recognition NumChannels = 1 // Mono audio BitsPerSample = 16 // 16-bit PCM BytesPerSample = BitsPerSample / 8 ) // MicrophoneInfo contains information about an available audio capture device. type MicrophoneInfo struct { ID string Name string IsDefault bool } // VolumeCallback is called when new volume levels are calculated type VolumeCallback func(level float64) // AudioRecorder handles audio capture from the system microphone. type AudioRecorder struct { ctx *malgo.AllocatedContext deviceConfig malgo.DeviceConfig device *malgo.Device outputFile *os.File dataSize uint32 isRecording bool deviceID *string OnVolume VolumeCallback mu sync.Mutex } // NewRecorder creates a new audio recorder instance. // Returns an error if the audio context cannot be initialized. func NewRecorder() (*AudioRecorder, error) { ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) if err != nil { return nil, fmt.Errorf("failed to initialize audio context: %w", err) } deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture) deviceConfig.Capture.Format = malgo.FormatS16 deviceConfig.Capture.Channels = NumChannels deviceConfig.SampleRate = SampleRate deviceConfig.Alsa.NoMMap = 1 return &AudioRecorder{ ctx: ctx, deviceConfig: deviceConfig, }, nil } // GetMicrophones returns a list of available audio capture devices. func (r *AudioRecorder) GetMicrophones() ([]MicrophoneInfo, error) { r.mu.Lock() defer r.mu.Unlock() if r.ctx == nil { return nil, fmt.Errorf("audio context not initialized") } devices, err := r.ctx.Context.Devices(malgo.Capture) if err != nil { return nil, fmt.Errorf("failed to enumerate devices: %w", err) } mics := []MicrophoneInfo{ {ID: "", Name: "System Default", IsDefault: true}, } for _, info := range devices { name := info.Name() if name == "" { continue } mics = append(mics, MicrophoneInfo{ ID: fmt.Sprintf("%v", info.ID), Name: name, IsDefault: false, }) logger.Info("Found microphone: %s", name) } return mics, nil } // SetDevice configures the recorder to use a specific device. // Pass an empty string to use the system default device. func (r *AudioRecorder) SetDevice(deviceID string) { r.mu.Lock() defer r.mu.Unlock() if deviceID == "" { r.deviceID = nil } else { r.deviceID = &deviceID } } // Start begins recording audio to the specified WAV file. // Returns an error if recording is already in progress or initialization fails. func (r *AudioRecorder) Start(filename string) error { r.mu.Lock() defer r.mu.Unlock() if r.isRecording { return fmt.Errorf("recording already in progress") } // Create output file file, err := os.Create(filename) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } r.outputFile = file r.dataSize = 0 // Write placeholder WAV header if err := r.writeWAVHeader(0); err != nil { file.Close() return fmt.Errorf("failed to write WAV header: %w", err) } // Configure audio callback callbacks := malgo.DeviceCallbacks{ Data: r.onAudioData, } // Use custom device if specified if r.deviceID != nil { // Enumerate devices to find the matching pointer devices, err := r.ctx.Context.Devices(malgo.Capture) if err == nil { for _, info := range devices { if fmt.Sprintf("%v", info.ID) == *r.deviceID { r.deviceConfig.Capture.DeviceID = info.ID.Pointer() break } } } } else { r.deviceConfig.Capture.DeviceID = nil } // Initialize device device, err := malgo.InitDevice(r.ctx.Context, r.deviceConfig, callbacks) if err != nil { file.Close() return fmt.Errorf("failed to initialize audio device: %w", err) } r.device = device // Start capturing if err := device.Start(); err != nil { device.Uninit() file.Close() return fmt.Errorf("failed to start audio capture: %w", err) } r.isRecording = true logger.Info("Recording started: %s", filename) return nil } // Stop ends the current recording and finalizes the WAV file. func (r *AudioRecorder) Stop() error { r.mu.Lock() defer r.mu.Unlock() if !r.isRecording { return nil } // Stop and cleanup device if r.device != nil { r.device.Uninit() r.device = nil } // Finalize WAV file if r.outputFile != nil { // Rewrite header with correct data size r.outputFile.Seek(0, 0) if err := r.writeWAVHeader(r.dataSize); err != nil { logger.Error("Failed to update WAV header: %v", err) } r.outputFile.Close() r.outputFile = nil } r.isRecording = false logger.Info("Recording stopped: %d bytes captured", r.dataSize) return nil } // IsRecording returns true if recording is currently in progress. func (r *AudioRecorder) IsRecording() bool { r.mu.Lock() defer r.mu.Unlock() return r.isRecording } // Cleanup releases all audio resources. func (r *AudioRecorder) Cleanup() { r.mu.Lock() defer r.mu.Unlock() if r.device != nil { r.device.Uninit() r.device = nil } if r.ctx != nil { r.ctx.Free() r.ctx = nil } logger.Info("Audio recorder cleaned up") } // onAudioData is called by miniaudio when audio data is available. func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) { if r.outputFile != nil && len(inputSamples) > 0 { n, _ := r.outputFile.Write(inputSamples) r.dataSize += uint32(n) // 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) } } } // writeWAVHeader writes a standard RIFF WAV header to the output file. func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error { sampleRate := uint32(SampleRate) numChannels := uint16(NumChannels) bitsPerSample := uint16(BitsPerSample) byteRate := sampleRate * uint32(numChannels) * uint32(bitsPerSample/8) blockAlign := numChannels * (bitsPerSample / 8) // RIFF chunk r.outputFile.Write([]byte("RIFF")) binary.Write(r.outputFile, binary.LittleEndian, uint32(36+dataSize)) r.outputFile.Write([]byte("WAVE")) // fmt sub-chunk r.outputFile.Write([]byte("fmt ")) binary.Write(r.outputFile, binary.LittleEndian, uint32(16)) // Subchunk1Size binary.Write(r.outputFile, binary.LittleEndian, uint16(1)) // AudioFormat (PCM) binary.Write(r.outputFile, binary.LittleEndian, numChannels) binary.Write(r.outputFile, binary.LittleEndian, sampleRate) binary.Write(r.outputFile, binary.LittleEndian, byteRate) binary.Write(r.outputFile, binary.LittleEndian, blockAlign) binary.Write(r.outputFile, binary.LittleEndian, bitsPerSample) // data sub-chunk r.outputFile.Write([]byte("data")) binary.Write(r.outputFile, binary.LittleEndian, dataSize) return nil }