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:
1 parent
1d7832e246
commit
97a440819a
5 files changed
+611
No files matched your search
@@ -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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user