mirror of
https://github.com/Nighthawk42/fulloch.git
synced 2026-08-30 10:32:26 +00:00
148 lines
4.9 KiB
Python
148 lines
4.9 KiB
Python
"""
|
|
Audio capture and silence detection module.
|
|
|
|
Handles microphone input, silence detection via RMS threshold,
|
|
and audio buffering for the speech recognition pipeline.
|
|
"""
|
|
|
|
import logging
|
|
import queue
|
|
import time
|
|
from collections import deque
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
import sounddevice as sd
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Audio configuration
|
|
SAMPLE_RATE = 16000
|
|
CHUNK_DURATION_MS = 200
|
|
SILENCE_DURATION_MS = 1000
|
|
MIN_UTTERANCE_MS = 1500
|
|
MAX_UTTERANCE_MS = 10000
|
|
SILENCE_THRESHOLD = 0.001
|
|
|
|
# Derived values
|
|
frames_per_chunk = int(SAMPLE_RATE * CHUNK_DURATION_MS / 1000)
|
|
silence_chunks_needed = max(1, int(SILENCE_DURATION_MS / CHUNK_DURATION_MS))
|
|
min_utterance_samples = int(SAMPLE_RATE * MIN_UTTERANCE_MS / 1000)
|
|
max_utterance_samples = int(SAMPLE_RATE * MAX_UTTERANCE_MS / 1000)
|
|
|
|
|
|
def is_silent(chunk: np.ndarray, threshold: float = SILENCE_THRESHOLD) -> bool:
|
|
"""
|
|
Check if an audio chunk is silent based on RMS energy.
|
|
|
|
Args:
|
|
chunk: Audio samples as numpy array
|
|
threshold: RMS threshold below which audio is considered silent
|
|
|
|
Returns:
|
|
True if the chunk is silent, False otherwise
|
|
"""
|
|
if chunk.size == 0:
|
|
return True
|
|
rms = np.sqrt(np.mean(chunk ** 2))
|
|
return rms < threshold
|
|
|
|
|
|
class AudioCapture:
|
|
"""
|
|
Manages audio capture from microphone with silence detection.
|
|
|
|
Attributes:
|
|
audio_buffer: Deque holding audio chunks
|
|
audio_queue: Queue for complete utterances ready for transcription
|
|
running: Flag to control capture loop
|
|
transcribing: Flag to control whether to process audio
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
sample_rate: int = SAMPLE_RATE,
|
|
chunk_duration_ms: int = CHUNK_DURATION_MS,
|
|
silence_duration_ms: int = SILENCE_DURATION_MS,
|
|
min_utterance_ms: int = MIN_UTTERANCE_MS,
|
|
max_utterance_ms: int = MAX_UTTERANCE_MS,
|
|
silence_threshold: float = SILENCE_THRESHOLD,
|
|
):
|
|
self.sample_rate = sample_rate
|
|
self.chunk_duration_ms = chunk_duration_ms
|
|
self.silence_threshold = silence_threshold
|
|
|
|
# Derived values
|
|
self.frames_per_chunk = int(sample_rate * chunk_duration_ms / 1000)
|
|
self.silence_chunks_needed = max(1, int(silence_duration_ms / chunk_duration_ms))
|
|
self.min_utterance_samples = int(sample_rate * min_utterance_ms / 1000)
|
|
self.max_utterance_samples = int(sample_rate * max_utterance_ms / 1000)
|
|
|
|
# State
|
|
self.audio_buffer: deque = deque()
|
|
self.audio_queue: "queue.Queue[Optional[np.ndarray]]" = queue.Queue()
|
|
self.running = True
|
|
self.transcribing = True
|
|
|
|
def _audio_callback(self, indata, frames, time_info, status):
|
|
"""Callback for sounddevice InputStream. Must be fast — no resampling here."""
|
|
if status:
|
|
logger.info(status)
|
|
self.audio_buffer.append(indata[:, 0].copy())
|
|
|
|
def recorder_thread(self):
|
|
"""
|
|
Main recording thread that captures audio and detects utterances.
|
|
|
|
Runs continuously, accumulating audio chunks and detecting
|
|
end-of-utterance based on silence duration.
|
|
"""
|
|
logger.info("Starting microphone stream...")
|
|
silence_counter = 0
|
|
|
|
with sd.InputStream(
|
|
channels=1,
|
|
samplerate=self.sample_rate,
|
|
dtype="float32",
|
|
blocksize=self.frames_per_chunk,
|
|
latency="low",
|
|
callback=self._audio_callback,
|
|
):
|
|
while self.running:
|
|
time.sleep(self.chunk_duration_ms / 1000.0)
|
|
|
|
if self.transcribing:
|
|
if not self.audio_buffer:
|
|
continue
|
|
|
|
# Check silence on the most recent chunk
|
|
last_chunk = self.audio_buffer[-1]
|
|
if is_silent(last_chunk, self.silence_threshold):
|
|
silence_counter += 1
|
|
else:
|
|
silence_counter = 0
|
|
|
|
# Check if we've hit silence threshold or max length
|
|
buffer_samples = sum(c.size for c in self.audio_buffer)
|
|
if (silence_counter < self.silence_chunks_needed and
|
|
buffer_samples <= self.max_utterance_samples):
|
|
continue
|
|
|
|
# End of utterance - concatenate buffer
|
|
buf = np.concatenate(list(self.audio_buffer), axis=0)
|
|
|
|
# Only enqueue if minimum length met
|
|
if buf.size >= self.min_utterance_samples:
|
|
self.audio_queue.put(buf)
|
|
secs = buf.size / self.sample_rate
|
|
logger.debug(f"Enqueued {secs:.2f}s for transcription")
|
|
|
|
# Reset state
|
|
self.audio_buffer.clear()
|
|
silence_counter = 0
|
|
|
|
def stop(self):
|
|
"""Signal the recorder to stop and inject poison pill."""
|
|
self.running = False
|
|
self.audio_queue.put(None)
|