import ast import os import re import time import asyncio from Core import settings as config # ================================================== # 🔤 Arabic & English normalization # ================================================== def _norm_ar(s: str) -> str: s = (s or "").strip().lower() if not s: return "" # Arabic punctuation: ؟ ، ؛ s = re.sub(r"[\u061F\u060C\u061B]", " ", s) # ✅ Keep Arabic, English letters, word chars, and spaces s = re.sub(r"[^\w\s\u0600-\u06FFa-zA-Z]", " ", s) # collapse spaces s = re.sub(r"\s+", " ", s).strip() # normalize hamza/alif variants s = s.replace("أ", "ا").replace("إ", "ا").replace("آ", "ا") # ta marbuta -> ha, alif maqsoora -> ya s = s.replace("ة", "ه").replace("ى", "ي") # tatweel s = s.replace("ـ", "") # common nickname normalization s = s.replace("ابو", "بو") return s.strip() # ================================================== # 📂 Load wake phrases from a python file (sanad_arm.txt) # Modified to search the local Scripts folder if not found next to module # ================================================== def load_arm_phrases(filename: str = "sanad_arm.txt", *, var_name: str = "WAKE_PHRASES") -> set[str]: base_dir = os.path.dirname(os.path.abspath(__file__)) # Candidate locations (module dir, module dir / Scripts, parent / Scripts) candidates = [ os.path.join(base_dir, filename), os.path.join(base_dir, "Scripts", filename), os.path.join(base_dir, "..", "Scripts", filename), ] arm_path = None for c in candidates: if os.path.exists(c): arm_path = c break if arm_path is None: raise FileNotFoundError(f"Arm phrases file not found in candidates: {candidates}") print(f"📂 Loading arm phrases from: {arm_path} (var={var_name})") with open(arm_path, "r", encoding="utf-8-sig") as f: raw = f.read() # Try to parse as python file (legacy format with WAKE_PHRASES variables). # Uses ast.parse + ast.literal_eval instead of exec() for safety. try: tree = ast.parse(raw) for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == var_name: phrases = ast.literal_eval(node.value) if isinstance(phrases, (set, list, tuple)): out = {_norm_ar(str(p)) for p in phrases if str(p).strip()} out = {p for p in out if p} print(f"✅ Loaded {len(out)} wake phrases from file variable: {var_name}") return out except Exception: # fall through to plain text parsing pass # Plain-text newline-separated phrases fallback lines = [ln.strip() for ln in raw.splitlines()] cand = [ln for ln in lines if ln and not ln.startswith("#")] out = {_norm_ar(ln) for ln in cand} out = {p for p in out if p} print(f"✅ Loaded {len(out)} wake phrases from plain text file: {arm_path}") return out # ================================================== # 🧠 Wake phrase matching + scheduling # ================================================== def _is_valid_text(s: str) -> bool: # Allow Arabic OR English letters has_ar = bool(re.search(r"[\u0600-\u06FF]", s or "")) has_en = bool(re.search(r"[a-zA-Z]", s or "")) return has_ar or has_en def _strip_ya_prefix(s: str) -> str: s = (s or "").strip() if not s: return "" if s.startswith("يا "): return s[3:].strip() if s.startswith("يا"): return s[2:].strip() return s def _remove_al_prefix_words(text: str) -> str: if not text: return "" parts = text.split() out = [] for w in parts: if w.startswith("ال") and len(w) > 2: out.append(w[2:]) else: out.append(w) return " ".join(out).strip() def _maybe_trigger_arm( obj, transcript_text: str, wake_phrases: set[str], *, fire_on_wake_match: bool = False, arm_trigger_fn=None, ) -> bool: if not transcript_text or not wake_phrases: return False # initialization of state variables (copied from original logic) if not hasattr(obj, "_asr_buf"): obj._asr_buf = "" if not hasattr(obj, "_asr_last_time"): obj._asr_last_time = 0.0 if not hasattr(obj, "ASR_WINDOW_SEC"): obj.ASR_WINDOW_SEC = 2.0 if not hasattr(obj, "ASR_SHORT_TOKEN_BONUS_SEC"): obj.ASR_SHORT_TOKEN_BONUS_SEC = 1.0 if not hasattr(obj, "ASR_JOIN_NO_SPACE_MAXLEN"): obj.ASR_JOIN_NO_SPACE_MAXLEN = 2 if not hasattr(obj, "ASR_MAX_CHARS"): obj.ASR_MAX_CHARS = 120 if not hasattr(obj, "_last_trigger_norm"): obj._last_trigger_norm = "" if not hasattr(obj, "_last_trigger_time"): obj._last_trigger_time = 0.0 if not hasattr(obj, "TRIGGER_DEDUP_WINDOW"): obj.TRIGGER_DEDUP_WINDOW = 2.0 if not hasattr(obj, "_pending_arm_wave"): obj._pending_arm_wave = False if not hasattr(obj, "_pending_arm_wave_fired"): obj._pending_arm_wave_fired = False if not hasattr(obj, "_pending_arm_wave_set_time"): obj._pending_arm_wave_set_time = 0.0 if not hasattr(obj, "PENDING_ARM_TTL"): obj.PENDING_ARM_TTL = 6.0 if not hasattr(obj, "_pending_arm_trigger_fn"): obj._pending_arm_trigger_fn = None if not hasattr(obj, "_pending_arm_fallback_time"): obj._pending_arm_fallback_time = 0.0 if not hasattr(obj, "_last_piece_call_norm"): obj._last_piece_call_norm = "" if not hasattr(obj, "_last_piece_call_time"): obj._last_piece_call_time = 0.0 if not hasattr(obj, "_asr_stream"): obj._asr_stream = "" if not hasattr(obj, "ASR_STREAM_MAX_CHARS"): obj.ASR_STREAM_MAX_CHARS = 80 dup_call_window = float(getattr(obj, "DUP_CALL_WINDOW_SEC", 0.25)) dup_asr_repeat_window = float(getattr(obj, "DUP_ASR_REPEAT_WINDOW_SEC", 0.9)) pending_fallback_sec = float(getattr(obj, "PENDING_ARM_FALLBACK_SEC", 0.65)) piece_raw = (transcript_text or "").strip() if not piece_raw: return False piece_norm = _norm_ar(piece_raw) if not piece_norm: return False now = time.time() if not _is_valid_text(piece_norm): return False duplicate_call = ( (piece_norm == obj._last_piece_call_norm) and ((now - obj._last_piece_call_time) < dup_call_window) ) repeated_asr = ( (piece_norm == obj._last_piece_call_norm) and ((now - obj._last_piece_call_time) < dup_asr_repeat_window) ) obj._last_piece_call_norm = piece_norm obj._last_piece_call_time = now if not duplicate_call and not repeated_asr: print(f"📝 USER SAID (raw): {piece_raw}") print(f"📝 USER SAID (norm): {piece_norm}") if not duplicate_call and not repeated_asr: if obj._asr_last_time: gap = now - obj._asr_last_time window = obj.ASR_WINDOW_SEC if len(piece_norm) <= obj.ASR_JOIN_NO_SPACE_MAXLEN: window += obj.ASR_SHORT_TOKEN_BONUS_SEC if gap > window: obj._asr_buf = "" obj._asr_stream = "" obj._asr_last_time = now if obj._asr_buf: if len(piece_norm) <= obj.ASR_JOIN_NO_SPACE_MAXLEN: obj._asr_buf = (obj._asr_buf + piece_norm).strip() else: obj._asr_buf = (obj._asr_buf + " " + piece_norm).strip() else: obj._asr_buf = piece_norm compact = piece_norm.replace(" ", "") obj._asr_stream = (obj._asr_stream + compact)[-obj.ASR_STREAM_MAX_CHARS :] if len(obj._asr_buf) > obj.ASR_MAX_CHARS: obj._asr_buf = obj._asr_buf[-obj.ASR_MAX_CHARS :] buf_norm = _norm_ar(obj._asr_buf) buf_nospace = buf_norm.replace(" ", "") buf_noal = _remove_al_prefix_words(buf_norm) buf_noal_nospace = buf_noal.replace(" ", "") stream = _norm_ar(obj._asr_stream).replace(" ", "") stream_noal = _remove_al_prefix_words(stream) if not duplicate_call and not repeated_asr: print(f"🧩 ASR BUFFER: {buf_norm}") if ( buf_norm == obj._last_trigger_norm and (now - obj._last_trigger_time) < obj.TRIGGER_DEDUP_WINDOW ): return False for phrase in wake_phrases: p_norm = _strip_ya_prefix(_norm_ar(str(phrase))) if not p_norm: continue p_nospace = p_norm.replace(" ", "") p_noal = _remove_al_prefix_words(p_norm) p_noal_nospace = p_noal.replace(" ", "") pattern = r'\b' + re.escape(p_norm) + r'\b' hit_buf = bool(re.search(pattern, buf_norm)) or (p_nospace and p_nospace == buf_nospace) hit_buf = hit_buf or (p_noal and (p_noal in buf_noal or (p_noal_nospace and p_noal_nospace in buf_noal_nospace))) hit_stream = False if p_nospace and p_nospace in stream: hit_stream = True elif p_noal_nospace and p_noal_nospace in stream_noal: hit_stream = True if hit_buf or hit_stream: if hit_stream and not hit_buf: print(f"⚡ FAST MATCH: '{phrase}' (recent tokens)") else: print(f"✅ MATCH: '{phrase}' in BUFFER='{obj._asr_buf}'") obj._last_trigger_norm = buf_norm obj._last_trigger_time = now obj._asr_buf = "" obj._asr_last_time = 0.0 obj._asr_stream = "" if fire_on_wake_match: if arm_trigger_fn: asyncio.create_task(asyncio.to_thread(arm_trigger_fn)) obj._pending_arm_wave = False obj._pending_arm_wave_fired = False obj._pending_arm_wave_set_time = 0.0 obj._pending_arm_trigger_fn = None obj._pending_arm_fallback_time = 0.0 else: obj._pending_arm_wave = True obj._pending_arm_wave_fired = False obj._pending_arm_wave_set_time = now obj._pending_arm_trigger_fn = arm_trigger_fn obj._pending_arm_fallback_time = now + pending_fallback_sec return True return False # ================================================== # 🔁 Phrase map loader (plain-text grouped format) # Format: groups separated by blank lines. First line = canonical command, # following lines = aliases. _phrase_map_cache = {} def load_phrase_map(filename: str = "photo_command_ai.txt") -> dict: base_dir = os.path.dirname(os.path.abspath(__file__)) candidates = [ str(config.PHOTO_PHRASES_FILE), os.path.join(base_dir, filename), os.path.join(base_dir, "Scripts", filename), os.path.join(base_dir, "Data", filename), os.path.join(base_dir, "..", "Data", "Scripts", filename), os.path.join(base_dir, "..", "Scripts", filename), os.path.join(base_dir, "..", "Data", filename), ] path = None for c in candidates: if os.path.exists(c): path = c break if path is None: return {} if path in _phrase_map_cache: return _phrase_map_cache[path] with open(path, 'r', encoding='utf-8-sig') as f: lines = [ln.rstrip() for ln in f.readlines()] groups = [] cur = [] for ln in lines: s = ln.strip() if not s: if cur: groups.append(cur) cur = [] continue if s.startswith("#"): continue cur.append(s) if cur: groups.append(cur) mapping = {} for g in groups: cmd = g[0] aliases = g for a in aliases: mapping[_norm_ar(a)] = cmd _phrase_map_cache[path] = mapping print(f"✅ Loaded phrase mapping from {path}: {len(mapping)} aliases") return mapping