<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://www.mindfiretechnology.com/blog/rss/xslt"?>
<rss xmlns:a10="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title>Mindfire Technology</title>
    <link>https://www.mindfiretechnology.com/blog/</link>
    <description>Welcome to our blog, where we share technical and business knowledge based on real life experiences.</description>
    <generator>Articulate, blogging built on Umbraco</generator>
    <item>
      <guid isPermaLink="false">2751</guid>
      <link>https://www.mindfiretechnology.com/blog/archive/inside-the-qwen3-tts-engine-code-qwen3-tts-part-2/</link>
      <category>System.String[]</category>
      <title>Inside the Qwen3-TTS Engine Code (Qwen3-TTS, Part 2)</title>
      <description>&lt;p&gt;This is the follow-up to my &lt;a href="https://www.mindfiretechnology.com/blog/archive/implementing-qwen3-tts-in-my-pdf-to-audiobook-pipeline-qwen3-tts-part-1/"&gt;previous post on adding Qwen3-TTS to Book2Audio&lt;/a&gt;. That post covered how to use Qwen3-TTS from the command line and how I refactored the code to support multiple TTS engines. This post walks through the actual engine code — how it's structured, what each piece does, and how Qwen3-TTS works under the hood.&lt;/p&gt;
&lt;p&gt;All the code discussed here is &lt;a href="https://github.com/brucenielson/Book2Audio/tree/8e7e547b8f97625c62d82f55bbe43d286daceb73"&gt;available in my GitHub repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The Engine Abstraction&lt;/h2&gt;
&lt;p&gt;The starting point is a simple abstract base class that defines what any TTS engine needs to do:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from abc import ABC, abstractmethod
import numpy as np

class TTSEngine(ABC):
    @abstractmethod
    def generate(self, text: str) -&amp;gt; np.ndarray:
        ...

    @property
    @abstractmethod
    def sample_rate(self) -&amp;gt; int:
        ...
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two methods, that's it. &lt;code&gt;generate&lt;/code&gt; takes a string of text and returns a numpy array of audio samples. &lt;code&gt;sample_rate&lt;/code&gt; returns the sample rate in Hz so the caller knows how to save the audio correctly. Any TTS backend — Kokoro, Qwen, or something else entirely — just needs to implement these two things.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AudioGenerator&lt;/code&gt; then wraps any engine and handles the model-agnostic parts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AudioGenerator:
    def __init__(self, engine: TTSEngine) -&amp;gt; None:
        self._engine = engine

    def generate(self, text: str) -&amp;gt; np.ndarray:
        return self._engine.generate(text)

    def save(self, audio: np.ndarray, output_file: str) -&amp;gt; None:
        sf.write(output_file, audio, self._engine.sample_rate)

    def generate_and_save(self, text: str, output_file: str) -&amp;gt; None:
        self.save(self.generate(text), output_file)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The key thing here is &lt;code&gt;save&lt;/code&gt; — it pulls &lt;code&gt;sample_rate&lt;/code&gt; from the engine rather than hardcoding it. Different engines could theoretically produce audio at different sample rates, and this handles that transparently. &lt;code&gt;generate_and_save&lt;/code&gt; is just a convenience method that chains the two together.&lt;/p&gt;
&lt;h2&gt;Loading the Model&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;QwenCustomVoiceEngine&lt;/code&gt; constructor handles model loading. If you don't pass in a pre-loaded model, it figures everything out from the &lt;code&gt;model_size&lt;/code&gt; parameter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;QWEN_MODEL_SIZES = {
    '0.6b': 'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice',
    '1.7b': 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice',
}

