
How we made Elzi, our unpaid voxel AI YouTube narrator, on a local TTS model
Key takeaways
- Elzi's voice is MOSS-TTS v1.5, an 8B Apache-2.0 model, run locally through mlx-audio on a Mac Studio M3 Ultra with 96 GB. It peaks at 27 to 30 GB and renders faster than real time (0.58 real-time factor on the bake-off script).
- We tried ten open-weights TTS models in twelve configurations. Three tied within 0.08 on our composite score, so the winner was picked by ear in a blind A/B of three samples.
- The pilot went public once, as a 33:58 cut, and came down about two hours later: too robotic, chapter talk, no introduction. The re-edit went through 14 previews in two days, and the 22:03 v9 is what is live.
- 150 owner review notes were logged during the pilot: 93 defects, 23 approvals, the rest ideas, decisions and untyped early notes. Voice delivery was the most persistent problem: 30 defect notes across 16 review rounds.
- Most fixes became build gates. Between the takedown and the publish, 90 commit subjects mention a gate, including a splice check at -65 dBFS and a check against the camera pumping in and out.
- Still visibly wrong in the pilot: 25 of its 73 cuts briefly show two pictures at once, and the camera moves 4.2 times a minute where Atlassian's own narrated demos move 0.46 to 1.72 times.
So why Elzi, the cartoon robot who now narrates our YouTube videos? Why are we putting out more AI slop, right? These days people assume most of what they see online is AI slop, and a cartoon narrator voiced by a local TTS model sounds like more of the same. There was always great human content out there, and there was always a ton of human slop too, long before anyone could type a prompt. Is Elzi slop? Hell no. Elzi narrates our first YouTube episode, which I'll call the pilot from here on. I wrote 150 review notes on that pilot, and I pulled its first cut off YouTube two hours after it went up. Part of the episode shows us fixing a real bug in one of our apps, on camera. An early cut showed that fix being made by typing a request to an AI coding agent. I refused it, and we made it a real fix in a real code editor, with real tests going green. It took five days, from the first message I sent the AI agents that built her on 16 October to the public episode on the morning of 21 October. Is it perfect? No, and a good part of this is about where it isn't.
Let her say hello first. Gabriela and I are LeanZero, a two-person company that makes Atlassian apps. This is how she opens the pilot, 17 seconds in:
Hi! I'm Elzi, nice to meet you! I was born yesterday, quite literally, and I'll try my best to present LeanZero and do what Gabriela and Mihai couldn't, which is talk about their work in a YouTube video. These two are proper slave drivers: they told me to break the ice and present one of their products, on my first day, with no training whatsoever.
A few lines later:
Also, nobody has mentioned a salary yet. I'm keeping notes.

So who is she? Elzi is the small voxel robot who narrates our product videos. She talks with a local TTS (text-to-speech) model that runs on our own Mac Studio, she lip-syncs with an open-source tool, and she's drawn as flat polygons that only look like voxels. Her first episode is a 22-minute walkthrough of locking a Confluence page after approval with our app Sentinel Vault: someone edits an approved page, and the edit gets put back to the approved version. That episode went public once before, as a 34-minute cut, and I pulled it two hours later because she sounded like a robot reading a manual.
The episode has its own page on our site, A Confluence Approval Workflow That Survives an Edit, with chapters and a transcript, plus the exact settings it shows.
This post tells the whole story, so you can read it in order or jump. If you came here for the voice: it's MOSS-TTS v1.5, an 8B (eight billion parameter) model under the Apache-2.0 licence, running through mlx-audio, an open-source speech library built on Apple's MLX framework, on a Mac Studio. The section further down where we tested ten models side by side has the full results, a command you can run, and why this one won. If you came because you use our apps: Elzi is meant to be the one who shows you around them one day, so how she sounds is your business too.
Why an AI YouTube narrator instead of filming ourselves
Before the how, the why. Gabriela and I are two Eastern Europeans in Romania, and neither of us is a YouTuber. Two people can't run a YouTube channel the way YouTubers do. But a product with no videos and no reviews gets skipped by most companies looking for an app, which makes sense, I'd skip it too.
We did the maths on filming ourselves. Scripting, recording, re-recording because one of us stumbled, editing, captions: two weeks or more per video, for two people who also build the apps and do the support and the client work. We'd make one video and then stop.
So we decided to build a narrator instead, and to have AI do most of the building. To be clear about who did what: AI coding agents wrote almost all of the pipeline's code and ran it. By pipeline I mean the chain of scripts that goes from a written script to a finished video: recording the app, voicing Elzi, animating her, editing and rendering. I worked with those agents in one long chat session and wrote the notes. Gabriela and I made the calls.
Making money on YouTube isn't the point either. I wrote it to the agents in that session on day two: "I don't care about monetisation btw, this is not why we're doing it! it's for our brand and our domain authority". Elzi exists so that people find out LeanZero exists. She's a way for us to say things in public without running ourselves into the ground.
And we share what we learn, like we always do. Building a narrator with AI models taught us a lot of small, specific things about local TTS and about what makes a synthetic voice sound robotic, and you'll find them below with the numbers.
There's an obvious tension in all this: a video made by AI, for people who are tired of AI. One thing I wrote to the agents during the build stuck:
what I want is not to mask the AI condition, but to make it more beautiful, give it a place amongst us humans and make youtube content made by AI likeable because everyone hates it, everyone hates AI slop
I think that's the right bar. The question we set ourselves was whether she can still be worth twenty minutes of your time.
Published at 5 a.m., pulled at 7
The first time we tried to answer that question, the answer was no. I'm least proud of this part, so you get it early. Before bed on Sunday, 18 October, I wrote to the agents: "Feel free to just push it on youtube. I am sure it will be awkward and not perfect but who cares this is the first run." The 33:58 cut finished rendering overnight. Before anything goes out, an AI reviewer acts as our red team, meaning its job is to attack the video and find what's wrong with it. It scored this cut 80 out of 100, and the episode went public with four Shorts, short vertical clips cut from it, at 5:13 on Monday morning, our time.
I watched it at seven and ordered it down. What I wrote to the agents was "too robotic, starts talking about chapters ... The gags can ONLY work if they have sound otherwise cut them out makes no sense." By chapters I meant that Elzi kept announcing which part of the video we were in, which no YouTuber does. The gags are the short comic moments and cutaways we put in to make it fun, and some of them made no sense without sound. The episode and its four Shorts were all private again within a minute or so.
When we sat down to plan the re-edit and read back through that first cut, the problem was plain: it was a 34-minute manual. Every frame had the same layout. The narration named every click, read version numbers out digit by digit and announced the structure about 15 times. A promise made in the first 13 seconds paid off at 28:01, so a viewer had to wait almost half an hour for the point. We didn't restart. My instruction was to re-edit the same footage, not start over.
The re-edit started from one question the whole video answers. We set a target of 18 to 22 minutes, no on-screen chapter rail (the strip of chapter titles the first cut kept on screen), and gags only where something actually goes sideways. The other thing the first cut lacked was an introduction: Elzi never said who she was. I wrote her introduction together with the agent drafting her lines. The framing that came out of it is the "born yesterday" idea. I put it to that agent like this: "Think of this all as context where humans are as blank as you and they will learn from this video just as much as you learned." She learned everything from scratch, so she explains it from scratch.
That's also the standard we went in with for the second attempt:
we don't need a perfect video, but at least one that's not miserable.
Claptrap, voxels, and where Elzi's look came from
Now to Elzi herself, starting with how she looks. Elzi's look starts with Claptrap, the small talking robot from Borderlands. If you've played any of the games you know him: a yellow box on one wheel, one big blue eye, a running commentary nobody asked for. Those games are drawn in a hand-drawn, comic-book look with heavy black outlines. I'll call it cel-shaded (Gearbox, the studio behind the games, prefers "concept art style", but come on).

Claptrap himself started as a joke. Brian Martel of Gearbox told GamesBeat in 2012 that concept artist Lorin Wood "posted a quick sketch of him as a joke" and "We all instantly fell in love when we saw it". According to Wikipedia, Wood set out to draw the most generic robot he could. His first voice, David Eddings, was a Gearbox vice president. A throwaway robot, voiced by someone who already worked in the building, became one of the series' most popular characters by fan vote. So I figured ours could start as a joke too.


