Voice assistant · Build a real-time voice assistant loop1 / 2

Build a real-time voice assistant loop

Example on GitHub(packages/sdk/examples/voice-assistant/voice-assistant.ts)

Now that we've seen speech-to-text, text generation, and text-to-speech separately, we're going to put them together.

A voice assistant is a loop: listen, transcribe, answer, speak. Each piece lives in a chapter we've already done. This lesson wires the three pieces into a streaming conversation.

Three models to load: ASR with Whisper + Silero VAD, LLM with Llama 3.2 1B, TTS with Supertonic English. You would load them in this sequence:

const asrModelId = await loadModel({
  modelSrc: WHISPER_TINY,
  modelConfig: {
    vadModelSrc: VAD_SILERO_5_1_2,
    audio_format: "f32le",
    language: "en",
  },
});
const llmModelId = await loadModel({
  modelSrc: LLAMA_3_2_1B_INST_Q4_0,
  modelConfig: { ctx_size: 4096 },
});
const ttsModelId = await loadModel({
  modelSrc: TTS_EN_SUPERTONIC_Q8_0,
  modelConfig: {
    ttsEngine: "supertonic",
    language: "en",
    voice: "F1",
    ttsSpeed: 1.05,
    ttsNumInferenceSteps: 5,
  },
});

The system prompt is what tells the model how to behave: short answers (TTS takes ~1s per sentence), no markdown (asterisks sound bad read aloud), no lists (hard to follow mid-task). The system prompt might look as follows:

const history: Array<{
  role: "system" | "user" | "assistant";
  content: string;
}> = [{ role: "system", content: SYSTEM_PROMPT }];

transcribeStream({ modelId }) returns a Promise<TranscribeStreamSession>. await it to get the session, then iterate with for await (const rawText of session). The session emits plain text strings.

Audio capture is a child ffmpeg process. The startMicrophone() helper spawns the system ffmpeg with the right -i args for the current platform (avfoundation on macOS, pulse on Linux, dshow on Windows) and pipes 16 kHz mono f32le PCM to stdout. Each chunk on stdout is a frame we feed into the session:

const ffmpeg = startMicrophone({ sampleRate: 16000, format: "f32le" });
const session = await transcribeStream({ modelId: asrModelId });

ffmpeg.stdout.on("data", (chunk: Buffer) => {
  session.write(chunk);
});

A startup check on ffmpeg -version and ffplay -version makes the failure mode obvious if either is missing. The for (const tool of ['ffmpeg', 'ffplay']) loop keeps it to a few lines.

The main loop is one async for await over the session. Each iteration produces a user turn, runs the LLM, then speaks the answer:

for await (const rawText of session) {
  const userText = rawText.trim();
  if (userText.length === 0) continue;

  history.push({ role: "user", content: userText });

  const llmResult = completion({
    modelId: llmModelId,
    history,
    stream: true,
  });
  let assistantText = "";
  for await (const token of llmResult.tokenStream) {
    process.stdout.write(token);
    assistantText += token;
  }
  history.push({ role: "assistant", content: assistantText });

  const ttsResult = textToSpeech({
    modelId: ttsModelId,
    text: assistantText.trim(),
    inputType: "text",
    stream: false,
  });
  const samples = await ttsResult.buffer;
  if (samples.length > 0) {
    const wavBuffer = Buffer.concat([
      createWavHeader(samples.length * 2, TTS_SAMPLE_RATE),
      int16ArrayToBuffer(samples),
    ]);
    playAudio(wavBuffer);
  }
}

textToSpeech() returns the audio as raw 16-bit signed PCM samples at 44.1 kHz mono. To play it we wrap the samples in a minimal WAV header and pipe the buffer into ffplay, which ships with ffmpeg.

A cleanup() handler kills the ffmpeg child, ends the session, and unloads all three models. Wire it to SIGINT and SIGTERM so Ctrl+C exits cleanly:

async function cleanup() {
  if (shuttingDown) return;
  shuttingDown = true;
  ffmpeg.kill();
  try { session.end(); } catch {}
  await unloadModel({ modelId: ttsModelId }).catch(() => {});
  await unloadModel({ modelId: llmModelId }).catch(() => {});
  await unloadModel({ modelId: asrModelId }).catch(() => {});
  process.exit(0);
}

process.on("SIGINT", () => void cleanup());
process.on("SIGTERM", () => void cleanup());

The SDK installs its own SIGINT / SIGTERM handler that aborts in-flight RPC streams on shutdown. The abort rejects any pending session.write() or unloadModel() call with a WorkerShutdownError. The bare-rpc socket also emits an RPCError with code: 'CHANNEL_CLOSED' on the same teardown path. Both surface as unhandled stream errors. The process.on("uncaughtException", ...) filter ignores the shutdown noise and re-throws anything else. The lesson scopes its Sets to avoid shadowing the runner preamble's globals of the same name:

const LESSON_TEARDOWN_NAMES = new Set([
  "WorkerShutdownError", "WorkerCrashedError", "BareRuntimeBinaryNotFoundError",
  "InferenceCancelledError", "TranscriptionFailedError", "TranslationFailedError",
  "TextToSpeechFailedError", "TextToSpeechStreamFailedError", "AbortError",
]);
const LESSON_TEARDOWN_CODES = new Set([
  "ABORT_ERR", "CHANNEL_CLOSED", "MODEL_NOT_LOADED", "MODEL_WAS_UNLOADED",
  "WORKER_SHUTDOWN", "RPC_CONNECTION_FAILED",
]);
function isTeardown(err) {
  if (!err) return true;
  if (LESSON_TEARDOWN_NAMES.has((err.name || "").toString())) return true;
  const code = (err.code || "").toString();
  if (LESSON_TEARDOWN_CODES.has(code)) return true;
  const msg = (err.message || String(err) || "").toString();
  if (/\bis shutting down\b/i.test(msg)) return true;
  if (/\bin-flight rpc\b/i.test(msg)) return true;
  return false;
}
process.on("uncaughtException", (err) => {
  if (isTeardown(err)) return;
  throw err;
});

Each teardown-aware RPC also sits in the body of the loop, so guard each call against the shuttingDown flag and catch per-turn errors, otherwise a flush that fires after unloadModel raises Transcription failed: Model X unloaded for every drained segment:

for await (const rawText of session) {
  if (shuttingDown) break;
  // ...
  try {
    for await (const token of llmResult.tokenStream) {
      if (shuttingDown) break;
      process.stdout.write(token);
      assistantText += token;
    }
  } catch (err) {
    if (!isTeardown(err)) console.error("✖ LLM:", err.message);
    break;
  }
}

Note: the system prompt bans markdown and lists because the output is spoken aloud. A ### heading or a 1. list reads as a stuttery mess through TTS.

Questions

Question 1 of 3

What is a key consideration behind the system prompt telling the model to avoid markdown and lists?

Question 2 of 3

What has to be added to textToSpeech()'s output before playAudio() can play it?

Question 3 of 3

Why does the loop write the assistant's reply into history before calling textToSpeech()?

index.ts
Loading editor...

Run your code, check your answer, or ask a question. It all shows up here.