audio_buffer.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. """
  2. Real-time audio buffer management for wakeword detection.
  3. This module provides efficient audio buffering and streaming capabilities
  4. optimized for real-time wakeword detection with minimal latency.
  5. """
  6. import threading
  7. import time
  8. from collections import deque
  9. from typing import Optional, Callable, List, Tuple, Union
  10. from dataclasses import dataclass
  11. import numpy as np
  12. import torch
  13. @dataclass
  14. class AudioChunk:
  15. """Represents a chunk of audio data with metadata."""
  16. data: Union[np.ndarray, torch.Tensor]
  17. timestamp: float
  18. sample_rate: int
  19. channels: int = 1
  20. chunk_id: int = 0
  21. def __post_init__(self):
  22. """Validate audio chunk parameters."""
  23. if isinstance(self.data, torch.Tensor):
  24. self.data = self.data.detach().cpu().numpy()
  25. self.data = np.asarray(self.data, dtype=np.float32)
  26. if self.data.ndim == 1:
  27. # Mono audio
  28. self.channels = 1
  29. elif self.data.ndim == 2:
  30. # Multi-channel audio
  31. self.channels = self.data.shape[0] if self.data.shape[0] < self.data.shape[1] else self.data.shape[1]
  32. else:
  33. raise ValueError(f"Invalid audio data shape: {self.data.shape}")
  34. @property
  35. def duration(self) -> float:
  36. """Get duration of audio chunk in seconds."""
  37. return len(self.data) / self.sample_rate
  38. @property
  39. def samples(self) -> int:
  40. """Get number of samples in the chunk."""
  41. return len(self.data)
  42. def to_tensor(self, device: Optional[torch.device] = None) -> torch.Tensor:
  43. """Convert audio data to PyTorch tensor."""
  44. tensor = torch.from_numpy(self.data).float()
  45. if device is not None:
  46. tensor = tensor.to(device)
  47. return tensor
  48. class AudioBuffer:
  49. """
  50. Base class for audio buffering with configurable size and overlap.
  51. """
  52. def __init__(self, buffer_duration: float, sample_rate: int,
  53. overlap_ratio: float = 0.5, max_chunks: int = 1000):
  54. """
  55. Initialize audio buffer.
  56. Args:
  57. buffer_duration: Duration of buffer in seconds
  58. sample_rate: Audio sample rate
  59. overlap_ratio: Overlap ratio between chunks (0.0 to 1.0)
  60. max_chunks: Maximum number of chunks to keep in memory
  61. """
  62. self.buffer_duration = buffer_duration
  63. self.sample_rate = sample_rate
  64. self.overlap_ratio = overlap_ratio
  65. self.max_chunks = max_chunks
  66. self.buffer_samples = int(buffer_duration * sample_rate)
  67. self.overlap_samples = int(self.buffer_samples * overlap_ratio)
  68. self.stride_samples = self.buffer_samples - self.overlap_samples
  69. self._buffer = np.zeros(self.buffer_samples, dtype=np.float32)
  70. self._current_position = 0
  71. self._total_samples_written = 0
  72. self._chunk_counter = 0
  73. self._lock = threading.Lock()
  74. def add_audio(self, audio_data: Union[np.ndarray, torch.Tensor]) -> List[AudioChunk]:
  75. """
  76. Add audio data to buffer and return complete chunks.
  77. Args:
  78. audio_data: Audio samples to add
  79. Returns:
  80. List of complete audio chunks ready for processing
  81. """
  82. if isinstance(audio_data, torch.Tensor):
  83. audio_data = audio_data.detach().cpu().numpy()
  84. audio_data = np.asarray(audio_data, dtype=np.float32)
  85. if audio_data.ndim > 1:
  86. # Convert to mono if multi-channel
  87. audio_data = np.mean(audio_data, axis=0)
  88. chunks = []
  89. with self._lock:
  90. data_idx = 0
  91. while data_idx < len(audio_data):
  92. # Calculate how much we can write to current buffer position
  93. remaining_buffer = self.buffer_samples - self._current_position
  94. remaining_data = len(audio_data) - data_idx
  95. samples_to_write = min(remaining_buffer, remaining_data)
  96. # Write data to buffer
  97. end_pos = self._current_position + samples_to_write
  98. self._buffer[self._current_position:end_pos] = audio_data[data_idx:data_idx + samples_to_write]
  99. self._current_position += samples_to_write
  100. self._total_samples_written += samples_to_write
  101. data_idx += samples_to_write
  102. # Check if buffer is full
  103. if self._current_position >= self.buffer_samples:
  104. # Create chunk from current buffer
  105. chunk_data = self._buffer.copy()
  106. chunk = AudioChunk(
  107. data=chunk_data,
  108. timestamp=time.time(),
  109. sample_rate=self.sample_rate,
  110. chunk_id=self._chunk_counter
  111. )
  112. chunks.append(chunk)
  113. self._chunk_counter += 1
  114. # Shift buffer for overlap
  115. if self.overlap_samples > 0:
  116. self._buffer[:self.overlap_samples] = self._buffer[-self.overlap_samples:]
  117. self._current_position = self.overlap_samples
  118. else:
  119. self._current_position = 0
  120. return chunks
  121. def get_current_buffer(self) -> Optional[AudioChunk]:
  122. """
  123. Get current buffer state (even if not full).
  124. Returns:
  125. Current audio chunk or None if buffer is empty
  126. """
  127. with self._lock:
  128. if self._current_position == 0:
  129. return None
  130. # Pad the current buffer to full size
  131. current_data = np.zeros(self.buffer_samples, dtype=np.float32)
  132. current_data[:self._current_position] = self._buffer[:self._current_position]
  133. return AudioChunk(
  134. data=current_data,
  135. timestamp=time.time(),
  136. sample_rate=self.sample_rate,
  137. chunk_id=self._chunk_counter
  138. )
  139. def clear(self):
  140. """Clear the buffer."""
  141. with self._lock:
  142. self._buffer.fill(0)
  143. self._current_position = 0
  144. self._total_samples_written = 0
  145. def get_stats(self) -> dict:
  146. """Get buffer statistics."""
  147. with self._lock:
  148. return {
  149. 'buffer_duration': self.buffer_duration,
  150. 'buffer_samples': self.buffer_samples,
  151. 'current_position': self._current_position,
  152. 'total_samples_written': self._total_samples_written,
  153. 'total_chunks_created': self._chunk_counter,
  154. 'overlap_ratio': self.overlap_ratio,
  155. 'buffer_fill_ratio': self._current_position / self.buffer_samples
  156. }
  157. class CircularAudioBuffer:
  158. """
  159. Circular audio buffer for continuous streaming with automatic chunk extraction.
  160. This buffer continuously accepts audio data and provides overlapping chunks
  161. for real-time processing.
  162. """
  163. def __init__(self, buffer_duration: float, chunk_duration: float,
  164. sample_rate: int, overlap_ratio: float = 0.5,
  165. chunk_callback: Optional[Callable[[AudioChunk], None]] = None):
  166. """
  167. Initialize circular audio buffer.
  168. Args:
  169. buffer_duration: Total buffer duration in seconds
  170. chunk_duration: Duration of each processing chunk in seconds
  171. sample_rate: Audio sample rate
  172. overlap_ratio: Overlap ratio between chunks
  173. chunk_callback: Optional callback for new chunks
  174. """
  175. self.buffer_duration = buffer_duration
  176. self.chunk_duration = chunk_duration
  177. self.sample_rate = sample_rate
  178. self.overlap_ratio = overlap_ratio
  179. self.chunk_callback = chunk_callback
  180. self.buffer_samples = int(buffer_duration * sample_rate)
  181. self.chunk_samples = int(chunk_duration * sample_rate)
  182. self.overlap_samples = int(self.chunk_samples * overlap_ratio)
  183. self.stride_samples = self.chunk_samples - self.overlap_samples
  184. self._buffer = np.zeros(self.buffer_samples, dtype=np.float32)
  185. self._write_pos = 0
  186. self._last_chunk_pos = 0
  187. self._chunk_counter = 0
  188. self._lock = threading.Lock()
  189. self._stats = {
  190. 'total_samples_written': 0,
  191. 'total_chunks_extracted': 0,
  192. 'buffer_overruns': 0,
  193. 'last_chunk_time': 0.0
  194. }
  195. def write_audio(self, audio_data: Union[np.ndarray, torch.Tensor]):
  196. """
  197. Write audio data to the circular buffer.
  198. Args:
  199. audio_data: Audio samples to write
  200. """
  201. if isinstance(audio_data, torch.Tensor):
  202. audio_data = audio_data.detach().cpu().numpy()
  203. audio_data = np.asarray(audio_data, dtype=np.float32)
  204. if audio_data.ndim > 1:
  205. audio_data = np.mean(audio_data, axis=0)
  206. with self._lock:
  207. data_len = len(audio_data)
  208. # Check for buffer overrun
  209. if data_len > self.buffer_samples:
  210. self._stats['buffer_overruns'] += 1
  211. # Take only the last part that fits
  212. audio_data = audio_data[-self.buffer_samples:]
  213. data_len = len(audio_data)
  214. # Write data to circular buffer
  215. if self._write_pos + data_len <= self.buffer_samples:
  216. # Simple case: no wrap-around
  217. self._buffer[self._write_pos:self._write_pos + data_len] = audio_data
  218. else:
  219. # Wrap-around case
  220. first_part = self.buffer_samples - self._write_pos
  221. self._buffer[self._write_pos:] = audio_data[:first_part]
  222. self._buffer[:data_len - first_part] = audio_data[first_part:]
  223. self._write_pos = (self._write_pos + data_len) % self.buffer_samples
  224. self._stats['total_samples_written'] += data_len
  225. # Check if we can extract new chunks
  226. self._extract_chunks()
  227. def _extract_chunks(self):
  228. """Extract ready chunks from the buffer."""
  229. while True:
  230. # Calculate available samples since last chunk
  231. if self._write_pos >= self._last_chunk_pos:
  232. available_samples = self._write_pos - self._last_chunk_pos
  233. else:
  234. available_samples = (self.buffer_samples - self._last_chunk_pos) + self._write_pos
  235. # Check if we have enough samples for a new chunk
  236. if available_samples < self.stride_samples:
  237. break
  238. # Extract chunk
  239. chunk_data = np.zeros(self.chunk_samples, dtype=np.float32)
  240. if self._last_chunk_pos + self.chunk_samples <= self.buffer_samples:
  241. # No wrap-around
  242. chunk_data = self._buffer[self._last_chunk_pos:self._last_chunk_pos + self.chunk_samples].copy()
  243. else:
  244. # Wrap-around
  245. first_part = self.buffer_samples - self._last_chunk_pos
  246. chunk_data[:first_part] = self._buffer[self._last_chunk_pos:]
  247. chunk_data[first_part:] = self._buffer[:self.chunk_samples - first_part]
  248. # Create chunk object
  249. chunk = AudioChunk(
  250. data=chunk_data,
  251. timestamp=time.time(),
  252. sample_rate=self.sample_rate,
  253. chunk_id=self._chunk_counter
  254. )
  255. self._chunk_counter += 1
  256. self._stats['total_chunks_extracted'] += 1
  257. self._stats['last_chunk_time'] = chunk.timestamp
  258. # Update position for next chunk
  259. self._last_chunk_pos = (self._last_chunk_pos + self.stride_samples) % self.buffer_samples
  260. # Call callback if provided
  261. if self.chunk_callback:
  262. try:
  263. self.chunk_callback(chunk)
  264. except Exception as e:
  265. # Don't let callback errors break the buffer
  266. print(f"Error in chunk callback: {e}")
  267. def get_latest_chunk(self) -> Optional[AudioChunk]:
  268. """
  269. Get the latest available chunk without affecting the buffer state.
  270. Returns:
  271. Latest audio chunk or None if not enough data
  272. """
  273. with self._lock:
  274. if self._write_pos >= self._last_chunk_pos:
  275. available_samples = self._write_pos - self._last_chunk_pos
  276. else:
  277. available_samples = (self.buffer_samples - self._last_chunk_pos) + self._write_pos
  278. if available_samples < self.chunk_samples:
  279. return None
  280. # Extract chunk without updating position
  281. chunk_data = np.zeros(self.chunk_samples, dtype=np.float32)
  282. # Get the most recent chunk_samples
  283. start_pos = (self._write_pos - self.chunk_samples) % self.buffer_samples
  284. if start_pos + self.chunk_samples <= self.buffer_samples:
  285. chunk_data = self._buffer[start_pos:start_pos + self.chunk_samples].copy()
  286. else:
  287. first_part = self.buffer_samples - start_pos
  288. chunk_data[:first_part] = self._buffer[start_pos:]
  289. chunk_data[first_part:] = self._buffer[:self.chunk_samples - first_part]
  290. return AudioChunk(
  291. data=chunk_data,
  292. timestamp=time.time(),
  293. sample_rate=self.sample_rate,
  294. chunk_id=self._chunk_counter
  295. )
  296. def clear(self):
  297. """Clear the buffer."""
  298. with self._lock:
  299. self._buffer.fill(0)
  300. self._write_pos = 0
  301. self._last_chunk_pos = 0
  302. self._chunk_counter = 0
  303. def get_stats(self) -> dict:
  304. """Get buffer statistics."""
  305. with self._lock:
  306. stats = self._stats.copy()
  307. stats.update({
  308. 'buffer_duration': self.buffer_duration,
  309. 'chunk_duration': self.chunk_duration,
  310. 'buffer_samples': self.buffer_samples,
  311. 'chunk_samples': self.chunk_samples,
  312. 'overlap_ratio': self.overlap_ratio,
  313. 'write_position': self._write_pos,
  314. 'last_chunk_position': self._last_chunk_pos,
  315. 'buffer_utilization': (abs(self._write_pos - self._last_chunk_pos) / self.buffer_samples)
  316. })
  317. return stats
  318. class StreamingAudioBuffer:
  319. """
  320. High-performance streaming audio buffer for real-time wakeword detection.
  321. Optimized for minimal latency and efficient memory usage.
  322. """
  323. def __init__(self, chunk_size: int, sample_rate: int,
  324. max_chunks_in_memory: int = 10,
  325. chunk_processor: Optional[Callable[[AudioChunk], None]] = None):
  326. """
  327. Initialize streaming audio buffer.
  328. Args:
  329. chunk_size: Size of each audio chunk in samples
  330. sample_rate: Audio sample rate
  331. max_chunks_in_memory: Maximum chunks to keep in memory
  332. chunk_processor: Optional processor for chunks
  333. """
  334. self.chunk_size = chunk_size
  335. self.sample_rate = sample_rate
  336. self.max_chunks_in_memory = max_chunks_in_memory
  337. self.chunk_processor = chunk_processor
  338. self._chunks = deque(maxlen=max_chunks_in_memory)
  339. self._current_chunk = np.zeros(chunk_size, dtype=np.float32)
  340. self._current_chunk_pos = 0
  341. self._chunk_counter = 0
  342. self._lock = threading.Lock()
  343. # Performance monitoring
  344. self._stats = {
  345. 'chunks_processed': 0,
  346. 'samples_processed': 0,
  347. 'processing_time_total': 0.0,
  348. 'avg_processing_time': 0.0,
  349. 'max_processing_time': 0.0,
  350. 'min_processing_time': float('inf'),
  351. 'chunks_dropped': 0
  352. }
  353. def process_audio_stream(self, audio_data: Union[np.ndarray, torch.Tensor]) -> List[AudioChunk]:
  354. """
  355. Process streaming audio data.
  356. Args:
  357. audio_data: Input audio stream
  358. Returns:
  359. List of completed chunks
  360. """
  361. if isinstance(audio_data, torch.Tensor):
  362. audio_data = audio_data.detach().cpu().numpy()
  363. audio_data = np.asarray(audio_data, dtype=np.float32)
  364. if audio_data.ndim > 1:
  365. audio_data = np.mean(audio_data, axis=0)
  366. completed_chunks = []
  367. with self._lock:
  368. data_idx = 0
  369. while data_idx < len(audio_data):
  370. # Calculate how much we can write to current chunk
  371. remaining_chunk = self.chunk_size - self._current_chunk_pos
  372. remaining_data = len(audio_data) - data_idx
  373. samples_to_write = min(remaining_chunk, remaining_data)
  374. # Write to current chunk
  375. end_pos = self._current_chunk_pos + samples_to_write
  376. self._current_chunk[self._current_chunk_pos:end_pos] = audio_data[data_idx:data_idx + samples_to_write]
  377. self._current_chunk_pos += samples_to_write
  378. data_idx += samples_to_write
  379. # Check if chunk is complete
  380. if self._current_chunk_pos >= self.chunk_size:
  381. # Create completed chunk
  382. chunk = AudioChunk(
  383. data=self._current_chunk.copy(),
  384. timestamp=time.time(),
  385. sample_rate=self.sample_rate,
  386. chunk_id=self._chunk_counter
  387. )
  388. completed_chunks.append(chunk)
  389. self._chunk_counter += 1
  390. # Store chunk in memory if there's space
  391. if len(self._chunks) < self.max_chunks_in_memory:
  392. self._chunks.append(chunk)
  393. else:
  394. self._stats['chunks_dropped'] += 1
  395. # Process chunk if processor is available
  396. if self.chunk_processor:
  397. start_time = time.time()
  398. try:
  399. self.chunk_processor(chunk)
  400. processing_time = (time.time() - start_time) * 1000 # ms
  401. self._update_processing_stats(processing_time)
  402. except Exception as e:
  403. print(f"Error in chunk processor: {e}")
  404. # Reset current chunk
  405. self._current_chunk.fill(0)
  406. self._current_chunk_pos = 0
  407. return completed_chunks
  408. def _update_processing_stats(self, processing_time_ms: float):
  409. """Update processing performance statistics."""
  410. self._stats['chunks_processed'] += 1
  411. self._stats['processing_time_total'] += processing_time_ms
  412. self._stats['avg_processing_time'] = (
  413. self._stats['processing_time_total'] / self._stats['chunks_processed']
  414. )
  415. self._stats['max_processing_time'] = max(self._stats['max_processing_time'], processing_time_ms)
  416. self._stats['min_processing_time'] = min(self._stats['min_processing_time'], processing_time_ms)
  417. def get_recent_chunks(self, n: int = 5) -> List[AudioChunk]:
  418. """Get the most recent n chunks."""
  419. with self._lock:
  420. return list(self._chunks)[-n:]
  421. def get_stats(self) -> dict:
  422. """Get buffer and processing statistics."""
  423. with self._lock:
  424. stats = self._stats.copy()
  425. stats.update({
  426. 'chunk_size': self.chunk_size,
  427. 'sample_rate': self.sample_rate,
  428. 'current_chunk_fill': self._current_chunk_pos / self.chunk_size,
  429. 'chunks_in_memory': len(self._chunks),
  430. 'max_chunks_in_memory': self.max_chunks_in_memory
  431. })
  432. return stats
  433. def clear(self):
  434. """Clear all buffers and reset state."""
  435. with self._lock:
  436. self._chunks.clear()
  437. self._current_chunk.fill(0)
  438. self._current_chunk_pos = 0
  439. self._chunk_counter = 0
  440. def create_audio_buffer(buffer_type: str = "circular", **kwargs) -> Union[AudioBuffer, CircularAudioBuffer, StreamingAudioBuffer]:
  441. """
  442. Factory function to create audio buffers.
  443. Args:
  444. buffer_type: Type of buffer ("basic", "circular", "streaming")
  445. **kwargs: Arguments for buffer initialization
  446. Returns:
  447. Audio buffer instance
  448. """
  449. if buffer_type == "basic":
  450. return AudioBuffer(**kwargs)
  451. elif buffer_type == "circular":
  452. return CircularAudioBuffer(**kwargs)
  453. elif buffer_type == "streaming":
  454. return StreamingAudioBuffer(**kwargs)
  455. else:
  456. raise ValueError(f"Unknown buffer type: {buffer_type}")
  457. def estimate_buffer_memory_usage(buffer_duration: float, sample_rate: int,
  458. max_chunks: int = 10) -> dict:
  459. """
  460. Estimate memory usage for audio buffers.
  461. Args:
  462. buffer_duration: Buffer duration in seconds
  463. sample_rate: Audio sample rate
  464. max_chunks: Maximum chunks in memory
  465. Returns:
  466. Memory usage estimates in bytes
  467. """
  468. samples = int(buffer_duration * sample_rate)
  469. # Assuming 32-bit float samples
  470. buffer_memory = samples * 4 # bytes
  471. chunk_memory = buffer_memory * max_chunks
  472. return {
  473. 'buffer_memory_bytes': buffer_memory,
  474. 'buffer_memory_mb': buffer_memory / (1024 * 1024),
  475. 'chunk_memory_bytes': chunk_memory,
  476. 'chunk_memory_mb': chunk_memory / (1024 * 1024),
  477. 'total_memory_bytes': buffer_memory + chunk_memory,
  478. 'total_memory_mb': (buffer_memory + chunk_memory) / (1024 * 1024)
  479. }