Dominic Böttger

← Zurück zum Blog

Die eigene Stimme klonen — lokal auf einer iGPU, die ROCm gar nicht unterstützt

Veröffentlicht am 13. August 2026 von Dominic Böttger (heute) · 12 Min. Lesezeit

Es gibt einen Moment, in dem lokale KI aufhört, ein Bastelprojekt zu sein. Bei mir war das der Punkt, an dem aus dem Lautsprecher meine eigene Stimme kam und einen Satz sagte, den ich nie gesprochen hatte — erzeugt auf der integrierten Grafikeinheit meines Laptops, ohne dass ein einziges Byte das Gerät verlassen hatte.

Das Modell dahinter ist Qwen3-TTS, das Voice Cloning aus wenigen Sekunden Referenzmaterial beherrscht. Die Hardware ist eine AMD Radeon 890M, die in einem Framework-Laptop steckt. Und die ist für so etwas offiziell nicht vorgesehen: Die Strix-Point-APU meldet sich als gfx1150, und gfx1150 steht nicht auf der Liste der von ROCm unterstützten Architekturen.

„Nicht unterstützt” ist aber nicht dasselbe wie „funktioniert nicht”. Was dazwischen liegt, ist im Wesentlichen eine Umgebungsvariable — und ein paar Einstellungen, die man kennen muss, weil sie sonst still auf die CPU zurückfallen oder gar nicht erst starten.

Am Ende steht ein Setup, das komplett in Docker läuft, eine OpenAI-kompatible API anbietet und geklonte Stimmen als Dateisystem-Profile verwaltet. Dazu ein paar Betriebseigenschaften, die man kennen sollte, bevor man es ernsthaft benutzt — und die in keiner Anleitung stehen.

„Nicht unterstützt” heißt: gib dich als etwas anderes aus

Der zentrale Trick ist eine einzige Zeile in der docker-compose.rocm.yml:

- HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-11.0.0}

Damit gibt sich die 890M gegenüber der ROCm-Runtime als gfx1100 aus — eine RDNA-3-Desktop-Karte, für die es einen gepflegten, getesteten Code-Pfad gibt. Die Hardware ist eng genug verwandt, dass die Kernel korrekt rechnen.

Bemerkenswert ist hier vor allem, wie unspektakulär es lief: Der Wert 11.0.0 hat beim ersten Versuch funktioniert. Kein Absturz, kein Rauschen statt Sprache, kein stiller Rückfall auf die CPU. Falls 11.0.0 auf einem anderen gfx1150-Gerät zickt, sind 11.5.0 und 11.5.1 die gängigen Alternativen — und weil der Wert als Variable durchgereicht wird, probiert man das ohne Dateiänderung durch:

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

Dass dieser Override funktioniert, ist übrigens kein Einzelfall. Ich benutze denselben Kniff auf derselben Hardware für GPU-beschleunigte Transkription mit WhisperX — dort wird er sogar zur Performance-Optimierung, weil ROCm für gfx1100 handoptimierte Kernel mitbringt und für gfx1150 nicht. Wer eine Strix-Point-APU hat und ROCm ausprobieren will, sollte diese Variable als Erstes kennen.

Das Docker-Setup und die zwei Zahlen, die niemand errät

Nichts davon ist auf dem Host installiert. Basis ist das offizielle ROCm-PyTorch-Image:

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

Dazu transformers 4.57.3 und accelerate 1.12.0, beide gepinnt. Der GPU-Zugriff läuft über zwei durchgereichte Devices — und über eine Stelle, die deutlich mehr Ärger macht, als sie sollte:

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

Die Devices allein reichen nicht. Der Prozess im Container muss auch Mitglied der Gruppen video und render sein, sonst darf er die Geräte nicht öffnen — und weil Docker Gruppen über numerische IDs auflöst, müssen dort die GIDs des Hosts stehen. Auf meinem System sind das 985 und 989; auf einem anderen Rechner sind es fast sicher andere. Herausfinden lassen sie sich so:

getent group video render

Das ist eine der unangenehmeren Fallen im ganzen Setup, weil sie sich genauso verhält wie alle GPU-Probleme in dieser Ecke: Es gibt keinen Fehler. Der Container startet, die API antwortet, die Sprachsynthese funktioniert — sie rechnet nur auf der CPU und ist entsprechend zäh.

Vier Volumes sorgen dafür, dass ein docker compose down nichts kostet:

HostContainerZweck
~/dev/qwen3-tts-models/root/.cache/huggingfaceModell-Cache
~/dev/voice-samples/app/voice-samplesReferenzaufnahmen und Ausgaben
~/dev/qwen3-tts-voice-library/app/voice_librarygespeicherte Stimmprofile
./config.rocm.yaml/app/config.rocm.yaml:roKonfiguration, live editierbar

