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 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-06-08 10:44:05 +02:00
1 parent 1d7832e246
commit 97a440819a
5 files changed
+611

No files matched your search

+47
View File
@@ -21,6 +21,12 @@ from core.downloader import (
) )
from core.spotify_client import get_access_token, resolve_spotify_url from core.spotify_client import get_access_token, resolve_spotify_url
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS 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 ( from core.upgrader import (
upgrade_folder, upgrade_folder,
request_stop as request_upgrade_stop, request_stop as request_upgrade_stop,
@@ -282,6 +288,47 @@ class Api:
except Exception as e: except Exception as e:
return {"ok": False, "error": str(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) # Audio folder scan (Upgrade tab)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+308
View File
@@ -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}
+63
View File
@@ -34,6 +34,10 @@
--amber-hover: #FBBF24; --amber-hover: #FBBF24;
--amber-dim: #78350F; --amber-dim: #78350F;
--rec-red: #EF4444;
--rec-red-hover: #F87171;
--rec-red-dim: #7F1D1D;
--red: #E22134; --red: #E22134;
--red-hover: #FF4055; --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%); 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-content { position: relative; z-index: 2; max-width: 70%; }
.hero-eyebrow { .hero-eyebrow {
@@ -255,6 +263,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.hero-eyebrow.purple { color: var(--purple); } .hero-eyebrow.purple { color: var(--purple); }
.hero-eyebrow.pink { color: var(--pink-hover); } .hero-eyebrow.pink { color: var(--pink-hover); }
.hero-eyebrow.amber { color: var(--amber-hover); } .hero-eyebrow.amber { color: var(--amber-hover); }
.hero-eyebrow.red { color: var(--rec-red-hover); }
.hero-title { .hero-title {
font-size: 36px; font-size: 36px;
@@ -477,6 +486,20 @@ input[type="number"]::-webkit-inner-spin-button {
color: #000; 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 { .btn-success {
background: #00CEC9; background: #00CEC9;
color: #000; color: #000;
@@ -926,3 +949,43 @@ input[type="number"]::-webkit-inner-spin-button {
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.cover-card { 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;
}
+76
View File
@@ -37,6 +37,10 @@
<span class="nav-icon">🏷</span> <span class="nav-icon">🏷</span>
<span>Metadati</span> <span>Metadati</span>
</button> </button>
<button class="nav-item" data-view="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
<button class="nav-item" data-view="settings"> <button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span> <span class="nav-icon">⚙</span>
<span>Impostazioni</span> <span>Impostazioni</span>
@@ -241,6 +245,78 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: REGISTRA ===== -->
<section class="view" id="view-record">
<header class="hero hero-red">
<div class="hero-content">
<div class="hero-eyebrow red">REGISTRAZIONE</div>
<h1 class="hero-title">Cattura audio</h1>
<p class="hero-subtitle">Registra in MP3 320 kbps da qualsiasi ingresso audio (mixer, scheda, microfono, BlackHole/loopback)</p>
</div>
<div class="hero-deco">●</div>
</header>
<h2 class="section-label">Dispositivo</h2>
<div class="card">
<div class="field">
<label class="field-label">Ingresso audio</label>
<div class="row">
<select id="recDevice" class="input pill">
<option value="">Caricamento…</option>
</select>
<button class="btn btn-ghost pill btn-sm" id="recRefreshBtn">↻ Aggiorna</button>
</div>
</div>
<div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span>
Per registrare l'audio di sistema (Spotify/YouTube/ecc.) installa <strong>BlackHole</strong> (gratuito, brew install blackhole-2ch) e poi imposta l'output di sistema su BlackHole.
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="field">
<label class="field-label">Cartella output</label>
<div class="row">
<div class="path-display" id="recPathDisplay">Nessuna cartella selezionata</div>
<button class="btn btn-ghost pill btn-sm" id="recBrowseBtn">Sfoglia</button>
</div>
</div>
<div class="field">
<label class="field-label">Nome file (opzionale)</label>
<input type="text" id="recFilename" class="input pill" placeholder="Registrazione_AAAAMMGG_HHMMSS.mp3" />
</div>
<div class="field inline">
<label class="field-label">Bitrate</label>
<select id="recBitrate" class="input pill input-sm">
<option value="128k">128 kbps</option>
<option value="192k">192 kbps</option>
<option value="256k">256 kbps</option>
<option value="320k" selected>320 kbps</option>
</select>
</div>
</div>
<div class="card actions">
<button class="btn btn-red pill btn-lg" id="recStartBtn">
<span class="ico">●</span> REC
</button>
<button class="btn btn-danger pill btn-lg" id="recStopBtn" disabled>
<span class="ico">◼</span> Stop
</button>
<div class="rec-timer" id="recTimer">00:00:00</div>
<div class="rec-status" id="recStatus"></div>
</div>
<div class="card" id="recOutputCard" hidden>
<div class="micro-label">Ultima registrazione</div>
<div class="rec-result" id="recResult"></div>
</div>
</section>
<!-- ===== VIEW: METADATI ===== --> <!-- ===== VIEW: METADATI ===== -->
<section class="view" id="view-meta"> <section class="view" id="view-meta">
<header class="hero hero-amber"> <header class="hero hero-amber">
+117
View File
@@ -16,6 +16,7 @@ const state = {
upgrading: false, upgrading: false,
videoDownloading: false, videoDownloading: false,
meta: { path: "", newCoverPath: "", removeCover: false }, meta: { path: "", newCoverPath: "", removeCover: false },
rec: { devices: [], outputDir: "", recording: false, seconds: 0 },
}; };
// Wait for pywebview ready // Wait for pywebview ready
@@ -104,8 +105,56 @@ const bridgeHandlers = {
$("#videoDownloadBtn").disabled = false; $("#videoDownloadBtn").disabled = false;
$("#stopVideoBtn").disabled = true; $("#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 = `
<span class="badge-dur">${formatHms(p.seconds || 0)}</span>
${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 // Logging
// ============================================================ // ============================================================
@@ -196,6 +245,34 @@ async function init() {
// Upgrade tab — default threshold // Upgrade tab — default threshold
$("#upThreshold").value = state.config.hq_threshold || 310; $("#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; $("#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 // METADATA editor
// ============================================================ // ============================================================