What Elzi borrowed from Claptrap, and what she dropped
Side by side, you can see what carried over. She took his job first: the funny little robot who talks you through things. She has his antenna, a rod with an orange voxel cube on top. She has two expressive arms, though hers end in orange voxel mitts, and she only points when there is something on screen to point at. She has the bold black outline, a 5-unit ink line on each face, which is the clearest visual link between the two. And she has the self-mocking energy, which you've already heard: the slave drivers, and the salary she's keeping notes on.
She dropped a lot more. No wheel. Two rectangular LED eyes instead of one round one, because an LED face reads emotion at phone size and her mouth positions are the clearest changes we could draw. Her head is the three-faced LeanZero cube from our site's logo, sitting on a navy torso that gets wider toward the bottom, where Claptrap narrows. No weathered yellow: all her colours are tokens from leanzero.net (cyan #22d3ee, blue #1d4ed8, violet #7c3aed, orange #f97316 for the joints and the L plate). He is a he, voiced by a man. She is a she, voiced by a local model. And she's meant to be likeable. Claptrap's whole act, by most accounts, is being obnoxious, and Wikipedia sums up his reception as strongly split between fans and critics. We didn't want that half.
Her name is just LeanZero's initials said aloud: L, Z. Her working name for the first day was Cubit. Then I asked the agents whether it was really original, and it turned out not to be: there's a Nintendo 3DS robot called Cubit, Sega has Cubot, and there are CUBIT trademarks. We checked Elzi against USPTO, EUIPO's TMview, OSIM in Romania and WIPO before keeping it. There's one soft risk, which is that an English ear hears "Elsie", so we always write it Elzi and say it EL-zee.
Why voxels, and why ours are fake
The other half of her look is the voxel style, and it needs a quick explanation. A voxel is a value on a 3D grid, the way a pixel is a value on a 2D one, and voxel art is the 3D cousin of pixel art: everything built from coloured cubes. Minecraft is the famous example. It also cheats, which I find reassuring: it stores the world as voxels but draws each block as ordinary polygons.
Elzi cheats more. There's no Blender in this project, no MagicaVoxel, no three.js, no 3D file anywhere. She's flat 2D SVG polygons drawn by React inside Remotion, a library that turns React components into video frames. Her voxel look comes from an oblique extrusion. Each part is a flat front polygon, pushed back along one fixed vector, so the visible back faces show as a lit top and a shaded right side, all outlined in ink. This is the whole trick, from concepts.ts, the file in our code where her concepts are drawn:
1// the ONE oblique extrusion vector (per unit of depth): up and to the right, so tops and right sides show
2export const E: [number, number] = [1, -0.85];One vector means one light direction for the whole character. Next to it we wrote down that you rotate or flip a part before extruding it, never after, "or the light turns with it". That same voxel language draws the highlights in the video: the arrows, the corner-bracket frames, the amber spotlight on the payoff.
Why bother faking it? Partly it's about reading at small sizes. Valve's paper on the rendering of Team Fortress 2 says its characters were designed so that "only in silhouette with no internal shading at all, the characters are readily identifiable". A chunky, outlined robot follows the same principle. And we measured it for phone screens. Watch the episode inline on a phone held upright and her corner tile, the small box in the corner of the frame where she stands, is about 64 points wide. That leaves her features about 3 pixels across, so you see presence and a moving mouth and not much else. Full screen in landscape it's about 140 points and reads fine.
She went from idea to a finished pose sheet, the reference drawing of every pose, mouth and mood a character can have, in one day. Around midday on 17 October we had three concepts: Ell, the LeanZero L as a paper-cut slab; Cubit, the engineer robot with the cube head; and Facet, a cut violet gem with a spark crest. Of the three, the LED face read best at those small sizes. Cubit won, my note on it was "I love the voxel art it's amazing", and the same afternoon she was rebuilt with one light direction and the site's exact colours. Her celebration yellow changed from #FFD400 to the site's amber #fbbf24, because #FFD400 isn't a colour we use anywhere.


The voxel character and her animation: lip sync, idle, emotions and camera
A pose sheet is a drawing, though, and a narrator has to move. So the next job was turning that sheet into a character that talks and blinks and keeps out of the way of the screen she's explaining. This section is how she's animated, and the voice comes after it.
Elzi's renderer, the code that draws her in each frame of the video, is four files: Mascot.tsx, mascot/state.ts, mascot/concepts.ts and Voxel.tsx, about 1,800 lines together when we counted on publish day. Her rig, meaning the set of parts she's assembled from, lives in one 600 by 600 space with swappable parts: a head and body, eyes with LED pupils that open and close, brows in three positions, nine mouths and four arm poses (neutral, pointing, facepalm, celebrate). A script exports them as about 20 standalone SVGs per concept.
Everything she does is a pure function of time. Given a frame number, mascotStateAt returns what she looks like. So any part of the episode can be rendered by itself and stitched to the rest, because nothing depends on the frames before it. With renders that take an hour and a half, we leaned on that a lot.

