Aller au contenu principal

Voix en temps réel

La voix en temps réel transforme un Agent Mastra en appel en direct auquel un utilisateur peut participer, dans le navigateur ou par téléphone. Mastra s’appuie sur LiveKit, une plateforme WebRTC open source pour l’audio et la vidéo en temps réel.

Le package @mastra/livekit connecte les Agents Mastra au framework LiveKit Agents : LiveKit gère la boucle audio, notamment la détection d’activité vocale, la transcription en continu, la détection sémantique des tours de parole, l’interruption et la synthèse vocale. Votre Agent Mastra génère chaque réponse avec son propre modèle, ses Tools et sa mémoire.

Utilisez la voix en temps réel lorsque vous avez besoin de conversations vocales interrompables et à faible latence. Pour de la parole à parole fondée sur un fournisseur sans LiveKit, consultez Parole à parole.

Démarrage rapide
Lien direct vers Démarrage rapide

Ces étapes vous font passer d’un projet vide à un Agent vocal avec lequel vous pouvez parler. Une session vocale comprend deux éléments à configurer ici : une route API sur votre serveur Mastra qui fournit les jetons d’accès, et un processus worker distinct qui exécute le pipeline audio et appelle votre Agent à chaque tour.

  1. Installez le package d’intégration ainsi que les plugins LiveKit de détection d’activité vocale et de détection des tours de parole :

    npm install @mastra/livekit @livekit/agents @livekit/agents-plugin-silero @livekit/agents-plugin-livekit
  2. Définissez vos identifiants LiveKit dans un fichier .env. Créez un projet gratuit sur LiveKit Cloud, ou exécutez un serveur local avec livekit-server --dev :

    .env
    LIVEKIT_URL=wss://your-project.livekit.cloud
    LIVEKIT_API_KEY=your-api-key
    LIVEKIT_API_SECRET=your-api-secret
  3. Ajoutez un Agent vocal à votre instance Mastra et exposez une route de connexion. L’assistant liveKitConnectionRoute() ajoute un point de terminaison POST /voice/livekit/connection-details qui crée un jeton LiveKit et répartit votre Agent dans une salle :

    src/mastra/index.ts
    import { Mastra } from '@mastra/core/mastra'
    import { Agent } from '@mastra/core/agent'
    import { liveKitConnectionRoute } from '@mastra/livekit'

    const supportAgent = new Agent({
    id: 'support',
    name: 'Support',
    instructions: 'You are a friendly phone support agent. Keep replies short and conversational.',
    model: 'openai/gpt-5-mini',
    })

    export const mastra = new Mastra({
    agents: { support: supportAgent },
    server: {
    apiRoutes: [liveKitConnectionRoute({ agentName: 'mastra-voice' })],
    },
    })
  4. Créez le worker. Il s’exécute comme processus distinct, répond aux sessions LiveKit et appelle votre Agent à chaque tour. Les API de worker se trouvent au point d’entrée @mastra/livekit/worker, de sorte que le serveur Mastra ne charge jamais le runtime LiveKit Agents. Cet exemple utilise les chaînes de modèles LiveKit Inference pour la transcription et la synthèse vocale ; aucun plugin de fournisseur n’est donc requis :

    src/mastra/voice-worker.ts
    import { fileURLToPath } from 'node:url'
    import { createLiveKitWorker, runLiveKitWorker } from '@mastra/livekit/worker'
    import { mastra } from './index'

    export default createLiveKitWorker({
    mastra,
    agent: 'support',
    stt: 'deepgram/nova-3',
    tts: 'cartesia/sonic-3',
    turnDetection: 'multilingual',
    greeting: 'Hi! How can I help you today?',
    })

    if (process.argv[1] === fileURLToPath(import.meta.url)) {
    runLiveKitWorker({ entry: import.meta.url, agentName: 'mastra-voice' })
    }

    L’option agent sélectionne l’Agent Mastra qui répond à chaque session. Transmettez une clé fixe, comme dans l’exemple, ou omettez-la pour utiliser l’agentId des métadonnées de répartition ; un seul worker peut ainsi servir tous les Agents de votre instance Mastra.

  5. Téléchargez une fois les modèles de détection des tours de parole et d’activité vocale. Exécutez ensuite le worker dans un terminal et votre serveur Mastra dans un autre :

    npx livekit-agents download-files
    npx tsx src/mastra/voice-worker.ts dev
    npm run dev

    Le worker s’enregistre auprès de votre serveur LiveKit et attend des sessions, tandis que mastra dev sert la route de connexion.

  6. Parlez à votre Agent. Ouvrez le LiveKit Agents Playground hébergé, puis connectez-le à votre projet pour démarrer un appel sans créer de frontend.

    Pour connecter votre propre application, appelez plutôt la route de connexion pour obtenir un jeton. POST /voice/livekit/connection-details accepte les champs facultatifs agentId, threadId et resourceId dans le corps de requête, puis renvoie :

    {
    "serverUrl": "wss://your-project.livekit.cloud",
    "roomName": "mastra-voice-a1b2c3d4",
    "participantName": "user-1",
    "participantToken": "eyJhbGci..."
    }

    Cette réponse correspond au contrat employé par les kits de démarrage frontend de LiveKit ; les applications créées depuis agent-starter-react ou les composants LiveKit React fonctionnent donc sans modification.