Das letzte ist eine Kleinigkeit mit großer Wirkung: Die Modellkonfiguration ist read-only ins Image gemountet statt hineinkopiert. Man ändert sie auf dem Host, startet den Container neu — und muss nichts neu bauen.

Der Port ist bewusst eingesperrt:

ports:
  - "127.0.0.1:8880:8880"

Ein TTS-Server, der die eigene Stimme klonen kann, hat im lokalen Netz nichts zu suchen. Ohne das 127.0.0.1:-Präfix würde Docker den Port auf allen Interfaces öffnen — und dabei sogar an einer Firewall vorbei, weil Docker seine Regeln in iptables vor die üblichen einhängt. Der Healthcheck bekommt außerdem start_period: 120s, weil beim allerersten Start noch das Modell heruntergeladen wird und der Container sonst als „unhealthy” gilt, bevor er überhaupt fertig ist.

Was auf einer inoffiziellen iGPU aus bleiben muss

Der Server läuft mit TTS_BACKEND=optimized und liest dann config.rocm.yaml. Die interessanten Zeilen darin sind die, die etwas abschalten:

optimization:
  attention: sdpa
  use_compile: false
  use_cuda_graphs: false

  streaming:
    decode_window_frames: 72
    emit_every_frames: 6

Der Reihe nach:

  • attention: sdpa — Flash-Attention gibt es auf ROCm nicht. Also PyTorchs eingebautes Scaled-Dot-Product-Attention. Das ist keine reine Formsache: Der Fallback-Pfad kostet Durchsatz, und zwar besonders bei langen Sequenzen.
  • use_compile: false und use_cuda_graphs: falsetorch.compile und CUDA-Graph-Capture sind die zwei Optimierungen, die auf einer nicht unterstützten Architektur am ehesten kaputtgehen. Auf einer iGPU, die man ohnehin schon belügt, ist der stabile Startpunkt der richtige. Man kann sie später einzeln dazuschalten.
  • decode_window_frames: 72 — und hier wird es sehr konkret. Die Werte 66, 67 und 71 lösen auf ROCm einen Bug beim CUDA-Graph-Capture aus. Auf NVIDIA funktionieren 64, 72 und 80 gleichermaßen. Solange die Graphs wie oben abgeschaltet sind, beißt das nicht — aber es beißt in dem Moment, in dem man sie einschaltet, und das ist der Moment, in dem man an alles andere denkt als an eine Fensterbreite. Ein Grund mehr, 72 gleich stehen zu lassen. Es ist genau die Sorte Detail, die man selbst nicht findet, sondern nur durch stundenlanges Ausprobieren — deshalb steht es als Kommentar direkt in der Konfigurationsdatei.

Ob das Ganze tatsächlich auf der GPU landet, prüft man in den Logs:

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

Erwartet werden Model '...' ready on cuda:0 und GPU: AMD Radeon Graphics. Steht dort cpu, stimmt etwas mit den Devices oder den Gruppen-IDs nicht. Und während ein Request läuft:

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

Bei mir zeigt das rund 95 % GPU-Auslastung während der Inferenz. Das ist der Unterschied, den eine einzige Umgebungsvariable ausmacht.

Die Reihenfolge in der Config ist die Modellauswahl

Qwen3-TTS gibt es in zwei Geschmacksrichtungen, und der Unterschied ist wichtig:

  • customvoice-Modelle bringen vordefinierte Sprecher mit (Vivian, Ryan und weitere). Schnell, gut für normales Text-to-Speech.
  • base-Modelle sind für Voice Cloning zwingend. Ohne ein Base-Modell gibt es keine geklonte Stimme.

Und hier lauert ein Fallstrick, der einen leicht das falsche Ergebnis liefern lässt, ohne dass etwas kaputt aussieht: Der Server nimmt für clone:-Stimmen schlicht den ersten Eintrag mit type: base, den er in der YAML findet. Nicht den größten, nicht den besten — den ersten.

In meiner Konfiguration steht 1.7B-Base deshalb bewusst vor 0.6B-Base:

models:
  0.6B-CustomVoice:
    hf_id: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
    type: customvoice
  # 1.7B-Base vor 0.6B-Base, damit es als Basis-/Klonmodell gewählt wird
  # (der Server nimmt den ersten "type: base"-Eintrag für clone:-Stimmen)
  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