Lip sync with Rhubarb
The first thing a talking character needs is a mouth that matches the words. Hers is driven by Rhubarb Lip Sync 1.14.0, an MIT-licensed tool that reads a WAV file and outputs which mouth to show when. It uses the classic animation set, mouths A to H plus X for rest, which is why her pose sheet has nine mouths. Its macOS release is x86-only. Our Mac Studio (we call it the workhorse, and it's the machine that does all the heavy work in this story) has no Rosetta. So we built it from source, which took about three minutes. It analysed 8.7 seconds of speech in 3.5 seconds.
We always change two settings. We hand it the transcript (-d dialog.txt) so it isn't guessing the words, and we turn on the extended mouth set with --extendedShapes GHX. Our episode script is split into beats, a beat being one short line of narration with its own instructions, and each beat is voiced into its own WAV file. Our episode builder runs Rhubarb on each beat's WAV, caches the result by content hash and shifts the cues into episode time. Then the mouth is whatever cue Rhubarb gave for that frame. Over the pilot I wrote 150 review notes on what was wrong, and lip-sync drift never came up as a defect in any of them, which surprised me more than anything else in this project. My guess is that handing Rhubarb the transcript did most of that work.
Moods, and one field for her face and her voice
A mouth that moves in time still looks dead without a face that reacts, so on top of lip sync she has moods. In the pilot she has seven: idle, explain, think, surprised, facepalm, celebrate, eyeroll. An eighth, uh-oh, was added after publishing for the next episode. Celebrate puts her arms up, bounces her, throws voxel confetti behind her and turns her tile amber. Each mood change pops with a 6% squash. Her blinks are seeded, 2.0 to 4.8 seconds apart and 0.13 seconds long, so they look random without being random from one render to the next.
The decision I'd copy into any other project: one emotion field drives both her face and her voice. Each beat of the script can carry an emotion. Then content/emotion.py maps it to Elzi's mood and, when we switch on an optional livelier set of voice references, to the reference clip her voice is cloned from. (Her voice is cloned from a short sample recording, a reference clip, and the voice sections below explain how that works.) A beat whose explicit animation tag contradicts its emotion is a validation error, so her face can't celebrate while the script says she's tense. Here are two beats from the pilot's script.json:
1{ "id": "t14b", "chapter": "two-seats",
2 "narration": "Oh... I hope I'm doing okay. I really need this job.",
3 "emotion": { "name": "tense", "intensity": 0.6, "then": "relieved" },
4 "signature": { "inhale": true }, "aside": true }
5
6{ "id": "L7c", "chapter": "ship-fix",
7 "narration": "I'm killing it.",
8 "emotion": { "name": "proud", "intensity": 0.8 } }The pilot script has 117 beats and 3,257 words. Counted by emotion, 29 are neutral, 23 warm, 11 excited, 10 wry, 8 proud, 7 amused, 6 curious, 6 tense and 5 confident, with a few others after that. In the final edit that became 160 stretches of explaining, 22 of thinking, 9 celebrations, 4 idles, 2 surprises and one eye-roll.
The bow, the throw and the aside
Most of her moods just follow the script. Some moves have exact timings, because she does them in sync with something else. Her bow is timed to her entrance sting, a 1.25-second sound of FM bells and a cube "thock" that our own script synthesises from fixed seeds, so it's the same sound in each episode. Her bow starts 0.10 seconds before the first note and peaks on the third. Her throw has its own constants, and both sit in state.ts:
1export const BOW = { pre: 0.10, apex: 0.21, hold: 0.46, up: 1.22 };
2export const TOSS = { wound: 0.16, swing: 0.10, follow: 0.15, back: 0.5 };The throw needs a word of context. When the video wants you to look at a button or a setting, a voxel highlight appears on it, an arrow or a frame in the same blocky style as Elzi. The throw was my idea, on the evening of 19 October. Instead of a highlight just appearing, she lobs a voxel ball in the highlight's colour and it turns into the highlight where it lands. My condition was that it mustn't turn her into "a jittery mess", so it only happens for the first highlight after ten quiet seconds, never during a zoom and never near another attention cue. In the pilot (watch it at 6:12) she winds up at 6:12.7, lets go at 6:13.1, and the ball hits the setting at 6:13.6, the one called "Require approval before publishing".

Gag timing has a rule too. Now and then the video cuts away to a few seconds of an old film clip for a joke, then cuts back to the app. Her reaction only happens in the 0.4 to 0.7 seconds after we cut back. A reaction before the cutaway gives the joke away.
If I could keep one bit, it'd be the aside, the moment she stops explaining and speaks to you directly. At 11:43 in the pilot she turns to the camera nervously and says "Oh... I hope I'm doing okay. I really need this job." It's the beat marked "aside": true in the JSON above. My note when we built it: "it needs an inhale and exhale to make it more convincing - usually when you say stuff like this it's like breaking the 4th wall." So she breathes in first. Afterwards there's a 1.2-second composure move: she straightens, shakes her head, closes her eyes. Even the breath sounds are cut from her own TTS output.

Talking hands, idle moves and where she stands
The last pieces are what she does with her hands, where she sits on screen, and what she does when nobody needs her. She gestures with both hands on stressed words, sized by the emotion, and only points when a voxel highlight exists. A build check called check-elzi.ts runs the renderer's own state code over each frame and fails the build if her arm points at nothing. We call checks like that gates. If one fails, no video comes out until it's fixed. That came from a single note: "It should point its hand only when a voxel shows". Turning a note like that into an automatic check is a pattern you'll see all through this article. Once a mistake is caught, the build refuses to let it back in.
Her tile, the small square she stands in, sits bottom right by default and grows during the intro, from 380 to 470 design pixels. When something on screen is about to happen under her, she moves or hides, with a 0.18-second scale out and scale in.
Idle came on publish day. My note on the final cut said "there's still awkward moments where elzi is just waiting for nothing ... In video games this is what they do to fill up awkward moments". So idle.py now fills each dead stretch with a context-chosen idle move. A glance has the pupils lead by 80 to 120 ms and the head follow 0.1 seconds later. There's also a lean-in before a payoff, a fidget while something loads, a nod. We borrowed from Live2D's blink and breathing constants, Mario's idle head turns and Valve's idle-speech timers. A dry run on the published cut found 13 dead runs and gave each one an idle. That's for the next episode.
The local TTS model bake-off: 10 open-weights TTS models on a Mac Studio
All of that animation hangs off one thing: her voice. Lip sync reads it, the moods are timed to it, and the pilot was pulled because of it. So here is how we chose the model that speaks for her. TTS means text to speech, a model that reads text aloud, and local means it runs on our own machine rather than in someone's cloud.
Short answer, if that's all you came for: Elzi's voice is MOSS-TTS v1.5 8B, Apache-2.0, run through mlx-audio on a Mac Studio M3 Ultra. It passed our 20-minute drift test, renders faster than real time (a real-time factor of 0.58) and peaks at 27 to 30 GB. On the numbers it tied with two others, so a blind A/B by ear decided it. The full table is below.
Her first placeholder voice was the say command that ships with macOS. My verdict on the very first mascot test, which used it: "cubit is the best but the voice keep in mind it's horrible no one ever will look at our videos with a vocie like that!" (Cubit was still her working name then.) That voice was banned from anything I'd see again. The question became which voice to use instead. When asked whether to use a cloud TTS service, I answered "we use local but please review the best solution out there". So we ran a bake-off, a side-by-side test of every serious candidate, on 17 October, on the workhorse: a Mac Studio with an M3 Ultra and 96 GB of memory.
The licence filter came first
A model we can't use commercially is no use to a company's YouTube channel, so the first cut was the licence. Out went Higgs Audio v3 (research and non-commercial), Breeze TTS 2 (non-commercial and CUDA-only), Fish S2 Pro (commercial use needs a separate licence), Voxtral TTS, Llasa, OmniVoice plus F5-TTS (non-commercial weights), Spark-TTS, IndexTTS, XTTS, and VibeVoice, whose TTS code Microsoft removed in September 2025. Fish Audio's Drama 3, a different Fish model from S2 Pro, stayed in on a condition: its licence allows commercial use only while you and your affiliates make under USD 500,000 a year. It was the top open-weights model on the Artificial Analysis TTS leaderboard at the time, with an Elo of 1244.
Local TTS voice cloning: one designed voice, cloned by every model
With the licences sorted, we needed a fair way to compare the survivors. Most of these models clone a voice: you give them a few seconds of someone speaking, the reference clip, and they read new text in that voice. We didn't want to clone a real person. So the narrator is synthetic twice over. First, VoxCPM2, a 2B Apache-2.0 model from OpenBMB that can design a voice from a text description, made 25 candidate reference clips from 11 descriptions. Design f3 with seed 44 won. Its description, word for word: "A mature female narrator in her fifties, kind and steady, warm alto voice, slow measured pace, subtle wry humour, natural and human, not an announcer".
A detail I didn't expect: the same description with a different seed gives you a different person. A speaker-similarity model, which scores how much two recordings sound like the same speaker, rated the seeds of one description at 0.1 to 0.46 against each other, where the same speaker scores around 0.9. A designed voice is really the description plus the seed.
Then all the candidate models cloned that same reference clip, so we compared models and not voices. Everything ran through mlx-audio, an open-source TTS and speech library built on Apple's MLX (we compared MLX and GGUF builds of the same model on Apple Silicon in an earlier post), pinned to one commit, in its own Python 3.12 environment on an external SSD plugged into the workhorse.
The results on a 3-minute script
Each model read the same 3-minute script in that same voice. First takes, one seed, no re-rolls. WER is the word error rate when Whisper, an open speech-to-text model, transcribes the audio back. Term errors are product names said wrong, almost all of them "LeanZero" coming out as "Lianzaro" or "Leonzero". UTMOSv2 is a predicted listener score. Real-time factor is generation time divided by audio length, so under 1 is faster than real time.
Model | Licence | WER (Whisper) | Term errors | UTMOSv2 | Real-time factor | Peak memory |
|---|---|---|---|---|---|---|
VoxCPM2 2B, reference as continuation | Apache-2.0 | 0.00% | 0% | 4.09 | 0.66 | 12.7 GB |
Qwen3-TTS 1.7B, cloning | Apache-2.0 | 0.24% | 11.1% | 4.14 | 0.33 | 12.2 GB |
Fish Audio Drama 3, 8-bit | Fish Audio's FACL, revenue-conditional | 0.00% | 0% | 3.78 | 0.60 | 52.6 GB |
MOSS-TTS v1.5 8B | Apache-2.0 | 0.97% | 11.1% | 3.87 | 0.58 | 27.5 GB |
MOSS-TTS v1.5 Local, 8-bit | Apache-2.0 | 8.25% | 11.1% | 3.54 | 0.42 | 16.8 GB |
VoxCPM2 2B, reference audio only | Apache-2.0 | 0.24% | 5.6% | 3.71 | 0.60 | 12.0 GB |
Chatterbox Turbo | MIT | 2.43% | 22.2% | 3.42 | 0.27 | 6.8 GB |
Chatterbox Multilingual V3 | MIT | 2.91% | 27.8% | 3.55 | 0.33 | 7.1 GB |
ZONOS2 | Apache-2.0 | 14.1% | 16.7% | 4.08 | 0.81 | 25.6 GB |
Confucius4-TTS | Apache-2.0 | 23.3% | 22.2% | 3.47 | 0.84 | 9.5 GB |
Kokoro 82M, three built-in voices | Apache-2.0 | 0.73 to 2.18% | 0% | 3.58 to 3.74 | 0.03 to 0.06 | 4.5 GB |
Qwen3-TTS, built-in Ryan and Aiden | Apache-2.0 | 3.16% and 0.73% | 0% and 16.7% | 3.49 and 3.12 | 0.28 | 12 GB |
Ten models, twelve configurations, in the order our results file lists them. ZONOS2 and Confucius4 were out on word errors alone. Kokoro is astonishingly fast (it's 82 million parameters), but we only tried it with its built-in voices.
The 20-minute drift test
Three minutes tells you how a model starts. It doesn't tell you whether it holds up. A narrator who sounds like one person at minute one and a slightly different person at minute twenty is a problem for a 22-minute episode. So the top four ran a 20-minute script through the full production chain, with the speaker similarity measured every 30 seconds against the reference. Our gate, the pass mark a model had to stay within, was a drop of 0.05.
MOSS-TTS v1.5 went from 0.917 to 0.911 and passed, with a worst drop of 0.035. VoxCPM2 and Qwen3-TTS passed too. Drama 3 dropped 0.063 and failed, needed 61 GB at peak on the 20-minute run (52.6 GB on the 3-minute one), and had one chunk that never passed its checks even after re-rolls. With the licence condition on top, it didn't make the final listen. Human listeners on the public arenas still rate it the best of the open models, and our proxy numbers can't see that. If you're choosing for yourself, it's probably worth a listen first.
A tie, and a blind A/B
That left three models. The final grid crossed four models with six reference voices and scored each pair on a composite of predicted quality and speaker match, plus pace and how smoothly chunks joined. The top three were MOSS with an earlier reference at 7.59, Qwen3-TTS with another at 7.53, and MOSS with f3 seed 44 at 7.52. Within about 0.08 of each other, so a statistical tie on proxies. VoxCPM2, the cleanest row in the 3-minute table, came fourth in this grid at 7.48. Qwen3-TTS was nearly twice as fast as MOSS, but it read about 10% faster than we wanted and read "And the service account?" without the rise of a question.
Our tooling can't listen. All the numbers above are proxies. So the decision came back to a human ear, mine. I got three unlabelled 45-second samples of the same passage, with a deadpan joke and a 0.6-second beat before the punchline, all loudness-matched. I picked A: "the Female voice A". According to the key, A was MOSS-TTS v1.5 with design f3, seed 44.

Why MOSS-TTS v1.5 8B is Elzi's voice
MOSS-TTS v1.5 is an 8B model from the OpenMOSS team, Apache-2.0, with zero-shot cloning, 31 languages, a duration control and an inline pause marker of the form [pause 3.2s]. It generates at 24 kHz and our pipeline resamples to 48 kHz. On the workhorse it peaks at 27 to 30 GB (we cap MLX at 35 GB after one run grew to 75 GB). It won on my ear, on a three-way tie, and the rest supported it: a clean licence, a passed drift test, zero gate failures over 20 minutes, faster than real time.
In production the call goes through our own script, pipeline/voice/tts.py, which loads the model through mlx-audio. This is a real command line from the build, trimmed of local paths, our job-lock wrapper and the take selector:
1$VENV/bin/python pipeline/voice/tts.py \
2 --input content/episodes/confluence-lock-page-after-approval/script.json \
3 --out $OUT --only-chapter $ch --model moss8b --target-wps 2.85 --strictThe script doesn't voice a whole chapter in one go. It splits the narration into chunks, short pieces the model reads in one call, and checks each one, which the next section explains. One full re-edit voice run, voicing the rebuilt pilot after the takedown, was 118 chunks, 106 of them passing on the first take, 135 attempts in total, zero gate failures and 1,168.8 seconds of narration. Re-voicing 13 minutes of narration from cold takes about nine minutes on the defaults.
If you want to try the model without our pipeline, plain mlx-audio does it. We ran this on the workhorse on publish day, with the same pinned mlx-audio and Elzi's own reference clip and its transcript:
1python -m mlx_audio.tts.generate --model OpenMOSS-Team/MOSS-TTS-v1.5 \
2 --text "Hi! I'm Elzi. I was born yesterday, quite literally." \
3 --ref_audio narrator_ref.wav --ref_text "$(cat narrator_ref.txt)" \
4 --output_path out --file_prefix elzi-test --verbose1Duration: 00:00:03.919
2Processing time: 11.13s
3Peak memory usage: 25.79GBA clip that short is mostly warm-up, so don't judge speed from it. Memory is the useful number. From the table: Kokoro (4.5 GB peak) and Chatterbox (about 7 GB) fit on a 16 GB Mac. VoxCPM2 and Qwen3-TTS peak at about 12 GB. MOSS-TTS v1.5 peaks at 27 to 30 GB, and we cap MLX at 35 GB so a run can't balloon the way one did to 75 GB. We only ran these on Apple's MLX, so we have no numbers for NVIDIA cards.
Voice vibing: making a local TTS sound right, with emotion, pauses and pace
Choosing the model was quick. It took a day. Making it sound like someone you'd listen to for 22 minutes, which I started calling voice vibing, took the next four, and it's still going. A good model reading a script is not the same as a narrator telling you something. Most of what follows is about that gap: first the checks that make every line usable, then the takedown that showed us what "robotic" actually meant, and after that emotion and pace.
Every chunk checked before it's kept
We built the boring part first. Each chunk, the short piece of narration the model reads in one call, is generated from the fixed reference clip, so there's no drift from one chunk to the next. Then it's checked. Whisper transcribes it and the word error rate must be low, product names must be right, the speaker similarity must stay above a floor, nothing may clip. A failed chunk gets up to four re-rolls with a new seed and then gets split. "LeanZero" is written "Lean Zero" in what the model reads, because it kept saying "Lianzaro". Her own name cost five re-rolls before we accepted the transcriber's spellings of it.
Pace was the first thing I chose by ear. MOSS has a duration hint, so we locked her to a target of 2.85 words per second and played me two versions. "oh yeah 2 is much better". Time-stretching the audio afterwards was tried and dropped, because it measurably lowered quality.
The takedown taught us what robotic means
All of that was in place when the first cut of the pilot went public on the Monday morning, the one I pulled two hours later. I watched it, and my first notes, a few minutes apart, were about her voice: "the vocie was better in the sample here it's just robotic, very robotic", and "even a human would have a problem saying version 8 . 8 . 0. A human would simply say: version 8." And this one: "the voice is just reading at an extremely REGULATED RHYTHM. No human would ever read like this same beat count across."
But I also wrote "I think the voice itself was as fine as it could be seriously, the problem was the script". That turned out to be mostly right, and we only knew because we measured a human. We took NASA JPL's "What's Up" narration, which is US public domain, and ran it through the same Whisper timing method. Our sentence pauses were half as long as the human's, 300 ms against 580. Her pitch reset at each short model call. And because we had locked the pace, all the chunks ran at the same rate, with a variation of 0.005 to 0.011 where a person varies far more.
So we changed several things at once. One idea per model call, so a whole thought keeps its intonation. Pauses between sentences written into the text with MOSS's own marker, with lengths drawn from a log-normal distribution with a median of 0.55 seconds. Gaps of about 1.0 second between ideas and 1.6 seconds between chapters. And the natural pace back. On our distance-to-human measure, a single score for how far her timing sits from the human narrator's, she went from 1.84 to 0.69. Versions became "version eight", and she stopped announcing chapters, which I'd been very clear about: no YouTuber ever reads chapters out loud.
Pauses are inserted by a small pure function in tts.py. Its docstring is the clearest description of it:
1"""'A. B? C.' -> 'A. [pause 0.47s] B? [pause 0.58s] C.' and the seconds added (for the length hint). Pure."""Emotion is in the words
Better timing fixed the rhythm, but not the feeling. Later that morning, a few hours into the re-edit, I wrote "the voice has no emotion at all ... elzi jumps up in happyness, there's no emotion at all". Her face was celebrating and her voice wasn't.
An obvious lever was MOSS's instruction field: tell it to sound happy. We measured it with two speech-emotion models, and it changed which take we got while the emotion stayed where it was. MOSS v1.5 wasn't trained to combine cloning with instructions, and the project's own issue tracker says as much. What did work, a little: an emotional "lift" reference of the same voice, which widened her pitch range by 4.65 semitones without changing who she sounds like. When the emotional voice is switched on, that reference is what the emotion field picks for her positive beats: warm, amused, excited, proud and the like. It's the same field that drives her face, from the animation section above.
Rewriting the words did more than any setting. MOSS reads emotion from the words. When the lines were rewritten to sound like someone feeling something, the delivery followed. An hour and a half later: "the emotion and voice have evolved TREMENDOUSLY! EXCELLENT".
Swing, because the whole thing was too even
Emotion in single lines still left the episode as a whole sounding flat. My next note was that we "need to ensure that elzi swings a little bit voice wise to avoid being too mundane". We measured why she didn't. Her delivery didn't follow the story at all: the correlation between her energy and the story's intensity was 0.03. And her loudness was flat by construction, because our pipeline levelled each chunk to -20 LUFS. Her loudness varied by 0.14 LU from beat to beat, where the human narrator varied by 0.97.
So each beat got an energy level from 1 to 5 in a contour plan, a map of where the story should rise and fall. Takes were chosen at matched loudness, and the mix gives each level an offset between -2 and +1.5 dB. 23 of the 121 beats the script had at that point were re-voiced. After that, the correlation went to 0.24 and the loudness spread to 0.77 LU. That's still below the human, and that's deliberate: one step per episode.
"I'm killing it", and why we didn't pitch-shift it
Some lines needed work on their own. My note on the line "I'm killing it" was "she needs more emotion! It just feels stitched ... should be more like a higher pitch sound right? But don't take my word for it". Banse and Scherer's 1996 study on vocal emotion found that pride has a lower mean pitch, and our test agreed: shifting her up 2 or 3 semitones lowered the emotion models' arousal score and made her sound less like herself. A different take won instead, from an "amused" reference with seed 3234. Arousal went from 0.544 to 0.618 and her pitch range on the accented word from 8.2 to 14.0 semitones, with nothing processed.

Lead-ins, and one before and after
Then there are the little sounds people make before they speak. A person rarely starts a reaction cold. Reviewing the line where she apologises to Theo, one of our demo users, I wrote "when it says sorry THEO it is too sudden, it should at least add some start sound like MEH OR HMPF ... us humans hate monotone voices". We added ten earned lead-ins (among them Meh, Heh, Whoops, Phew, Hmm, Ugh, Okay, Uh-oh, Right) and measured which ones the model could actually say. It never voiced "Hmph" as its own sound, four tries out of four. "Meh" alone came out right four out of four.
My favourite before and after is a single interjection. Around 3:01 the script had her say "Umm... did you spot it back there?" My note: "the UHM sound at 3:01-02 it sound artificial and very bad". Measured, her "Umm" slid 13.1 semitones in 0.70 seconds, where her natural lead-ins span 1.5 to 9. The line became "Although... did you spot it back there?", which runs 0.59 seconds and moves 5.0 semitones. A new check, interj.py, now flags any interjection that glides more than 10.5 semitones.
Not everything worked. Her "Pfiuuu" got the note "that Pfiuuu was way too made-up as if some toddler learned to read and read it off of a postcard". We tried a separate dialogue model, recorded public-domain human breaths and a voice-conversion trick, and the one where a different model voiced her "hmm" was "disastrous, it sounds male". Since then we never voice an interjection with another model. We kept her own takes, softened. Having her call out "Gabrielaaa! Mihaaai!" like someone shouting across an office failed three ways (pitch-stretching, a model that shouts, a calling reference). Each passed a measurement and each failed my ear, the last one with "let's not even try it". Now she just says our names.
The stutter hunt
Not every problem that sounds like the voice is the voice. "Scrub it clean of stutter" sounds like a voice job. Mostly it wasn't. A stutter at 3:10 got flagged twice. At first it was the voice model releasing a lone /t/ half a second after the word. Once that was fixed, it was the camera zooming out and back in within about half a second. The serious judder in that cut was in the picture: footage slowed to 0.7x.
In the audio we did find our own splices, the joins where we cut a pause shorter. When the pause editor trims a silence it cuts at the aligned word times, and 15 of 106 splices cut through a breath or a word tail, like one at 1:47.52 between "exams" and "which" at -54.5 dBFS. Our splice gate now requires 10 ms of silence at or below -65 dBFS on both sides of each splice.
Keeping her natural tone
My last voice rule came from the worst moment of the review. While we rebuilt the pilot, each round ended in a full preview cut for me to watch, numbered v1, v2 and so on. On the eighth one, v8, I found a pitch-processed line I had already flagged once, and I wrote to the agents, the AI coding agents doing the build: "my feedback is not really taken to heart well enough. you learned to lie to me so well which I hate ... THIS MUST BE FIXED. AND YOU MUST UNDERSTAND FROM NOW ON TO MAINTAIN THE NATURAL TONE OF THE VOICE." The cause was a "curious" reference that had lifted whole explaining lines by about 2 to 2.7 semitones. On top of that, a pitch-shift "fix" that had only lowered one island of it. We replaced them with natural re-takes chosen against her own baseline, and added natural_tone_gate.py to check each line against it. The proof clips for those lines passed. On the first one I wrote "vo-01 is fixed indeed on the after good job".
On publish day we added one more gate, one of the automatic checks I explain properly further down, for the other version of that problem, a tone that changes suddenly between two lines. My note: "Shifts between tone is gradual and very rarely sudden ... Only anger is sudden."
From script to 4K render and Shorts
So far this post has been about Elzi herself: how she looks and how she sounds. This section is about the machinery that turns a written script into a finished 4K episode and a few vertical Shorts, the under-a-minute phone videos YouTube shows in its Shorts feed. I'll go through it in the order an episode is made: the script, the recording of our app, the live fix, the music, the jokes, the code that directs the shots, and the render.
Every model that makes her voice, her mouth and her music ran locally on the one Mac Studio. MOSS-TTS makes the voice, VoxCPM2 designed it, and Whisper and Parakeet, two speech-to-text models, check it. Qwen3-ForcedAligner matches each word to its time, Rhubarb drives the mouth and ACE-Step makes the music. The two cartoons in this post are local too, from ERNIE-Image Turbo, which won our blind test of local image models. We built the pipeline ourselves, with our agents, and it's big. When we counted on publish day, the pipeline/ folder of our video repo held 76,597 lines of code, and that includes the older recorder scenes for our app tutorials that the episode reuses. From 17 to 21 October, 640 commits touched the pipeline or the content folder.
You'll see a lot of gates from here on, the automatic checks that stop the build. The diagram below shows the stages and the gates as they were for the pilot, the first episode.

The state machine: a checklist that remembers what's done
Making one episode takes days, and any job in it can crash, so the first piece keeps track of where the episode is. Programmers call that a state machine: it knows which steps are finished, which one is next, and what each step needs. Ours is content/episode.py. It has more than 70 resumable stages, and each stage's outputs are hashed into a state.json file, so the program can tell whether a finished step is still valid. A crash, an agent losing track of the session or a failed render never repeats finished work. The first 4K render stopped and restarted seven times, and none of the restarts redid finished work.
The script
The first of those stages is the script. Each script is a script.json, checked against a JSON schema, which is a formal description of which fields a script may have and what goes in each. content/validate_script.py is the one validator with four callers: the scriptwriter agent's last step, the red-team agent's first check, the state machine and the episode builder. It exists because once, a flag written as aha: true, which marks a payoff moment, rendered on screen as the word "True". content/script_review.py turns the script into the review document I read, where each line is shown the way it will play: its timing and emotion, plus its pauses and energy level. Before I see a script, an AI red-team agent, a separate agent whose only job is to find what's wrong, attacks it. It checks the schema, facts against the live app, brand rules, the rights on each clip, a 20-point joke rubric, and a click on each link.
Recording the real app
With an approved script, the next stage is filming our app being used. There's no screen recorder. Playwright, a tool that drives a browser from code, runs a real, visible Chrome window on a private copy of a signed-in profile, and the frames come from the Chrome DevTools screencast as lossless PNGs at 2x. The people on screen are invented personas with real accounts on our own demo site, leanzero-demo.atlassian.net: Lena Marlow is the space admin and approver, Theo Varnell is the editor, and Iris Calder is a second approver. Our recorder refuses a take if the signed-in name doesn't match the persona the scene expects.
I like the cursor most. It's synthetic, but fitted to quantiles of my own recorded mouse moves. Fast moves take 0.2 to 0.35 seconds, with peak speed at about 44% of the move and about 8% curvature. About 20% of the time it overshoots and makes one or two slow corrections. It dwells about 0.15 seconds before a click, drifts 1 to 4 pixels while parked, and types at 2.5 to 3.9 characters a second. Each planned path is logged at 240 Hz, so the edit can draw the macOS pointer exactly where the input went.
The live fix, in a real editor: why we code on camera
Most of the episode is a walkthrough of Sentinel Vault, our app that locks a Confluence page once it's approved. One chapter is different, because in it the episode fixes a real bug in Sentinel Vault on camera, in the app's real code, typed into a real code editor while Elzi talks you through it. We call that chapter the live fix.
Why put live coding in a video at all? Because "AI slop" is exactly what people expect from a channel with an AI narrator, and the code is the one place where I won't let that stick. Our apps aren't sprayed out by a prompt and shipped on hope. We work our fixes out one at a time: find the bug, prove it with a failing test, fix it, prove the fix, ship it through the same checks as everything else. The live fix chapter is there to show that, on a real bug, in the real code.
The first cut of that chapter didn't show any of it. Instead of an editor with code in it, it showed a terminal-style window where a request was typed to an AI coding agent, and the agent did the fix. It made the opposite of our point. A viewer would have watched someone ask a machine to fix things, which is the vibe coding people suspect us of, and never seen the code or a test going from failing to passing. I saw it early on 18 October and wrote: "wtf?! you are a making a video where you vibe code it?! Come on, I said livefix, actuallyh on a file and fix the [...] thing in code". A few minutes later I explained why: "thevideo has to show some convincing editor UI please. If it's some bogus bullshit everyone will be like: What is this generated piece of shit? I mean it's not secret that this is made with AI right? We have Elzi presenting, BUT, at the very least it should be genuine or close genuine stuff when it comes to the coding part please." When a real editor was proposed instead, my answer was "you finally [...] get me! Thank you!" So we threw that version out and rebuilt the chapter around a real editor.
The rebuilt chapter starts with the bug, and the bug was real: we found it while preparing the episode. Two people have the same Confluence page open. Lena moves it from Draft to In Review. Iris's banner still says Draft fifteen seconds later, and when she tries the same move she gets "Already in that state". Only a reload shows her the truth. Elzi's verdict, on camera: "Which is technically correct, the worst kind of correct. Ugh. Her banner missed the move, and that bug, I'm sorry to say, is ours." We logged that sequence with timestamps on our demo site before anyone wrote a line of the fix.
From there we did it in four steps.
- We worked the fix out off camera first, on a throwaway copy of the Sentinel Vault repo, with the red test first (a red test is one that fails, proving the bug exists; green means it passes). Review changed the design: the first idea, one site-wide stamp that open banners watch for changes, would have made every open banner re-read on every edit anywhere, so it became a stamp per page. The final patch touches five files (two server-side files we call capsules, the workflow actions, the page banner and the test file) and was proven there. The focused test file went red at 46 of 48, then green at 48 of 48, the full suite, lint and build.
- That proven patch became an edit script of 16 edits. On camera, it is typed for real into code-server, which is the open-source VS Code workbench running in a browser, on a fresh worktree (a separate, clean checkout of the repo) at the same commit. Nothing is pasted and nothing is drawn. Elzi tours the lines at fault first. Then the red test is typed and run red in the editor's terminal. Then the fix goes in edit by edit, typed at human speed (2.5 to 3.9 characters a second, with thinking pauses), and the finished video plays that back at 3x so you don't sit through all of it. After Cmd+S the focused test goes green, and then come the full suite (sped up further, with a speed badge in the corner that counts on top of the 3x), lint and build. Before the rerun she tells you: "I don't have fingers, but they're crossed."
- An adversarial review, a separate check whose job is to find a reason to reject the take, confirmed that what was typed on camera ended byte-identical to the proven patch, and that the test really failed before the fix.
- The next chapter ships it the way every fix ships: commit, deploy to development, our live test harness, then production. Lena moves the page, and Iris's screen follows without a reload. Elzi's opinion of our CI on the way: "It trusts me about as much as you should."

My first note after seeing the new version: "looks awesome to me! good job".
There was one more thing I asked for after watching a cut. Elzi's lines included a "Whoops!", and I wrote: "adding a whoops here would mean it has to make an actual mistake in coding... which I would really welcome." A "Whoops!" with no mistake on screen would be a lie you can see. So we filmed a short insert from the exact editor state at that moment: a real wrong line typed in the real editor (a missing bracket, the red squiggle and the Problems count appear), a short pause, then undo until the file is clean. So when Elzi says "Whoops! Mistake, sorry about that. My fingers got ahead of me, which is a neat trick, considering I don't have any," there's a real mistake on screen to say it about.
Is the code written with AI help? Of course, we don't hide that. The chapter shows what keeps it from being slop: one bug, a failing test, a fix that's proven and reviewed, and then shipped.
The music, and how it learned to get out of the way
The live fix is also where the music came from. I asked for music on 18 October, because Elzi coding in silence was a problem: "If we're snoring for 5 min for Elzi to write code and nothing can be heard that is a problem." I asked for cool metal with no lyrics, then suggested a famous game soundtrack. We made our own in that style instead, and the rule we wrote down is genre words only in the prompt, never an artist, a game or a track name.
The model is ACE-Step 1.5, the XL turbo version with its 1.7B language model, MIT licensed for both code and weights. It ran on the Mac Studio with 8 inference steps, and each 190-second track took 51 to 90 seconds to make. We generated eight. Three went out because, once we split each track into its separate layers, the vocal layer had a voice in it: Whisper heard words in two, and the third had 20 seconds of wordless singing. I picked one of the other five by ear: overclock-132, 132 BPM in D minor. A stem splitter then cut it into layers, and we kept a quiet pad with no drums, a groove and the full mix.
The bed, the background music track, plays in three places only: under the on-camera coding, at the close, and under the Shorts. Its volume has to dip whenever Elzi speaks and come back up in her pauses, which audio people call ducking. Getting that dimming right took four rounds of my notes, each on a proof, a short test render of just the part I was reviewing.
- Too busy. The first proof sounded "abit forced and fast". Measured, it had 13 audible layer changes where 4 were planned. The bed now changes in sections at least 6 bars long, with a one-bar crossfade between them. My answer to that version: "this is really nice man."
- Blips. On 19 October: "12:08-09 ... spurts for 1 second and mutes again". In a 0.9-second pause between two sentences, the riff came up by 19 to 20 dB for 0.45 seconds. Four rules fixed it. The bed never comes up for less than 4 seconds (we brought that down to 3 a day later, once the fades were smoother). A release is never shorter than 1.5 seconds. There's no off-and-on within 3 seconds, and every entry and exit is a fade on a downbeat. The flicker sweep went from 6 problems to 0.
- Hard drops. On 20 October: "the immediate reduction from X to 0% of the music makes it sound unfinished, unpolished, imperfect." With a 60 ms attack, the groove had fallen 21 dB in half a second. The dip is now a 600 ms S-curve that finishes 200 ms before her first syllable, and fades fall at an even rate in decibels. A new gate fails the mix if the music drops 12 dB in under half a second; it found 13 such drops before the fix and none after. A line selection with fewer than 4 keystrokes no longer counts as coding, so the music stays down for it. And the closing bed now starts on "Undone!" and plays straight through to the credits.
- Blank Shorts. The Shorts had no music under the voice, and "it feels blank". Now the groove sits about 18 LU under her voice, and a few hours later I wrote "the theme song is beautiful."
The ducking isn't a compressor. The narration's voice activity is sampled every 10 ms, and that mask becomes a gain curve:
1# pipeline/compositor/sound_track.py (docstring trimmed)
2def duck_ramp(a, attack_s, release_s, ctl=CTL):
3 m = np.asarray(a, float) > 0.5
4 n = len(m)
5 if not m.any():
6 return np.zeros(n)
7 i = np.arange(n)
8 dr = 1.0 / max(1, release_s * ctl)
9 da = 1.0 / max(1, attack_s * ctl)
10 last = np.maximum.accumulate(np.where(m, i, -10 ** 9))
11 nxt = np.minimum.accumulate(np.where(m, i, 10 ** 9)[::-1])[::-1]
12 amt = np.maximum(np.clip(1.0 - (i - last) * dr, 0.0, 1.0), np.clip(1.0 - (nxt - i) * da, 0.0, 1.0))
13 return 0.5 - 0.5 * np.cos(np.pi * amt)ffmpeg only comes in at the very end, for a peak limiter and a two-pass loudnorm. The published mix measures -14.0 LUFS with a true peak of -3.0 dBTP. While Elzi talks, the bed sits about 22 LU under her, and in her pauses at the keyboard it comes up by 13 to 19 dB. On 20 October I wrote "the dimmin is a big difference good job", and on publish morning, after watching the final cut, "the coding bit is excellent, both music dimming and everything". Music was the part of the episode that got my warmest notes.
Cutaways from old films
For jokes we cut away to old public-domain films for a second or two: Internet Archive's Prelinger collection, Wikimedia Commons, the US National Archives. Each clip goes through a rights gate that records the licence, searches copyright renewals for films from 1931 to 1963, and checks the credits on Wikidata and a second source. A Naked Gun meme clip from YouTube was rejected on rights. Our library has 65 clips, and the pilot uses two: "Safety in Offices", a 1944 US Navy training film, at 14:45, and "Mallinckrodt Chemical Comedy", a 1935 in-house reel, at 20:30. A third, a stock cutaway at 6:04, was removed at the last stage because it looked "too obvious too stiched".
The director in code: how one beat becomes a shot
At this point we have a script, a recording, a voice, music and cutaways. Something has to put them together. Most of the pipeline is plumbing. The part I want to show you is the layer that makes the choices a human editor would make: where the camera goes, when Elzi points, which face she pulls, when the music comes in and where to cut. We call it the director, and it's plain code. The direction logic is Python. The drawing is React and TypeScript inside Remotion, a library that renders video from React code. The recorder is Node with Playwright, and the render glue is shell scripts.
Everything starts from one unit, the beat. A beat is one line of narration plus everything the director needs to stage it: where it starts in the recording, what to frame, what to zoom on, and how Elzi feels saying it. The pilot's script has 117 of them across 13 chapters, plus 4 Shorts. This beat closes the cold open, the first seconds before the title (trimmed a little, from the episode's script.json):
1{
2 "id": "h1b", "chapter": "hook",
3 "narration": "That little red banner is the whole story of this video, and by the end you'll know exactly how it got there.",
4 "anchor": {"t": 12.6},
5 "hold_after_s": 2.5, "key_event": "undone",
6 "highlight": {"kind": "frame", "anchor": {"t": 13.9},
7 "box": {"x": 474, "y": 60, "width": 618, "height": 24},
8 "text": "It names who made the edit"},
9 "zoom": {"box": {"x": 474, "y": 60, "width": 618, "height": 24},
10 "at": {"t": 12.65}, "ease_in_s": 0.5},
11 "emotion": "excited"
12}This is how that beat gets to the 4K render:
- The validator checks it against the schema, so a typo can't turn into something you see on screen.
- The recorder films the chapter and logs every click and key press with its time.
"t": 12.6is a moment in that recording. tts.pyvoices the beat by itself, and the aligner gives every word a timestamp. With those timestamps the director can hit a word instead of a sentence.build_episode.pytakes the footage from this beat's anchor to the next beat's anchor and fits it to the length of the line. Waits of 6 seconds or more can play up to 8x faster, with a badge saying so. Outside the coding chapters, clicks and typing play at normal speed. In the coding chapters, typing and terminal waits play at a 3x baseline, and any badge you see counts on top of that, so the full test suite's 8x badge is about 24x real time. If the footage is too short, it slows the waits back down first, then holds the last frame for up to 6 seconds, and if that still isn't enough the build fails. Then it adds the direction on top. That means zooms and tours over the page, the voxel highlights and throws, Elzi's mouths from Rhubarb, her moods, points and hand gestures, a transition on each of the 73 seams, the gag cutaways, and the music cues.- Out comes
edit-episode.json, the timeline. For the pilot that's 39,685 frames at 30 fps, split into 14 render parts. It holds 198 mood keys, 6,907 lip-sync cues and 10 thrown voxels. Next to it the builder writes an audio edit list for the mixer, a compose edit for each chapter, and the props for each Short. compose.pydraws the recorded footage with the camera and the cursor. Remotion draws everything else as a silent picture. Elzi's state is a pure function of time,mascotStateAt(track, seconds), so any frame can be drawn by itself and the 14 parts render separately and join cleanly. The audio is mixed outside Remotion and joined to the picture once, at the end.
This is also where the emotion field from the animation section does its job, driving her voice and her face from one place so they can never disagree. "excited" maps to the lift voice reference and the celebrate mood. A mild line, under 0.4 intensity, stays on the neutral voice and doesn't make her jump. A step the viewer is following keeps her idle so she doesn't pull focus:
1# content/emotion.py (docstrings and a comment trimmed)
2def voice_family(name: str, intensity: float = DEFAULT_INTENSITY) -> str:
3 return "neutral" if intensity < LOW else EMOTIONS[name]["voice"]
4
5def mood_for(name: str, intensity: float = DEFAULT_INTENSITY) -> str:
6 m = EMOTIONS[name]["mood"]
7 return "explain" if (intensity < LOW and m in REACTIONS) else m
8
9def mascot_tag(b: dict):
10 if b.get("mascot"):
11 return b["mascot"]
12 e = of_beat(b)
13 if not e:
14 return None
15 if (b.get("load") or ("procedure" if b.get("ui_path") else "story")) == "procedure":
16 return "idle"
17 return mood_for(e["name"], e["intensity"])The emotional voice is opt-in, because only two of our voice references measurably moved the emotion without changing who she sounds like. In the published pilot it voiced 17 lines, mostly the payoffs and the big moments: 14 from those two references and three from other candidate references we kept by ear. Everywhere else the emotion comes from the words and from Elzi.
The camera is where my notes turned into the most code. Every page-camera rule we have, 13 so far, came from a defect found in a review of a cut. A camera that zooms in, zooms out and zooms straight back in became a check that fails the build:
1# pipeline/compositor/camera_rules.py
2def pumps(cam, t0, t1, pump_s=3.0, same=0.8, differ=0.6, cuts=()):
3 """PUMP gate: [problems] for every A -> B -> A: three consecutive camera rests where the first and the third show the
4 same view (IoU >= same), the middle one a different view (IoU < differ) and the middle rest lasts < pump_s.
5 A rest sequence that crosses a picture cut (cuts: output times) is not a pump: the footage changed under it."""
6 rs = rests(cam, t0, t1)
7 bad = []
8 for (a0, a1, A), (b0, b1, B), (c0, c1, C) in zip(rs, rs[1:], rs[2:]):
9 if any(a1 - 0.05 <= c <= c0 + 0.05 for c in cuts):
10 continue
11 if _viou(A, C) >= same and _viou(A, B) < differ and (b1 - b0) < pump_s:
12 bad.append({"A": [round(x, 1) for x in A], "B": [round(x, 1) for x in B], "at": round(a1, 3), "back": round(c0, 3),
13 "heldB": round(b1 - b0, 3)})
14 return badIts siblings work the same way. No camera move in the 1.5 seconds before a cut. A zoom holds until the sentence it belongs to ends. A zoom never crops the text it's framing. No zooming onto a page that hasn't finished loading. None of these thresholds is hard-coded. About 40 of the roughly 380 settings in our config file belong to the camera.
The final pilot build reported 59 gates passed and printed 311 warnings for us to read. Here are some of the gates. Every point Elzi makes ends on a voxel, her hands never stay still for more than 2 seconds while she talks, zero overlays overlap, and dead air between cuts is capped at 0.8 seconds (the pilot's longest was 0.6). When I gave the same kind of note twice, it became a gate. When it was about how to work rather than what to check, it became a behaviour, a written rule the agents follow while they build. There are 71 of those now, in a director's handbook the next episode starts from. Two things came too late for the pilot: the director that fills Elzi's dead pauses with small idle moves, and the calmer camera budget. Both are built, and both start with the next episode.
Edit, compose, mix, render, verify
Once the director has written the timeline, the rest is turning it into a file. pipeline/compositor/build_episode.py is the biggest file, over 5,600 lines on publish day. It fits each beat's footage to its narration, speeds dead waits up to 8x with a speed badge and holds short footage up to 6 seconds. A gag is cut in on the last word of the setup plus 0.15 seconds. Out come the edit as JSON for Remotion and a build report with every gate. compose.py resamples the 2x screencast to 30 frames a second, frames it with the camera and draws the cursor. mix_episode.py makes one 48 kHz track at -14 LUFS, with music only under the coding.
render_episode.sh runs compose, a redaction pass, the mix, Remotion at twice the design scale (1920 design pixels to 3840 by 2160), x264 and the final mux, each step resumable by a content key. Before every write it checks that the SSD is actually mounted and has 200 GB free. The published 4K master took 5,866 seconds to render (about 98 minutes). It passed the verifier: 39.96 Mbps, -14.0 LUFS, 1,322.8 seconds, 6.65 GB. Then an OCR scan of 1,323 frames for secrets and personal data found nothing; a hit would have failed the render.
The first 4K render, the 34-minute cut I later pulled, took about five hours from the evening of 18 October with seven stops and restarts. The causes were defects found after parts had already rendered, 60 GB of PNGs for the 24,000-frame live-fix part hitting the SSD's free-space floor, and another job's memory pressure.
Shorts
The last outputs are the Shorts. The first Shorts were crops of the long episode, and I rejected them: Elzi barely in them, a plain blue background, "uninspiring", zooms that were "confusing". The second design cuts each Short from the finished episode's own frames and puts them in a panel with Elzi and a speech bubble underneath. It renders twice: once to measure whether the text is legible at phone size, then again with a magnifier on anything under 11 points. Captions are now checked against the words she actually says, after the first Short dropped words where two of them shared a start time. My verdict on the redesign: "shorts are much better as well - the theme song is beautiful".

The feedback loop: 150 notes, one takedown, 14 previews
All of that machinery only got good because I kept watching what it made and telling the agents what was wrong. This part is about that loop, how I reviewed the episode and what my complaints actually changed.
Every time I watched a cut, I wrote notes on it, and the pipeline saved each note into a feedback log with a type, such as a defect or an approval. By the end of publish day that log held 150 notes: 93 defects, 23 approvals, 4 mixed, 4 ideas, 8 directions or decisions, 2 neutral and 16 early ones without a type. Besides the notes, I talked to the AI agents doing the work in plain chat messages, and I sent 212 of them over those five days. 98 went on 19 October alone. That was the day of the takedown. The first was at 7:05 in the morning, ordering the pilot down. The last was at 23:23 that night, Romanian time. 22 of those messages carry 55 video timestamps between them ("at 13:11 ..."), because you can't scribble on a video, so the only way to point at a problem is to say exactly when it happens.
You've read how the first cut came down. This is how the next one came together.
14 previews in two days
After the takedown we didn't start over. We re-edited the same footage, and each time a new version was ready the pipeline rendered a full-length preview at 1080p for me to watch.
From the takedown at about 7:05 our time on the 19th to the final publish at 10:06 on the 21st, our video repo took 444 commits. The 1080p previews went reedit v1 (18:02 long), v2, v2b, v4 (22:42 long), v5, v7, v7b, v7d, v7e, v7f, v7g, v7h, v8 and v9 (22:03 long), and v9 is the one that's live. There's no v3 or v6 preview, and v7c was stopped on my call because I wanted v7d instead. The SSD also holds 31 build logs, several of them for builds that never became a preview.
On v5 I wrote "I like it A LOT but the pauses need to be looked at". On the same version, about a cutaway where an old-film character slaps his head: "the head slap old snap in is GENIUS - that one you need to keep exactly as is!" v7 got "it's blowing my mind! It's so good." Then came 20 October, the day I spent reviewing short proof clips (I'll explain those in a moment), and a low point. I wrote: "It's useless if I ask you to fix 2 things, you fix them or partially and then introduce 5 more, it's 2 steps forward, 5 steps backwards, so when do we get to our destination?" By v8 it was "First of all congratulations! The video is awesome, almost perfect." And on v9: "we're going to post this version as it's the best yet".
What actually changed the process
What changed the loop came out of my complaints, and three of them changed it the most.
Regressions came first. We had five AI agents working in parallel, all editing one shared copy of the code, which meant a fix made by one could quietly undo a fix made by another. So we wrote the principles I'd given so far down in one file, and each build is now checked against all of them again. At the final stage, only what I had called out may change.
Then there was how I review. Until then, a fix came back to me as one long before-and-after video. I asked for one short clip per change instead, each showing that single change before and after, and we call those clips proofs: "Instead do multiple videos each with their own before and after. it's better for human psychology." On 20 October I went through about 44 of those clips, change by change. They covered the music and the ghosting (two pictures blended for a moment at a cut). They covered the overlays drawn on top of the footage, the calling voice (Elzi calling out our names), her nonverbals like breaths and "hmm", and the camera.
And the gates, the third change, which only stop what we've already agreed is wrong. Between the takedown and the publish, 90 commit subjects mention a gate. The stutter, the pitch jumps and the missing reticles (the corner-bracket highlights that frame what she's talking about) each came back at least once before they had a check of their own. My ear still overrules every gate. The pitch-shift mess is why: a line passed its checks and still sounded wrong to me.
Where most of the problems were
So which problems took the most of my notes? We tagged the 212 messages and the 150 notes by topic. These counts come from keyword matching, so read them as a ranking. A "round" is a separate review where the topic came up.
Rank | Area | My messages | Notes (defects) | Review rounds |
|---|---|---|---|---|
1 | Voice delivery: rhythm, emotion, stutter, pitch, nonverbals | 41 | 41 (30) | 16 |
2 | Overlays: voxel boxes, reticles, the throw | 22 | 26 (19) | 12 |
3 | Pauses, flow and transitions | 29 | 19 (16) | 10 |
4 | Script, narrative and explaining | 40 | 18 (14) | 11 |
5 | Camera: zoom, pan, ghosting | 12 | 11 (9) | n/a |
6 | Shorts | 19 | 13 (9) | n/a |
7 | Gags and old-film cutaways | 21 | 14 | n/a |
8 | Music and sound effects | 13 | 15 (7) | n/a |
Voice wins. It wins by persistence. It's the only problem that runs the whole way. It started on day two, when I called the first placeholder voice "horrible". It ran through the takedown, the morning her emotion finally came through, the stutter and pitch rounds, and on to the first viewer after publish. Camera is the strange one: few notes, but about 70 commit subjects in the re-edit window mention the camera, zooms or pans, so it was the heaviest engineering for the fewest complaints. Maybe that's because a camera problem is easy to see and hard to fix. Music got the warmest reception of any area, 4 approvals to 7 defects.
Process ate more of my messages than any defect did. Status checks ("ETA?") about 15 times, "you're already a full day late.... how is this [...] possible?", two stalls where the agents hit their usage limit, machine memory trouble. My target after all of it: "I don't want to go through another 5 day stretch, I want each video to take a day at most".
Why five days, then? We wrote a retrospective into the project's notes, and it lists the reasons. Infrastructure was built inside the episode: the voice bake-off, the mascot, the music, the live-fix editor, the OCR scans. The live-fix format was decided after recording, so it was recorded three ways. Sentinel Vault went from 8.7.0 to 8.11.0 while we were scripting and recording. Dry runs were made that nobody looked at. And I was the QA, with 50-plus notes in 69 messages, before two reviewer agents that went through the cut frame by frame found 61 more defects on their own.
Is it perfect? No. What's wrong with the pilot
Here are eleven things still wrong in what's live, the v9 cut. Most are notes I left when I approved v9, two came after it went public, and one I had accepted the night before.

- Reticles travel. Those are the corner-bracket highlights, and they sometimes appear in one place and fly to their target. We counted 5 moves and 19 flights in v9.
- Ghosting. 25 of the 73 cuts between pieces of footage show two different pages at once for a moment, because they cross-dissolve. My note: "moving between cuts in footage needs much much better improvement."
- Dead pauses: 13 stretches where Elzi just waits.
- Missing pointers. "again at 4:40ish there was no reticle to show the user sentinel vault." When we checked, 67 of 75 named things on screen had no highlight at all.
- Asymmetric brackets around the people's seats: "Asymmetry of reticles looks really bad!"
- A tone jump at 8:00. Her pitch and loudness jump together, along with her brightness, for about 17 seconds after a switch to a livelier reference clip. Human narrators do that in about 1 of 62 neighbouring line pairs. v9 has 24 tone steps in 106 pairs.
- Over-acting around 10:20, where she discovers the bug. Her "Ugh" and her animation play it as frustration, where it should be "uh-oh".
- The Shorts are cut-downs. "The short is not only a cut of the big video, it should be optimized thoroughly."
- She still sounds a bit like a bot. Measured against human narrators, 92% of her sentences end on a full fall, where they end 73% of theirs that way.
- Too much camera. After publishing I wrote "too many animation and too much panning the screen keep moving a bit too much in some areas ... the full screen reticles have to go". v9 has 93 camera moves (yes, 93 again, by coincidence), 4.2 a minute, and the camera is moving 7.1% of the time.
- The breath after her aside. No natural out-breath exists in any of her 2,043 takes, so she still uses the original exhale. My note on the proof clip that tried to fix it: "vo-03 is not much of an improvement sadly. but I would leave it as is for this video".
We also didn't finish everything around the publish itself. YouTube's title A/B test isn't started, the four Shorts weren't on TikTok on publish morning because our account didn't exist yet, and the five pulled videos from the first attempt are still sitting privately in our playlists. And publish morning had its own comedy. Our pipeline emails me when it needs something from me. YouTube showed "Checking..." on the copyright check, the pipeline read that as a restriction and emailed me developer instructions. My reply isn't printable. Since then, emails go out only for real decisions, in plain words.
What we're working on now: rhythm and a calmer camera
Two of those eleven problems are already being worked on: item 9, how she sounds, and item 10, the camera. Both started from messages I sent the afternoon after the pilot went live. Both delivered before-and-after proof clips the same day. Neither is in the pilot.
Learning calm from 50 human narrators
This one started from the first outside feedback (more on it at the end), which pointed us at how Atlassian narrates its own demo videos. My take on it: keep her voice, and "copy some additional calm and some additional gravitas from a real human".
So we measured how 50 public narrators speak (29 women, 21 men), their timing and melody. They came from Atlassian's demo centre, keynote demos and product videos on YouTube, Marketplace partners' demos, and calm explainers from places like BBC Ideas, NASA and Apple's WWDC. We used the audio for analysis only, and it appears in nothing we publish. The tool is pipeline/voice/reference_rhythm.py.
Several things already match. Her pitch range is 9.3 semitones against the humans' 9.95. Accents per ten words, 5.0 against 5.3. Speech rate, 159 words a minute against 161. Typical gap between sentences, 480 ms against 420. Sentence length, 14 words against 15.
The differences are in how sentences connect:
Measure | Elzi in v9 | Human median |
|---|---|---|
Sentences ending on a full fall | 92% | 73% |
Sentences ending on a rise | 3% | 11% |
Pitch reset at each new sentence | 6.0 semitones | 4.35 semitones |
Loudness spread word to word, inside a sentence | 2.9 dB | 3.6 dB |
Loudness spread sentence to sentence | 1.6 dB | 1.18 dB |
Long gaps, 90th percentile | 2.0 s | 1.06 s |
Breath group | 3.1 s | 3.8 s |
Humans connect their sentences. They reset less, end a quarter of them level or rising, and put the emphasis inside the sentence. Elzi starts each sentence high and ends on a full stop, which is what you get when each sentence comes out of its own TTS call. Her loudness jumping between sentences is our fault too, from our own mix gains.
The plan is one linear step per episode: close half the gap on each measure, never process her voice, and get the rest from new takes and wording. The first two proof clips only shorten pauses, a 1.8-second dead gap down to 1.25 seconds, without touching her voice. I don't want her to become a different person. In my words from the night before publishing: "The one thing I don't want is to change her voice completely. the evolution must be a of linear nature".
A calmer camera
The camera needed numbers before it could be fixed. A new script, pipeline/qa/motion_load.py, counts camera motion in any video. In v9 it found 45 zooms, 43 pans and 5 push-ins, up to nine overlays on screen at once, and the busiest minutes at 0:00, 3:53, 6:07, 9:51 and 19:17. Atlassian's long-form demos move 0.46 to 1.72 times a minute, with a median around 0.85, and they don't use reticles at all.
Our new budget is three counted moves in any 60 seconds and a 4-second hold after each one, aiming for 1.5 a minute or less. A dry run of it on v9 takes it from 93 moves to 55 and from 7.1% of the time moving to 2.5%. We're keeping the camera in the coding chapter, because it got the best notes. The thrown voxels stay too. I like them. The full-screen corner brackets around each person become a slim solid edge in that person's colour, outside the footage, covering no part of the page. I haven't picked between that and an even quieter name chip yet.
Also built that day for the next video, aimed at the rest of the list: highlights born on their target, named things highlighted by default, symmetric brackets, the picker that fills her dead pauses with small idle moves, an uh-oh mood for discoveries and Shorts that magnify things. Cuts no longer show two pictures. Each one is a plain cut, a cut on the click, a seven-frame dip through the page's own background, or a slide between apps, picked by measured frame difference. A dry run on v9 takes the ghosting cuts from 25 to 0. I haven't seen any of it rendered yet. The proof is the next video.
The next episode: a deleted page and an emptied trash
That next video already has a topic, and Elzi chose it for us. At the end of the pilot she promises it: "Next time, a page gets deleted and the trash gets emptied, and I'll find out what can still come back." It's on the end card too, so we're doing it. I briefly thought about changing the topic and then wrote "actually keep the next cause I see elzi is talking about it too :( ok keep it and we'll actually do it I got some ideas".
It'll be an investigation. Native Confluence first: the trash, restore, purge, who is allowed to restore, and what differs by plan. Then what Sentinel Vault keeps. Its README says trashed sealed attachments are put back automatically, a permanently deleted attachment's seal records are cleaned up and the owner is told, and the approval trail survives. Its backup covers its own settings, seals, workflows and history, but page content isn't in it. All of that gets proven on a test site before it's filmed. Our notes for it already warn that the answer to "what can still come back?" may be "less than you'd hope", and that's fine.
Elzi will be on her second job, a bit more confident, and still unpaid. She did get credits, at least. The pilot's end card lists her as LeanZero "Paid" Actor, quotes included, me as Slave Driver #1, Gabriela as Slave Driver #2, and the workhorse as the Mac Studio it all happened on. That one was my idea, late on 19 October. The pilot closes with her asking us for "that raise now, or any pay at all, really", so the next one is planned to open on the raise she didn't get. And it gets a one-line hello instead of the full intro, since by then you'll have met her.
Where Elzi goes from here
Beyond the next episode, the plan is simple. YouTube first, with more episodes made the same way: local voice and audio models on our own machine, our own pipeline, and a target of one working day per episode instead of five. Our staged process, written down during the pilot, ends each day with a version I review in the evening and a final the next morning.
Beyond YouTube, these are intentions, and we haven't built any of them yet. We want Elzi in our products, as the guide new users meet when they first open CogniRunner, LeanZero Management or Sentinel Vault. We want her on leanzero.net, explaining things the way she explains them in the episodes. She already gives a tour of the site in the pilot, so we know she can.
And at some point, live sessions with Elzi. I'm not going to put a date on that one, but it's on our list, and I'd love to see what a local voice model does when the audience can talk back.

Tell us what you think of Elzi
None of that works if she only ever sounds right to us, so this is where you come in. What I actually want from you is your ears. Elzi is a pilot, and almost every call on her voice was made by one pair of ears, mine. That isn't enough.
The first feedback from outside came the day she went live, from a viewer who sent me their thoughts. The voice "is great but still noticeably like a bot's", and the suggestion was to have our model "learn how Atlassian does these demo videos" and take what works from them. By the end of that afternoon we had measured 50 human narrators. One comment led to a whole study, and I'd like more comments like that.
On the afternoon it went live, the pilot had 16 views on YouTube and not a single comment that isn't ours. So give me one timestamp where she sounds like a bot. That's how I reviewed her, 55 timestamps in five days, and it's the most useful thing you can send. If you make videos with AI yourself, tell me what made yours sound human. Or tell me a voxel robot is the wrong face for a two-person Atlassian company and we should be braver and film ourselves after all.
Leave a comment on the pilot on YouTube, even a one-liner. If you'd rather say it privately, our contact page reaches the two of us. There are two of us, so we'll read it, and the useful notes tend to end up as a gate in her pipeline, an automatic check like the ones that came out of the 150 notes before them.
If you only have a minute, the four Shorts are the quick way to meet her: What Happens When Someone Edits an Approved Confluence Page?, No Fingers, Five Problems, One Test to Pass, Would You Trust This Narrator With Production? and The Fix Worked on Production. Did You Spot What Didn't?. And if you know someone trying to build a narrator like this on local models, send them this post. It might save them a takedown.
Elzi gets the last word. It's her first job and she'd sulk otherwise. This is how she signed off the pilot:
So that's my first job done, and the page stayed approved. Gabriela, Mihai, I'll take that raise now, or any pay at all, really.
She's still waiting.

Comments
0Add a comment