Détection des tours de parole et interruptions
Lien direct vers Détection des tours de parole et interruptions

LiveKit détermine quand l’utilisateur a fini de parler et quand l’Agent a été interrompu. Les valeurs par défaut fonctionnent bien ; ajustez-les avec turnHandling :

src/mastra/voice-worker.ts
export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
turnDetection: 'multilingual',
turnHandling: {
endpointing: { mode: 'dynamic', minDelay: 300, maxDelay: 3000 },
interruption: { minDuration: 500, resumeFalseInterruption: true },
},
})
  • turnDetection: 'multilingual' : exécute localement sur le processeur le modèle sémantique LiveKit de fin de tour. Il lit la transcription en direct afin d’éviter de couper l’utilisateur au milieu de sa pensée. Utilisez plutôt 'vad' ou 'stt' pour une détection de fin fondée sur le silence.
  • endpointing : limite le temps d’attente de l’Agent après que l’utilisateur a cessé de parler.
  • interruption : contrôle l’interruption. Lorsque l’utilisateur parle en même temps que l’Agent, LiveKit arrête la lecture et annule le flux Mastra en cours ; la génération de tokens s’arrête donc elle aussi.
  • preemptiveGeneration : démarre la réponse de l’Agent Mastra alors que l’utilisateur finit encore de parler, masquant le délai avant le premier token. Le worker la désactive par défaut : chaque tentative anticipée exécute l’Agent Mastra sur une transcription provisoire, et chaque exécution conserve le message de l’utilisateur, ce qui duplique les messages dans le thread. Réactivez-la avec preemptiveGeneration: { enabled: true } si la latence importe davantage que l’exactitude de l’historique du thread.

Consultez la documentation LiveKit sur la détection des tours pour toutes les options.

Voix et transcription par appel
Lien direct vers Voix et transcription par appel

Les options stt et tts de premier niveau s’appliquent à chaque appel. Pour les choisir par appel, avec une voix ou une langue par locataire, définissez plutôt les résolveurs configuration.stt et configuration.tts. Chaque résolveur s’exécute une fois par appel avec les métadonnées de répartition, le contexte de requête, le nom de la salle et le contexte du job, puis renvoie une valeur acceptée par l’option de premier niveau correspondante. Cette valeur est une instance de plugin ou une chaîne de modèle d’inférence. Renvoyez undefined pour revenir à l’option de premier niveau.

L’exemple suivant attribue à chaque locataire sa propre voix de synthèse vocale, à partir de l’entrée tenant dans les métadonnées de répartition :

src/mastra/voice-worker.ts
import * as cartesia from '@livekit/agents-plugin-cartesia'

// One voice id per tenant, resolved from the dispatch metadata on each call.
const tenantVoices: Record<string, string> = {
meridian: 'your-cartesia-voice-id-1',
coastal: 'your-cartesia-voice-id-2',
}
// The resolver runs during call setup, so cache plugin instances across calls.
const ttsByVoice = new Map<string, cartesia.TTS>()

export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
configuration: {
tts: ({ requestContext }) => {
const voice = tenantVoices[requestContext?.tenant as string]
if (!voice) return undefined // fall back to the top-level `tts`
let tts = ttsByVoice.get(voice)
if (!tts) {
tts = new cartesia.TTS({ voice })
ttsByVoice.set(voice, tts)
}
return tts
},
},
})

configuration.stt fonctionne de la même manière pour la transcription par appel, par exemple avec un modèle de transcription ou une langue différente par locataire. Le message d’accueil possède une forme équivalente par appel : configuration.greeting.text accepte un résolveur ayant le même contexte d’appel, ce qui permet à un worker de commencer avec la formulation propre à chaque locataire.

Mémoire et threads
Lien direct vers Mémoire et threads

