initial commit

This commit is contained in:
2026-03-24 07:57:16 +01:00
commit da4bdbd3d3

740
declencheur-vocal-0.20.2.py Normal file
View File

@@ -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("<Button-1>", lambda e: toggle_opts())
opts_title_lbl.bind("<Button-1>", 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()