← All posts

Speech generation from the inside

Post 3 of 8

Speech without tokens: how Pocket TTS works

Posts in this series · 8

Most speech generators of the last few years borrowed the playbook of text language models: turn audio into a sequence of integers, then predict the next integer. Pocket TTS, released by Kyutai in January 2026, does not. It predicts the next stretch of audio as a vector of 32 real numbers, and it produces each one from random noise in a single forward pass of a small network. The whole model has about 100 million parameters and, per Kyutai’s README, runs at roughly six times real time on two cores of a MacBook Air M4.

The method behind it is described in the ICLR 2026 paper Continuous Audio Language Models (CALM) by Rouard, Orsini, Roebel, Zeghidour and Défossez. The code is in kyutai-labs/pocket-tts. This post walks the path from a text string to a waveform. It starts with why tokens were the default, then builds up flow matching from scratch, because the speed trick only makes sense once you see what it is short-cutting.

Every shape below comes from the released English config, not from the paper’s larger research models. Where the two differ I say so.

Part 1: Why audio became tokens, and what that cost

The codec-token recipe

Raw audio is a terrible sequence to model directly. At 24 kHz, one second is 24,000 numbers, and the structure that matters (phonemes, pitch contours, breath) spans thousands of them. A transformer over raw samples would need enormous context for a single sentence.

Neural audio codecs solve the length problem. An encoder compresses the waveform into a much slower sequence of frames; a decoder reconstructs audio from those frames. Models such as AudioLM and VALL-E then go one step further and quantize each frame, so that a language model can predict it with a softmax over a vocabulary, exactly as it would a word.

The standard quantizer is residual vector quantization (RVQ). A frame vector z is replaced by the nearest entry of a learned codebook. The leftover error is quantized again by a second codebook, and so on:

r0=z,ki=arg⁡min⁡j∥ri−1−cj(i)∥,ri=ri−1−cki(i),z^=∑i=1Qcki(i)r_0 = z, \qquad k_i = \arg\min_j \lVert r_{i-1} - c^{(i)}_j \rVert, \qquad r_i = r_{i-1} - c^{(i)}_{k_i}, \qquad \hat z = \sum_{i=1}^{Q} c^{(i)}_{k_i}

Reading it symbol by symbol:

In words: find the closest colour, subtract it, find the closest colour to what is left, and repeat QQ times. The frame is then stored as QQ whole numbers, k1,…,kQk_1, \dots, k_Q.

VALL-E uses EnCodec at 75 frames per second with Q = 8 codebooks of 1,024 entries. That is 600 integers for every second of speech, and each integer carries at most log₂ 1024 = 10 bits.

The mosaic and the painting

Think of quantization as building a picture from mosaic tiles. You have a fixed box of colours; every patch of the image is replaced by the closest tile. Add more boxes (more codebooks) and the residual gets finer, but there is always a gap between the true colour and the tile you placed.

Quantized latent against a continuous latentLeft: a smooth curve forced onto eight horizontal levels, drawn as a staircase of tiles. Right: the same curve kept as a continuous line.MOSAIC · CODEBOOK OF 8 LEVELSPAINTING · 32-BIT FLOATevery frame snaps to the nearest leveleach frame is any point on the line: no rounding step

A codec token is an index into a finite table. A continuous latent is the value itself.

Figure 1. The mosaic against the painting. One latent channel over time. Quantization rounds every frame to the nearest of a few levels; a continuous latent keeps the value. Schematic.

That gap shows up in four ways:

The continuous alternative

CALM removes the quantizer. The codec is trained as a variational autoencoder whose bottleneck is a plain continuous vector. For speech, the paper’s VAE uses Mimi’s architecture at 12.5 frames per second with 32 latent channels, operating on 24 kHz audio. One second of speech is therefore a 12.5 × 32 matrix of floats: 400 numbers, against VALL-E’s 600 integers.

This is not perfect fidelity. The VAE is still a lossy compressor, and its decoder has its own reconstruction error. What disappears is the second loss, the rounding step, and with it the trade-off between more codebooks and longer sequences. One latent per frame, one prediction per frame.

The price is that a softmax can no longer produce the next frame. There is no vocabulary to normalise over. The model has to output a sample from a continuous distribution in 32 dimensions, and that is the problem the rest of this post is about.

Part 2: The transformer brain