Lorsque l’Agent Mastra résolu possède une mémoire configurée, chaque appel devient un thread de mémoire :

  • thread prend par défaut le threadId des métadonnées de répartition, puis le nom de la salle.
  • resource prend par défaut le resourceId des métadonnées de répartition, puis le thread. Transmettez ici l’ID de votre utilisateur final afin de regrouper les appels sous le bon utilisateur. Mastra Studio envoie l’ID de l’Agent, conformément à la manière dont sa barre latérale liste les threads.
  • Lorsque le thread n’existe pas encore, le worker le crée avec le titre « Voice call » et les métadonnées { source: 'livekit' }. Le message d’accueil parlé est enregistré comme premier message de l’assistant afin que le thread constitue une transcription complète de l’appel ; désactivez cela avec persistGreeting: false.

Chaque tour envoie uniquement la nouvelle entrée de l’utilisateur ; Mastra Memory fournit l’historique, le rappel sémantique et la mémoire de travail. Épinglez une session à un thread existant en transmettant threadId dans le corps de la requête de connexion ; c’est utile pour poursuivre une conversation texte par la voix. Dans Studio, démarrer un appel depuis une discussion ouverte lie l’appel à ce thread, et la transcription est ajoutée à la discussion après chaque échange.

Lorsqu’un utilisateur interrompt l’Agent, la génération en cours est abandonnée et aucun élément de ce tour n’est alors conservé. LiveKit garde dans sa transcription la partie que l’utilisateur a réellement entendue et, au tour suivant, le worker renvoie ce fragment entendu afin de compléter le thread pour qu’il corresponde à l’appel. Un utilisateur qui raccroche juste après l’interruption laisse ce dernier fragment non enregistré. Consultez les tours interrompus pour les détails et une méthode de réconciliation.

Parler pendant l’exécution des Tools
Lien direct vers Parler pendant l’exécution des Tools

Les conversations vocales ne peuvent pas rester silencieuses lorsqu’un Tool lent s’exécute. Utilisez toolFeedback pour prononcer une courte phrase lorsque l’Agent Mastra démarre un appel de Tool :

src/mastra/voice-worker.ts
export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
toolFeedback: ({ toolName }) =>
toolName === 'searchOrders' ? 'Let me look that up.' : undefined,
})

La phrase est prononcée dans la réponse et enregistrée dans la transcription.

Générer des réponses avec un Workflow
Lien direct vers Générer des réponses avec un Workflow

Par défaut, le worker génère chaque réponse avec un Agent Mastra. Pour exécuter une logique en plusieurs étapes à chaque tour, par exemple classifier l’intention, router, appeler des Tools en séquence puis composer une réponse, générez plutôt les réponses avec un Workflow Mastra. Définissez workflow à la place de agent.

LiveKit gère toujours la boucle audio et appelle Mastra une fois par tour : le Workflow s’exécute donc jusqu’à son terme à chaque tour. Le Workflow ne peut ni être suspendu ni reprendre, et aucun état de conversation n’est conservé entre les tours. Transmettez la transcription via workflowInput afin que le Workflow reste sans état :

src/mastra/voice-worker.ts
import { createLiveKitWorker, chatContextToMessages } from '@mastra/livekit/worker'
import { mastra } from './index'

export default createLiveKitWorker({
mastra,
workflow: 'phoneConversation',
workflowInput: ({ chatCtx }) => ({ history: chatContextToMessages(chatCtx) }),
replyStep: 'generateResponse',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
turnDetection: 'multilingual',
})

Un Workflow diffuse des événements d’étape structurés, et non du texte. Pour prononcer les tokens au fur et à mesure de leur génération, l’étape de réponse transfère le texte de son Agent vers le writer de l’étape :

src/mastra/workflows/phone-conversation.ts
const generateResponse = createStep({
id: 'generateResponse',
// input and output schemas omitted
execute: async ({ inputData, mastra, writer, abortSignal }) => {
const stream = await mastra.getAgent('voice').stream(inputData.history, { abortSignal })
await stream.textStream.pipeTo(writer)
return { assistantMessage: await stream.text }
},
})
  • replyStep : limite la sortie prononcée à une étape. Omettez-le pour prononcer chaque étape qui écrit dans son writer.
  • resultText : solution de repli qui dérive la réponse du résultat final de l’exécution lorsqu’aucune étape ne diffuse de texte. La diffusion via writer réduit le délai avant le premier token ; privilégiez-la.
  • abortSignal : transmettez l’abortSignal de l’étape à agent.stream() afin que l’interruption arrête rapidement la génération. Lorsque l’utilisateur interrompt, le worker annule l’exécution.
  • generate : pour un contrôle total, transmettez plutôt une fonction generate. Il peut s’agir de tout générateur de réponses qui transforme un tour en flux texte.