def __init__(self,
             speaker: str = 'vivian',
             language: str = 'Auto',
             instruct: str | None = None,
             model_size: str = '0.6b',
             model: Qwen3TTSModel | None = None) -&amp;gt; None:
    if model is None:
        model_id = QWEN_MODEL_SIZES.get(
            model_size.lower(), QWEN_MODEL_SIZES['0.6b']
        )
        attn_impl = 'sdpa'
        try:
            import flash_attn
            attn_impl = 'flash_attention_2'
        except ImportError:
            pass
        device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
        model = Qwen3TTSModel.from_pretrained(
            model_id,
            device_map=device,
            dtype=torch.bfloat16,
            attn_implementation=attn_impl,
        )
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;QWEN_MODEL_SIZES&lt;/code&gt; dictionary maps friendly size names to full Hugging Face model identifiers. This way the rest of the code just passes around &amp;quot;0.6b&amp;quot; or &amp;quot;1.7b&amp;quot; instead of long model strings. It also means that if Qwen releases new checkpoints, there's only one place to update. Note to self: I should really allow you to pass a full model name here and only use this dictionary for shorthands. I need to implement that still. &lt;/p&gt;
&lt;p&gt;There are a few things worth noting about the &lt;code&gt;from_pretrained&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;device_map&lt;/code&gt; controls where the model runs. The code checks &lt;code&gt;torch.cuda.is_available()&lt;/code&gt; and uses the GPU if present, falling back to CPU otherwise. This is the same pattern that the Kokoro engine uses, so both engines behave consistently.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;dtype=torch.bfloat16&lt;/code&gt; halves the memory footprint compared to full float32 precision with negligible quality loss. For the 1.7B model, this is the difference between fitting on a consumer GPU and not fitting at all.&lt;/p&gt;
&lt;p&gt;The attention implementation check is about GPU memory efficiency. FlashAttention 2 is an optimized attention algorithm that reduces VRAM usage during inference. But it requires the &lt;code&gt;flash-attn&lt;/code&gt; package, which compiles from source and needs the CUDA Toolkit and a C++ compiler installed — a nontrivial setup on Windows. If it's not available, the engine falls back to PyTorch's built-in scaled dot product attention (&lt;code&gt;sdpa&lt;/code&gt;), which works fine but uses a bit more VRAM. The code handles this gracefully: try the import, use it if it's there, move on if it's not. To be frank, I never got flash attention working — getting the CUDA Toolkit and C++ compiler set up on Windows was more than I wanted to take on right now. So the code is there for when I get around to it, but for now I use sdpa.&lt;/p&gt;
&lt;p&gt;The constructor also accepts a pre-loaded model via the &lt;code&gt;model&lt;/code&gt; parameter. This is useful for testing — you can inject a mock — and it also means you could share a single model instance across multiple engine objects if you needed to.&lt;/p&gt;
&lt;h2&gt;Generating Speech&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;generate&lt;/code&gt; method is where text actually becomes audio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def generate(self, text: str) -&amp;gt; np.ndarray:
    kwargs = {
        'text': text,
        'language': self._language,
        'speaker': self._speaker,
    }
    if self._instruct is not None:
        kwargs['instruct'] = self._instruct

    wavs, sr = self._model.generate_custom_voice(**kwargs)
    self._sample_rate = sr
    return wavs[0]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;It assembles the keyword arguments for &lt;code&gt;generate_custom_voice&lt;/code&gt;, conditionally including the &lt;code&gt;instruct&lt;/code&gt; parameter, makes the call, and returns the first waveform.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;instruct&lt;/code&gt; parameter is only included when it's not &lt;code&gt;None&lt;/code&gt;. This matters because the 0.6B model doesn't support instruction control — only the 1.7B CustomVoice model does. Passing &lt;code&gt;instruct&lt;/code&gt; to the 0.6B model won't cause an error, but it will be silently ignored.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;generate_custom_voice&lt;/code&gt; returns a list of waveforms because the Qwen3-TTS API supports batched generation — you can pass a list of strings and get back multiple audio arrays in one call. For our paragraph-at-a-time use case, we always pass a single string and take &lt;code&gt;wavs[0]&lt;/code&gt;. However, I should really change things to allow this code to handle everything at once as an option. As I mentioned in the previous post, Qwen3-TTS produces a somewhat different voice each time you call it, which makes for a questionable audiobook experience when you're generating paragraph by paragraph. Batching everything into a single call might help with that consistency.&lt;/p&gt;
