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