AI_Photographer/Modes/Manual/trigger_loop.py
2026-04-12 18:52:37 +04:00

184 lines
6.5 KiB
Python

# trigger_loop.py
import asyncio
import threading
import time
from pathlib import Path
from Core import settings as config
from Core.Logger import Logs
from Server.capture_service import capture_with_replay_sync, replay_timing_profile
sanad_logger = Logs()
sanad_logger.LogEngine("G1_Logs", "trigger_loop")
async def _play_prompt(voice, prompt_key: str, fallback_text: str) -> bool:
try:
return bool(await voice.play_prompt_key(prompt_key, fallback_text))
except Exception as e:
sanad_logger.print_and_log(f"⚠️ Prompt playback failed for {prompt_key}: {e}", message_type="warning")
return False
async def _play_capture_prompt(voice, prompt_key: str, fallback_text: str) -> bool:
try:
return bool(
await voice.play_prompt_key(
prompt_key,
fallback_text="",
allow_gemini_fallback=False,
mode_override="audio",
)
)
except Exception as e:
sanad_logger.print_and_log(f"⚠️ Capture prompt playback failed for {prompt_key}: {e}", message_type="warning")
return False
async def _play_manual_capture_prompt_sequence(
voice,
shot_delay_sec: float,
cancel_event: threading.Event,
):
start_ts = time.time()
await _play_capture_prompt(
voice,
"countdown_intro",
"Look at the camera, stay ready, hold your pose with me, keep still, keep your smile soft, and in a moment I will count down for the photo.",
)
announced = set()
while True:
if cancel_event.is_set():
raise asyncio.CancelledError
remaining = max(0.0, shot_delay_sec - (time.time() - start_ts))
sec_left = int(round(remaining))
if sec_left in (3, 2, 1) and sec_left not in announced:
announced.add(sec_left)
await _play_capture_prompt(voice, f"count_{sec_left}", f"{sec_left}...")
elif sec_left <= 0 and 0 not in announced:
announced.add(0)
await _play_capture_prompt(voice, "smile", "Smile.")
return
await asyncio.sleep(0.05)
async def trigger_loop(
hub,
replay,
voice,
take_photo_sync_callable=None,
ws=None,
):
"""
R2+X:
- play the fixed capture prompt sequence (recorded audio by default)
- run unified capture pipeline (replay + timed capture/upload)
R2+L1:
- cancels pending delayed photo (replay cancel handled in replay engine)
Debounce:
- must release R2+X to re-trigger
- blocks retrigger for PHOTO_TOTAL_SEC window
"""
r2x_prev = False
waiting_release = False
photo_busy_until = 0.0
pending_photo_task: asyncio.Task | None = None
pending_audio_task: asyncio.Task | None = None
pending_capture_cancel: threading.Event | None = None
def _hard_cancel_pressed() -> bool:
if hasattr(hub, "hard_cancel_combo"):
return bool(hub.hard_cancel_combo())
return bool(hub.combo_r2l1())
async def delayed_photo(delay_sec: float, cancel_event: threading.Event):
# Unified pipeline: replay + trigger/fallback capture + upload touch.
base_prefix = None # keep existing default naming behavior
photo_result = await asyncio.to_thread(
capture_with_replay_sync,
replay_runner=replay,
replay_file=config.REPLAY_FILE,
home_file=config.HOME_FILE,
delay_sec=delay_sec,
prefix=base_prefix,
speed=1.0,
cancel_event=cancel_event,
)
sanad_logger.print_and_log(f"📸 Capture pipeline result: {photo_result}", message_type="info")
if cancel_event.is_set():
return
if isinstance(photo_result, str) and not photo_result.startswith("[ERR]"):
await _play_capture_prompt(
voice,
"photo_saved_thanks",
"Thank you. Photo saved. Do not forget to check your photos.",
)
while True:
await asyncio.sleep(0.02)
if not hub.first_state:
continue
# Cancel combo: cancel pending photo
if _hard_cancel_pressed():
if pending_capture_cancel is not None:
pending_capture_cancel.set()
if pending_photo_task and not pending_photo_task.done():
pending_photo_task.cancel()
if pending_audio_task and not pending_audio_task.done():
pending_audio_task.cancel()
sanad_logger.print_and_log("🛑 HARD CANCEL: pending photo cancelled (R2+L1).", message_type="warning")
r2x_now = hub.combo_r2x()
# Require release before next trigger
if waiting_release:
if not r2x_now:
waiting_release = False
r2x_prev = r2x_now
continue
# Do not retrigger while replay is playing
if replay.is_playing:
r2x_prev = r2x_now
continue
now = time.time()
# Rising edge trigger + not inside busy window
if r2x_now and (not r2x_prev) and (now >= photo_busy_until):
waiting_release = True
# 1) schedule manual countdown speech using the same prompt keys as AI mode
if pending_photo_task and not pending_photo_task.done():
if pending_capture_cancel is not None:
pending_capture_cancel.set()
pending_photo_task.cancel()
if pending_audio_task and not pending_audio_task.done():
pending_audio_task.cancel()
timing = replay_timing_profile(config.REPLAY_FILE)
delay = float(timing.get("capture_offset_sec") or max(0.0, min(config.PHOTO_DELAY_SEC, config.PHOTO_TOTAL_SEC)))
replay_duration = float(timing.get("duration_sec") or 0.0)
photo_busy_until = now + max(config.PHOTO_TOTAL_SEC, replay_duration + 3.2, delay + config.PHOTO_THANKS_SEC)
sanad_logger.print_and_log(
"🎯 Manual capture timing: "
f"replay={Path(config.REPLAY_FILE).name} "
f"duration={replay_duration:.3f}s "
f"shot_at={delay:.3f}s "
f"source={timing.get('capture_source', 'config_fallback')}",
message_type="info",
)
pending_capture_cancel = threading.Event()
pending_audio_task = asyncio.create_task(
_play_manual_capture_prompt_sequence(voice, delay, pending_capture_cancel)
)
pending_photo_task = asyncio.create_task(delayed_photo(delay, pending_capture_cancel))
r2x_prev = r2x_now