&lt;p&gt;The sample rate is captured from the return value rather than hardcoded, though in practice Qwen3-TTS always returns 24000 Hz. By reading it from the response, the code stays correct even if a future model version changes the rate.&lt;/p&gt;
&lt;h2&gt;Wiring It Together&lt;/h2&gt;
&lt;p&gt;The CLI entry point in &lt;code&gt;book_to_audio.py&lt;/code&gt; ties everything together. When the user passes &lt;code&gt;--engine qwen&lt;/code&gt;, a small factory function creates the right engine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _create_engine(args):
    if args.engine == 'qwen':
        return QwenCustomVoiceEngine(
            speaker=args.speaker,
            language=args.language,
            instruct=args.instruct,
            model_size=args.model_size,
        )
    else:
        return KokoroEngine(voice=args.voice)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That engine gets wrapped in an &lt;code&gt;AudioGenerator&lt;/code&gt;, which gets handed to &lt;code&gt;BookToAudio&lt;/code&gt;, which does the actual document processing. &lt;code&gt;BookToAudio&lt;/code&gt; doesn't know or care whether it's using Kokoro or Qwen — it just calls &lt;code&gt;generate&lt;/code&gt; and gets audio back.&lt;/p&gt;
&lt;p&gt;This is the payoff of the strategy pattern. Adding a third engine later — say, for voice cloning with the Qwen3-TTS Base model — means writing a new engine class, adding an option to &lt;code&gt;_create_engine&lt;/code&gt;, and nothing else changes.&lt;/p&gt;
&lt;h2&gt;What's Next&lt;/h2&gt;
&lt;p&gt;Voice cloning is the natural next step. The Qwen3-TTS Base model can clone a voice from just a few seconds of reference audio, which opens up the possibility of generating an entire audiobook in a specific narrator's voice. That will be a separate engine class since it uses a different model and a different API (&lt;code&gt;generate_voice_clone&lt;/code&gt; instead of &lt;code&gt;generate_custom_voice&lt;/code&gt;), but the abstraction is already in place to support it.&lt;/p&gt;
&lt;p&gt;If you need help with your &lt;a href="https://www.mindfiretechnology.com/services/artificial-intelligence/"&gt;Artificial Intelligence solutions, we're here to help&lt;/a&gt;.&lt;/p&gt;
</description>
      <pubDate>Thu, 07 May 2026 00:00:00 -0600</pubDate>
      <a10:updated>2026-05-07T00:00:00-06:00</a10:updated>
    </item>
    <item>
      <guid isPermaLink="false">2750</guid>
      <link>https://www.mindfiretechnology.com/blog/archive/implementing-qwen3-tts-in-my-pdf-to-audiobook-pipeline-qwen3-tts-part-1/</link>
      <category>System.String[]</category>
      <title>Implementing Qwen3-TTS in My PDF-to-Audiobook Pipeline (Qwen3-TTS, Part 1)</title>
      <description>&lt;p&gt;&lt;a href="https://www.mindfiretechnology.com/blog/archive/using-kokoro-82m-to-convert-a-pdf-to-an-audiobook/"&gt;In my last post&lt;/a&gt;, I walked through building a PDF-to-audiobook pipeline using Kokoro for text-to-speech. The pipeline worked well enough that I've been actively using it to listen to books that only exist as PDFs. But I mentioned wanting to try Alibaba's recently open-sourced &lt;a href="https://github.com/QwenLM/Qwen3-TTS"&gt;Qwen3-TTS&lt;/a&gt; as an alternative voice engine, and I've now done exactly that. (&lt;a href="https://github.com/brucenielson/Book2Audio/tree/8e7e547b8f97625c62d82f55bbe43d286daceb73"&gt;My code is found in my github repo&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;This post covers how to use Qwen3-TTS to generate speech from the command line and a discussion of how I refactored the code to support multiple TTS engines. A follow-up post will walk through the Qwen engine code itself.&lt;/p&gt;
&lt;h2&gt;Trying Qwen3-TTS&lt;/h2&gt;
&lt;p&gt;Before touching any of my existing code, I wanted to hear what Qwen3-TTS actually sounded like. The setup is straightforward. Install the package:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U qwen-tts
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The first time you use a model, the weights download automatically from Hugging Face. The 0.6B model is roughly 1.2GB and the 1.7B model is around 3.4GB.&lt;/p&gt;
&lt;p&gt;Once installed, generating speech from Python is only a few lines:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel

