From 5ee99efaff7d94df236e51b7af96476cdbb22397 Mon Sep 17 00:00:00 2001 From: Johnny Fontaine Date: Thu, 9 Apr 2026 08:26:31 +0000 Subject: [PATCH] =?UTF-8?q?T=C3=A9l=C3=A9verser=20les=20fichiers=20vers=20?= =?UTF-8?q?"/"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.py | 851 ++++++++++++ icon.ico | Bin 0 -> 196 bytes icon.png | Bin 0 -> 1638 bytes launch.sh | 4 + transcribe_station.py | 2900 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 3755 insertions(+) create mode 100644 build.py create mode 100644 icon.ico create mode 100644 icon.png create mode 100644 launch.sh create mode 100644 transcribe_station.py diff --git a/build.py b/build.py new file mode 100644 index 0000000..8d2cb4b --- /dev/null +++ b/build.py @@ -0,0 +1,851 @@ +#!/usr/bin/env python3 +""" +build.py – Compilation + installation de TranscribeStation +Produit un binaire onefile PyInstaller et installe les raccourcis système. + +Usage : + python build.py # build + install (plateforme courante) + python build.py --linux # build Linux + python build.py --windows # build Windows (Wine ou .bat natif) + python build.py --both # Linux + Windows + python build.py --install-only # installe sans recompiler + python build.py --clean # nettoyer build/ dist/ *.spec icon.* + +Raccourcis créés : + Linux → ~/.local/share/applications/TranscribeStation.desktop + ~/.local/share/icons/hicolor/256x256/apps/TranscribeStation.png + ~/Bureau/TranscribeStation.desktop (si dossier existe) + Windows → %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\TranscribeStation.lnk + %USERPROFILE%\\Desktop\\TranscribeStation.lnk +""" + +import sys +import os +import shutil +import platform +import subprocess +import argparse +import textwrap +import urllib.request +import zipfile +import tempfile +from pathlib import Path + +# ─── Configuration ──────────────────────────────────────────────────────────── +APP_NAME = "TranscribeStation" +APP_VERSION = "1.2.0" +APP_AUTHOR = "H3Campus" +APP_URL = "https://github.com/h3campus/transcribe-station" +APP_COMMENT = "Gestionnaire de transcription audio – JT-Tools by Johnny" +APP_COMMENT_WIN = "Gestionnaire de transcription audio - JT-Tools by Johnny" +APP_CATS = "Audio;AudioVideo;Office;" +MAIN_SCRIPT = "transcribe_station.py" +ICON_ICO = Path("icon.ico") +ICON_PNG = Path("icon.png") +BUILD_DIR = Path("build") +DIST_DIR = Path("dist") + +HIDDEN_IMPORTS = [ + "PySide6.QtMultimedia", + "PySide6.QtCore", + "PySide6.QtGui", + "PySide6.QtWidgets", + "soundfile", + "numpy", + "hid", + "cffi", + "_cffi_backend", +] + +# --collect-all hid : inclut la DLL native hidapi.dll dans le bundle Windows +COLLECT_ALL = ["hid"] + +ADD_DATA: list[str] = [] + +# hidapi.dll natif Windows (absent du package pip hid 1.x qui est un pur wrapper ctypes) +HIDAPI_VERSION = "0.14.0" +HIDAPI_DLL_URL = ( + f"https://github.com/libusb/hidapi/releases/download/" + f"hidapi-{HIDAPI_VERSION}/hidapi-win.zip" +) + +# ─── Couleurs terminal ───────────────────────────────────────────────────────── +R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m' +C='\033[0;36m'; B='\033[1m'; N='\033[0m' +def ok(m): print(f"{G}[OK]{N} {m}") +def info(m):print(f"{C}[..]{N} {m}") +def warn(m):print(f"{Y}[!!]{N} {m}") +def err(m): print(f"{R}[XX]{N} {m}", file=sys.stderr) +def step(m):print(f"\n{B}{m}{N}") + +# ─── hidapi.dll ─────────────────────────────────────────────────────────────── +def _fetch_hidapi_dll() -> Path | None: + """Télécharge hidapi.dll (x64) depuis les releases GitHub, mise en cache dans build/.""" + dll_cache = BUILD_DIR / "hidapi.dll" + if dll_cache.exists(): + ok(f"hidapi.dll en cache : {dll_cache}") + return dll_cache + info(f"Téléchargement hidapi.dll v{HIDAPI_VERSION} depuis GitHub...") + BUILD_DIR.mkdir(parents=True, exist_ok=True) + try: + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp_path = tmp.name + urllib.request.urlretrieve(HIDAPI_DLL_URL, tmp_path) + with zipfile.ZipFile(tmp_path) as zf: + candidates = [n for n in zf.namelist() + if n.lower().endswith("hidapi.dll") and "x64" in n.lower()] + if not candidates: + candidates = [n for n in zf.namelist() + if n.lower().endswith("hidapi.dll")] + if candidates: + with zf.open(candidates[0]) as src, open(dll_cache, "wb") as dst: + dst.write(src.read()) + ok(f"hidapi.dll extrait ({candidates[0]}) → {dll_cache}") + return dll_cache + warn("hidapi.dll introuvable dans l'archive") + except Exception as exc: + warn(f"Échec téléchargement hidapi.dll : {exc}") + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + return None + + +def _install_hidapi_dll_onedir(internal_dir: Path) -> None: + """Copie hidapi.dll dans _internal/ d'un bundle onedir.""" + if not internal_dir.exists(): + warn(f"Dossier _internal introuvable : {internal_dir}") + return + dll = _fetch_hidapi_dll() + if dll: + dest = internal_dir / "hidapi.dll" + shutil.copy2(dll, dest) + ok(f"hidapi.dll installé → {dest}") + else: + warn("hidapi.dll non disponible — le pédalier ne fonctionnera pas sans cette DLL.") + + +# ─── Helpers ────────────────────────────────────────────────────────────────── +def run(cmd: list, check=True) -> int: + info(" ".join(str(c) for c in cmd)) + r = subprocess.run(cmd, check=False) + if check and r.returncode != 0: + err(f"Échec (code {r.returncode})") + sys.exit(r.returncode) + return r.returncode + +def pip_install(pkgs: list, python=sys.executable): + run([python, "-m", "pip", "install", "--quiet"] + pkgs) + +def validate_wine_pyside(wine: str, wine_python: str) -> bool: + probe = subprocess.run( + [ + wine, wine_python, "-c", + ( + "from pathlib import Path; " + "import importlib.metadata as md; " + "import PySide6; " + "qt_dir = Path(PySide6.__file__).resolve().parent; " + "missing = [name for name in ('Qt6Core.dll', 'Qt6Multimedia.dll') if not (qt_dir / name).exists()]; " + "print('PYSIDE6_VERSION=' + md.version('PySide6')); " + "print('QT_DIR=' + str(qt_dir).replace(chr(92), '/')); " + "print('MISSING_DLLS=' + ','.join(missing)); " + "from PySide6 import QtCore; " + "print('QTCORE_OK=1')" + ), + ], + check=False, + capture_output=True, + text=True, + ) + output = (probe.stdout or "") + (probe.stderr or "") + for line in output.splitlines(): + if line.strip(): + info(f"Wine Qt probe : {line}") + if probe.returncode != 0: + missing = "" + for line in output.splitlines(): + if line.startswith("MISSING_DLLS="): + missing = line.split("=", 1)[1].strip() + break + err("PySide6 est installé dans Wine, mais QtCore ne se charge pas.") + if missing: + err(f"DLL Qt manquantes dans le wheel Windows : {missing}") + err("Le binaire Windows généré via Wine serait invalide.") + err("Utilisez le script build_windows.bat sur Windows natif.") + return False + return True + +# ─── Génération d'icônes ────────────────────────────────────────────────────── +def _draw_icon(draw, sz): + """Dessine le logo waveform TranscribeStation.""" + draw.rounded_rectangle( + [0, 0, sz - 1, sz - 1], + radius=int(sz * 0.18), + fill=(59, 110, 220, 255), + ) + bars = [0.25, 0.45, 0.70, 0.90, 0.65, 1.0, 0.55, 0.80, 0.40, 0.20] + n, mg = len(bars), sz * 0.13 + bw = (sz - 2 * mg) / (n * 1.7) + gap = (sz - 2 * mg) / n + mid = sz / 2 + for i, h in enumerate(bars): + x = mg + i * gap + (gap - bw) / 2 + bh = h * sz * 0.36 + draw.rounded_rectangle( + [x, mid - bh, x + bw, mid + bh], + radius=bw / 2, + fill=(255, 255, 255, 215), + ) + +def generate_icons() -> bool: + """Génère icon.png (256px) et icon.ico (multi-tailles). Retourne True si OK.""" + try: + from PIL import Image, ImageDraw + except ImportError: + warn("Pillow absent → icônes ignorées (pip install Pillow)") + return False + + sizes_ico = [16, 32, 48, 64, 128, 256] + images = [] + for sz in sizes_ico: + img = Image.new("RGBA", (sz, sz), (0, 0, 0, 0)) + _draw_icon(ImageDraw.Draw(img), sz) + images.append(img) + + # PNG 256 px pour Linux XDG + images[-1].save(str(ICON_PNG)) + ok(f"Icône PNG : {ICON_PNG}") + + # ICO multi-tailles pour Windows + images[0].save( + str(ICON_ICO), format="ICO", + sizes=[(s, s) for s in sizes_ico], + append_images=images[1:], + ) + ok(f"Icône ICO : {ICON_ICO}") + return True + +# ─── Raccourci GNOME / Linux ────────────────────────────────────────────────── +def install_linux_shortcut(binary: Path): + step("📌 Installation du raccourci GNOME...") + + binary = binary.resolve() + + # 1. Icône → XDG hicolor + xdg_icon_dir = Path.home() / ".local/share/icons/hicolor/256x256/apps" + xdg_icon_dir.mkdir(parents=True, exist_ok=True) + xdg_icon = xdg_icon_dir / f"{APP_NAME}.png" + if ICON_PNG.exists(): + shutil.copy(ICON_PNG, xdg_icon) + ok(f"Icône installée : {xdg_icon}") + else: + warn("icon.png absent — raccourci sans icône") + + # 2. Fichier .desktop → applications + apps_dir = Path.home() / ".local/share/applications" + apps_dir.mkdir(parents=True, exist_ok=True) + desktop_path = apps_dir / f"{APP_NAME}.desktop" + + desktop_content = textwrap.dedent(f"""\ + [Desktop Entry] + Version=1.0 + Type=Application + Name={APP_NAME} + GenericName=Transcription Audio + Comment={APP_COMMENT} + Exec={binary} + Icon={APP_NAME} + Terminal=false + Categories={APP_CATS} + StartupNotify=true + StartupWMClass={APP_NAME} + Keywords=transcription;audio;dictée;pédalier; + """) + desktop_path.write_text(desktop_content, encoding="utf-8") + desktop_path.chmod(0o755) + ok(f".desktop installé : {desktop_path}") + + # 3. Raccourci Bureau (Desktop) + for bureau in ["Bureau", "Desktop", "Escritorio"]: + bureau_dir = Path.home() / bureau + if bureau_dir.is_dir(): + bureau_link = bureau_dir / f"{APP_NAME}.desktop" + shutil.copy(desktop_path, bureau_link) + bureau_link.chmod(0o755) + ok(f"Raccourci bureau : {bureau_link}") + break + + # 4. Mettre à jour la base des .desktop + for cmd in [ + ["update-desktop-database", str(apps_dir)], + ["gtk-update-icon-cache", "-f", "-t", + str(Path.home() / ".local/share/icons/hicolor")], + ["xdg-desktop-menu", "forceupdate"], + ]: + if shutil.which(cmd[0]): + run(cmd, check=False) + + ok("Raccourci GNOME installé. Visible au prochain rechargement de session.") + +def uninstall_linux_shortcut(): + step("🗑 Désinstallation raccourci GNOME...") + targets = [ + Path.home() / f".local/share/applications/{APP_NAME}.desktop", + Path.home() / f".local/share/icons/hicolor/256x256/apps/{APP_NAME}.png", + ] + for bureau in ["Bureau", "Desktop", "Escritorio"]: + targets.append(Path.home() / bureau / f"{APP_NAME}.desktop") + for t in targets: + if t.exists(): + t.unlink() + ok(f"Supprimé : {t}") + +# ─── Raccourci Windows (depuis Windows natif) ───────────────────────────────── +def install_windows_shortcut(binary: Path): + """Crée Menu Démarrer + Bureau via PowerShell (à appeler depuis Windows).""" + step("📌 Installation du raccourci Windows...") + binary = binary.resolve() + + # param() en premiere ligne : ExePath est passe par le bat via -ExePath "..." + # Ne PAS hardcoder $ExePath dans le corps du script (serait ecrase par la ligne param) + ps_script = textwrap.dedent(f""" + param([string]$ExePath) + $AppName = "{APP_NAME}" + $Comment = "{APP_COMMENT_WIN}" + + function New-Shortcut([string]$dest) {{ + $wsh = New-Object -ComObject WScript.Shell + $lnk = $wsh.CreateShortcut($dest) + $lnk.TargetPath = $ExePath + $lnk.IconLocation = "$ExePath,0" + $lnk.Description = $Comment + $lnk.WorkingDirectory = [System.IO.Path]::GetDirectoryName($ExePath) + $lnk.Save() + Write-Host "[OK] Raccourci : $dest" + }} + + $startMenu = "$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs" + New-Shortcut "$startMenu\\$AppName.lnk" + + $desktop = [Environment]::GetFolderPath("Desktop") + New-Shortcut "$desktop\\$AppName.lnk" + + Write-Host "" + Write-Host "[OK] Shortcuts installed successfully." + """).strip() + + ps_file = DIST_DIR / "windows" / "install_shortcut.ps1" + ps_file.parent.mkdir(parents=True, exist_ok=True) + ps_file.write_text(ps_script, encoding="utf-8-sig") + ok(f"Script PowerShell généré : {ps_file}") + + if platform.system() == "Windows": + info("Exécution de PowerShell...") + run(["powershell", "-ExecutionPolicy", "Bypass", + "-File", str(ps_file)], check=False) + else: + warn("Cross-compilation : exécutez ce script sur la machine Windows cible :") + warn(f" powershell -ExecutionPolicy Bypass -File {ps_file}") + +# ─── Build Linux ────────────────────────────────────────────────────────────── +def build_linux(install: bool = True): + step("🐧 Build Linux") + pip_install(["pyinstaller", "Pillow"]) + generate_icons() + + run([sys.executable, "-m", "PyInstaller", + "--onefile", "--clean", "--noconfirm", + f"--name={APP_NAME}", + "--distpath=dist/linux", + "--workpath=build/linux", + "--specpath=build/linux", + "--log-level=WARN", + *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), + *(f"--collect-all={m}" for m in COLLECT_ALL), + *(f"--add-data={d}" for d in ADD_DATA), + f"--icon={ICON_ICO}" if ICON_ICO.exists() else "", + MAIN_SCRIPT, + ]) + + binary = Path(f"dist/linux/{APP_NAME}") + if binary.exists(): + binary.chmod(0o755) + size = binary.stat().st_size // (1024 * 1024) + ok(f"Binaire Linux : {binary} ({size} Mo)") + if install: + install_linux_shortcut(binary) + else: + err("Binaire introuvable dans dist/linux/") + +# ─── Build Windows ──────────────────────────────────────────────────────────── +def build_windows(install: bool = True): + step("🪟 Build Windows") + + # ── Sur machine Windows native ──────────────────────────────────────────── + if platform.system() == "Windows": + pip_install(["pyinstaller", "Pillow"]) + generate_icons() + icon_arg = f"--icon={ICON_ICO.resolve()}" if ICON_ICO.exists() else "" + main_script = str(Path(MAIN_SCRIPT).resolve()) + run([sys.executable, "-m", "PyInstaller", + "--onedir", "--clean", "--noconfirm", "--windowed", + f"--name={APP_NAME}", + "--distpath=dist/windows", + "--workpath=build/windows", + "--specpath=build/windows", + "--log-level=WARN", + *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), + *(f"--collect-all={m}" for m in COLLECT_ALL), + icon_arg, + main_script, + ]) + binary = Path(f"dist/windows/{APP_NAME}/{APP_NAME}.exe") + if binary.exists(): + ok(f"Binaire Windows : {binary}") + _install_hidapi_dll_onedir(Path(f"dist/windows/{APP_NAME}/_internal")) + if install: + install_windows_shortcut(binary) + else: + err("Binaire introuvable.") + return + + # ── Cross-compilation depuis Linux via Wine ──────────────────────────────── + wine = shutil.which("wine") or shutil.which("wine64") + wine_python = None + if wine: + for candidate in [ + Path.home() / ".wine/drive_c/Python312/python.exe", + Path.home() / ".wine/drive_c/Python311/python.exe", + Path.home() / ".wine/drive_c/Python310/python.exe", + ]: + if candidate.exists(): + wine_python = candidate + break + + if wine and wine_python: + info(f"Wine Python : {wine_python}") + pip_install(["pyinstaller", "PySide6", "numpy", "soundfile", "hid", "Pillow"], + python=str(wine_python)) + generate_icons() + if not validate_wine_pyside(wine, str(wine_python)): + sys.exit(1) + # Récupérer hidapi.dll pour l'embarquer dans le onefile via --add-binary + hidapi_dll = _fetch_hidapi_dll() + add_binary_args = [f"--add-binary={hidapi_dll}:."] if hidapi_dll else [] + if not hidapi_dll: + warn("hidapi.dll non disponible — le pédalier ne fonctionnera pas dans le .exe Wine") + run([wine, str(wine_python), "-m", "PyInstaller", + "--onefile", "--clean", "--noconfirm", "--windowed", + f"--name={APP_NAME}", + "--distpath=dist/windows", + "--workpath=build/windows", + "--specpath=build/windows", + "--log-level=WARN", + *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), + *(f"--collect-all={m}" for m in COLLECT_ALL), + *add_binary_args, + f"--icon={ICON_ICO}" if ICON_ICO.exists() else "", + MAIN_SCRIPT, + ]) + binary = Path(f"dist/windows/{APP_NAME}.exe") + if binary.exists(): + size = binary.stat().st_size // (1024 * 1024) + ok(f"Binaire Windows : {binary} ({size} Mo)") + install_windows_shortcut(Path(f"dist/windows/{APP_NAME}.exe")) + else: + warn("Wine ou Python Windows introuvable → génération du script natif Windows") + _write_windows_bat(install=install) + +def _write_windows_bat(install: bool = True): + """Genere build_windows.bat + _build_icons.py dans dist/ pour execution native sur Windows.""" + out_dir = Path("dist") / "windows" + out_dir.mkdir(parents=True, exist_ok=True) + bat_path = out_dir / "build_windows.bat" + icons_path = out_dir / "_build_icons.py" + + # ── Script Python icones dans un fichier separe (evite l'interpretation batch) ── + icon_py = ( + "from PIL import Image, ImageDraw\n" + "sizes = [16, 32, 48, 64, 128, 256]\n" + "imgs = []\n" + "for sz in sizes:\n" + " img = Image.new('RGBA', (sz, sz), (0, 0, 0, 0))\n" + " d = ImageDraw.Draw(img)\n" + " d.rounded_rectangle([0, 0, sz-1, sz-1], radius=int(sz*.18), fill=(59, 110, 220, 255))\n" + " bars = [.25, .45, .7, .9, .65, 1., .55, .8, .4, .2]\n" + " n, mg = len(bars), sz*.13\n" + " bw = (sz - 2*mg) / (n * 1.7)\n" + " gap = (sz - 2*mg) / n\n" + " mid = sz / 2\n" + " for i, h in enumerate(bars):\n" + " x = mg + i*gap + (gap - bw) / 2\n" + " bh = h * sz * .36\n" + " d.rounded_rectangle([x, mid-bh, x+bw, mid+bh], radius=bw/2, fill=(255, 255, 255, 215))\n" + " imgs.append(img)\n" + "imgs[-1].save('icon.png')\n" + "imgs[0].save('icon.ico', format='ICO', sizes=[(s,s) for s in sizes], append_images=imgs[1:])\n" + "print('[OK] Icones generees')\n" + ) + icons_path.write_text(icon_py, encoding="utf-8") + ok(f"Script icones genere : {icons_path}") + + # ── Arguments hidden-import + collect-all PyInstaller ── + hi_lines = " ^\n ".join(f"--hidden-import {h}" for h in HIDDEN_IMPORTS) + ca_lines = " ^\n ".join(f"--collect-all {m}" for m in COLLECT_ALL) + + shortcut_block = "" + if install: + shortcut_block = ( + "echo [5/5] Installation des raccourcis...\n" + "REM Raccourci vers binaire LOCAL (WScript.Shell refuse les chemins reseau/partages)\n" + f'set "LOCAL_EXE=%LOCAL_DIST%\\{APP_NAME}\\{APP_NAME}.exe"\n' + f'if exist "%PROJECT_ROOT%dist\\windows\\install_shortcut.ps1" (\n' + f' powershell -NoProfile -ExecutionPolicy Bypass ^\n' + f' -File "%PROJECT_ROOT%dist\\windows\\install_shortcut.ps1" ^\n' + f' -ExePath "%LOCAL_EXE%"\n' + f') else (\n' + f' echo [!!] Script PowerShell introuvable\n' + f')\n' + ) + + # Venv court pour contourner MAX_PATH avec PySide6 (Microsoft Store Python) + # C:\ts\.venv\Lib\site-packages\ = 30 chars vs ~140 chars pour AppData\Packages\... + VENV = "C:\\ts\\.venv" + + # ASCII uniquement dans le bat + bat = ( + "@echo off\n" + "setlocal EnableDelayedExpansion\n" + f"REM build_windows.bat -- {APP_NAME} v{APP_VERSION}\n" + "REM A executer sur une machine Windows avec Python 3.12+\n" + "echo.\n" + f"echo *** Build {APP_NAME} v{APP_VERSION} pour Windows ***\n" + "echo.\n" + "\n" + "REM -- Localisation du projet\n" + "set \"SCRIPT_DIR=%~dp0\"\n" + "for %%I in (\"%SCRIPT_DIR%\\..\") do set \"PROJECT_ROOT=%%~fI\\\"\n" + f"if exist \"%PROJECT_ROOT%{MAIN_SCRIPT}\" goto ROOT_FOUND\n" + "for %%I in (\"%SCRIPT_DIR%\\..\\..\") do set \"PROJECT_ROOT=%%~fI\\\"\n" + f"if exist \"%PROJECT_ROOT%{MAIN_SCRIPT}\" goto ROOT_FOUND\n" + f"echo [XX] {MAIN_SCRIPT} introuvable.\n" + "pause & exit /b 1\n" + "\n" + ":ROOT_FOUND\n" + "echo [OK] Dossier projet : %PROJECT_ROOT%\n" + "echo.\n" + "\n" + "REM -- Localisation d'un vrai Python (evite le stub Windows Store)\n" + "set \"REAL_PYTHON=\"\n" + "\n" + "REM Essai 1 : py launcher (installe avec Python officiel)\n" + "where py >nul 2>&1\n" + "if not errorlevel 1 (\n" + " py -3 -c \"import sys; print(sys.executable)\" >nul 2>&1\n" + " if not errorlevel 1 (\n" + " for /f \"delims=\" %%P in ('py -3 -c \"import sys; print(sys.executable)\"') do set \"REAL_PYTHON=%%P\"\n" + " )\n" + ")\n" + "\n" + "REM Essai 2 : emplacements standard Python 3.x\n" + "if not defined REAL_PYTHON (\n" + " for %%V in (313 312 311 310 39) do (\n" + " if not defined REAL_PYTHON (\n" + " if exist \"C:\\Python%%V\\python.exe\" set \"REAL_PYTHON=C:\\Python%%V\\python.exe\"\n" + " )\n" + " )\n" + ")\n" + "REM Essai 3 : AppData\\Local\\Programs\\Python\n" + "if not defined REAL_PYTHON (\n" + " for %%V in (313 312 311 310 39) do (\n" + " if not defined REAL_PYTHON (\n" + " set \"_CANDIDATE=%LOCALAPPDATA%\\Programs\\Python\\Python%%V\\python.exe\"\n" + " if exist \"!_CANDIDATE!\" set \"REAL_PYTHON=!_CANDIDATE!\"\n" + " )\n" + " )\n" + ")\n" + "\n" + "if not defined REAL_PYTHON (\n" + " echo [XX] Python 3 introuvable. Installez Python depuis https://www.python.org/downloads/\n" + " echo [XX] Assurez-vous de cocher \"Add python.exe to PATH\" lors de l'installation.\n" + " pause & exit /b 1\n" + ")\n" + "echo [OK] Python trouve : %REAL_PYTHON%\n" + "\n" + "REM -- Venv a chemin court pour eviter MAX_PATH avec PySide6\n" + f"set \"VENV={VENV}\"\n" + "set \"VPYTHON=%VENV%\\Scripts\\python.exe\"\n" + "set \"VPIP=%VENV%\\Scripts\\pip.exe\"\n" + "echo [1/5] Preparation venv dans %VENV%...\n" + "if exist \"%VENV%\" (\n" + " \"%VPYTHON%\" -c \"import sys\" >nul 2>&1\n" + " if errorlevel 1 (\n" + " echo [!!] Venv existant invalide - recreation...\n" + " rmdir /S /Q \"%VENV%\"\n" + " )\n" + ")\n" + "if not exist \"%VPYTHON%\" (\n" + " mkdir C:\\ts 2>nul\n" + " \"%REAL_PYTHON%\" -m venv \"%VENV%\"\n" + " if errorlevel 1 ( echo ERREUR venv & pause & exit /b 1 )\n" + ")\n" + "echo [OK] Venv pret.\n" + "\n" + "echo [2/5] Installation des dependances Python...\n" + "\"%VPIP%\" install --quiet --upgrade pip\n" + "\"%VPIP%\" install --quiet pyinstaller PySide6 numpy soundfile hid Pillow\n" + "if errorlevel 1 ( echo ERREUR pip & pause & exit /b 1 )\n" + "\"%VPYTHON%\" -c \"from PySide6.QtMultimedia import QMediaPlayer; print('[OK] PySide6 + QtMultimedia OK')\"\n" + "if errorlevel 1 ( echo ERREUR PySide6.QtMultimedia & pause & exit /b 1 )\n" + "\n" + "echo [3/5] Generation des icones...\n" + "copy /Y \"%SCRIPT_DIR%_build_icons.py\" \"%PROJECT_ROOT%_build_icons.py\" > nul\n" + "\"%VPYTHON%\" \"%PROJECT_ROOT%_build_icons.py\"\n" + "if errorlevel 1 ( echo ERREUR icones & pause & exit /b 1 )\n" + "del \"%PROJECT_ROOT%_build_icons.py\" > nul 2>&1\n" + "\n" + "REM -- Build dans C:\\ts (chemin local court) pour eviter les erreurs reseau/MAX_PATH\n" + "set \"LOCAL_DIST=C:\\ts\\dist\"\n" + "set \"LOCAL_BUILD=C:\\ts\\build\"\n" + "\n" + "echo [4/5] Compilation PyInstaller (sortie locale C:\\ts\\dist)...\n" + "\"%VENV%\\Scripts\\pyinstaller.exe\" --onedir --windowed --clean --noconfirm ^\n" + f" --name {APP_NAME} ^\n" + " --distpath \"%LOCAL_DIST%\" ^\n" + " --workpath \"%LOCAL_BUILD%\" ^\n" + " --specpath \"%LOCAL_BUILD%\" ^\n" + " --icon \"%PROJECT_ROOT%icon.ico\" ^\n" + f" {hi_lines} ^\n" + f" {ca_lines} ^\n" + f" \"%PROJECT_ROOT%{MAIN_SCRIPT}\"\n" + "if errorlevel 1 ( echo ERREUR PyInstaller & pause & exit /b 1 )\n" + "\n" + "REM -- Ajout de hidapi.dll (absent du package pip hid)\n" + f"set \"HIDAPI_URL=https://github.com/libusb/hidapi/releases/download/hidapi-{HIDAPI_VERSION}/hidapi-win.zip\"\n" + f"set \"HIDAPI_ZIP=C:\\ts\\hidapi-win.zip\"\n" + f"set \"HIDAPI_DLL=%LOCAL_DIST%\\{APP_NAME}\\_internal\\hidapi.dll\"\n" + "if not exist \"%HIDAPI_DLL%\" (\n" + " echo Telechargement hidapi.dll...\n" + " powershell -NoProfile -Command \"Invoke-WebRequest -Uri '%HIDAPI_URL%' -OutFile '%HIDAPI_ZIP%'\"\n" + " if not errorlevel 1 (\n" + " powershell -NoProfile -Command \"" + f"Expand-Archive -Force '%HIDAPI_ZIP%' 'C:\\\\ts\\\\hidapi_tmp'; " + f"$dll = Get-ChildItem 'C:\\\\ts\\\\hidapi_tmp' -Recurse -Filter hidapi.dll | " + f"Where-Object {{ $_.FullName -match 'x64' }} | Select-Object -First 1; " + f"if ($dll) {{ Copy-Item $dll.FullName '%HIDAPI_DLL%'; Write-Host '[OK] hidapi.dll installe' }} " + f"else {{ Write-Host '[!!] hidapi.dll x64 introuvable dans archive' }}; " + f"Remove-Item 'C:\\\\ts\\\\hidapi_tmp' -Recurse -Force\"\n" + " ) else (\n" + " echo [!!] Echec telechargement hidapi.dll - le pedalier ne fonctionnera pas.\n" + " )\n" + ") else (\n" + " echo [OK] hidapi.dll deja present.\n" + ")\n" + "\n" + "echo Copie du binaire vers le dossier projet...\n" + f"if exist \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\" rmdir /S /Q \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\"\n" + f"xcopy /E /I /Y \"%LOCAL_DIST%\\{APP_NAME}\" \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\\\"\n" + "if errorlevel 1 ( echo ERREUR copie xcopy & pause & exit /b 1 )\n" + f"echo [OK] Binaire copie dans %PROJECT_ROOT%dist\\windows\\{APP_NAME}\\\n" + "\n" + + shortcut_block + + "\n" + "copy /Y \"%PROJECT_ROOT%icon.ico\" \"C:\\ts\\icon.ico\" > nul\n" + "\n" + "echo [6/6] Creation de l'installeur (Inno Setup)...\n" + "set \"ISCC_EXE=\"\n" + "if exist \"C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe\" " + "set \"ISCC_EXE=C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe\"\n" + "if exist \"C:\\Program Files\\Inno Setup 6\\ISCC.exe\" " + "set \"ISCC_EXE=C:\\Program Files\\Inno Setup 6\\ISCC.exe\"\n" + "if not defined ISCC_EXE (\n" + " echo [!!] Inno Setup 6 non installe - installeur ignore.\n" + " echo [!!] Telecharger gratuitement : https://jrsoftware.org/isdl.php\n" + " echo [!!] Puis relancez ce script pour generer l'installeur.\n" + " goto BUILD_DONE\n" + ")\n" + f"mkdir C:\\ts\\installer 2>nul\n" + f"echo Compilation Inno Setup...\n" + f"\"%ISCC_EXE%\" \"%SCRIPT_DIR%{APP_NAME}.iss\"\n" + "if errorlevel 1 ( echo [!!] Erreur Inno Setup & goto BUILD_DONE )\n" + f"echo [OK] Installeur : C:\\ts\\installer\\{APP_NAME}_Setup_v{APP_VERSION}.exe\n" + "\n" + ":BUILD_DONE\n" + "echo.\n" + f"echo *** Build termine ! ***\n" + f"echo Binaire : %PROJECT_ROOT%dist\\windows\\{APP_NAME}\\{APP_NAME}.exe\n" + f"echo Installeur : C:\\ts\\installer\\{APP_NAME}_Setup_v{APP_VERSION}.exe (si Inno Setup installe)\n" + "echo.\n" + "pause\n" + ) + + bat_crlf = bat.replace("\n", "\r\n") + bat_path.write_bytes(bat_crlf.encode("ascii", errors="replace")) + ok(f"Script Windows genere : {bat_path}") + ok(f"Script icones genere : {icons_path}") + generate_inno_script() + warn(f"Sur Windows : lancez dist\\windows\\build_windows.bat depuis la racine du projet") + warn(f"Inno Setup requis pour l'installeur : https://jrsoftware.org/isdl.php") + +# ─── Installeur Inno Setup ──────────────────────────────────────────────────── +def generate_inno_script(): + """ + Génère TranscribeStation.iss pour Inno Setup 6. + Sortie : dist/windows/TranscribeStation.iss (CRLF + BOM UTF-8) + Compilation : ISCC.exe TranscribeStation.iss + Résultat : C:\\ts\\installer\\TranscribeStation_Setup_v{APP_VERSION}.exe + """ + # On utilise une string ordinaire pour éviter les conflits entre + # les accolades Inno Setup {app} et le f-string Python. + # N = APP_NAME, V = APP_VERSION, A = APP_AUTHOR, U = APP_URL + N, V, A, U = APP_NAME, APP_VERSION, APP_AUTHOR, APP_URL + iss = ( + "[Setup]\n" + f"AppName={N}\n" + f"AppVersion={V}\n" + f"AppVerName={N} {V}\n" + f"AppPublisher={A}\n" + f"AppPublisherURL={U}\n" + f"AppSupportURL={U}\n" + f"DefaultDirName={{localappdata}}\\Programs\\{N}\n" + f"DefaultGroupName={N}\n" + "AllowNoIcons=yes\n" + "OutputDir=C:\\ts\\installer\n" + f"OutputBaseFilename={N}_Setup_v{V}\n" + "SetupIconFile=C:\\ts\\icon.ico\n" + "Compression=lzma2/ultra64\n" + "SolidCompression=yes\n" + "WizardStyle=modern\n" + "WizardSizePercent=120\n" + "PrivilegesRequired=lowest\n" + "ArchitecturesAllowed=x64compatible\n" + "ArchitecturesInstallIn64BitMode=x64compatible\n" + f"UninstallDisplayIcon={{app}}\\{N}.exe\n" + f"UninstallDisplayName={N} {V}\n" + "CloseApplications=yes\n" + "\n" + "[Languages]\n" + 'Name: "french"; MessagesFile: "compiler:Languages\\French.isl"\n' + "\n" + "[Tasks]\n" + 'Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; ' + 'GroupDescription: "{cm:AdditionalIcons}"\n' + "\n" + "[Files]\n" + f'Source: "C:\\ts\\dist\\{N}\\*"; ' + 'DestDir: "{app}"; ' + "Flags: ignoreversion recursesubdirs createallsubdirs\n" + "\n" + "[Icons]\n" + f'Name: "{{group}}\\{N}"; Filename: "{{app}}\\{N}.exe"\n' + f'Name: "{{userdesktop}}\\{N}"; Filename: "{{app}}\\{N}.exe"; Tasks: desktopicon\n' + "\n" + "[Run]\n" + f'Filename: "{{app}}\\{N}.exe"; ' + f'Description: "Lancer {N}"; ' + "Flags: nowait postinstall skipifsilent\n" + "\n" + "[UninstallDelete]\n" + 'Type: filesandordirs; Name: "{app}"\n' + "\n" + "[Code]\n" + "// Verifie que l'architecture est x64\n" + "function InitializeSetup(): Boolean;\n" + "begin\n" + " Result := True;\n" + " if not Is64BitInstallMode then begin\n" + f" MsgBox('{N} necessite Windows 64 bits.', mbError, MB_OK);\n" + " Result := False;\n" + " end;\n" + "end;\n" + ) + + out = DIST_DIR / "windows" / f"{APP_NAME}.iss" + out.parent.mkdir(parents=True, exist_ok=True) + # BOM UTF-8 + CRLF obligatoires pour Inno Setup + iss_crlf = iss.replace("\n", "\r\n") + out.write_bytes(b"\xef\xbb\xbf" + iss_crlf.encode("utf-8")) + ok(f"Script Inno Setup généré : {out}") + return out + +# ─── Nettoyage ──────────────────────────────────────────────────────────────── +def clean(): + step("🧹 Nettoyage...") + for d in [BUILD_DIR, DIST_DIR]: + if d.exists(): + shutil.rmtree(d); ok(f"Supprimé : {d}/") + for f in Path(".").glob("*.spec"): + f.unlink(); ok(f"Supprimé : {f}") + for icon in [ICON_ICO, ICON_PNG]: + if icon.exists(): + icon.unlink(); ok(f"Supprimé : {icon}") + ok("Nettoyage terminé.") + +# ─── Point d'entrée ─────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser( + description=f"Build + install {APP_NAME}", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent(""" + Exemples : + python build.py build + raccourcis (auto-detect OS) + python build.py --linux build Linux + raccourci GNOME + python build.py --windows build Windows + raccourci Menu Démarrer + python build.py --both les deux + python build.py --no-install build sans créer de raccourcis + python build.py --uninstall supprimer le raccourci GNOME + python build.py --clean supprimer artefacts build/dist/ + """), + ) + parser.add_argument("--linux", action="store_true") + parser.add_argument("--windows", action="store_true") + parser.add_argument("--both", action="store_true") + parser.add_argument("--no-install", action="store_true", + help="Ne pas installer de raccourci") + parser.add_argument("--install-only", action="store_true", + help="Installer raccourci sans recompiler") + parser.add_argument("--uninstall", action="store_true", + help="Désinstaller raccourci GNOME") + parser.add_argument("--clean", action="store_true") + args = parser.parse_args() + + os.chdir(Path(__file__).parent) + do_install = not args.no_install + + if args.clean: + clean(); return + + if args.uninstall: + uninstall_linux_shortcut(); return + + if args.install_only: + binary = Path(f"dist/linux/{APP_NAME}") + if binary.exists(): + generate_icons() + install_linux_shortcut(binary) + else: + err(f"Binaire introuvable : {binary}") + err("Lancez d'abord : python build.py --linux") + return + + if args.both: + build_linux(install=do_install) + build_windows(install=do_install) + return + + if args.windows: + build_windows(install=do_install); return + + if args.linux or platform.system() == "Linux": + build_linux(install=do_install) + elif platform.system() == "Windows": + build_windows(install=do_install) + else: + err(f"Plateforme inconnue : {platform.system()}") + err("Utilisez --linux ou --windows") + +if __name__ == "__main__": + main() diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b41b37f182fcf5a45eaa3de3e1b59a086dc9bb54 GIT binary patch literal 196 zcmZQzU<5(|0R|vYU|0tv#eldoz|WnRONtA~gnPb zQZXkvB_SbU!@1p`=Qr{cluY7MR^B-)v7vK9ld|yCe3_XmYM7_nv~hpXOam!9xqr3e zByJ8y5oV5?eZIWHdYnnT3)hLWU1Rw2;voBkt40qeDYzN8F+O3KQ^{x>Rk)LHId{&d l_gMmuRo&RwPHi$~I2EkFqI=uh%RmP(c)I$ztaD0e0sw8_J;49~ literal 0 HcmV?d00001 diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ae821345d2f5dd7500f068dc83f8cd432fcbf603 GIT binary patch literal 1638 zcmah~eKb^g7=G`a8DyB5Vr5iIPU6H!p=J@YyQzFOk*RH{q0kCViOT0~irG$`ty79h zrPH?AOc`eygTe@F6B-c-o9WAFeXZmBbMH*2UAup@fBo+Lz3=mWJn!?~dy?(DQD4_Y z7Xb7~xsZt)?$!RWXbL^Wu)8k(MYH-Nw!yCIT>M_#ZVQIUQWSbhf zSytP3TkR;@XWyd-f*?Ii=5|^s`K|!Pc;ST3Uh3{5hei(2dX~ZOGj>16NTNtk?n;!m znf@B*@&-3}gV_r%*vu}iP>Q$+%MQVKyW*Umq->I0AJ3DTo+y}}81EfbrntG5xHiQb zHCsYftjkz+nJsZQUrT<{tHNz~RN;!F4pZPOhvB+tYsFe?&{?jDoCz$uQ7}8TA&g7Mi3k7x@Z9AG}) zS^&rS%*Tt>U>;B`FL{BPy10+=+6g>+TPx{Cu&O8BifcvL>159+O+kil1b&lq3&iQ# z-tKNDYv>H}hV~7|{pWK5e|h)=K$m_QDKmai9mj0*V3A?~!Mw7_tthRgNRgsWi!n?6 z5-tCxWo{=Qdl#KLbL?DMv*6ri3YI6$7KO>6v{^8CSs&Ot&m#j;qsW`x_Mr0FuicD- zwQ5*i{FnhlKXnoIX=0Iwl6j2JgvTrYr{n*$^#2C33qJq!u@dfl^_;{C#{LnbS2GVf zqDQ481i8)#Vdjr6*{iPPk^sE6h@{RljzuKZG>;VDum`Xxheg;mDkpoP&vj%dUW}IP zb%F$~FKhP3kslI~fi4HDc^qJGzV#typ zR34_6m=KA~C5Z>N?RFVznHZlSudP*nDQ5*12CqPj&mQaI6o3IUa<_vNB&-B%f(6s@6MLCO%<(A3Nws<03Z7iqJ$1wPu50SXI38HGD{TacW}Dx)1{ z>N&1Bn4kKxosd&oWfZXh+hBYoLQMK3|D>ZwOUrVnY4VYs*ZTWKFeWG6veBr_z}D&= zpNdHW*);=kFg9I%z0A>+_@aJh;1y01zGw+#Y!c2Y`OSAVyojlYSRcz@^0e>^AUe;( z0^oFadnEoP5K9oTE|wp7`Fnhw<5mF&u}gaz)~XAFBooL5RhyJvT8z>VRU<=^&}hUW zX0Mdhi|ldV7!aY^RR6HpXS(=plz;f4LABW@CM>3zj_I(t@9W?8e9M}Tt+BaOHzqBuy@WBDAJ!?4)!BPwCooDIR zwMV&R#9LMs5#RW(m?n?qM&HcsX-Ng&=S;WUWS*tgvfQM-JJ*4*5+h25Gi-b6!IM@B e$;GtktQ1eHmC@t!h)wwa13cV)*WdewC-@5vI(PK| literal 0 HcmV?d00001 diff --git a/launch.sh b/launch.sh new file mode 100644 index 0000000..4fada6f --- /dev/null +++ b/launch.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/.venv/bin/activate" +exec python "$SCRIPT_DIR/transcribe_station.py" "$@" diff --git a/transcribe_station.py b/transcribe_station.py new file mode 100644 index 0000000..0a852ee --- /dev/null +++ b/transcribe_station.py @@ -0,0 +1,2900 @@ +#!/usr/bin/env python3 +""" +TranscribeStation +Gestionnaire de transcription audio avec support pédaliers +Olympus RS27H/N · RS28H/N · RS31H/N + +Dépendances : + pip install PySide6 numpy soundfile hid + +Règle udev nécessaire pour les pédaliers (sans root) : + /etc/udev/rules.d/99-olympus-pedal.rules + ----------------------------------------------- + SUBSYSTEM=="hidraw", ATTRS{idVendor}=="07b4", MODE="0666", GROUP="plugdev" + SUBSYSTEM=="hidraw", ATTRS{idVendor}=="33a2", MODE="0666", GROUP="plugdev" + SUBSYSTEM=="input", ATTRS{idVendor}=="07b4", ENV{LIBINPUT_IGNORE_DEVICE}="1" + SUBSYSTEM=="input", ATTRS{idVendor}=="33a2", ENV{LIBINPUT_IGNORE_DEVICE}="1" + Puis : sudo udevadm control --reload-rules && sudo udevadm trigger +""" + +# ─── Métadonnées application ────────────────────────────────────────────────── +APP_NAME = "TranscribeStation" +APP_VERSION = "1.2.0" +APP_AUTHOR = "H3Campus" +APP_YEAR = "Mars 2026" +APP_URL = "https://github.com/h3campus/transcribe-station" + +import sys +import os +import json +import time +from pathlib import Path +from datetime import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Optional, List, Dict + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, + QTableWidget, QTableWidgetItem, QHeaderView, QVBoxLayout, QHBoxLayout, + QToolBar, QStatusBar, QLabel, QSlider, QComboBox, QPushButton, QSizePolicy, + QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox, + QFileDialog, QMessageBox, QMenu, QGroupBox, QPlainTextEdit, QInputDialog, + QAbstractItemView, QTabWidget, +) +from PySide6.QtCore import ( + Qt, QUrl, QTimer, QThread, Signal, QSize, QRect, + Slot, +) +from PySide6.QtGui import ( + QIcon, QAction, QColor, QPainter, QPen, QFont, + QLinearGradient, QPixmap, +) +from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput + +try: + import hid + hid.enumerate() # vérifie que la DLL native est chargeable + HID_AVAILABLE = True +except (ImportError, OSError, Exception): + HID_AVAILABLE = False + + +class _HidDevice: + """ + Wrapper de compatibilité HID — supporte trois variantes du module 'hid' : + + • Nouvelle API (hid >= 1.0.6) : hid.Device(vid=…, pid=…) + dev.nonblocking + • API OO classique : hid.device() + dev.open() + dev.set_nonblocking() + • API fonctionnelle : hid.open() / hid.read() / hid.close() + """ + if HID_AVAILABLE: + if hasattr(hid, "Device"): + _API = "new" # hid.Device (constructeur ouvre directement) + elif hasattr(hid, "device"): + _API = "oo" # hid.device() + open() + else: + _API = "func" # hid.open() fonctionnel + else: + _API = "none" + + def __init__(self): + self._dev = None + + def open(self, vid: int, pid: int, path: bytes = None) -> None: + """Ouvre le device par VID/PID ou par chemin d'interface (path).""" + api = self._API + if api == "new": + self._dev = hid.Device(path=path) if path else hid.Device(vid=vid, pid=pid) + elif api == "oo": + self._dev = hid.device() + if path: + self._dev.open_path(path) + else: + self._dev.open(vid, pid) + elif api == "func": + self._dev = hid.open_path(path) if path else hid.open(vid, pid) + else: + raise RuntimeError("Module hid non disponible") + + @staticmethod + def paths_for(vid: int, pid: int) -> list: + """ + Retourne les interfaces HID uniques pour un VID/PID. + Chaque entrée : {'path': bytes, 'interface': int, 'usage_page': int, 'usage': int} + """ + if not HID_AVAILABLE: + return [] + seen: set = set() + result: List = [] + try: + for d in hid.enumerate(): + if d["vendor_id"] != vid or d["product_id"] != pid: + continue + path = d.get("path", b"") + if path and path not in seen: + seen.add(path) + result.append({ + "path": path, + "interface": d.get("interface_number", 0), + "usage_page": d.get("usage_page", 0), + "usage": d.get("usage", 0), + }) + except Exception: + pass + return result + + def set_nonblocking(self, value: bool) -> None: + api = self._API + if api == "new": + self._dev.nonblocking = bool(value) + elif api == "oo": + self._dev.set_nonblocking(value) + elif api == "func": + hid.set_nonblocking(self._dev, 1 if value else 0) + + def read(self, length: int, timeout_ms: int = 0) -> list: + api = self._API + if api == "new": + # La nouvelle API n'a pas de timeout_ms dans read() ; + # le comportement bloquant/non-bloquant est géré par nonblocking. + return list(self._dev.read(length)) + elif api == "oo": + raw = self._dev.read(length, timeout_ms) if timeout_ms else self._dev.read(length) + return list(raw) + elif api == "func": + return list(hid.read(self._dev, length)) + return [] + + def close(self) -> None: + try: + if self._dev is not None: + if self._API == "func": + hid.close(self._dev) + else: + self._dev.close() + except Exception: + pass + self._dev = None + +try: + import numpy as np + import soundfile as sf + WAVEFORM_AVAILABLE = True +except ImportError: + WAVEFORM_AVAILABLE = False + +# ─── Paramètres JSON ────────────────────────────────────────────────────────── +class AppSettings: + """ + Paramètres persistants stockés dans ~/.config/TranscribeStation/settings.json + Accès par clé pointée : get("pedal.enabled") / set("pedal.saved_profiles", {...}) + """ + CONFIG_PATH = ( + Path(os.environ.get("APPDATA", Path.home())) / "TranscribeStation" / "settings.json" + if sys.platform == "win32" + else Path.home() / ".config" / "TranscribeStation" / "settings.json" + ) + MAX_RECENT = 8 + + DEFAULTS: Dict = { + "pedal": { + "enabled": True, + "skip_ms": 3000, + # Profils par pédalier : clé = "vid_hex:pid_hex" (ex. "07b4:0110") + # Valeur : {name, vid, pid, buttons: {action:[bidx,val]}, hid_interface} + "saved_profiles": {}, + }, + "general": { + "author": "", + "auto_play": False, + "theme": "light", + }, + "window": { + "x": 100, "y": 100, + "width": 1280, "height": 800, + }, + "recent": [], # liste de chemins récemment ouverts + } + + def __init__(self, path: Optional[Path] = None): + self._path = path or self.CONFIG_PATH + self._data: Dict = {} + self.load() + + # ── I/O ─────────────────────────────────────────────────────────────────── + def load(self): + disk: Dict = {} + if self._path.exists(): + try: + with open(self._path, encoding="utf-8") as f: + disk = json.load(f) + except Exception: + disk = {} + self._data = self._merge(self._copy_defaults(), disk) + self._migrate_old_pedal_settings() + + def _migrate_old_pedal_settings(self): + """Migre l'ancien format (un seul pédalier) vers saved_profiles.""" + pedal = self._data.get("pedal", {}) + old_vid = pedal.get("vid") + old_pid = pedal.get("pid") + old_buttons = pedal.get("buttons") + old_iface = pedal.get("hid_interface") + old_model = pedal.get("model") + if old_vid and old_pid and isinstance(old_buttons, dict): + key = f"{old_vid:04x}:{old_pid:04x}" + profiles = pedal.setdefault("saved_profiles", {}) + if key not in profiles: + profiles[key] = { + "name": old_model or key, + "vid": old_vid, + "pid": old_pid, + "buttons": old_buttons, + "hid_interface": old_iface, + } + for k in ("vid", "pid", "buttons", "hid_interface", "model"): + pedal.pop(k, None) + self._data["pedal"] = pedal + + def save(self): + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump(self._data, f, ensure_ascii=False, indent=2) + + # ── Accès par clé pointée ───────────────────────────────────────────────── + def get(self, key: str, default=None): + node = self._data + for part in key.split("."): + if isinstance(node, dict) and part in node: + node = node[part] + else: + return default + return node + + def set(self, key: str, value): + parts = key.split(".") + node = self._data + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = value + self.save() + + # ── Dossiers récents ────────────────────────────────────────────────────── + def add_recent(self, folder: Path): + path_str = str(folder) + recent: List[str] = self.get("recent", []) + if path_str in recent: + recent.remove(path_str) + recent.insert(0, path_str) + self.set("recent", recent[: self.MAX_RECENT]) + + def get_recent(self) -> List[Path]: + return [Path(p) for p in self.get("recent", []) if Path(p).is_dir()] + + # ── Géométrie fenêtre ───────────────────────────────────────────────────── + def save_geometry(self, x: int, y: int, w: int, h: int): + self._data["window"] = {"x": x, "y": y, "width": w, "height": h} + self.save() + + def load_geometry(self) -> tuple: + win = self.get("window", {}) + return ( + win.get("x", 100), win.get("y", 100), + win.get("width", 1280), win.get("height", 800), + ) + + # ── Helpers ─────────────────────────────────────────────────────────────── + def _copy_defaults(self) -> Dict: + import copy + return copy.deepcopy(self.DEFAULTS) + + @staticmethod + def _merge(base: Dict, override: Dict) -> Dict: + for k, v in override.items(): + if k in base and isinstance(base[k], dict) and isinstance(v, dict): + base[k] = AppSettings._merge(base[k], v) + else: + base[k] = v + return base +OLYMPUS_VID = 0x07B4 +OMDS_VID = 0x33A2 # OM Digital Solutions (ex-Olympus, depuis 2022) +KNOWN_VIDS = {OLYMPUS_VID, OMDS_VID} + +# Format buttons: {action: [byte_idx, mask]} +# action ∈ {"rewind", "play", "forward"} +# byte_idx : indice dans le rapport HID brut (0-based) +# mask : masque de bit (ex: 0x04) +PEDAL_PROFILES: Dict[str, Dict] = { + "RS27H/N": { + "vendor_id": 0x07B4, + "product_id": 0x0110, + "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]}, + }, + "RS28H/N": { + "vendor_id": 0x07B4, + "product_id": 0x0111, + "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]}, + }, + "RS28H (0x0218)": { + "vendor_id": 0x07B4, + "product_id": 0x0218, + "buttons": {"rewind": [1, 0x01], "play": [1, 0x02], "forward": [1, 0x04]}, + }, + "RS31H/N": { + "vendor_id": 0x07B4, + "product_id": 0x0112, + "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]}, + }, + "OM RS Series (0x33A2)": { + "vendor_id": 0x33A2, + "product_id": 0x0293, + "buttons": {"rewind": [1, 0x04], "play": [1, 0x02], "forward": [1, 0x08]}, + }, +} +# Délai de rembobinage/avance rapide en ms +SKIP_MS = 3000 + +# ─── Couleurs & thème ───────────────────────────────────────────────────────── +THEMES: Dict[str, Dict[str, str]] = { + "light": { + "bg": "#F6F8FC", + "surface": "#FFFFFF", + "surface2": "#EEF3FB", + "border": "#D6DEEC", + "accent": "#2E6BE6", + "accent_hover": "#4F84EC", + "success": "#1FA971", + "warning": "#D98B12", + "error": "#D14B4B", + "pending": "#D06A44", + "text": "#1F2937", + "text_dim": "#66758A", + "waveform": "#7EA7F1", + "waveform_pos": "#24B47E", + "selected_row": "#DCE8FF", + "header_bg": "#E9F0FB", + }, + "dark": { + "bg": "#1E1E2E", + "surface": "#252535", + "surface2": "#2A2A3E", + "border": "#3A3A5C", + "accent": "#5B8DEF", + "accent_hover": "#7AAEFF", + "success": "#3ECF8E", + "warning": "#F5A623", + "error": "#E05555", + "pending": "#E07B55", + "text": "#E8E8F0", + "text_dim": "#888899", + "waveform": "#5B8DEF", + "waveform_pos": "#3ECF8E", + "selected_row": "#2E3C5A", + "header_bg": "#1A1A2A", + }, +} + +COLORS: Dict[str, str] = dict(THEMES["light"]) +STYLESHEET = "" + +def _build_stylesheet(colors: Dict[str, str]) -> str: + return f""" +QMainWindow, QWidget {{ + background-color: {colors['bg']}; + color: {colors['text']}; + font-family: 'Segoe UI', 'DejaVu Sans', 'Liberation Sans', sans-serif; + font-size: 13px; +}} +QMenuBar {{ + background: {colors['surface']}; + border-bottom: 1px solid {colors['border']}; + padding: 2px; +}} +QMenuBar::item {{ padding: 4px 10px; border-radius: 4px; }} +QMenuBar::item:selected {{ background: {colors['accent']}; }} +QMenu {{ + background: {colors['surface']}; + border: 1px solid {colors['border']}; + border-radius: 6px; + padding: 4px; +}} +QMenu::item {{ padding: 6px 20px 6px 12px; border-radius: 4px; }} +QMenu::item:selected {{ background: {colors['accent']}; }} +QToolBar {{ + background: {colors['surface']}; + border-bottom: 1px solid {colors['border']}; + spacing: 4px; + padding: 4px; +}} +QToolButton {{ + background: transparent; + border: 1px solid transparent; + border-radius: 5px; + padding: 4px 8px; + color: {colors['text']}; +}} +QToolButton:hover {{ background: {colors['surface2']}; border-color: {colors['border']}; }} +QToolButton:pressed {{ background: {colors['accent']}; }} +QTreeWidget {{ + background: {colors['surface']}; + border: none; + border-right: 1px solid {colors['border']}; + outline: none; +}} +QTreeWidget::item {{ padding: 4px 6px; border-radius: 4px; }} +QTreeWidget::item:selected {{ background: {colors['accent']}; color: white; }} +QTreeWidget::item:hover:!selected {{ background: {colors['surface2']}; }} +QTreeWidget::branch:has-children:!has-siblings:closed, +QTreeWidget::branch:closed:has-children:has-siblings {{ + image: url(none); +}} +QHeaderView::section {{ + background: {colors['header_bg']}; + color: {colors['text_dim']}; + border: none; + border-bottom: 2px solid {colors['accent']}; + border-right: 1px solid {colors['border']}; + padding: 6px 8px; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +}} +QTableWidget {{ + background: {colors['bg']}; + gridline-color: {colors['border']}; + border: none; + outline: none; + selection-background-color: {colors['selected_row']}; +}} +QTableWidget::item {{ padding: 6px 8px; border-bottom: 1px solid {colors['border']}; }} +QTableWidget::item:selected {{ + background: {colors['selected_row']}; + color: {colors['text']}; +}} +QScrollBar:vertical {{ + background: {colors['surface']}; + width: 8px; + border-radius: 4px; +}} +QScrollBar::handle:vertical {{ + background: {colors['border']}; + border-radius: 4px; + min-height: 20px; +}} +QScrollBar::handle:vertical:hover {{ background: {colors['accent']}; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ + background: {colors['surface']}; + height: 8px; + border-radius: 4px; +}} +QScrollBar::handle:horizontal {{ + background: {colors['border']}; + border-radius: 4px; + min-width: 20px; +}} +QSlider::groove:horizontal {{ + background: {colors['border']}; + height: 4px; + border-radius: 2px; +}} +QSlider::sub-page:horizontal {{ + background: {colors['accent']}; + height: 4px; + border-radius: 2px; +}} +QSlider::handle:horizontal {{ + background: white; + border: 2px solid {colors['accent']}; + width: 14px; + height: 14px; + border-radius: 7px; + margin: -5px 0; +}} +QSlider::handle:horizontal:hover {{ background: {colors['accent']}; }} +QPushButton {{ + background: {colors['surface2']}; + border: 1px solid {colors['border']}; + border-radius: 6px; + padding: 6px 16px; + color: {colors['text']}; + font-weight: 500; +}} +QPushButton:hover {{ background: {colors['accent']}; border-color: {colors['accent']}; color: white; }} +QPushButton:pressed {{ background: {colors['accent_hover']}; }} +QPushButton#accent {{ + background: {colors['accent']}; + border-color: {colors['accent']}; + color: white; +}} +QPushButton#accent:hover {{ background: {colors['accent_hover']}; }} +QComboBox {{ + background: {colors['surface2']}; + border: 1px solid {colors['border']}; + border-radius: 6px; + padding: 4px 10px; + min-width: 80px; +}} +QComboBox::drop-down {{ border: none; width: 20px; }} +QComboBox QAbstractItemView {{ + background: {colors['surface']}; + border: 1px solid {colors['border']}; + selection-background-color: {colors['accent']}; +}} +QStatusBar {{ + background: {colors['surface']}; + border-top: 1px solid {colors['border']}; + color: {colors['text_dim']}; + font-size: 11px; + padding: 2px 8px; +}} +QGroupBox {{ + border: 1px solid {colors['border']}; + border-radius: 6px; + margin-top: 8px; + padding-top: 8px; + font-weight: 600; + color: {colors['text_dim']}; +}} +QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 4px; }} +QLabel {{ color: {colors['text']}; }} +QLineEdit, QSpinBox {{ + background: {colors['surface2']}; + border: 1px solid {colors['border']}; + border-radius: 6px; + padding: 5px 10px; + color: {colors['text']}; +}} +QLineEdit:focus, QSpinBox:focus {{ border-color: {colors['accent']}; }} +QDialog {{ + background: {colors['bg']}; +}} +QTabWidget::pane {{ + border: 1px solid {colors['border']}; + border-radius: 6px; +}} +QTabBar::tab {{ + background: {colors['surface']}; + border: 1px solid {colors['border']}; + padding: 6px 16px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; +}} +QTabBar::tab:selected {{ background: {colors['accent']}; border-color: {colors['accent']}; color: white; }} +""" + +def set_theme(theme_name: str) -> str: + global COLORS, STYLESHEET + actual = theme_name if theme_name in THEMES else "light" + COLORS = dict(THEMES[actual]) + STYLESHEET = _build_stylesheet(COLORS) + return actual + +set_theme("light") + +# ─── Modèle de données ──────────────────────────────────────────────────────── +class DictationStatus(Enum): + TODO = "À faire" + IN_PROGRESS = "En cours" + SUSPENDED = "Suspendu" + DONE = "Terminé" + +@dataclass +class DictationFile: + path: Path + job_number: int = 0 + author: str = "" + work_type: str = "GENERAL" + status: DictationStatus = DictationStatus.TODO + created: datetime = field(default_factory=datetime.now) + completed: Optional[datetime] = None + duration_s: float = 0.0 + notes: str = "" + + @property + def filename(self) -> str: + return self.path.name + + @property + def duration_str(self) -> str: + h, r = divmod(int(self.duration_s), 3600) + m, s = divmod(r, 60) + if h: + return f"{h:02d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" + + @property + def status_color(self) -> str: + return { + DictationStatus.TODO: COLORS["warning"], + DictationStatus.IN_PROGRESS: COLORS["accent"], + DictationStatus.SUSPENDED: COLORS["text_dim"], + DictationStatus.DONE: COLORS["success"], + }[self.status] + + def to_dict(self) -> dict: + return { + "path": str(self.path), + "job_number": self.job_number, + "author": self.author, + "work_type": self.work_type, + "status": self.status.name, + "created": self.created.isoformat(), + "completed": self.completed.isoformat() if self.completed else None, + "duration_s": self.duration_s, + "notes": self.notes, + } + + @classmethod + def from_dict(cls, d: dict) -> "DictationFile": + obj = cls(path=Path(d["path"])) + obj.job_number = d.get("job_number", 0) + obj.author = d.get("author", "") + obj.work_type = d.get("work_type", "GENERAL") + obj.status = DictationStatus[d.get("status", "TODO")] + obj.created = datetime.fromisoformat(d["created"]) + obj.completed = datetime.fromisoformat(d["completed"]) if d.get("completed") else None + obj.duration_s = d.get("duration_s", 0.0) + obj.notes = d.get("notes", "") + return obj + + +# ─── Thread pédalier USB HID ────────────────────────────────────────────────── +class FootPedalWorker(QThread): + """Lit les événements HID du pédalier en arrière-plan.""" + pedal_pressed = Signal(str) # "rewind" | "play" | "forward" + pedal_released = Signal(str) + device_status = Signal(str) # message statut + + def __init__(self, profile: Dict, parent=None): + super().__init__(parent) + self._profile = profile + self._running = False + self._device = None + + @staticmethod + def _match(prev_byte: int, curr_byte: int, value: int) -> tuple: + """ + Détecte presse / relâchement pour un binding donné. + + Si 'value' est une puissance de 2 → mode bitmask (bit set/clear). + Sinon → mode valeur exacte (key code, consumer usage…). + Retourne (was_active, now_active). + """ + if value > 0 and (value & (value - 1)) == 0: + # Bitmask : un seul bit + return bool(prev_byte & value), bool(curr_byte & value) + else: + # Valeur exacte (keyboard / consumer) + return (prev_byte == value), (curr_byte == value) + + def _open_device(self, vid: int, pid: int) -> None: + """Ouvre le device sur la bonne interface si hid_interface est spécifié.""" + iface_num = self._profile.get("hid_interface", None) + path = None + if iface_num is not None: + for info in _HidDevice.paths_for(vid, pid): + if info["interface"] == iface_num: + path = info["path"] + break + self._device = _HidDevice() + self._device.open(vid, pid, path=path) + + def run(self): + self._running = True + vid = self._profile["vendor_id"] + pid = self._profile["product_id"] + btn_map = self._profile["buttons"] # {action: [byte_idx, value]} + + while self._running: + try: + self._open_device(vid, pid) + self._device.set_nonblocking(True) + self.device_status.emit("Pédalier connecté") + prev = [0] * 16 + + while self._running: + data = self._device.read(16, timeout_ms=100) + if not data: + time.sleep(0.01) + continue + raw = list(data) + [0] * max(0, 16 - len(data)) + for action, binding in btn_map.items(): + bidx = binding[0] + value = binding[1] + if bidx >= len(raw): + continue + was, now = self._match(prev[bidx], raw[bidx], value) + if was != now: + if now: + self.pedal_pressed.emit(action) + else: + self.pedal_released.emit(action) + prev = raw + + except Exception as e: + self.device_status.emit(f"Pédalier déconnecté ({e})") + if self._device: + try: + self._device.close() + except Exception: + pass + self._device = None + time.sleep(1.0) # retry + + def stop(self): + self._running = False + if self._device: + try: + self._device.close() + except Exception: + pass + + @staticmethod + def list_devices() -> List[Dict]: + """Retourne les pédaliers Olympus/OM Digital détectés (dédupliqués par VID+PID).""" + if not HID_AVAILABLE: + return [] + seen: set = set() + found: List = [] + try: + for dev in hid.enumerate(): + if dev["vendor_id"] not in KNOWN_VIDS: + continue + key = (dev["vendor_id"], dev["product_id"]) + if key not in seen: + seen.add(key) + found.append(dev) + except Exception: + pass + return found + + +# ─── Widget forme d'onde ────────────────────────────────────────────────────── +class WaveformWidget(QWidget): + seek_requested = Signal(float) # 0.0–1.0 + + def __init__(self, parent=None): + super().__init__(parent) + self._samples: Optional["np.ndarray"] = None + self._position = 0.0 # 0.0–1.0 + self.setMinimumHeight(70) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def load_file(self, path: Path): + if not WAVEFORM_AVAILABLE: + return + try: + data, sr = sf.read(str(path), always_2d=True) + mono = data.mean(axis=1) + # Décimer pour l'affichage + target = 2000 + step = max(1, len(mono) // target) + self._samples = mono[::step].astype(np.float32) + # Normaliser + peak = np.abs(self._samples).max() + if peak > 0: + self._samples /= peak + self.update() + except Exception: + self._samples = None + self.update() + + def clear(self): + self._samples = None + self._position = 0.0 + self.update() + + def set_position(self, ratio: float): + self._position = max(0.0, min(1.0, ratio)) + self.update() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton and self._samples is not None: + ratio = event.position().x() / self.width() + self.seek_requested.emit(ratio) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + mid = h / 2 + + # Fond + grad = QLinearGradient(0, 0, 0, h) + grad.setColorAt(0, QColor(COLORS["surface"])) + grad.setColorAt(1, QColor(COLORS["bg"])) + p.fillRect(0, 0, w, h, grad) + + if self._samples is None or len(self._samples) == 0: + # Message "no waveform" + p.setPen(QColor(COLORS["text_dim"])) + p.drawText(QRect(0, 0, w, h), Qt.AlignmentFlag.AlignCenter, + "Chargez un fichier audio pour afficher la forme d'onde") + return + + n = len(self._samples) + pos_x = int(self._position * w) + + # Dessiner les barres de forme d'onde + bar_w = max(1, w // n) + if bar_w < 1: + bar_w = 1 + + for i, sample in enumerate(self._samples): + x = int(i * w / n) + bar_h = abs(float(sample)) * mid * 0.85 + if x < pos_x: + color = QColor(COLORS["waveform_pos"]) + color.setAlphaF(0.8) + else: + color = QColor(COLORS["waveform"]) + color.setAlphaF(0.5) + p.fillRect(x, int(mid - bar_h), max(1, bar_w - 1), int(bar_h * 2) + 1, color) + + # Ligne centrale + p.setPen(QPen(QColor(COLORS["border"]), 1, Qt.PenStyle.DotLine)) + p.drawLine(0, int(mid), w, int(mid)) + + # Curseur de position + p.setPen(QPen(QColor("#FFFFFF"), 2)) + p.drawLine(pos_x, 0, pos_x, h) + + # Bord supérieur + p.setPen(QPen(QColor(COLORS["border"]), 1)) + p.drawLine(0, 0, w, 0) + p.drawLine(0, h - 1, w, h - 1) + + +# ─── Panneau de contrôle audio ──────────────────────────────────────────────── +class PlayerPanel(QWidget): + """Barre de contrôle audio + forme d'onde.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._player = QMediaPlayer() + self._audio = QAudioOutput() + self._player.setAudioOutput(self._audio) + self._audio.setVolume(1.0) + self._rewind_timer = QTimer() + self._rewind_timer.setInterval(100) + self._rewind_timer.timeout.connect(self._do_rewind) + self._forward_timer = QTimer() + self._forward_timer.setInterval(100) + self._forward_timer.timeout.connect(self._do_forward) + self._pedal_connected = False + + self._build_ui() + self._connect_signals() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(4) + + # ── Forme d'onde ────────────────────────────────────────────────────── + self.waveform = WaveformWidget() + layout.addWidget(self.waveform) + + # ── Slider de position ──────────────────────────────────────────────── + pos_row = QHBoxLayout() + self._lbl_cur = QLabel("00:00") + self._lbl_cur.setFixedWidth(45) + self._lbl_cur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;") + self._lbl_dur = QLabel("00:00") + self._lbl_dur.setFixedWidth(45) + self._lbl_dur.setAlignment(Qt.AlignmentFlag.AlignRight) + self._lbl_dur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;") + self._seek_slider = QSlider(Qt.Orientation.Horizontal) + self._seek_slider.setRange(0, 0) + pos_row.addWidget(self._lbl_cur) + pos_row.addWidget(self._seek_slider) + pos_row.addWidget(self._lbl_dur) + layout.addLayout(pos_row) + + # ── Boutons de transport ────────────────────────────────────────────── + ctrl_row = QHBoxLayout() + ctrl_row.setSpacing(8) + + # Volume + vol_lbl = QLabel("🔊") + vol_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:14px;") + self._vol_slider = QSlider(Qt.Orientation.Horizontal) + self._vol_slider.setRange(0, 100) + self._vol_slider.setValue(100) + self._vol_slider.setFixedWidth(80) + + # Boutons transport + self._btn_rw = self._mk_btn("⏮", "Début (Home)", self.goto_start) + self._btn_rew = self._mk_btn("⏪", f"Reculer {SKIP_MS//1000}s", self.skip_back) + self._btn_play = self._mk_btn("▶", "Lecture / Pause (Espace)", self.toggle_play) + self._btn_fwd = self._mk_btn("⏩", f"Avancer {SKIP_MS//1000}s", self.skip_forward) + self._btn_end = self._mk_btn("⏭", "Fin", self.goto_end) + + self._btn_play.setObjectName("accent") + self._btn_play.setFixedSize(44, 44) + + # Vitesse + spd_lbl = QLabel("Vitesse:") + spd_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;") + self._speed_box = QComboBox() + self._speed_box.addItems(["0.5×", "0.75×", "1.0×", "1.25×", "1.5×", "2.0×"]) + self._speed_box.setCurrentIndex(2) + self._speed_box.setFixedWidth(75) + self._speed_box.currentIndexChanged.connect(self._on_speed_changed) + + ctrl_row.addWidget(vol_lbl) + ctrl_row.addWidget(self._vol_slider) + ctrl_row.addStretch() + ctrl_row.addWidget(self._btn_rw) + ctrl_row.addWidget(self._btn_rew) + ctrl_row.addWidget(self._btn_play) + ctrl_row.addWidget(self._btn_fwd) + ctrl_row.addWidget(self._btn_end) + ctrl_row.addStretch() + ctrl_row.addWidget(spd_lbl) + ctrl_row.addWidget(self._speed_box) + + layout.addLayout(ctrl_row) + + # Indicateur pédalier + self._pedal_lbl = QLabel("🦶 Pédalier: non connecté") + self._pedal_lbl.setStyleSheet(f"color:{COLORS['text_dim']};font-size:10px;") + layout.addWidget(self._pedal_lbl, alignment=Qt.AlignmentFlag.AlignRight) + + # Style fond panneau + self.setStyleSheet(f""" + PlayerPanel {{ + background: {COLORS['surface']}; + border-top: 2px solid {COLORS['accent']}; + }} + """) + self.setAutoFillBackground(True) + + def apply_theme(self): + self._lbl_cur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;") + self._lbl_dur.setStyleSheet(f"color:{COLORS['text_dim']};font-size:11px;") + self._pedal_lbl.setStyleSheet( + f"color:{COLORS['success'] if self._pedal_connected else COLORS['text_dim']};font-size:10px;") + self.setStyleSheet(f""" + PlayerPanel {{ + background: {COLORS['surface']}; + border-top: 2px solid {COLORS['accent']}; + }} + """) + for btn in [self._btn_rw, self._btn_rew, self._btn_play, self._btn_fwd, self._btn_end]: + btn.setStyleSheet(f""" + QPushButton {{ + background: {COLORS['surface2']}; + border: 1px solid {COLORS['border']}; + border-radius: 18px; + font-size: 14px; + color: {COLORS['text']}; + }} + QPushButton:hover {{ background: {COLORS['accent']}; color: white; border: none; }} + QPushButton#accent {{ + background: {COLORS['accent']}; + color: white; + border: none; + border-radius: 22px; + }} + QPushButton#accent:hover {{ background: {COLORS['accent_hover']}; }} + """) + self.waveform.update() + + def _mk_btn(self, icon_txt: str, tooltip: str, slot) -> QPushButton: + btn = QPushButton(icon_txt) + btn.setToolTip(tooltip) + btn.setFixedSize(36, 36) + btn.clicked.connect(slot) + btn.setStyleSheet(f""" + QPushButton {{ + background: {COLORS['surface2']}; + border: 1px solid {COLORS['border']}; + border-radius: 18px; + font-size: 14px; + color: {COLORS['text']}; + }} + QPushButton:hover {{ background: {COLORS['accent']}; color: white; border: none; }} + QPushButton#accent {{ + background: {COLORS['accent']}; + color: white; + border: none; + border-radius: 22px; + }} + QPushButton#accent:hover {{ background: {COLORS['accent_hover']}; }} + """) + return btn + + def _connect_signals(self): + self._player.positionChanged.connect(self._on_position_changed) + self._player.durationChanged.connect(self._on_duration_changed) + self._player.playbackStateChanged.connect(self._on_state_changed) + self._seek_slider.sliderMoved.connect(self._on_seek) + self._vol_slider.valueChanged.connect(self._on_volume_changed) + self.waveform.seek_requested.connect(self._on_waveform_seek) + + # ── Chargement fichier ──────────────────────────────────────────────────── + def load_file(self, path: Path): + self._player.stop() + self._player.setSource(QUrl.fromLocalFile(str(path))) + self.waveform.load_file(path) + self._btn_play.setText("▶") + + def unload(self): + self._player.stop() + self._player.setSource(QUrl()) + self.waveform.clear() + self._seek_slider.setRange(0, 0) + self._lbl_cur.setText("00:00") + self._lbl_dur.setText("00:00") + + # ── Transport ───────────────────────────────────────────────────────────── + def toggle_play(self): + if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self._player.pause() + else: + self._player.play() + + def stop_playback(self): + self._player.stop() + + def toggle_mute(self): + self._audio.setMuted(not self._audio.isMuted()) + + def set_volume(self, value: int): + """Slot connecté au slider volume toolbar (0-100).""" + self._audio.setVolume(value / 100.0) + # Synchroniser le slider interne du PlayerPanel + self._vol_slider.blockSignals(True) + self._vol_slider.setValue(value) + self._vol_slider.blockSignals(False) + + def volume_up(self): + v = min(100, self._vol_slider.value() + 10) + self._vol_slider.setValue(v) + + def volume_down(self): + v = max(0, self._vol_slider.value() - 10) + self._vol_slider.setValue(v) + + def speed_up(self): + i = min(self._speed_box.count() - 1, self._speed_box.currentIndex() + 1) + self._speed_box.setCurrentIndex(i) + + def speed_down(self): + i = max(0, self._speed_box.currentIndex() - 1) + self._speed_box.setCurrentIndex(i) + + def skip_back(self): + self._player.setPosition(max(0, self._player.position() - SKIP_MS)) + + def skip_forward(self): + dur = self._player.duration() + self._player.setPosition(min(dur, self._player.position() + SKIP_MS) if dur else 0) + + def goto_start(self): + self._player.setPosition(0) + + def goto_end(self): + dur = self._player.duration() + if dur: + self._player.setPosition(dur) + + # ── Pédalier ───────────────────────────────────────────────────────────── + def pedal_pressed(self, action: str): + if action == "play": + self.toggle_play() + elif action == "rewind": + self._player.pause() + self._rewind_timer.start() + elif action == "forward": + self._forward_timer.start() + + def pedal_released(self, action: str): + if action == "rewind": + self._rewind_timer.stop() + self._player.play() + elif action == "forward": + self._forward_timer.stop() + + def _do_rewind(self): + self._player.setPosition(max(0, self._player.position() - 500)) + + def _do_forward(self): + dur = self._player.duration() + pos = self._player.position() + 500 + self._player.setPosition(min(dur, pos) if dur else 0) + + def set_pedal_status(self, msg: str): + connected = msg.lower().startswith("pédalier connecté") + self._pedal_connected = connected + color = COLORS["success"] if connected else COLORS["text_dim"] + self._pedal_lbl.setText(f"🦶 Pédalier: {msg}") + self._pedal_lbl.setStyleSheet(f"color:{color};font-size:10px;") + + # ── Slots Qt ────────────────────────────────────────────────────────────── + def _on_position_changed(self, ms: int): + dur = self._player.duration() + if dur > 0: + self._seek_slider.setValue(ms) + ratio = ms / dur + self.waveform.set_position(ratio) + self._lbl_cur.setText(self._ms_to_str(ms)) + + def _on_duration_changed(self, ms: int): + self._seek_slider.setRange(0, ms) + self._lbl_dur.setText(self._ms_to_str(ms)) + + def _on_state_changed(self, state): + if state == QMediaPlayer.PlaybackState.PlayingState: + self._btn_play.setText("⏸") + else: + self._btn_play.setText("▶") + + def _on_seek(self, value: int): + self._player.setPosition(value) + + def _on_volume_changed(self, value: int): + self._audio.setVolume(value / 100.0) + # Sync toolbar slider si disponible (signal émis depuis PlayerPanel) + # La synchronisation inverse (toolbar→panel) est gérée par set_volume() + + def _on_waveform_seek(self, ratio: float): + dur = self._player.duration() + if dur > 0: + self._player.setPosition(int(ratio * dur)) + + def _on_speed_changed(self, idx: int): + speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] + self._player.setPlaybackRate(speeds[idx]) + + @staticmethod + def _ms_to_str(ms: int) -> str: + s = ms // 1000 + m, s = divmod(s, 60) + h, m = divmod(m, 60) + if h: + return f"{h:02d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" + + +# ─── Table des dictées ──────────────────────────────────────────────────────── +COLUMNS = ["", "Nom du fichier", "N° tâche", "Auteur", "Type", "Durée", "Créé", "Statut"] +COL_IDX = {name: i for i, name in enumerate(COLUMNS)} + +class DictationTable(QTableWidget): + file_selected = Signal(object) # DictationFile + + def __init__(self, parent=None): + super().__init__(0, len(COLUMNS), parent) + self._files: List[DictationFile] = [] + self._setup_header() + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.setAlternatingRowColors(True) + self.verticalHeader().setVisible(False) + self.setShowGrid(True) + self.setSortingEnabled(True) + self.cellDoubleClicked.connect(self._on_double_click) + self.itemSelectionChanged.connect(self._on_selection) + self.setStyleSheet(f""" + QTableWidget {{ + alternate-background-color: {COLORS['surface']}; + }} + """) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._context_menu) + + def apply_theme(self): + self.setStyleSheet(f""" + QTableWidget {{ + alternate-background-color: {COLORS['surface']}; + }} + """) + self.viewport().update() + self.horizontalHeader().viewport().update() + + def _setup_header(self): + self.setHorizontalHeaderLabels(COLUMNS) + hdr = self.horizontalHeader() + hdr.setStretchLastSection(False) + hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + hdr.resizeSection(0, 24) + hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + for i in range(2, len(COLUMNS)): + hdr.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) + self.setColumnWidth(0, 24) + + def load_files(self, files: List[DictationFile]): + self._files = files + self._refresh() + + def _refresh(self): + self.setSortingEnabled(False) + self.setRowCount(0) + for f in self._files: + self._add_row(f) + self.setSortingEnabled(True) + + def _add_row(self, f: DictationFile): + row = self.rowCount() + self.insertRow(row) + self.setRowHeight(row, 32) + + # Police monospace pour les colonnes numériques/temporelles + mono = QFont() + mono.setFamilies(["JetBrains Mono", "Fira Mono", "DejaVu Sans Mono", + "Liberation Mono", "Courier New", "monospace"]) + mono.setPointSize(11) + + # Indicateur statut coloré + status_item = QTableWidgetItem() + status_item.setBackground(QColor(f.status_color)) + status_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + self.setItem(row, 0, status_item) + + items_data = [ + (1, f.filename, False), + (2, str(f.job_number) if f.job_number else "", True), + (3, f.author, False), + (4, f.work_type, False), + (5, f.duration_str, True), + (6, f.created.strftime("%d/%m/%Y %H:%M"), True), + (7, f.status.value, False), + ] + for col, text, use_mono in items_data: + item = QTableWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, f) + if use_mono: + item.setFont(mono) + if col == 7: + item.setForeground(QColor(f.status_color)) + self.setItem(row, col, item) + + def _on_double_click(self, row: int, col: int): + item = self.item(row, 1) + if item: + f = item.data(Qt.ItemDataRole.UserRole) + if f: + self.file_selected.emit(f) + + def _on_selection(self): + rows = self.selectionModel().selectedRows() + if rows: + item = self.item(rows[0].row(), 1) + if item: + f = item.data(Qt.ItemDataRole.UserRole) + if f: + self.file_selected.emit(f) + + def _context_menu(self, pos): + item = self.item(self.rowAt(pos.y()), 1) + if not item: + return + f: DictationFile = item.data(Qt.ItemDataRole.UserRole) + if not f: + return + + menu = QMenu(self) + menu.setStyleSheet(STYLESHEET) + act_open = menu.addAction("▶ Ouvrir / Lire") + menu.addSeparator() + # Sous-menu statut + status_menu = menu.addMenu("Changer le statut") + acts_status = {} + for s in DictationStatus: + a = status_menu.addAction(s.value) + acts_status[a] = s + menu.addSeparator() + act_reveal = menu.addAction("📂 Révéler dans le dossier") + act_remove = menu.addAction("🗑 Supprimer de la liste") + + chosen = menu.exec(self.viewport().mapToGlobal(pos)) + if chosen == act_open: + self.file_selected.emit(f) + elif chosen in acts_status: + f.status = acts_status[chosen] + self._refresh() + elif chosen == act_reveal: + import subprocess + if sys.platform == "win32": + subprocess.Popen(["explorer", str(f.path.parent)]) + elif sys.platform == "darwin": + subprocess.Popen(["open", str(f.path.parent)]) + else: + subprocess.Popen(["xdg-open", str(f.path.parent)]) + elif chosen == act_remove: + self._files.remove(f) + self._refresh() + + def add_file(self, f: DictationFile): + if f not in self._files: + self._files.append(f) + self._add_row(f) + + def get_stats(self) -> Dict[str, int]: + stats = {s: 0 for s in DictationStatus} + for f in self._files: + stats[f.status] += 1 + return stats + + +# ─── Arbre de dossiers ──────────────────────────────────────────────────────── +class FolderTree(QTreeWidget): + folder_changed = Signal(Path) + + def __init__(self, parent=None): + super().__init__(parent) + self.setHeaderLabel("Arbre de dictées") + self.setMinimumWidth(200) + self.setMaximumWidth(280) + self._root_path: Optional[Path] = None + self._build_default() + self.itemClicked.connect(self._on_clicked) + + def _build_default(self): + self.clear() + root = QTreeWidgetItem(self, ["📋 Dictées"]) + root.setExpanded(True) + + todo_item = QTreeWidgetItem(root, ["📥 À faire (0)"]) + todo_item.setData(0, Qt.ItemDataRole.UserRole, "filter:TODO") + prog_item = QTreeWidgetItem(root, ["🔵 En cours (0)"]) + prog_item.setData(0, Qt.ItemDataRole.UserRole, "filter:IN_PROGRESS") + done_item = QTreeWidgetItem(root, ["✅ Terminées (0)"]) + done_item.setData(0, Qt.ItemDataRole.UserRole, "filter:DONE") + susp_item = QTreeWidgetItem(root, ["⏸ Suspendues (0)"]) + susp_item.setData(0, Qt.ItemDataRole.UserRole, "filter:SUSPENDED") + + sep = QTreeWidgetItem(self, [""]) + sep.setFlags(Qt.ItemFlag.NoItemFlags) + + inbox = QTreeWidgetItem(self, ["📨 Répertoires"]) + inbox.setExpanded(True) + self._folder_root = inbox + self._status_items = { + "TODO": todo_item, + "IN_PROGRESS": prog_item, + "DONE": done_item, + "SUSPENDED": susp_item, + } + + def set_root_folder(self, path: Path): + self._root_path = path + # Supprimer anciens enfants dossier + while self._folder_root.childCount(): + self._folder_root.removeChild(self._folder_root.child(0)) + + for sub in sorted(path.iterdir()): + if sub.is_dir() and not sub.name.startswith("."): + item = QTreeWidgetItem(self._folder_root, [f"📁 {sub.name}"]) + item.setData(0, Qt.ItemDataRole.UserRole, str(sub)) + self._folder_root.setExpanded(True) + + # Ajouter dossier racine en tête (sans parent pour éviter le double-add) + root_item = QTreeWidgetItem([f"📂 {path.name} (racine)"]) + root_item.setData(0, Qt.ItemDataRole.UserRole, str(path)) + self._folder_root.insertChild(0, root_item) + + def update_stats(self, stats: Dict): + labels = { + "TODO": "📥 À faire", + "IN_PROGRESS": "🔵 En cours", + "DONE": "✅ Terminées", + "SUSPENDED": "⏸ Suspendues", + } + for key, item in self._status_items.items(): + count = stats.get(DictationStatus[key], 0) + item.setText(0, f"{labels[key]} ({count})") + + def _on_clicked(self, item, col): + data = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(data, str) and os.path.isdir(data): + self.folder_changed.emit(Path(data)) + + +# ─── Dialogue test HID brut ─────────────────────────────────────────────────── +class HidTestWorker(QThread): + """Lit les données HID brutes pour diagnostic.""" + raw_data = Signal(list) # liste d'entiers (bytes reçus) + + def __init__(self, vid: int, pid: int, parent=None): + super().__init__(parent) + self._vid = vid + self._pid = pid + self._running = False + + def run(self): + self._running = True + try: + dev = _HidDevice() + dev.open(self._vid, self._pid) + dev.set_nonblocking(True) + while self._running: + data = dev.read(16, timeout_ms=100) + if data: + self.raw_data.emit(list(data)) + else: + time.sleep(0.01) + dev.close() + except Exception: + pass + + def stop(self): + self._running = False + + +class HidTestDialog(QDialog): + """Affiche les données HID brutes pour identifier le mapping des boutons.""" + + def __init__(self, vid: int, pid: int, parent=None): + super().__init__(parent) + self.setWindowTitle(f"Test HID brut — VID={hex(vid)} PID={hex(pid)}") + self.setMinimumWidth(520) + self._worker = None + + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Appuyez sur les pédales une à une.\n" + "Les octets qui changent indiquent les bits de chaque pédale.")) + + self._log = QPlainTextEdit() + self._log.setReadOnly(True) + self._log.setMaximumBlockCount(200) + self._log.setFont(QFont("Monospace", 10)) + layout.addWidget(self._log) + + btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btn_box.rejected.connect(self._stop_and_close) + layout.addWidget(btn_box) + + if HID_AVAILABLE: + self._worker = HidTestWorker(vid, pid) + self._worker.raw_data.connect(self._on_data) + self._worker.start() + else: + self._log.appendPlainText("Module 'hid' non disponible.") + + def _on_data(self, data: list): + hex_str = " ".join(f"{b:02X}" for b in data) + bits_str = " ".join(f"[{i}]={data[i]:08b}" for i in range(min(4, len(data)))) + self._log.appendPlainText(f"Hex: {hex_str}\n {bits_str}\n") + + def _stop_and_close(self): + if self._worker: + self._worker.stop() + self._worker.wait(1000) + self.reject() + + def closeEvent(self, event): + if self._worker: + self._worker.stop() + self._worker.wait(1000) + super().closeEvent(event) + + +# ─── Capture d'une touche HID ───────────────────────────────────────────────── +class HidCaptureWorker(QThread): + """ + Teste TOUTES les interfaces HID d'un device en parallèle. + La première interface qui détecte un appui gagne. + Détection par valeur exacte du byte (key code) OU bit isolé (bitmask). + """ + captured = Signal(int, int, int) # byte_idx, value, interface_num + capture_failed = Signal(str) + raw_received = Signal(list, int) # (bytes, interface_num) pour affichage live + status_update = Signal(str) + + TIMEOUT_S = 12 + + def __init__(self, vid: int, pid: int, parent=None): + super().__init__(parent) + self._vid = vid + self._pid = pid + self._running = False + + def run(self): + import queue as _queue + import threading as _threading + + self._running = True + + # Délai de libération du device (FootPedalWorker vient d'être arrêté) + for _ in range(10): + if not self._running: + return + time.sleep(0.05) + + paths = _HidDevice.paths_for(self._vid, self._pid) + if not paths: + self.capture_failed.emit( + f"Aucune interface trouvée pour VID={hex(self._vid)} PID={hex(self._pid)}\n" + "Vérifiez les règles udev et la connexion USB.") + return + + iface_desc = ", ".join(f"iface {p['interface']}" for p in paths) + self.status_update.emit(f"Appuyez sur la pédale…\n({iface_desc})") + + result_q = _queue.Queue() + stop_evt = _threading.Event() + + def test_path(info): + path = info["path"] + iface = info["interface"] + try: + dev = _HidDevice() + dev.open(self._vid, self._pid, path=path) + dev.set_nonblocking(True) + except Exception: + return # interface inaccessible, silencieux + + try: + # Drainage + baseline (500 ms) + baseline = [0] * 16 + last_raw = None + t_drain = time.time() + 0.5 + while not stop_evt.is_set() and time.time() < t_drain: + d = dev.read(16) + if d: + raw = list(d) + [0] * max(0, 16 - len(d)) + self.raw_received.emit(raw, iface) + last_raw = raw + else: + time.sleep(0.02) + if last_raw: + baseline = last_raw + + # Attente appui + deadline = time.time() + self.TIMEOUT_S + while not stop_evt.is_set() and time.time() < deadline: + d = dev.read(16) + if not d: + time.sleep(0.02) + continue + raw = list(d) + [0] * max(0, 16 - len(d)) + self.raw_received.emit(raw, iface) + + for bidx in range(min(len(raw), len(baseline))): + curr = raw[bidx] + base = baseline[bidx] + if curr != base and curr != 0: + # Changement non-nul → c'est un appui + stop_evt.set() + result_q.put((bidx, curr, iface)) + dev.close() + return + except Exception: + pass + try: + dev.close() + except Exception: + pass + + threads = [_threading.Thread(target=test_path, args=(p,), daemon=True) for p in paths] + for t in threads: + t.start() + + try: + bidx, val, iface = result_q.get(timeout=self.TIMEOUT_S + 2) + self.captured.emit(bidx, val, iface) + except Exception: + stop_evt.set() + if self._running: + self.capture_failed.emit( + "Délai dépassé — aucune touche détectée.\n\n" + "Causes possibles :\n" + "• Règle udev non appliquée (rebrancher le pédalier)\n" + "• Mauvais VID/PID sélectionné\n" + "• Interface non supportée") + + def stop(self): + self._running = False + + +class HidCaptureDialog(QDialog): + """Fenêtre d'attente de capture d'une touche pédalier.""" + + def __init__(self, vid: int, pid: int, action_label: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Capture de touche") + self.setModal(True) + self.setMinimumWidth(420) + self._vid = vid + self._pid = pid + self._action_label = action_label + self._binding: Optional[tuple] = None + self._iface_num: int = 0 + self._worker: Optional[HidCaptureWorker] = None + self._countdown = HidCaptureWorker.TIMEOUT_S + + # ── UI ─────────────────────────────────────────────────────────────── + self._lbl_action = QLabel(f"Appuyez sur la pédale :\n{action_label}") + self._lbl_action.setAlignment(Qt.AlignmentFlag.AlignCenter) + f = self._lbl_action.font() + f.setPointSize(12) + f.setBold(True) + self._lbl_action.setFont(f) + + self._lbl_bytes = QLabel("—") + self._lbl_bytes.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_bytes.setFont(QFont("Monospace", 9)) + self._lbl_bytes.setStyleSheet(f"color:{COLORS['text_dim']};") + + self._lbl_status = QLabel("Initialisation…") + self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_status.setWordWrap(True) + + self._lbl_timer = QLabel(f"{self._countdown}s") + self._lbl_timer.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_timer.setStyleSheet(f"color:{COLORS['text_dim']};") + + self._btn_retry = QPushButton("Réessayer") + self._btn_cancel = QPushButton("Annuler") + self._btn_retry.setVisible(False) + self._btn_retry.clicked.connect(self._retry) + self._btn_cancel.clicked.connect(self._cancel) + + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_row.addWidget(self._btn_retry) + btn_row.addWidget(self._btn_cancel) + + lay = QVBoxLayout(self) + lay.setSpacing(8) + lay.addWidget(self._lbl_action) + lay.addWidget(self._lbl_bytes) + lay.addWidget(self._lbl_status) + lay.addWidget(self._lbl_timer) + lay.addLayout(btn_row) + + # Compte à rebours (1 s) + self._tick = QTimer(self) + self._tick.setInterval(1000) + self._tick.timeout.connect(self._on_tick) + + self._start_worker() + + # ── Worker ─────────────────────────────────────────────────────────────── + def _start_worker(self): + self._countdown = HidCaptureWorker.TIMEOUT_S + self._lbl_timer.setText(f"{self._countdown}s") + self._lbl_status.setText("Établissement de la baseline (repos)…") + self._lbl_status.setStyleSheet("") + self._btn_retry.setVisible(False) + self._btn_cancel.setText("Annuler") + + self._worker = HidCaptureWorker(self._vid, self._pid) + self._worker.captured.connect(self._on_captured) + self._worker.capture_failed.connect(self._on_failed) + self._worker.raw_received.connect(self._on_raw) + self._worker.status_update.connect(self._on_status) + self._worker.start() + self._tick.start() + + def _stop_worker(self): + self._tick.stop() + if self._worker and self._worker.isRunning(): + self._worker.stop() + self._worker.wait(2000) + + # ── Slots ───────────────────────────────────────────────────────────────── + def _on_tick(self): + self._countdown -= 1 + if self._countdown > 0: + self._lbl_timer.setText(f"{self._countdown}s") + if self._countdown <= HidCaptureWorker.TIMEOUT_S - 1: + self._lbl_status.setText("Appuyez maintenant sur la pédale…") + else: + self._lbl_timer.setText("0s") + + def _on_raw(self, data: list, iface: int): + self._lbl_bytes.setText(f"[iface {iface}] " + " ".join(f"{b:02X}" for b in data[:8])) + + def _on_status(self, msg: str): + self._lbl_status.setText(msg) + + def _on_captured(self, bidx: int, val: int, iface: int): + self._stop_worker() + self._binding = (bidx, val) + self._iface_num = iface + mode = "bitmask" if (val > 0 and (val & (val - 1)) == 0) else "valeur" + self._lbl_status.setText( + f"Capturé : byte {bidx}, valeur {hex(val)} ({mode}, interface {iface})") + self._lbl_status.setStyleSheet(f"color:{COLORS['success']};font-weight:bold;") + self._lbl_timer.setText("") + self._btn_cancel.setText("Fermer") + QTimer.singleShot(800, self.accept) + + def _on_failed(self, msg: str): + self._stop_worker() + self._lbl_status.setText(msg) + self._lbl_status.setStyleSheet(f"color:{COLORS['warning']};") + self._lbl_timer.setText("") + self._btn_retry.setVisible(True) + self._btn_cancel.setText("Fermer") + + def _retry(self): + self._stop_worker() + self._lbl_bytes.setText("—") + self._start_worker() + + def _cancel(self): + self._stop_worker() + self.reject() + + def closeEvent(self, event): + self._stop_worker() + super().closeEvent(event) + + @property + def binding(self) -> Optional[tuple]: + return self._binding + + @property + def iface_num(self) -> int: + return self._iface_num + + +# ─── Dialogue Paramètres ────────────────────────────────────────────────────── +class SettingsDialog(QDialog): + + # Actions pédalier avec leur libellé affiché + _ACTION_LABELS = [ + ("rewind", "Rembobinage"), + ("play", "Lecture / Pause"), + ("forward", "Avance rapide"), + ] + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self.setWindowTitle("Paramètres – TranscribeStation") + self.setMinimumWidth(560) + self._settings = settings + self._detected_devices: List[Dict] = [] + self._current_key: Optional[str] = None + self._bindings: Dict = {} + self._binding_labels: Dict = {} # action -> QLabel + self._hid_interface: Optional[int] = None + self._build_ui() + self._refresh_detected() # peuple le combo + charge le premier profil + + def _build_ui(self): + layout = QVBoxLayout(self) + tabs = QTabWidget() + + # ── Onglet Pédalier ─────────────────────────────────────────────────── + pedal_tab = QWidget() + tab_lay = QVBoxLayout(pedal_tab) + tab_lay.setSpacing(10) + + pl = QFormLayout() + pl.setSpacing(10) + + self._pedal_enable = QCheckBox("Activer le support pédalier") + self._pedal_enable.setChecked(bool(self._settings.get("pedal.enabled", True))) + pl.addRow("Activer :", self._pedal_enable) + + # Sélection du pédalier détecté + det_row = QHBoxLayout() + self._detected_combo = QComboBox() + self._detected_combo.setMinimumWidth(260) + self._detected_combo.currentIndexChanged.connect(self._on_pedal_selected) + refresh_btn = QPushButton("🔄") + refresh_btn.setFixedWidth(36) + refresh_btn.setToolTip("Actualiser la liste des pédaliers connectés") + refresh_btn.clicked.connect(self._refresh_detected) + det_row.addWidget(self._detected_combo, 1) + det_row.addWidget(refresh_btn) + pl.addRow("Pédalier détecté :", det_row) + + self._pedal_vid = QLineEdit() + self._pedal_vid.setReadOnly(True) + self._pedal_vid.setStyleSheet("color: gray;") + self._pedal_pid = QLineEdit() + self._pedal_pid.setReadOnly(True) + self._pedal_pid.setStyleSheet("color: gray;") + + self._skip_ms = QSpinBox() + self._skip_ms.setRange(500, 30000) + self._skip_ms.setSingleStep(500) + self._skip_ms.setValue(int(self._settings.get("pedal.skip_ms", SKIP_MS))) + self._skip_ms.setSuffix(" ms") + + pl.addRow("Vendor ID :", self._pedal_vid) + pl.addRow("Product ID :", self._pedal_pid) + pl.addRow("Délai rembobinage/avance :", self._skip_ms) + + test_btn = QPushButton("🎛 Données HID brutes") + test_btn.clicked.connect(self._test_hid) + pl.addRow(test_btn) + + if not HID_AVAILABLE: + warn = QLabel("⚠️ Module 'hid' non installé (pip install hid)") + warn.setStyleSheet(f"color:{COLORS['warning']};") + pl.addRow(warn) + + tab_lay.addLayout(pl) + + # ── Groupe assignation des touches ──────────────────────────────────── + grp = QGroupBox("Assignation des touches") + grp_lay = QFormLayout(grp) + grp_lay.setSpacing(8) + + info = QLabel( + "Sélectionnez un pédalier détecté puis cliquez «\u00a0Capturer\u00a0» " + "et appuyez sur la pédale souhaitée.") + info.setWordWrap(True) + info.setStyleSheet(f"color:{COLORS['text_dim']};font-style:italic;") + grp_lay.addRow(info) + + for action, label in self._ACTION_LABELS: + b = self._bindings.get(action, [1, 0]) + lbl = QLabel(self._fmt_binding(b)) + lbl.setStyleSheet("font-family:monospace;") + cap = QPushButton("🎯 Capturer") + cap.setFixedWidth(110) + cap.clicked.connect(lambda _=False, a=action, l=lbl: self._capture(a, l)) + row_w = QWidget() + row_l = QHBoxLayout(row_w) + row_l.setContentsMargins(0, 0, 0, 0) + row_l.addWidget(lbl, 1) + row_l.addWidget(cap) + grp_lay.addRow(f"{label} :", row_w) + self._binding_labels[action] = lbl + + tab_lay.addWidget(grp) + tab_lay.addStretch() + tabs.addTab(pedal_tab, "🦶 Pédalier") + + # ── Onglet Général ──────────────────────────────────────────────────── + gen_tab = QWidget() + gl = QFormLayout(gen_tab) + gl.setSpacing(12) + + self._default_author = QLineEdit(self._settings.get("general.author", "")) + self._auto_play = QCheckBox("Lecture auto à la sélection") + self._auto_play.setChecked(bool(self._settings.get("general.auto_play", False))) + self._theme = QComboBox() + self._theme.addItem("Clair", "light") + self._theme.addItem("Sombre", "dark") + idx = max(0, self._theme.findData(self._settings.get("general.theme", "light"))) + self._theme.setCurrentIndex(idx) + + gl.addRow("Auteur par défaut :", self._default_author) + gl.addRow("Thème :", self._theme) + gl.addRow(self._auto_play) + tabs.addTab(gen_tab, "⚙️ Général") + + layout.addWidget(tabs) + btns = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + btns.accepted.connect(self._save) + btns.rejected.connect(self.reject) + layout.addWidget(btns) + + @staticmethod + def _fmt_binding(b: list) -> str: + if not b or len(b) < 2 or b[1] == 0: + return "Non configuré" + val = b[1] + mode = "bitmask" if (val > 0 and (val & (val - 1)) == 0) else "valeur" + return f"byte {b[0]}, {hex(val)} ({mode})" + + def _refresh_detected(self): + """Rescanne les pédaliers connectés et met à jour le combo.""" + self._detected_devices = FootPedalWorker.list_devices() + self._detected_combo.blockSignals(True) + self._detected_combo.clear() + for dev in self._detected_devices: + name = (dev.get("product_string", "") or "").strip() or "Pédalier USB" + key = f"{dev['vendor_id']:04x}:{dev['product_id']:04x}" + self._detected_combo.addItem(f"{name} — {key}", userData=key) + if not self._detected_devices: + self._detected_combo.addItem("Aucun pédalier détecté") + self._detected_combo.blockSignals(False) + # Charger le profil du premier périphérique + if self._detected_devices: + self._on_pedal_selected(0) + else: + self._current_key = None + self._pedal_vid.clear() + self._pedal_pid.clear() + + def _on_pedal_selected(self, index: int): + """Charge le profil sauvegardé (ou le défaut) pour le pédalier sélectionné.""" + if index < 0 or index >= len(self._detected_devices): + return + dev = self._detected_devices[index] + vid, pid = dev["vendor_id"], dev["product_id"] + self._current_key = f"{vid:04x}:{pid:04x}" + self._pedal_vid.setText(f"0x{vid:04x}") + self._pedal_pid.setText(f"0x{pid:04x}") + + saved_profiles = self._settings.get("pedal.saved_profiles", {}) or {} + if self._current_key in saved_profiles: + # Profil personnalisé sauvegardé + sp = saved_profiles[self._current_key] + self._bindings = {a: list(b) for a, b in sp["buttons"].items()} + self._hid_interface = sp.get("hid_interface") + else: + # Chercher dans PEDAL_PROFILES par VID/PID + matched = None + for pdata in PEDAL_PROFILES.values(): + if pdata["vendor_id"] == vid and pdata["product_id"] == pid: + matched = pdata + break + if matched: + self._bindings = {a: list(b) for a, b in matched["buttons"].items()} + else: + self._bindings = {"rewind": [1, 4], "play": [1, 2], "forward": [1, 8]} + self._hid_interface = None + + # Mettre à jour les étiquettes de binding + for action, lbl in self._binding_labels.items(): + lbl.setText(self._fmt_binding(self._bindings.get(action, [1, 0]))) + + def _test_hid(self): + if not self._current_key: + QMessageBox.warning(self, "Erreur", "Aucun pédalier sélectionné.") + return + try: + vid = int(self._pedal_vid.text(), 16) + pid = int(self._pedal_pid.text(), 16) + except ValueError: + QMessageBox.warning(self, "Erreur", "VID/PID invalides.") + return + dlg = HidTestDialog(vid, pid, self) + dlg.exec() + + def _capture(self, action: str, label: QLabel): + if not self._current_key: + QMessageBox.warning(self, "Erreur", + "Aucun pédalier sélectionné.\nBranchez le pédalier et cliquez 🔄.") + return + try: + vid = int(self._pedal_vid.text(), 16) + pid = int(self._pedal_pid.text(), 16) + except ValueError: + QMessageBox.warning(self, "Erreur", "VID/PID invalides.") + return + action_label = dict(self._ACTION_LABELS).get(action, action) + dlg = HidCaptureDialog(vid, pid, action_label, self) + if dlg.exec() == QDialog.DialogCode.Accepted and dlg.binding: + bidx, mask = dlg.binding + self._bindings[action] = [bidx, mask] + if dlg.iface_num is not None: + self._hid_interface = dlg.iface_num + label.setText(self._fmt_binding([bidx, mask])) + + def _save(self): + self._settings.set("pedal.enabled", self._pedal_enable.isChecked()) + self._settings.set("pedal.skip_ms", self._skip_ms.value()) + + # Sauvegarder le profil du pédalier actuellement sélectionné + if self._current_key and self._bindings: + try: + vid = int(self._pedal_vid.text(), 16) + pid = int(self._pedal_pid.text(), 16) + except ValueError: + vid = pid = 0 + # Déterminer le nom lisible + dev_name = "" + for dev in self._detected_devices: + if (f"{dev['vendor_id']:04x}:{dev['product_id']:04x}") == self._current_key: + dev_name = (dev.get("product_string", "") or "").strip() + break + if not dev_name: + # Chercher dans PEDAL_PROFILES + for pname, pdata in PEDAL_PROFILES.items(): + if pdata["vendor_id"] == vid and pdata["product_id"] == pid: + dev_name = pname + break + saved_profiles = dict(self._settings.get("pedal.saved_profiles", {}) or {}) + saved_profiles[self._current_key] = { + "name": dev_name or self._current_key, + "vid": vid, + "pid": pid, + "buttons": self._bindings, + "hid_interface": self._hid_interface, + } + self._settings.set("pedal.saved_profiles", saved_profiles) + + self._settings.set("general.author", self._default_author.text()) + self._settings.set("general.theme", self._theme.currentData()) + self._settings.set("general.auto_play", self._auto_play.isChecked()) + self.accept() + + +# ─── Fenêtre principale ─────────────────────────────────────────────────────── +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self._settings = AppSettings() + set_theme(self._settings.get("general.theme", "light")) + self._current_dir: Optional[Path] = None + self._pedal_worker: Optional[FootPedalWorker] = None + self._db_path: Optional[Path] = None + self._pedal_connected = False + + self.setWindowTitle(APP_NAME) + self.setMinimumSize(1100, 700) + self.resize(1280, 800) + + self._build_ui() + self._build_menus() + self._build_toolbar() + # Synchroniser volume : slider player → toolbar + self.player._vol_slider.valueChanged.connect( + lambda v: self._tb_vol.setValue(v) if hasattr(self, "_tb_vol") else None + ) + self._apply_theme() + self._restore_state() + self._start_pedal() + self._update_status_bar() + self.setWindowIcon(_build_app_icon()) + + # ── Construction UI ─────────────────────────────────────────────────────── + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Splitter horizontal : arbre | table + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.setHandleWidth(1) + + self.folder_tree = FolderTree() + self.folder_tree.folder_changed.connect(self._load_folder) + splitter.addWidget(self.folder_tree) + + # Panneau droit : table + player + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(0) + + # En-tête dossier courant + self._dir_label = QLabel(" Aucun dossier ouvert") + self._dir_label.setStyleSheet(f""" + background: {COLORS['surface']}; + color: {COLORS['text_dim']}; + padding: 6px 12px; + border-bottom: 1px solid {COLORS['border']}; + font-size: 11px; + """) + right_layout.addWidget(self._dir_label) + + self.table = DictationTable() + self.table.file_selected.connect(self._on_file_selected) + right_layout.addWidget(self.table) + + splitter.addWidget(right_panel) + splitter.setSizes([220, 860]) + main_layout.addWidget(splitter, 1) + + # Panneau lecteur en bas + self.player = PlayerPanel() + self.player.setFixedHeight(180) + main_layout.addWidget(self.player) + + # Barre de statut + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self._lbl_files = QLabel() + self._lbl_pedal = QLabel() + self._lbl_format = QLabel() + for lbl in [self._lbl_files, self._lbl_format, self._lbl_pedal]: + lbl.setStyleSheet(f"color:{COLORS['text_dim']};padding:0 8px;") + self.status_bar.addWidget(self._lbl_files) + self.status_bar.addPermanentWidget(self._lbl_format) + self.status_bar.addPermanentWidget(self._lbl_pedal) + + def _build_menus(self): + mb = self.menuBar() + + # ── Icône application devant le menu Fichier ────────────────────────── + ico_lbl = QLabel() + ico_lbl.setPixmap(_build_app_icon().pixmap(QSize(20, 20))) + ico_lbl.setContentsMargins(6, 0, 4, 0) + ico_lbl.setToolTip(f"{APP_NAME} v{APP_VERSION}") + mb.setCornerWidget(ico_lbl, Qt.Corner.TopLeftCorner) + + # ── Fichier ─────────────────────────────────────────────────────────── + m_file = mb.addMenu("Fichier") + self._act_open_folder = QAction("📂 Ouvrir un dossier…", self) + self._act_open_folder.setShortcut("Ctrl+O") + self._act_open_folder.triggered.connect(self._open_folder_dialog) + m_file.addAction(self._act_open_folder) + + self._act_add_files = QAction("➕ Ajouter des fichiers…", self) + self._act_add_files.setShortcut("Ctrl+Shift+O") + self._act_add_files.triggered.connect(self._add_files_dialog) + m_file.addAction(self._act_add_files) + + # ── Sous-menu Récemment ouvert ──────────────────────────────────────── + m_file.addSeparator() + self._m_recent = m_file.addMenu("🕘 Récemment ouvert") + self._refresh_recent_menu() + + m_file.addSeparator() + act_save = QAction("💾 Enregistrer la liste", self) + act_save.setShortcut("Ctrl+S") + act_save.triggered.connect(self._save_db) + m_file.addAction(act_save) + + m_file.addSeparator() + act_quit = QAction("Quitter", self) + act_quit.setShortcut("Ctrl+Q") + act_quit.triggered.connect(self.close) + m_file.addAction(act_quit) + + # ── Lecture ─────────────────────────────────────────────────────────── + m_play = mb.addMenu("Lecture") + play_actions = [ + ("▶ / ⏸ Lecture / Pause", "Space", self.player.toggle_play), + ("⏹ Stop", "S", self.player.stop_playback), + ("⏪ Reculer", "Left", self.player.skip_back), + ("⏩ Avancer", "Right", self.player.skip_forward), + ("⏮ Début", "Home", self.player.goto_start), + ("⏭ Fin", "End", self.player.goto_end), + ] + for label, shortcut, slot in play_actions: + a = QAction(label, self) + a.setShortcut(shortcut) + a.triggered.connect(slot) + m_play.addAction(a) + + m_play.addSeparator() + + vol_actions = [ + ("🔊 Volume +", "+", self.player.volume_up), + ("🔉 Volume -", "-", self.player.volume_down), + ("🔇 Muet / Actif", "M", self.player.toggle_mute), + ] + for label, shortcut, slot in vol_actions: + a = QAction(label, self) + a.setShortcut(shortcut) + a.triggered.connect(slot) + m_play.addAction(a) + + m_play.addSeparator() + + speed_actions = [ + ("🐇 Vitesse +", "]", self.player.speed_up), + ("🐢 Vitesse -", "[", self.player.speed_down), + ] + for label, shortcut, slot in speed_actions: + a = QAction(label, self) + a.setShortcut(shortcut) + a.triggered.connect(slot) + m_play.addAction(a) + + m_play.addSeparator() + + nav_actions = [ + ("⬆ Fichier précédent", "Ctrl+Up", self._prev_file), + ("⬇ Fichier suivant", "Ctrl+Down", self._next_file), + ] + for label, shortcut, slot in nav_actions: + a = QAction(label, self) + a.setShortcut(shortcut) + a.triggered.connect(slot) + m_play.addAction(a) + + # ── Périphérique ────────────────────────────────────────────────────── + m_dev = mb.addMenu("Périphérique") + act_reconnect = QAction("🔄 Reconnecter le pédalier", self) + act_reconnect.triggered.connect(self._restart_pedal) + m_dev.addAction(act_reconnect) + + act_detect = QAction("🔍 Détecter les périphériques", self) + act_detect.triggered.connect(self._detect_devices) + m_dev.addAction(act_detect) + + # ── Outils ──────────────────────────────────────────────────────────── + m_tools = mb.addMenu("Outils") + act_settings = QAction("⚙️ Paramètres…", self) + act_settings.setShortcut("Ctrl+,") + act_settings.triggered.connect(self._open_settings) + m_tools.addAction(act_settings) + + # ── Aide ───────────────────────────────────────────────────────────── + m_help = mb.addMenu("Aide") + act_about = QAction("À propos", self) + act_about.triggered.connect(self._show_about) + m_help.addAction(act_about) + + udev_label = "📋 Accès pédalier (Windows)" if sys.platform == "win32" else "📋 Règle udev pédalier" + act_udev = QAction(udev_label, self) + act_udev.triggered.connect(self._show_udev) + m_help.addAction(act_udev) + + def _build_toolbar(self): + tb = QToolBar("Contrôles") + tb.setMovable(False) + self.addToolBar(tb) + self._toolbar_buttons: List[tuple[QPushButton, bool]] = [] + + # ── Police emoji couleur explicite ──────────────────────────────────── + emoji_font = QFont() + emoji_font.setFamilies([ + "Noto Color Emoji", "Segoe UI Emoji", + "Apple Color Emoji", "Twemoji Mozilla", + "Noto Emoji", "sans-serif", + ]) + emoji_font.setPointSize(16) + + # ── Style bouton ────────────────────────────────────────────────────── + BTN = f""" + QPushButton {{ + background : {COLORS['surface2']}; + border : 1px solid {COLORS['border']}; + border-radius: 6px; + padding : 0px; + }} + QPushButton:hover {{ background:{COLORS['accent']}; + border-color:{COLORS['accent']}; }} + QPushButton:pressed {{ background:{COLORS['accent_hover']}; }} + """ + PLAY = f""" + QPushButton {{ + background : {COLORS['accent']}; + border : 1px solid {COLORS['accent']}; + border-radius: 6px; + padding : 0px; + }} + QPushButton:hover {{ background:{COLORS['accent_hover']}; }} + QPushButton:pressed {{ background:{COLORS['accent_hover']}; }} + """ + + def mk(emoji: str, tip: str, slot, + accent: bool = False, sz: int = 36) -> QPushButton: + btn = QPushButton(emoji) + btn.setFont(emoji_font) + btn.setToolTip(tip) + btn.setFixedSize(sz, sz) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(PLAY if accent else BTN) + btn.clicked.connect(slot) + self._toolbar_buttons.append((btn, accent)) + return btn + + # ── Fichiers ────────────────────────────────────────────────────────── + for emoji, tip, slot in [ + ("📂", "Ouvrir un dossier Ctrl+O", self._open_folder_dialog), + ("➕", "Ajouter des fichiers Ctrl+Shift+O", self._add_files_dialog), + ("💾", "Enregistrer Ctrl+S", self._save_db), + ]: + tb.addWidget(mk(emoji, tip, slot)) + + tb.addSeparator() + + # ── Transport ───────────────────────────────────────────────────────── + for emoji, tip, slot, acc, sz in [ + ("⏮️", "Début Home", self.player.goto_start, False, 36), + ("⏪️", "Reculer ←", self.player.skip_back, False, 36), + ("▶️", "Lecture / Pause Espace", self.player.toggle_play, True, 40), + ("⏩️", "Avancer →", self.player.skip_forward, False, 36), + ("⏭️", "Fin End", self.player.goto_end, False, 36), + ("⏹️", "Stop S", self.player.stop_playback, False, 36), + ]: + tb.addWidget(mk(emoji, tip, slot, accent=acc, sz=sz)) + + tb.addSeparator() + + # ── Navigation fichiers ─────────────────────────────────────────────── + for emoji, tip, slot in [ + ("⏫", "Fichier précédent Ctrl+↑", self._prev_file), + ("⏬", "Fichier suivant Ctrl+↓", self._next_file), + ]: + tb.addWidget(mk(emoji, tip, slot)) + + tb.addSeparator() + + # ── Volume ──────────────────────────────────────────────────────────── + tb.addWidget(mk("🔇", "Muet M", self.player.toggle_mute)) + + self._tb_vol = QSlider(Qt.Orientation.Horizontal) + self._tb_vol.setRange(0, 100) + self._tb_vol.setValue(100) + self._tb_vol.setFixedWidth(80) + self._tb_vol.setFixedHeight(20) + self._tb_vol.setToolTip("Volume + / −") + self._tb_vol.valueChanged.connect(self.player.set_volume) + tb.addWidget(self._tb_vol) + + tb.addSeparator() + + # ── Outils ──────────────────────────────────────────────────────────── + for emoji, tip, slot in [ + ("⚙️", "Paramètres Ctrl+,", self._open_settings), + ("❓", "À propos", self._show_about), + ]: + tb.addWidget(mk(emoji, tip, slot)) + + # ── Indicateur pédalier à droite ────────────────────────────────────── + spacer = QWidget() + spacer.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + tb.addWidget(spacer) + + ped_font = QFont() + ped_font.setFamilies(["Noto Color Emoji", "Segoe UI Emoji", + "DejaVu Sans", "sans-serif"]) + ped_font.setPointSize(11) + self._tb_pedal_lbl = QLabel("🦶 Pédalier: —") + self._tb_pedal_lbl.setFont(ped_font) + self._tb_pedal_lbl.setStyleSheet( + f"color:{COLORS['text_dim']};padding:0 10px;") + tb.addWidget(self._tb_pedal_lbl) + + # ── Pédalier ────────────────────────────────────────────────────────────── + def _start_pedal(self): + if not HID_AVAILABLE: + return + if not bool(self._settings.get("pedal.enabled", True)): + return + + connected = FootPedalWorker.list_devices() + saved_profiles = self._settings.get("pedal.saved_profiles", {}) or {} + profile = None + + # 1. Pédalier connecté avec profil personnalisé sauvegardé + for dev in connected: + key = f"{dev['vendor_id']:04x}:{dev['product_id']:04x}" + if key in saved_profiles: + sp = saved_profiles[key] + profile = { + "vendor_id": sp["vid"], + "product_id": sp["pid"], + "buttons": {a: list(b) for a, b in sp["buttons"].items()}, + } + if sp.get("hid_interface") is not None: + profile["hid_interface"] = int(sp["hid_interface"]) + break + + # 2. Repli : premier pédalier connecté reconnu dans PEDAL_PROFILES + if profile is None: + for dev in connected: + vid, pid = dev["vendor_id"], dev["product_id"] + for pdata in PEDAL_PROFILES.values(): + if pdata["vendor_id"] == vid and pdata["product_id"] == pid: + profile = pdata.copy() + break + if profile: + break + + # 3. Dernier recours : pédalier inconnu, mapping par défaut + if profile is None and connected: + dev = connected[0] + profile = { + "vendor_id": dev["vendor_id"], + "product_id": dev["product_id"], + "buttons": {"rewind": [1, 4], "play": [1, 2], "forward": [1, 8]}, + } + + if profile is None: + self._on_pedal_status("Aucun pédalier détecté") + return + + if self._pedal_worker: + self._pedal_worker.stop() + self._pedal_worker.wait(3000) + + self._on_pedal_status( + f"Connexion pédalier… VID={hex(profile['vendor_id'])} PID={hex(profile['product_id'])}") + self._pedal_worker = FootPedalWorker(profile) + self._pedal_worker.pedal_pressed.connect(self.player.pedal_pressed) + self._pedal_worker.pedal_released.connect(self.player.pedal_released) + self._pedal_worker.device_status.connect(self._on_pedal_status) + self._pedal_worker.start() + + def _restart_pedal(self): + self._start_pedal() + + def _detect_devices(self): + devices = FootPedalWorker.list_devices() + if not devices: + QMessageBox.information(self, "Détection HID", + f"Aucun périphérique détecté (VIDs : {', '.join(hex(v) for v in sorted(KNOWN_VIDS))}).\n\n" + "Vérifiez :\n" + "• Le câble USB du pédalier\n" + "• Les règles udev (Aide → Règle udev pédalier)") + else: + lines = "\n".join( + f" • {d.get('product_string','?')} — VID={hex(d['vendor_id'])} PID={hex(d['product_id'])}" + for d in devices) + QMessageBox.information(self, "Détection HID", + f"{len(devices)} périphérique(s) trouvé(s) :\n\n{lines}") + + @Slot(str) + def _on_pedal_status(self, msg: str): + connected = msg.lower().startswith("pédalier connecté") + self._pedal_connected = connected + if connected: + color = COLORS["success"] + elif msg.lower().startswith("paramètres"): + color = COLORS["text_dim"] + else: + color = COLORS["warning"] + self._tb_pedal_lbl.setText(f"🦶 {msg}") + self._tb_pedal_lbl.setStyleSheet(f"color:{color};padding:0 10px;") + self.player.set_pedal_status(msg) + self._lbl_pedal.setText(f"🦶 {msg}") + self._lbl_pedal.setStyleSheet(f"color:{color};padding:0 8px;") + + # ── Gestion fichiers ────────────────────────────────────────────────────── + def _prev_file(self): + rows = self.table.rowCount() + if rows == 0: + return + sel = self.table.currentRow() + target = max(0, sel - 1) if sel > 0 else rows - 1 + self.table.selectRow(target) + self.table.scrollToItem(self.table.item(target, 1)) + + def _next_file(self): + rows = self.table.rowCount() + if rows == 0: + return + sel = self.table.currentRow() + target = (sel + 1) % rows + self.table.selectRow(target) + self.table.scrollToItem(self.table.item(target, 1)) + + def _refresh_recent_menu(self): + """Reconstruit le sous-menu Récemment ouvert depuis les paramètres.""" + self._m_recent.clear() + recent = self._settings.get_recent() + if not recent: + act = QAction("(aucun)", self) + act.setEnabled(False) + self._m_recent.addAction(act) + return + for folder in recent: + label = str(folder) + # Abréger si le chemin est long + if len(label) > 60: + label = "…" + label[-57:] + act = QAction(f"📁 {label}", self) + act.setToolTip(str(folder)) + act.triggered.connect(lambda checked, f=folder: self._load_folder(f)) + self._m_recent.addAction(act) + self._m_recent.addSeparator() + act_clear = QAction("🗑 Effacer l'historique", self) + act_clear.triggered.connect(self._clear_recent) + self._m_recent.addAction(act_clear) + + def _clear_recent(self): + self._settings.set("recent", []) + self._refresh_recent_menu() + + def _open_folder_dialog(self): + path = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de dictées") + if path: + self._load_folder(Path(path)) + + def _load_folder(self, folder: Path): + self._current_dir = folder + self._dir_label.setText(f" 📂 {folder}") + self.folder_tree.set_root_folder(folder) + self._db_path = folder / ".transcribe_station.json" + + if self._db_path.exists(): + self._load_db() + else: + files: List[DictationFile] = [] + exts = {".wav", ".mp3", ".mp4", ".m4a", ".ogg", ".flac", ".dss", ".ds2"} + for p in sorted(folder.iterdir()): + if p.suffix.lower() in exts: + f = DictationFile(path=p) + f.author = self._settings.get("general.author", "") + files.append(f) + self.table.load_files(files) + + # Mémoriser dans les récents et rafraîchir le menu + self._settings.add_recent(folder) + self._refresh_recent_menu() + + self._update_status_bar() + self.setWindowTitle(f"{APP_NAME} {APP_VERSION} – {folder.name}") + + def _add_files_dialog(self): + paths, _ = QFileDialog.getOpenFileNames( + self, "Ajouter des fichiers audio", "", + "Fichiers audio (*.wav *.mp3 *.mp4 *.m4a *.ogg *.flac *.dss *.ds2);;Tous (*)") + for p in paths: + f = DictationFile(path=Path(p)) + f.author = self._settings.get("general.author", "") + self.table.add_file(f) # table._files est la source unique, pas de double append + self._update_status_bar() + + def _on_file_selected(self, f: DictationFile): + if not f.path.exists(): + QMessageBox.warning(self, "Fichier introuvable", + f"Le fichier n'existe plus :\n{f.path}") + return + self.player.load_file(f.path) + self._lbl_format.setText(f" {f.path.suffix.upper().lstrip('.')} | {f.filename}") + f.status = DictationStatus.IN_PROGRESS + self.table._refresh() + self._update_status_bar() + if bool(self._settings.get("general.auto_play", False)): + self.player.toggle_play() + + # ── Persistance ─────────────────────────────────────────────────────────── + def _save_db(self): + if not self._db_path: + return + data = [f.to_dict() for f in self.table._files] # source unique + try: + with open(self._db_path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + self.status_bar.showMessage("✅ Liste sauvegardée.", 3000) + except Exception as e: + QMessageBox.critical(self, "Erreur", f"Sauvegarde impossible :\n{e}") + + def _load_db(self): + try: + with open(self._db_path, encoding="utf-8") as fh: + data = json.load(fh) + files: List[DictationFile] = [] + for d in data: + try: + f = DictationFile.from_dict(d) + if f.path.exists(): + files.append(f) + except Exception: + pass + self.table.load_files(files) # source unique, pas de _all_files + except Exception as e: + QMessageBox.warning(self, "Erreur", f"Impossible de lire la liste :\n{e}") + + # ── Statut & état ───────────────────────────────────────────────────────── + def _update_status_bar(self): + stats = self.table.get_stats() + total = sum(stats.values()) + todo = stats.get(DictationStatus.TODO, 0) + done = stats.get(DictationStatus.DONE, 0) + prog = stats.get(DictationStatus.IN_PROGRESS, 0) + self._lbl_files.setText( + f" {total} fichier(s) | ✅ {done} terminé(s)" + f" | 🔵 {prog} en cours | 📥 {todo} à faire") + self.folder_tree.update_stats(stats) + + def _restore_state(self): + x, y, w, h = self._settings.load_geometry() + self.setGeometry(x, y, w, h) + # Rouvrir le dernier dossier (en priorité sur les récents) + recent = self._settings.get_recent() + if recent: + self._load_folder(recent[0]) + + def _save_state(self): + geo = self.geometry() + self._settings.save_geometry(geo.x(), geo.y(), geo.width(), geo.height()) + self._save_db() + + # ── Dialogues ───────────────────────────────────────────────────────────── + def _open_settings(self): + # Libérer le device HID pendant toute la durée de SettingsDialog + # (permet à HidCaptureWorker d'ouvrir l'appareil sans conflit) + if self._pedal_worker and self._pedal_worker.isRunning(): + self._pedal_worker.stop() + self._pedal_worker.wait(3000) + self._on_pedal_status("Paramètres ouverts…") + + old_theme = self._settings.get("general.theme", "light") + dlg = SettingsDialog(self._settings, self) + dlg.exec() # Accepted ou non : on redémarre le worker dans tous les cas + + if self._settings.get("general.theme", "light") != old_theme: + self._apply_theme() + self._restart_pedal() + + def _apply_theme(self): + set_theme(self._settings.get("general.theme", "light")) + app = QApplication.instance() + if app: + app.setStyleSheet(STYLESHEET) + + if hasattr(self, "_dir_label"): + self._dir_label.setStyleSheet(f""" + background: {COLORS['surface']}; + color: {COLORS['text_dim']}; + padding: 6px 12px; + border-bottom: 1px solid {COLORS['border']}; + font-size: 11px; + """) + + if hasattr(self, "status_bar"): + for lbl in [self._lbl_files, self._lbl_format, self._lbl_pedal]: + lbl.setStyleSheet(f"color:{COLORS['text_dim']};padding:0 8px;") + + if hasattr(self, "_toolbar_buttons"): + btn_style = f""" + QPushButton {{ + background : {COLORS['surface2']}; + border : 1px solid {COLORS['border']}; + border-radius: 6px; + padding : 0px; + }} + QPushButton:hover {{ background:{COLORS['accent']}; + border-color:{COLORS['accent']}; }} + QPushButton:pressed {{ background:{COLORS['accent_hover']}; }} + """ + play_style = f""" + QPushButton {{ + background : {COLORS['accent']}; + border : 1px solid {COLORS['accent']}; + border-radius: 6px; + padding : 0px; + }} + QPushButton:hover {{ background:{COLORS['accent_hover']}; }} + QPushButton:pressed {{ background:{COLORS['accent_hover']}; }} + """ + for btn, accent in self._toolbar_buttons: + btn.setStyleSheet(play_style if accent else btn_style) + + if hasattr(self, "_tb_pedal_lbl"): + ped_color = COLORS["success"] if self._pedal_connected else COLORS["text_dim"] + self._tb_pedal_lbl.setStyleSheet(f"color:{ped_color};padding:0 10px;") + + if hasattr(self, "player"): + self.player.apply_theme() + if hasattr(self, "table"): + self.table.apply_theme() + if hasattr(self, "folder_tree"): + self.folder_tree.viewport().update() + + def _show_about(self): + dlg = QDialog(self) + dlg.setWindowTitle(f"À propos de {APP_NAME}") + dlg.setFixedWidth(440) + dlg.setWindowIcon(_build_app_icon()) + + layout = QVBoxLayout(dlg) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # ── Bandeau header avec icône et titre ──────────────────────────────── + header = QWidget() + header.setStyleSheet(f""" + background: qlineargradient(x1:0,y1:0,x2:1,y2:1, + stop:0 #3A6BD4, stop:1 #1A3B94); + border-bottom: 2px solid {COLORS['accent']}; + """) + h_layout = QHBoxLayout(header) + h_layout.setContentsMargins(20, 18, 20, 18) + h_layout.setSpacing(16) + + # Icône grande + icon_lbl = QLabel() + icon_pix = _build_app_icon().pixmap(QSize(64, 64)) + icon_lbl.setPixmap(icon_pix) + icon_lbl.setFixedSize(64, 64) + + # Titre + version + title_col = QVBoxLayout() + title_col.setSpacing(2) + lbl_name = QLabel(APP_NAME) + lbl_name.setStyleSheet( + "color:white;font-size:22px;font-weight:700;background:transparent;") + lbl_ver = QLabel(f"Version {APP_VERSION}") + lbl_ver.setStyleSheet( + "color:rgba(255,255,255,0.75);font-size:12px;background:transparent;") + title_col.addWidget(lbl_name) + title_col.addWidget(lbl_ver) + + h_layout.addWidget(icon_lbl) + h_layout.addLayout(title_col) + h_layout.addStretch() + layout.addWidget(header) + + # ── Corps du dialogue ───────────────────────────────────────────────── + body = QWidget() + body.setStyleSheet(f"background:{COLORS['bg']};") + b_layout = QVBoxLayout(body) + b_layout.setContentsMargins(24, 18, 24, 6) + b_layout.setSpacing(10) + + def info_row(label: str, value: str, link: bool = False) -> QHBoxLayout: + row = QHBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet( + f"color:{COLORS['text_dim']};font-size:11px;min-width:110px;") + val = QLabel(f'{value}' + if link else value) + val.setStyleSheet(f"color:{COLORS['text']};font-size:11px;") + if link: + val.setOpenExternalLinks(True) + row.addWidget(lbl) + row.addWidget(val) + row.addStretch() + return row + + # Infos app + b_layout.addLayout(info_row("Description :", + "Gestionnaire de transcription audio")) + b_layout.addLayout(info_row("Pédaliers supportés :", + "RS27H/N · RS28H/N · RS31H/N")) + b_layout.addLayout(info_row("Organisation :", APP_AUTHOR)) + b_layout.addLayout(info_row("Auteur :", "JT-Tools by Johnny")) + b_layout.addLayout(info_row("Année :", APP_YEAR)) + b_layout.addLayout(info_row("Code source :", APP_URL, link=True)) + + # Séparateur + sep = QWidget() + sep.setFixedHeight(1) + sep.setStyleSheet(f"background:{COLORS['border']};") + b_layout.addSpacing(4) + b_layout.addWidget(sep) + b_layout.addSpacing(4) + + # Infos runtime + py_ver = sys.version.split()[0] + try: + from PySide6 import __version__ as qt_ver + except Exception: + qt_ver = "—" + + b_layout.addLayout(info_row("Python :", py_ver)) + b_layout.addLayout(info_row("PySide6 :", qt_ver)) + b_layout.addLayout(info_row("NumPy / soundfile :", + "disponible" if WAVEFORM_AVAILABLE else "non installé")) + b_layout.addLayout(info_row("Support HID :", + "disponible" if HID_AVAILABLE else "non installé (pip install hid)")) + + b_layout.addSpacing(8) + + # Bouton Fermer + close_btn = QPushButton("Fermer") + close_btn.setObjectName("accent") + close_btn.setFixedWidth(100) + close_btn.clicked.connect(dlg.accept) + b_layout.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + b_layout.addSpacing(4) + + layout.addWidget(body) + dlg.exec() + + def _show_udev(self): + if sys.platform == "win32": + rule = ( + "══ CAS 1 — Pédalier non détecté ══════════════════════════════\n\n" + "Windows doit voir le pédalier sous 'Périphériques d'interface\n" + "utilisateur (HID)' dans le Gestionnaire de périphériques.\n\n" + " • Débranchez et rebranchez le câble USB\n" + " • Essayez un autre port USB (USB 2.0 de préférence)\n" + " • Si le pédalier apparaît sous 'Souris et autres dispositifs\n" + " de pointage' → voir Cas 2 ci-dessous\n\n" + "══ CAS 2 — Les touches déclenchent des clics souris ══════════\n\n" + "Windows traite le pédalier comme une souris. Il faut remplacer\n" + "son pilote par le pilote HID générique via Zadig :\n\n" + " 1. Télécharger Zadig : https://zadig.akeo.ie\n" + " 2. Options → List All Devices\n" + " 3. Sélectionner le pédalier dans la liste\n" + " 4. Choisir le pilote 'HID (Human Interface Device)'\n" + " (NE PAS choisir WinUSB ou libusb — incompatibles)\n" + " 5. Cliquer 'Replace Driver'\n" + " 6. Débrancher / rebrancher le pédalier\n\n" + "══ INFORMATION ══════════════════════════════════════════════\n\n" + "La bibliothèque hidapi.dll est incluse dans l'exécutable.\n" + "Aucune installation de driver supplémentaire n'est requise\n" + "si le périphérique est correctement reconnu en HID." + ) + title = "Accès pédalier – Windows 11" + else: + rule = ( + "Créez le fichier suivant :\n\n" + "/etc/udev/rules.d/99-olympus-pedal.rules\n" + "──────────────────────────────────────────────────────────\n" + "# Accès hidraw sans root\n" + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="07b4", MODE="0666", GROUP="plugdev"\n' + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="33a2", MODE="0666", GROUP="plugdev"\n\n' + "# Empêcher le kernel de traiter le pédalier comme une souris\n" + "# (évite que les touches ouvrent un menu contextuel ou déplacent le curseur)\n" + 'SUBSYSTEM=="input", ATTRS{idVendor}=="07b4", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n' + 'SUBSYSTEM=="input", ATTRS{idVendor}=="33a2", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n' + "──────────────────────────────────────────────────────────\n\n" + "Rechargez udev :\n" + " sudo udevadm control --reload-rules && sudo udevadm trigger\n\n" + "Ajoutez votre utilisateur au groupe plugdev :\n" + " sudo usermod -aG plugdev $USER\n\n" + "Puis déconnectez/reconnectez le pédalier (ou redémarrez)." + ) + title = "Règle udev – Pédalier Olympus / OM Digital" + dlg = QDialog(self) + dlg.setWindowTitle(title) + dlg.setMinimumWidth(520) + layout = QVBoxLayout(dlg) + txt = QLabel(rule) + txt.setFont(QFont("Monospace", 10)) + txt.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + txt.setWordWrap(True) + layout.addWidget(txt) + btn = QPushButton("Fermer") + btn.clicked.connect(dlg.accept) + layout.addWidget(btn) + dlg.exec() + + # ── Fermeture ───────────────────────────────────────────────────────────── + def closeEvent(self, event): + self._save_state() + if self._pedal_worker: + self._pedal_worker.stop() + self._pedal_worker.wait(2000) + event.accept() + + +# ─── Point d'entrée ─────────────────────────────────────────────────────────── +def _build_app_icon() -> QIcon: + """Génère l'icône de l'application avec QPainter (waveform stylisée).""" + sizes = [16, 32, 48, 64, 128] + icon = QIcon() + for sz in sizes: + pix = QPixmap(sz, sz) + pix.fill(Qt.GlobalColor.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Fond arrondi dégradé bleu → bleu foncé + grad = QLinearGradient(0, 0, sz, sz) + grad.setColorAt(0, QColor("#4A7DE8")) + grad.setColorAt(1, QColor("#2A4DB8")) + p.setBrush(grad) + p.setPen(Qt.PenStyle.NoPen) + radius = sz * 0.18 + p.drawRoundedRect(0, 0, sz, sz, radius, radius) + + # Barres de forme d'onde centrées + bar_heights = [0.25, 0.45, 0.70, 0.90, 0.65, 1.0, 0.55, 0.80, 0.40, 0.20] + n = len(bar_heights) + margin = sz * 0.14 + total_w = sz - 2 * margin + bar_w = total_w / (n * 1.7) + gap = total_w / n + mid_y = sz / 2 + + for i, h in enumerate(bar_heights): + x = margin + i * gap + (gap - bar_w) / 2 + bh = h * (sz * 0.36) + # Couleur : blanc semi-transparent, plus lumineux au centre + alpha = int(180 + 60 * abs(i - n // 2) / (n // 2)) + color = QColor(255, 255, 255, min(255, alpha)) + p.setBrush(color) + p.drawRoundedRect( + int(x), int(mid_y - bh), + max(1, int(bar_w)), int(bh * 2), + bar_w / 2, bar_w / 2 + ) + + p.end() + icon.addPixmap(pix) + return icon + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setApplicationVersion(APP_VERSION) + app.setOrganizationName(APP_AUTHOR) + settings = AppSettings() + set_theme(settings.get("general.theme", "light")) + app.setStyleSheet(STYLESHEET) + app.setWindowIcon(_build_app_icon()) + + win = MainWindow() + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()