Pocket TTS generation loopA voice clip and a sentence become the first cells of one sequence. A causal transformer reads the whole sequence; for each new frame a small head turns 32 numbers of noise into one 32-number latent in a single call. That latent is appended to the sequence and decoded by Mimi into 80 milliseconds of audio, six times, growing the sequence and the waveform together.voice clip10 s, 24 kHz"Hello world."tokenizer · 4 000 idsMIMI → [125, 1024]LOOKUP → [n_text, 1024]6 frames, one latent each, decoded as they arrivet = 6 · 480 ms of audiocausal transformer6 layers · 16 heads · d = 1024 · reads every earlier cellvoice prefix · 125 framestext tokensgenerated latentsONE SEQUENCE · EVERY CELL 1024 WIDEMLP headnoise(32) → xₜ(32), 1 callMimi decoder1 920 samples / frame0 ms80 ms160 ms240 ms320 ms400 ms480 ms

The voice is not a separate stream: it is the start of the same sequence the transformer reads. Each frame costs one transformer pass and one head call, and its 80 ms of audio can play before the next frame exists.

Figure 2. The whole pipeline, as it runs. One sequence holds the voice, the text and every generated latent. Shapes are from the released English config; the voice clip is assumed to be 10 seconds long, and the tape shows 8 of its 125 frames. Attention weights are illustrative.

Text in

Take the prompt “Hello world.” A SentencePiece-style tokenizer with a 4,000-entry vocabulary turns it into a short list of integer ids, say ntext of them. A lookup table, an ordinary embedding matrix of shape 4,000 × 1,024, replaces each id with its row:

Etext=Wlut[ids]∈Rntext×1024E_{\text{text}} = W_{\text{lut}}[\text{ids}] \in \mathbb{R}^{n_{\text{text}} \times 1024}

In words: each token number is swapped for its own learned list of 1,024 numbers. If “Hello world.” becomes 4 tokens, EtextE_{\text{text}} is 4 rows of 1,024.

What a 1,024-dimensional hidden state is

dmodel = 1024 means every position in the sequence is described by a list of 1,024 real numbers. Geometrically, each position is a point in ℝ¹⁰²⁴. Stack the whole sequence and you get a matrix of shape T × 1024, one row per position. Each transformer layer reads that matrix and writes a new one of the same shape.

Nothing in this matrix is text and nothing is audio. It is a working representation that the layers progressively rewrite until the last row contains whatever the next audio frame needs to be predicted.

The causal backbone

The Pocket TTS backbone is a stack of 6 transformer layers, 16 attention heads each, with a feed-forward width of 4 × 1024. In one layer, the matrix X ∈ ℝT×1024 is projected three ways:

Q=XWQ,K=XWK,V=XWV,WQ,WK,WV∈R1024×1024Q = XW_Q, \quad K = XW_K, \quad V = XW_V, \qquad W_Q, W_K, W_V \in \mathbb{R}^{1024 \times 1024}

Think of a library: the query is your question, keys are the book titles, values are the books’ contents.

and split into 16 heads of width 64. For one head:

