Recorder: drena stderr di ffmpeg + messaggi di errore leggibili

Il vecchio worker non drenava lo stderr di ffmpeg: dopo ~64KB il
pipe si bloccava e quando ffmpeg falliva subito (es. permesso
microfono negato su macOS) si vedeva solo 'ffmpeg exit -6' senza
contesto.

- Thread dedicato _drain_stderr() che legge stderr in continuo e
  mantiene le ultime 50 righe in un buffer protetto
- _extract_friendly_error() riconosce i casi tipici e produce
  messaggi italiani:
    * exit -6 su macOS -> 'permesso Microfono mancante'
    * Input/Output error -> 'dispositivo non disponibile'
    * 'no such device' -> 'premere Aggiorna'
- Pre-flight check: aspetta che ffmpeg stampi 'Press [q]' (segnale
  di device aperto) prima di emettere 'started'. Se ffmpeg muore
  prima, l'errore viene segnalato senza falso 'recording in corso'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-06-08 11:03:22 +02:00
1 parent 5041d3e14c
commit 9f37b7eb94
1 file changed
+86 -21
+86 -21
View File
@@ -29,6 +29,8 @@ _current_proc: Optional[subprocess.Popen] = None
_proc_lock = threading.Lock() _proc_lock = threading.Lock()
_stop_event = threading.Event() _stop_event = threading.Event()
_recording_thread: Optional[threading.Thread] = None _recording_thread: Optional[threading.Thread] = None
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool: def is_recording() -> bool:
@@ -216,6 +218,24 @@ def start_recording(
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate) cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
_stop_event.clear() _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(): def _worker():
global _current_proc global _current_proc
@@ -224,16 +244,41 @@ def start_recording(
_current_proc = subprocess.Popen( _current_proc = subprocess.Popen(
cmd, cmd,
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=False, # bytes su stdin per "q" 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: if progress_callback:
progress_callback("started", {"output_path": str(out)}) progress_callback("started", {"output_path": str(out)})
start = time.monotonic()
# Pubblica un tick al secondo
while True: while True:
if _stop_event.is_set(): if _stop_event.is_set():
break break
@@ -271,12 +316,7 @@ def start_recording(
if rc == 0 or out.exists(): if rc == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds}) progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else: else:
err = "" progress_callback("error", {"error": _extract_friendly_error(rc)})
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: except Exception as e:
with _proc_lock: with _proc_lock:
_current_proc = None _current_proc = None
@@ -289,17 +329,42 @@ def start_recording(
return {"ok": True, "output_path": str(out)} return {"ok": True, "output_path": str(out)}
def _get_last_stderr() -> str: def _extract_friendly_error(rc: int) -> str:
"""Tenta di leggere lo stderr residuo del processo corrente.""" """Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
with _proc_lock: Riconosce i casi tipici: permessi microfono, device occupato,
proc = _current_proc device non disponibile, ..."""
if proc is None or proc.stderr is None: with _stderr_lock:
return "" lines = list(_stderr_buf)
try:
data = proc.stderr.read() or b"" text = "\n".join(lines).lower()
return data.decode("utf-8", errors="ignore")
except Exception: if "abort" in text and rc == -6 and sys.platform == "darwin":
return "" 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/MusicDownload 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: def stop_recording() -> dict: