From 97a440819a6225b07164f78c5e96f70914320d17 Mon Sep 17 00:00:00 2001 From: luzadev Date: Mon, 8 Jun 2026 10:44:05 +0200 Subject: [PATCH] Nuova tab Registra: cattura audio da ingresso del computer Aggiunta sezione per registrare in MP3 320 kbps da qualsiasi dispositivo di input audio del sistema (mixer, scheda audio esterna, microfono interno, BlackHole / driver virtuali per audio di sistema). - core/recorder.py: list_input_devices() parsa l'output di ffmpeg -list_devices su AVFoundation (macOS) e DirectShow (Win), marca come 'virtual' i driver loopback noti (BlackHole, Soundflower, Loopback, VB-Cable, Stereo Mix, OBS). start_recording() lancia ffmpeg in thread e emette eventi tick/started/stopped/error. - api/bridge.py: list_audio_inputs, start_audio_recording, stop_audio_recording, is_audio_recording - webui: hero rosso, dropdown dispositivi con badge 'loopback', selettore bitrate, timer HH:MM:SS animato, bottone REC con pulse animation, card 'Ultima registrazione' con durata+path - Hint visibile: per registrare audio di sistema serve BlackHole Co-Authored-By: Claude Opus 4.7 --- api/bridge.py | 47 +++++++ core/recorder.py | 308 ++++++++++++++++++++++++++++++++++++++++++++ webui/css/style.css | 63 +++++++++ webui/index.html | 76 +++++++++++ webui/js/app.js | 117 +++++++++++++++++ 5 files changed, 611 insertions(+) create mode 100644 core/recorder.py diff --git a/api/bridge.py b/api/bridge.py index 20d16a1..e708d78 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -21,6 +21,12 @@ from core.downloader import ( ) from core.spotify_client import get_access_token, resolve_spotify_url from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS +from core.recorder import ( + list_input_devices, + start_recording, + stop_recording as recorder_stop, + is_recording, +) from core.upgrader import ( upgrade_folder, request_stop as request_upgrade_stop, @@ -282,6 +288,47 @@ class Api: except Exception as e: return {"ok": False, "error": str(e)} + # ------------------------------------------------------------------ + # Recorder + # ------------------------------------------------------------------ + def list_audio_inputs(self) -> dict: + try: + return {"ok": True, "devices": list_input_devices()} + except Exception as e: + return {"ok": False, "error": str(e), "devices": []} + + def start_audio_recording(self, payload: dict) -> dict: + device_id = str(payload.get("device_id", "")).strip() + output_dir = (payload.get("output_dir") or "").strip() + filename = (payload.get("filename") or "").strip() + bitrate = (payload.get("bitrate") or "320k").strip() + + if not device_id: + return {"ok": False, "error": "Dispositivo non selezionato"} + if not output_dir: + return {"ok": False, "error": "Cartella output non impostata"} + if not filename: + filename = f"Registrazione_{time.strftime('%Y%m%d_%H%M%S')}.mp3" + if not filename.lower().endswith(".mp3"): + filename += ".mp3" + + out_path = os.path.join(output_dir, filename) + + def cb(status, payload_evt): + payload_evt = dict(payload_evt or {}) + payload_evt["status"] = status + self._emit("recording:event", payload_evt) + + res = start_recording(device_id, out_path, bitrate=bitrate, + progress_callback=cb) + return res + + def stop_audio_recording(self) -> dict: + return recorder_stop() + + def is_audio_recording(self) -> dict: + return {"recording": is_recording()} + # ------------------------------------------------------------------ # Audio folder scan (Upgrade tab) # ------------------------------------------------------------------ diff --git a/core/recorder.py b/core/recorder.py new file mode 100644 index 0000000..2563eb5 --- /dev/null +++ b/core/recorder.py @@ -0,0 +1,308 @@ +"""Registrazione audio da un dispositivo di input via ffmpeg. + +macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole) +Windows: usa DirectShow +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Optional + +from core.paths import find_ffmpeg + + +_IS_MAC = sys.platform == "darwin" +_IS_WIN = sys.platform == "win32" + + +# ============================================================ +# Stato globale (un solo recording alla volta) +# ============================================================ +_current_proc: Optional[subprocess.Popen] = None +_proc_lock = threading.Lock() +_stop_event = threading.Event() +_recording_thread: Optional[threading.Thread] = None + + +def is_recording() -> bool: + with _proc_lock: + return _current_proc is not None and _current_proc.poll() is None + + +# ============================================================ +# Lista dispositivi +# ============================================================ +def list_input_devices() -> list[dict]: + """Ritorna la lista dei dispositivi audio di input. + Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}. + + is_virtual = True per driver di loopback noti (BlackHole, Soundflower, + Loopback, Audio Hijack, VB-Cable, ...) + """ + ffmpeg = find_ffmpeg() + if not ffmpeg: + return [] + + if _IS_MAC: + return _list_macos(ffmpeg) + if _IS_WIN: + return _list_windows(ffmpeg) + return [] + + +_VIRTUAL_KEYWORDS = ( + "blackhole", "soundflower", "loopback", "audio hijack", + "vb-cable", "vb-audio", "stereo mix", "what u hear", + "obs", "system audio", +) + + +def _is_virtual(name: str) -> bool: + n = (name or "").lower() + return any(k in n for k in _VIRTUAL_KEYWORDS) + + +def _list_macos(ffmpeg: str) -> list[dict]: + """Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`. + L'output va su stderr.""" + try: + proc = subprocess.run( + [ffmpeg, "-hide_banner", "-f", "avfoundation", + "-list_devices", "true", "-i", ""], + capture_output=True, text=True, timeout=10, + ) + except Exception: + return [] + + devices: list[dict] = [] + in_audio = False + audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE) + other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE) + line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$") + + for raw in proc.stderr.splitlines(): + line = raw.strip() + if audio_section_re.search(line): + in_audio = True + continue + if other_section_re.search(line): + in_audio = False + continue + if not in_audio: + continue + clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line) + m = line_re.match(clean) + if not m: + continue + idx, name = m.group(1), m.group(2).strip() + devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)}) + + return devices + + +def _list_windows(ffmpeg: str) -> list[dict]: + """Parsa l'output di `ffmpeg -f dshow -list_devices true -i dummy`.""" + try: + proc = subprocess.run( + [ffmpeg, "-hide_banner", "-f", "dshow", + "-list_devices", "true", "-i", "dummy"], + capture_output=True, text=True, timeout=10, + ) + except Exception: + return [] + + devices: list[dict] = [] + in_audio = False + line_re = re.compile(r'"([^"]+)"\s*\((audio|video)\)', re.IGNORECASE) + name_re = re.compile(r'"([^"]+)"') + + for raw in proc.stderr.splitlines(): + line = raw.strip() + low = line.lower() + if "directshow audio devices" in low: + in_audio = True + continue + if "directshow video devices" in low: + in_audio = False + continue + + m = line_re.search(line) + if m: + name, kind = m.group(1), m.group(2).lower() + if kind == "audio": + devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)}) + continue + + if in_audio: + mn = name_re.search(line) + if mn: + name = mn.group(1) + devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)}) + + # Dedup preservando l'ordine + seen = set() + out = [] + for d in devices: + if d["id"] in seen: + continue + seen.add(d["id"]) + out.append(d) + return out + + +# ============================================================ +# Registrazione +# ============================================================ +def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str, + bitrate: str) -> list[str]: + """Comando ffmpeg per registrare su file MP3.""" + if _IS_MAC: + return [ + ffmpeg, "-hide_banner", "-y", + "-f", "avfoundation", + "-i", f":{device_id}", + "-acodec", "libmp3lame", + "-b:a", bitrate, + "-vn", + output_path, + ] + if _IS_WIN: + return [ + ffmpeg, "-hide_banner", "-y", + "-f", "dshow", + "-i", f"audio={device_id}", + "-acodec", "libmp3lame", + "-b:a", bitrate, + "-vn", + output_path, + ] + raise RuntimeError("Sistema operativo non supportato per la registrazione") + + +def start_recording( + device_id: str, + output_path: str, + bitrate: str = "320k", + progress_callback: Optional[Callable] = None, +) -> dict: + """Avvia una registrazione in un thread. + Ritorna {ok, error?, output_path?}. + + progress_callback(status, payload) viene chiamato con: + ("started", {"output_path": ...}) + ("tick", {"seconds": int}) + ("stopped", {"output_path": ..., "seconds": int}) + ("error", {"error": "..."}) + """ + global _current_proc, _recording_thread + + if is_recording(): + return {"ok": False, "error": "Una registrazione e gia in corso"} + + ffmpeg = find_ffmpeg() + if not ffmpeg: + return {"ok": False, "error": "ffmpeg non trovato"} + + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + + cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate) + + _stop_event.clear() + + def _worker(): + global _current_proc + try: + with _proc_lock: + _current_proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, # bytes su stdin per "q" + ) + + if progress_callback: + progress_callback("started", {"output_path": str(out)}) + + start = time.monotonic() + # Pubblica un tick al secondo + while True: + if _stop_event.is_set(): + break + rc = _current_proc.poll() + if rc is not None: + break + elapsed = int(time.monotonic() - start) + if progress_callback: + progress_callback("tick", {"seconds": elapsed}) + time.sleep(1.0) + + # Stop gracefully + if _current_proc.poll() is None: + try: + _current_proc.stdin.write(b"q\n") + _current_proc.stdin.flush() + except Exception: + pass + try: + _current_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + _current_proc.terminate() + try: + _current_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + _current_proc.kill() + + rc = _current_proc.returncode + seconds = int(time.monotonic() - start) + + with _proc_lock: + _current_proc = None + + if progress_callback: + if rc == 0 or out.exists(): + progress_callback("stopped", {"output_path": str(out), "seconds": seconds}) + else: + err = "" + try: + err = (_get_last_stderr() or "").strip().splitlines()[-1] + except Exception: + pass + progress_callback("error", {"error": err or f"ffmpeg exit {rc}"}) + except Exception as e: + with _proc_lock: + _current_proc = None + if progress_callback: + progress_callback("error", {"error": str(e)}) + + _recording_thread = threading.Thread(target=_worker, daemon=True) + _recording_thread.start() + + return {"ok": True, "output_path": str(out)} + + +def _get_last_stderr() -> str: + """Tenta di leggere lo stderr residuo del processo corrente.""" + with _proc_lock: + proc = _current_proc + if proc is None or proc.stderr is None: + return "" + try: + data = proc.stderr.read() or b"" + return data.decode("utf-8", errors="ignore") + except Exception: + return "" + + +def stop_recording() -> dict: + """Ferma la registrazione corrente (se attiva).""" + _stop_event.set() + return {"ok": True} diff --git a/webui/css/style.css b/webui/css/style.css index 55848cf..062b0ba 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -34,6 +34,10 @@ --amber-hover: #FBBF24; --amber-dim: #78350F; + --rec-red: #EF4444; + --rec-red-hover: #F87171; + --rec-red-dim: #7F1D1D; + --red: #E22134; --red-hover: #FF4055; @@ -241,6 +245,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } background: linear-gradient(135deg, #F59E0B 0%, #78350F 60%, #3B1E07 100%); } +.hero-red { + background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%); +} + .hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-eyebrow { @@ -255,6 +263,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } .hero-eyebrow.purple { color: var(--purple); } .hero-eyebrow.pink { color: var(--pink-hover); } .hero-eyebrow.amber { color: var(--amber-hover); } +.hero-eyebrow.red { color: var(--rec-red-hover); } .hero-title { font-size: 36px; @@ -477,6 +486,20 @@ input[type="number"]::-webkit-inner-spin-button { color: #000; } +.btn-red { + background: var(--rec-red); + color: #fff; +} + +.btn-red.recording { + animation: pulseRed 1.2s ease-in-out infinite; +} + +@keyframes pulseRed { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); } + 50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); } +} + .btn-success { background: #00CEC9; color: #000; @@ -926,3 +949,43 @@ input[type="number"]::-webkit-inner-spin-button { .form-grid { grid-template-columns: 1fr; } .cover-card { grid-template-columns: 1fr; } } + +/* =============================== + RECORDER + =============================== */ +.rec-timer { + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 28px; + font-weight: 800; + color: var(--rec-red-hover); + letter-spacing: 1px; + margin-left: auto; + min-width: 130px; + text-align: right; +} + +.rec-status { + color: var(--text-2); + font-size: 12.5px; + font-weight: 600; + margin-left: 18px; +} + +.rec-result { + color: var(--text); + font-size: 13px; + font-weight: 600; + margin-top: 6px; + word-break: break-all; + user-select: text; +} + +.rec-result .badge-dur { + background: rgba(239, 68, 68, 0.15); + color: var(--rec-red-hover); + padding: 2px 10px; + border-radius: 10px; + font-size: 11.5px; + font-weight: 700; + margin-right: 10px; +} diff --git a/webui/index.html b/webui/index.html index a8bc645..6e32d45 100644 --- a/webui/index.html +++ b/webui/index.html @@ -37,6 +37,10 @@ 🏷 Metadati + + + + +
+ ⓘ + Per registrare l'audio di sistema (Spotify/YouTube/ecc.) installa BlackHole (gratuito, brew install blackhole-2ch) e poi imposta l'output di sistema su BlackHole. +
+ + + +
+
+ +
+
Nessuna cartella selezionata
+ +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
00:00:00
+
+
+ + + +
diff --git a/webui/js/app.js b/webui/js/app.js index bfe4c10..0eb73ef 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -16,6 +16,7 @@ const state = { upgrading: false, videoDownloading: false, meta: { path: "", newCoverPath: "", removeCover: false }, + rec: { devices: [], outputDir: "", recording: false, seconds: 0 }, }; // Wait for pywebview ready @@ -104,8 +105,56 @@ const bridgeHandlers = { $("#videoDownloadBtn").disabled = false; $("#stopVideoBtn").disabled = true; }, + + "recording:event": (p) => { + const status = p.status; + if (status === "started") { + state.rec.recording = true; + state.rec.seconds = 0; + $("#recStartBtn").disabled = true; + $("#recStartBtn").classList.add("recording"); + $("#recStopBtn").disabled = false; + $("#recDevice").disabled = true; + $("#recRefreshBtn").disabled = true; + $("#recTimer").textContent = "00:00:00"; + $("#recStatus").textContent = "● Registrazione in corso…"; + } else if (status === "tick") { + state.rec.seconds = p.seconds || 0; + $("#recTimer").textContent = formatHms(state.rec.seconds); + } else if (status === "stopped") { + state.rec.recording = false; + $("#recStartBtn").disabled = false; + $("#recStartBtn").classList.remove("recording"); + $("#recStopBtn").disabled = true; + $("#recDevice").disabled = false; + $("#recRefreshBtn").disabled = false; + $("#recStatus").textContent = ""; + $("#recOutputCard").hidden = false; + $("#recResult").innerHTML = ` + ${formatHms(p.seconds || 0)} + ${p.output_path || ""} + `; + toast("Registrazione salvata", "success"); + } else if (status === "error") { + state.rec.recording = false; + $("#recStartBtn").disabled = false; + $("#recStartBtn").classList.remove("recording"); + $("#recStopBtn").disabled = true; + $("#recDevice").disabled = false; + $("#recRefreshBtn").disabled = false; + $("#recStatus").textContent = ""; + toast("Errore registrazione: " + (p.error || ""), "error"); + } + }, }; +function formatHms(sec) { + const h = Math.floor(sec / 3600).toString().padStart(2, "0"); + const m = Math.floor((sec % 3600) / 60).toString().padStart(2, "0"); + const s = Math.floor(sec % 60).toString().padStart(2, "0"); + return `${h}:${m}:${s}`; +} + // ============================================================ // Logging // ============================================================ @@ -196,6 +245,34 @@ async function init() { // Upgrade tab — default threshold $("#upThreshold").value = state.config.hq_threshold || 310; + + // Record tab — default output dir + carica dispositivi + state.rec.outputDir = state.config.output_dir || ""; + if (state.rec.outputDir) { + $("#recPathDisplay").textContent = state.rec.outputDir; + $("#recPathDisplay").classList.remove("empty"); + } + await refreshRecDevices(); +} + +async function refreshRecDevices() { + const res = await window.pywebview.api.list_audio_inputs(); + const select = $("#recDevice"); + select.innerHTML = ""; + state.rec.devices = res.devices || []; + if (!state.rec.devices.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "Nessun dispositivo trovato"; + select.appendChild(opt); + return; + } + for (const d of state.rec.devices) { + const opt = document.createElement("option"); + opt.value = d.id; + opt.textContent = d.is_virtual ? `🔄 ${d.name} (loopback)` : d.name; + select.appendChild(opt); + } } // ============================================================ @@ -392,6 +469,46 @@ $("#stopUpgradeBtn").addEventListener("click", async () => { $("#stopUpgradeBtn").disabled = true; }); +// ============================================================ +// RECORDER tab +// ============================================================ +$("#recRefreshBtn").addEventListener("click", refreshRecDevices); + +$("#recBrowseBtn").addEventListener("click", async () => { + const path = await window.pywebview.api.browse_directory(); + if (path) { + state.rec.outputDir = path; + $("#recPathDisplay").textContent = path; + $("#recPathDisplay").classList.remove("empty"); + } +}); + +$("#recStartBtn").addEventListener("click", async () => { + const deviceId = $("#recDevice").value; + if (!deviceId) { + toast("Seleziona un dispositivo", "error"); + return; + } + if (!state.rec.outputDir) { + toast("Seleziona una cartella di destinazione", "error"); + return; + } + const res = await window.pywebview.api.start_audio_recording({ + device_id: deviceId, + output_dir: state.rec.outputDir, + filename: $("#recFilename").value.trim(), + bitrate: $("#recBitrate").value, + }); + if (!res.ok) { + toast(res.error || "Errore", "error"); + } +}); + +$("#recStopBtn").addEventListener("click", async () => { + $("#recStopBtn").disabled = true; + await window.pywebview.api.stop_audio_recording(); +}); + // ============================================================ // METADATA editor // ============================================================