From 54895586c87e9b3b6cf737047a1529e95a40a0f4 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 6 Oct 2026 15:31:19 +0200 Subject: [PATCH] Voce Qwen3-TTS in streaming (parte in ~0,5 s) con il modello Base a 8 bit Co-Authored-By: Claude Opus 5.5 --- avatar/assistant.py | 17 +++++++++++++++++ avatar/settings.py | 3 ++- avatar/tts.py | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/avatar/assistant.py b/avatar/assistant.py index f61e19b..6463bf7 100644 --- a/avatar/assistant.py +++ b/avatar/assistant.py @@ -429,6 +429,23 @@ class Assistant: if item is END: self._audio_q.put((turn, END, None)) continue + if hasattr(self.voice, "stream"): # voce in streaming: i pezzi partono mentre la frase è ancora in generazione + try: + first = True + for chunk in self.voice.stream(item, cancel=lambda t=turn: t != self._turn): + if turn != self._turn: + break + remote = getattr(self, "remote", None) + if remote is not None and remote.has_clients(): + try: + remote.on_audio(item if first else "", chunk) + except Exception: + pass + self._audio_q.put((turn, item if first else "", chunk)) + first = False + except Exception as err: + self.ui.write_log(f"ERR: Sintesi vocale — {err}") + continue try: audio = self.voice.synthesize(item) except Exception as err: diff --git a/avatar/settings.py b/avatar/settings.py index 7324fb4..c3619ad 100644 --- a/avatar/settings.py +++ b/avatar/settings.py @@ -57,7 +57,8 @@ DEFAULTS: dict[str, Any] = { "abitudini_enabled": True, # distillazione settimanale delle abitudini nella memoria "umore_enabled": False, # stima oraria dell'umore dai messaggi scritti dall'utente "io_nomi": "Luciano", # come compare l'utente come mittente nelle chat esportate - "qwen_voce": "luza_voce", # voce clonata (file in data/voices) per il motore Qwen3-TTS + "qwen_voce": "luza_voce", + "qwen_modello": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit", # oppure ...-Base-bf16 (più lento, qualità piena) # voce clonata (file in data/voices) per il motore Qwen3-TTS "ipixel_enabled": False, # pannello LED iPIXEL via Bluetooth "ipixel_address": "", # identificativo BLE (rilevato da solo) "ipixel_luminosita": 40, diff --git a/avatar/tts.py b/avatar/tts.py index 17b5c97..7137ae7 100644 --- a/avatar/tts.py +++ b/avatar/tts.py @@ -374,12 +374,13 @@ class QwenVoice: """Qwen3-TTS dentro LuZa (mlx-audio), voce clonata da un campione + trascrizione. Modello e generazione girano sempre sullo stesso thread: MLX non condivide gli stream GPU tra thread.""" - MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" + MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit" # 8 bit: ~1,7× più veloce del bf16, qualità quasi uguale - def __init__(self, ref_audio: str, ref_text: str, on_status=None) -> None: + def __init__(self, ref_audio: str, ref_text: str, on_status=None, model: str = "") -> None: from concurrent.futures import ThreadPoolExecutor self.ref_audio, self.ref_text = ref_audio, ref_text - self.voice = f"qwen:{ref_audio}" + self.model_name = model or self.MODEL + self.voice = f"qwen:{self.model_name}:{ref_audio}" self._on_status = on_status or (lambda m: None) self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="qwen-tts") self._model = None @@ -387,7 +388,7 @@ class QwenVoice: def _load(self) -> None: if self._model is None: from mlx_audio.tts.utils import load_model - self._model = load_model(self.MODEL) + self._model = load_model(self.model_name) def load(self) -> None: from pathlib import Path as _P @@ -408,6 +409,32 @@ class QwenVoice: def synthesize(self, text: str) -> np.ndarray: return self._pool.submit(self._gen, clean_for_speech(text)).result() + def stream(self, text: str, cancel=None): + """Generatore di pezzi d'audio (~0,6 s) mentre la frase viene ancora generata: la voce parte dopo circa un secondo.""" + import queue + q: queue.Queue = queue.Queue() + + def work(): + try: + self._load() + for r in self._model.generate(clean_for_speech(text), ref_audio=self.ref_audio, ref_text=self.ref_text, + lang_code="italian", stream=True, streaming_interval=0.6): + if cancel is not None and cancel(): + break + q.put(np.array(r.audio, dtype=np.float32)) + except Exception as err: + q.put(err) + q.put(None) + + self._pool.submit(work) + while True: + item = q.get() + if item is None: + return + if isinstance(item, Exception): + raise item + yield item + def make_voice(settings, on_status=None): if settings.get("tts_engine") == "qwen": @@ -416,7 +443,8 @@ def make_voice(settings, on_status=None): if not ref.exists() and voices: ref = VOICES_DIR / f"{voices[0]}.wav" txt = ref.with_suffix(".txt") - return QwenVoice(str(ref), txt.read_text(encoding="utf-8") if txt.exists() else "", on_status=on_status) + return QwenVoice(str(ref), txt.read_text(encoding="utf-8") if txt.exists() else "", on_status=on_status, + model=str(settings.get("qwen_modello") or "")) if settings.get("tts_engine") == "voicebox": return VoiceboxVoice(str(settings.get("voicebox_profile_id") or ""), str(settings.get("voicebox_engine") or "qwen"), str(settings.get("voicebox_instruct") or ""), on_status=on_status)