# Keep flyin'

> Qwen 3 TTS experiment on the best sci-fi show ever, Firefly.

Published 2026-08-18 by Zsombor Paróczi · tags: tts, qwen3, firefly
Source: https://blog.stormregion.hu/posts/keep-flyin/

Playing around with the local AI stack for the last couple of months, TTS or Speech-to-Text was not my main focus. I installed [Voicebox](https://voicebox.sh/) in the past, cloned my voice, made myself speak Korean, but it was slow and painful and not developer-friendly.

Then during my PTO I did see [Qwen3-TTS vs dots.tts for local AI voice cloning by No place like localhost](https://www.youtube.com/watch?v=jDudeaWppSE) and the idea was born to create an end-to-end voice cloning PoC from source material. I did not want to use a 10-sec sample to generate a couple of lines, I wanted to see how mature this part of the LLM stack is.

After reading a bit, the plan is kind of simple: backpropagation on a pre-trained model, generating a LoRA (and/or baking it into the model itself).

The "core" part of the training is quite simple to be honest.

```python
# Load the base Qwen3-TTS model in bf16
qwen3tts = Qwen3TTSModel.from_pretrained(
    args.model,
    device_map=device,
    dtype=torch.bfloat16,
    attn_implementation="sdpa",
)

model = qwen3tts.model

# Freeze the original model weights
for p in model.parameters():
    p.requires_grad = False

# Add small trainable LoRA adapters
from peft import LoraConfig, get_peft_model

targets = ["q_proj", "k_proj", "v_proj", "o_proj"]

# Also adapt the MLP layers to better learn voice/timbre
targets += ["gate_proj", "up_proj", "down_proj"]

lora = LoraConfig(
    r=32,
    lora_alpha=64,
    lora_dropout=0.05,
    bias="none",
    target_modules=targets,
    task_type="CAUSAL_LM",
)

model.talker = get_peft_model(model.talker, lora)
model.talker.print_trainable_parameters()

# Train only the LoRA parameters
trainable = [p for p in model.parameters() if p.requires_grad]

optimizer = AdamW(
    trainable,
    lr=2e-5,
    weight_decay=0.01,
)

# Example training step
main_loss, sub_loss = losses(batch)
loss = main_loss + 0.3 * sub_loss

loss.backward()
optimizer.step()
optimizer.zero_grad()
```

But the training is one thing; there is a whole flow that I created to get to the final result. But the TL;DR is: we need input data for the training (WAV files and transcripts).

## Getting the training data

![Best sci-fi series of all time](https://blog.stormregion.hu/posts/keep-flyin/firefly1.png)

The input was a 5.1 Blu-ray of the Firefly series and Serenity the movie (treated as the last episode of the series). Let's pretend that we have this on disk, MKV format, all subtitles ripped. I wanted to have just series-specific lines; characters can have different tempo, pitch, and timbre even if it's the same actor.

The grind was real in here. To get (per character) transcribed lines, I did:

1. FFmpeg: 16 kHz + 48 kHz mono WAVs (5.1 center channel) - voice is usually muxed into the center
2. Demux the subtitle stream (text) or PGS bitmap track ([pgsrip](https://github.com/ratoaq2/pgsrip)). Also considered OpenSubtitles.com at some point
3. Strip SDH annotations (Subtitles for the Deaf and Hard of Hearing) and other noise from the text, like [Music], [Applause], etc.

And at this point you will have a (badly) synced subtitle track which is somewhat clean at least, and an audio file per episode. No clue on speaker, no separation, doing a cut of the timestamps will leave you with a lot of half-finished sentences.

## Fixing the training data

The desync is an issue we can deal with. Running Whisper will tell you exactly which word is where, so you can build out a shift database - since it's not just a constant per episode shift.

Listening to a couple of samples in here, I noticed a lot of background music and noise, so I decided to do some quick research with Claude on what I could do and [Demucs](https://github.com/facebookresearch/demucs) was the recommended way. That can separate the voice from the music and other background noise, so you can get a clean voice track. I used the default model, which yielded great results - in most cases. Where the noise was too loud I simply removed it from the training dataset.

Now we have a whole bunch of cleanish lines, a reasonable amount to manually label, so I started doing that for some time, but in the background I started a new search for automatic labeling.

I learned about the [VoxCeleb](https://www.robots.ox.ac.uk/~vgg/data/voxceleb/) dataset, which is awesome. I tried using anything built on top of that, but single-pass embeddings of ~3s lines are noisy and same-speaker similarity lands around 0.25-0.5 (not the textbook 0.7+).

I needed to use a small but useful trick to get it working:

```python
from speechbrain.inference.speaker import EncoderClassifier
from sklearn.cluster import AgglomerativeClustering
import numpy as np
import torch

# Load pretrained ECAPA speaker-embedding model
speaker_model = EncoderClassifier.from_hparams(
    source="speechbrain/spkrec-ecapa-voxceleb",
    run_opts={"device": "cpu"},
)


def embed_segment(audio):
    """
    Split one dialogue line into overlapping crops,
    embed each crop, then average them.
    """
    n = len(audio)

    crops = [
        audio[0:int(0.6 * n)],
        audio[int(0.2 * n):int(0.8 * n)],
        audio[int(0.4 * n):n],
    ]

    embeddings = []

    with torch.no_grad():
        for crop in crops:
            emb = speaker_model.encode_batch(
                crop.unsqueeze(0)
            )
            embeddings.append(emb.squeeze().numpy())

    embedding = np.mean(embeddings, axis=0)

    # normalize for cosine distance
    return embedding / np.linalg.norm(embedding)


# Create one speaker embedding for every dialogue segment
embeddings = [
    embed_segment(segment_audio)
    for segment_audio in clean_segments
]

X = np.stack(embeddings)


# Group similar voices together
clustering = AgglomerativeClustering(
    n_clusters=None,
    metric="cosine",
    linkage="average",
    distance_threshold=0.78,
)

cluster_ids = clustering.fit_predict(X)

# ...

# Small clusters are often fragments/noise.
# Merge them into the most similar large speaker cluster.

for small_cluster in small_clusters:

    small_centroid = mean_embedding(small_cluster)

    nearest_cluster = min(
        large_clusters,
        key=lambda cluster:
            cosine_distance(
                small_centroid,
                mean_embedding(cluster),
            ),
    )

    if cosine_distance(
        small_centroid,
        mean_embedding(nearest_cluster),
    ) < 0.86:
        merge(small_cluster, nearest_cluster)
```

I did some manual checking, seemed fine (but from the final results I still think there is a bit of mismatch somewhere).

Another source that I found is https://central-cortex.livejournal.com/, which had a stage-play-like version of the episodes that matched 95%+ with the auto-clustering.

Fun fact, none of the sources matched perfectly: Whisper, official subtitles, stage play, OpenSubtitles.

## Then the boring part, the training

![Some more awesome Serenity images](https://blog.stormregion.hu/posts/keep-flyin/firefly2.png)

Once the pipeline was in order, I could start to do the plumbing and training. At this point I had 100 GB of audio, random transcripts, more WAVs than I ever wanted to deal with. Running it on my dual RTX 3060s was simple: the first run ran overnight, and once I had a working v1 I could throw Claude at it. The last run (I did around 12–14 rounds) was 1.5 hours long.

Just as a side note, these are the stats:

**Subtitle cards, 14 episodes + film**

```
  9,452  100.0%   73,150w   subtitle cards, 14 eps + film
  8,353   88.4%   66,131w   a speaker name could be aligned      −1,099
  6,799   71.9%   53,960w   that speaker is one of the ten crew  −1,554
  3,046   32.2%   26,293w   clean: CER ≤ 0.15, rel_db ≥ 18       −3,753
    903    9.6%   10,307w   recut into whole sentences           −2,143
    580    6.1%    7,459w   survived clip_quality → trained on     −323
```

The recut stage looks like the second-biggest cull but mostly isn't — it merges consecutive cards into whole sentences, so the card count drops 70% while words only drop 61%. The cleanliness gate is the real loss.

Per character, words spoken vs what reached training:

```
  ┌───────────────┬────────┬───────┬───────┬───────┬──────┬───────┐
  │               │ series │ film  │ lines │ clips │ min  │ kept  │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Mal Reynolds  │ 17,574 │ 2,406 │ 2,360 │   188 │ 14.3 │ 13.0% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Simon Tam     │  5,688 │   316 │   814 │    56 │  4.0 │ 10.8% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Jayne Cobb    │  4,829 │   450 │   642 │    57 │  4.9 │ 15.3% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Inara Serra   │  4,467 │   227 │   597 │    54 │  3.9 │ 14.8% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Wash          │  4,410 │   236 │   560 │    47 │  3.8 │ 14.4% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Zoe Washburne │  3,459 │   493 │   483 │    46 │  3.2 │ 13.9% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Kaylee Frye   │  3,372 │   309 │   501 │    52 │  3.5 │ 14.3% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Shepherd Book │  2,169 │   117 │   313 │    36 │  2.9 │ 19.7% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Saffron       │  1,813 │     — │   237 │    18 │  1.6 │ 14.5% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ River Tam     │  1,498 │   127 │   292 │    26 │  1.8 │ 15.3% │
  └───────────────┴────────┴───────┴───────┴───────┴──────┴───────┘
```

One caveat you should know before quoting the film column. Speaker alignment covers 95.8% of series cards but only 42.4% of the film cards (560 of 1,321) — the Serenity fan transcript aligned badly. The film actually has 8,908 spoken words; only 4,720 got a name. So film counts are a floor and the series-vs-film ratio is unreliable.

## Final result

For copyright reasons I cannot share the model or the training data, but the final results are here, you can be the judge of how good it is. If you want the easy-mode way of doing this, just use a clean voice clip and the clone mode (test that here: [https://huggingface.co/spaces/Qwen/Qwen3-TTS](https://huggingface.co/spaces/Qwen/Qwen3-TTS)).

![Best cast of any series ever](https://blog.stormregion.hu/posts/keep-flyin/firefly3.png)

My result, two lines per character:

<audio controls src="https://blog.stormregion.hu/posts/keep-flyin/stormregion_all_voices.mp3"></audio>

<a href="https://blog.stormregion.hu/posts/keep-flyin/stormregion_all_voices.mp3" download>Download the MP3</a>

Considering this is just a PoC, I do think it's better than I hoped for. Also I worked mostly with [GLM 5.3](https://z.ai/blog/glm-5.3) on the code side, which just came out, and [Qwen 3 TTS](https://huggingface.co/collections/Qwen/qwen3-tts) on the training and LoRA side.
