Skip to content

Commit c8b49be

Browse files
authored
Add audio integration (#2)
* Add audio integration * Fixing issues with audio capture for windows and linux * fixing copilot issues and build issues. * adding fixes for linux building * fixing formatting errors
1 parent 369453b commit c8b49be

16 files changed

Lines changed: 2318 additions & 42 deletions

File tree

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(cargo build:*)",
5+
"Bash(npm run build)",
6+
"Bash(cargo check:*)"
7+
],
8+
"deny": [],
9+
"ask": []
10+
}
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add audio file path to meetings table
2+
-- This allows tracking of recorded audio files for each meeting
3+
4+
ALTER TABLE meetings ADD COLUMN audio_file_path TEXT;
5+
6+
-- Index for searching meetings with audio files
7+
CREATE INDEX idx_meetings_audio_file ON meetings(audio_file_path) WHERE audio_file_path IS NOT NULL;
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
//! Linux PulseAudio Capture Implementation
2+
//!
3+
//! Uses PulseAudio monitor sources to capture system audio streams.
4+
//! Monitor sources allow non-intrusive capture of audio playing through the system.
5+
6+
use crate::error::{AppError, Result};
7+
use crate::ports::audio::{AudioBuffer, AudioCapturePort, AudioFormat};
8+
use async_trait::async_trait;
9+
use libpulse_binding::sample::{Format, Spec};
10+
use libpulse_binding::stream::Direction;
11+
use libpulse_simple_binding::Simple;
12+
use std::sync::{Arc, Mutex};
13+
use std::time::Duration;
14+
15+
/// Linux PulseAudio capture implementation
16+
///
17+
/// Captures system audio output using PulseAudio monitor sources.
18+
/// Uses @DEFAULT_MONITOR@ to capture what's playing through the speakers.
19+
///
20+
/// Audio format: 44100 Hz, 2 channels (stereo), 16-bit signed little-endian
21+
pub struct PulseAudioCapture {
22+
is_capturing: Arc<Mutex<bool>>,
23+
audio_buffer: Arc<Mutex<Vec<f32>>>,
24+
/// Audio format - placeholder until capture starts, then set to 44.1kHz stereo 16-bit
25+
format: AudioFormat,
26+
capture_handle: Option<tokio::task::JoinHandle<()>>,
27+
}
28+
29+
impl PulseAudioCapture {
30+
/// Creates a new PulseAudio capture instance
31+
///
32+
/// The format field is initialized to a default placeholder.
33+
/// Actual format (44.1kHz stereo 16-bit) is set when `start_capture()` is called.
34+
pub fn new() -> Self {
35+
Self {
36+
is_capturing: Arc::new(Mutex::new(false)),
37+
audio_buffer: Arc::new(Mutex::new(Vec::new())),
38+
format: AudioFormat::default(), // Placeholder, updated during start_capture()
39+
capture_handle: None,
40+
}
41+
}
42+
43+
/// Convert audio samples from i16 to f32 normalized format
44+
fn convert_samples(samples: &[i16]) -> Vec<f32> {
45+
samples.iter().map(|&s| s as f32 / 32768.0).collect()
46+
}
47+
}
48+
49+
impl Default for PulseAudioCapture {
50+
fn default() -> Self {
51+
Self::new()
52+
}
53+
}
54+
55+
#[async_trait]
56+
impl AudioCapturePort for PulseAudioCapture {
57+
async fn list_devices(&self) -> Result<Vec<String>> {
58+
// For Phase 2, we'll just return the default monitor source
59+
// TODO: Implement full device enumeration using libpulse-binding in future phases
60+
Ok(vec!["Default Monitor Source".to_string()])
61+
}
62+
63+
async fn start_capture(&mut self, device_name: Option<String>) -> Result<()> {
64+
{
65+
let mut is_capturing = self.is_capturing.lock().unwrap();
66+
if *is_capturing {
67+
return Err(AppError::AudioCapture(
68+
"Capture already in progress".to_string(),
69+
));
70+
}
71+
72+
*is_capturing = true;
73+
} // Drop is_capturing guard here
74+
75+
let is_capturing_clone = Arc::clone(&self.is_capturing);
76+
let audio_buffer_clone = Arc::clone(&self.audio_buffer);
77+
78+
// Determine which device to use for capture
79+
// Default to system monitor source if not specified
80+
let device = device_name.unwrap_or_else(|| "@DEFAULT_MONITOR@".to_string());
81+
82+
// Store format info to be updated after detection
83+
let format_info = Arc::new(Mutex::new(AudioFormat::default()));
84+
let format_info_clone = Arc::clone(&format_info);
85+
86+
// Spawn background task for audio capture
87+
let handle = tokio::task::spawn_blocking(move || {
88+
// Set up PulseAudio sample specification
89+
let spec = Spec {
90+
format: Format::S16le, // 16-bit signed little-endian
91+
channels: 2, // Stereo
92+
rate: 44100, // 44.1 kHz
93+
};
94+
95+
// Store the format
96+
*format_info_clone.lock().unwrap() = AudioFormat {
97+
sample_rate: spec.rate,
98+
channels: spec.channels as u16,
99+
bits_per_sample: 16, // S16LE is 16-bit
100+
};
101+
102+
// Create a simple recording connection
103+
// Use monitor source to capture system audio output
104+
let simple = match Simple::new(
105+
None, // Use default server
106+
"Meet-Scribe", // Application name
107+
Direction::Record, // Recording
108+
Some(&device), // Monitor source for system audio
109+
"Audio Capture", // Stream description
110+
&spec, // Sample spec
111+
None, // Use default channel map
112+
None, // Use default buffering attributes
113+
) {
114+
Ok(s) => s,
115+
Err(e) => {
116+
log::error!("Failed to create PulseAudio simple connection: {}", e);
117+
*is_capturing_clone.lock().unwrap() = false;
118+
return;
119+
}
120+
};
121+
122+
log::info!("PulseAudio capture initialized successfully");
123+
log::info!("Device: {}", device);
124+
log::info!(
125+
"Format: {} Hz, {} channels, 16-bit",
126+
spec.rate,
127+
spec.channels
128+
);
129+
130+
// Buffer for reading samples (1024 frames at a time)
131+
let buffer_size = 1024 * spec.channels as usize * 2; // 2 bytes per sample (16-bit)
132+
let mut read_buffer = vec![0u8; buffer_size];
133+
134+
// Capture loop
135+
while *is_capturing_clone.lock().unwrap() {
136+
// Read audio data from PulseAudio
137+
match simple.read(&mut read_buffer) {
138+
Ok(_) => {
139+
// Convert bytes to i16 samples
140+
let mut i16_samples = Vec::with_capacity(buffer_size / 2);
141+
for chunk in read_buffer.chunks_exact(2) {
142+
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
143+
i16_samples.push(sample);
144+
}
145+
146+
// Convert to f32 normalized format
147+
let f32_samples = Self::convert_samples(&i16_samples);
148+
149+
// Append to the shared buffer
150+
let mut buffer = audio_buffer_clone.lock().unwrap();
151+
buffer.extend(f32_samples);
152+
}
153+
Err(e) => {
154+
log::error!("Failed to read from PulseAudio: {}", e);
155+
break;
156+
}
157+
}
158+
159+
// Small sleep to prevent busy-waiting
160+
std::thread::sleep(Duration::from_millis(1));
161+
}
162+
163+
// Drain any remaining buffered data
164+
if let Err(e) = simple.drain() {
165+
log::warn!("Failed to drain PulseAudio buffer: {}", e);
166+
}
167+
168+
log::info!("PulseAudio capture thread stopped");
169+
});
170+
171+
self.capture_handle = Some(handle);
172+
173+
// Wait for format initialization to complete
174+
// Format is set to 44100 Hz, stereo, 16-bit in the background thread
175+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
176+
177+
// Update our format from the initialized format
178+
self.format = format_info.lock().unwrap().clone();
179+
180+
log::info!(
181+
"Audio capture started with format: {} Hz, {} channels, {} bits",
182+
self.format.sample_rate,
183+
self.format.channels,
184+
self.format.bits_per_sample
185+
);
186+
Ok(())
187+
}
188+
189+
async fn stop_capture(&mut self) -> Result<()> {
190+
{
191+
let mut is_capturing = self.is_capturing.lock().unwrap();
192+
if !*is_capturing {
193+
return Ok(());
194+
}
195+
*is_capturing = false;
196+
} // MutexGuard dropped here
197+
198+
// Wait for capture thread to finish
199+
if let Some(handle) = self.capture_handle.take() {
200+
handle.await.map_err(|e| {
201+
AppError::AudioCapture(format!("Failed to stop capture thread: {}", e))
202+
})?;
203+
}
204+
205+
log::info!("Audio capture stopped");
206+
Ok(())
207+
}
208+
209+
async fn get_audio_buffer(&mut self) -> Result<Option<AudioBuffer>> {
210+
let mut buffer = self.audio_buffer.lock().unwrap();
211+
if buffer.is_empty() {
212+
return Ok(None);
213+
}
214+
215+
let samples = buffer.drain(..).collect();
216+
Ok(Some(AudioBuffer {
217+
samples,
218+
format: self.format.clone(),
219+
}))
220+
}
221+
222+
fn is_capturing(&self) -> bool {
223+
*self.is_capturing.lock().unwrap()
224+
}
225+
226+
fn get_format(&self) -> AudioFormat {
227+
self.format.clone()
228+
}
229+
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::*;
234+
235+
#[test]
236+
fn test_new_pulseaudio_capture() {
237+
let capture = PulseAudioCapture::new();
238+
assert!(!capture.is_capturing());
239+
}
240+
241+
#[test]
242+
fn test_default_format() {
243+
let capture = PulseAudioCapture::new();
244+
let format = capture.get_format();
245+
// Before capture starts, format is the default placeholder
246+
// Actual format is set during start_capture() to: 44100 Hz, stereo, 16-bit
247+
assert_eq!(format.sample_rate, 16000); // Placeholder before capture
248+
assert_eq!(format.channels, 1); // Placeholder before capture
249+
assert_eq!(format.bits_per_sample, 16); // Placeholder before capture
250+
}
251+
252+
#[tokio::test]
253+
async fn test_list_devices() {
254+
let capture = PulseAudioCapture::new();
255+
let devices = capture.list_devices().await.unwrap();
256+
assert!(!devices.is_empty());
257+
}
258+
259+
#[test]
260+
fn test_convert_samples() {
261+
let samples = vec![0i16, 16384, -16384, 32767, -32768];
262+
let converted = PulseAudioCapture::convert_samples(&samples);
263+
assert_eq!(converted.len(), 5);
264+
assert!((converted[0] - 0.0).abs() < 0.001);
265+
assert!((converted[1] - 0.5).abs() < 0.001);
266+
assert!((converted[2] + 0.5).abs() < 0.001);
267+
}
268+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//! Audio capture adapters
2+
//!
3+
//! Platform-specific implementations for audio capture
4+
5+
#[cfg(target_os = "windows")]
6+
pub mod windows;
7+
8+
#[cfg(target_os = "linux")]
9+
pub mod linux;
10+
11+
#[cfg(target_os = "windows")]
12+
pub use windows::WasapiAudioCapture;
13+
14+
#[cfg(target_os = "linux")]
15+
pub use linux::PulseAudioCapture;

0 commit comments

Comments
 (0)