Files
musicdownload/core/recorder.py
T
luzadevandClaude Opus 4.7 850d3f4b6c Rename: l'app si chiama MusicTools (non piu MusicDownload)
- main.py: titolo finestra pywebview -> 'MusicTools'
- core/config.py: APP_NAME='MusicTools', cartella config bundled
  ora ~/Library/Application Support/MusicTools/ e %APPDATA%/MusicTools/.
  Migrazione one-shot: se non esiste la nuova ma esiste la legacy
  'MusicDownload', il config.json viene copiato cosi' le impostazioni
  non vanno perse dopo l'aggiornamento
- core/recorder.py: messaggio errore permesso microfono
- api/bridge.py: User-Agent update check + guida Spotify menzionano
  MusicTools
- build_macos.py / build_windows.py / .github/workflows/build.yml:
  prodotti rinominati (MusicTools.app, MusicTools.exe,
  MusicTools-macOS.zip, MusicTools-Windows.zip, Release 'Build MusicTools')
- webui: <title>, brand-name sidebar, intestazioni commenti
- README: tutti i riferimenti aggiornati + nota che il repo Git
  conserva il nome storico 'musicdownload' (URL stabili per
  release gia' pubblicate)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:15:34 +02:00

374 lines
12 KiB
Python

"""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
_stderr_buf: list = []
_stderr_lock = threading.Lock()
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()
with _stderr_lock:
_stderr_buf.clear()
def _drain_stderr(proc):
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
del pipe e per salvare le ultime righe ai fini diagnostici."""
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
with _stderr_lock:
_stderr_buf.append(line)
# Mantieni solo le ultime 50 righe per non sforare la RAM
if len(_stderr_buf) > 50:
del _stderr_buf[0]
except Exception:
pass
def _worker():
global _current_proc
try:
with _proc_lock:
_current_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False,
)
drain_t = threading.Thread(target=_drain_stderr,
args=(_current_proc,), daemon=True)
drain_t.start()
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
# primi ~1.5s, segnala errore prima di emettere 'started'.
start = time.monotonic()
opened = False
for _ in range(15):
if _current_proc.poll() is not None:
break
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
rc_early = _current_proc.poll()
if not opened and rc_early is not None:
# ffmpeg e' uscito prima di iniziare a registrare
with _proc_lock:
_current_proc = None
if progress_callback:
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
return
if progress_callback:
progress_callback("started", {"output_path": str(out)})
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:
progress_callback("error", {"error": _extract_friendly_error(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 _extract_friendly_error(rc: int) -> str:
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
Riconosce i casi tipici: permessi microfono, device occupato,
device non disponibile, ..."""
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
if "abort" in text and rc == -6 and sys.platform == "darwin":
return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
for line in reversed(lines):
l = line.strip()
if not l:
continue
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
return l
return f"ffmpeg exit {rc}"
def stop_recording() -> dict:
"""Ferma la registrazione corrente (se attiva)."""
_stop_event.set()
return {"ok": True}