← All work

Python asyncio · LLMs · streaming TTS · LanceDB · MLX · RVC

Quill & Boo

The brain behind two autonomous AI VTubers: Quill, a small, whimsical witch goose, and Boo, her ghost cohost. They talk with each other, with me and with Twitch chat, remember people across streams, hold grudges and warm to people, play games, run tabletop campaigns, and sing. They perform on a Live2D stage, in Goose World's 3D town, and in my iPhone's AR room.

Quill is the DM (sound on). Quill runs a tabletop game for Boo and me. Boo drops from the rafters and uses my tied-up character, Alleyne, as a footrest, and I end up hired as a doorstop for a copper a night. Quill's and Boo's lines are generated live.
< 2 starget from a line of chat to the first spoken word
1streamed LLM call per spoken line; everything else feeds it
8vector-memory tables, plus inspectable state files
4game adapters, from Minecraft to a tabletop campaign

The speaking path

Fast enough to banter

Everything a character says comes out of one streamed LLM call. Tokens flow straight into sentence-chunked TTS, and playback starts on the first chunk of audio. Hosted models answer in about 200–600 ms to the first token, which leaves room for the voice inside the two-second target.

chat · my mic · game · vision · idle→rank + dedupe→context builder→LLM stream→tag split→safety→TTS→delay buffer→stream audio + mouth

Inner life

Moods with reasons, relationships that last

The design rule: if a number has no human-readable reason, it's noise. Models report a direction and a "because"; code owns the arithmetic: step sizes, daily caps, decay. And the character never gets a separate "decide how cheeky to be, then write" pass. She sees her own state as plain sentences and makes her choices in the same call that writes the line.

Memory

Remembering people, not transcripts

Long-term memory lives in LanceDB with local embeddings, run off the speaking path; the embedding model is pinned to one thread so it can never steal CPU from the audio. Separate stores for separate kinds of memory:

Experience writing is rate-limited so a long game session doesn't turn into a click log, and nothing gets merged or consolidated without my approval.

Playing games

Separate the hands from the mouth

A game adapter is not the speaking model. Game state comes in; one validated action goes out (an invalid one falls back to a safe default, so play never freezes); and short stage directions ("she walked to the stump") enter the same speaking pipeline as chat, so commentary is just another turn. The chooser gets one plain sentence of how the character feels, so mood shows up in play too.

Safety

On every path to the speakers

Singing

Lyrics in, Quill singing out

Live TTS can't really sing, so karaoke works the way it does for human VTubers: bake the vocal, then perform it. I automated the whole chain.

lyrics + key→guide score (MIDI)→DiffSinger guide vocal→RVC in Quill's voice→procedural backing→stream + lipsync

An experiment I stopped

Training a LoRA on my own voice

I wanted Quill to sound more like me, so I fine-tuned local models (Qwen3 4B, 8B and 14B, with LoRA through MLX) on my own anonymized texting history, about 550 examples, and tested every checkpoint against Quill's real system prompt.

1

Five configurations, three failure modes

The small models copied the prompt's literal formatting example word for word or dropped the required emotion tags. The larger ones were "thinking" models that sometimes spent the whole token budget reasoning and never spoke; at about 20–27 tokens a second, that's eight seconds of silence on stream.

2

A control that changed the diagnosis

The same simplified prompt on the base model with no adapter produced perfectly tagged, coherent, funny lines. So the adapter itself was eroding instruction-following: the training data never paired my voice with emitting tags, and a strong adapter overwrote that behaviour while learning the narrow voice.

3

The finding that ended it

A retrained adapter produced a harmful line completely unprompted. Nothing like it appeared anywhere in the training data, and the base model was clean on the same seed; across fresh seeds the adapter's very first token was garbage every time. Fine-tuning, even on benign data, had decalibrated the output and worn through the safety tuning, which is a known, published effect.

4

Ship the simpler thing

I closed the local-model track and kept the hosted model, adding a short voice reference of my real phrasing to Quill's prompt as tone calibration. It needed zero architecture change, shipped the same day, and is the fix that stuck.

Next

What's next