mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-08 22:53:01 +03:00
* remove unreachable FileNotFoundError catches around frame_manager.get * clear the end time cache entry when a segment is moved The successful move returned before the end_time_cache pop at the bottom of move_segment, so every kept segment left its entry, keyframes list included, in the cache for the life of the process. Removing the cache file through drop_segment clears the entry with it. * drop the cache file when the recording destination already exists When the destination mp4 already existed, move_segment returned without removing the cache file, so the next pass probed and resolved the same segment again, forever. The leftover file is now dropped along with its end time cache entry, the same as the exception path. * pace restarts of exited record and audio ffmpeg processes The watchdog loop runs every second, and an exited record or audio ffmpeg was restarted on every pass with no backoff. A camera that refuses connections, such as one rebooting or rejecting credentials, got a reconnect attempt and a dump of ffmpeg's logs every second until it came back. The restart is now gated on can_restart like the stale segment and detect paths. * don't send a stray newline in the certsync tls probe The probe piped echo into openssl s_client, which sent the newline to nginx as a request after the handshake, so nginx logged an empty "" 400 line every 60 seconds on any install with TLS enabled. Reading stdin from /dev/null closes the connection right after the handshake. The fingerprint and the failure logging are unchanged. * require a record expire_interval of at least 1 An expire_interval of 0 or less made the cleanup loop iterate over an empty range, so the cleanup thread exited on startup and expired recordings, temp clips and previews were never removed. * remove the transcription temp wav when whisper raises The temp wav was only removed after a successful transcribe call, so every failed transcription left a file in the cache directory. * don't kill the ipc responder when a described event is gone handle_update_event_description used Event.get, so a GenAI description that finished after its event was deleted raised DoesNotExist on the inter-process REP thread. That thread died with a reply still owed, and every process waiting on the requestor hung until restart. A missing event is now skipped. * size the birdseye frame from the snapped canvas shape The birdseye writer sizes its shared memory with get_canvas_shape, which snaps an unsupported aspect ratio such as 1920x1200 to a supported one, but the tracked object processor read it with the raw config width and height. The sizes never matched, so get returned None on every call and /api/birdseye/latest.jpg returned a 500, which broke the Home Assistant birdseye entity's still image. The reader now uses the same canvas shape, and a missing frame during startup returns an error response instead of raising in cvtColor. * remove dead disconnect timer from the mse player The effect that scheduled a delayed disconnect when playback was disabled never ran its body. The connect effect's cleanup already calls onDisconnect, which clears wsRef before the later effect checks it. * remove partial output when a recording move fails A remux that failed partway, such as on a full disk, left a partial mp4 at the destination. The next pass saw the destination already existed and dropped the cache file instead of retrying, so the segment was never saved. The failed output is now removed so the next pass retries the move. Tests cover the success, existing destination and failed move paths of move_segment.
268 lines
9.4 KiB
Python
268 lines
9.4 KiB
Python
"""Handle post-processing for audio transcription."""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from peewee import DoesNotExist
|
|
|
|
from frigate.comms.inter_process import InterProcessRequestor
|
|
from frigate.config import FrigateConfig
|
|
from frigate.config.classification import AudioTranscriptionModelEnum
|
|
from frigate.const import (
|
|
CACHE_DIR,
|
|
MODEL_CACHE_DIR,
|
|
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
|
UPDATE_EVENT_DESCRIPTION,
|
|
)
|
|
from frigate.data_processing.types import PostProcessDataEnum
|
|
from frigate.embeddings.embeddings import Embeddings
|
|
from frigate.genai.manager import GenAIClientManager
|
|
from frigate.types import TrackedObjectUpdateTypesEnum
|
|
from frigate.util.audio import (
|
|
clean_transcript,
|
|
get_audio_from_recording,
|
|
resolve_language,
|
|
)
|
|
|
|
from ..types import DataProcessorMetrics
|
|
from .api import PostProcessorApi
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|
def __init__(
|
|
self,
|
|
config: FrigateConfig,
|
|
requestor: InterProcessRequestor,
|
|
embeddings: Embeddings,
|
|
metrics: DataProcessorMetrics,
|
|
genai_manager: GenAIClientManager | None = None,
|
|
):
|
|
super().__init__(config, metrics, None)
|
|
self.config = config
|
|
self.requestor = requestor
|
|
self.embeddings = embeddings
|
|
self.genai_manager = genai_manager
|
|
self.recognizer = None
|
|
self.transcription_lock = threading.Lock()
|
|
self.transcription_thread: threading.Thread | None = None
|
|
self.transcription_running = False
|
|
self._use_genai = not isinstance(
|
|
config.audio_transcription.model, AudioTranscriptionModelEnum
|
|
)
|
|
|
|
if self._use_genai:
|
|
# never build the local recognizer on the GenAI path; WhisperModel
|
|
# downloads several hundred MB on first use
|
|
return
|
|
|
|
# faster-whisper handles model downloading automatically
|
|
self.model_path = os.path.join(MODEL_CACHE_DIR, "whisper")
|
|
os.makedirs(self.model_path, exist_ok=True)
|
|
|
|
self.__build_recognizer()
|
|
|
|
def __build_recognizer(self) -> None:
|
|
try:
|
|
# Import dynamically to avoid crashes on systems without AVX support
|
|
from faster_whisper import WhisperModel
|
|
|
|
self.recognizer = WhisperModel(
|
|
model_size_or_path="small",
|
|
device="cuda"
|
|
if self.config.audio_transcription.device == "GPU"
|
|
else "cpu",
|
|
download_root=self.model_path,
|
|
local_files_only=False, # Allow downloading if not cached
|
|
compute_type="int8",
|
|
)
|
|
logger.debug("Audio transcription (recordings) initialized")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize recordings audio transcription: {e}")
|
|
self.recognizer = None
|
|
|
|
def process_data(
|
|
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
|
) -> None:
|
|
"""Transcribe audio from a recording.
|
|
|
|
Args:
|
|
data (dict): Contains data about the input (event_id, camera, etc.).
|
|
data_type (enum): Describes the data being processed (recording or tracked_object).
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
event_id = data["event_id"]
|
|
camera_name = data["camera"]
|
|
camera_config = self.config.cameras.get(camera_name)
|
|
|
|
if camera_config is None:
|
|
return
|
|
|
|
if data_type == PostProcessDataEnum.recording:
|
|
start_ts = data["frame_time"]
|
|
recordings_available_through = data["recordings_available"]
|
|
end_ts = min(recordings_available_through, start_ts + 60) # Default 60s
|
|
|
|
elif data_type == PostProcessDataEnum.tracked_object:
|
|
obj_data = data["event"]["data"]
|
|
obj_data["id"] = data["event"]["id"]
|
|
obj_data["camera"] = data["event"]["camera"]
|
|
start_ts = data["event"]["start_time"]
|
|
end_ts = data["event"].get(
|
|
"end_time", start_ts + 60
|
|
) # Use end_time if available
|
|
|
|
else:
|
|
logger.error("No data type passed to audio transcription post-processing")
|
|
return
|
|
|
|
try:
|
|
audio_data = get_audio_from_recording(
|
|
camera_config.ffmpeg,
|
|
camera_name,
|
|
start_ts,
|
|
end_ts,
|
|
sample_rate=16000,
|
|
)
|
|
|
|
if not audio_data:
|
|
logger.debug(f"No audio data extracted for {event_id}")
|
|
return
|
|
|
|
transcription = self.__transcribe_audio(audio_data)
|
|
if not transcription:
|
|
logger.debug("No transcription generated from audio")
|
|
return
|
|
|
|
logger.debug(f"Transcribed audio for {event_id}: '{transcription}'")
|
|
|
|
self.requestor.send_data(
|
|
UPDATE_EVENT_DESCRIPTION,
|
|
{
|
|
"type": TrackedObjectUpdateTypesEnum.description,
|
|
"id": event_id,
|
|
"description": transcription,
|
|
"camera": camera_name,
|
|
},
|
|
)
|
|
|
|
# Embed the description if semantic search is enabled
|
|
if self.config.semantic_search.enabled:
|
|
self.embeddings.embed_description(event_id, transcription)
|
|
|
|
except DoesNotExist:
|
|
logger.debug("No recording found for audio transcription post-processing")
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"Error in audio transcription post-processing: {e}")
|
|
|
|
def __transcribe_audio(self, audio_data: bytes) -> str | None:
|
|
"""Transcribe WAV audio data with the configured backend."""
|
|
if self._use_genai:
|
|
return self.__transcribe_audio_genai(audio_data)
|
|
|
|
return self.__transcribe_audio_whisper(audio_data)
|
|
|
|
def __transcribe_audio_genai(self, audio_data: bytes) -> str | None:
|
|
"""Hand the WAV bytes to the GenAI provider holding the transcribe role."""
|
|
client = self.genai_manager.transcribe_client if self.genai_manager else None
|
|
|
|
if not client:
|
|
logger.error(
|
|
"audio_transcription.model is '%s' (GenAI provider) but no transcribe "
|
|
"client is configured. Ensure the GenAI provider has 'transcribe' in its roles",
|
|
self.config.audio_transcription.model,
|
|
)
|
|
return None
|
|
|
|
text = client.transcribe(
|
|
audio_data,
|
|
language=resolve_language(self.config.audio_transcription.language),
|
|
)
|
|
return clean_transcript(text) or None
|
|
|
|
def __transcribe_audio_whisper(self, audio_data: bytes) -> str | None:
|
|
"""Transcribe WAV audio data using faster-whisper."""
|
|
if not self.recognizer:
|
|
logger.debug("Recognizer not initialized")
|
|
return None
|
|
|
|
# Save audio data to a temporary wav (faster-whisper expects a file)
|
|
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") # type: ignore[unreachable]
|
|
|
|
try:
|
|
with open(temp_wav, "wb") as f:
|
|
f.write(audio_data)
|
|
|
|
segments, info = self.recognizer.transcribe(
|
|
temp_wav,
|
|
language=resolve_language(self.config.audio_transcription.language),
|
|
beam_size=5,
|
|
)
|
|
|
|
# Combine all segment texts
|
|
text = " ".join(segment.text.strip() for segment in segments)
|
|
if not text:
|
|
return None
|
|
|
|
logger.debug(
|
|
"Detected language '%s' with probability %f",
|
|
info.language,
|
|
info.language_probability,
|
|
)
|
|
|
|
return text
|
|
except Exception as e:
|
|
logger.error(f"Error transcribing audio: {e}")
|
|
return None
|
|
finally:
|
|
Path(temp_wav).unlink(missing_ok=True)
|
|
|
|
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
|
|
"""Wrapper to run transcription and reset running flag when done."""
|
|
try:
|
|
self.process_data(
|
|
{
|
|
"event_id": event["id"],
|
|
"camera": event["camera"],
|
|
"event": event,
|
|
},
|
|
PostProcessDataEnum.tracked_object,
|
|
)
|
|
finally:
|
|
with self.transcription_lock:
|
|
self.transcription_running = False
|
|
self.transcription_thread = None
|
|
|
|
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle")
|
|
|
|
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
|
if topic == "transcribe_audio":
|
|
event = request_data["event"]
|
|
|
|
with self.transcription_lock:
|
|
if self.transcription_running:
|
|
logger.warning(
|
|
"Audio transcription for a speech event is already running."
|
|
)
|
|
return "in_progress"
|
|
|
|
# Mark as running and start the thread
|
|
self.transcription_running = True
|
|
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "processing")
|
|
|
|
self.transcription_thread = threading.Thread(
|
|
target=self._transcription_wrapper, args=(event,), daemon=True
|
|
)
|
|
self.transcription_thread.start()
|
|
return "started"
|
|
|
|
return None
|