Attn(Q,K,V)=softmax⁡ ⁣(QK⊤64+M)V,Mij={0j≤i−∞j>i\text{Attn}(Q,K,V) = \operatorname{softmax}\!\Big(\frac{QK^{\top}}{\sqrt{64}} + M\Big)V, \qquad M_{ij} = \begin{cases} 0 & j \le i \\ -\infty & j > i \end{cases}

Reading it piece by piece:

QKᵀ is a T × T table: entry (i, j) scores how relevant position j is to position i. The mask M sets every score for a future position to −∞, so after the softmax those weights are exactly zero. Row i of the output is a weighted average of the value vectors of positions 0 to i. This is what causal means: the representation of frame i can depend on the past and never on the future, which is what allows streaming generation.

What comes out

The backbone emits no text tokens and no audio. At the last position it emits one context vector

ht∈R1024h_t \in \mathbb{R}^{1024}

Here hth_t is the context vector for audio frame tt: 1,024 numbers that summarise everything the model has read so far (voice, text and the audio already made). The symbol ∈R1024\in \mathbb{R}^{1024} just means “is a list of 1,024 real numbers”.

which is layer-normalised and passed to a separate small network, the head, together with a fresh draw of Gaussian noise. The head returns the next latent xt ∈ ℝ³². That latent is then mapped back up to 1,024 dimensions by a linear layer (32 → 1024) and appended to the sequence, and the backbone runs again for the next frame. A separate linear output on ht predicts end-of-speech.

So the loop is: sequence → ht → head(noise, ht) → xt → append → repeat, 12.5 times per second of audio.

The CALM paper adds two refinements in its research models. The backbone’s own past latents are fed in with injected noise, which stops it from relying on tiny details that drift during generation, and a short-context transformer over the last ten clean latents restores that local detail. The paper also places each word at the timestep where it is spoken, an “inner monologue” that keeps text and audio aligned. I describe the released Pocket TTS code below, which prepends text as a prefix.

Part 3: Voice cloning: attention over a prefix

The roadmap version of this story has a dedicated audio encoder and a cross-attention block. The released code does something simpler. The reference audio becomes the first part of the same sequence the transformer is already reading.

Encoding the reference

A 10-second clip at 24 kHz is 240,000 samples. The Mimi encoder turns it into 125 latent frames of 32 numbers each, and one learned matrix, speaker_proj, lifts each frame to the model width:

Cref=Mimi⁡enc(wav) Wspk,Mimi⁡enc(wav)∈R125×32,Wspk∈R32×1024,Cref∈R125×1024C_{\text{ref}} = \operatorname{Mimi}_{\text{enc}}(\text{wav}) \, W_{\text{spk}}, \qquad \operatorname{Mimi}_{\text{enc}}(\text{wav}) \in \mathbb{R}^{125 \times 32}, \quad W_{\text{spk}} \in \mathbb{R}^{32 \times 1024}, \quad C_{\text{ref}} \in \mathbb{R}^{125 \times 1024}

The code puts a learned start vector in front, runs these 126 positions through the transformer once, and keeps the resulting key-value cache. That cache is the “voice state”. The README’s tip that a saved .safetensors voice loads much faster than a .wav is exactly this: the saved file skips re-encoding and re-running the prefix.

The Q, K, V interaction

During generation the full sequence is [start, voice frames, text tokens, generated audio frames]. Consider the newest position, with query qt. Its attention weights over all earlier positions split into three groups:

ot=∑j∈voiceαtjvj  +  ∑j∈textαtjvj  +  ∑j∈audioαtjvj,αtj=exp⁡(qt⊤kj/8)∑j′≤texp⁡(qt⊤kj′/8)o_t = \sum_{j \in \text{voice}} \alpha_{tj}v_j \;+\; \sum_{j \in \text{text}} \alpha_{tj}v_j \;+\; \sum_{j \in \text{audio}} \alpha_{tj}v_j, \qquad \alpha_{tj} = \frac{\exp(q_t^{\top}k_j/8)}{\sum_{j' \le t}\exp(q_t^{\top}k_{j'}/8)}

In words: the output is a weighted average of everything earlier, and we have just split that average into three piles: voice, text, and audio made so far.

Mathematically this is the same Q·K·V operation as cross-attention. The query comes from the current frame; the keys and values come from the reference. The difference is architectural: there is one attention, over one sequence, with one set of weights, rather than a second attention block with its own projections.

Attention from the current audio frame to the voice prefixA row of cells: one start marker, eight voice-prompt frames, six text tokens, five already generated audio frames, then the current frame. Arcs of varying thickness run from the current frame back to every earlier cell, thickest to several voice frames and to the text token being spoken.bosvoice prompt (Mimi latents, projected)text tokensgenerated audioq_t

The voice is not a separate input stream. It sits in the same causal sequence, so every new frame can read timbre from it through ordinary self-attention.

Figure 3. Where one frame looks. Arc thickness is an attention weight. Illustrative weights, not measured from the model.

How the reference moves the target

Here is the precise sense in which the voice “shifts” the output. Group the voice term:

ot=At vˉtvoice+(1−At) vˉtrest,At=∑j∈voiceαtj,vˉtvoice=1At∑j∈voiceαtjvjo_t = A_t \, \bar v^{\text{voice}}_t + (1 - A_t)\, \bar v^{\text{rest}}_t, \qquad A_t = \sum_{j \in \text{voice}} \alpha_{tj}, \quad \bar v^{\text{voice}}_t = \frac{1}{A_t}\sum_{j \in \text{voice}} \alpha_{tj} v_j

So the output is a blend: AtA_t parts “what the voice says” and 1−At1 - A_t parts “everything else”.

Every head in every layer adds a vector like this to the residual stream. Swap in a different speaker and v̄voice changes, so ot moves, and that movement compounds through six layers into a different ht. Since the head generates xt conditioned on ht, a different ht means a different distribution over the next latent: same words, different timbre.