model = Qwen3TTSModel.from_pretrained(
    &amp;quot;Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice&amp;quot;,
    device_map=&amp;quot;cuda:0&amp;quot;,
    dtype=torch.bfloat16,
)

wavs, sr = model.generate_custom_voice(
    text=&amp;quot;The philosopher argued that all knowledge is provisional.&amp;quot;,
    language=&amp;quot;English&amp;quot;,
    speaker=&amp;quot;ryan&amp;quot;,
)

sf.write(&amp;quot;output.wav&amp;quot;, wavs[0], sr)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;a href="https://github.com/brucenielson/Book2Audio/blob/8e7e547b8f97625c62d82f55bbe43d286daceb73/try_qwen3-tts.py"&gt;Code found here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You load a model with &lt;code&gt;from_pretrained&lt;/code&gt;, call &lt;code&gt;generate_custom_voice&lt;/code&gt; with your text, a language, and a speaker name, and you get back a list of waveform arrays and a sample rate. Write the first waveform to a file and you have a WAV you can play.&lt;/p&gt;
&lt;p&gt;Qwen3-TTS comes with nine built-in speakers: aiden, dylan, eric, ono&lt;em&gt;anna, ryan, serena, sohee, uncle&lt;/em&gt;fu, and vivian. They vary quite a bit in tone and accent. I'd recommend generating a short sample with each one to find what works for your use case. However, my experience is that you get a somewhat different voice each time you run the voice. This makes it less than desirable for reading an audio book.&lt;/p&gt;
&lt;p&gt;The 1.7B CustomVoice model also supports an &lt;code&gt;instruct&lt;/code&gt; parameter that lets you control the delivery style with natural language:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wavs, sr = model.generate_custom_voice(
    text=&amp;quot;The philosopher argued that all knowledge is provisional.&amp;quot;,
    language=&amp;quot;English&amp;quot;,
    speaker=&amp;quot;ryan&amp;quot;,
    instruct=&amp;quot;Read in a calm, steady audiobook narration style&amp;quot;,
)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is a genuinely interesting feature, but be aware that instruction control only works on the 1.7B models. The 0.6B models silently ignore the &lt;code&gt;instruct&lt;/code&gt; parameter.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://huggingface.co/collections/Qwen/qwen3-tts"&gt;Find a list of all the available models on Hugging Face here&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Refactoring for Multiple Engines&lt;/h2&gt;
&lt;p&gt;My original code for Book2Audio had the Kokoro TTS model wired directly into the &lt;code&gt;AudioGenerator&lt;/code&gt; class. To support Qwen3-TTS as an alternative, I needed to pull the model-specific logic out and make it swappable. (Apologies for naming the repo Book2Audio and the python file book&lt;em&gt;to&lt;/em&gt;audio. I need to rename the repo at some point to match.)&lt;/p&gt;
&lt;p&gt;The approach was a straightforward application of the strategy pattern. I created a &lt;code&gt;TTSEngine&lt;/code&gt; abstract base class with two methods: &lt;code&gt;generate&lt;/code&gt;, which takes text and returns a numpy audio array, and a &lt;code&gt;sample_rate&lt;/code&gt; property. Then I wrote two concrete implementations: &lt;code&gt;KokoroEngine&lt;/code&gt; wrapping the existing Kokoro pipeline, and &lt;code&gt;QwenCustomVoiceEngine&lt;/code&gt; wrapping Qwen3-TTS.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AudioGenerator&lt;/code&gt;, which previously owned the Kokoro pipeline directly, now takes any &lt;code&gt;TTSEngine&lt;/code&gt;. It delegates audio generation to whatever engine it's given and handles only the model-agnostic work of saving WAV files. &lt;code&gt;BookToAudio&lt;/code&gt;, the class that orchestrates document parsing and paragraph-by-paragraph generation, didn't need to change at all. It still talks to &lt;code&gt;AudioGenerator&lt;/code&gt; the same way it always did.&lt;/p&gt;
&lt;p&gt;I also split the single &lt;code&gt;book_to_audio.py&lt;/code&gt; file into several files. The engines live in their own directory, &lt;code&gt;AudioGenerator&lt;/code&gt; and &lt;code&gt;BookToAudio&lt;/code&gt; each got their own module, and &lt;code&gt;book_to_audio.py&lt;/code&gt; became a thin CLI entry point. This makes it easy to add more engines later without everything piling up in one file.&lt;/p&gt;
&lt;p&gt;From the command line, switching engines is just a flag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --engine kokoro --voice af_heart

