Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion dashboard/src/api/modules/voice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request, requestBlob, requestUpload } from "../request";
import { request, requestBlob, requestStream, requestUpload } from "../request";

export interface VoicePreset {
id: string;
Expand Down Expand Up @@ -66,6 +66,23 @@ export const voiceApi = {
...(provider ? { provider } : {}),
}),
}),
/**
* Streamed variant for low-latency playback. The server flushes chunks as
* they arrive (MiMo streams live WAV); other providers stream MP3, in
* which case the buffered `synthesize` path is used instead.
*/
synthesizeStream: (
text: string,
provider?: string,
): Promise<{ contentType: string; body: ReadableStream<Uint8Array> }> =>
requestStream("/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
...(provider ? { provider } : {}),
}),
}),
createProvider: (body: {
name: string;
kind: string;
Expand Down
41 changes: 41 additions & 0 deletions dashboard/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,47 @@ export async function probeAuthResource(
}
}

/**
* POST JSON and hand back the response body as a byte stream (chunked TTS).
* Mirrors requestBlob()'s auth/setup/401 handling but never buffers.
*/
export async function requestStream(
path: string,
options: RequestInit = {},
): Promise<{ contentType: string; body: ReadableStream<Uint8Array> }> {
const url = getApiUrl(path);
const headers = buildAuthHeaders(path);
const response = await fetch(url, {
...options,
headers: { ...headers, ...(options.headers as Record<string, string>) },
});

if (await check503ForSetupRequired(path, response)) {
throw new Error("Setup required — redirecting to /setup");
}

await throwIfUnauthorized(path, response);
applyRenewedAccessToken(response);

if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Request failed: ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`,
);
}

if (!response.body) {
throw new Error("Empty stream from server");
}

return {
contentType: response.headers.get("content-type") || "",
body: response.body,
};
}

export type UploadProgressHandler = (percent: number) => void;

/**
Expand Down
79 changes: 78 additions & 1 deletion dashboard/src/hooks/useVoiceOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
import { prepareSpeechText } from "../utils/plainTextForSpeech";
import { speakBrowserText, stopBrowserSpeech } from "../utils/browserSpeech";
import { isMobileUserAgent } from "../utils/mobileDevice";
import { WavStreamPlayer } from "../utils/wavStreamPlayer";

import { message as antMessage } from "@/utils/antdMessage";

export function useVoiceOutput() {
const { t } = useTranslation();
const [speakingId, setSpeakingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const streamPlayerRef = useRef<WavStreamPlayer | null>(null);
const speakingIdRef = useRef<string | null>(null);
const playGenerationRef = useRef(0);

Expand All @@ -28,6 +30,8 @@ export function useVoiceOutput() {
const abortPlayback = useCallback(() => {
playGenerationRef.current += 1;
stopBrowserSpeech();
streamPlayerRef.current?.stop();
streamPlayerRef.current = null;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
Expand All @@ -46,8 +50,81 @@ export function useVoiceOutput() {
primeAudioElement(audio);
}, []);

/**
* Stream the MiMo WAV response and schedule chunks as they arrive.
* Returns false when streaming is unsupported or the response is not a
* WAV — the caller then falls back to the buffered blob path.
*/
const speakMimoStream = useCallback(
async (plain: string, gen: number) => {
const player = new WavStreamPlayer();
streamPlayerRef.current = player;
try {
if (!player.ensureContext()) return false;
const { contentType, body } = await voiceApi.synthesizeStream(plain);
if (
!contentType.includes("audio/wav") &&
!contentType.includes("audio/wave")
) {
try {
await body.cancel();
} catch {
/* ignore */
}
return false;
}
const reader = body.getReader();
// Read until the WAV header plus first audio chunk are scheduled —
// malformed streams can still fall back to the blob path here.
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
if (!player.push(value)) {
player.stop();
return false;
}
if (player.hasAudio) break;
}
// Feed remaining chunks in the background until the stream ends.
void (async () => {
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) player.push(value);
}
} catch {
/* network hiccup — keep whatever was scheduled */
} finally {
const wait = Math.max(player.msRemaining(), 0);
window.setTimeout(() => {
player.stop();
if (playGenerationRef.current === gen) finishSpeaking();
}, wait);
}
})();
if (playGenerationRef.current !== gen) {
player.stop();
}
return true;
} catch {
return false;
}
},
[finishSpeaking],
);

const speakWithServer = useCallback(
async (plain: string, gen: number, provider?: string) => {
// MiMo streams a live WAV — play it chunk-by-chunk for low latency.
// Other providers stream MP3, which needs the buffered blob path.
const active = cachedActiveVoice();
const ttsProvider = provider ?? active?.tts;
if (ttsProvider === "mimo-tts" || ttsProvider === "mimo") {
if (await speakMimoStream(plain, gen)) return;
}

try {
const blob = await voiceApi.synthesize(plain, provider);
if (playGenerationRef.current !== gen) return;
Expand Down Expand Up @@ -86,7 +163,7 @@ export function useVoiceOutput() {
finishSpeaking();
}
},
[finishSpeaking, t],
[finishSpeaking, speakMimoStream, t],
);

const speakWithBrowser = useCallback(
Expand Down
14 changes: 11 additions & 3 deletions dashboard/src/pages/Settings/Voice/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function VoiceSettingsPanel() {
const [mimoEndpoint, setMimoEndpoint] = useState<"payg" | "tokenplan">(
"payg",
);
const [mimoVoiceId, setMimoVoiceId] = useState("mimo_default");
const [mimoVoiceId, setMimoVoiceId] = useState("冰糖");
const [saving, setSaving] = useState(false);

const fetchAll = useCallback(async () => {
Expand Down Expand Up @@ -100,7 +100,7 @@ export function VoiceSettingsPanel() {
setSecretId(String(extra.secret_id ?? ""));
setSecretKey(String(extra.secret_key ?? ""));
setMimoEndpoint(extra.endpoint_type === "tokenplan" ? "tokenplan" : "payg");
setMimoVoiceId(String(extra.voice_id ?? "mimo_default"));
setMimoVoiceId(String(extra.voice_id ?? "冰糖"));
};

const handleSaveProvider = async () => {
Expand Down Expand Up @@ -158,6 +158,15 @@ export function VoiceSettingsPanel() {
setActive(next);
invalidateVoiceConfigCache();
}
if (
preset.kind !== "browser" &&
(preset.capability === "tts" || preset.capability === "both") &&
active.tts === "browser"
) {
const next = await voiceApi.setActive({ tts: preset.id });
setActive(next);
invalidateVoiceConfigCache();
}
message.success(t("voice.saved"));
setConfigure(null);
await fetchAll();
Expand Down Expand Up @@ -407,7 +416,6 @@ export function VoiceSettingsPanel() {
value={mimoVoiceId}
onChange={(v) => setMimoVoiceId(v)}
options={[
{ value: "mimo_default", label: "MiMo Default" },
{ value: "冰糖", label: "冰糖 (中文·女)" },
{ value: "茉莉", label: "茉莉 (中文·女)" },
{ value: "苏打", label: "苏打 (中文·男)" },
Expand Down
90 changes: 90 additions & 0 deletions dashboard/src/utils/wavStreamPlayer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";

import { parseWavHeader, pcm16ToFloat32 } from "./wavStreamPlayer";

function wavHeaderBytes(
sampleRate = 24000,
channels = 1,
dataLen = 8,
): Uint8Array {
const bytes = new Uint8Array(44);
const dv = new DataView(bytes.buffer);
const ascii = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) bytes[offset + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataLen, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, channels, true);
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * channels * 2, true);
dv.setUint16(32, channels * 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataLen, true);
return bytes;
}

describe("parseWavHeader", () => {
it("parses a canonical 44-byte PCM header", () => {
const fmt = parseWavHeader(wavHeaderBytes());
expect(fmt).not.toBeNull();
expect(fmt?.sampleRate).toBe(24000);
expect(fmt?.channels).toBe(1);
expect(fmt?.bitsPerSample).toBe(16);
expect(fmt?.headerLength).toBe(44);
});

it("rejects short buffers", () => {
expect(parseWavHeader(new Uint8Array(10))).toBeNull();
});

it("rejects non-WAV payloads", () => {
const mp3 = new TextEncoder().encode(
"ID3SOMEDATASOMEDATASOMEDATASOMEDATASOMED",
);
expect(parseWavHeader(mp3)).toBeNull();
});
});

describe("pcm16ToFloat32", () => {
it("decodes little-endian int16 to [-1, 1] floats", () => {
const bytes = new Uint8Array([0x00, 0x00, 0x00, 0x80, 0x00, 0x7f]);
const out = pcm16ToFloat32(bytes);
expect(out).toHaveLength(3);
expect(out[0]).toBe(0);
expect(out[1]).toBeCloseTo(-1);
expect(out[2]).toBeCloseTo(32512 / 32768);
});

it("drops a trailing odd byte", () => {
expect(pcm16ToFloat32(new Uint8Array(5))).toHaveLength(2);
});
});

describe("WavStreamPlayer frame buffering (node env without AudioContext)", () => {
it("pushing a non-WAV first chunk reports unsupported", async () => {
const { WavStreamPlayer } = await import("./wavStreamPlayer");
const player = new WavStreamPlayer();
const mp3 = new TextEncoder().encode("ID3...");
expect(player.push(mp3)).toBe(false);
});

it("validates header and buffers partial frames via push/carry", async () => {
const { WavStreamPlayer } = await import("./wavStreamPlayer");
const player = new WavStreamPlayer();
// No AudioContext in node — push() should still parse the header and
// buffer partial frames without throwing.
const header = wavHeaderBytes();
const odd = new Uint8Array(3);
expect(player.push(header)).toBe(true);
expect(player.push(odd)).toBe(true);
expect(player.hasAudio).toBe(false);
expect(player.push(new Uint8Array(1))).toBe(true);
// hasAudio stays false in node (no context to schedule on).
player.stop();
});
});
Loading
Loading