Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6ae1fbe1 | ||
|
|
0e14d161d0 | ||
|
|
7dd793253b | ||
|
|
5771d631fd |
No files matched your search
+6
-1
@@ -229,7 +229,12 @@ class Api:
|
||||
),
|
||||
"plan": plan,
|
||||
}
|
||||
# 2) Quota gate server-side
|
||||
# 2) Quota gate server-side: skippato per i piani senza limite
|
||||
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
|
||||
# funzionare l'app anche offline per quei piani.
|
||||
plan = license_mod.get_plan()
|
||||
if plan.get("daily_limit") is None:
|
||||
return None
|
||||
try:
|
||||
res = license_mod.consume_quota(feature)
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
|
||||
+203
-55
@@ -239,6 +239,12 @@ def run_pyinstaller():
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
|
||||
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
|
||||
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
|
||||
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
|
||||
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
|
||||
# wrap_subprocess_in_bundle(app_path)
|
||||
adhoc_codesign(app_path)
|
||||
# Mostra dimensione
|
||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||
@@ -252,81 +258,223 @@ def run_pyinstaller():
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
|
||||
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
|
||||
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
|
||||
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||
|
||||
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
|
||||
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
|
||||
sistema e non viene mostrato nessun prompt all'utente.
|
||||
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
|
||||
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
|
||||
|
||||
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
|
||||
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
info_plist = app_path / "Contents" / "Info.plist"
|
||||
if not info_plist.exists():
|
||||
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||
return
|
||||
|
||||
pb = "/usr/libexec/PlistBuddy"
|
||||
|
||||
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
|
||||
def add_or_set(key, value, ptype="string"):
|
||||
# Tenta Add; se esiste, fa Set.
|
||||
r = subprocess.run(
|
||||
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
|
||||
subprocess.run(
|
||||
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
add_or_set(
|
||||
"NSMicrophoneUsageDescription",
|
||||
with open(info_plist, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
|
||||
data["NSMicrophoneUsageDescription"] = (
|
||||
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback).",
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||
)
|
||||
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
|
||||
# database TCC a riconoscere coerentemente l'app tra le sessioni.
|
||||
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
|
||||
add_or_set("LSApplicationCategoryType", "public.app-category.music")
|
||||
log("Info.plist aggiornato.")
|
||||
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
|
||||
data["CFBundleIdentifier"] = "com.djluza.musictools"
|
||||
data["LSApplicationCategoryType"] = "public.app-category.music"
|
||||
|
||||
with open(info_plist, "wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
|
||||
# Verifica
|
||||
with open(info_plist, "rb") as f:
|
||||
verify = plistlib.load(f)
|
||||
if "NSMicrophoneUsageDescription" not in verify:
|
||||
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
|
||||
else:
|
||||
log("Verifica OK: NSMicrophoneUsageDescription presente.")
|
||||
|
||||
|
||||
_BUNDLE_ID = "com.djluza.musictools"
|
||||
|
||||
|
||||
def _subprocess_info_plist(executable, bundle_id):
|
||||
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
|
||||
|
||||
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
|
||||
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
|
||||
device audio, anche se il main MusicTools ha il proprio TCC concesso.
|
||||
"""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
||||
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
||||
'<plist version="1.0">\n'
|
||||
'<dict>\n'
|
||||
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
|
||||
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
|
||||
f' <key>CFBundleName</key><string>{executable}</string>\n'
|
||||
' <key>CFBundlePackageType</key><string>APPL</string>\n'
|
||||
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
|
||||
' <key>CFBundleVersion</key><string>1</string>\n'
|
||||
' <key>LSBackgroundOnly</key><true/>\n'
|
||||
' <key>LSUIElement</key><true/>\n'
|
||||
' <key>NSMicrophoneUsageDescription</key>\n'
|
||||
' <string>MusicTools usa il microfono per registrare l\'audio del '
|
||||
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
|
||||
'</dict>\n'
|
||||
'</plist>\n'
|
||||
)
|
||||
|
||||
|
||||
def wrap_subprocess_in_bundle(app_path):
|
||||
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
|
||||
|
||||
macOS 14+ ENFORCE il fatto che ogni binario che apre device
|
||||
privacy-sensitive (microfono, camera, ...) debba avere un proprio
|
||||
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
|
||||
ffmpeg viene lanciato come binario standalone (anche se figlio di
|
||||
MusicTools), non c'e' Info.plist associato -> SIGABRT.
|
||||
|
||||
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
|
||||
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
|
||||
Stesso per ffprobe.
|
||||
"""
|
||||
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
|
||||
fw = app_path / "Contents" / "Frameworks"
|
||||
if not fw.exists():
|
||||
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
|
||||
return
|
||||
|
||||
for name in ("ffmpeg", "ffprobe"):
|
||||
src = fw / name
|
||||
if not src.exists() or not src.is_file():
|
||||
log(f" skip {name}: non trovato in Frameworks/")
|
||||
continue
|
||||
wrap = fw / f"{name}.app"
|
||||
if wrap.exists():
|
||||
shutil.rmtree(wrap)
|
||||
macos = wrap / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
dest = macos / name
|
||||
shutil.move(str(src), str(dest))
|
||||
dest.chmod(0o755)
|
||||
|
||||
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
|
||||
# stanno le dylib del bundle MusicTools).
|
||||
# @executable_path = ffmpeg.app/Contents/MacOS/
|
||||
# ../../../ = MusicTools.app/Contents/Frameworks/
|
||||
subprocess.run(
|
||||
["install_name_tool", "-add_rpath",
|
||||
"@executable_path/../../../", str(dest)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Crea Info.plist con NSMicrophoneUsageDescription.
|
||||
info = wrap / "Contents" / "Info.plist"
|
||||
bundle_id = f"com.djluza.musictools.{name}"
|
||||
info.write_text(_subprocess_info_plist(name, bundle_id))
|
||||
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
|
||||
|
||||
|
||||
def adhoc_codesign(app_path):
|
||||
"""Ad-hoc codesign dell'intero bundle .app.
|
||||
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
|
||||
|
||||
Senza una firma coerente, macOS tratta ffmpeg (subprocess bundled)
|
||||
come binario separato dal main MusicTools per TCC: ffmpeg apre il
|
||||
dispositivo audio senza errore ma riceve solo silenzio perche' il
|
||||
sistema non gli concede il permesso microfono ereditato.
|
||||
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
|
||||
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
|
||||
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
|
||||
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
|
||||
quello del bundle principale -> TCC NEGA al subprocess.
|
||||
|
||||
`codesign --force --deep --sign -` applica una firma ad-hoc (gratis,
|
||||
senza Developer ID) a tutti i binari nested. Per macOS questo rende
|
||||
l'app un singolo "team" coerente, e il permesso TCC concesso al
|
||||
main si propaga anche a ffmpeg/ffprobe/yt-dlp.
|
||||
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
|
||||
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
|
||||
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
|
||||
del main ai subprocess.
|
||||
"""
|
||||
log("Ad-hoc codesign del bundle (per TCC microfono ereditato)...")
|
||||
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
|
||||
try:
|
||||
# Rimuovi vecchie firme che potrebbero confondere codesign --deep
|
||||
subprocess.run(
|
||||
["xattr", "-cr", str(app_path)],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["codesign", "--remove-signature", "--deep", str(app_path)],
|
||||
capture_output=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
["codesign", "--force", "--deep", "--sign", "-",
|
||||
"--timestamp=none", str(app_path)],
|
||||
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
|
||||
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
|
||||
|
||||
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
|
||||
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
|
||||
# al piu' esterno (richiesta da codesign).
|
||||
nested_machos = []
|
||||
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
|
||||
if not p.is_file():
|
||||
continue
|
||||
# Skip risorse non eseguibili
|
||||
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
|
||||
".html", ".css", ".js", ".json", ".md", ".txt"):
|
||||
continue
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
magic = f.read(4)
|
||||
except Exception:
|
||||
continue
|
||||
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
|
||||
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
|
||||
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
||||
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
|
||||
nested_machos.append(p)
|
||||
|
||||
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
|
||||
|
||||
for p in nested_machos:
|
||||
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
|
||||
# del SUB-bundle (TCC li tratta come app separate con Info.plist
|
||||
# proprio). Per gli altri binari nested (dylib, framework), usa
|
||||
# l'identifier del bundle principale.
|
||||
ident = _BUNDLE_ID
|
||||
parts = p.relative_to(app_path).parts
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
if sub in parts:
|
||||
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
break
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", ident,
|
||||
"--timestamp=none",
|
||||
str(p)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log(f"ATTENZIONE: codesign fallito: {result.stderr.strip()}")
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
|
||||
|
||||
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_path = app_path / "Contents" / "Frameworks" / sub
|
||||
if not sub_path.exists():
|
||||
continue
|
||||
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", sub_ident,
|
||||
"--timestamp=none",
|
||||
str(sub_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
|
||||
|
||||
# 3) Firma del bundle .app principale (l'identifier corretto viene
|
||||
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", _BUNDLE_ID,
|
||||
"--timestamp=none",
|
||||
str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
|
||||
else:
|
||||
log("Codesign ad-hoc completato.")
|
||||
# Verifica
|
||||
|
||||
# 4) Verifica finale
|
||||
verify = subprocess.run(
|
||||
["codesign", "--verify", "--deep", "--strict", str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
@@ -334,7 +482,7 @@ def adhoc_codesign(app_path):
|
||||
if verify.returncode == 0:
|
||||
log("Verifica firma OK.")
|
||||
else:
|
||||
log(f"NOTA: verifica firma con warning: {verify.stderr.strip()}")
|
||||
log(f"NOTA verifica firma: {verify.stderr.strip()}")
|
||||
except FileNotFoundError:
|
||||
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.7.11"
|
||||
VERSION = "v1.7.15"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
|
||||
+31
-34
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
|
||||
# Directory dell'eseguibile
|
||||
exe_dir = Path(sys.executable).parent
|
||||
dirs.append(exe_dir)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
|
||||
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
|
||||
if not _IS_WINDOWS:
|
||||
frameworks = exe_dir.parent / "Frameworks"
|
||||
if frameworks.exists():
|
||||
dirs.append(frameworks)
|
||||
# Mini-bundle .app per i subprocess (vedi build_macos.py
|
||||
# wrap_subprocess_in_bundle).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||
if sub_macos.exists():
|
||||
dirs.append(sub_macos)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Trova la directory contenente ffmpeg e ffprobe.
|
||||
|
||||
Ordine di ricerca:
|
||||
1. Bundle PyInstaller
|
||||
2. Percorsi noti per OS
|
||||
3. Qualsiasi posizione nel PATH
|
||||
"""
|
||||
ffmpeg_name = _exe("ffmpeg")
|
||||
|
||||
# 1. Bundle
|
||||
def _find_binary(name: str) -> Optional[str]:
|
||||
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
|
||||
exe_name = _exe(name)
|
||||
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
|
||||
for d in _bundle_dirs():
|
||||
if (d / ffmpeg_name).exists():
|
||||
return str(d)
|
||||
|
||||
# 2. Percorsi noti
|
||||
candidate = d / exe_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
# 2. Percorsi noti per OS
|
||||
if _IS_WINDOWS:
|
||||
# Posizioni comuni su Windows
|
||||
for search in (
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
||||
Path("C:/ffmpeg/bin"),
|
||||
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
||||
):
|
||||
if (search / "ffmpeg.exe").exists():
|
||||
return str(search)
|
||||
cand = search / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
else:
|
||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||
if (Path(search_path) / "ffmpeg").exists():
|
||||
return search_path
|
||||
|
||||
cand = Path(search_path) / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
# 3. PATH generico
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
return str(Path(ffmpeg).parent)
|
||||
found = shutil.which(name)
|
||||
return found if found else None
|
||||
|
||||
return None
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
|
||||
ff = _find_binary("ffmpeg")
|
||||
return str(Path(ff).parent) if ff else None
|
||||
|
||||
|
||||
def find_ffmpeg() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffmpeg."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffmpeg"))
|
||||
return None
|
||||
return _find_binary("ffmpeg")
|
||||
|
||||
|
||||
def find_ffprobe() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffprobe."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffprobe"))
|
||||
return None
|
||||
return _find_binary("ffprobe")
|
||||
+341
-217
@@ -1,7 +1,10 @@
|
||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
||||
"""Registrazione audio.
|
||||
|
||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
||||
Windows: usa DirectShow
|
||||
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
|
||||
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
|
||||
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
|
||||
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
|
||||
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,38 +28,33 @@ _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
|
||||
_recording_lock = threading.Lock()
|
||||
_recording_state: dict = {}
|
||||
_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
|
||||
with _recording_lock:
|
||||
return bool(_recording_state.get("active"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
|
||||
|
||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
||||
Loopback, Audio Hijack, VB-Cable, ...)
|
||||
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
|
||||
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
|
||||
stderr di ffmpeg per la registrazione DirectShow.
|
||||
"""
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return []
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
|
||||
if _IS_MAC:
|
||||
return _list_macos(ffmpeg)
|
||||
if _IS_WIN:
|
||||
return _list_windows(ffmpeg)
|
||||
return []
|
||||
|
||||
def _log_diag(line: str) -> None:
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
if len(_stderr_buf) > 200:
|
||||
del _stderr_buf[0]
|
||||
|
||||
|
||||
_VIRTUAL_KEYWORDS = (
|
||||
@@ -71,54 +69,44 @@ def _is_virtual(name: str) -> bool:
|
||||
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.
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
|
||||
IMPORTANTE: come 'id' usiamo il NOME del device (non l'index numerico).
|
||||
AVFoundation rinumera silenziosamente i device quando cambia il set di
|
||||
hardware collegato (es. iPhone in continuita', Multi-Output Device, ecc).
|
||||
Il nome e' invece stabile; ffmpeg AVFoundation accetta sia ':<index>'
|
||||
sia ':<name>' come specifica del device input.
|
||||
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
|
||||
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
|
||||
Su Windows e' il nome del device (richiesto da DirectShow).
|
||||
"""
|
||||
if _IS_MAC:
|
||||
return _list_macos_avf()
|
||||
if _IS_WIN:
|
||||
ffmpeg = find_ffmpeg()
|
||||
return _list_windows(ffmpeg) if ffmpeg else []
|
||||
return []
|
||||
|
||||
|
||||
def _list_macos_avf() -> list[dict]:
|
||||
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
|
||||
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
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
try:
|
||||
name = str(d.localizedName())
|
||||
uid = str(d.uniqueID())
|
||||
except Exception:
|
||||
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": name, # nome stabile (vedi docstring)
|
||||
"index": idx, # solo informativo per debug
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"is_virtual": _is_virtual(name),
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
@@ -162,7 +150,6 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
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:
|
||||
@@ -174,88 +161,250 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione
|
||||
# Permesso microfono (macOS)
|
||||
# ============================================================
|
||||
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 _ensure_macos_mic_permission() -> tuple[bool, str]:
|
||||
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
|
||||
|
||||
|
||||
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": "..."})
|
||||
Ritorna (granted, reason).
|
||||
"""
|
||||
global _current_proc, _recording_thread
|
||||
try:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
return False, f"AVFoundation non disponibile: {e}"
|
||||
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
|
||||
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
|
||||
if status == 3:
|
||||
return True, "authorized"
|
||||
if status in (1, 2):
|
||||
return False, (
|
||||
"Permesso microfono negato.\n"
|
||||
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
"Attiva MusicTools e riavvia l'app."
|
||||
)
|
||||
# notDetermined -> chiedi il prompt in modo sincrono.
|
||||
ev = threading.Event()
|
||||
granted = [False]
|
||||
|
||||
def cb(ok):
|
||||
granted[0] = bool(ok)
|
||||
ev.set()
|
||||
|
||||
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
|
||||
# PyObjC pumpa il run loop automaticamente in attesa del callback.
|
||||
ev.wait(60)
|
||||
if not granted[0]:
|
||||
return False, "Permesso microfono negato dall'utente."
|
||||
return True, "granted"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione macOS via AVCaptureSession
|
||||
# ============================================================
|
||||
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
from AVFoundation import (
|
||||
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
|
||||
AVCaptureAudioFileOutput, AVMediaTypeAudio,
|
||||
)
|
||||
from Foundation import NSURL, NSObject
|
||||
|
||||
ok, reason = _ensure_macos_mic_permission()
|
||||
if not ok:
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
# Trova il device: prima per uniqueID, poi per nome (fallback)
|
||||
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
|
||||
if dev is None:
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
if str(d.localizedName()) == device_id:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return {"ok": False, "error": "Dispositivo audio non trovato."}
|
||||
|
||||
session = AVCaptureSession.alloc().init()
|
||||
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
|
||||
if err is not None:
|
||||
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
|
||||
if not session.canAddInput_(inp):
|
||||
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
|
||||
session.addInput_(inp)
|
||||
|
||||
out = AVCaptureAudioFileOutput.alloc().init()
|
||||
if not session.canAddOutput_(out):
|
||||
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
|
||||
session.addOutput_(out)
|
||||
|
||||
out_path = Path(output_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
caf_path = out_path.with_suffix(".caf")
|
||||
|
||||
# Delegate: aspetta che lo stop produca il file finale
|
||||
class _Delegate(NSObject):
|
||||
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
|
||||
self, output, url, conns):
|
||||
self.started = True
|
||||
|
||||
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
|
||||
self, output, url, conns, err):
|
||||
self.done = True
|
||||
self.err = err.localizedDescription() if err is not None else None
|
||||
|
||||
delegate = _Delegate.alloc().init()
|
||||
delegate.started = False
|
||||
delegate.done = False
|
||||
delegate.err = None
|
||||
|
||||
session.startRunning()
|
||||
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
|
||||
NSURL.fileURLWithPath_(str(caf_path)),
|
||||
"com.apple.coreaudio-format",
|
||||
delegate,
|
||||
)
|
||||
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"session": session,
|
||||
"output": out,
|
||||
"delegate": delegate,
|
||||
"caf_path": str(caf_path),
|
||||
"mp3_path": str(out_path),
|
||||
"bitrate": bitrate,
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out_path)})
|
||||
|
||||
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
|
||||
threading.Thread(target=_tick_worker, daemon=True).start()
|
||||
|
||||
_log_diag(f"[avf] Recording started -> {caf_path}")
|
||||
return {"ok": True, "output_path": str(out_path)}
|
||||
|
||||
|
||||
def _tick_worker():
|
||||
while is_recording():
|
||||
with _recording_lock:
|
||||
cb = _recording_state.get("progress_cb")
|
||||
started_at = _recording_state.get("started_at", time.monotonic())
|
||||
elapsed = int(time.monotonic() - started_at)
|
||||
if cb:
|
||||
cb("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _stop_recording_macos() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
state = dict(_recording_state)
|
||||
_recording_state["active"] = False
|
||||
|
||||
session = state["session"]
|
||||
out = state["output"]
|
||||
delegate = state["delegate"]
|
||||
caf_path = Path(state["caf_path"])
|
||||
mp3_path = Path(state["mp3_path"])
|
||||
bitrate = state["bitrate"]
|
||||
cb = state["progress_cb"]
|
||||
started_at = state["started_at"]
|
||||
|
||||
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
|
||||
out.stopRecording()
|
||||
# Aspetta il completamento del file
|
||||
deadline = time.time() + 8
|
||||
while not getattr(delegate, "done", False) and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
session.stopRunning()
|
||||
|
||||
if getattr(delegate, "err", None):
|
||||
_log_diag(f"[avf] delegate error: {delegate.err}")
|
||||
|
||||
seconds = int(time.monotonic() - started_at)
|
||||
|
||||
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
|
||||
if caf_path.exists():
|
||||
ffmpeg = find_ffmpeg()
|
||||
if ffmpeg:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
|
||||
"-acodec", "libmp3lame", "-b:a", bitrate,
|
||||
"-vn", str(mp3_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
|
||||
for ln in (proc.stderr or "").splitlines()[-10:]:
|
||||
_log_diag(ln)
|
||||
# Cleanup CAF temporaneo
|
||||
try:
|
||||
caf_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log_diag(f"[ffmpeg-convert] exception: {e}")
|
||||
else:
|
||||
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
|
||||
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
|
||||
# ma l'utente ha qualcosa)
|
||||
try:
|
||||
caf_path.rename(mp3_path.with_suffix(".caf"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if cb:
|
||||
if mp3_path.exists():
|
||||
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
|
||||
else:
|
||||
cb("error", {"error": "File registrato non trovato dopo lo stop."})
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione Windows via ffmpeg DirectShow (invariato)
|
||||
# ============================================================
|
||||
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
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 = [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
str(out),
|
||||
]
|
||||
|
||||
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."""
|
||||
def _drain(proc):
|
||||
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]
|
||||
_log_diag(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
global _current_proc
|
||||
try:
|
||||
with _proc_lock:
|
||||
_current_proc = subprocess.Popen(
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -263,41 +412,44 @@ def start_recording(
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"proc": proc,
|
||||
"output_path": str(out),
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
|
||||
|
||||
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:
|
||||
if 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 not opened and proc.poll() is not None:
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out)})
|
||||
|
||||
while True:
|
||||
if _stop_event.is_set():
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
break
|
||||
rc = _current_proc.poll()
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
@@ -305,123 +457,95 @@ def start_recording(
|
||||
progress_callback("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
# Stop gracefully
|
||||
if _current_proc.poll() is None:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
_current_proc.stdin.write(b"q\n")
|
||||
_current_proc.stdin.flush()
|
||||
proc.stdin.write(b"q\n")
|
||||
proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_current_proc.wait(timeout=5)
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.terminate()
|
||||
proc.terminate()
|
||||
try:
|
||||
_current_proc.wait(timeout=3)
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.kill()
|
||||
proc.kill()
|
||||
|
||||
rc = _current_proc.returncode
|
||||
seconds = int(time.monotonic() - start)
|
||||
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
|
||||
if progress_callback:
|
||||
if rc == 0 or out.exists():
|
||||
if proc.returncode == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
except Exception as e:
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": str(e)})
|
||||
|
||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
||||
_recording_thread.start()
|
||||
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).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, clock drift di BlackHole, ecc.
|
||||
|
||||
Fallback: se non riconosce il pattern, ritorna le ultime righe
|
||||
significative dello stderr cosi' l'utente puo' diagnosticare
|
||||
o aprire un ticket.
|
||||
"""
|
||||
def _extract_friendly_error_windows(rc: int) -> str:
|
||||
with _stderr_lock:
|
||||
lines = list(_stderr_buf)
|
||||
|
||||
text = "\n".join(lines).lower()
|
||||
is_mac = sys.platform == "darwin"
|
||||
|
||||
# ---- Errori specifici (priorita' alta) ----
|
||||
|
||||
if "input/output error" in text or "errno 22" in text:
|
||||
return (
|
||||
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
||||
"Spesso si risolve cosi':\n"
|
||||
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
||||
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
||||
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
||||
)
|
||||
|
||||
if "device not configured" in text or "device not available" in text:
|
||||
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
||||
|
||||
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
||||
or "tcc" in text or "denied" in text:
|
||||
return _permission_message()
|
||||
|
||||
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
|
||||
|
||||
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
|
||||
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
|
||||
# dovuto al permesso Microfono mancante. La parola 'abort' non
|
||||
# sempre compare nello stderr di ffmpeg recenti.
|
||||
if rc == -6 and is_mac:
|
||||
return _permission_message()
|
||||
|
||||
# ---- Fallback: cerca le ultime righe significative ----
|
||||
if "no such device" in text or "could not find" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
|
||||
if "permission" in text or "denied" in text:
|
||||
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
|
||||
for line in reversed(lines):
|
||||
l = line.strip()
|
||||
if not l:
|
||||
continue
|
||||
low = l.lower()
|
||||
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
||||
if "error" in low or "fail" in low or "denied" in low:
|
||||
return l
|
||||
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
||||
return l
|
||||
|
||||
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||
|
||||
|
||||
def _permission_message() -> str:
|
||||
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
||||
return (
|
||||
"Permesso Microfono mancante. Su macOS:\n"
|
||||
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
||||
" (se usi l'app installata). Poi riavvia l'app."
|
||||
)
|
||||
def _stop_recording_windows() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
|
||||
_recording_state["active"] = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di stderr ai fini diagnostici.
|
||||
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
_stderr_buf.clear()
|
||||
|
||||
if _IS_MAC:
|
||||
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
|
||||
if _IS_WIN:
|
||||
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
|
||||
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
|
||||
|
||||
|
||||
def stop_recording() -> dict:
|
||||
"""Ferma la registrazione corrente (se attiva)."""
|
||||
_stop_event.set()
|
||||
if _IS_MAC:
|
||||
return _stop_recording_macos()
|
||||
if _IS_WIN:
|
||||
return _stop_recording_windows()
|
||||
return {"ok": True}
|
||||
@@ -5,5 +5,6 @@ mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
Reference in new issue
Block a user