That is a mechanism, not a proof that timbre is captured. The proof is empirical: training on speech where the prompt and continuation come from the same speaker teaches the model that copying the prompt’s acoustics minimises the loss.

There is one more lever. With classifier-free guidance, the paper extrapolates between an unconditioned and a conditioned context vector,

htCFG=ht∅+α (htc−ht∅)h^{\text{CFG}}_t = h^{\varnothing}_t + \alpha\,(h^{c}_t - h^{\varnothing}_t)

Example in one number: if h∅=2h^{\varnothing} = 2 and hc=4h^{c} = 4, then α=1.5\alpha = 1.5 gives 2+1.5×2=52 + 1.5 \times 2 = 5.

which pushes ht further in the conditioning direction when α > 1. Pocket TTS bakes this in: a 24-layer teacher run with guidance (α = 1.5 in the paper) is distilled into the 6-layer student, so the student produces the guided vector in one pass instead of two.

Part 4: Flow matching from scratch

The head’s job is now precise. Given ht, draw a sample from p(xt ∣ ht), a distribution over ℝ³² we cannot write down. The family of tools for this is continuous normalizing flows.

Vector fields and flows

Start with a point x drawn from a simple distribution, here a standard Gaussian in ℝ³². Define a velocity field v(x, τ): at every location x and every time τ ∈ [0, 1], it says which direction to move and how fast. Follow it:

dxτdτ=v(xτ,τ),x0∼N(0,I)\frac{dx_\tau}{d\tau} = v(x_\tau, \tau), \qquad x_0 \sim \mathcal{N}(0, I)

Think of a leaf dropped on a river. The river’s current at each spot is vv; the leaf’s path is the flow.

This ordinary differential equation (ODE) carries every starting point along a trajectory. Carry the whole Gaussian cloud along and, if v is right, the cloud at τ = 1 has the shape of the data distribution. The map from x0 to x1 is the flow. (I write τ for flow time, to keep t for the audio frame index.)

The question is how to learn v without simulating the ODE during training. That is what flow matching solved.

The straight-line path

Pick a noise sample x0 and a real audio latent x1. Connect them with a straight line:

xτ=(1−τ) x0+τ x1x_\tau = (1 - \tau)\,x_0 + \tau\,x_1

Differentiate with respect to τ:

dxτdτ=x1−x0\frac{dx_\tau}{d\tau} = x_1 - x_0

τ has vanished. Along this one path the velocity is the same at every moment: constant speed, constant direction. This is the conditional velocity for the pair.

The training objective

The network vθ(x, τ, h) never sees the pair. It sees only a point xτ on some line and the time τ. Train it to predict the pair’s velocity anyway:

LFM(θ)=Eτ, x0, x1[∥vθ(xτ,τ,h)−(x1−x0)∥2]\mathcal{L}_{\text{FM}}(\theta) = \mathbb{E}_{\tau,\, x_0,\, x_1}\Big[\big\lVert v_\theta(x_\tau, \tau, h) - (x_1 - x_0) \big\rVert^2\Big]

Example: if the true direction is (1.3,−1.5)(1.3, -1.5) (Part 5) and the network guesses (1.0,−1.0)(1.0, -1.0), the loss for that draw is 0.32+0.52=0.340.3^2 + 0.5^2 = 0.34.

The key result of Lipman et al. is that minimising this per-pair regression gives the same gradient as regressing on the true marginal velocity field, the average of x1 − x0 over all pairs whose lines pass through that point. No ODE is simulated in training; each step is one sample, one interpolation and one squared error.

Against diffusion

Straight conditional path against a stochastic diffusion pathLeft panel: a noise point and a data point joined by a straight line with evenly spaced ticks for t equal to 0, 0.25, 0.5, 0.75 and 1. Right panel: the same two points joined by a jagged random walk.FLOW MATCHING · x_t = (1 − t)x₀ + t·x₁DIFFUSION · STOCHASTIC FORWARD PROCESS0.250.50.75x₀ noise, t = 0x₁ audio latent, t = 1noisedata

For a single pair the flow-matching velocity is constant: x₁ − x₀ at every tick. The jagged path is a cartoon of the noising process; a deterministic sampler like DDIM is smoother, but its learned path still bends.

Figure 4. Straight interpolation against a noising process. Schematic.

