Přeskočit obsah

Hlasové a Video hovory — specifikace

Nahrazuje voice-chat.md. Scope: 1:1 hovory v DM konverzacích. Skupinové hovory jsou mimo scope (vyžadují SFU server).


1. Přehled

Peer-to-peer audio/video komunikace integrovaná do ChatWindow. Hovor se iniciuje tlačítky 🎙 (hlas) a 📷 (video) ve vstupní liště ConversationPanel. Šifrování zajišťuje WebRTC (DTLS-SRTP) — žádné audio/video neprochází serverem, pouze signalizační zprávy (SDP, ICE).

Volající                Backend (FastAPI WS)              Přijímající
    │── /ws/voice/{conv_id} ───────►│                          │
    │   offer SDP + capabilities    │── incoming_call ─────────►│
    │                               │   {caller, sdp, has_video}│
    │◄── answer SDP ────────────────│◄── answer ───────────────│
    │── ICE candidates ─────────────►│◄── ICE candidates ───────│
    │◄──────────────────────────────│──────────────────────────►│
    │                                                            │
    │  ◄══════ přímý RTP proud (P2P nebo přes TURN) ══════════► │
    │         audio (Opus) + video (H.264/VP8)                  │

2. Hardwarové požadavky

Komponenta Minimum Doporučeno
Mikrofon jakýkoliv USB/jack noise-cancelling headset
Reproduktory / sluchátka jakékoliv sluchátka (echo cancel)
Kamera 720p 30fps 1080p 30fps
CPU 2 jádra (audio) 4 jádra (video H.264 HW encode)
RAM 200 MB volné
Síť 100 kbps (audio) 2 Mbps (720p video)

3. Správa audio/video zařízení

3a. Enumarace zařízení

# Audio — sounddevice
import sounddevice as sd
devices = sd.query_devices()
inputs  = [d for d in devices if d["max_input_channels"] > 0]
outputs = [d for d in devices if d["max_output_channels"] > 0]

# Vždy první možnost v každém dropdown:
"Windows výchozí zařízení"  # → index = None v sounddevice

# Video — OpenCV
import cv2
cameras = []
for i in range(8):          # prohledá indexy 0–7
    cap = cv2.VideoCapture(i, cv2.CAP_DSHOW)
    if cap.isOpened():
        cameras.append({"index": i, "name": _get_camera_name(i)})
    cap.release()

3b. Detekce hot-plug (připojení/odpojení za běhu)

Background vlákno každé 3 s porovná aktuální seznam zařízení s předchozím. Změna → event on_devices_changed(added, removed): - Pokud odpojeno zařízení aktivní v hovoru → varování v ActiveCallBar „Zařízení bylo odpojeno — přepínám na výchozí" - Pokud odpojeno uložené zařízení (mimo hovor) → badge ⚠ v záložce Zvuk nastavení

3c. Nedostupné uložené zařízení

Při každém startu hovoru se ověří, zda uložené zařízení existuje v aktuálním seznamu.

Situace: uloženo "Headset Pro" — není v seznamu zařízení

┌──────────────────────────────────────────────────────┐
│  ⚠  Zařízení není dostupné                           │
│                                                      │
│  Uložené zařízení "Headset Pro" není připojeno.      │
│  Vyberte náhradní zařízení:                          │
│                                                      │
│  Mikrofon:    [▼ Windows výchozí zařízení     ]      │
│  Reproduktory:[▼ Reproduktory (Realtek Audio) ]      │
│                                                      │
│  ☐ Zapamatovat tuto volbu                            │
│                                                      │
│       [ Zrušit ]  [ Pokračovat v hovoru ]            │
└──────────────────────────────────────────────────────┘

4. Nastavení zařízení — záložka Zvuk

Rozšíření stávající sekce ZVUK v SettingsScreen.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 ZVUK — HOVORY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 Mikrofon
 [▼ Headset Pro (USB Audio)          ]  [ 🎙 Test ]
 Citlivost:  ━━━━●━━━━━━━━━  50 %
             [ ████░░░░░░░ ]  ← živý VU meter při testu

 Reproduktory / sluchátka
 [▼ Windows výchozí zařízení         ]  [ 🔊 Test ]
 Hlasitost:  ━━━━━━━●━━━━━  70 %

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 VIDEO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 Kamera
 [▼ Logitech C920 HD Pro Webcam      ]  [ 📷 Náhled ]
 Rozlišení: [▼ 720p 30fps ]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 NOTIFIKACE PŘÍCHOZÍCH HOVORŮ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 Vyzváněcí tón
 [▼ Klasický (built-in)              ]  [ ▶ Přehrát ]
    • Klasický
    • Jemný
    • Digitální
    • Vlastní soubor...              ← otevře file dialog (.mp3 .wav .ogg)

 Hlasitost vyzvánění:  ━━━━●━━━━━  40 %
 ☑ Záblesk ikony v trayu
 ☑ Windows toast notifikace
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Uložené klíče v settings.py:

