Dominic Böttger

← Back to blog

Cloning Your Own Voice on an iGPU That ROCm Doesn't Support

Published on August 13, 2026 by Dominic Böttger (today) · 13 min read

There’s a moment when local AI stops being a tinkering project. For me it was the point where my own voice came out of the speakers saying a sentence I had never spoken — generated on my laptop’s integrated graphics, without a single byte leaving the device.

The model behind it is Qwen3-TTS, which can clone a voice from a few seconds of reference material. The hardware is an AMD Radeon 890M inside a Framework laptop. And officially it isn’t meant for this at all: the Strix Point APU reports itself as gfx1150, and gfx1150 is not on the list of architectures ROCm supports.

“Not supported”, however, is not the same as “doesn’t work”. What sits between the two is essentially one environment variable — plus a handful of settings you have to know about, because otherwise they silently fall back to the CPU or refuse to start.

The end result is a setup that runs entirely in Docker, offers an OpenAI-compatible API, and manages cloned voices as filesystem profiles. Plus a handful of operational quirks worth knowing before you use it in earnest — and which appear in no guide anywhere.

”Not Supported” Means: Pretend to Be Something Else

The central trick is a single line in docker-compose.rocm.yml:

- HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-11.0.0}

With that, the 890M presents itself to the ROCm runtime as gfx1100 — an RDNA 3 desktop card with a maintained, tested code path. The hardware is closely enough related that the kernels compute correctly.

What’s remarkable here is mostly how uneventful it was: the value 11.0.0 worked on the first attempt. No crash, no noise instead of speech, no silent fallback to the CPU. If 11.0.0 misbehaves on a different gfx1150 device, 11.5.0 and 11.5.1 are the common alternatives — and because the value is passed through as a variable, you can try them without editing a file:

HSA_OVERRIDE_GFX_VERSION=11.5.1 docker compose -f docker-compose.rocm.yml up -d

This override working is not a one-off, incidentally. I use the same trick on the same hardware for GPU-accelerated transcription with WhisperX — where it even becomes a performance optimisation, because ROCm ships hand-tuned kernels for gfx1100 and none for gfx1150. If you own a Strix Point APU and want to try ROCm, this variable is the first thing to know about.

The Docker Setup and the Two Numbers Nobody Guesses

None of this is installed on the host. The base is the official ROCm PyTorch image:

rocm/pytorch:rocm7.1.1_ubuntu24.04_py3.13_pytorch_release_2.10.0

Plus transformers 4.57.3 and accelerate 1.12.0, both pinned. GPU access goes through two forwarded devices — and through one spot that causes considerably more trouble than it should:

devices:
  - /dev/kfd
  - /dev/dri
group_add:
  # Host GIDs for 'video' and 'render'
  - "985"
  - "989"

The devices alone are not enough. The process inside the container also has to be a member of the video and render groups, otherwise it isn’t allowed to open those devices — and because Docker resolves groups by numeric ID, what goes in there are the GIDs of the host. On my system that’s 985 and 989; on another machine they will almost certainly differ. You find them like this:

getent group video render

This is one of the nastier traps in the whole setup, because it behaves exactly like every GPU problem in this corner: there’s no error. The container starts, the API answers, speech synthesis works — it just runs on the CPU, and drags accordingly.

Four volumes make sure a docker compose down costs nothing:

HostContainerPurpose
~/dev/qwen3-tts-models/root/.cache/huggingfacemodel cache
~/dev/voice-samples/app/voice-samplesreference recordings and outputs
~/dev/qwen3-tts-voice-library/app/voice_librarysaved voice profiles
./config.rocm.yaml/app/config.rocm.yaml:roconfiguration, live-editable

The last one is a small thing with a large effect: the model configuration is mounted read-only into the image instead of copied in. You edit it on the host, restart the container — and rebuild nothing.

The port is deliberately locked in:

ports:
  - "127.0.0.1:8880:8880"

A TTS server that can clone your own voice has no business being on the local network. Without the 127.0.0.1: prefix Docker would open the port on every interface — and even bypass a firewall while doing so, because Docker inserts its iptables rules ahead of the usual ones. The healthcheck also gets start_period: 120s, because on the very first start the model still has to be downloaded and the container would otherwise be marked “unhealthy” before it’s even finished.

What Has to Stay Off on an Unsupported iGPU

The server runs with TTS_BACKEND=optimized and then reads config.rocm.yaml. The interesting lines in it are the ones that turn something off:

optimization:
  attention: sdpa
  use_compile: false
  use_cuda_graphs: false

  streaming:
    decode_window_frames: 72
    emit_every_frames: 6

