mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
feat(aquamarine): serve Whisper transcription from the NPU (#472)
The goal is speech recognition on the NPU for vinput, and sherpa-onnx cannot
deliver it: every model it loads keeps a dynamic time axis, which the NPU
compiler rejects outright. Qwen3-ASR and SenseVoice both core-dumped in
vpux-compiler ("Got non broadcastable dimensions pair"), and reshaping the ONNX
inputs does not help because the dynamic dimensions sit inside the graph
(WA-037). Whisper is the only family OpenVINO publishes fully static IRs for,
and its fixed 30 s window is exactly what the compiler accepts.
The host now runs an OpenAI-compatible /v1/audio/transcriptions service backed
by whisper-large-v3-turbo int8 on the NPU: 1.5 s for 11 s of audio against
2.1 s on CPU, with the compiled graph cached in /var/cache so only a cold cache
pays the ~60 s NPU compile. vinput on the desktop sends raw PCM to it through a
small provider script.
Only the NPU driver and compiler still come from the unmerged branch in the
`nixpkgs-npu` input (NixOS/nixpkgs#570230), whose nixpkgs also happens to be
the one where openvino-genai 2026.4.0.0 builds at all.
Assisted-by: DeepSeek Harness 0.2.1-rc.2 (deepseek-v4.1-flash)
This commit is contained in:
1 parent
d5e35e2641
commit
9f092808fd
10 files changed
+451
-13
No files matched your search
@@ -0,0 +1,78 @@
|
||||
"""vinput ASR provider: send recorded PCM to the aquamarine Whisper service.
|
||||
|
||||
vinput pipes raw PCM (S16_LE, mono, 16 kHz) to stdin and reads the transcript
|
||||
from stdout. The service speaks the OpenAI transcription API, so the PCM is
|
||||
wrapped in a WAV container and POSTed as multipart.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import wave
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
URL = os.environ["VINPUT_ASR_URL"]
|
||||
TIMEOUT = float(os.environ.get("VINPUT_ASR_TIMEOUT", "60"))
|
||||
SAMPLE_RATE = 16000
|
||||
FILE_FIELD = (
|
||||
'Content-Disposition: form-data; name="file"; filename="audio.wav"'
|
||||
)
|
||||
|
||||
|
||||
def as_wav(pcm: bytes) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(SAMPLE_RATE)
|
||||
wav.writeframes(pcm)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def multipart(wav: bytes) -> tuple[bytes, str]:
|
||||
boundary = f"----vinput{uuid.uuid4().hex}"
|
||||
body = bytearray()
|
||||
body += f"--{boundary}\r\n".encode()
|
||||
body += FILE_FIELD.encode() + b"\r\n"
|
||||
body += b"Content-Type: audio/wav\r\n\r\n"
|
||||
body += wav
|
||||
body += b"\r\n"
|
||||
body += f"--{boundary}\r\n".encode()
|
||||
body += b'Content-Disposition: form-data; name="model"\r\n\r\n'
|
||||
body += b"whisper-large-v3-turbo\r\n"
|
||||
language = os.environ.get("VINPUT_ASR_LANGUAGE", "")
|
||||
if language:
|
||||
body += f"--{boundary}\r\n".encode()
|
||||
body += b'Content-Disposition: form-data; name="language"\r\n\r\n'
|
||||
body += f"{language}\r\n".encode()
|
||||
body += f"--{boundary}--\r\n".encode()
|
||||
return bytes(body), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
pcm = sys.stdin.buffer.read()
|
||||
if not pcm:
|
||||
print("no audio on stdin", file=sys.stderr)
|
||||
return 1
|
||||
payload, content_type = multipart(as_wav(pcm))
|
||||
request = Request(
|
||||
URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": content_type},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=TIMEOUT) as response:
|
||||
transcript = json.load(response)
|
||||
except (URLError, OSError, ValueError) as exc:
|
||||
print(f"transcription request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(transcript.get("text", "").strip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,7 +13,7 @@
|
||||
"duck_output_volume": 0.25
|
||||
},
|
||||
"asr": {
|
||||
"active_provider": "sherpa-onnx",
|
||||
"active_provider": "aqua-whisper",
|
||||
"normalize_audio": true,
|
||||
"input_gain": 1.0,
|
||||
"vad": {
|
||||
@@ -29,6 +29,15 @@
|
||||
"type": "local",
|
||||
"model": "model.sherpa-onnx.qwen3-asr-0.6b-int8",
|
||||
"timeout_ms": 15000
|
||||
},
|
||||
{
|
||||
"id": "aqua-whisper",
|
||||
"type": "command",
|
||||
"command": "/home/ryan/.config/vinput/aqua-asr-provider",
|
||||
"env": {
|
||||
"VINPUT_ASR_URL": "http://192.168.5.101:18080/v1/audio/transcriptions"
|
||||
},
|
||||
"timeout_ms": 60000
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,6 +38,12 @@ in
|
||||
force = true;
|
||||
};
|
||||
|
||||
# The OpenAI-transcription client vinput runs as a provider. config.json points at this
|
||||
# absolute path, so the two must stay in sync.
|
||||
xdg.configFile."vinput/aqua-asr-provider".source =
|
||||
pkgs.writers.writePython3 "vinput-aqua-asr-provider" { }
|
||||
./aqua-provider.py;
|
||||
|
||||
systemd.user.services.vinput-daemon = {
|
||||
Unit = {
|
||||
Description = "Fcitx5 Vinput voice input daemon";
|
||||
|
||||
Reference in new issue
Block a user