Die Zeilenreihenfolge in einer YAML-Datei entscheidet also über die Qualität der geklonten Stimme. Das ist kein offensichtliches Verhalten, weshalb der Kommentar direkt darüber steht — sonst sortiert es beim nächsten Aufräumen jemand alphabetisch und wundert sich, warum die Klone schlechter geworden sind.

Stimmprofile sind ein Ordner mit zwei Dateien

Der naheliegende Weg zum Voice Cloning ist der Endpoint /v1/audio/voice-clone, dem man die Referenzaufnahme bei jedem Request base64-codiert mitschickt. Das funktioniert, ist aber unhandlich: Die Aufnahme geht jedes Mal über die Leitung, und das Speaker-Embedding wird jedes Mal neu berechnet.

Der bessere Weg sind persistente Profile. Ein Profil ist nichts weiter als ein Ordner mit zwei Dateien:

~/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"
}

Das Verzeichnis wird bei jedem Request neu eingelesen. Ein neues Profil ist also sofort verfügbar, ohne Neustart, ohne Registrierung, ohne Datenbank. Man legt einen Ordner an und die Stimme existiert.

Das ist hemdsärmelig, und genau deshalb richtig. Für ein lokales Setup ist das Dateisystem die passende Datenbank: Ein Profil lässt sich kopieren, versionieren, per scp auf einen anderen Rechner schieben und mit cat inspizieren. Ein zweites Profil anzulegen war bei mir eine Zwei-Minuten-Sache — resamplen, reference.wav ablegen, meta.json schreiben, fertig.

Danach taucht die Stimme unter GET /v1/voices mit clone:-Präfix auf und wird über den ganz normalen OpenAI-kompatiblen Endpoint angesprochen:

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":"Das ist meine geklonte Stimme.","response_format":"wav"}'

Und das ist der eigentliche Multiplikator des ganzen Aufbaus. Weil die API OpenAI-kompatibel ist, funktioniert auch der offizielle Client:

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="Das ist meine geklonte Stimme.",
)
response.stream_to_file("output.mp3")

Zwei Zeilen Konfiguration — eine andere base_url, ein Dummy-Key — und jede bestehende Anwendung, die OpenAI-TTS beherrscht, spricht plötzlich mit meiner Stimme. Aus lokaler Hardware. Die geklonte Stimme ist am Ende ein String in einem Feld.

Die Qualität steckt in der Aufnahme, nicht in den Parametern

Zwei Modi bestimmen, wie das Modell die Referenz verwertet:

  • x_vector_only_mode: true zieht nur einen Sprecher-Fingerabdruck aus der Aufnahme. Kein Transkript nötig, dafür merklich schlechtere Klangtreue.
  • x_vector_only_mode: false plus ein exaktes ref_text-Transkript aktiviert den ICL-Modus. Das Modell bekommt Audio und zugehörigen Text und kann daraus deutlich mehr über die Stimme ableiten.

Beide produktiven Profile laufen inzwischen im ICL-Modus, und der Unterschied ist deutlich hörbar. Wichtig ist dabei nur, dass das Transkript wirklich exakt dem Gesprochenen entspricht.

Ein paar handfeste Anforderungen an das Referenzmaterial:

  • 5 bis 20 Sekunden sauberes Sprachmaterial genügen.
  • WAV oder MP3, kein M4A/AAC.
  • Das Modell will 24 kHz mono PCM. Eine typische 48-kHz-Handyaufnahme muss also vorher durch:
ffmpeg -i in.m4a -ac 1 -ar 24000 out.wav

Das eigentliche Learning war aber ein anderes, und es hatte mit Technik nichts zu tun. Meine erste Referenzaufnahme führte zu einem Klon, der zu schnell sprach. Ich habe zuerst nach einem Parameter gesucht, der das Tempo regelt. Den gibt es nicht — und er wäre auch die falsche Lösung gewesen.

Die Lösung war eine neue Aufnahme: entspanntes Sprechtempo, natürliche Pausen, variierte Betonung. Das Sprechtempo der Referenz überträgt sich direkt auf den Klon, genau wie Betonungsmuster und Sprachmelodie. Das Modell imitiert nicht nur den Klang einer Stimme, sondern die Art zu sprechen. Wer einen guten Klon will, muss also gut einsprechen — ein bisschen Silence-Removal als Nachbearbeitung hilft zusätzlich.

Das ist die Stelle, an der die meiste Qualität liegt, und sie kostet keine Rechenzeit. Nur eine bessere Aufnahme.

Was in der Praxis zu erwarten ist

Zwei Messwerte derselben geklonten Stimme, beide in einem einzigen Request ohne Chunking:

AudioRechenzeitEchtzeitfaktor
34,96 s99,7 s2,85×
106,0 s461,3 s4,35×

