mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-17 12:44:51 +00:00
* feat(serve): voice dictation over the daemon for the Web Shell Bring voice input to the `qwen serve` Web Shell. The browser captures the microphone, streams raw 16 kHz mono PCM to a new `/voice/stream` WebSocket, and the daemon transcribes server-side by reusing the existing CLI voice pipeline (realtime streaming + on-stop batch) — provider credentials never reach the browser, and it works whether the daemon is local or remote. Daemon: - `/voice/stream` WebSocket handler (serve/voice) reusing openVoiceStream / openQwenAsrRealtimeStream / transcribeVoiceAudio; resolves the workspace voiceModel from a ModelsConfig built off settings. - Routed through the existing ACP upgrade listener so it shares the loopback / host-allowlist / CSRF / bearer checks; concurrency-capped. - Advertises the `voice_transcribe` capability (unconditional, like auth_device_flow; the WS errors when no voice model is configured). - Relax `Permissions-Policy` to `microphone=(self)` so the same-origin shell can request the mic — an empty `microphone=()` allowlist blocked the prompt entirely. - Allowlist `voiceModel` on `/workspace/settings` so the picker can read it. Web Shell: - Mic button in the composer: click to record, click to stop -> the transcript is inserted into the input for review before sending. - `/model --voice` picker (sourced from `/workspace/providers`, since voice models are hidden from the session model list) and `/model --voice <id>`, persisted via the prompt channel like `/model --fast`. The voiceModel resolver now accepts a structural model lookup so the daemon can resolve it without constructing a full CLI Config. Co-authored-by: Qwen-Coder <noreply@qwen.ai> * fix(serve): harden web voice streaming * fix(serve): clean stale voice capture resources * fix(serve): harden voice websocket errors * fix(serve): hide voice capability when token auth is configured * fix(serve): address voice review blockers * fix(web-shell): keep voice cancel available when disabled * fix(web-shell): inline the voice mic in the composer toolbar The mic was an absolute-positioned overlay anchored to the composer wrapper, so after the #5775 chat-UI restructure it floated outside the input box (clipped at the bottom-right). Render it inside ChatEditor's `toolbarRight`, just before the send button, wiring the transcript insert to the composer core — it sits inline with the `/`, `@`, and send controls like the rest of the toolbar. Restyle to a Codex-style recording experience: an idle mic that matches the other toolbar icon buttons, and a recording pill with a live waveform (driven by the RMS meter), an elapsed `M:SS` timer, and a stop control. Also forward `/voice/stream` through the vite dev proxy (`ws: true`, scoped to the exact path so it doesn't shadow the client's own `client/voice/*` source modules) so the WebSocket reaches the daemon in dev instead of silently failing against vite. Co-authored-by: Qwen-Coder <noreply@qwen.ai> * feat(serve): authenticate the voice WebSocket via a bearer subprotocol Voice was suppressed on any token-configured daemon because browsers cannot set an `Authorization` header on a WebSocket, so the bearer check on the shared ACP upgrade listener always failed for the browser mic. Let the browser carry the token in `Sec-WebSocket-Protocol` as `qwen-bearer.<base64url(token)>` (the only header a browser can set on a WS). The upgrade listener now accepts the token from either the `Authorization` header (non-browser clients, unchanged) or the subprotocol, hash-compared in constant time. `handleProtocols` selects a non-secret subprotocol (or none) so the token is never echoed back in the handshake response. This aligns the voice WS with how the ACP WS will authenticate browsers. With the WS now authenticated, drop the `!tokenConfigured` / `requireAuth` suppression of the `voice_transcribe` capability — it is advertised whenever the `/voice/stream` endpoint exists. Tests updated to assert voice is advertised under a token / `--require-auth`. Co-authored-by: Qwen-Coder <noreply@qwen.ai> * fix(serve): make the voice WS subprotocol handshake complete + test it Review follow-up to the bearer-subprotocol auth. Fixes a real handshake bug and closes the test gap on the security-critical path. - Handshake bug: when the browser offered only the `qwen-bearer.*` subprotocol, the daemon's `handleProtocols` selected none, and a strict WS client (the `ws` library) aborts with "Server sent no subprotocol". The web-shell now offers a non-secret marker (`qwen-ws`) alongside the bearer one, so the daemon always selects the marker — never echoing the secret — and the handshake completes for strict and lenient clients alike. - Tests: add daemon-side coverage for the subprotocol auth path (accept valid token, reject wrong/malformed, no-token loopback) and assert the secret is never echoed back; add client coverage that the bearer token is offered as `[qwen-ws, qwen-bearer.<b64url>]` and round-trips. - Drop the unreachable try/catch around `Buffer.from(_, 'base64url')` (it never throws) and correct the comment. - a11y: mark the transcribing/interim regions as `role="status"` / `aria-live`, and add a `:focus-visible` ring to the idle mic. - Refresh the stale `useVoiceCapture` doc comment (token deployments are now supported in the browser) and fix the mock typing in VoiceButton.test.tsx. Co-authored-by: Qwen-Coder <noreply@qwen.ai> * fix(web-shell): ignore stale voice socket callbacks * test(serve): sync voice capability integration expectation * fix(serve): harden voice websocket review gaps * fix(serve): sanitize voice websocket failures * fix(serve): address voice review suggestions * fix(serve): harden voice websocket lifecycle --------- Co-authored-by: Qwen-Coder <noreply@qwen.ai>
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
/**
|
|
* Browser-side voice capture for the Web Shell. Captures the microphone via
|
|
* `getUserMedia`, downsamples to 16 kHz mono s16le PCM in an AudioWorklet, and
|
|
* streams the raw frames to the daemon's `/voice/stream` WebSocket. The daemon
|
|
* transcribes server-side (credentials never reach the browser) and returns
|
|
* interim/final transcripts.
|
|
*
|
|
* Note: browsers cannot set an `Authorization` header on a WebSocket. When a
|
|
* bearer token is configured it rides in the `Sec-WebSocket-Protocol`
|
|
* subprotocol as `qwen-bearer.<base64url(token)>` (see `bearerSubprotocol`),
|
|
* which the daemon's ACP upgrade listener verifies — so this works against both
|
|
* no-token loopback and token-required deployments.
|
|
*/
|
|
export type VoiceCaptureStatus =
|
|
| 'idle'
|
|
| 'connecting'
|
|
| 'recording'
|
|
| 'transcribing'
|
|
| 'error';
|
|
|
|
export interface UseVoiceCaptureOptions {
|
|
baseUrl: string;
|
|
token?: string;
|
|
/** Called with the final transcript (may be empty). */
|
|
onFinal: (text: string) => void;
|
|
onError?: (message: string) => void;
|
|
}
|
|
|
|
export interface UseVoiceCaptureReturn {
|
|
status: VoiceCaptureStatus;
|
|
interimText: string;
|
|
/** Recent input level, 0..1, for a live meter. */
|
|
audioLevel: number;
|
|
errorMessage: string | undefined;
|
|
start: () => void;
|
|
stop: () => void;
|
|
abort: () => void;
|
|
}
|
|
|
|
const SAMPLE_RATE = 16_000;
|
|
const FRAME_SIZE = 4096;
|
|
const TRANSCRIPTION_TIMEOUT_MS = 60_000;
|
|
|
|
function toWebSocketUrl(baseUrl: string): string {
|
|
const base = new URL(baseUrl);
|
|
const basePath = base.pathname.replace(/\/?$/, '/');
|
|
const url = new URL('voice/stream', `${base.origin}${basePath}`);
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return url.toString();
|
|
}
|
|
|
|
/**
|
|
* Browsers can't set an `Authorization` header on a WebSocket, so the bearer
|
|
* token rides in `Sec-WebSocket-Protocol` as `qwen-bearer.<base64url(token)>`.
|
|
* The daemon's ACP upgrade listener decodes it (serve/acp-http/index.ts) — keep
|
|
* this prefix in sync with `WS_BEARER_SUBPROTOCOL_PREFIX` there.
|
|
*/
|
|
const WS_BEARER_SUBPROTOCOL_PREFIX = 'qwen-bearer.';
|
|
// Non-secret marker offered alongside the bearer subprotocol. The daemon
|
|
// completes the handshake by selecting THIS (never echoing the secret), which
|
|
// also satisfies WS clients that require the server to pick an offered
|
|
// subprotocol when any were requested. Must not start with the bearer prefix.
|
|
const WS_AUTH_SUBPROTOCOL = 'qwen-ws';
|
|
|
|
function bearerSubprotocol(token: string): string {
|
|
const bytes = new TextEncoder().encode(token);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
const b64 = btoa(binary)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '');
|
|
return `${WS_BEARER_SUBPROTOCOL_PREFIX}${b64}`;
|
|
}
|
|
|
|
/** Turn a getUserMedia rejection into an actionable, human message. */
|
|
function describeMicError(err: unknown): string {
|
|
const name = (err as { name?: string } | undefined)?.name;
|
|
switch (name) {
|
|
case 'NotAllowedError':
|
|
case 'SecurityError':
|
|
return 'Microphone blocked. Click the camera/lock icon in the address bar to allow the mic for this site, and enable your browser under System Settings → Privacy → Microphone, then retry.';
|
|
case 'NotFoundError':
|
|
case 'DevicesNotFoundError':
|
|
case 'OverconstrainedError':
|
|
return 'No microphone found. Connect one and retry.';
|
|
case 'NotReadableError':
|
|
case 'TrackStartError':
|
|
return 'Microphone is in use by another app. Close it and retry.';
|
|
default:
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
/** Float32 [-1,1] frame → Int16 PCM + RMS level. */
|
|
function floatToPcm16(input: Float32Array): {
|
|
pcm: ArrayBuffer;
|
|
level: number;
|
|
} {
|
|
const pcm = new Int16Array(input.length);
|
|
let sumSquares = 0;
|
|
for (let i = 0; i < input.length; i++) {
|
|
let s = input[i];
|
|
if (s > 1) s = 1;
|
|
else if (s < -1) s = -1;
|
|
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
sumSquares += s * s;
|
|
}
|
|
return {
|
|
pcm: pcm.buffer,
|
|
level: input.length ? Math.sqrt(sumSquares / input.length) : 0,
|
|
};
|
|
}
|
|
|
|
interface CaptureResources {
|
|
ws?: WebSocket;
|
|
stream?: MediaStream;
|
|
context?: AudioContext;
|
|
source?: MediaStreamAudioSourceNode;
|
|
// ScriptProcessorNode (not AudioWorklet): the Web Shell CSP `script-src`
|
|
// omits `blob:`, which blocks a Blob-URL worklet module. ScriptProcessor
|
|
// needs no module load, so it sidesteps CSP entirely.
|
|
processor?: ScriptProcessorNode;
|
|
sink?: GainNode;
|
|
transcribeTimeout?: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export function useVoiceCapture(
|
|
options: UseVoiceCaptureOptions,
|
|
): UseVoiceCaptureReturn {
|
|
const { baseUrl, token, onFinal, onError } = options;
|
|
|
|
const [status, setStatus] = useState<VoiceCaptureStatus>('idle');
|
|
const [interimText, setInterimText] = useState('');
|
|
const [audioLevel, setAudioLevel] = useState(0);
|
|
const [errorMessage, setErrorMessage] = useState<string | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const resourcesRef = useRef<CaptureResources>({});
|
|
const mountedRef = useRef(true);
|
|
const captureGenerationRef = useRef(0);
|
|
// Live status for async WS/worklet callbacks, which would otherwise read a
|
|
// stale closure copy of `status`.
|
|
const statusRef = useRef<VoiceCaptureStatus>('idle');
|
|
// Latest callbacks without re-binding the capture lifecycle.
|
|
const onFinalRef = useRef(onFinal);
|
|
onFinalRef.current = onFinal;
|
|
const onErrorRef = useRef(onError);
|
|
onErrorRef.current = onError;
|
|
|
|
const applyStatus = useCallback((next: VoiceCaptureStatus) => {
|
|
statusRef.current = next;
|
|
setStatus(next);
|
|
}, []);
|
|
|
|
const clearTranscribeTimeout = useCallback(() => {
|
|
const res = resourcesRef.current;
|
|
if (res.transcribeTimeout) {
|
|
clearTimeout(res.transcribeTimeout);
|
|
res.transcribeTimeout = undefined;
|
|
}
|
|
}, []);
|
|
|
|
const teardownAudio = useCallback(() => {
|
|
const res = resourcesRef.current;
|
|
if (res.processor) res.processor.onaudioprocess = null;
|
|
for (const node of [res.processor, res.source, res.sink]) {
|
|
try {
|
|
node?.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
res.stream?.getTracks().forEach((track) => track.stop());
|
|
if (res.context && res.context.state !== 'closed') {
|
|
void res.context.close().catch(() => {});
|
|
}
|
|
res.processor = undefined;
|
|
res.sink = undefined;
|
|
res.source = undefined;
|
|
res.stream = undefined;
|
|
res.context = undefined;
|
|
}, []);
|
|
|
|
const cleanup = useCallback(() => {
|
|
captureGenerationRef.current++;
|
|
teardownAudio();
|
|
const res = resourcesRef.current;
|
|
clearTranscribeTimeout();
|
|
if (res.ws) {
|
|
try {
|
|
res.ws.onmessage = null;
|
|
res.ws.onerror = null;
|
|
res.ws.onclose = null;
|
|
res.ws.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
res.ws = undefined;
|
|
}
|
|
}, [teardownAudio, clearTranscribeTimeout]);
|
|
|
|
const fail = useCallback(
|
|
(message: string, generation?: number) => {
|
|
if (
|
|
!mountedRef.current ||
|
|
(generation !== undefined &&
|
|
captureGenerationRef.current !== generation)
|
|
) {
|
|
return;
|
|
}
|
|
cleanup();
|
|
applyStatus('error');
|
|
setInterimText('');
|
|
setAudioLevel(0);
|
|
setErrorMessage(message);
|
|
onErrorRef.current?.(message);
|
|
},
|
|
[cleanup, applyStatus],
|
|
);
|
|
|
|
const finishWith = useCallback(
|
|
(text: string, generation?: number) => {
|
|
if (
|
|
generation !== undefined &&
|
|
captureGenerationRef.current !== generation
|
|
) {
|
|
return;
|
|
}
|
|
cleanup();
|
|
if (!mountedRef.current) return;
|
|
applyStatus('idle');
|
|
setInterimText('');
|
|
setAudioLevel(0);
|
|
onFinalRef.current(text);
|
|
},
|
|
[cleanup, applyStatus],
|
|
);
|
|
|
|
const start = useCallback(() => {
|
|
if (statusRef.current !== 'idle' && statusRef.current !== 'error') return;
|
|
setErrorMessage(undefined);
|
|
setInterimText('');
|
|
applyStatus('connecting');
|
|
const generation = ++captureGenerationRef.current;
|
|
const isStale = () =>
|
|
!mountedRef.current || captureGenerationRef.current !== generation;
|
|
|
|
void (async () => {
|
|
try {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
throw new Error(
|
|
window.isSecureContext
|
|
? 'Microphone capture is not supported in this browser.'
|
|
: 'Microphone needs a secure context — open the Web Shell via localhost/127.0.0.1 or https.',
|
|
);
|
|
}
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
channelCount: 1,
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new Error(describeMicError(err));
|
|
}
|
|
if (isStale()) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
resourcesRef.current.stream = stream;
|
|
|
|
const context = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
if (context.sampleRate !== SAMPLE_RATE) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
void context.close().catch(() => {});
|
|
throw new Error(
|
|
`Browser audio rate ${context.sampleRate} Hz is not the required ${SAMPLE_RATE} Hz.`,
|
|
);
|
|
}
|
|
resourcesRef.current.context = context;
|
|
// Resume in case the browser created it suspended (pre-gesture).
|
|
if (context.state === 'suspended') await context.resume();
|
|
if (isStale()) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
void context.close().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
const source = context.createMediaStreamSource(stream);
|
|
const processor = context.createScriptProcessor(FRAME_SIZE, 1, 1);
|
|
// Silent sink: a ScriptProcessorNode only fires `onaudioprocess` while
|
|
// connected to the destination; gain 0 avoids routing mic to speakers.
|
|
const sink = context.createGain();
|
|
sink.gain.value = 0;
|
|
resourcesRef.current.source = source;
|
|
resourcesRef.current.processor = processor;
|
|
resourcesRef.current.sink = sink;
|
|
|
|
const ws = new WebSocket(
|
|
toWebSocketUrl(baseUrl),
|
|
token ? [WS_AUTH_SUBPROTOCOL, bearerSubprotocol(token)] : undefined,
|
|
);
|
|
ws.binaryType = 'arraybuffer';
|
|
resourcesRef.current.ws = ws;
|
|
|
|
processor.onaudioprocess = (event: AudioProcessingEvent) => {
|
|
const { pcm, level } = floatToPcm16(
|
|
event.inputBuffer.getChannelData(0),
|
|
);
|
|
if (mountedRef.current) setAudioLevel(level);
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(pcm);
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
if (isStale()) {
|
|
processor.onaudioprocess = null;
|
|
for (const node of [processor, source, sink]) {
|
|
try {
|
|
node.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
if (context.state !== 'closed') {
|
|
void context.close().catch(() => {});
|
|
}
|
|
const res = resourcesRef.current;
|
|
if (res.ws === ws) res.ws = undefined;
|
|
if (res.processor === processor) res.processor = undefined;
|
|
if (res.source === source) res.source = undefined;
|
|
if (res.sink === sink) res.sink = undefined;
|
|
if (res.stream === stream) res.stream = undefined;
|
|
if (res.context === context) res.context = undefined;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return;
|
|
}
|
|
ws.send(JSON.stringify({ type: 'start' }));
|
|
source.connect(processor);
|
|
processor.connect(sink);
|
|
sink.connect(context.destination);
|
|
clearTranscribeTimeout();
|
|
resourcesRef.current.transcribeTimeout = setTimeout(() => {
|
|
if (statusRef.current === 'recording') {
|
|
fail(
|
|
'No response from server. Check that the voice model is running.',
|
|
generation,
|
|
);
|
|
}
|
|
}, TRANSCRIPTION_TIMEOUT_MS);
|
|
if (mountedRef.current) applyStatus('recording');
|
|
};
|
|
|
|
ws.onmessage = (event: MessageEvent) => {
|
|
if (
|
|
statusRef.current === 'connecting' ||
|
|
statusRef.current === 'recording'
|
|
) {
|
|
clearTranscribeTimeout();
|
|
}
|
|
let msg: { type?: string; text?: string; message?: string };
|
|
try {
|
|
msg = JSON.parse(String(event.data));
|
|
} catch {
|
|
return;
|
|
}
|
|
if (msg.type === 'interim') {
|
|
if (mountedRef.current) setInterimText(msg.text ?? '');
|
|
} else if (msg.type === 'final') {
|
|
finishWith(msg.text ?? '', generation);
|
|
} else if (msg.type === 'error') {
|
|
fail(
|
|
msg.message ?? msg.text ?? 'Voice transcription failed.',
|
|
generation,
|
|
);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// The following close event carries the useful code/reason.
|
|
};
|
|
ws.onclose = (event) => {
|
|
// A close before a final result (and not during normal teardown)
|
|
// surfaces as an error so the user isn't left stuck.
|
|
if (
|
|
mountedRef.current &&
|
|
(statusRef.current === 'recording' ||
|
|
statusRef.current === 'connecting' ||
|
|
statusRef.current === 'transcribing')
|
|
) {
|
|
const code = event.code || 1006;
|
|
const reason = event.reason || 'none';
|
|
fail(
|
|
`Voice connection closed (code=${code}, reason=${reason}).`,
|
|
generation,
|
|
);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
fail(
|
|
error instanceof Error ? error.message : String(error),
|
|
generation,
|
|
);
|
|
}
|
|
})();
|
|
}, [baseUrl, token, fail, finishWith, applyStatus, clearTranscribeTimeout]);
|
|
|
|
const stop = useCallback(() => {
|
|
const ws = resourcesRef.current.ws;
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
cleanup();
|
|
applyStatus('idle');
|
|
return;
|
|
}
|
|
// Stop feeding audio, then ask the daemon to finalize. The 'final' frame
|
|
// resolves the transcript; teardownAudio releases the mic immediately.
|
|
teardownAudio();
|
|
setAudioLevel(0);
|
|
applyStatus('transcribing');
|
|
const generation = captureGenerationRef.current;
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'stop' }));
|
|
clearTranscribeTimeout();
|
|
resourcesRef.current.transcribeTimeout = setTimeout(() => {
|
|
if (statusRef.current === 'transcribing') {
|
|
fail('Transcription timed out.', generation);
|
|
}
|
|
}, TRANSCRIPTION_TIMEOUT_MS);
|
|
} catch {
|
|
fail('Failed to finalize voice transcription.', generation);
|
|
}
|
|
}, [cleanup, teardownAudio, fail, applyStatus, clearTranscribeTimeout]);
|
|
|
|
const abort = useCallback(() => {
|
|
const ws = resourcesRef.current.ws;
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'abort' }));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
cleanup();
|
|
applyStatus('idle');
|
|
setInterimText('');
|
|
setAudioLevel(0);
|
|
}, [cleanup, applyStatus]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
cleanup();
|
|
};
|
|
}, [cleanup]);
|
|
|
|
return {
|
|
status,
|
|
interimText,
|
|
audioLevel,
|
|
errorMessage,
|
|
start,
|
|
stop,
|
|
abort,
|
|
};
|
|
}
|