settings["audio_input"]        # název zařízení nebo None (výchozí)
settings["audio_output"]       # název zařízení nebo None
settings["audio_input_vol"]    # 0–100
settings["audio_output_vol"]   # 0–100
settings["video_device"]       # index kamery nebo None
settings["video_resolution"]   # "720p" | "1080p" | "480p"
settings["ringtone"]           # "classic" | "soft" | "digital" | "/path/to/file"
settings["ringtone_vol"]       # 0–100
settings["notif_tray_flash"]   # bool
settings["notif_toast"]        # bool

Test mikrofonu — 3s nahrávka → živý VU meter (CTkProgressBar), pak přehrání. Náhled kamery — malé CTkToplevel okno 320×240, live stream z OpenCV, zavírací tlačítko.


5. Architektura

5a. Technologie

Vrstva Knihovna Verze
WebRTC (SDP, ICE, DTLS-SRTP, RTP) aiortc ≥ 1.9
Audio capture/playback sounddevice ≥ 0.4.6
Video capture opencv-python ≥ 4.9
Video zobrazení v UI Pillow (CTkImage) již závislost
Toast notifikace winotify ≥ 0.1.6
Zvuk vyzvánění pygame.mixer ≥ 2.5
Signalizace WS websockets ≥ 12.0

Proč aiortc? Řeší kompletní WebRTC stack v Pythonu — SDP offer/answer, ICE gathering, DTLS handshake, SRTP šifrování, Opus a H.264/VP8 kodeky. Alternativa (ruční sounddevice + opus přes WS) by vyžadovala implementaci vlastního ICE a šifrování.

5b. Nové soubory launcheru

desktop/launcher2/
├── voice_call.py      # VoiceVideoCall engine (aiortc + sounddevice + cv2)
├── voice_ui.py        # IncomingCallDialog, ActiveCallBar, VideoWindow
├── voice_notify.py    # Windows toast + tray flash + ringtone přehrávání
└── voice_devices.py   # DeviceManager — enumarace, hot-plug, persistence

5c. VoiceVideoCall (voice_call.py)

class VoiceVideoCall:
    def __init__(self, session, conv_id, with_video=False):
        self.conv_id = conv_id
        self.with_video = with_video
        self._pc: RTCPeerConnection = None   # aiortc
        self._ws = None                      # signaling WebSocket
        self._audio_track = None             # SounddeviceTrack
        self._video_track = None             # OpenCVTrack (pokud with_video)

    async def start_call(self) -> str:       # → offer SDP
    async def answer_call(self, sdp: str):   # přijmout příchozí hovor
    async def add_ice(self, candidate: dict):
    def mute_mic(self, muted: bool):
    def mute_speaker(self, muted: bool):
    def toggle_video(self, on: bool):
    async def hangup(self):

    # Callbacky (volá UI)
    on_remote_audio: Callable           # přijatý audio frame
    on_remote_video: Callable[[ndarray], None]  # přijatý video frame (cv2)
    on_state_change: Callable[[str], None]      # "connecting"|"connected"|"ended"
    on_error: Callable[[str], None]

5d. Backend — signalizace

Nové soubory: - backend/app/routers/voice.py — WebSocket /ws/voice/{conv_id} - Přidáno do main.py

WebSocket zprávy:

// Volající → backend → Přijímající
{"type": "offer",   "sdp": "...", "has_video": true,  "caller_name": "Jan Novák"}
{"type": "answer",  "sdp": "..."}
{"type": "ice",     "candidate": {"candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0}}
{"type": "hangup"}

// Backend → Přijímající (push)
{"type": "incoming_call", "conv_id": "...", "caller_name": "...", "has_video": true}

// Backend → Volající (push)
{"type": "call_rejected"}
{"type": "call_busy"}      // přijímající již v jiném hovoru

REST endpointy:

POST /voice/call             → zahájit hovor (pošle incoming_call push)
POST /voice/hangup           → ukončit
GET  /voice/status/{conv_id} → { "active": bool, "participants": [...] }

5e. ICE / STUN / TURN

ice_servers = [
    RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
    RTCIceServer(
        urls=["turn:vm-gateway.avaxis.cz:3478"],
        username="avax",
        credential="<TURN_SECRET>",  # z env proměnné
    ),
]
pc = RTCPeerConnection(RTCConfiguration(iceServers=ice_servers))

TURN server: coturn na vm-gateway. Nutný pokud jsou oba klienti za NAT/firewall. Konfigurace coturn: infrastructure/coturn.conf.


6. Příchozí hovor — notifikace

Příchozí hovor detekuje chat_poll.py (polling /chat/pending) nebo WS listener.

6a. Sekvence notifikací

1. Přijde {"type": "incoming_call", "caller_name": "Jan", "has_video": true}
2. Paralelně:
   a. 🔔 Zvuk vyzvánění (pygame.mixer, opakovaně dokud nepřijme/neodmítne)
   b. 📳 Záblesk ikony v trayu (střídá normální/zvýrazněnou ikonu á 500ms)
   c. 🪟 Windows Toast notifikace (winotify):
        "Jan Novák volá (video)"
        [Přijmout] [Odmítnout]
   d. 🪟 IncomingCallDialog v launcheru (pokud je okno otevřeno)
3. Timeout 30s → automatické odmítnutí ("missed call")

6b. IncomingCallDialog

┌─────────────────────────────────────┐
│                                     │
│   📞  Příchozí hovor                │
│                                     │
│   ┌───┐                             │
│   │ JN│  Jan Novák                  │
│   └───┘  Volá vás (video + audio)   │
│                                     │
│   ⏱ 00:12                           │
│                                     │
│   [ ❌ Odmítnout ]  [ ✅ Přijmout ] │
└─────────────────────────────────────┘
  • Zobrazí se vždy nad ostatními okny (attributes("-topmost", True))
  • Pokud launcher v trayu — okno se vynoří
  • Časovač odpočítává 30s
  • Přijmout: otevře ActiveCallBar + případně VideoWindow

6c. Toast notifikace (winotify)

from winotify import Notification, audio

notif = Notification(
    app_id="AVAX Launcher",
    title="Příchozí hovor",
    msg=f"{caller_name} vás volá",
    icon=str(ICON_PATH),
    duration="long",
)
notif.set_audio(audio.Default, loop=False)  # vlastní zvuk přes pygame
notif.add_actions(label="Přijmout",  launch="avax://call/accept")
notif.add_actions(label="Odmítnout", launch="avax://call/reject")
notif.show()

Klik na akci v toastu pošle URI do launcheru (registrovaný URL handler avax://).


7. UI komponenty (voice_ui.py)

7a. ActiveCallBar

Zobrazí se nad vstupní lištou ConversationPanel po dobu hovoru:

┌────────────────────────────────────────────────────────────────┐
│ 🔴 HOVOR  ⏱ 00:03:42   Jan Novák                              │
│ [ 🎙 Mikrofon ]  [ 🔊 Zvuk ]  [ 📷 Kamera ]  [ 📞 Zavěsit ]  │
└────────────────────────────────────────────────────────────────┘
  • Mikrofon: toggle mute/unmute (červená = muted)
  • Zvuk: toggle mute reproduktoru
  • Kamera: toggle video on/off (zobrazí/skryje VideoWindow)
  • Zavěsit: ukončí hovor, odstraní bar

7b. VideoWindow

Samostatné CTkToplevel okno, zobrazí se vedle ChatWindow:

┌──────────────────────────────────────────────────┐
│  Hovor — Jan Novák                    [━][□][✕]  │
│ ┌──────────────────────────────────────────────┐ │
│ │                                              │ │
│ │           remote video (hlavní)              │ │
│ │                          ┌──────────────┐   │ │
│ │                          │  local video │   │ │
│ │                          │   (malý roh) │   │ │
│ │                          └──────────────┘   │ │
│ └──────────────────────────────────────────────┘ │
│  [ 🎙 ]  [ 🔊 ]  [ 📷 ]  [ ⛶ Celá obrazovka ]  [ 📞 ] │
└──────────────────────────────────────────────────┘

Video frames z aiortc → OpenCV ndarray → PIL Image → CTkImage → CTkLabel. Refresh: after(33, ...) = ~30 fps.

Celá obrazovka: VideoWindow.state("zoomed") + skrytí dekorace.

Lokální náhled (pip — picture-in-picture): - 160×120 px, vpravo dole v remote videu - Kliknutím přepne strany (remote ↔ local velký)


8. Šifrování a bezpečnost

Vrstva Mechanismus
Signalizace HTTPS/WSS (TLS na ws.avaxis.cz)
Audio/video DTLS-SRTP (zajišťuje aiortc automaticky)
TURN přihlašovací údaje HMAC-SHA1 time-limited credentials
Nahrávání zcela mimo scope (GDPR)

9. Zpracování chybových stavů

Situace Chování
Zařízení není k dispozici při startu Dialog výběru náhradního zařízení (viz sekce 3c)
Zařízení odpojeno během hovoru Toast „Zařízení odpojeno", přepnutí na výchozí bez přerušení hovoru
ICE connection failed (P2P blokován) Automatický fallback na TURN server
TURN selže Hovor ukončen, chybová hláška „Nelze navázat spojení"
Přijímající je offline 30s timeout → „Volání nepřijato"
Přijímající je v jiném hovoru Okamžitá zpráva „Uživatel je momentálně v hovoru" (call_busy)
Ztráta spojení WS Pokus o reconnect 3× á 2s, pak ukončení hovoru
Kamera nedostupná Hovor pokračuje pouze s audiem, upozornění v ActiveCallBar

10. Závislosti (requirements.txt)

# Stávající
sounddevice>=0.4.6

# Nové
aiortc>=1.9.0          # WebRTC: SDP, ICE, DTLS-SRTP, Opus, H.264/VP8
opencv-python>=4.9.0   # video capture (headless build — bez GUI)
winotify>=0.1.6        # Windows toast notifikace
pygame>=2.5.0          # přehrávání zvuku vyzvánění
websockets>=12.0       # WS signalizace pro voice

opencv-python vs opencv-python-headless: Použít opencv-python (má GUI okna cv2.imshow, ale my je nepoužijeme — frames předáme do CTkImage). Headless build je o ~10 MB menší, ale nemusí mít správné video backend drivery na Windows.

Windows: libopus.dll aiortc na Windows potřebuje opus.dll: 1. Stáhnout z https://github.com/xiph/opus/releases (precompiled) 2. Umístit do dist/ vedle exe 3. PyInstaller spec: datas=[("opus.dll", ".")]


11. Backend — nové soubory a migrace

backend/app/routers/voice.py    # WS /ws/voice/{conv_id} + REST /voice/*
backend/app/services/voice.py   # session management (kdo s kým hovoří)

DB tabulka (volitelná — pro statistiky a missed calls):

CREATE TABLE voice_sessions (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    conv_id      UUID REFERENCES conversations(id),
    caller_id    UUID REFERENCES users(id),
    callee_id    UUID REFERENCES users(id),
    has_video    BOOLEAN DEFAULT false,
    started_at   TIMESTAMPTZ,
    answered_at  TIMESTAMPTZ,
    ended_at     TIMESTAMPTZ,
    end_reason   VARCHAR(30)  -- 'hangup' | 'rejected' | 'missed' | 'error'
);


12. Integrace do ChatWindow

# ConversationPanel._build() — tlačítka v input baru:
self._voice_btn = CTkButton(bar, text="🎙", command=self._start_voice)
self._video_btn = CTkButton(bar, text="📷", command=self._start_video)
# (oba zatím disabled, zapnout až po implementaci)

# ConversationPanel:
def _start_voice(self):
    from voice_call import VoiceVideoCall
    call = VoiceVideoCall(self.session, self.conv["id"], with_video=False)
    asyncio.run_coroutine_threadsafe(call.start_call(), self._loop)
    self._show_active_bar(call)

def _start_video(self):
    from voice_call import VoiceVideoCall
    call = VoiceVideoCall(self.session, self.conv["id"], with_video=True)
    ...

asyncio event loop pro aiortc běží v samostatném daemonickém vlákně (aiortc je async, tkinter je synchronní — nutná most).


13. Odhad implementace

Část Odhad
voice_devices.py — DeviceManager, hot-plug, settings 2 dny
voice_notify.py — toast, tray, ringtone, URL handler 1 den
voice_call.py — VoiceVideoCall (aiortc engine) 3 dny
voice_ui.py — IncomingCallDialog, ActiveCallBar, VideoWindow 2 dny
Backend signalizace (WS + REST) 2 dny
TURN server konfigurace (coturn) 0.5 dne
Nastavení zařízení (Settings screen rozšíření) 1 den
Integrace do ChatWindow + testování 2 dny
Celkem ~13.5 dne

14. Phase P2 — dokončení & hardening

Grounded gap-analýza 2026-05-16. P2 NENÍ greenfieldvoice_devices.py, voice_notify.py, voice_ui.py + Settings audio/video/notif sekce jsou z velké části napsané (P0 stabilizace + P1 ICE/TURN shipped). P2 = zapojit existující nedrátované kusy + zahärdrovat in-call chyby + audit latentních P0-class bugů. Odhad ~3 dny dev (mimo user/infra-blokované).

P2.1 — In-call robustness (spec sek. 9) ✅ HOTOVO 2026-05-16 (60160d2)

Vše ve voice_call.py (dnes chybí — grep potvrdil 0 výskytů reconnect/ device-disconnect/camera-unavail handling):

  • WS reconnect 3× á 2 s než hovor skončí. Dnes: _ws_recv_loop výjimka → rovnou _async_hangup. Cíl: 3 pokusy reconnect na /ws/voice/{conv}, re-send poslední SDP stav, pak teprve hangup.
  • Kamera nedostupná při startu → pokračuj audio-only + notice v ActiveCallBar. Dnes: resolve_camera() < 0 → tiše bez video tracku.
  • Zařízení odpojeno během hovoru → switch na default bez přerušení (viz P2.2 hot-plug wiring).
  • ICE failed → ověřit že TURN fallback se reálně uplatní (P1 ice-servers + coturn hotové; chybí E2E za-NAT test = user, 2 stroje).
  • Akceptace: vytáhnout síť na 5 s během hovoru → po obnově hovor pokračuje (ne drop). Start bez kamery → audio hovor + hláška.

P2.2 — Device resilience wiring (sek. 3b/3c) ✅ HOTOVO 2026-05-16 (60160d2+)

Substitution dialog (3c): nový voice_ui.SubstituteDeviceDialog (modální, blokující). chat_window _start_call_internal/ _accept_incoming po capability gate: !is_saved_*_available → dialog (mic/kamera dropdown + Zapamatovat), Zrušit → abort/reject. Hot-plug (3b): voice_call._async_startdm.start() + dm.add_listener(_on_devices_changed); aktivní zařízení odpojeno → on_notice + hovor POKRAČUJE (NEteardown PC); _async_hangup odregistruje. Ověřeno izolovaně (notice 1×, neaktivní 0, ended-guard). DEFER: seamless switch na default mid-call (přepnutí audio capture bez výpadku) — SounddeviceAudioTrack nemá live reopen API → vyžaduje audio-track refaktor (P3). Dnes: notice „odpoj → vyber v Nastavení / restartuj hovor", hovor se NEpřeruší. SubstituteDeviceDialog „Zapamatovat" persistuje vždy (ephemeral-only override = P3). Původní gap-analýza níže:

  • Substitution dialog (3c): DeviceManager.is_saved_audio_input_ available() / is_saved_camera_available() existují ale nikde se nevolají. Zapojit do chat_window._start_call_internal / _accept_incoming PŘED startem: pokud uložené zařízení chybí → nový malý dialog voice_ui.SubstituteDeviceDialog (dropdown náhrady + „Zapamatovat") → pak teprv call.
  • Hot-plug během hovoru (3b): DeviceManageradd_listener + _scan_loop, ale VoiceVideoCall se nepřihlašuje. Zapojit: dm.add_listener(self._on_devices_changed) v _async_start; při odpojení aktivního zařízení → ActiveCallBar varování + přepnout track na default (sounddevice reopen) bez teardownu PC.
  • Akceptace: odpoj USB headset během hovoru → hláška + hovor jede dál přes default. Uložený mic chybí při startu → substitution dialog.

P2.3 — Settings polish & verify (sek. 4) ✅ HOTOVO 2026-05-16

Audit Settings „Zvuk — Hovory / Video / Notifikace" (screens.py): všechny 4 položky existují a jsou zadrátované_test_microphone (VU meter RMS), _preview_camera_CameraPreviewDialog, ringtone „Vlastní soubor…" filedialog (.mp3/.wav/.ogg), notif toggly (notif_tray_flash / notif_toast checkboxy → settings.set).

Nalezeno+opraveno: _test_microphone měl P0-class bug — sd.sleep(3000) + sd.play běžely na Tk threadu (button command) → UI zamrzlé 3 s, VU meter se nevykresloval live (after callbacky čekaly za blokem). → přesunuto do worker threadu (VU/reset přes self.after, _mic_vu_active guard). Teď VU meter reálně animuje + UI neztuhne.

Ověřeno čisté: _CameraPreviewDialog má korektní cv2 lifecycle (_running flag, WM_DELETE_WINDOW_closecap.release()+destroy, after(33,…) ~30fps na main threadu) — žádný handle leak. _test_ speakers sd.play non-blocking OK.

Vizuální chování (VU meter animace, camera náhled render) = uživatelův launcher test (avaxdev headless bez displeje/AV). Logika + threading + cv2 lifecycle ověřeny code-review + py_compile. Původní gap-analýza níže:

Settings „Zvuk — Hovory / Video / Notifikace" existuje (mic/spk/vol/ VU/kamera dropdown/ringtone+play/vol). Ověřit/dodělat: - Camera live „Náhled" (320×240 CTkToplevel z OpenCV) — ověřit že funguje + zavírací cleanup (uvolnit cv2.VideoCapture). - VU meter reálně animuje při test mikrofonu (3 s capture). - Ringtone „Vlastní soubor…" → file dialog (.mp3/.wav/.ogg). - Notif toggly (tray flash / toast) checkboxy přítomné + uložené.

P2.4 — Audit latentních P0-class bugů ✅ HOTOVO 2026-05-16

Nalezeno+opraveno: voice_notify.pyimport numpy byl NEguardovaný (P0-class nekonzistence, voice_notify se při P0 vynechal) → guard + syntetický tón no-op když np is None. Pre-existing SyntaxWarning: invalid escape '\S' (docstring HKCU\Software) → raw docstring. Ověřeno python -W error::SyntaxWarning clean.

Ověřeno čisté: voice_devices.py (sd/cv2 guarded, scan thread daemon+stop(), cv2 cap.release(), bez numpy, bez Tk, bez blokujícího .result/sleep na Tk threadu). voice_ui.py (čistě ctk, set_remote/ local_frame marshallují přes after, timery přes after). voice_notify Tk-safety: nesahá na Tk; _on_timeout volá callback z Timer threadu, ale caller (chat_window) marshalluje přes self.after — dokumentovaný kontrakt, OK.

VYŘEŠENO 2026-05-16 (commit 360ed20) — orig. DEFER → P3: list_cameras() se v _scan_loop volala každý HOT_PLUG_INTERVAL a (po camera-open robustness fixu) kameru reálně otevírala+read() → LED bliká i mimo hovor + kolize s aktivním video hovorem. Fix: kamery úplně mimo background scan_scan_once jen audio (sounddevice query nic neotevírá). list_cameras() jen on-demand (Nastavení→Video, start video hovoru). Tradeoff: žádný background camera hot-plug (audio unplug = kritický, kryt; camera-at-start řeší P2.1). Původní gap-analýza níže:

voice_devices/notify/ui vznikly PŘED P0 fixy → projít stejnou optikou: - guard heavy importů (sounddevice/cv2/pygame/winotify) — voice_devices má guard; ověřit voice_notify (pygame/winotify) a že Settings sekce degraduje když chybí (ne traceback). - žádné Tk volání z non-main threadu (_scan_loop, ringtone thread, notifier callbacky) — vše přes root.after. - žádné blokující .result() / time.sleep na Tk threadu. - dead callbacky / lifecycle leaky (ringtone thread join, scan thread stop, cv2 capture release).

P2.5 — Production TURN (sek. 5e/8) ⏳ BLOKOVÁNO user/infra

Dev coturn na avaxdev:3478 hotové (P1). Produkce vyžaduje (mimo dev režii — stejný blocker jako Phase 4 docs, vm-gateway není provisioned): coturn na vm-gateway + external-ip=<veřejná> + TLS 5349 + anti-SSRF hardening (dnes jen loopback/multicast deny) + DNS. Až bude vm-gateway: TURN_HOST env → vm-gateway, cert, firewall UDP 3478 + relay range.

P2 priorita

P2.1 (robustness) → P2.2 (device wiring) → P2.4 (audit) → P2.3 (polish). P2.5 čeká na vm-gateway (user/infra). E2E reálný hovor mezi 2 stroji = user (avaxdev headless, bez AV).