106 Sekunden zusammenhängendes Audio am Stück, also grob 280 bis 300 Wörter. Das läuft durch — es dauert nur. Der Echtzeitfaktor verschlechtert sich mit der Länge, aber nichts bricht ab: Die Kosten sind Zeit, nicht Scheitern.

Der Timeout, über den man dabei stolpert, sitzt im Client. Der Server rechnet stur weiter, auch wenn curl oder httpx längst aufgegeben haben. Wer lange Texte am Stück generiert, setzt den Client-Timeout also großzügig — bei curl etwa --max-time 900 — sonst hält man ein Serverproblem in der Hand, das keines ist. Ob ein Lauf tatsächlich durchgekommen ist, steht ohnehin im Log:

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

Und der Echtzeitfaktor ist keine Konstante. Während des 106-Sekunden-Laufs lag die GPU durchgehend bei 99 bis 100 % Auslastung, die Temperatur fiel dabei aber von 88 °C auf 69 °C. Das ist kein Abkühlen, sondern sichtbares Runtakten: Die Auslastung bleibt, die Leistung sinkt. Entsprechend schwankten kurze Clips derselben Aufgabe an einem Nachmittag zwischen 2,96× und 8,63×. Wer auf dieser Hardware Zeiten misst, misst immer den thermischen Zustand mit.

Lange Texte: Chunking lohnt sich trotzdem

Für Hörbuchlängen splitte ich den Text dennoch in einzelne Sätze und füge die Ergebnisse danach zusammen. Nicht weil es anders nicht ginge, sondern weil es praktischer ist: Man sieht Fortschritt, einzelne Sätze lassen sich einzeln wiederholen, und man wartet nicht siebeneinhalb Minuten auf ein Ergebnis, an dem hinterher womöglich ein einziger Satz stört.

Das Vorgehen ist unglamourös und funktioniert: Text in einzelne Sätze splitten, jeden Satz separat generieren, danach zusammenfügen. Mit ffmpeg -f concat und etwa 0,3 bis 0,4 Sekunden Stille zwischen den Sätzen klingt das Ergebnis natürlich.

Zwei Details, die man dabei selbst herausfinden müsste:

Erstens braucht es Retry-Logik. Genau hier schlägt die Streuung von oben zu: Ein Satz, der normalerweise in einer halben Minute fertig ist, braucht sporadisch das Dreifache — und reißt damit jeden fest gesetzten Timeout. Mein Praxistest war das Märchen „Sterntaler” als Hörbuch mit der geklonten Stimme: 16 Chunks, davon drei im ersten Durchlauf im Timeout, per Wiederholung nachgezogen. Ein Skript ohne Retry hätte an dieser Stelle einfach ein kaputtes Hörbuch produziert.

Zweitens schwankt die Lautstärke. Zwischen den 16 Chunks lagen 5,8 dB Unterschied — hörbar als Sprünge bei jedem Satzwechsel. Das ist ein Effekt, der überhaupt erst durch das Chunking entsteht: Jeder Request ist ein eigener Generierungslauf ohne Kenntnis der Nachbarn. Ein Durchlauf mit loudnorm über alle Chunks räumt das auf:

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

Wer chunked TTS baut, sollte Normalisierung von Anfang an einplanen. Ich habe es erst gemerkt, als das fertige Hörbuch schon da war.

Fazit

Drei Dinge nehme ich aus diesem Setup mit.

„Nicht unterstützt” ist eine Aussage über Support, nicht über Fähigkeit. Zwischen CPU-Fallback und 95 % GPU-Auslastung lag hier eine Umgebungsvariable. Die Hardware konnte es die ganze Zeit — es fehlte nur die Erlaubnis, es zu versuchen.

Docker macht solche Experimente risikofrei. Auf dem Host ist für dieses Projekt kein einziges Paket installiert. Ein ROCm-Setup, das man mit docker compose down rückstandsfrei entfernt, probiert man einfach aus. Genau das senkt die Hürde, überhaupt anzufangen.

Und die Betriebseigenschaften gehören zur Dokumentation. Auf dieser Hardware ist der Echtzeitfaktor keine Kennzahl, sondern eine Funktion des thermischen Zustands — und der Timeout, der einem das Genick bricht, sitzt im Client und nicht im Server. Beides steht in keiner Anleitung, beides merkt man erst im Betrieb, und beides kostet den nächsten Menschen einen Abend, wenn es niemand aufschreibt.

Geschrieben von Dominic Böttger

← Zurück zum Blog

Kommentare werden über GitHub Discussions bereitgestellt. Zum Kommentieren wird ein GitHub-Konto benötigt.

Aktuelle Blogbeiträge