Use qwen3-asr throughout speech integrations
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user