#!/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()