Diffusion models learn the reverse of a process that adds noise gradually, with a schedule that makes the path between data and noise curved even in its deterministic form. Samplers like DDIM follow that curve with 20 to 50 steps because a large step on a curved path overshoots.

Here is the subtlety the “straight line” picture hides. Each conditional path is straight, but the learned marginal field is not. Many lines cross every point, and the network learns their average direction, which bends as τ changes. So a flow-matching sampler still needs several Euler steps in general. Flow matching makes paths straighter, which means fewer steps; it does not make one step exact. Getting to one step needs Part 6.

Part 5: A worked example in two dimensions

Shrink 32 dimensions to two and give them names, purely for intuition: axis 1 is “pitch” and axis 2 is “amplitude”. Real Mimi channels do not have tidy meanings like these.

x0=[−0.50.9](noise),x1=[0.8−0.6](target sound)x_0 = \begin{bmatrix} -0.5 \\ 0.9 \end{bmatrix} \text{(noise)}, \qquad x_1 = \begin{bmatrix} 0.8 \\ -0.6 \end{bmatrix} \text{(target sound)}

Velocity. Subtract component by component:

v=x1−x0=[0.8−(−0.5)−0.6−0.9]=[1.3−1.5],∥v∥=1.32+1.52=3.94≈1.985v = x_1 - x_0 = \begin{bmatrix} 0.8 - (-0.5) \\ -0.6 - 0.9 \end{bmatrix} = \begin{bmatrix} 1.3 \\ -1.5 \end{bmatrix}, \qquad \lVert v \rVert = \sqrt{1.3^2 + 1.5^2} = \sqrt{3.94} \approx 1.985

τ = 0.0. The weights are 1 and 0:

x0.0=1.0[−0.50.9]+0.0[0.8−0.6]=[−0.50.9]x_{0.0} = 1.0 \begin{bmatrix} -0.5 \\ 0.9 \end{bmatrix} + 0.0 \begin{bmatrix} 0.8 \\ -0.6 \end{bmatrix} = \begin{bmatrix} -0.5 \\ 0.9 \end{bmatrix}

τ = 0.5. Equal weights:

x0.5=0.5[−0.50.9]+0.5[0.8−0.6]=[−0.25+0.400.45−0.30]=[0.150.15]x_{0.5} = 0.5 \begin{bmatrix} -0.5 \\ 0.9 \end{bmatrix} + 0.5 \begin{bmatrix} 0.8 \\ -0.6 \end{bmatrix} = \begin{bmatrix} -0.25 + 0.40 \\ 0.45 - 0.30 \end{bmatrix} = \begin{bmatrix} 0.15 \\ 0.15 \end{bmatrix}

τ = 1.0. The weights are 0 and 1:

x1.0=0.0[−0.50.9]+1.0[0.8−0.6]=[0.8−0.6]x_{1.0} = 0.0 \begin{bmatrix} -0.5 \\ 0.9 \end{bmatrix} + 1.0 \begin{bmatrix} 0.8 \\ -0.6 \end{bmatrix} = \begin{bmatrix} 0.8 \\ -0.6 \end{bmatrix}

Check with the velocity. Starting from x0 and moving at v for half a unit of time: x0 + 0.5·v = (−0.5 + 0.65, 0.9 − 0.75) = (0.15, 0.15). Same answer, as it must be.

Two-dimensional flow matching exampleA plane from minus one to one on both axes. The noise point (−0.5, 0.9) joins the target (0.8, −0.6) by a straight line, and a point moves along it as t changes.−1011−1pitch →amplitude →x₀ = (−0.5, 0.9)x₁ = (0.8, −0.6)VELOCITY, SAME AT EVERY tv = x₁ − x₀ = (1.3, −1.5)speed ‖v‖ ≈ 1.985POSITIONt = 0.0 → (−0.50, 0.90)t = 0.5 → (0.15, 0.15)t = 1.0 → (0.80, −0.60)t = 0.50 → (0.15, 0.15)xₜ

The dashed line is the whole trajectory. The arrow is the velocity scaled by 0.25 so it fits; as you drag, it keeps the same length and direction.

Figure 5. Drag t along the line. The point is (1 − t)·x₀ + t·x₁ and the readout updates live. Toy coordinates, not real latent channels.

What training sees. Suppose training samples τ = 0.5 for this pair. The network gets the input (0.15, 0.15) and τ = 0.5, plus ht, and its regression target is (1.3, −1.5). If it predicted (1.0, −1.2), the loss for this sample would be (0.3)² + (0.3)² = 0.18.

