Files
frigate/frigate/events/audio.py
T

428 lines
15 KiB
Python
Raw Normal View History

2023-07-01 07:18:33 -06:00
"""Handle creating audio events."""
import datetime
import logging
import threading
import time
2025-06-12 12:12:34 -06:00
from multiprocessing.managers import DictProxy
2025-06-24 11:41:11 -06:00
from multiprocessing.synchronize import Event as MpEvent
2025-08-25 13:40:21 -05:00
from typing import Tuple
2023-07-01 07:18:33 -06:00
import numpy as np
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.inter_process import InterProcessRequestor
2025-05-27 10:26:00 -05:00
from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig
2025-05-22 12:16:51 -06:00
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
2023-07-01 07:18:33 -06:00
from frigate.const import (
AUDIO_DURATION,
AUDIO_FORMAT,
AUDIO_MAX_BIT_RANGE,
AUDIO_MIN_CONFIDENCE,
2023-07-01 07:18:33 -06:00
AUDIO_SAMPLE_RATE,
2025-08-25 13:40:21 -05:00
EXPIRE_AUDIO_ACTIVITY,
PROCESS_PRIORITY_HIGH,
2025-08-25 13:40:21 -05:00
UPDATE_AUDIO_ACTIVITY,
2023-07-01 07:18:33 -06:00
)
2025-06-03 06:53:48 -05:00
from frigate.data_processing.common.audio_transcription.model import (
AudioTranscriptionModelRunner,
)
2025-05-27 10:26:00 -05:00
from frigate.data_processing.real_time.audio_transcription import (
AudioTranscriptionRealTimeProcessor,
)
2023-07-01 07:18:33 -06:00
from frigate.ffmpeg_presets import parse_preset_input
2025-06-25 07:24:45 -06:00
from frigate.log import LogPipe, redirect_output_to_logger
from frigate.object_detection.base import load_labels
2023-07-06 08:28:50 -06:00
from frigate.util.builtin import get_ffmpeg_arg_list
2025-06-13 11:09:51 -06:00
from frigate.util.process import FrigateProcess
2023-07-01 07:18:33 -06:00
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
try:
from tflite_runtime.interpreter import Interpreter
except ModuleNotFoundError:
from tensorflow.lite.python.interpreter import Interpreter
2025-06-25 07:24:45 -06:00
logger = logging.getLogger(__name__)
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
parse_preset_input(ffmpeg_input.input_args, 1)
2023-12-12 03:48:09 -07:00
or get_ffmpeg_arg_list(ffmpeg_input.input_args)
or parse_preset_input(ffmpeg.input_args, 1)
2023-12-12 03:48:09 -07:00
or get_ffmpeg_arg_list(ffmpeg.input_args)
)
return (
2024-09-13 14:14:51 -06:00
[ffmpeg.ffmpeg_path, "-vn", "-threads", "1"]
+ input_args
+ ["-i"]
+ [ffmpeg_input.path]
+ [
"-threads",
"1",
"-f",
f"{AUDIO_FORMAT}",
"-ar",
f"{AUDIO_SAMPLE_RATE}",
"-ac",
"1",
"-y",
"pipe:",
]
2023-07-01 07:18:33 -06:00
)
2025-06-13 11:09:51 -06:00
class AudioProcessor(FrigateProcess):
2024-10-21 18:00:38 +03:00
name = "frigate.audio_manager"
def __init__(
self,
2025-05-27 10:26:00 -05:00
config: FrigateConfig,
cameras: list[CameraConfig],
2025-06-12 12:12:34 -06:00
camera_metrics: DictProxy,
2025-06-24 11:41:11 -06:00
stop_event: MpEvent,
):
super().__init__(
stop_event, PROCESS_PRIORITY_HIGH, name="frigate.audio_manager", daemon=True
)
2023-07-01 07:18:33 -06:00
self.camera_metrics = camera_metrics
self.cameras = cameras
2025-05-27 10:26:00 -05:00
self.config = config
2025-06-11 11:25:30 -06:00
2025-10-08 18:06:03 -05:00
def run(self) -> None:
self.pre_run_setup(self.config.logger)
audio_threads: list[AudioEventMaintainer] = []
threading.current_thread().name = "process:audio_manager"
if self.config.audio_transcription.enabled:
2025-06-11 11:25:30 -06:00
self.transcription_model_runner = AudioTranscriptionModelRunner(
self.config.audio_transcription.device,
self.config.audio_transcription.model_size,
)
else:
self.transcription_model_runner = None
2023-07-01 07:18:33 -06:00
if len(self.cameras) == 0:
return
2023-07-01 07:18:33 -06:00
2024-10-03 20:03:43 +03:00
for camera in self.cameras:
audio_thread = AudioEventMaintainer(
camera,
2025-05-27 10:26:00 -05:00
self.config,
2024-10-03 20:03:43 +03:00
self.camera_metrics,
2025-06-03 06:53:48 -05:00
self.transcription_model_runner,
2024-10-03 20:03:43 +03:00
self.stop_event,
)
audio_threads.append(audio_thread)
audio_thread.start()
2024-09-24 15:07:47 +03:00
2024-10-03 20:03:43 +03:00
self.logger.info(f"Audio processor started (pid: {self.pid})")
2024-09-24 15:07:47 +03:00
2024-10-03 20:03:43 +03:00
while not self.stop_event.wait():
pass
2023-07-01 07:18:33 -06:00
2024-10-03 20:03:43 +03:00
for thread in audio_threads:
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads:
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
self.logger.info("Exiting audio processor")
2023-07-01 07:18:33 -06:00
class AudioEventMaintainer(threading.Thread):
def __init__(
self,
camera: CameraConfig,
2025-05-27 10:26:00 -05:00
config: FrigateConfig,
2025-06-12 12:12:34 -06:00
camera_metrics: DictProxy,
2025-06-11 11:25:30 -06:00
audio_transcription_model_runner: AudioTranscriptionModelRunner | None,
stop_event: threading.Event,
2023-07-01 07:18:33 -06:00
) -> None:
super().__init__(name=f"{camera.name}_audio_event_processor")
2025-05-27 10:26:00 -05:00
self.config = config
self.camera_config = camera
self.camera_metrics = camera_metrics
2023-07-01 07:18:33 -06:00
self.stop_event = stop_event
2025-05-27 10:26:00 -05:00
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
2023-07-01 07:18:33 -06:00
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
2025-05-27 10:26:00 -05:00
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
self.ffmpeg_cmd = get_ffmpeg_command(self.camera_config.ffmpeg)
self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio")
2023-07-01 07:18:33 -06:00
self.audio_listener = None
2025-06-03 06:53:48 -05:00
self.audio_transcription_model_runner = audio_transcription_model_runner
2025-05-27 10:26:00 -05:00
self.transcription_processor = None
self.transcription_thread = None
2023-07-01 07:18:33 -06:00
# create communication for audio detections
self.requestor = InterProcessRequestor()
2025-05-22 12:16:51 -06:00
self.config_subscriber = CameraConfigUpdateSubscriber(
2025-06-11 11:25:30 -06:00
None,
2025-05-27 10:26:00 -05:00
{self.camera_config.name: self.camera_config},
[
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.audio_transcription,
],
)
2025-08-08 06:08:37 -06:00
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
2025-10-08 18:06:03 -05:00
if self.config.audio_transcription.enabled:
2025-05-27 10:26:00 -05:00
# init the transcription processor for this camera
self.transcription_processor = AudioTranscriptionRealTimeProcessor(
config=self.config,
camera_config=self.camera_config,
requestor=self.requestor,
2025-06-03 06:53:48 -05:00
model_runner=self.audio_transcription_model_runner,
2025-05-27 10:26:00 -05:00
metrics=self.camera_metrics[self.camera_config.name],
stop_event=self.stop_event,
)
self.transcription_thread = threading.Thread(
target=self.transcription_processor.run,
name=f"{self.camera_config.name}_transcription_processor",
daemon=True,
)
self.transcription_thread.start()
self.was_enabled = camera.enabled
2023-07-01 07:18:33 -06:00
def detect_audio(self, audio) -> None:
2025-05-27 10:26:00 -05:00
if not self.camera_config.audio.enabled or self.stop_event.is_set():
2023-07-01 07:18:33 -06:00
return
audio_as_float = audio.astype(np.float32)
rms, dBFS = self.calculate_audio_levels(audio_as_float)
2023-07-01 07:18:33 -06:00
2025-05-27 10:26:00 -05:00
self.camera_metrics[self.camera_config.name].audio_rms.value = rms
self.camera_metrics[self.camera_config.name].audio_dBFS.value = dBFS
2025-08-25 13:40:21 -05:00
audio_detections: list[Tuple[str, float]] = []
# only run audio detection when volume is above min_volume
2025-05-27 10:26:00 -05:00
if rms >= self.camera_config.audio.min_volume:
# create waveform relative to max range and look for detections
waveform = (audio / AUDIO_MAX_BIT_RANGE).astype(np.float32)
model_detections = self.detector.detect(waveform)
2023-07-01 07:18:33 -06:00
for label, score, _ in model_detections:
self.logger.debug(
2025-05-27 10:26:00 -05:00
f"{self.camera_config.name} heard {label} with a score of {score}"
)
2023-10-07 08:23:11 -06:00
2025-05-27 10:26:00 -05:00
if label not in self.camera_config.audio.listen:
continue
2025-05-27 10:26:00 -05:00
if score > dict(
(self.camera_config.audio.filters or {}).get(label, {})
).get("threshold", 0.8):
2025-08-25 13:40:21 -05:00
audio_detections.append((label, score))
# send audio detection data
2024-06-21 17:30:19 -04:00
self.detection_publisher.publish(
(
2025-05-27 10:26:00 -05:00
self.camera_config.name,
datetime.datetime.now().timestamp(),
dBFS,
2025-08-25 13:40:21 -05:00
[label for label, _ in audio_detections],
)
)
2023-07-01 07:18:33 -06:00
2025-08-25 13:40:21 -05:00
# send audio activity update
self.requestor.send_data(
UPDATE_AUDIO_ACTIVITY,
{self.camera_config.name: {"detections": audio_detections}},
)
2025-05-27 10:26:00 -05:00
# run audio transcription
2025-06-03 06:53:48 -05:00
if self.transcription_processor is not None:
if self.camera_config.audio_transcription.live_enabled:
# process audio until we've reached the endpoint
self.transcription_processor.process_audio(
{
"id": f"{self.camera_config.name}_audio",
"camera": self.camera_config.name,
},
audio,
)
else:
self.transcription_processor.check_unload_model()
2025-05-27 10:26:00 -05:00
def calculate_audio_levels(self, audio_as_float: np.float32) -> Tuple[float, float]:
# Calculate RMS (Root-Mean-Square) which represents the average signal amplitude
# Note: np.float32 isn't serializable, we must use np.float64 to publish the message
rms = np.sqrt(np.mean(np.absolute(np.square(audio_as_float))))
# Transform RMS to dBFS (decibels relative to full scale)
2023-11-03 20:18:23 -06:00
if rms > 0:
dBFS = 20 * np.log10(np.abs(rms) / AUDIO_MAX_BIT_RANGE)
else:
dBFS = 0
2025-05-27 10:26:00 -05:00
self.requestor.send_data(f"{self.camera_config.name}/audio/dBFS", float(dBFS))
self.requestor.send_data(f"{self.camera_config.name}/audio/rms", float(rms))
return float(rms), float(dBFS)
def start_or_restart_ffmpeg(self) -> None:
2023-07-01 07:18:33 -06:00
self.audio_listener = start_or_restart_ffmpeg(
self.ffmpeg_cmd,
self.logger,
self.logpipe,
self.chunk_size,
self.audio_listener,
2023-07-01 07:18:33 -06:00
)
2025-08-22 06:42:36 -06:00
self.requestor.send_data(f"{self.camera_config.name}/status/audio", "online")
2023-07-01 07:18:33 -06:00
def read_audio(self) -> None:
def log_and_restart() -> None:
if self.stop_event.is_set():
return
2025-05-27 10:26:00 -05:00
time.sleep(self.camera_config.ffmpeg.retry_interval)
self.logpipe.dump()
self.start_or_restart_ffmpeg()
2023-07-01 07:18:33 -06:00
try:
chunk = self.audio_listener.stdout.read(self.chunk_size)
if not chunk:
if self.audio_listener.poll() is not None:
2025-08-22 06:42:36 -06:00
self.requestor.send_data(
f"{self.camera_config.name}/status/audio", "offline"
)
self.logger.error("ffmpeg process is not running, restarting...")
log_and_restart()
return
return
audio = np.frombuffer(chunk, dtype=np.int16)
2023-07-01 07:18:33 -06:00
self.detect_audio(audio)
2023-08-05 04:47:56 -06:00
except Exception as e:
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart()
2023-07-01 07:18:33 -06:00
def run(self) -> None:
2025-05-27 10:26:00 -05:00
if self.camera_config.enabled:
self.start_or_restart_ffmpeg()
2023-07-01 07:18:33 -06:00
while not self.stop_event.is_set():
2025-05-27 10:26:00 -05:00
enabled = self.camera_config.enabled
if enabled != self.was_enabled:
if enabled:
self.logger.debug(
2025-05-27 10:26:00 -05:00
f"Enabling audio detections for {self.camera_config.name}"
)
self.start_or_restart_ffmpeg()
else:
2025-08-22 06:42:36 -06:00
self.requestor.send_data(
f"{self.camera_config.name}/status/audio", "disabled"
)
self.logger.debug(
2025-05-27 10:26:00 -05:00
f"Disabling audio detections for {self.camera_config.name}, ending events"
)
2025-08-25 13:40:21 -05:00
self.requestor.send_data(
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
)
stop_ffmpeg(self.audio_listener, self.logger)
self.audio_listener = None
self.was_enabled = enabled
continue
if not enabled:
time.sleep(0.1)
continue
# check if there is an updated config
2025-05-22 12:16:51 -06:00
self.config_subscriber.check_for_updates()
2023-07-01 07:18:33 -06:00
self.read_audio()
if self.audio_listener:
stop_ffmpeg(self.audio_listener, self.logger)
2025-05-27 10:26:00 -05:00
if self.transcription_thread:
self.transcription_thread.join(timeout=2)
if self.transcription_thread.is_alive():
self.logger.warning(
f"Audio transcription thread {self.transcription_thread.name} is still alive"
)
2023-07-01 07:18:33 -06:00
self.logpipe.close()
self.requestor.stop()
self.config_subscriber.stop()
self.detection_publisher.stop()
class AudioTfl:
2025-06-25 07:24:45 -06:00
@redirect_output_to_logger(logger, logging.DEBUG)
def __init__(self, stop_event: threading.Event, num_threads=2):
self.stop_event = stop_event
self.num_threads = num_threads
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
self.interpreter = Interpreter(
model_path="/cpu_audio_model.tflite",
num_threads=self.num_threads,
)
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
def _detect_raw(self, tensor_input):
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
detections = np.zeros((20, 6), np.float32)
res = self.interpreter.get_tensor(self.tensor_output_details[0]["index"])[0]
non_zero_indices = res > 0
class_ids = np.argpartition(-res, 20)[:20]
class_ids = class_ids[np.argsort(-res[class_ids])]
class_ids = class_ids[non_zero_indices[class_ids]]
scores = res[class_ids]
boxes = np.full((scores.shape[0], 4), -1, np.float32)
count = len(scores)
for i in range(count):
if scores[i] < AUDIO_MIN_CONFIDENCE or i == 20:
break
detections[i] = [
class_ids[i],
float(scores[i]),
boxes[i][0],
boxes[i][1],
boxes[i][2],
boxes[i][3],
]
return detections
def detect(self, tensor_input, threshold=AUDIO_MIN_CONFIDENCE):
detections = []
if self.stop_event.is_set():
return detections
raw_detections = self._detect_raw(tensor_input)
for d in raw_detections:
if d[1] < threshold:
break
detections.append(
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
)
return detections