Skip to Content
Mélodium 0.10.1 is now available!
DocsExemplesAssistant vocal temps réel

Assistant vocal temps réel

Source: 11_realtime_voice_assistant

Un assistant vocal qui transcrit l’audio du microphone avec Whisper local et envoie chaque segment à un modèle de langage pour obtenir une réponse. Deux points d’entrée permettent de choisir entre un LLM distant (tokens en flux) ou un Mistral 7B entièrement local (pas de clé d’API).

Exécution

Avec un LLM distant (GPT-4o) :

melodium run 11_realtime_voice_assistant/Compo.toml --openai_key sk-...
Note

openai_key est une clé d’API OpenAI.

Entièrement local (pas de clé d’API, nécessite ~14 Go de RAM) :

melodium run 11_realtime_voice_assistant/Compo.toml localonly
[…] info: assistant: ready, speak into the microphone […] info: you: What time is it in Tokyo? […] info: assistant: Tokyo est en heure standard du Japon (JST), soit UTC+9…

Fonctionnement

Les deux points d’entrée partagent la même séquence de chargement Whisper et les mêmes modèles :

model WhisperHub() : HfHub { repo_id = "openai/whisper-tiny" } model Asr() : Whisper {}

La différence réside uniquement dans le backend LLM utilisé en aval.

main : Whisper local + LLM distant

Le LLM distant est un modèle RemoteLlm configuré pour GPT-4o avec un system prompt court et concis :

model RemoteAssistant(const openai_key: string) : RemoteLlm { backend = "openai" api_key = |wrap<string>(openai_key) base_url = "" model = "gpt-4o" system = "You are a concise voice assistant. Answer in plain text, one short paragraph." max_tokens = |wrap<u64>(256) temperature = |wrap<f32>(0.6) top_p = _ timeout = _ }

main récupère et charge Whisper en premier, puis ne démarre l’enregistrement qu’une fois le chargement terminé :

treatment main(const openai_key: string) model whisperHub: WhisperHub() model asr: Asr() model llm: RemoteAssistant(openai_key=openai_key) { fetchAsr: fetch[hub=whisperHub]() loadAsr: loadWhisper[whisper=asr]() startup.trigger -> fetchAsr.trigger fetchAsr.safetensors -> loadAsr.safetensors fetchAsr.tokenizer -> loadAsr.tokenizer record: recordMono(device=_, sample_rate=_) asrDecode: decode[whisper=asr]() loadAsr.loaded -> record.trigger loadAsr.loaded -> asrDecode.ready record.signal -> asrDecode.audio }

main treatment diagram Voir dans Compositeur Studio

Chaque segment transcrit est transmis en fan-out à deux consommateurs simultanément :

asrDecode.transcribed -> logQuestion.messages asrDecode.transcribed -> remoteAnswer.question

remoteAnswer[llm: RemoteLlm] enveloppe la question dans "[Question] {q}" et appelle llmStream, qui émet les tokens un par un en tant que Stream<string>, affichés dans le journal en temps réel sans attendre la réponse complète :

treatment remoteAnswer[llm: RemoteLlm]() input question: Stream<string> output tokens: Stream<string> { wrapEntry: entry(key="q") fmt: format(format="[Question] {q}") llmStream[llm=llm]() Self.question -> wrapEntry.value,map -> fmt.entries,formatted -> llmStream.prompt,token -> Self.tokens }

localonly : Whisper local + Mistral local

Le point d’entrée localOnly ajoute deux modèles supplémentaires pour un backend Mistral 7B entièrement local :

model MistralHub() : HfHub { repo_id = "mistralai/Mistral-7B-v0.1" } model LocalLlm() : Mistral { temperature = 0.7 top_p = 0.9 max_new_tokens = 200 }

Whisper et Mistral sont récupérés en parallèle depuis startup, et se chargent indépendamment :

treatment localOnly() model whisperHub: WhisperHub() model mistralHub: MistralHub() model asr: Asr() model llm: LocalLlm() { fetchAsr: fetch[hub=whisperHub]() fetchLlm: fetch[hub=mistralHub]() loadAsr: loadWhisper[whisper=asr]() loadLlm: loadMistral[mistral=llm]() startup.trigger -> fetchAsr.trigger startup.trigger -> fetchLlm.trigger fetchAsr.safetensors -> loadAsr.safetensors fetchAsr.tokenizer -> loadAsr.tokenizer fetchLlm.safetensors -> loadLlm.safetensors fetchLlm.tokenizer -> loadLlm.tokenizer }

localOnly treatment diagram Voir dans Compositeur Studio

L’enregistrement démarre dès que Whisper est chargé (loadAsr.loaded), ce qui peut arriver avant que Mistral ait fini de charger ; chaque segment transcrit est tout de même transmis à localAnswer[llm: Mistral], qui le formate dans le style d’instruction de Mistral et appelle generate au lieu de llmStream :

treatment localAnswer[llm: Mistral]() input question: Stream<string> output tokens: Stream<string> { wrapEntry: entry(key="q") fmt: format(format="[INST] {q} [/INST]") generate[mistral=llm]() Self.question -> wrapEntry.value,map -> fmt.entries,formatted -> generate.prompt,generated -> Self.tokens }

Interface partagée, backends différents

remoteAnswer et localAnswer exposent tous deux la même interface question: Stream<string> en entrée / tokens: Stream<string> en sortie, ce qui rend la logique de fan-out et de journalisation identique dans chaque point d’entrée. Le point d’entrée ne sait pas et n’a pas besoin de savoir lequel il appelle ; changer de backend est purement une préoccupation au niveau du modèle.

Dépendances

[dependencies] std = "0.10.1" # flux de base, journalisation, structures de données audio = "0.10.1" # décodage / encodage / rééchantillonnage audio record = "0.10.1" # capture microphone ml = "0.10.1" # inférence LLM, STT, TTS et modèles locaux