For this single pair, one Euler step of size 1 from x0 lands exactly on x1. With a real data distribution, the network’s answer at (0.15, 0.15) is the average over every pair whose line crosses there, and one step no longer lands on any one of them. That gap is what Pocket TTS closes.

Part 6: The breakthrough: one step via a learned flow map

The cost of solving the ODE

Euler’s method approximates the ODE with N equal steps:

x(k+1)/N=xk/N+1N vθ(xk/N,kN,ht),k=0,…,N−1x_{(k+1)/N} = x_{k/N} + \frac{1}{N}\, v_\theta\big(x_{k/N}, \tfrac{k}{N}, h_t\big), \qquad k = 0, \dots, N-1

With N=4N = 4: start at τ=0\tau = 0, ask for the velocity, move a quarter; at τ=0.25\tau = 0.25 ask again, and so on until τ=1\tau = 1.

Each step is one forward pass of the head. On a curved field, few steps miss. N = 8 is eight head calls per frame, 100 per second of audio. The head is small, but on a CPU it is not free.

Euler integration error against a learned one-step jumppurple · Euler on the velocity fieldgreen · one call to the flow mapdashed · exact trajectoryfaint · how far each step lands from it
Figure 6. Watch the error build up. Play draws the Euler steps one at a time; the faint lines show how far each step lands from the exact path. More steps (the slider) shrink the error, and every step costs a head call. The flow map pays one call. A circular field stands in for a learned one.

Learn the solution, not the velocity

Instead of the velocity, learn the flow map: a function that takes a point at time s and returns where the ODE carries it at time τ.

Xs,τ(xs)=xτX_{s,\tau}(x_s) = x_\tau

Read it as: “the flow map from time ss to time τ\tau takes the point xsx_s and returns the point xτx_\tau”. It is a shortcut that jumps along the river instead of following it step by step.

Pocket TTS parameterises it the way Boffi, Albergo and Vanden-Eijnden do, as a jump with a learned average velocity:

Xs,τ(x)=x+(τ−s) Fθ(x,s,τ,ht)X_{s,\tau}(x) = x + (\tau - s)\, F_\theta(x, s, \tau, h_t)

So “where you end up = where you start + time × average speed”, like a car trip: start at km 10, drive 2 hours at an average of 50 km/h, end at km 110.

With s = 0 and τ = 1 this is x0 + Fθ(x0, 0, 1, ht): noise in, latent out, one call. You can read this directly in the released sampler, lsd_decode, whose default is num_steps = 1.

Lagrangian self-distillation

How do you train Fθ without first solving the ODE to get endpoints? Two facts.

The diagonal is a velocity. As τ → s the jump shrinks to zero, and Fθ(x, s, s) must equal the instantaneous velocity at x. So the diagonal can be trained with the ordinary flow-matching loss from Part 4.

Off the diagonal, the flow map must obey the ODE. Moving the endpoint τ forward a little must move the output at the velocity of wherever it currently is. That is the Lagrangian equation, which follows a particle along its path:

∂∂τXs,τ(x)=v(Xs,τ(x),τ)\frac{\partial}{\partial \tau} X_{s,\tau}(x) = v\big(X_{s,\tau}(x), \tau\big)

In words: a correct shortcut, nudged a little further, must move exactly as the river flows where it lands.

LSD uses the model’s own diagonal as v, with gradients stopped, and penalises the residual:

LLSD=E[∥∂τXs,τ(xs)−sg⁡ ⁣[Fθ(Xs,τ(xs),τ,τ)]∥2]\mathcal{L}_{\text{LSD}} = \mathbb{E}\Big[\big\lVert \partial_\tau X_{s,\tau}(x_s) - \operatorname{sg}\!\big[F_\theta(X_{s,\tau}(x_s), \tau, \tau)\big] \big\rVert^2\Big]

“Self-distillation” because the teacher, the velocity on the diagonal, is the same network. Nothing is memorised; the model is trained to be consistent with its own velocity field at every (s, τ). The CALM paper reports splitting each batch 75 % to the flow-matching term and 25 % to the self-distillation term.

The paper’s main experiments use a different consistency-model objective in the TrigFlow parameterisation, which it reports as marginally higher quality but slower to sample. LSD is the variant the released Pocket TTS head uses.

Drifting: a third option

