initial commit
This commit is contained in:
740
declencheur-vocal-0.20.2.py
Normal file
740
declencheur-vocal-0.20.2.py
Normal 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()
|
||||
Reference in New Issue
Block a user