TranscriptStation/transcribe_station.py

2901 lines
114 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.01.0
def __init__(self, parent=None):
super().__init__(parent)
self._samples: Optional["np.ndarray"] = None
self._position = 0.0 # 0.01.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'<a href="{value}" style="color:{COLORS["accent"]}">{value}</a>'
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()