Avec un Workflow, le worker ne conserve pas automatiquement les tours comme le fait stream() d’un Agent. Conservez l’historique de conversation dans le Workflow, ou gardez la transcription LiveKit comme source de vérité et transmettez-la à chaque tour.

Utiliser Mastra comme composant LLM
Lien direct vers Utiliser Mastra comme composant LLM

createLiveKitWorker() gère la session LiveKit pour vous. Pour gérer vous-même la session, utilisez plutôt MastraLLM : un plugin LLM LiveKit standard qui place un Agent Mastra dans l’emplacement llm de votre propre voice.AgentSession. L’application Mastra, la boucle d’Agent, les Tools, la mémoire et l’observabilité s’exécutent sur votre serveur Mastra, auquel le worker accède via HTTP. Le processus worker n’a besoin ni d’application Mastra, ni de base de données, ni de clés de fournisseur de modèles.

src/mastra/voice-worker-plugin.ts
import { fileURLToPath } from 'node:url'
import { defineAgent, voice } from '@livekit/agents'
import * as silero from '@livekit/agents-plugin-silero'
import { MastraLLM } from '@mastra/livekit/plugin'
import { runLiveKitWorker } from '@mastra/livekit/worker'

export default defineAgent({
entry: async ctx => {
await ctx.connect()

const session = new voice.AgentSession({
llm: new MastraLLM({
remote: { baseUrl: process.env.MASTRA_URL!, agentId: 'support' },
memory: { thread: ctx.room.name!, resource: 'user-7' },
}),
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
vad: await silero.VAD.load(),
// Required with `memory`: LiveKit enables preemptive generation by default.
turnHandling: { preemptiveGeneration: { enabled: false } },
})

await session.start({
// These instructions never reach the Mastra agent; its own instructions apply.
agent: new voice.Agent({ instructions: 'Replies come from the Mastra agent.' }),
room: ctx.room,
})

session.say('Hi! How can I help you today?')
},
})

if (process.argv[1] === fileURLToPath(import.meta.url)) {
runLiveKitWorker({ entry: import.meta.url, agentName: 'mastra-voice' })
}

Les deux approches partagent le même pipeline de réponse ; choisissez selon qui doit gérer la session :

createLiveKitWorker()MastraLLM
Gestion de sessionL’assistant worker crée et gère l’AgentSessionVotre code crée la session ; toutes les options et tous les hooks LiveKit vous appartiennent
Où l’application Mastra s’exécuteDans le processus workerSur votre serveur Mastra, accessible via HTTP ou dans le processus via agent
Besoins du processus workerVotre application Mastra, votre stockage et les clés de fournisseur de modèlesUniquement le SDK LiveKit et un accès réseau à votre serveur
Commodités intégréesMessage d’accueil, contrôle du consentement, raccrochage déclenché par l’Agent, initialisation de thread, agrégation de l’observabilitéRecréez ce dont vous avez besoin avec les assistants de session
Idéal pourLe chemin le plus rapide vers un Agent vocal fonctionnel ; le mode vocal StudioApplications LiveKit existantes et contrôle total de la session

Les Tools restent sur l’Agent Mastra et s’exécutent sur le serveur. Les Tools côté LiveKit transmis à la session sont ignorés. L’activité des Tools parvient au worker via toolFeedback (formule d’attente prononcée), onToolCall (se déclenche au début de chaque appel de Tool) et onTurnComplete (se déclenche après chaque réponse avec le texte, les appels de Tools et l’usage de tokens). Le raccrochage déclenché par l’Agent ne prend que quelques lignes : associez onToolCall à runEndCall().

attention

Ne combinez pas l’option memory avec preemptiveGeneration de LiveKit, que LiveKit active par défaut dans les sessions que vous créez vous-même. Un tour spéculatif qui se termine avant que LiveKit ne l’écarte conserve dans le thread un message utilisateur et une réponse jamais prononcée. Définissez turnHandling: { preemptiveGeneration: { enabled: false } }, ou exécutez sans memory et transmettez la transcription complète à chaque tour.

MastraLLM accepte également une instance agent Mastra dans le processus, la gestion de session sans second déploiement, ou une fonction generate personnalisée. Le transport distant est disponible de façon autonome sous la forme de createRemoteAgentReplyGenerator(), qui se branche aussi sur l’option generate de createLiveKitWorker pour exécuter le worker complet sur un serveur distant.

