Use qwen3-asr throughout speech integrations

This commit is contained in:
Mikei386
2026-09-25 21:46:43 +02:00
parent 8a323e5b9e
commit da10f6b48d
17 changed files with 52 additions and 58 deletions
+2 -2
View File
@@ -95,8 +95,8 @@ nicht direkt. Kein automatischer Host-Neustart ist vorgesehen.
- Qwen-Image-2.1 INT8: Bildgenerierung und Editing auf der RTX 5080; das Textmodell wird dafür kurz entladen und danach - Qwen-Image-2.1 INT8: Bildgenerierung und Editing auf der RTX 5080; das Textmodell wird dafür kurz entladen und danach
automatisch wiederhergestellt automatisch wiederhergestellt
- Qwen3-TTS 1.7B: RTX 3060; kein Piper-Fallback - Qwen3-TTS 1.7B: RTX 3060; kein Piper-Fallback
- Qwen3-ASR 0.6B Q8: CPU, hinter dem bestehenden OpenAI-kompatiblen - Qwen3-ASR 0.6B Q8: CPU, hinter dem OpenAI-kompatiblen
Transkriptionsendpunkt. `whisper-1` bleibt nur als API-Kompatibilitätsname. Transkriptionsendpunkt mit dem Modellnamen `qwen3-asr`.
- EmbeddingGemma 300M Q8: CPU, OpenAI-kompatibel auf Port 8082; kein GPU-Zugriff - EmbeddingGemma 300M Q8: CPU, OpenAI-kompatibel auf Port 8082; kein GPU-Zugriff
Die geprüften Live-Werte stehen in [docs/LIVE_STATE.md](docs/LIVE_STATE.md). Die geprüften Live-Werte stehen in [docs/LIVE_STATE.md](docs/LIVE_STATE.md).
+2 -2
View File
@@ -145,8 +145,8 @@ Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
`/v1/audio/speech` und Spracherkennung über `/v1/audio/transcriptions`. `/v1/audio/speech` und Spracherkennung über `/v1/audio/transcriptions`.
Spracherkennung nutzt Qwen3-ASR-0.6B Q8 auf der CPU; das Modell liegt unter Spracherkennung nutzt Qwen3-ASR-0.6B Q8 auf der CPU; das Modell liegt unter
`/data/models/qwen3-asr-0.6b-q8`. Der Adapter liefert reinen Text unter `/data/models/qwen3-asr-0.6b-q8`. Der Adapter liefert reinen Text unter
`qwen3-asr` und dem bisherigen `whisper-1`-Kompatibilitätsnamen, sodass `qwen3-asr`; OpenClaw und die Voice-Brücke verwenden denselben Modellnamen.
OpenClaw nicht neu konfiguriert werden muss. Whisper-Container, Image und Whisper-Container, Image und
Modellvolume sind entfernt. Audiodaten werden lokal auf Athena verarbeitet. Modellvolume sind entfernt. Audiodaten werden lokal auf Athena verarbeitet.
Für OpenClaw Talk liegt der lokale Für OpenClaw Talk liegt der lokale
Realtime-Provider unter Realtime-Provider unter
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Mock-STT-Worker für lokale Tests. """Mock-STT-Worker für lokale Tests.
Simuliert den Whisper-STT-Worker: Simuliert den Qwen3-ASR-Adapter:
GET /status → ready: true GET /status → ready: true
POST /transcribe → liefert festes Transkript POST /transcribe → liefert festes Transkript
+8 -8
View File
@@ -220,7 +220,7 @@ def main() -> None:
# Test 1: Multipart + Content-Length (bestehender Pfad) # Test 1: Multipart + Content-Length (bestehender Pfad)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 1: Multipart + Content-Length") print("Test 1: Multipart + Content-Length")
mp = build_multipart({"model": "whisper-1", "language": "de"}, mp = build_multipart({"model": "qwen3-asr", "language": "de"},
file_data=fake_webm) file_data=fake_webm)
status, body = http_request( status, body = http_request(
"POST", PORTS["router"], "/v1/audio/transcriptions", "POST", PORTS["router"], "/v1/audio/transcriptions",
@@ -235,7 +235,7 @@ def main() -> None:
# Test 2: Multipart + Transfer-Encoding chunked (einfach) # Test 2: Multipart + Transfer-Encoding chunked (einfach)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 2: Multipart + chunked (einfach)") print("Test 2: Multipart + chunked (einfach)")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunked = build_chunked_body([mp]) chunked = build_chunked_body([mp])
raw = ( raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n" b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
@@ -254,7 +254,7 @@ def main() -> None:
# Test 3: Mehrere unterschiedlich große Chunks # Test 3: Mehrere unterschiedlich große Chunks
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 3: Mehrere unterschiedlich große Chunks") print("Test 3: Mehrere unterschiedlich große Chunks")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
# In 5 Chunks aufteilen (unterschiedlich groß) # In 5 Chunks aufteilen (unterschiedlich groß)
chunks = [] chunks = []
sizes = [10, 50, 7, 100, 33] sizes = [10, 50, 7, 100, 33]
@@ -283,7 +283,7 @@ def main() -> None:
# Test 4: Boundary über Chunk-Grenzen verteilt # Test 4: Boundary über Chunk-Grenzen verteilt
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 4: Boundary über Chunk-Grenzen verteilt") print("Test 4: Boundary über Chunk-Grenzen verteilt")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
# Boundary-String finden und Chunk-Grenze genau dorthin setzen # Boundary-String finden und Chunk-Grenze genau dorthin setzen
boundary_str = b"--testboundary123" boundary_str = b"--testboundary123"
idx = mp.find(boundary_str, 10) # zweite Boundary (vor file) idx = mp.find(boundary_str, 10) # zweite Boundary (vor file)
@@ -310,7 +310,7 @@ def main() -> None:
# Test 5: Chunk Extensions # Test 5: Chunk Extensions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 5: Chunk Extensions") print("Test 5: Chunk Extensions")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunks_ext = [ chunks_ext = [
(mp[:20], "ext1=value1"), (mp[:20], "ext1=value1"),
(mp[20:60], None), (mp[20:60], None),
@@ -335,7 +335,7 @@ def main() -> None:
# hier explizit mit Trailer) # hier explizit mit Trailer)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 6: 0-Chunk mit Trailer") print("Test 6: 0-Chunk mit Trailer")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunked = build_chunked_body([mp]) chunked = build_chunked_body([mp])
# Trailer hinzufügen # Trailer hinzufügen
chunked_with_trailer = chunked.replace( chunked_with_trailer = chunked.replace(
@@ -376,7 +376,7 @@ def main() -> None:
print("Test 8: Uploadgrößenlimit") print("Test 8: Uploadgrößenlimit")
# MAX_UPLOAD_SIZE = 1 MB, also 2 MB senden # MAX_UPLOAD_SIZE = 1 MB, also 2 MB senden
big_data = b"A" * (2 * 1024 * 1024) big_data = b"A" * (2 * 1024 * 1024)
mp = build_multipart({"model": "whisper-1"}, file_data=big_data) mp = build_multipart({"model": "qwen3-asr"}, file_data=big_data)
chunked = build_chunked_body([mp[:1024 * 1024], mp[1024 * 1024:]]) chunked = build_chunked_body([mp[:1024 * 1024], mp[1024 * 1024:]])
raw = ( raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n" b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
@@ -402,7 +402,7 @@ def main() -> None:
+ b"WEBM_OPUS_AUDIO_DATA" * 100) + b"WEBM_OPUS_AUDIO_DATA" * 100)
boundary = "950bd961b24c4a32801e31b128c85e09" boundary = "950bd961b24c4a32801e31b128c85e09"
mp = build_multipart( mp = build_multipart(
{"model": "whisper-1", "language": "de"}, {"model": "qwen3-asr", "language": "de"},
file_data=webm_data, file_data=webm_data,
filename="recording.webm", filename="recording.webm",
boundary=boundary, boundary=boundary,
+12 -12
View File
@@ -71,13 +71,13 @@ def test_quoted_boundary():
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW" boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50 webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50
body, ct = build( body, ct = build(
[("model", "whisper-1", None), ("file", webm, "t.webm")], [("model", "qwen3-asr", None), ("file", webm, "t.webm")],
boundary, quoted=True, boundary, quoted=True,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "t.webm", f"filename mismatch: {fn!r}" assert fn == "t.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" quoted boundary: OK") print(" quoted boundary: OK")
@@ -109,7 +109,7 @@ def test_openwebui_style():
f"\r\n--{boundary}\r\n" f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n' f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n" f"\r\n"
f"whisper-1\r\n" f"qwen3-asr\r\n"
f"--{boundary}\r\n" f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="temperature"\r\n' f'Content-Disposition: form-data; name="temperature"\r\n'
f"\r\n" f"\r\n"
@@ -119,7 +119,7 @@ def test_openwebui_style():
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "rec.webm", f"filename mismatch: {fn!r}" assert fn == "rec.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}" assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}"
print(" Open-WebUI-artig: OK") print(" Open-WebUI-artig: OK")
@@ -128,13 +128,13 @@ def test_file_before_model():
"""File-Feld vor model-Feld.""" """File-Feld vor model-Feld."""
boundary = "boundary123" boundary = "boundary123"
body, ct = build( body, ct = build(
[("file", b"DATA", "f.wav"), ("model", "whisper-1", None)], [("file", b"DATA", "f.wav"), ("model", "qwen3-asr", None)],
boundary, quoted=False, boundary, quoted=False,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch" assert fd == b"DATA", "file_data mismatch"
assert fn == "f.wav", f"filename mismatch: {fn!r}" assert fn == "f.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" File vor model: OK") print(" File vor model: OK")
@@ -142,13 +142,13 @@ def test_file_after_model():
"""model-Feld vor File-Feld.""" """model-Feld vor File-Feld."""
boundary = "boundary456" boundary = "boundary456"
body, ct = build( body, ct = build(
[("model", "whisper-1", None), ("file", b"DATA", "g.wav")], [("model", "qwen3-asr", None), ("file", b"DATA", "g.wav")],
boundary, quoted=False, boundary, quoted=False,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch" assert fd == b"DATA", "file_data mismatch"
assert fn == "g.wav", f"filename mismatch: {fn!r}" assert fn == "g.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" File nach model: OK") print(" File nach model: OK")
@@ -182,13 +182,13 @@ def test_extra_headers_ignored():
f"\r\n--{boundary}\r\n" f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n' f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n" f"\r\n"
f"whisper-1\r\n" f"qwen3-asr\r\n"
f"--{boundary}--\r\n" f"--{boundary}--\r\n"
).encode() ).encode()
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "x.webm", f"filename mismatch: {fn!r}" assert fn == "x.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" Extra-Header ignoriert: OK") print(" Extra-Header ignoriert: OK")
@@ -198,7 +198,7 @@ def test_all_fields():
body, ct = build( body, ct = build(
[ [
("file", b"AUDIO", "a.webm"), ("file", b"AUDIO", "a.webm"),
("model", "whisper-1", None), ("model", "qwen3-asr", None),
("language", "de", None), ("language", "de", None),
("prompt", "Kontext", None), ("prompt", "Kontext", None),
("response_format", "verbose_json", None), ("response_format", "verbose_json", None),
@@ -209,7 +209,7 @@ def test_all_fields():
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"AUDIO", "file_data mismatch" assert fd == b"AUDIO", "file_data mismatch"
assert fn == "a.webm", f"filename mismatch: {fn!r}" assert fn == "a.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
assert fl["language"] == "de", f"language mismatch: {fl!r}" assert fl["language"] == "de", f"language mismatch: {fl!r}"
assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}" assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}"
assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}" assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}"
+1 -1
View File
@@ -39,7 +39,7 @@ nicht automatisch ein ungenutzter Rest.
| `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. | | `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. |
| `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. | | `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. |
| `mike-ai-qwen-asr` | Qwen3-ASR 0.6B Q8 | CPU-Inferenz für lokale deutsche Spracherkennung. | | `mike-ai-qwen-asr` | Qwen3-ASR 0.6B Q8 | CPU-Inferenz für lokale deutsche Spracherkennung. |
| `mike-ai-qwen-asr-worker` | kein eigenes Modell | Audio-Adapter für `/v1/audio/transcriptions`; `whisper-1` ist nur ein API-Kompatibilitätsname. | | `mike-ai-qwen-asr-worker` | kein eigenes Modell | Audio-Adapter für `/v1/audio/transcriptions` mit dem Modellnamen `qwen3-asr`. |
| `mike-ai-wireguard-gateway` | kein Modell | Veröffentlicht Dashboard und Fachoberflächen ausschließlich über den privaten WireGuard-Pfad. | | `mike-ai-wireguard-gateway` | kein Modell | Veröffentlicht Dashboard und Fachoberflächen ausschließlich über den privaten WireGuard-Pfad. |
| `mike-ai-xvc-studio` | `chenxie95/X-VC`, GLM-4-Voice-Tokenizer und optional Resemble Enhance | Wandelt eine vorhandene Sprachaufnahme anhand einer Referenzstimme in Audio zu Audio um; gibt das native 16-kHz-Ergebnis und optional eine neural restaurierte 44,1-kHz-Fassung aus. | | `mike-ai-xvc-studio` | `chenxie95/X-VC`, GLM-4-Voice-Tokenizer und optional Resemble Enhance | Wandelt eine vorhandene Sprachaufnahme anhand einer Referenzstimme in Audio zu Audio um; gibt das native 16-kHz-Ergebnis und optional eine neural restaurierte 44,1-kHz-Fassung aus. |
| `mike-ai-yue2-playground` | Image `mike-ai/yue2:3b-0.1.6` | Vorhandener, gestoppter Playground; in diesem Abgleich nicht funktional getestet. | | `mike-ai-yue2-playground` | Image `mike-ai/yue2:3b-0.1.6` | Vorhandener, gestoppter Playground; in diesem Abgleich nicht funktional getestet. |
+3 -3
View File
@@ -2,9 +2,9 @@
Nachtrag vom 25. September 2026: Die produktive Spracherkennung läuft über Nachtrag vom 25. September 2026: Die produktive Spracherkennung läuft über
Qwen3-ASR 0.6B Q8 auf der CPU (`mike-ai-qwen-asr` und Qwen3-ASR 0.6B Q8 auf der CPU (`mike-ai-qwen-asr` und
`mike-ai-qwen-asr-worker`). Der Router bietet weiterhin `whisper-1` als `mike-ai-qwen-asr-worker`). Der Router bietet nur `qwen3-asr` als STT-Modell
Kompatibilitätsnamen und zusätzlich `qwen3-asr` an. Beide wurden mit einer an; OpenClaw und die Voice-Brücke verwenden denselben Namen. Eine M4A-Aufnahme
M4A-Aufnahme über `/v1/audio/transcriptions` geprüft. Whisper-Container, wurde über `/v1/audio/transcriptions` geprüft. Whisper-Container,
Images und Modellvolume wurden entfernt. Das aktive Ultra-Profil wurde bei Images und Modellvolume wurden entfernt. Das aktive Ultra-Profil wurde bei
dieser Umstellung nicht gewechselt. Die Angaben zu Whisper weiter unten dieser Umstellung nicht gewechselt. Die Angaben zu Whisper weiter unten
beschreiben den historischen Stand vom 21. September. beschreiben den historischen Stand vom 21. September.
+4 -4
View File
@@ -60,7 +60,8 @@ openclaw plugins install . --force --accept-capabilities
openclaw plugins inspect athena-talk --runtime --json openclaw plugins inspect athena-talk --runtime --json
``` ```
The plugin registers **Athena Qwen3-ASR (Diktieren)** as a separate Version 1.3.1 sends `qwen3-asr` throughout dictation and Talk. The plugin
registers **Athena Qwen3-ASR (Diktieren)** as a separate
realtime transcription provider through OpenClaw's official plugin API. In the realtime transcription provider through OpenClaw's official plugin API. In the
browser composer, hold the microphone for dictation, then release it to send browser composer, hold the microphone for dictation, then release it to send
the 8 kHz G.711 audio through the Gateway. Short recordings are converted to the 8 kHz G.711 audio through the Gateway. Short recordings are converted to
@@ -95,8 +96,7 @@ An M4A voice note uploaded as a chat attachment does **not** use the realtime
dictation provider above. OpenClaw processes it through its built-in dictation provider above. OpenClaw processes it through its built-in
`tools.media.audio` path. On the Unraid installation, automatic provider `tools.media.audio` path. On the Unraid installation, automatic provider
selection hit `SsrFBlockedError` for the private Athena address. Configure the selection hit `SsrFBlockedError` for the private Athena address. Configure the
existing OpenAI-compatible provider and retain the `whisper-1` API alias for existing OpenAI-compatible provider with the `qwen3-asr` model:
Qwen3-ASR:
```json5 ```json5
{ {
@@ -113,7 +113,7 @@ Qwen3-ASR:
models: [ models: [
{ {
provider: "openai", provider: "openai",
model: "whisper-1", model: "qwen3-asr",
baseUrl: "http://192.168.1.212:8081/v1", baseUrl: "http://192.168.1.212:8081/v1",
capabilities: ["audio"], capabilities: ["audio"],
}, },
+4 -4
View File
@@ -262,7 +262,7 @@ class AthenaTranscriptionSession {
async transcribe(audio, prompt) { async transcribe(audio, prompt) {
const form = new FormData(); const form = new FormData();
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav"); form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.config.language); form.append("language", this.config.language);
if (prompt) if (prompt)
form.append("prompt", prompt); form.append("prompt", prompt);
@@ -471,7 +471,7 @@ class AthenaTalkBridge {
const form = new FormData(); const form = new FormData();
const wavBytes = Uint8Array.from(wavFromPcm16(pcm)); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav"); form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
method: "POST", method: "POST",
@@ -568,8 +568,8 @@ export default definePluginEntry({
api.registerRealtimeTranscriptionProvider({ api.registerRealtimeTranscriptionProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Qwen3-ASR (Diktieren)", label: "Athena Qwen3-ASR (Diktieren)",
defaultModel: "whisper-1", defaultModel: "qwen3-asr",
models: ["whisper-1"], models: ["qwen3-asr"],
autoSelectOrder: 1, autoSelectOrder: 1,
resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig), resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig),
isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl), isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl),
+4 -4
View File
@@ -289,7 +289,7 @@ class AthenaTranscriptionSession {
private async transcribe(audio: Buffer, prompt: string): Promise<string> { private async transcribe(audio: Buffer, prompt: string): Promise<string> {
const form = new FormData(); const form = new FormData();
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav"); form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.config.language); form.append("language", this.config.language);
if (prompt) form.append("prompt", prompt); if (prompt) form.append("prompt", prompt);
const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, {
@@ -490,7 +490,7 @@ class AthenaTalkBridge {
const form = new FormData(); const form = new FormData();
const wavBytes = Uint8Array.from(wavFromPcm16(pcm)); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav"); form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
method: "POST", method: "POST",
@@ -579,8 +579,8 @@ export default definePluginEntry({
api.registerRealtimeTranscriptionProvider({ api.registerRealtimeTranscriptionProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Qwen3-ASR (Diktieren)", label: "Athena Qwen3-ASR (Diktieren)",
defaultModel: "whisper-1", defaultModel: "qwen3-asr",
models: ["whisper-1"], models: ["qwen3-asr"],
autoSelectOrder: 1, autoSelectOrder: 1,
resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig), resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig),
isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl), isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl),
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.3.0", "version": "1.3.1",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"openclaw": "2026.9.4", "openclaw": "2026.9.4",
@@ -1,6 +1,6 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "Local OpenClaw Talk provider backed by Athena Qwen3-ASR and Qwen3-TTS", "description": "Local OpenClaw Talk provider backed by Athena Qwen3-ASR and Qwen3-TTS",
"type": "module", "type": "module",
@@ -11,7 +11,7 @@ async function waitFor(predicate, timeoutMs = 2000) {
} }
} }
test("dictation registers separately and sends G.711 audio to Athena Whisper", async () => { test("dictation registers separately and sends G.711 audio to Athena Qwen3-ASR", async () => {
let transcription; let transcription;
plugin.register({ plugin.register({
registerRealtimeTranscriptionProvider: (value) => { transcription = value; }, registerRealtimeTranscriptionProvider: (value) => { transcription = value; },
@@ -37,7 +37,7 @@ test("dictation registers separately and sends G.711 audio to Athena Whisper", a
assert.equal(wav.readUInt16LE(34), 16); assert.equal(wav.readUInt16LE(34), 16);
assert.equal(wav.length, 48); assert.equal(wav.length, 48);
assert.equal(form.get("language"), "de"); assert.equal(form.get("language"), "de");
assert.equal(form.get("model"), "whisper-1"); assert.equal(form.get("model"), "qwen3-asr");
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ text: "Hallo Athena" })); res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ text: "Hallo Athena" }));
}); });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+2 -2
View File
@@ -133,8 +133,8 @@ display:
language: "de" language: "de"
show_reasoning: false show_reasoning: false
# Speech input stays local and private. A fixed German hint avoids Whisper # Speech input stays local and private. The fixed German hint helps with
# interpreting short utterances as English while retaining the fast base model. # short utterances and product names.
stt: stt:
enabled: true enabled: true
provider: "local" provider: "local"
+2 -8
View File
@@ -219,7 +219,7 @@ TTS_DEFAULT_FORMAT = "mp3"
STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084") STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084")
STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription
STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5")) STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5"))
STT_MODEL = "whisper-1" # OpenClaw/OpenAI compatibility alias; Qwen3-ASR serves it STT_MODEL = "qwen3-asr"
# Maximale Upload-Größe (Bytes) – verhindert unbegrenzten RAM-Verbrauch. # Maximale Upload-Größe (Bytes) – verhindert unbegrenzten RAM-Verbrauch.
# 50 MB ist für Audio-Dateien (WebM/Opus, WAV, MP3) mehr als ausreichend. # 50 MB ist für Audio-Dateien (WebM/Opus, WAV, MP3) mehr als ausreichend.
@@ -2848,12 +2848,6 @@ class Handler(BaseHTTPRequestHandler):
"owned_by": "qwen3-asr", "owned_by": "qwen3-asr",
"type": "transcription", "type": "transcription",
}) })
models.append({
"id": "qwen3-asr",
"object": "model",
"owned_by": "qwen3-asr",
"type": "transcription",
})
if tts.get("ready"): if tts.get("ready"):
models.append({ models.append({
"id": TTS_MODEL, "id": TTS_MODEL,
@@ -2974,7 +2968,7 @@ class Handler(BaseHTTPRequestHandler):
# Modell-Validierung # Modell-Validierung
model = fields.get("model", STT_MODEL) model = fields.get("model", STT_MODEL)
if model not in (STT_MODEL, "whisper", "qwen3-asr"): if model != STT_MODEL:
self._send_error(400, f"unbekanntes Modell: {model!r} " self._send_error(400, f"unbekanntes Modell: {model!r} "
f"(erwartet: {STT_MODEL})", f"(erwartet: {STT_MODEL})",
"invalid_request_error", "unknown_model") "invalid_request_error", "unknown_model")
+1 -1
View File
@@ -47,7 +47,7 @@ def main() -> int:
body = ( body = (
f"--{boundary}\r\n" f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n' 'Content-Disposition: form-data; name="model"\r\n\r\n'
"whisper-1\r\n" "qwen3-asr\r\n"
f"--{boundary}\r\n" f"--{boundary}\r\n"
'Content-Disposition: form-data; name="language"\r\n\r\n' 'Content-Disposition: form-data; name="language"\r\n\r\n'
"de\r\n" "de\r\n"
+1 -1
View File
@@ -281,7 +281,7 @@ class RealtimeSession:
try: try:
form = FormData() form = FormData()
form.add_field("file", make_wav(pcm), filename="talk.wav", content_type="audio/wav") form.add_field("file", make_wav(pcm), filename="talk.wav", content_type="audio/wav")
form.add_field("model", "whisper-1") form.add_field("model", "qwen3-asr")
form.add_field("language", os.environ.get("STT_LANGUAGE", "de")) form.add_field("language", os.environ.get("STT_LANGUAGE", "de"))
async with self.http.post( async with self.http.post(
os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/transcriptions", os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/transcriptions",