commit da4bdbd3d3146289df4b82ba193ac63644631144 Author: zogzog Date: Tue Mar 24 07:57:16 2026 +0100 initial commit diff --git a/declencheur-vocal-0.20.2.py b/declencheur-vocal-0.20.2.py new file mode 100644 index 0000000..91e2a5e --- /dev/null +++ b/declencheur-vocal-0.20.2.py @@ -0,0 +1,740 @@ +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()