In order:

  • attention: sdpa — Flash Attention doesn’t exist on ROCm. So PyTorch’s built-in scaled dot-product attention it is. That’s not a formality: the fallback path costs throughput, particularly on long sequences.
  • use_compile: false and use_cuda_graphs: falsetorch.compile and CUDA graph capture are the two optimisations most likely to break on an unsupported architecture. On an iGPU you’re already lying to, the stable starting point is the right one. You can switch them on individually later.
  • decode_window_frames: 72 — and here it gets very specific. The values 66, 67 and 71 trigger a CUDA graph capture bug on ROCm. On NVIDIA, 64, 72 and 80 all work equally well. As long as graphs stay disabled per above this doesn’t bite — but it bites the moment you switch them on, which is the moment you’ll be thinking about anything except a window width. All the more reason to leave 72 where it is. It’s exactly the kind of detail you don’t find, you only discover through hours of trial and error — which is why it sits as a comment right there in the config file.

Whether any of this actually lands on the GPU is verified in the logs:

docker logs qwen3-tts-api-rocm | grep -E "ready on|GPU:"

Expected are Model '...' ready on cuda:0 and GPU: AMD Radeon Graphics. If it says cpu, something is wrong with the devices or the group IDs. And while a request is running:

docker exec qwen3-tts-api-rocm rocm-smi --showuse

On mine that shows around 95 % GPU utilisation during inference. That’s the difference a single environment variable makes.

The Order in the Config Is the Model Selection

Qwen3-TTS comes in two flavours, and the difference matters:

  • customvoice models ship predefined speakers (Vivian, Ryan and others). Fast, good for ordinary text-to-speech.
  • base models are mandatory for voice cloning. Without a base model there is no cloned voice.

And here lies a trap that easily hands you the wrong result without anything looking broken: for clone: voices, the server simply takes the first entry with type: base it finds in the YAML. Not the largest, not the best — the first.

In my configuration 1.7B-Base therefore sits deliberately before 0.6B-Base:

models:
  0.6B-CustomVoice:
    hf_id: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
    type: customvoice
  # 1.7B-Base listed before 0.6B-Base so it's picked as the base/clone model
  # (the server uses the first "type: base" entry it finds for clone: voices)
  1.7B-Base:
    hf_id: Qwen/Qwen3-TTS-12Hz-1.7B-Base
    type: base
  1.7B-CustomVoice:
    hf_id: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
    type: customvoice
  0.6B-Base:
    hf_id: Qwen/Qwen3-TTS-12Hz-0.6B-Base
    type: base

So the line order in a YAML file decides the quality of your cloned voice. That is not obvious behaviour, which is why the comment sits directly above it — otherwise someone sorts it alphabetically during the next tidy-up and wonders why the clones got worse.

Voice Profiles Are a Folder With Two Files

The obvious route to voice cloning is the /v1/audio/voice-clone endpoint, to which you pass the reference recording base64-encoded on every request. That works, but it’s unwieldy: the recording goes over the wire every time, and the speaker embedding is recomputed every time.

The better route is persistent profiles. A profile is nothing more than a folder with two files:

~/dev/qwen3-tts-voice-library/profiles/tim/
  reference.wav
  meta.json
{
    "name": "Tim",
    "profile_id": "tim",
    "ref_audio_filename": "reference.wav",
    "ref_text": "Hallo mein Name ist Tim! Das ist ein Test um meine Stimme zu Klonen.",
    "x_vector_only_mode": false,
    "language": "German"
}

The directory is re-scanned on every request. So a new profile is available immediately — no restart, no registration, no database. You create a folder and the voice exists.

That’s rough and ready, and precisely right for this. For a local setup the filesystem is the appropriate database: a profile can be copied, versioned, scp’d to another machine and inspected with cat. Adding a second profile took me two minutes — resample, drop in reference.wav, write meta.json, done.

After that the voice shows up under GET /v1/voices with a clone: prefix and is addressed through the perfectly ordinary OpenAI-compatible endpoint:

curl -s -o out.wav http://127.0.0.1:8880/v1/audio/speech \
  -H "Content-Type: application/json" \
  -d '{"model":"tts-1","voice":"clone:Dominic","input":"This is my cloned voice.","response_format":"wav"}'

And that is the real multiplier of the whole build. Because the API is OpenAI-compatible, the official client works too:

from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8880/v1", api_key="not-needed")

response = client.audio.speech.create(
    model="tts-1",
    voice="clone:Dominic",
    input="This is my cloned voice.",
)
response.stream_to_file("output.mp3")

Two lines of configuration — a different base_url, a dummy key — and any existing application that speaks OpenAI TTS suddenly speaks in my voice. From local hardware. The cloned voice is, in the end, a string in a field.

