Skip to main content
This example deploys Moshi, a full-duplex speech-to-speech model from Kyutai, as a real-time voice chat app on one H100 with the WMA SDK. The browser sends its microphone over WebRTC. The app streams Moshi’s voice back as a live audio track and sends Moshi’s words as text messages.
If you want real-time speech from a ready-made API, fal hosts two from Kotoba Technologies: Kotoba Realtime Speech Translation (speak in one language and hear another, with the translated text alongside the audio) and Kotoba Text-to-Speech (natural Japanese speech, streamed as it is generated). Nothing to deploy. Use this example when you want to run your own speech-to-speech model.
The clip is a recording of this app on one H100, with three recorded questions in place of the microphone. Play it with sound. The top lane is what the client sent, and the bottom lane is what the app sent back. The captions show the question and the text messages.
The WMA SDK is experimental. Pin the SDK version, as this page does.

Key Facts

What the App Does

The client sends an audio track and gets Moshi’s reply back as audio. The video restyle example has the same shape with video.
  • It resamples the microphone to 24 kHz mono and cuts it into 80 ms frames.
  • Each frame goes to Moshi. Each step returns one 80 ms frame of Moshi’s voice and, often, one text token.
  • Moshi’s voice leaves as an audio track, and each word piece leaves as a text message.
  • A reset message from the client starts a new conversation.
  • A status message goes out when the state changes, and every 2 seconds.
Moshi listens and speaks at the same time. It does not wait for the end of a sentence, so the caller can interrupt it. The model is natively real time. Every 80 ms of input produces 80 ms of output, including silence. The app adds no turn detection or text-to-speech step.

Prerequisites

  • A fal account with permission to deploy and call the app. The browser client connects through fal’s WMA service at wma.fal.run. It needs a fal API key on your server, or a signed-in dashboard session for the playground.
  • Access to the fal WMA TURN servers for the account that owns the app (the X in fal.run/X/app). Request it from fal before you rely on the app. Without it, a session connects only when the runner is directly reachable, which fails on many runners and networks. See If the Session Does Not Start.
  • Python 3.12 on your computer. The app image uses Python 3.12, and the Python versions must match for deployment.
  • The fal CLI and the demo code. Run these commands:
  • A browser with a microphone. Microphone capture needs HTTPS or localhost. Use headphones, so Moshi does not hear its own voice.
  • One H100 GPU. Moshi is a 7B model in bfloat16.
The weights are public and not gated. They use the CC-BY-4.0 license, and the moshi package uses the MIT license. Each new runner downloads about 16 GB of weights from Hugging Face. The app pins the weights to one revision.

The App

View the complete source code on GitHub. This page shows the parts that use the WMA SDK.

Messages

The client sends one reset message. The app sends text, status, and rejected messages.

The App Class

The class sets the runner, the pinned requirements, and a long startup_timeout for the weight download. The max_multiplexing value of 1 gives each session the whole GPU.
max_concurrency is the most runners, and max_multiplexing is the most sessions on one runner. Both values are examples. Adjust them for your workload and cost. See Scale your application.

The Contract

The contract declares a required microphone track, an audio output track, and one example message.

The Model Step

Moshi.step takes one 80 ms input frame and returns the audio and text that Moshi produced for it. Text tokens 0 and 3 are padding, so the app skips them.

The Session Backend

The create_backend method attaches the output track when the microphone track arrives. It also ends the session after MAX_SESSION_SECONDS. The lines that build the ICE servers (the RunnerIceConfig call) are boilerplate. Copy them as they are.

How the App Works

Pinned Model

The app loads Moshi with the moshi package from Kyutai, as the upstream server does. The weights come from kyutai/moshiko-pytorch-bf16, pinned to one revision with a constant at the top of the file. All heavy imports run inside the Moshi class, so the file imports on a computer without PyTorch. The streaming state lives for the whole runner and is reset at the start of each conversation. setup() runs four silent frames, so CUDA graph capture happens before the first session.

Audio In

Browsers send 48 kHz Opus. read_microphone resamples every incoming frame to 24 kHz mono with an av.AudioResampler, then cuts the audio into 80 ms frames of 1,920 samples. The input buffer holds at most 4 frames (320 ms). If the model falls behind, the app drops the oldest frames and counts them in dropped_input_frames. The first encoded frame is discarded, as upstream does, because the encoder’s left padding makes it look like the past to the model.

Audio Out

The WMA SDK has a queue track for video, make_video_queue_track, but not for audio. The app builds its own audio track, make_audio_queue_track.
The track sends 20 ms packets, plays silence when the queue is empty, and rebases its clock after a stall of more than one second. The output queue holds at most 2 seconds. The app drops the oldest audio when the queue is full, with queue_put_drop_oldest, and counts it as undelivered. aiortc encodes the 24 kHz mono frames to Opus for the browser.

Model Thread

The model call blocks, so it runs on its own thread and never on the event loop. The thread calls loop.call_soon_threadsafe to give audio to the output queue. The shutdown function is a deferred cleanup. It stops the thread and waits for the thread to end before the session closes. The cap_billing function runs after shutdown and before billing is final. The app sets max_multiplexing = 1 because the model keeps one streaming state, and a lock protects it.

Contract and Client Controls

The realtime_contract describes the session for the playground and for client code. The app does not use an interaction profile, because its only control is reset.

Run and Deploy

From the root of the cloned repository, use fal run as the development loop. It starts the app on an ephemeral runner:
To deploy the app as a private app, run the next command. Use the deployed app for the playground and the browser client below.

Cost and First Start

You pay per second for the runner, including setup time and the idle keep_alive time. See Pricing. The billable units in the app matter only if you list the app on the fal Marketplace. Callers then pay for these units at a price that the fal team sets. The first session starts a runner. The runner downloads about 16 GB of weights and captures CUDA graphs. On a fresh runner this took about 3 minutes in our tests. Watch the runner logs in the dashboard. To keep a runner warm, set min_concurrency = 1. A warm runner costs idle time. See cold starts.

Clean Up

To stop paying for the app, scale it to zero with fal apps scale <app-name> --min-concurrency 0. To remove it, run fal apps delete <app-name>. The idle runner timeout (keep_alive) is 300 seconds. A runner stays up for that time after its last session. The maximum session length is also 300 seconds (MAX_SESSION_SECONDS). Change the constant to change the limit.

Try It

Playground

  1. Open the deployed app in the fal dashboard.
  2. Select its /start-session playground.
  3. Select Connect and allow the microphone.
  4. Start talking. Moshi often speaks first.
  5. Read the text messages in the message log. They spell out what Moshi is saying.
  6. Send New conversation to reset Moshi.
  7. Select Disconnect to end the session.

Browser Client

The experimental WMA browser client sends the microphone and the reset message. There is no Python or Node client today. The deploy output shows the app ID as your-account/<app-name>. The name comes from the class name, so VoiceChatApp becomes voice-chat-app. Install the client and the server proxy in your web application. Set up the proxy route as the browser client page describes.
The API key stays on your server behind the proxy route. This function uses only the options that the browser guide documents.
The onData callback receives the raw JSON string, so parse it first. Each text message is one word piece, often with a leading space, so append the pieces in order. Browsers can block audio playback until the user interacts with the page, so call connectVoiceChat from a click handler. On a closed or failed session, the code stops the microphone and calls onEnded. The ended flag makes that cleanup run once. The app closes each session after 300 seconds, so expect onEnded even when nothing fails.

Server Messages

The state field of status is waiting_for_microphone, listening, or restarting.

If the Session Does Not Start

Use Your Own Model

The model is the Moshi class. The model thread calls Moshi.step with one input frame and plays the audio frames it returns. The rest is the same WMA shape for any streaming audio model:
  • A model thread takes a fixed amount of input audio and returns output audio.
  • The thread puts each output chunk in a bounded queue, with the put_output method. The queue drops the oldest chunk when it is full.
  • make_audio_queue_track plays the queue as the audio track, and AiortcPeer sends it to the client.
To serve a cascaded voice agent (speech recognition, a language model, and text-to-speech), keep the same input and output code and replace Moshi.step with your pipeline. A cascade waits for the caller to finish a sentence, so add turn detection and expect a longer delay before each reply. The put_output method queues one output chunk.

Billing

This section applies if you list the app on the fal Marketplace. For your own private app, you pay only for runner time, and the units do not change the cost. The app calls session.add_billable_units with the seconds of audio that it queues for playback. One unit is one second of Moshi’s output audio, silence included. The warm-up in setup() does not count. Some audio never plays. The app drops the oldest audio when the output queue is full, and some audio remains queued when the session ends. Before billing is final, a deferred cleanup calls session.cap_billable_units(total - undelivered). The argument is the new total, so the undelivered audio is not billed. The fal team configures the price per unit when it lists the app. See billing in the WMA reference.

Limits

  • Speed. On one H100, the app kept up with real time over a 27 second conversation with no dropped input frames. Moshi’s replies started about one second after the end of each question, network included.
  • Slow model, dropped audio. If a step takes longer than 80 ms, the app drops input frames. The conversation gets choppy, but the delay stays bounded.
  • Transcript gaps. Moshi’s text stream can skip a word piece that its audio says, for example I' sorry for “I’m sorry”. The audio is not affected.
  • Echo. Moshi hears whatever the microphone picks up, including its own voice from speakers. Use headphones or browser echo cancellation.
  • Generation failure. The app sends a rejected message with the reason “generation failed”. It then closes the session.
  • Microphone ends. When the microphone track ends, the app returns to waiting_for_microphone and stops generating.
  • One voice and persona. The moshiko weights have one male voice and no system prompt. Kyutai publishes a female voice as kyutai/moshika-pytorch-bf16.
  • English only. Moshi understands and speaks English.
  • No content moderation. The app does not check what the caller says or what Moshi says. Add checks before you serve public users.