Deep-dive · Voice Pipeline cross-device¶
Versione librerie: maggio 2026 Target latenza end-to-end: < 1.5 secondi Phase: 2 (tracker)
Questa pagina è il deep-dive tecnico della voice pipeline di Open-Jarvis. Per la vista user-facing vedi Funzionalità voce.
1. Latency budget end-to-end¶
La conversazione naturale richiede una finestra di risposta di 300-500ms per non sembrare "lenta". Per Jarvis, con pipeline distribuita multi-device, il target è < 1.5s di latenza totale percepita dall'utente.
| Fase | Budget | Cumulativo |
|---|---|---|
| Wake-word detection | < 50 ms | 50 ms |
| VAD + audio capture (buffering) | < 100 ms | 150 ms |
| STT streaming (first token) | < 400 ms | 550 ms |
| LLM inference (TTFT) | < 700 ms | 1250 ms |
| TTS first audio chunk | < 150 ms | 1400 ms |
| Network overhead (BLE/WiFi) | < 100 ms | 1500 ms |
Edge cases e degradazione graceful¶
Rete lenta (RTT > 200ms): wake-word rimane on-device, STT commuta su Vosk locale (vosk-model-small-it-0.22, ~50MB), LLM cade su Llama 3.2 3B Q4_K_M via llama.cpp, TTS resta su Piper locale.
GPU offload assente: faster-whisper base.en con int8 (RTF ~0.04 su CPU 8-core). Per LLM: quantizzazione Q4_K_M con TTFT ~900ms.
Smartwatch standalone: pipeline completamente on-device con microWakeWord o Porcupine on-device, comandi predefiniti, risposte precompilate.
2. Wake-word detection¶
2.1 Picovoice Porcupine 3.x¶
Motore wake-word maturo per embedded. SDK v3 (mag 2026) supporta Python, Android, iOS, Web (WASM), C, .NET, Flutter, React Native.
Caratteristiche:
- Latenza < 50ms (tipicamente 20-30ms)
- Consumo ~2-5% CPU su ARM Cortex-M7 a 216 MHz
- Supporto MCU (STM32F7, nRF52840, ESP32-S3 via C SDK)
- Wear OS: gira in
ForegroundServicepersistente - Custom wake-word via Picovoice Console (25 registrazioni da 5 speaker, training ~5 min)
# requirements: pvporcupine==3.0.2, pvrecorder==1.2.3
import pvporcupine
import pvrecorder
import struct
from collections import deque
from pathlib import Path
class WakeWordDetector:
"""Detector Porcupine con buffer circolare pre-roll (250ms)."""
SENSITIVITY = 0.6
PRE_ROLL_FRAMES = 8
def __init__(self, access_key: str, keyword_path: Path | None = None):
self._porcupine = pvporcupine.create(
access_key=access_key,
keywords=["jarvis"] if keyword_path is None else [],
keyword_paths=None if keyword_path is None else [str(keyword_path)],
sensitivities=[self.SENSITIVITY],
)
self._recorder = pvrecorder.PvRecorder(
frame_length=self._porcupine.frame_length, device_index=-1
)
self._pre_roll: deque[bytes] = deque(maxlen=self.PRE_ROLL_FRAMES)
def listen_once(self) -> list[bytes]:
"""Blocca fino al wake-word. Restituisce i frame pre-roll."""
self._recorder.start()
try:
while True:
pcm_frame = self._recorder.read()
self._pre_roll.append(struct.pack("h" * len(pcm_frame), *pcm_frame))
if self._porcupine.process(pcm_frame) >= 0:
return list(self._pre_roll)
finally:
self._recorder.stop()
Power consumption:
- Wear OS (Snapdragon W5+ Gen 1) Porcupine in ForegroundService: ~3-5 mA in ascolto continuo. Con batteria 300mAh, durata ~60-80h.
- Strategia "listen-on-wrist-raise": mic attivo solo dopo gesto rilevato dall'accelerometro (consumo medio < 0.5 mA).
- Brilliant Frame (nRF52840): interrupt hardware ADC sveglia processore solo sopra soglia acustica (< 1 mA standby).
2.2 openWakeWord 0.6.x¶
Alternativa open source (Apache 2.0). Backbone condiviso Google + classificatore leggero su sintesi TTS.
Training pipeline (CoreWorxLab/openwakeword-training) genera modelli ONNX ~200KB. Custom wake-word in ~30 min su CPU con 1000 esempi sintetici Piper.
Limite: tasso falsi positivi ~3x rispetto a Porcupine in ambiente rumoroso.
2.3 microWakeWord per ESP32-S3¶
Per dispositivi ESP32-S3 con ESPHome ≥ 2024.7. Modelli TFLite INT8, fino a 4 simultanei con PSRAM. Inferenza 10ms.
Use case Jarvis: satellite hardware low-cost (ESP32-S3 + MEMS I2S + speaker) per ogni stanza, trasmette audio raw via WebSocket WiFi al server principale.
3. Speech-to-Text¶
3.1 faster-whisper 1.1.x (CTranslate2 4.x)¶
Reimplementa Whisper via CTranslate2: speedup 4-8x e minore VRAM rispetto all'originale.
Benchmark RTX 3090, mag 2026:
| Modello | VRAM | WER en | RTF GPU | RTF CPU int8 |
|---|---|---|---|---|
tiny.en | ~1GB | ~12% | 0.006 | 0.03 |
base.en | ~1GB | ~8% | 0.009 | 0.04 |
medium.en | ~5GB | ~4% | 0.02 | 0.15 |
large-v3-turbo | ~6GB | ~2.5% | 0.03 | N/A |
distil-large-v3 | ~3GB | ~3% | 0.02 | 0.12 |
Profilo Jarvis raccomandato: distil-large-v3 con int8_float16 su GPU. Segmenti da 5s trascritti in ~100-150ms.
# requirements: faster-whisper==1.1.0, silero-vad==5.1.2, websockets==12.0
import asyncio
import io
import websockets
import numpy as np
import soundfile as sf
import torch
from collections import deque
from faster_whisper import WhisperModel
_vad_model, _vad_utils = torch.hub.load(
repo_or_dir="snakers4/silero-vad", model="silero_vad", onnx=False
)
(_get_speech_timestamps, *_) = _vad_utils
SAMPLE_RATE = 16_000
CHUNK_SAMPLES = int(SAMPLE_RATE * 0.5)
SILENCE_THRESHOLD_S = 1.2
_whisper_model = WhisperModel(
"distil-large-v3", device="cuda", compute_type="int8_float16"
)
async def stream_transcribe(websocket) -> None:
"""Riceve chunk PCM float32 16kHz, ritorna trascrizioni parziali real-time."""
audio_buffer: deque[np.ndarray] = deque()
silence_frames = 0
max_silence = int(SILENCE_THRESHOLD_S / 0.5)
async for message in websocket:
chunk = np.frombuffer(message, dtype=np.float32)
audio_buffer.append(chunk)
speech_ts = _get_speech_timestamps(
chunk, _vad_model, sampling_rate=SAMPLE_RATE, threshold=0.5
)
silence_frames = 0 if speech_ts else silence_frames + 1
accumulated = np.concatenate(list(audio_buffer))
if len(accumulated) >= SAMPLE_RATE * 1.5 or silence_frames >= max_silence:
buf = io.BytesIO()
sf.write(buf, accumulated, SAMPLE_RATE, format="WAV")
buf.seek(0)
segments, _ = _whisper_model.transcribe(
buf, beam_size=1, language="it", vad_filter=False
)
transcript = " ".join(seg.text.strip() for seg in segments)
if transcript:
await websocket.send(transcript)
if silence_frames >= max_silence:
audio_buffer.clear()
silence_frames = 0
await websocket.send("__END_OF_UTTERANCE__")
async def main() -> None:
async with websockets.serve(stream_transcribe, "0.0.0.0", 8765):
await asyncio.Future()
3.2 Vosk 0.3.45 — fallback offline¶
Modelli small italiano (vosk-model-small-it-0.22) ~50MB, RTF < 0.05 su Raspberry Pi 4. Attivato automaticamente se latency check (ping STT > 500ms) rileva rete degradata.
3.3 Coqui STT — deprecato¶
Coqui AI ha cessato gennaio 2024. Non usare per nuovi progetti. Alternative: Vosk (lightweight kaldi) o faster-whisper (qualità superiore).
4. Text-to-Speech¶
4.1 Piper (Open Home Foundation) 1.2.x¶
TTS locale raccomandato. Architettura VITS, modelli ONNX, 35+ lingue.
| Qualità | Sample rate | First-chunk latency | Modello |
|---|---|---|---|
x_low | 16kHz | 20-30ms | ~5MB |
medium | 22.05kHz | 60-80ms | ~60MB |
high | 22.05kHz | 120-150ms | ~120MB |
Profilo Jarvis IT: it_IT-paola-medium o it_IT-riccardo-x_low.
# requirements: piper-tts==1.2.0 (OHF fork), sounddevice==0.4.7
import numpy as np
import sounddevice as sd
from piper.voice import PiperVoice
from pathlib import Path
class PiperStreamer:
"""TTS streaming: emette chunk audio mentre il modello genera."""
SAMPLE_RATE = 22_050
CHUNK_FRAMES = 1_024 # ~46ms
def __init__(self, model_path: Path, config_path: Path):
self._voice = PiperVoice.load(
str(model_path), config_path=str(config_path), use_cuda=False
)
self._stream = sd.OutputStream(
samplerate=self.SAMPLE_RATE, channels=1, dtype="int16"
)
async def speak(self, text: str) -> None:
self._stream.start()
try:
buf = np.array([], dtype=np.int16)
for audio_bytes in self._voice.synthesize_stream_raw(text):
chunk = np.frombuffer(audio_bytes, dtype=np.int16)
buf = np.concatenate([buf, chunk])
while len(buf) >= self.CHUNK_FRAMES:
self._stream.write(buf[: self.CHUNK_FRAMES])
buf = buf[self.CHUNK_FRAMES :]
if len(buf):
self._stream.write(buf)
finally:
self._stream.stop()
4.2 Kokoro 82M (2025)¶
Modello TTS open-weight (Apache 2.0) da 82M parametri. Sintetizza in 40-70ms su GPU (RTX 3090: ~210x real-time). Qualità superiore a Piper a paragonabile latenza. Preferibile per server con GPU.
4.3 XTTS-v2 (Coqui community fork)¶
Voice cloning con 3-6s di audio reference. Latency ~300-500ms su GPU. Inadatto al real-time primario, utile per personalizzare la voce di Jarvis.
4.4 Voice cloning ethics¶
Policy in Jarvis:
- Solo il proprietario dell'istanza può clonare la propria voce
- Modelli clonati cifrati con chiave derivata dall'identità utente
- Le voci clonate non vengono mai usate per impersonare terzi
- Vedi linee guida EU AI Act (in vigore dal 2025) sull'audio sintetico
5. Orchestrazione della pipeline¶
5.1 Architettura event-driven¶
mic → [WakeWordDetector] --event:wake_detected-->
[VAD + AudioCapture] --event:utterance_ready-->
[STTWorker] --event:transcript_ready-->
[LLMWorker] --event:response_chunk-->
[TTSWorker] --event:audio_chunk-->
speaker
# Event bus asyncio in-process
import asyncio
from dataclasses import dataclass
from typing import Any, Callable, Awaitable
@dataclass
class Event:
topic: str
payload: Any
source: str = ""
EventHandler = Callable[[Event], Awaitable[None]]
class EventBus:
def __init__(self) -> None:
self._subscribers: dict[str, list[EventHandler]] = {}
def subscribe(self, topic: str, handler: EventHandler) -> None:
self._subscribers.setdefault(topic, []).append(handler)
async def publish(self, event: Event) -> None:
handlers = self._subscribers.get(event.topic, [])
await asyncio.gather(*(h(event) for h in handlers))
5.2 Interruption handling (barge-in)¶
Il barge-in è la capacità di interrompere Jarvis mentre parla.
Requisiti:
- Echo cancellation (AEC): il microfono non deve "sentire" l'audio dello speaker. Linux:
pulseaudio+module-echo-cancel(WebRTC AEC3). Embedded: chip MEMS con AEC HW (ES8388, INMP441+DSP). - VAD durante riproduzione: Silero continua a girare, dopo AEC se rileva parlato → emette
barge_in_detected. - Cancellazione immediata: il TTSWorker stops, svuota buffer LLM, riavvia da VAD.
class InterruptibleTTSWorker:
def __init__(self, bus: EventBus, streamer: PiperStreamer) -> None:
self._bus = bus
self._streamer = streamer
self._playing = asyncio.Event()
self._interrupted = asyncio.Event()
bus.subscribe("barge_in_detected", self._on_barge_in)
async def _on_barge_in(self, event: Event) -> None:
if self._playing.is_set():
self._interrupted.set()
async def speak(self, text: str) -> None:
self._playing.set()
self._interrupted.clear()
speak_task = asyncio.create_task(self._streamer.speak(text))
interrupt_task = asyncio.create_task(self._interrupted.wait())
_, pending = await asyncio.wait(
{speak_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
self._playing.clear()
5.3 Multi-speaker detection¶
Per ambienti con più persone: pyannote.audio 4.0 (pyannote/speaker-diarization-community-1).
Pipeline real-time:
- Segmenta flusso in finestre 2s overlap 50%
- Estrae embedding ECAPA-TDNN
- Compara con vettore voce proprietario (Qdrant)
- Similarità coseno > 0.82 → processa; altrimenti ignora
Latenza diarizzazione real-time: 80-150ms per segmento.
6. Edge deployment¶
6.1 Architettura on-device + offload¶
┌──────────────────── EDGE (watch/glasses) ────────────────────┐
│ [mic] → [microWakeWord/Porcupine on-device] │
│ wake → [AEC HW] → [Silero VAD ONNX] → [PCM buffer] │
│ utterance ready → [BLE/WiFi WebSocket] ──────────────► │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────── SERVER ─────────────────────────────┐
│ [WS recv] → [STT faster-whisper] → [LLM] → [TTS Kokoro] │
│ → [WS send audio chunks] ─────────────────────────────► │
└─────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────── EDGE (watch/glasses) ────────────────────┐
│ [WS recv] → [speaker / haptic] │
└──────────────────────────────────────────────────────────────┘
6.2 BLE Frame audio offload protocol¶
Quando WiFi non disponibile (es. Brilliant Frame BLE-only):
Frame BLE Audio (max 244 bytes per MTU BT 5.2):
┌────────┬──────────┬────────┬────────────────────────┐
│ 1 byte │ 2 bytes │ 1 byte │ fino a 240 bytes │
│ type │ seq_num │ flags │ payload (PCM mu-law) │
└────────┴──────────┴────────┴────────────────────────┘
type: 0x01 audio chunk · 0x02 end_of_utterance · 0x03 cancel
flags: bit0 is_last_frame · bit1 vad_active
Audio compresso mu-law (G.711) 8kHz mono → 64kbps, compatibile con BLE 2M PHY (~1.4 Mbps). Server decomprime e upsampling a 16kHz prima dello STT.
6.3 Power-aware mic management¶
class PowerAwareMicController:
INACTIVITY_TIMEOUT_S = 30.0
WRIST_RAISE_THRESHOLD_G = 0.8
async def run_power_loop(self) -> None:
last_activity = time.monotonic()
mic_active = False
async for sensor_event in self._sensor_stream():
if sensor_event.type == "wrist_raise" and not mic_active:
await self._activate_mic()
mic_active = True
if sensor_event.type in ("wrist_raise", "wake_word_detected"):
last_activity = time.monotonic()
if mic_active and (time.monotonic() - last_activity) > self.INACTIVITY_TIMEOUT_S:
await self._deactivate_mic()
mic_active = False
6.4 Wear OS Tile per controllo vocale¶
Tile con tre pulsanti:
- "Ascolta" → attiva mic e bypassa wake-word
- "Muto" → silenzia Jarvis per N minuti
- "Stato" → latenza last-request + modalità (online/offline)
API Tiles 1.4 (Wear OS 4.0+). Update ogni 30 min via TileService.getUpdater(). Comunicazione con WatchAgent via ChannelClient (Wearable Data Layer API).
7. Stack finale (mag 2026)¶
| Componente | Libreria · Versione | Licenza |
|---|---|---|
| Wake-word server | pvporcupine 3.0.2 / openWakeWord 0.6.3 | Comm. / Apache 2.0 |
| Wake-word ESP32-S3 | microWakeWord (ESPHome 2025.6) | MIT |
| VAD | silero-vad 5.1.2 | MIT |
| STT primario | faster-whisper 1.1.0 + CTranslate2 4.4.0 | MIT |
| STT fallback | vosk 0.3.45 | Apache 2.0 |
| TTS primario | piper-tts 1.2.0 (OHF fork) | GPL 3.0 |
| TTS GPU | kokoro 82M | Apache 2.0 |
| TTS voice clone | coqui-ai-TTS 0.25.x (idiap fork XTTS-v2) | MPL 2.0 |
| Diarizzazione | pyannote-audio 4.0 + community-1 | MIT |
| Audio I/O | pvrecorder 1.2.3 / sounddevice 0.4.7 | Apache / BSD |
| WebSocket server | websockets 12.0 | BSD |
| Event bus | asyncio stdlib / redis-py 5.2.0 | MIT |
8. Note implementative per Jarvis¶
Il voice-agent è strutturato come worker asincroni indipendenti su EventBus. Separazione modulare (wake-word, VAD, STT, LLM, TTS) → sostituire un componente non tocca il resto. Esempio: passare da Piper a Kokoro è solo sostituzione TTSWorker.
Il VoiceAgent orchestratore leggero: registra worker, gestisce stati FSM (IDLE → LISTENING → PROCESSING → SPEAKING), coordina interruption.
Protocollo edge↔server: WebSocket cifrato TLS 1.3, autenticato con device token emesso da Identity Layer.