import sys import os import json import threading import time import shutil import subprocess import tkinter as tk from tkinter import filedialog, messagebox, simpledialog import tkinter.ttk as ttk import customtkinter as ctk from datetime import datetime import numpy as np import pyaudio from vosk import Model, KaldiRecognizer import sounddevice as sd import soundfile as sf import shlex # ———————————————————————————— # Thème CustomTkinter # ———————————————————————————— ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") # ———————————————————————————— # Chemins et configuration # ———————————————————————————— if getattr(sys, 'frozen', False): DATA_DIR = os.path.dirname(sys.executable) else: DATA_DIR = os.path.dirname(__file__) CONFIG = os.path.join(DATA_DIR, 'config.json') SON_DIR = os.path.join(DATA_DIR, 'Son') MODEL_DIR = os.path.join(DATA_DIR, 'Model') os.makedirs(SON_DIR, exist_ok=True) # ———————————————————————————— # Chargement config # ———————————————————————————— mappings = [] if os.path.isfile(CONFIG): with open(CONFIG, 'r', encoding='utf-8') as f: raw = json.load(f) if isinstance(raw, list): mappings = raw elif isinstance(raw, dict): for ph, fn in raw.items(): fn = str(fn) mappings.append({'phrase': ph, 'name': fn, 'path': os.path.join(SON_DIR, fn), 'args': ''}) preloaded_sounds = {} for m in mappings: if m['path'].lower().endswith('.mp3') and os.path.isfile(m['path']): data, fs = sf.read(m['path'], dtype='int16') preloaded_sounds[m['path']] = (data, fs) # ———————————————————————————— # Audio devices # ———————————————————————————— pa = pyaudio.PyAudio() def_host = pa.get_default_host_api_info()['index'] input_devices = [ (i, pa.get_device_info_by_index(i)['name']) for i in range(pa.get_device_count()) if pa.get_device_info_by_index(i)['hostApi'] == def_host and pa.get_device_info_by_index(i)['maxInputChannels'] > 0 ] output_devices = [ (i, pa.get_device_info_by_index(i)['name']) for i in range(pa.get_device_count()) if pa.get_device_info_by_index(i)['hostApi'] == def_host and pa.get_device_info_by_index(i)['maxOutputChannels'] > 0 ] # ———————————————————————————— # Variables reconnaissance # ———————————————————————————— model = None rec = None stream = None loaded_models = {} volume_var = None FRAMES_PER_BUFFER = 1024 stream_lock = threading.Lock() # ———————————————————————————— # Options de reconnaissance (modifiables en live) # ———————————————————————————— opt_trigger_on_partial = False # déclencher sur résultat partiel ou final seulement opt_cooldown = 1.5 # secondes min entre deux déclenchements opt_anti_doublon = 3.0 # secondes avant de pouvoir re-déclencher la MÊME phrase opt_min_length = 2 # longueur minimale du texte reconnu (en caractères) opt_log_partials = True # afficher les partiels dans les logs # État interne cooldown / anti-doublon _last_trigger_time = 0.0 _last_trigger_phrase = "" def open_stream(idx): global stream, rec with stream_lock: if stream: try: stream.stop_stream() stream.close() except: pass stream = None rec = None try: stream = pa.open( format=pyaudio.paInt16, channels=1, rate=16000, input=True, input_device_index=idx, frames_per_buffer=FRAMES_PER_BUFFER ) if model: rec = KaldiRecognizer(model, 16000) except Exception as e: stream = None print(f"[open_stream] Erreur : {e}") def play_or_launch(path, out_idx, args_str=''): ext = os.path.splitext(path)[1].lower() if ext == '.mp3' and path in preloaded_sounds: data, fs = preloaded_sounds[path] vol = volume_var.get() / 100.0 buf = (data.astype(np.float32) * vol).clip(-32768, 32767).astype('int16') sd.play(buf, fs, device=out_idx) else: cmd = [path] + shlex.split(args_str) subprocess.Popen(cmd) # ———————————————————————————— # Log queue (thread-safe) # ———————————————————————————— import queue log_queue = queue.Queue() last_partial = '' def log_add(text, kind='partial'): """ kind: 'partial' → texte gris en cours de reconnaissance 'final' → texte blanc finalisé 'trigger' → déclenchement vert 'info' → info bleue """ ts = datetime.now().strftime('%H:%M:%S') log_queue.put((ts, text, kind)) def recognition_loop(): global last_partial, _last_trigger_time, _last_trigger_phrase while True: with stream_lock: cur_stream = stream cur_rec = rec if not cur_rec or not cur_stream: time.sleep(0.05) continue try: cur_rec.Reset() while True: with stream_lock: if stream is not cur_stream or rec is not cur_rec: break try: buf = cur_stream.read(FRAMES_PER_BUFFER, exception_on_overflow=False) except OSError: break is_final = cur_rec.AcceptWaveform(buf) if is_final: text = json.loads(cur_rec.Result()).get('text', '').lower() if text.strip(): log_add(text, 'final') last_partial = '' else: text = json.loads(cur_rec.PartialResult()).get('partial', '').lower() if text.strip() and text != last_partial: if opt_log_partials: log_add(text, 'partial') last_partial = text # Ne traiter que si le mode le permet if not text.strip(): continue if len(text) < opt_min_length: continue if not is_final and not opt_trigger_on_partial: continue now = time.time() for m in mappings: if m['phrase'] in text: # Cooldown global if now - _last_trigger_time < opt_cooldown: break # Anti-doublon par phrase if (m['phrase'] == _last_trigger_phrase and now - _last_trigger_time < opt_anti_doublon): break out_idx = int(out_var.get().split(' – ')[0]) play_or_launch(m['path'], out_idx, m.get('args', '')) log_add(f"🔊 \"{m['phrase']}\" → {m['name']}", 'trigger') _last_trigger_time = now _last_trigger_phrase = m['phrase'] break else: continue break except Exception as e: print(f"[recognition_loop] Erreur : {e}") time.sleep(0.1) # ———————————————————————————— # Fenêtre principale # ———————————————————————————— root = ctk.CTk() root.title("Déclencheur Vocal") root.geometry("780x900") root.minsize(500, 600) root.resizable(True, True) CLR_BG = "#0f0f1a" CLR_CARD = "#1a1a2e" CLR_ACCENT = "#4f8ef7" CLR_GREEN = "#22c55e" CLR_RED = "#ef4444" CLR_ORANGE = "#f97316" root.configure(fg_color=CLR_BG) # ———————————————————————————— # Layout horizontal principal # ———————————————————————————— main_frame = ctk.CTkFrame(root, fg_color="transparent") main_frame.pack(fill='both', expand=True) # Panneau gauche — wrapper fixe left_wrapper = ctk.CTkFrame(main_frame, fg_color="transparent") left_wrapper.pack(side='left', fill='both', expand=True) # Contenu scrollable left_panel = ctk.CTkScrollableFrame(left_wrapper, fg_color="transparent", scrollbar_button_color="#333", scrollbar_button_hover_color="#4f8ef7") left_panel.pack(fill='both', expand=True) # Panneau droit (logs) — initialement masqué log_panel_visible = tk.BooleanVar(value=False) log_panel = ctk.CTkFrame(main_frame, fg_color=CLR_CARD, corner_radius=0, width=300) # ———————————————————————————— # En-tête # ———————————————————————————— header = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) header.pack(fill='x', padx=16, pady=(16, 8)) ctk.CTkLabel( header, text="🎙 Déclencheur Vocal", font=ctk.CTkFont(family="Segoe UI", size=22, weight="bold"), text_color=CLR_ACCENT ).pack(side='left', padx=20, pady=14) # Bouton toggle log def toggle_log_panel(): if log_panel_visible.get(): log_panel.pack_forget() log_panel_visible.set(False) btn_log.configure(text="📋 Logs ▶") root.geometry(f"780x{root.winfo_height()}") else: log_panel.pack(side='left', fill='both', padx=(0, 0)) log_panel_visible.set(True) btn_log.configure(text="◀ Logs") root.geometry(f"1100x{root.winfo_height()}") btn_log = ctk.CTkButton( header, text="📋 Logs ▶", command=toggle_log_panel, width=110, height=32, fg_color="#1a2a4a", hover_color="#2a3a6a", font=ctk.CTkFont(size=12), corner_radius=8 ) btn_log.pack(side='right', padx=(8, 4), pady=10) status_model = ctk.CTkLabel( header, text="⏳ Chargement...", font=ctk.CTkFont(size=12), text_color="#888" ) status_model.pack(side='right', padx=8) # ———————————————————————————— # Barre de progression # ———————————————————————————— progress = ctk.CTkProgressBar(left_panel, mode='indeterminate', height=4, fg_color=CLR_CARD, progress_color=CLR_ACCENT) progress.pack(fill='x', padx=16, pady=(0, 4)) # ———————————————————————————— # Section modèle # ———————————————————————————— card_model = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) card_model.pack(fill='x', padx=16, pady=4) ctk.CTkLabel(card_model, text="Modèle Vosk", font=ctk.CTkFont(size=13, weight="bold"), text_color="#aaa" ).pack(anchor='w', padx=16, pady=(12, 4)) model_dirs = sorted(d for d in os.listdir(MODEL_DIR) if os.path.isdir(os.path.join(MODEL_DIR, d))) ordered = [] for pref in ('small', 'normal'): ordered += [m for m in model_dirs if pref in m.lower()] ordered += [m for m in model_dirs if m not in ordered] model_var = ctk.StringVar(value=ordered[0] if ordered else "") model_menu = ctk.CTkComboBox(card_model, variable=model_var, values=ordered, state='readonly', fg_color=CLR_BG, border_color=CLR_ACCENT, button_color=CLR_ACCENT, dropdown_fg_color=CLR_CARD) model_menu.pack(fill='x', padx=16, pady=(0, 12)) def load_model(name): root.after(0, progress.start) log_add(f"Chargement modèle : {name}", 'info') try: global model, rec new_model = Model(os.path.join(MODEL_DIR, name)) loaded_models[name] = new_model with stream_lock: model = new_model if stream: rec = KaldiRecognizer(model, 16000) root.after(0, lambda: status_model.configure(text=f"✅ {name}", text_color=CLR_GREEN)) log_add(f"Modèle prêt : {name}", 'info') except Exception as e: root.after(0, lambda: status_model.configure(text="❌ Erreur modèle", text_color=CLR_RED)) root.after(0, lambda: messagebox.showerror("Erreur modèle", str(e))) log_add(f"Erreur modèle : {e}", 'info') finally: root.after(0, progress.stop) model_menu.configure(command=lambda name: threading.Thread(target=load_model, args=(name,), daemon=True).start()) threading.Thread(target=load_model, args=(ordered[0],), daemon=True).start() for extra in ordered[1:]: threading.Thread(target=lambda m=extra: loaded_models.setdefault(m, Model(os.path.join(MODEL_DIR, m))), daemon=True).start() # ———————————————————————————— # Section Audio I/O # ———————————————————————————— card_io = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) card_io.pack(fill='x', padx=16, pady=4) ctk.CTkLabel(card_io, text="Périphériques Audio", font=ctk.CTkFont(size=13, weight="bold"), text_color="#aaa" ).grid(row=0, column=0, columnspan=2, sticky='w', padx=16, pady=(12, 4)) ctk.CTkLabel(card_io, text="🎤 Micro (entrée)", font=ctk.CTkFont(size=12), text_color="#888").grid(row=1, column=0, sticky='w', padx=16) in_var = ctk.StringVar() in_menu = ctk.CTkComboBox(card_io, variable=in_var, values=[f"{i} – {n}" for i, n in input_devices], state='readonly', fg_color=CLR_BG, border_color="#333", button_color="#333", dropdown_fg_color=CLR_CARD) in_menu.grid(row=2, column=0, sticky='we', padx=(16, 8), pady=(0, 12)) ctk.CTkLabel(card_io, text="🔊 Sortie audio", font=ctk.CTkFont(size=12), text_color="#888").grid(row=1, column=1, sticky='w', padx=(8, 16)) out_var = ctk.StringVar() out_menu = ctk.CTkComboBox(card_io, variable=out_var, values=[f"{i} – {n}" for i, n in output_devices], state='readonly', fg_color=CLR_BG, border_color="#333", button_color="#333", dropdown_fg_color=CLR_CARD) out_menu.grid(row=2, column=1, sticky='we', padx=(8, 16), pady=(0, 12)) card_io.columnconfigure((0, 1), weight=1) if input_devices: in_var.set(f"{input_devices[0][0]} – {input_devices[0][1]}") open_stream(input_devices[0][0]) if output_devices: out_var.set(f"{output_devices[0][0]} – {output_devices[0][1]}") def on_input_change(choice): idx = int(choice.split(' – ')[0]) open_stream(idx) log_add(f"Micro changé : {choice}", 'info') in_menu.configure(command=on_input_change) # ———————————————————————————— # Volume # ———————————————————————————— card_vol = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) card_vol.pack(fill='x', padx=16, pady=4) vol_row = ctk.CTkFrame(card_vol, fg_color="transparent") vol_row.pack(fill='x', padx=16, pady=12) ctk.CTkLabel(vol_row, text="🔉 Volume de sortie", font=ctk.CTkFont(size=12), text_color="#aaa").pack(side='left') vol_label = ctk.CTkLabel(vol_row, text="100%", font=ctk.CTkFont(size=12, weight="bold"), text_color=CLR_ACCENT, width=45) vol_label.pack(side='right') volume_var = ctk.DoubleVar(value=100) def on_vol_change(v): vol_label.configure(text=f"{int(float(v))}%") ctk.CTkSlider(card_vol, from_=0, to=100, variable=volume_var, fg_color=CLR_BG, progress_color=CLR_ACCENT, button_color=CLR_ACCENT, button_hover_color="#6ba3ff", command=on_vol_change).pack(fill='x', padx=16, pady=(0, 12)) # ———————————————————————————— # Carte OPTIONS (collapsible) # ———————————————————————————— opt_expanded = tk.BooleanVar(value=False) card_opts_outer = ctk.CTkFrame(left_panel, fg_color="#1a1a2e", corner_radius=12) card_opts_outer.pack(fill='x', padx=16, pady=4) def opt_label(parent, title, desc): """Affiche un titre + texte explicatif gris sous l'option.""" ctk.CTkLabel(parent, text=title, font=ctk.CTkFont(size=12, weight="bold"), text_color="#ccc", anchor='w').pack(fill='x', padx=0, pady=(8, 0)) ctk.CTkLabel(parent, text=desc, font=ctk.CTkFont(size=10), text_color="#666", anchor='w', wraplength=420, justify='left' ).pack(fill='x', padx=0, pady=(1, 0)) # ---- En-tête cliquable ---- opts_header = ctk.CTkFrame(card_opts_outer, fg_color="transparent") opts_header.pack(fill='x', padx=16, pady=8) opts_title_lbl = ctk.CTkLabel(opts_header, text="⚙️ Options de reconnaissance ▼", font=ctk.CTkFont(size=13, weight="bold"), text_color="#aaa") opts_title_lbl.pack(side='left') # ---- Corps masquable ---- opts_body = ctk.CTkFrame(card_opts_outer, fg_color="transparent") def toggle_opts(): if opt_expanded.get(): opts_body.pack_forget() opts_title_lbl.configure(text="⚙️ Options de reconnaissance ▼") opt_expanded.set(False) else: opts_body.pack(fill='x', padx=16, pady=(0, 12)) opts_title_lbl.configure(text="⚙️ Options de reconnaissance ▲") opt_expanded.set(True) opts_header.bind("", lambda e: toggle_opts()) opts_title_lbl.bind("", lambda e: toggle_opts()) # ---- 1. Mode déclenchement ---- opt_label(opts_body, "Mode de déclenchement", "Partiel : réagit dès que la phrase est repérée pendant que vous parlez (plus réactif, " "peut déclencher sur un mot mal reconnu). Final : attend la fin d'une phrase complète " "avant de vérifier (plus précis, légèrement plus lent).") mode_var = ctk.StringVar(value="final") mode_row = ctk.CTkFrame(opts_body, fg_color="transparent") mode_row.pack(fill='x', pady=(4, 0)) ctk.CTkRadioButton(mode_row, text="Final seulement", variable=mode_var, value="final", font=ctk.CTkFont(size=11)).pack(side='left', padx=(0, 20)) ctk.CTkRadioButton(mode_row, text="Partiel + Final", variable=mode_var, value="partial", font=ctk.CTkFont(size=11)).pack(side='left') def on_mode_change(*_): global opt_trigger_on_partial opt_trigger_on_partial = (mode_var.get() == "partial") mode_var.trace_add('write', on_mode_change) # ---- 2. Cooldown ---- opt_label(opts_body, "Cooldown global (secondes)", "Durée minimale entre deux déclenchements, quelle que soit la phrase. " "Évite les salves de sons si le micro capte beaucoup de bruit.") cooldown_row = ctk.CTkFrame(opts_body, fg_color="transparent") cooldown_row.pack(fill='x', pady=(4, 0)) cooldown_lbl = ctk.CTkLabel(cooldown_row, text="1.5 s", width=50, font=ctk.CTkFont(size=11, weight="bold"), text_color="#4f8ef7") cooldown_lbl.pack(side='right') cooldown_var = ctk.DoubleVar(value=1.5) def on_cooldown(v): global opt_cooldown opt_cooldown = round(float(v), 1) cooldown_lbl.configure(text=f"{opt_cooldown:.1f} s") ctk.CTkSlider(cooldown_row, from_=0.0, to=10.0, variable=cooldown_var, fg_color="#0f0f1a", progress_color="#4f8ef7", button_color="#4f8ef7", button_hover_color="#6ba3ff", command=on_cooldown).pack(side='left', fill='x', expand=True, padx=(0, 8)) # ---- 3. Anti-doublon ---- opt_label(opts_body, "Anti-doublon par phrase (secondes)", "Empêche la même phrase de déclencher deux fois de suite avant ce délai. " "Utile si vous dites le même mot plusieurs fois dans une phrase naturelle.") doublon_row = ctk.CTkFrame(opts_body, fg_color="transparent") doublon_row.pack(fill='x', pady=(4, 0)) doublon_lbl = ctk.CTkLabel(doublon_row, text="3.0 s", width=50, font=ctk.CTkFont(size=11, weight="bold"), text_color="#f97316") doublon_lbl.pack(side='right') doublon_var = ctk.DoubleVar(value=3.0) def on_doublon(v): global opt_anti_doublon opt_anti_doublon = round(float(v), 1) doublon_lbl.configure(text=f"{opt_anti_doublon:.1f} s") ctk.CTkSlider(doublon_row, from_=0.0, to=30.0, variable=doublon_var, fg_color="#0f0f1a", progress_color="#f97316", button_color="#f97316", button_hover_color="#fb923c", command=on_doublon).pack(side='left', fill='x', expand=True, padx=(0, 8)) # ---- 4. Longueur minimale ---- opt_label(opts_body, "Longueur minimale reconnue (caractères)", "Ignore les reconnaissances trop courtes. Réduit les faux positifs causés " "par des bruits courts (toux, claquement) qui seraient interprétés comme un mot.") minlen_row = ctk.CTkFrame(opts_body, fg_color="transparent") minlen_row.pack(fill='x', pady=(4, 0)) minlen_lbl = ctk.CTkLabel(minlen_row, text="2 car.", width=50, font=ctk.CTkFont(size=11, weight="bold"), text_color="#22c55e") minlen_lbl.pack(side='right') minlen_var = ctk.IntVar(value=2) def on_minlen(v): global opt_min_length opt_min_length = int(float(v)) minlen_lbl.configure(text=f"{opt_min_length} car.") ctk.CTkSlider(minlen_row, from_=1, to=20, variable=minlen_var, fg_color="#0f0f1a", progress_color="#22c55e", button_color="#22c55e", button_hover_color="#4ade80", command=on_minlen).pack(side='left', fill='x', expand=True, padx=(0, 8)) # ---- 5. Logs partiels ---- opt_label(opts_body, "Afficher les résultats partiels dans les logs", "Affiche en gris chaque fragment reconnu avant la finalisation. " "Pratique pour voir ce que Vosk entend en temps réel, mais peut rendre " "les logs verbeux. Désactiver si vous voulez uniquement voir les déclenchements.") partial_log_var = ctk.BooleanVar(value=True) def on_partial_log(): global opt_log_partials opt_log_partials = partial_log_var.get() ctk.CTkCheckBox(opts_body, text="Activé", variable=partial_log_var, command=on_partial_log, font=ctk.CTkFont(size=11), checkmark_color="#0f0f1a", fg_color="#4f8ef7", hover_color="#3a6fd4").pack(anchor='w', pady=(4, 0)) # ———————————————————————————— # Mappings # ———————————————————————————— card_map = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) card_map.pack(fill='both', expand=True, padx=16, pady=4) ctk.CTkLabel(card_map, text="🗂 Mappings phrase → son", font=ctk.CTkFont(size=13, weight="bold"), text_color="#aaa" ).pack(anchor='w', padx=16, pady=(12, 6)) style = ttk.Style() style.theme_use("clam") style.configure("Dark.Treeview", background=CLR_BG, foreground="#e0e0e0", fieldbackground=CLR_BG, rowheight=30, font=("Segoe UI", 10)) style.configure("Dark.Treeview.Heading", background=CLR_CARD, foreground=CLR_ACCENT, font=("Segoe UI", 10, "bold"), relief="flat") style.map("Dark.Treeview", background=[("selected", "#2a2a4a")], foreground=[("selected", "#ffffff")]) cols = ('Phrase', 'Nom', 'Args', 'Chemin') tree_frame = ctk.CTkFrame(card_map, fg_color=CLR_BG, corner_radius=8) tree_frame.pack(fill='both', expand=True, padx=16, pady=(0, 12)) tree = ttk.Treeview(tree_frame, columns=cols, show='headings', style="Dark.Treeview", selectmode='browse') for c in cols: w = 120 if c != 'Chemin' else 200 tree.heading(c, text=c) tree.column(c, width=w, minwidth=60) scrollbar = ctk.CTkScrollbar(tree_frame, command=tree.yview, fg_color=CLR_CARD, button_color="#333", button_hover_color=CLR_ACCENT) tree.configure(yscrollcommand=scrollbar.set) scrollbar.pack(side='right', fill='y', padx=(0, 4), pady=4) tree.pack(fill='both', expand=True, padx=4, pady=4) tree.tag_configure('odd', background=CLR_BG) tree.tag_configure('even', background="#141428") def update_tree(): tree.delete(*tree.get_children()) for i, m in enumerate(mappings): tag = 'even' if i % 2 == 0 else 'odd' tree.insert('', 'end', values=(m['phrase'], m['name'], m.get('args', ''), m['path']), tags=(tag,)) update_tree() # ———————————————————————————— # Barre d'ajout # ———————————————————————————— add_frame = ctk.CTkFrame(left_panel, fg_color=CLR_CARD, corner_radius=12) add_frame.pack(fill='x', padx=16, pady=4) phrase_entry = ctk.CTkEntry(add_frame, placeholder_text="Mot / phrase déclencheur...", fg_color=CLR_BG, border_color="#333", font=ctk.CTkFont(size=12)) phrase_entry.pack(side='left', fill='x', expand=True, padx=(16, 8), pady=12) def add_mapping(): ph = phrase_entry.get().strip().lower() if not ph or any(m['phrase'] == ph for m in mappings): return fn = filedialog.askopenfilename(filetypes=[("Audio/Exe", "*.mp3 *.exe")]) if not fn: return nm = os.path.basename(fn) args = simpledialog.askstring("Arguments", "Arguments (séparés par espaces) :", parent=root) or '' dst = fn if nm.lower().endswith('.mp3'): dst = os.path.join(SON_DIR, nm) shutil.copy(fn, dst) data, fs = sf.read(dst, dtype='int16') preloaded_sounds[dst] = (data, fs) mappings.append({'phrase': ph, 'name': nm, 'path': dst, 'args': args}) with open(CONFIG, 'w', encoding='utf-8') as f: json.dump(mappings, f, ensure_ascii=False, indent=2) phrase_entry.delete(0, 'end') update_tree() log_add(f"Mapping ajouté : \"{ph}\" → {nm}", 'info') ctk.CTkButton(add_frame, text="+ Ajouter", command=add_mapping, width=120, fg_color=CLR_ACCENT, hover_color="#3a6fd4", font=ctk.CTkFont(size=12, weight="bold"), corner_radius=8).pack(side='right', padx=(0, 16), pady=12) # ———————————————————————————— # Boutons action # ———————————————————————————— btn_frame = ctk.CTkFrame(left_panel, fg_color="transparent") btn_frame.pack(fill='x', padx=16, pady=(4, 16)) def remove_mapping(): sel = tree.selection() if not sel: return ph = tree.item(sel[0])['values'][0] if not messagebox.askyesno("Suppression", f"Supprimer le mapping « {ph} » ?"): return mappings[:] = [m for m in mappings if m['phrase'] != ph] with open(CONFIG, 'w', encoding='utf-8') as f: json.dump(mappings, f, ensure_ascii=False, indent=2) update_tree() log_add(f"Mapping supprimé : \"{ph}\"", 'info') ctk.CTkButton(btn_frame, text="🗑 Supprimer", command=remove_mapping, width=160, fg_color="#2a1a1a", hover_color="#4a1a1a", text_color=CLR_RED, border_color=CLR_RED, border_width=1, font=ctk.CTkFont(size=12), corner_radius=8).pack(side='left') ctk.CTkButton(btn_frame, text="⏹ Arrêter son", command=sd.stop, width=160, fg_color="#1a1a2a", hover_color="#2a2a4a", text_color="#aaa", border_color="#333", border_width=1, font=ctk.CTkFont(size=12), corner_radius=8).pack(side='right') # ———————————————————————————— # Panneau LOG (droite) # ———————————————————————————— log_header = ctk.CTkFrame(log_panel, fg_color="#111120", corner_radius=0) log_header.pack(fill='x') ctk.CTkLabel(log_header, text="📋 Reconnaissance vocale", font=ctk.CTkFont(size=13, weight="bold"), text_color=CLR_ACCENT ).pack(side='left', padx=14, pady=10) def clear_log(): log_box.configure(state='normal') log_box.delete('1.0', 'end') log_box.configure(state='disabled') ctk.CTkButton(log_header, text="🗑", command=clear_log, width=32, height=28, fg_color="#1a1a2e", hover_color="#2a2a4e", font=ctk.CTkFont(size=13), corner_radius=6 ).pack(side='right', padx=8, pady=8) # Légende legend_frame = ctk.CTkFrame(log_panel, fg_color="#111120", corner_radius=0) legend_frame.pack(fill='x', padx=12, pady=(0, 4)) for color, label in [("#555", "partiel"), ("#e0e0e0", "final"), ("#22c55e", "déclenché"), ("#4f8ef7", "info")]: dot = tk.Label(legend_frame, text="●", fg=color, bg="#111120", font=("Segoe UI", 9)) dot.pack(side='left') lbl = tk.Label(legend_frame, text=f" {label} ", fg="#666", bg="#111120", font=("Segoe UI", 9)) lbl.pack(side='left') # Zone de texte log log_box = tk.Text( log_panel, bg="#0a0a18", fg="#e0e0e0", font=("Consolas", 10), wrap='word', bd=0, padx=10, pady=8, state='disabled', cursor="arrow", selectbackground="#2a2a4a" ) log_box.pack(fill='both', expand=True, padx=8, pady=(0, 8)) # Tags de couleur log_box.tag_configure('time', foreground="#444", font=("Consolas", 9)) log_box.tag_configure('partial', foreground="#666666", font=("Consolas", 10, "italic")) log_box.tag_configure('final', foreground="#e0e0e0", font=("Consolas", 10)) log_box.tag_configure('trigger', foreground="#22c55e", font=("Consolas", 10, "bold")) log_box.tag_configure('info', foreground="#4f8ef7", font=("Consolas", 9)) MAX_LOG_LINES = 300 def flush_log_queue(): """Appelé régulièrement par root.after pour vider la queue de logs.""" try: while True: ts, text, kind = log_queue.get_nowait() log_box.configure(state='normal') # Limiter le nombre de lignes lines = int(log_box.index('end-1c').split('.')[0]) if lines > MAX_LOG_LINES: log_box.delete('1.0', f'{lines - MAX_LOG_LINES}.0') log_box.insert('end', f"[{ts}] ", 'time') log_box.insert('end', text + "\n", kind) log_box.see('end') log_box.configure(state='disabled') except queue.Empty: pass root.after(80, flush_log_queue) # ———————————————————————————— # Lancement # ———————————————————————————— threading.Thread(target=recognition_loop, daemon=True).start() root.after(80, flush_log_queue) root.mainloop()