The repository later added a third head objective, drifting, released as the english_drifting_26-09 model. A drifting head maps noise to a latent in one step with no time input at all. For each position during training it draws 64 samples; each is pulled toward the real data latent and pushed away from the other samples by a kernel-based “drift field”. The pull fits the data, and the push keeps the samples spread, so the head does not collapse onto the average latent. The released variant is again a 6-layer student, distilled from a 24-layer drifting teacher with guidance 2.0.

Temperature without a softmax

Token models sample with a temperature on the logits. Here, the code samples the starting noise with variance equal to the temperature: x0 ∼ 𝒩(0, τtemp·I), with a default of 0.3. Smaller starting noise pulls samples toward the high-density core of the distribution, the continuous analogue of a sharper softmax.

Where the speed comes from

Three independent savings multiply:

Part 7: Mimi decodes the latent

At this point the model has produced a sequence of 32-number vectors at 12.5 per second. The Mimi decoder turns them into sound, streaming as they arrive.

Mimi decoder upsamplingFive stages left to right. A narrow block of 12.5 latent frames by 32 channels widens to 512 channels, is upsampled 16 times to 200 frames per second, passes a small transformer, then is upsampled 120 times by convolutions into a 24 000 sample waveform.[12.5, 32][12.5, 512][200, 512] · ×16transformer[24 000] · ×6·5·4 = ×120latentslinear 32→512transposed conv2 layers @ 200 HzSEANet decoder, causal convs

16 × 120 = 1 920: every 32-number latent becomes 80 ms of audio.

Figure 7. From latent to waveform. Shapes for one second of audio, from the released config.

The stages, for one second:

  1. Widen. A linear layer maps each 32-number latent to 512 channels: [12.5, 32] → [12.5, 512].
  2. Upsample in time, ×16. A transposed convolution with stride 16 turns 12.5 frames per second into 200: [200, 512].
  3. Contextualise. A small causal transformer (2 layers, 8 heads, width 512) refines the sequence at 200 frames per second. Its attention reaches back 250 frames, which at that rate is 1.25 seconds.
  4. Synthesize, ×120. The SEANet decoder applies transposed convolutions with strides 6, 5 and 4 (6 × 5 × 4 = 120), interleaved with residual blocks, while the channel count shrinks to one: [24,000].

16 × 120 = 1,920 = 24,000 / 12.5. Every latent becomes 1,920 samples, 80 ms of audio. Because every layer is causal, the decoder can emit those 80 ms as soon as the latent exists. Combined with the per-frame generation loop, that is what makes the roughly 200 ms time-to-first-audio Kyutai reports possible.

The whole flow in code

The script below follows the pipeline end to end with random weights. It makes noise, not speech, but every tensor has the shape the released model uses, so it is a map you can print and poke. It needs only NumPy.

"""Shape-faithful toy of the Pocket TTS data flow, with random weights.

It produces no intelligible speech. Every tensor has the shape the released
model uses, so you can print and follow the pipeline end to end.
"""
import numpy as np

rng = np.random.default_rng(0)

SAMPLE_RATE = 24_000
FRAME_RATE = 12.5
HOP = int(SAMPLE_RATE / FRAME_RATE)  # 1 920 samples per latent frame
D_LATENT = 32                         # Mimi latent channels
D_MODEL = 1024                        # transformer width
N_HEADS = 16
VOCAB = 4_000


def layer_norm(x, eps=1e-5):
    mu = x.mean(-1, keepdims=True)
    var = x.var(-1, keepdims=True)
    return (x - mu) / np.sqrt(var + eps)


def causal_self_attention(x, wq, wk, wv, wo):
    """x: [T, D_MODEL] -> [T, D_MODEL], one layer, N_HEADS heads."""
    T, D = x.shape
    dh = D // N_HEADS
    q = (x @ wq).reshape(T, N_HEADS, dh).transpose(1, 0, 2)  # [H, T, dh]
    k = (x @ wk).reshape(T, N_HEADS, dh).transpose(1, 0, 2)
    v = (x @ wv).reshape(T, N_HEADS, dh).transpose(1, 0, 2)
    scores = q @ k.transpose(0, 2, 1) / np.sqrt(dh)            # [H, T, T]
    scores += np.triu(np.full((T, T), -np.inf), k=1)            # no peeking ahead
    weights = np.exp(scores - scores.max(-1, keepdims=True))
    weights /= weights.sum(-1, keepdims=True)
    out = (weights @ v).transpose(1, 0, 2).reshape(T, D)
    return x + out @ wo