The Quality Is in the Recording, Not in the Parameters

Two modes determine how the model uses the reference:

  • x_vector_only_mode: true extracts only a speaker fingerprint from the recording. No transcript needed, but noticeably lower fidelity.
  • x_vector_only_mode: false plus an exact ref_text transcript activates ICL mode. The model gets audio and matching text, and can derive considerably more about the voice from that.

Both production profiles now run in ICL mode, and the difference is clearly audible. The only important part is that the transcript matches the spoken words exactly.

A few hard requirements for the reference material:

  • 5 to 20 seconds of clean speech is enough.
  • WAV or MP3, no M4A/AAC.
  • The model wants 24 kHz mono PCM. So a typical 48 kHz phone recording has to go through:
ffmpeg -i in.m4a -ac 1 -ar 24000 out.wav

The actual lesson, though, was a different one, and it had nothing to do with technology. My first reference recording produced a clone that spoke too fast. I initially went looking for a parameter to control the tempo. There isn’t one — and it would have been the wrong fix anyway.

The fix was a new recording: relaxed pace, natural pauses, varied emphasis. The speaking tempo of the reference transfers directly to the clone, as do stress patterns and intonation. The model doesn’t just imitate the sound of a voice, it imitates the way of speaking. So if you want a good clone, you have to deliver well — a bit of silence removal in post helps on top.

That’s where most of the quality lives, and it costs no compute at all. Just a better recording.

What to Expect in Practice

Two measurements of the same cloned voice, both in a single request without chunking:

AudioCompute timeReal-time factor
34.96 s99.7 s2.85×
106.0 s461.3 s4.35×

106 seconds of continuous audio in one go — roughly 280 to 300 words. It completes; it just takes a while. The real-time factor gets worse with length, but nothing aborts: the cost is time, not failure.

The timeout you trip over sits in the client. The server keeps computing regardless, long after curl or httpx have given up. So if you generate long texts in one piece, set the client timeout generously — --max-time 900 for curl, say — otherwise you’re holding a server problem that isn’t one. Whether a run actually completed is in the log anyway:

docker logs qwen3-tts-api-rocm | grep "Voice clone done"

And the real-time factor is not a constant. During the 106-second run the GPU sat at 99 to 100 % utilisation throughout, while the temperature fell from 88 °C to 69 °C. That isn’t cooling down, it’s visible downclocking: utilisation stays, performance drops. Accordingly, short clips of the same task varied between 2.96× and 8.63× over a single afternoon. Measuring times on this hardware always means measuring thermal state as well.

Long Texts: Chunking Is Still Worth It

For audiobook lengths I split the text into individual sentences anyway and stitch the results together afterwards. Not because there’s no other way, but because it’s more practical: you see progress, individual sentences can be re-run individually, and you don’t wait seven and a half minutes for a result that one bad sentence might spoil.

The approach is unglamorous and it works: split the text into individual sentences, generate each one separately, then stitch them together. With ffmpeg -f concat and roughly 0.3 to 0.4 seconds of silence between sentences, the result sounds natural.

Two details you’d otherwise have to discover yourself:

First, you need retry logic. This is exactly where the spread from earlier hits: a sentence that normally finishes in half a minute sporadically takes three times as long — and blows through any fixed timeout. My real-world test was the fairy tale “Sterntaler” as an audiobook in the cloned voice: 16 chunks, three of which timed out on the first pass and had to be re-run. A script without retries would simply have produced a broken audiobook at that point.

Second, the loudness drifts. Between those 16 chunks there was a 5.8 dB spread — audible as jumps at every sentence boundary. This is an effect the chunking itself creates: each request is its own generation run with no knowledge of its neighbours. A pass of loudnorm across all chunks cleans it up:

ffmpeg -i chunk.wav -af loudnorm chunk-norm.wav

If you’re building chunked TTS, plan for normalisation from the start. I only noticed once the finished audiobook was already sitting there.

Takeaways

Three things I’m taking away from this setup.

“Not supported” is a statement about support, not about capability. Between CPU fallback and 95 % GPU utilisation sat one environment variable here. The hardware could do it all along — it was only missing permission to try.

Docker makes experiments like this risk-free. Not a single package is installed on the host for this project. A ROCm setup you can remove without a trace via docker compose down is one you simply try. That alone lowers the barrier to starting at all.

And the operational quirks are part of the documentation. On this hardware the real-time factor isn’t a metric, it’s a function of thermal state — and the timeout that breaks your neck sits in the client, not the server. Neither appears in any guide, both only show up in day-to-day use, and both cost the next person an evening if nobody writes them down.

Written by Dominic Böttger

← Back to blog

Comments are powered by GitHub Discussions. A GitHub account is required to comment.