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
2 changes: 1 addition & 1 deletion open-sse/executors/gtts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ async function synthesizeGttsChunk(
export async function synthesizeGtts(
input: GttsSynthInput,
fetchImpl: FetchLike = fetch
): Promise<Buffer> {
): Promise<Buffer<ArrayBuffer>> {
const lang = normalizeGttsLang(input.lang);
const tld = (typeof input.tld === "string" && input.tld.trim()) || DEFAULT_TLD;
const chunks = chunkGttsText(input.text);
Expand Down
9 changes: 7 additions & 2 deletions open-sse/executors/vertexMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@ async function vertexError(res: Response): Promise<VertexHttpError> {
}

/** Wrap raw little-endian 16-bit PCM mono samples in a minimal WAV container. */
export function pcmToWav(pcm: Buffer, sampleRate = 24000, channels = 1, bitsPerSample = 16): Buffer {
export function pcmToWav(
pcm: Buffer,
sampleRate = 24000,
channels = 1,
bitsPerSample = 16
): Buffer<ArrayBuffer> {
const blockAlign = (channels * bitsPerSample) / 8;
const byteRate = sampleRate * blockAlign;
const header = Buffer.alloc(44);
Expand Down Expand Up @@ -190,7 +195,7 @@ function extractText(data: unknown): string {
export async function vertexGenerateSpeech(
credentials: VertexMediaCredentials,
options: { model: string; input: string; voice?: string }
): Promise<{ audio: Buffer; contentType: string }> {
): Promise<{ audio: Buffer<ArrayBuffer>; contentType: string }> {
const auth = await resolveVertexAuth(credentials);
const { url, headers } = buildModelRequest(auth, options.model, "generateContent");
const payload = {
Expand Down
4 changes: 2 additions & 2 deletions open-sse/handlers/audioSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, cred
* `base_resp.status_code` (0 = success).
* Port of decolua/9router#1043 by toanalien <toanalien@gmail.com>.
*/
function hexToBytes(audioHex) {
function hexToBytes(audioHex): Uint8Array<ArrayBuffer> {
const clean = typeof audioHex === "string" ? audioHex.trim() : "";
if (!clean) throw new Error("MiniMax TTS returned no audio");
if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) {
Expand Down Expand Up @@ -684,7 +684,7 @@ async function handleMinimaxSpeech(providerConfig, body, modelId, token) {
}

const audioField = (data.data as Record<string, unknown> | undefined)?.audio;
let bytes: Uint8Array;
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = hexToBytes(audioField);
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/browserPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ export function __resetBrowserPoolMetricsForTest(): void {

export async function readPageResponseBody(
response: import("playwright").Response
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
): Promise<{ status: number; headers: Record<string, string>; body: Buffer<ArrayBuffer> }> {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(response.headers())) {
headers[name] = value;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/network/remoteImageFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface RemoteImageFetchOptions {
}

export interface RemoteImageFetchResult {
buffer: Buffer;
buffer: Buffer<ArrayBuffer>;
contentType: string;
url: string;
}
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/media-body-arraybuffer-backing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";

import { pcmToWav } from "../../open-sse/executors/vertexMedia.ts";
import { synthesizeGtts } from "../../open-sse/executors/gtts.ts";
import { readPageResponseBody } from "../../open-sse/services/browserPool.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";

/**
* Each producer below is handed straight to a Web API that only accepts an
* `ArrayBuffer`-backed view — `new Response(body)` (BodyInit) or
* `new Blob([part])` (BlobPart). A `Buffer` / `Uint8Array` written without its
* type argument widens to `ArrayBufferLike`, which also admits
* `SharedArrayBuffer` and is therefore rejected at that boundary.
*
* The existing tests for these functions assert their *contents* — the RIFF
* header, the concatenated chunks, the decoded base64. None assert the backing,
* which is the single property the narrowed return types exist to guarantee and
* the one a plausible refactor to a shared or pooled allocation would break.
*
* So each case asserts both: the backing itself, and that the value really is
* accepted by the Web API it is passed to in production.
*/

test("pcmToWav returns an ArrayBuffer-backed WAV that new Response accepts", () => {
const wav = pcmToWav(Buffer.from([0x01, 0x02, 0x03, 0x04]), 24000);

assert.ok(wav.buffer instanceof ArrayBuffer, "WAV buffer must not be SharedArrayBuffer-backed");
// open-sse/handlers/audioSpeech.ts hands this to `new Response(audio, …)`.
assert.doesNotThrow(() => new Response(wav));
});

test("synthesizeGtts returns an ArrayBuffer-backed buffer that new Response accepts", async () => {
// Same batchexecute envelope shape gtts-provider.test.ts builds; "aGk=" is
// base64 for "hi".
const inner = JSON.stringify(["aGk=", null, null, null, null, null, []]);
const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]);
const fetchImpl = async () =>
new Response(`)]}'\n\n${outer.length}\n${outer}\n`, { status: 200 });

const audio = await synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl);

assert.ok(audio.buffer instanceof ArrayBuffer, "gTTS audio must not be SharedArrayBuffer-backed");
assert.doesNotThrow(() => new Response(audio));
});

test("fetchRemoteImage returns an ArrayBuffer-backed buffer that new Blob accepts", async () => {
const result = await fetchRemoteImage("https://cdn.example.com/image.png", {
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" },
}),
guard: "public-only",
lookup: async () => [{ address: "203.0.113.5", family: 4 }],
});

assert.ok(result.buffer.buffer instanceof ArrayBuffer, "image bytes must be ArrayBuffer-backed");
// open-sse/handlers/imageGeneration.ts wraps this in `new Blob([…])` for the
// Topaz multipart upload.
assert.doesNotThrow(() => new Blob([result.buffer], { type: "image/png" }));
});

test("readPageResponseBody returns an ArrayBuffer-backed body", async () => {
const fakeResponse = {
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
body: async () => Buffer.from('{"ok":true}'),
};

const captured = await readPageResponseBody(
fakeResponse as unknown as Parameters<typeof readPageResponseBody>[0]
);

assert.equal(captured.status, 200);
assert.equal(captured.body.toString(), '{"ok":true}');
// browserBackedChat.ts assigns this into a `Buffer.alloc(0)`-seeded local
// that it later returns as the response body.
assert.ok(captured.body.buffer instanceof ArrayBuffer);
});
Loading