def rand(*shape):
    return rng.standard_normal(shape) / np.sqrt(shape[0])


# --- 1. Voice prompt: 10 s of audio -> Mimi latents -> speaker_proj ---------
voice_wave = rng.standard_normal(10 * SAMPLE_RATE) * 0.1     # [240 000]
voice_latents = voice_wave.reshape(-1, HOP) @ rand(HOP, D_LATENT)  # stand-in encoder: [125, 32]
speaker_proj = rand(D_LATENT, D_MODEL)
voice_prefix = voice_latents @ speaker_proj                   # [125, 1024]

# --- 2. Text: ids -> lookup table ---------------------------------------
text_ids = np.array([17, 942, 3051, 8])                        # "Hello world." after tokenizing
lookup_table = rng.standard_normal((VOCAB, D_MODEL)) * 0.02
text_prefix = lookup_table[text_ids]                           # [4, 1024]

# --- 3. Weights for one transformer layer, the head and the feedback ---
wq, wk, wv, wo = (rand(D_MODEL, D_MODEL) for _ in range(4))
input_linear = rand(D_LATENT, D_MODEL)
bos = rng.standard_normal(D_MODEL) * 0.02
head_cond = rand(D_MODEL, D_LATENT)
head_noise = rand(D_LATENT, D_LATENT)


def flow_map_one_step(h_t, noise):
    """f(x_0; s=0, t=1, h_t): noise [32] -> latent [32] in a single call.

    A real head is a 6-block MLP with AdaLN; this toy keeps the signature:
    x_1 = x_0 + (1 - 0) * F(x_0, s, t, h_t).
    """
    velocity = np.tanh(layer_norm(h_t) @ head_cond + noise @ head_noise)
    return noise + velocity


# --- 4. Autoregressive generation, one latent frame per step -----------
temperature = 0.3
n_frames = 13                                                  # about one second
generated = []
for step in range(n_frames):
    past = [bos] + [x @ input_linear for x in generated]
    sequence = np.vstack([voice_prefix, text_prefix, np.array(past)])  # [125 + 4 + 1 + step, 1024]
    hidden = causal_self_attention(sequence, wq, wk, wv, wo)
    h_t = hidden[-1]                                           # context vector for this frame: [1024]
    x0 = rng.standard_normal(D_LATENT) * np.sqrt(temperature)  # noise: [32]
    generated.append(flow_map_one_step(h_t, x0))               # latent: [32]

latents = np.stack(generated)                                  # [13, 32]

# --- 5. Stand-in Mimi decoder: each latent -> 1 920 samples ------------
decoder = rand(D_LATENT, HOP)
waveform = np.tanh(latents @ decoder).reshape(-1)              # [24 960]

print("voice prefix  ", voice_prefix.shape)
print("text prefix   ", text_prefix.shape)
print("last sequence ", sequence.shape)
print("latents       ", latents.shape)
print("waveform      ", waveform.shape, f"= {waveform.size / SAMPLE_RATE:.2f} s at {SAMPLE_RATE} Hz")

# --- 6. The worked example from the article ---------------------------
x0 = np.array([-0.5, 0.9])
x1 = np.array([0.8, -0.6])
for t in (0.0, 0.5, 1.0):
    print(f"t={t:.1f}  x_t={(1 - t) * x0 + t * x1}  v={x1 - x0}")

Its output:

voice prefix   (125, 1024)
text prefix    (4, 1024)
last sequence  (142, 1024)
latents        (13, 32)
waveform       (24960,) = 1.04 s at 24000 Hz
t=0.0  x_t=[-0.5  0.9]  v=[ 1.3 -1.5]
t=0.5  x_t=[0.15 0.15]  v=[ 1.3 -1.5]
t=1.0  x_t=[ 0.8 -0.6]  v=[ 1.3 -1.5]

The toy re-runs attention over the whole sequence at every step to keep the code short. The real model keeps a key-value cache, so each new frame costs one position of attention rather than the whole prefix, which matters when the voice prompt alone is 125 positions.

What to take away

Tokens made audio fit the language-model machinery, at the cost of a rounding step and a long sequence. CALM keeps the machinery (a causal transformer reading a prefix and predicting the next frame) and replaces the softmax with a generative head over continuous vectors. Pocket TTS then makes that head cheap: it learns the ODE’s solution map directly, so each frame costs one call instead of a loop.