python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --engine qwen --speaker ryan --language English
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You can also convert plain text directly without a PDF:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py --text &amp;quot;Hello world&amp;quot; --engine qwen --speaker vivian
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;By default, the Qwen engine uses the 0.6B model. To use the larger 1.7B model, which supports instruction control:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --engine qwen --speaker ryan --language English --model-size 1.7b --instruct &amp;quot;Read in a calm, steady audiobook narration style&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For long documents, you can limit the page range to test on a small section before committing to a full run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --engine qwen --speaker ryan --start-page 10 --end-page 15
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;To process the document without generating audio — useful for inspecting the extracted text before spending time on generation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --dry-run --generate-text-file
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And to specify an output file name instead of the default:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python book_to_audio.py &amp;quot;documents/MyBook.pdf&amp;quot; --engine qwen --speaker ryan --output-file &amp;quot;my_audiobook.wav&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The Kokoro path works exactly as before. The Qwen path adds a few extra options for speaker, language, model size, and style instructions.&lt;/p&gt;
&lt;h2&gt;Kokoro vs. Qwen3-TTS for Audiobooks&lt;/h2&gt;
&lt;p&gt;After testing both engines on the same material, I have to be honest: I still prefer Kokoro for most audiobook listening. The Qwen3-TTS voices, while technically impressive, tend to have cadence patterns and occasional accent shifts that can be fatiguing over long listening sessions. The &lt;code&gt;ryan&lt;/code&gt; speaker comes closest to a natural audiobook narrator in English, and I may switch to it in the future as I experiment more with the &lt;code&gt;instruct&lt;/code&gt; parameter on the 1.7B model. But for now, Kokoro's more neutral delivery wins for extended listening.&lt;/p&gt;
&lt;h2&gt;Hardware Considerations&lt;/h2&gt;
&lt;p&gt;One thing that surprised me during this process was discovering that my laptop had been running Kokoro on CPU the whole time. PyTorch had been installed without CUDA support, which meant &lt;code&gt;torch.cuda.is_available()&lt;/code&gt; returned &lt;code&gt;False&lt;/code&gt; and everything silently fell through to CPU inference. It worked, just slower than it needed to be.&lt;/p&gt;
&lt;p&gt;If you're running this on a machine with an NVIDIA GPU, make sure you install the CUDA-enabled version of PyTorch:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You can verify it worked with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -c &amp;quot;import torch; print(torch.cuda.is_available())&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In any case, my code will use your GPU if it's available and CUDA is properly installed.&lt;/p&gt;
&lt;p&gt;For Qwen3-TTS specifically, the 0.6B model needs roughly 1.5GB of VRAM and runs comfortably on a 6GB laptop GPU. The 1.7B model needs 4-6GB and may be tight on consumer hardware, especially if other applications are using the GPU. I'd recommend starting with the 0.6B model and only moving to the 1.7B if you want instruction control or find the quality insufficient. Theoretically the 1.7B should work, but I haven't really tested it yet on my laptop. I'll do that in a future post.&lt;/p&gt;
&lt;h2&gt;What's Next&lt;/h2&gt;
&lt;p&gt;In the next post, I'll walk through the actual Qwen engine code, explaining how it works and how to use the Qwen3-TTS API. I also plan to explore Qwen3-TTS voice cloning, which lets you train the model on a specific narrator's voice from just a short audio clip and then generate an entire audiobook in that style.&lt;/p&gt;
&lt;p&gt;If you need help with your &lt;a href="https://www.mindfiretechnology.com/services/artificial-intelligence/"&gt;Artificial Intelligence solutions, we're here to help&lt;/a&gt;.&lt;/p&gt;
</description>
      <pubDate>Fri, 17 Apr 2026 10:32:51 -0600</pubDate>
      <a10:updated>2026-04-17T10:32:51-06:00</a10:updated>
    </item>
  </channel>
</rss>