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:
ryan4yin | 二花 authored and GitHub committed 2026-10-18 08:34:50 +08:00
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())
+10 -1
View File
@@ -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
}
]
},
+6
View File
@@ -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";