Sessions initiées par le serveur
Lien direct vers Sessions initiées par le serveur

Utilisez dispatchVoiceSession() pour ajouter un Agent vocal à une salle depuis votre propre code, par exemple afin de rejoindre une salle existante ou de lancer un appel SIP sortant :

import { dispatchVoiceSession } from '@mastra/livekit'

await dispatchVoiceSession({
roomName: 'support-call-42',
agentName: 'mastra-voice',
metadata: { agentId: 'support', threadId: 'thread-42', resourceId: 'user-7' },
})

Observabilité
Lien direct vers Observabilité

Lorsque l’observabilité est configurée sur l’instance Mastra, le worker trace chaque appel. Il ouvre une span voice call par session et y imbrique tous les éléments :

  • L’exécution de l’Agent Mastra à chaque tour, avec la génération du modèle, les appels de Tools et les opérations de mémoire, exactement comme ils sont consignés par une discussion texte.
  • Une span enfant pour chaque métrique du pipeline LiveKit : transcription, synthèse vocale, fin d’énoncé (détection de tour), détection d’activité vocale et délai du modèle avant le premier token. Elles contiennent les mesures de latence et d’audio que les traces texte ne peuvent pas montrer.
  • Une agrégation d’utilisation par modèle, totaux de tokens, caractères et audio pour tout l’appel, écrite dans la span à la fin de la session.

Le worker est un processus distinct : pointez donc le stockage vers un backend qui accepte les écritures simultanées du serveur et du worker. LibSQL, basé sur SQLite, convient ; les stockages à un seul écrivain ne conviennent pas. Les traces, la mémoire et les threads peuvent partager un même stockage :

src/mastra/index.ts
import { Mastra } from '@mastra/core/mastra'
import { LibSQLStore } from '@mastra/libsql'
import { Observability, MastraStorageExporter } from '@mastra/observability'

export const mastra = new Mastra({
storage: new LibSQLStore({ id: 'voice-agent-storage', url: 'file:./voice-agent.db' }),
observability: new Observability({
configs: {
default: {
serviceName: 'voice-agent',
exporters: [new MastraStorageExporter()],
},
},
}),
})

Le traçage est activé par défaut. Transmettez observability: false à createLiveKitWorker pour le désactiver.

Déploiement
Lien direct vers Déploiement

Le worker est un processus distinct de votre serveur Mastra ; mastra build doit donc le produire avec son propre point d’entrée. Ajoutez-le à bundler.entries :

src/mastra/index.ts
import { Mastra } from '@mastra/core'

export const mastra = new Mastra({
bundler: {
entries: { 'voice-worker': './voice-worker.ts' },
// Keep LiveKit's native modules out of the bundle. `mastra build` only applies
// this default when you set no other bundler options, so set it explicitly here.
externals: true,
},
})

mastra build écrit désormais les deux processus dans .mastra/output, avec un même package.json et une seule installation des dépendances :

.mastra/output/
index.mjs # Mastra server
voice-worker.mjs # LiveKit worker

Déployez ce répertoire comme un artefact unique et démarrez chaque processus avec sa propre commande :

node .mastra/output/index.mjs # server
node .mastra/output/voice-worker.mjs start # worker

Le worker a besoin des mêmes variables d’environnement que le serveur, auxquelles s’ajoutent LIVEKIT_URL, LIVEKIT_API_KEY et LIVEKIT_API_SECRET.

Les recommandations de LiveKit sur le dimensionnement, l’arrêt gracieux et l’hébergement s’appliquent telles quelles. Consultez Deploying agents. Les workers se connectent à LiveKit en sortie et n’ont donc pas besoin de ports entrants.

Fonctionnement
Lien direct vers Fonctionnement

Une session vocale LiveKit comporte trois éléments :

  1. Votre serveur Mastra crée un jeton d’accès LiveKit et répartit votre Agent dans une salle. La répartition transporte des métadonnées telles que l’ID d’Agent Mastra, le thread de mémoire et la ressource.
  2. Un worker d’Agent LiveKit, processus distinct de longue durée, reçoit le job et exécute le pipeline audio. L’audio circule entre le navigateur et le worker via WebRTC et ne passe jamais par votre serveur HTTP Mastra.
  3. Chaque fois que l’utilisateur termine un tour, le worker appelle stream() de l’Agent Mastra avec la nouvelle entrée et prononce le texte diffusé. Lorsque l’utilisateur interrompt, LiveKit annule le flux et Mastra cesse de générer.

L’historique de conversation se trouve dans Mastra Memory ; les sessions vocales et les discussions texte peuvent donc partager un même thread.