-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
2018 lines (1790 loc) · 89.6 KB
/
Copy pathmain.py
File metadata and controls
2018 lines (1790 loc) · 89.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Webcam stream over HTTP: 640x640, 70% JPEG, MJPEG at GET /video.
Optional local Visual Audit: set ENABLE_LOCAL_AUDIT=1 to run Ollama (MiniCPM-V 2.6) on frames.
Optional listening: set ENABLE_AUDIO_STT=1 to capture mic audio and transcribe via OpenAI STT.
"""
from pathlib import Path
from dotenv import load_dotenv
# Load .env from project dir and from cwd (in case uvicorn is run from elsewhere)
_project_dir = Path(__file__).resolve().parent
load_dotenv(_project_dir / ".env")
load_dotenv(Path.cwd() / ".env")
import asyncio
import base64
import io
import logging
import os
import subprocess
import tempfile
import wave
from contextlib import asynccontextmanager
from enum import Enum
from urllib.parse import urlparse
import datetime
import json
import re
import threading
import time
from collections import deque
# In-memory event log for the Alerts Log tab timeline
_event_log: deque = deque(maxlen=500)
def _log_event(event_type: str, text: str = "") -> None:
"""Append a timestamped event to the in-memory event log. Thread-safe via GIL + deque."""
_event_log.append({
"type": event_type,
"text": text,
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
})
import numpy as np
import cv2
import httpx
import sounddevice as sd
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# Optional local Visual Audit (Ollama)
ENABLE_LOCAL_AUDIT = os.environ.get("ENABLE_LOCAL_AUDIT", "").strip().lower() in ("1", "true", "yes")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "openbmb/minicpm-v2.6")
AUDIT_INTERVAL_SEC = float(os.environ.get("AUDIT_INTERVAL_SEC", "2.0"))
# Require this many consecutive non-CLEAR results before firing TTS/log (reduces false positives)
AUDIT_CONFIRM_FRAMES = int(os.environ.get("AUDIT_CONFIRM_FRAMES", "2"))
# Minimum seconds between detection alerts (guards _add_detection + TTS fallback from firing
# every scan cycle). Independent of TTS_COOLDOWN_SEC, which only applies when SPEAKER_URL is set.
ALERT_COOLDOWN_SEC = float(os.environ.get("ALERT_COOLDOWN_SEC", "30.0"))
# Size to resize frames to before sending to cloud AI (smaller = faster inference, default 320x320)
AUDIT_AI_FRAME_SIZE = int(os.environ.get("AUDIT_AI_FRAME_SIZE", "320"))
# YOLO person detection pre-filter: gates VLM calls — only sends to LLM when YOLO sees a person.
# Dramatically reduces cloud costs and latency. Set ENABLE_YOLO=0 to disable.
ENABLE_YOLO = os.environ.get("ENABLE_YOLO", "1").strip().lower() in ("1", "true", "yes")
YOLO_MODEL = os.environ.get("YOLO_MODEL", "yolov8n.pt") # nano model: fast, ~6MB
YOLO_CONFIDENCE = float(os.environ.get("YOLO_CONFIDENCE", "0.45"))
# Frame source controls which person-detection path is active:
# "local_yolo" — webcam + local YOLO loop (dev/demo default)
# "webhook" — no local loop; person-detected events arrive via POST /api/person-detected
# (production path when live-ffmpeg RiocHook is the detection source)
# "live_ffmpeg"— reserved stub; falls back to local_yolo until AGX interface is wired up
FRAME_SOURCE = os.environ.get("FRAME_SOURCE", "webhook").strip().lower()
# Optional cloud AI visual analysis (OpenAI-compatible vLLM server)
ENABLE_CLOUD_AI = os.environ.get("ENABLE_CLOUD_AI", "").strip().lower() in ("1", "true", "yes")
CLOUD_AI_URL = (os.environ.get("CLOUD_AI_URL") or "").rstrip("/")
CLOUD_AI_API_KEY = os.environ.get("CLOUD_AI_API_KEY", "token-minicpm-v45")
CLOUD_AI_MODEL = os.environ.get("CLOUD_AI_MODEL", "openbmb/MiniCPM-o-4_5-awq")
AUDIT_SYSTEM_PROMPT = (
"You are the Rioc Sentinel, an automated audio-broadcast security system. Do not use labels like 'Visual Data' or 'Warning'. Do not use headers or bullet points. Speak only the final warning text as it should be heard over a loudspeaker. Be cold, concise, and observational. Do not identify as an AI. Always respond in English only."
)
AUDIT_USER_PROMPT = (
"""Examine this image.
No clearly visible person → respond with the single word: CLEAR
If unsure whether a shape is a person → respond CLEAR
Person clearly visible → respond with a one-sentence spoken warning addressed directly TO THEM. Mention their clothing. Cold, authoritative tone. Speak as if over a loudspeaker.
The message must tell the person to leave — it is not a command to a security team. Do not say things like "secure the area" or "detain the subject". Speak directly to the person you see.
Good examples: "You in the grey hoodie — this area is restricted. Leave immediately." / "You by the door — you are not authorized to be here. Exit now."
Do not describe. Do not use passive detection language. Speak to the person."""
)
# Webhook-path prompt: person presence is already confirmed by YOLO/PeopleNet upstream,
# so skip the CLEAR gate and just describe/address the person.
AUDIT_WEBHOOK_PROMPT = (
"""A person has been detected by motion sensors. A camera image is attached.
Respond with a single spoken warning addressed directly TO THEM. Mention their clothing or appearance if visible. Cold, authoritative tone. Speak as if over a loudspeaker.
Tell the person to leave — do not issue commands to a security team. Speak directly to the person.
Good examples: "You in the grey hoodie — this area is restricted. Leave immediately." / "You by the door — you are not authorized to be here. Exit now."
Do not describe. Do not use passive detection language. Speak to the person."""
)
# Optional audio transcription (cloud STT)
ENABLE_AUDIO_STT = os.environ.get("ENABLE_AUDIO_STT", "").strip().lower() in ("1", "true", "yes")
OPENAI_STT_API_KEY = os.environ.get("OPENAI_STT_API_KEY") or os.environ.get("OPENAI_API_KEY")
OPENAI_STT_MODEL = os.environ.get("OPENAI_STT_MODEL", "whisper-1")
STT_SAMPLE_RATE = int(os.environ.get("STT_SAMPLE_RATE", "16000"))
STT_DURATION_SEC = float(os.environ.get("STT_DURATION_SEC", "5.0"))
STT_GAP_SEC = float(os.environ.get("STT_GAP_SEC", "0.0"))
STT_SILENCE_THRESHOLD = float(os.environ.get("STT_SILENCE_THRESHOLD", "300")) # RMS below this = skip STT
# Audio input device: index (e.g. 1) or name substring (e.g. Fanvil, LINKVIL, CS20). Use external speaker mic when set.
AUDIO_INPUT_DEVICE = (os.environ.get("AUDIO_INPUT_DEVICE") or "").strip() or None
# Optional VideoDB eyes and ears (real-time transcript + visual/audio indexing)
# Docs: https://docs.videodb.io/pages/getting-started/quickstart
ENABLE_VIDEODB = os.environ.get("ENABLE_VIDEODB", "").strip().lower() in ("1", "true", "yes")
VIDEODB_BATCH_SEC = int(os.environ.get("VIDEODB_BATCH_SEC", "5"))
# Optional TTS + speaker output (Rioc speaks through IP speaker)
ENABLE_SPEAKER_TTS = os.environ.get("ENABLE_SPEAKER_TTS", "").strip().lower() in ("1", "true", "yes")
SPEAKER_URL = (os.environ.get("SPEAKER_URL") or "").rstrip("/")
SPEAKER_WS_URL = os.environ.get("SPEAKER_WS_URL", "").strip()
if not SPEAKER_WS_URL and SPEAKER_URL:
# Derive from SPEAKER_URL: https://192.168.10.183 -> wss://192.168.10.183:8000/webtwowayaudio
p = urlparse(SPEAKER_URL)
host = p.hostname or p.netloc.split(":")[0]
SPEAKER_WS_URL = f"wss://{host}:8000/webtwowayaudio"
SPEAKER_USER = os.environ.get("SPEAKER_USER", "")
SPEAKER_PASS = os.environ.get("SPEAKER_PASS", "")
SPEAKER_PLAY_PATH = os.environ.get("SPEAKER_PLAY_PATH", "/play")
# Paths to try in order (first from env, then these fallbacks)
SPEAKER_PLAY_FALLBACK_PATHS = [p.strip() for p in (os.environ.get("SPEAKER_PLAY_FALLBACK_PATHS") or "/api/play,/tts,/speak,/api/tts").split(",") if p.strip()]
# Set SPEAKER_TYPE=axis to skip non-AXIS fallback paths (auto-detected from SPEAKER_URL if not set)
SPEAKER_TYPE = os.environ.get("SPEAKER_TYPE", "").strip().lower()
# Minimum seconds between TTS announcements — prevents rapid-fire warnings interrupting each other
TTS_COOLDOWN_SEC = float(os.environ.get("TTS_COOLDOWN_SEC", "20.0"))
OPENAI_TTS_MODEL = os.environ.get("OPENAI_TTS_MODEL", "tts-1")
OPENAI_TTS_VOICE = os.environ.get("OPENAI_TTS_VOICE", "onyx") # Deep, authoritative
# Fallback: play through Mac speakers when speaker fails or for testing
ENABLE_LOCAL_PLAYBACK = os.environ.get("ENABLE_LOCAL_PLAYBACK", "").strip().lower() in ("1", "true", "yes")
# WebSocket: G.711 μ-law at 8kHz (per talk.js)
SPEAKER_WS_SAMPLE_RATE = int(os.environ.get("SPEAKER_WS_SAMPLE_RATE", "8000"))
latest_transcript: str = ""
latest_tts_audio: bytes = b"" # Served at /tts/latest.mp3 for play-from-URL mode
latest_analysis: str = "" # Latest Cloud AI vision output (non-CLEAR)
# Most recent JPEG available to the conversation manager for turn context.
# Updated from both the RTSP path (_get_ai_frame) and the webhook path so that
# conversation turns always see the freshest frame regardless of whether a live
# camera is configured.
_latest_conv_frame: bytes | None = None
# TTS rate-limiting: one announcement at a time with a cooldown between them.
# _tts_active is set True before the first await, so asyncio's single-threaded model
# makes this check+set atomic — no two coroutines can both see it False simultaneously.
_tts_active: bool = False
_last_tts_time: float = 0.0
# Pre-generated "Attention." TTS audio — generated once at startup so the first-detection
# chime plays instantly without a TTS API round-trip on the hot path.
_attention_audio_mp3: bytes | None = None
# Detection-level alert cooldown: gates _add_detection + the TTS fallback path so that
# alerts never fire more often than ALERT_COOLDOWN_SEC regardless of TTS or speaker state.
_last_alert_time: float = 0.0
# Attention chime: fired once when YOLO first detects a person (before Cloud AI responds).
# Reset after each full alert cycle so it fires again on the next new detection.
_attention_chime_pending: bool = True
# Detection history: list of {timestamp, type, text} newest-first
detections: deque = deque(maxlen=100)
# SSE subscribers: one asyncio.Queue per connected /detections/stream client
_sse_subscribers: list[asyncio.Queue] = []
# Frames that passed person detection, waiting for vLLM — size 1 ensures freshest frame is always used
_detection_frame_queue: asyncio.Queue = asyncio.Queue(maxsize=1)
# AI Guard conversation manager (lazy-imported and initialized in lifespan)
_conv_manager = None
def _add_detection(detection_type: str, text: str) -> None:
d = {
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
"type": detection_type,
"text": text,
}
detections.appendleft(d)
# Instantly push to all connected SSE clients (called from async context — put_nowait is safe)
for q in list(_sse_subscribers):
try:
q.put_nowait(d)
except asyncio.QueueFull:
pass # Slow client — drop rather than block
# Base URL for TTS (speaker must reach this). Set to your Mac's IP, e.g. http://192.168.10.50:8000
TTS_PUBLIC_URL = (os.environ.get("TTS_PUBLIC_URL") or "").rstrip("/")
# Optional: test with a public MP3 URL to verify speaker can play streams (e.g. https://...)
SPEAKER_TEST_URL = (os.environ.get("SPEAKER_TEST_URL") or "").strip() or None
# Phrases checked as SUBSTRINGS — safe because they cannot appear in real human speech.
# Event tags, blank-audio markers, URL patterns, and highly specific platform phrases
# that no person in a security-guard scenario would ever say.
STT_HALLUCINATION_SUBSTRING = frozenset((
# Silent / blank audio markers
"[blank_audio]", "(silence)", "[silence]", "(no audio)", "[no audio]",
"(no speech)", "[no speech]",
# Sound / music event tags
"[music]", "(music)", "[applause]", "(applause)", "[laughter]", "(laughter)",
"[noise]", "(noise)", "[sound]", "(sound)", "[audio]",
# STT prompt echo — Whisper sometimes repeats the prompt text verbatim or as a prefix.
# These phrases are specific enough that they cannot appear in real speech.
"transcribe only their direct speech",
"a person is speaking to a security guard",
# URL-shaped outputs on static noise
"www.",
# Subtitle / caption credit lines
"captioned by", "captions by", "closed caption", "auto-generated",
"subtitles by", "translated by",
# Highly specific video-platform phrases — no plausible security context
"thank you for watching", "thanks for watching", "for watching",
"subscribe to my channel", "don't forget to subscribe", "like and subscribe",
"please subscribe", "hit the bell", "let me know in the comments",
"leave a comment", "see you in the next", "see you next time", "until next time",
"youtube",
# Japanese / Spanish / Portuguese video-outro markers
"ありがとう", "視聴", "最後まで", "ございます",
"gracias por ver", "suscríbete", "inscreva-se",
))
# Phrases checked as EXACT / WHOLE-STRING matches only.
# These words can legitimately appear inside real speech ("I'll say goodbye and leave",
# "I know the security guard here") so substring matching would produce false positives.
# A transcript is discarded only when it consists of nothing but one of these phrases.
STT_HALLUCINATION_EXACT = frozenset((
# Whisper echoing the STT prompt back verbatim (full or key fragments)
"a person is speaking to a security guard. transcribe only their direct speech.",
"a person is speaking to a security guard",
"a person is speaking",
"transcribe only their direct speech",
"transcribe only",
"direct speech",
"transcribe",
# Farewell / filler words — only discard when the whole transcript is one of these
"thank you so much", "thank you very much",
"bye bye", "bye",
"goodbye", "take care", "peace out",
"the end", "fin", "ciao", "adios",
# Whisper's language-detection tag on unrecognised audio
"foreign",
# Platform / metadata words that are hallucinations when alone
"subscribe", "copyright", "all rights reserved",
"music by", "sound effects", "subtitles", "end of",
# Security context word — valid speech but also a common Whisper hallucination
# when it echoes the prompt; only reject if the entire transcript is this phrase
"security guard",
))
# Prompt to prime Whisper away from video-outro hallucinations
STT_PROMPT = "A person is speaking to a security guard. Transcribe only their direct speech."
def _strip_think_tags(text: str) -> str:
"""Extract only the final response after <think>...</think> reasoning blocks."""
import re
# Remove complete think blocks, keep everything outside them
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL | re.IGNORECASE).strip()
# If any <think> tag remains (unclosed), model ran out of tokens mid-reasoning.
# Return empty — treat as CLEAR and retry on next frame.
if re.search(r'<think>', cleaned, re.IGNORECASE):
return ""
return cleaned
def _is_stt_hallucination(text: str) -> bool:
"""Return True only when the transcript is entirely a hallucination.
Two-tier matching strategy:
- SUBSTRING match for STT_HALLUCINATION_SUBSTRING: event tags, blank-audio
markers, and highly specific platform phrases that cannot appear in real
speech. A single occurrence anywhere in the transcript is enough.
- EXACT / whole-string match for STT_HALLUCINATION_EXACT: words and phrases
that could legitimately appear inside real speech ("I know the security
guard", "say goodbye and leave"). The transcript must consist of nothing
but the phrase — partial matches are ignored.
This prevents the filter from discarding real speech that happens to contain
a hallucination word as a substring (e.g. "nearby" triggering on "bye", or
"I'll be fine" triggering on "fin", or a full sentence being rejected because
it mentions "security guard").
"""
import re as _re
t = text.lower().strip()
# Normalised form used for exact comparisons: trailing punctuation removed
t_exact = t.rstrip('.!?,;: ')
if not t or len(t) < 8:
return True # Very short outputs are almost always noise
# Entire output is a bracket / paren event tag, e.g. "[Music]", "(Silence)"
if _re.fullmatch(r'[\[(][^\]\)]{1,40}[\])]\.?', t):
return True
# Repeated-segment loop: same phrase (4+ chars) appearing 3+ times consecutively
if _re.search(r'(.{4,}?)(?:\s*\1){2,}', t):
return True
# Substring check — safe for phrases that cannot appear in real speech
if any(phrase in t for phrase in STT_HALLUCINATION_SUBSTRING):
return True
# Exact / whole-string check — only discard when the full transcript IS this phrase
if t_exact in STT_HALLUCINATION_EXACT:
return True
return False
def _is_audio_silent(wav_bytes: bytes) -> bool:
"""Return True if audio is mostly silence (skip STT to avoid hallucinations)."""
try:
with io.BytesIO(wav_bytes) as buf:
with wave.open(buf, "rb") as wf:
data = wf.readframes(wf.getnframes())
samples = np.frombuffer(data, dtype=np.int16)
rms = np.sqrt(np.mean(samples.astype(np.float64) ** 2))
return rms < STT_SILENCE_THRESHOLD
except Exception:
return False
# Global camera; set in lifespan, released on shutdown.
cap: cv2.VideoCapture | None = None
cap_lock = threading.Lock() # Guards all reads/writes of cap
# YOLO singleton — loaded once on first use.
_yolo_model = None
_yolo_model_lock = threading.Lock()
def _get_yolo_model():
"""Lazy-load the YOLO model (thread-safe singleton)."""
global _yolo_model
if _yolo_model is None:
with _yolo_model_lock:
if _yolo_model is None:
from ultralytics import YOLO # noqa: PLC0415
print(f"[YOLO] Loading model: {YOLO_MODEL}", flush=True)
_yolo_model = YOLO(YOLO_MODEL)
return _yolo_model
def _yolo_person_in_frame(frame: np.ndarray) -> bool:
"""Return True if YOLO detects at least one person (COCO class 0) in frame."""
# Run without confidence filter first to see actual best confidence for diagnostics
results = _get_yolo_model()(frame, classes=[0], conf=0.01, verbose=False)
all_confs = [float(box.conf) for r in results for box in r.boxes]
best = max(all_confs, default=0.0)
detected = best >= YOLO_CONFIDENCE
if not detected:
print(f"[YOLO] Best conf={best:.2f} (threshold={YOLO_CONFIDENCE}) — not detected", flush=True)
else:
print(f"[YOLO] Person detected (conf={best:.2f})", flush=True)
_log_event("yolo_detected", f"conf={best:.2f}")
return detected
FRAME_SIZE = (640, 640)
JPEG_QUALITY = 70
CONSECUTIVE_READ_FAILURES_MAX = 30
def get_next_frame() -> bytes | None:
"""Read one frame from the global camera; resize and encode as JPEG. Returns None on failure."""
with cap_lock:
if cap is None or not cap.isOpened():
return None
ret, frame = cap.read()
if not ret or frame is None:
return None
frame = cv2.resize(frame, FRAME_SIZE, interpolation=cv2.INTER_LINEAR)
_, jpeg = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]
)
return jpeg.tobytes()
async def video_stream_generator():
"""Async generator that yields MJPEG chunks (run frame capture in thread pool)."""
consecutive_failures = 0
while True:
jpeg_bytes = await asyncio.to_thread(get_next_frame)
if jpeg_bytes is None:
consecutive_failures += 1
if consecutive_failures >= CONSECUTIVE_READ_FAILURES_MAX:
logger.warning("Too many consecutive read failures; ending stream.")
break
await asyncio.sleep(0.05)
continue
consecutive_failures = 0
header = (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: %d\r\n\r\n" % len(jpeg_bytes)
)
yield header + jpeg_bytes
def _resolve_audio_input_device() -> int | None:
"""Resolve AUDIO_INPUT_DEVICE to a sounddevice index. Returns None for default device."""
if not AUDIO_INPUT_DEVICE:
return None
try:
# If it's a numeric string, use as index
return int(AUDIO_INPUT_DEVICE)
except ValueError:
pass
# Search by name (e.g. Fanvil, LINKVIL, CS20)
name_lower = AUDIO_INPUT_DEVICE.lower()
for i, dev in enumerate(sd.query_devices()):
if dev["max_input_channels"] > 0 and name_lower in (dev.get("name") or "").lower():
return i
logger.warning("AUDIO_INPUT_DEVICE=%r: no matching input device found, using default", AUDIO_INPUT_DEVICE)
return None
def _record_audio_chunk() -> bytes | None:
"""Record STT_DURATION_SEC of mono audio from the configured mic, return WAV bytes."""
try:
device = _resolve_audio_input_device()
n_samples = int(STT_SAMPLE_RATE * STT_DURATION_SEC)
rec_kw = {"samplerate": STT_SAMPLE_RATE, "channels": 1, "dtype": "int16"}
if device is not None:
rec_kw["device"] = device
audio = sd.rec(n_samples, **rec_kw)
sd.wait()
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # int16
wf.setframerate(STT_SAMPLE_RATE)
wf.writeframes(audio.tobytes())
buf.seek(0)
return buf.read()
except Exception as exc:
logger.warning("Audio capture failed: %s", exc)
return None
def _print_audio(msg: str) -> None:
"""Print with flush so it appears immediately in uvicorn output."""
print(msg, flush=True)
def _wav_to_mp3(wav_bytes: bytes) -> bytes | None:
"""Convert WAV to MP3 via ffmpeg. Used when MiniCPM-o returns WAV and speaker needs MP3."""
try:
proc = subprocess.run(
["ffmpeg", "-y", "-i", "pipe:0", "-f", "mp3", "-q:a", "2", "pipe:1"],
input=wav_bytes,
capture_output=True,
timeout=30,
)
if proc.returncode == 0 and proc.stdout:
return proc.stdout
except FileNotFoundError:
logger.warning("ffmpeg not found. Install with: brew install ffmpeg")
except Exception as e:
logger.warning("WAV to MP3 conversion failed: %s", e)
return None
def _resample_for_speaker(audio_bytes: bytes, sample_rate: int = 16000) -> bytes | None:
"""Re-encode audio to mono MP3 at the given sample rate for embedded speaker compatibility.
CS20 and similar embedded speakers often can't play 24kHz stereo MP3 from streams."""
try:
proc = subprocess.run(
[
"ffmpeg", "-y", "-i", "pipe:0",
"-ac", "1", "-ar", str(sample_rate),
"-f", "mp3", "-q:a", "4",
"pipe:1",
],
input=audio_bytes,
capture_output=True,
timeout=30,
)
if proc.returncode == 0 and proc.stdout:
return proc.stdout
except Exception as e:
logger.warning("Resample for speaker failed: %s", e)
return None
def _mp3_to_mulaw(mp3_bytes: bytes, sample_rate: int = 8000) -> bytes | None:
"""Convert MP3 (or WAV) to G.711 μ-law at 8kHz - format talk.js sends to speaker."""
try:
proc = subprocess.run(
[
"ffmpeg", "-y", "-i", "pipe:0",
"-f", "mulaw", "-ar", str(sample_rate), "-ac", "1",
"pipe:1",
],
input=mp3_bytes,
capture_output=True,
timeout=30,
)
if proc.returncode == 0 and proc.stdout:
return proc.stdout
except FileNotFoundError:
logger.warning("ffmpeg not found. Install with: brew install ffmpeg")
except Exception as e:
logger.warning("MP3 to μ-law conversion failed: %s", e)
return None
def _axis_transmit_sync(url: str, content_type: str, body: bytes, user: str, password: str) -> tuple[int, str]:
"""POST audio to AXIS VAPIX transmit.cgi using Digest auth.
Runs synchronously in a thread — requests handles the 401+WWW-Authenticate challenge-response."""
import requests
from requests.auth import HTTPDigestAuth
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
r = requests.post(
url,
data=body,
headers={"Content-Type": content_type},
auth=HTTPDigestAuth(user, password) if user else None,
verify=False,
timeout=8.0,
)
return r.status_code, r.text[:80]
async def _guarded_play(audio_bytes: bytes, audio_format: str = "wav", force: bool = False, chime: bool = False) -> None:
"""Deliver pre-generated audio to the IP speaker with TTS guard (rate-limit + no overlaps).
audio_format: "wav" (from MiniCPM-o) or "mp3". WAV is converted to MP3 before delivery.
chime=True: bypasses cooldown and does not set _last_tts_time — used for short attention tones
that must not block the real alert that follows ~1s later."""
global _tts_active, _last_tts_time, latest_tts_audio
if not force and not chime and (not ENABLE_SPEAKER_TTS or not SPEAKER_URL):
return
if not audio_bytes:
return
if not force and not chime:
# If a chime is mid-stream, wait up to 3s for it to finish before playing the real alert
if _tts_active:
deadline = time.monotonic() + 3.0
while _tts_active and time.monotonic() < deadline:
await asyncio.sleep(0.05)
if _tts_active:
_print_audio("[Rioc] TTS in progress — dropping")
return
now = time.monotonic()
if now - _last_tts_time < TTS_COOLDOWN_SEC:
remaining = TTS_COOLDOWN_SEC - (now - _last_tts_time)
_print_audio(f"[Rioc] TTS cooldown — {remaining:.0f}s remaining")
return
if _tts_active:
_print_audio(f"[Rioc] _guarded_play: _tts_active=True — dropping call (force={force}, chime={chime}, {len(audio_bytes)}B)")
return # Prevent overlap even for forced/chime calls
_tts_active = True # Set before first await — atomic in asyncio (no context switch possible)
if not chime:
_last_tts_time = time.monotonic() # Chime does not reset the cooldown clock
try:
# Normalize to MP3 — all speaker delivery paths below expect MP3 bytes
if audio_format == "wav":
_print_audio("[Rioc] Converting WAV → MP3...")
mp3_bytes = await asyncio.to_thread(_wav_to_mp3, audio_bytes)
if not mp3_bytes:
_print_audio("[Rioc] WAV→MP3 conversion failed")
return
else:
mp3_bytes = audio_bytes
# Re-encode to 16kHz mono for embedded speaker stream compatibility.
# OpenAI TTS outputs 24kHz stereo; many embedded speakers can't decode that when streaming.
# For ipspk: kick off stopstream concurrently with resample to save ~0.3s
_early_stopstream_task = None
if SPEAKER_TYPE == "ipspk" and SPEAKER_URL and TTS_PUBLIC_URL:
_auth = (SPEAKER_USER, SPEAKER_PASS) if SPEAKER_USER else None
_spk_url = SPEAKER_URL
async def _do_early_stopstream():
try:
async with httpx.AsyncClient(verify=False) as _c:
await _c.get(f"{_spk_url}/api/play", params={"action": "stopstream"}, auth=_auth, timeout=5.0)
except Exception:
pass
_early_stopstream_task = asyncio.create_task(_do_early_stopstream())
resampled = await asyncio.to_thread(_resample_for_speaker, mp3_bytes)
if resampled:
_print_audio(f"[Rioc] Resampled {len(mp3_bytes)}→{len(resampled)} bytes (16kHz mono) for speaker stream")
latest_tts_audio = resampled
else:
_print_audio("[Rioc] Resample failed — serving original MP3")
latest_tts_audio = mp3_bytes
audio_bytes = mp3_bytes # delivery paths (upload, AXIS) still use original quality
auth = (SPEAKER_USER, SPEAKER_PASS) if SPEAKER_USER else None
played = False
# Try AXIS VAPIX transmit (AXIS C1310-E and similar AXIS network speakers)
if SPEAKER_URL and not played and SPEAKER_TYPE == "axis":
# AXIS C1310-E requires audio/basic (G.711 μ-law 8kHz mono)
mulaw_bytes = _mp3_to_mulaw(audio_bytes, sample_rate=8000)
for ct, body in [
("audio/basic", mulaw_bytes),
("audio/mpeg", audio_bytes), # fallback if basic unsupported
]:
if body is None:
continue
try:
_print_audio(f"[Rioc] Trying AXIS transmit ({ct})")
status, resp_text = await asyncio.to_thread(
_axis_transmit_sync,
f"{SPEAKER_URL}/axis-cgi/audio/transmit.cgi",
ct,
body,
SPEAKER_USER,
SPEAKER_PASS,
)
if status < 400:
_print_audio(f"[Rioc] Speaker OK (AXIS transmit {ct})")
played = True
break
else:
_print_audio(f"[Rioc] AXIS transmit {ct}: {status} {resp_text}")
except Exception as e:
_print_audio(f"[Rioc] AXIS transmit error ({ct}): {type(e).__name__}: {e}")
# Try WebSocket (Fanvil/LINKVIL speakers use wss://.../webtwowayaudio)
if SPEAKER_WS_URL and not played and SPEAKER_TYPE not in ("axis",):
try:
import ssl
import websockets
_print_audio(f"[Rioc] Trying WebSocket: {SPEAKER_WS_URL}")
ssl_ctx = None
if SPEAKER_WS_URL.startswith("wss"):
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
async with websockets.connect(
SPEAKER_WS_URL,
close_timeout=5,
open_timeout=10,
ping_timeout=None,
ssl=ssl_ctx,
) as ws:
# Speaker expects G.711 μ-law at 8kHz (per talk.js: mulawEncode → send)
mulaw_bytes = _mp3_to_mulaw(audio_bytes, sample_rate=SPEAKER_WS_SAMPLE_RATE)
if mulaw_bytes:
# Send in ~20ms chunks (160 bytes at 8kHz) - matches talk.js buffer flow.
# The loop is real-time paced (sleep 20ms per 20ms chunk) so when the
# last ws.send() returns, the speaker is playing the final chunk.
# _dispatch_audio's finally block clears _speaking_event here, which
# is the precise half-duplex gate that releases the mic listener.
chunk_size = (SPEAKER_WS_SAMPLE_RATE // 50) # 160 bytes = 20ms at 8kHz
first_chunk = True
for i in range(0, len(mulaw_bytes), chunk_size):
await ws.send(mulaw_bytes[i : i + chunk_size])
if first_chunk:
# Log when audio actually starts playing, not when it finishes
_log_event("speaker_playing", "WebSocket")
first_chunk = False
await asyncio.sleep(0.02)
# ← WebSocket transmission complete: all μ-law bytes sent to speaker.
# For conversation turns (force=True) hold the mic-suppression lock
# until the speaker hardware finishes playing, then add a 2.5s buffer
# to account for the IP speaker's internal audio buffering.
# For fire-and-forget calls (initial alert, etc.) skip the wait so
# the initial detection response is not delayed.
_print_audio(f"[Rioc] WebSocket: last chunk sent ({len(mulaw_bytes)}B) — transmission complete")
played = True
else:
_print_audio("[Rioc] WebSocket: μ-law conversion failed")
except Exception as e:
_print_audio(f"[Rioc] WebSocket error: {e}")
if (TTS_PUBLIC_URL or SPEAKER_TEST_URL) and not played and SPEAKER_TYPE not in ("axis", "ipspk"):
# Prefer test URL for diagnostics (can the speaker play ANY stream?)
audio_url = (SPEAKER_TEST_URL or f"{TTS_PUBLIC_URL}/tts/latest.mp3")
_print_audio(f"[Rioc] Playing stream: {audio_url}")
try:
async with httpx.AsyncClient(verify=False) as client:
r = await client.get(
f"{SPEAKER_URL}/api/play",
params={
"action": "startstream",
"stream": audio_url,
"volume": 20, # 0-100, per dashboard examples
},
auth=auth,
timeout=15.0,
)
if r.status_code < 400:
_print_audio("[Rioc] Speaker OK (startstream)")
played = True
else:
_print_audio(f"[Rioc] startstream: {r.status_code} {r.text[:80]}")
except Exception as e:
_print_audio(f"[Rioc] startstream error: {e}")
if not played and SPEAKER_TYPE not in ("axis", "ipspk"):
# Try upload-then-play (dashboard shows file=userfile1 for uploaded files)
upload_paths = ["/api/upload", "/api/file/upload", "/upload", "/api/media/upload"]
for up_path in upload_paths:
try:
async with httpx.AsyncClient(verify=False) as client:
r = await client.post(
f"{SPEAKER_URL}{up_path}",
files={"file": ("tts.mp3", audio_bytes, "audio/mpeg")},
auth=auth,
timeout=30.0,
)
if r.status_code < 400:
_print_audio(f"[Rioc] Upload OK ({up_path}), playing userfile1...")
async with httpx.AsyncClient(verify=False) as client:
r2 = await client.get(
f"{SPEAKER_URL}/api/play",
params={"action": "start", "file": "userfile1", "volume": 20},
auth=auth,
timeout=10.0,
)
if r2.status_code < 400:
_print_audio("[Rioc] Speaker OK (upload+play)")
played = True
break
except Exception as e:
pass # Try next path
# Generic IP speaker (CS20/LINKVIL and similar)
if not played and SPEAKER_TYPE == "ipspk":
try:
def _ipspk_result_ok(resp) -> bool:
"""CS20 returns HTTP 200 even on failure — check the JSON result field."""
try:
return resp.json().get("result", -1) == 0
except Exception:
return resp.status_code < 400
if TTS_PUBLIC_URL:
tts_url = f"{TTS_PUBLIC_URL}/tts/latest.mp3?t={int(time.monotonic() * 1000)}"
# Wait for the early stopstream task (started concurrently with resample above)
if _early_stopstream_task is not None:
_print_audio("[Rioc] Waiting for early stopstream...")
try:
await _early_stopstream_task
except Exception:
pass
_print_audio("[Rioc] Stopstream done — sending startstream")
else:
# Fallback: stopstream inline if early task wasn't created
try:
async with httpx.AsyncClient(verify=False) as client:
await client.get(f"{SPEAKER_URL}/api/play", params={"action": "stopstream"}, auth=auth, timeout=5.0)
except Exception:
pass
await asyncio.sleep(0.05)
async with httpx.AsyncClient(verify=False) as client:
rs = await client.get(
f"{SPEAKER_URL}/api/play",
params={"action": "startstream", "stream": tts_url, "volume": 80},
auth=auth, timeout=15.0,
)
_print_audio(f"[Rioc] ipspk startstream: {rs.status_code} {rs.text[:200]}")
if _ipspk_result_ok(rs):
# Schedule stopstream after audio finishes to prevent CS20 looping
est_duration = max(2.0, len(latest_tts_audio) / 4000) + 2.0
asyncio.create_task(_ipspk_stopstream_after(est_duration))
_print_audio(f"[Rioc] Speaker OK (ipspk startstream, stopstream in {est_duration:.1f}s)")
_log_event("speaker_playing", "ipspk startstream")
played = True
else:
_print_audio("[Rioc] ipspk: TTS_PUBLIC_URL not set")
except Exception as e:
_print_audio(f"[Rioc] ipspk error: {e}")
if not played and SPEAKER_TYPE not in ("axis", "ipspk"):
paths_to_try = [SPEAKER_PLAY_PATH] + [p for p in SPEAKER_PLAY_FALLBACK_PATHS if p != SPEAKER_PLAY_PATH]
last_err = ""
for path in paths_to_try:
play_url = f"{SPEAKER_URL}{path}"
_print_audio(f"[Rioc] Trying speaker at {play_url}...")
try:
async with httpx.AsyncClient(verify=False) as client:
r = await client.post(
play_url,
content=audio_bytes,
headers={"Content-Type": "audio/mpeg"},
auth=auth,
timeout=30.0,
)
if r.status_code < 400:
_print_audio("[Rioc] Speaker OK")
played = True
break
last_err = f"{r.status_code} {r.text[:100]}"
except Exception as e:
last_err = str(e)
if not played:
_print_audio(f"[Rioc] Speaker failed (all paths): {last_err}")
if not played and ENABLE_LOCAL_PLAYBACK:
_play_audio_locally(audio_bytes)
except Exception as e:
_print_audio(f"[Rioc] Speaker error: {e}")
finally:
_tts_active = False
async def _ipspk_stopstream_after(delay: float) -> None:
"""Stop CS20 stream after delay seconds to prevent looping on a static file URL."""
await asyncio.sleep(delay)
try:
auth = (SPEAKER_USER, SPEAKER_PASS) if SPEAKER_USER else None
async with httpx.AsyncClient(verify=False) as client:
await client.get(
f"{SPEAKER_URL}/api/play",
params={"action": "stopstream"},
auth=auth, timeout=5.0,
)
_print_audio(f"[Rioc] ipspk stopstream (after {delay:.1f}s)")
except Exception as e:
_print_audio(f"[Rioc] ipspk stopstream error: {e}")
async def _transcribe_audio(wav_bytes: bytes) -> str | None:
"""Transcribe WAV bytes using OpenAI Whisper. Used for person-response logging."""
if not OPENAI_STT_API_KEY:
logger.warning("[Transcribe] OPENAI_STT_API_KEY not set — skipping transcription")
return None
# Log raw audio energy so we can tell whether real speech was captured
try:
with io.BytesIO(wav_bytes) as _ebuf:
with wave.open(_ebuf, "rb") as _wf:
_samples = np.frombuffer(_wf.readframes(_wf.getnframes()), dtype=np.int16)
_rms = float(np.sqrt(np.mean(_samples.astype(np.float64) ** 2)))
_peak = int(np.max(np.abs(_samples)))
logger.info("[Transcribe] Audio energy — RMS: %.1f peak: %d silence_threshold: %.1f silent: %s",
_rms, _peak, STT_SILENCE_THRESHOLD, _rms < STT_SILENCE_THRESHOLD)
except Exception as _e:
logger.debug("[Transcribe] Could not compute audio energy: %s", _e)
try:
import io as _io
def _do() -> str:
client = OpenAI(api_key=OPENAI_STT_API_KEY)
buf = _io.BytesIO(wav_bytes)
buf.name = "audio.wav"
result = client.audio.transcriptions.create(
model=OPENAI_STT_MODEL,
file=buf,
prompt=STT_PROMPT,
)
return (result.text or "").strip()
text = await asyncio.to_thread(_do)
logger.info("[Transcribe] Raw Whisper result: %r", text)
if not text:
logger.warning("[Transcribe] Empty transcript returned")
return None
if _is_stt_hallucination(text):
logger.warning("[Transcribe] Hallucination filter discarded: %r", text)
return None
return text
except Exception as exc:
logger.warning("[Transcribe] Person transcription failed: %s", exc)
return None
async def _speak_through_speaker(text: str, force: bool = False, chime: bool = False) -> None:
"""Generate speech for arbitrary text and play on speaker.
Used by /tts/test, local_audit_loop, VideoDB, and as fallback when model returns no audio.
Current backend: OpenAI TTS (model audio output not yet enabled on the vLLM server).
TODO: switch to _speak_via_model_audio() below once vLLM is started with --enable-audio-output."""
_print_audio(f"[Rioc] _speak_through_speaker called — force={force} len={len(text)} _tts_active={_tts_active}")
if not text or len(text) > 500:
_print_audio(f"[Rioc] _speak_through_speaker: skipping — text empty or too long ({len(text)} chars)")
return
if not OPENAI_STT_API_KEY:
_print_audio("[Rioc] TTS skipped: OPENAI_STT_API_KEY not set")
return
_log_event("tts_started", text[:200])
try:
def _tts():
client = OpenAI(api_key=OPENAI_STT_API_KEY)
resp = client.audio.speech.create(
model=OPENAI_TTS_MODEL,
voice=OPENAI_TTS_VOICE,
input=text[:500],
)
return resp.content
mp3_bytes = await asyncio.to_thread(_tts)
if mp3_bytes:
await _guarded_play(mp3_bytes, "mp3", force, chime=chime)
except Exception as e:
_print_audio(f"[Rioc] TTS generation failed: {e}")
def _play_audio_locally(audio_bytes: bytes) -> None:
"""Play MP3 through Mac speakers via afplay (fallback when IP speaker fails)."""
try:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(audio_bytes)
path = f.name
os.system(f'afplay "{path}" 2>/dev/null')
os.unlink(path)
except Exception as e:
logger.debug("Local playback failed: %s", e)
async def audio_transcription_loop() -> None:
"""Background task: record mic audio, send to MiniCPM-o for STT + spoken response.
MiniCPM-o handles both transcription and response generation in a single call."""
global latest_transcript
if not ENABLE_CLOUD_AI or not CLOUD_AI_URL:
_print_audio("[Rioc] Audio loop disabled: ENABLE_CLOUD_AI or CLOUD_AI_URL not set")
return
dev_info = ""
if AUDIO_INPUT_DEVICE:
idx = _resolve_audio_input_device()
if idx is not None:
dev = sd.query_devices(idx)
dev_info = f" (input: {dev.get('name', '?')})"
_print_audio(f"[Rioc] Audio loop started{dev_info}. Recording {STT_DURATION_SEC}s chunks...")
while True:
_print_audio("[Rioc] Recording chunk...")
wav_bytes = await asyncio.to_thread(_record_audio_chunk)
if not wav_bytes:
_print_audio("[Rioc] Audio capture failed — check mic permissions")
await asyncio.sleep(1.0)
continue
if _is_audio_silent(wav_bytes):
continue # Skip when silent
_print_audio("[Rioc] Sending audio to MiniCPM-o...")
wav_b64 = base64.standard_b64encode(wav_bytes).decode("ascii")
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{CLOUD_AI_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {CLOUD_AI_API_KEY}"},
json={
"model": CLOUD_AI_MODEL,
"modalities": ["text", "audio"],
"audio": {"voice": "default", "format": "wav"},
"messages": [
{
"role": "system",
"content": (
"You are Rioc, a cold and authoritative security guard. "
"The person in front of you is speaking. Respond to them directly "
"in one sentence. Do not transcribe what they said."
),
},
{
"role": "user",
"content": [
{"type": "input_audio", "input_audio": {"data": wav_b64, "format": "wav"}},
],
},
],
"max_tokens": 200,
},
timeout=60.0,
)
resp.raise_for_status()
choice_msg = ((resp.json().get("choices") or [{}])[0].get("message") or {})
audio_data = choice_msg.get("audio") or {}
# Transcript: what the model heard / its text reply
transcript = choice_msg.get("content") or audio_data.get("transcript") or ""
if transcript:
latest_transcript = transcript
_add_detection("audio", transcript)
_print_audio(f"[Rioc] {transcript}")
# Play the model's spoken response
response_wav_b64 = audio_data.get("data") or ""
if response_wav_b64:
asyncio.create_task(_guarded_play(base64.b64decode(response_wav_b64), "wav"))
except Exception as exc:
_print_audio(f"[Rioc] Audio processing failed: {exc}")
if STT_GAP_SEC > 0:
await asyncio.sleep(STT_GAP_SEC)