Skip to content

Commit 71d2347

Browse files
Fix playAudio for Response streams (#1952)
* fix playAudio response streams * preserve Node response streams * fix(audio): handle playback stream errors --------- Co-authored-by: Hayden <hayden@openai.com>
1 parent 6371623 commit 71d2347

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

src/helpers/audio.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from 'node:child_process';
2-
import { Readable } from 'node:stream';
2+
import { pipeline, Readable } from 'node:stream';
3+
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
34
import { platform, versions } from 'node:process';
45
import { checkFileSupport } from '../internal/uploads';
56

@@ -36,14 +37,27 @@ async function nodejsPlayAudio(stream: NodeJS.ReadableStream | Response | File):
3637
try {
3738
const ffplay = spawn('ffplay', ['-autoexit', '-nodisp', '-i', 'pipe:0']);
3839

40+
let source: NodeJS.ReadableStream;
3941
if (isResponse(stream)) {
40-
(stream.body! as any).pipe(ffplay.stdin);
42+
const body = stream.body! as NodeReadableStream | NodeJS.ReadableStream;
43+
if ('pipe' in body && typeof body.pipe === 'function') {
44+
source = body;
45+
} else {
46+
source = Readable.fromWeb(body as NodeReadableStream);
47+
}
4148
} else if (isFile(stream)) {
42-
Readable.from(stream.stream()).pipe(ffplay.stdin);
49+
source = Readable.from(stream.stream());
4350
} else {
44-
stream.pipe(ffplay.stdin);
51+
source = stream;
4552
}
4653

54+
pipeline(source, ffplay.stdin, (error) => {
55+
if (error) {
56+
ffplay.kill();
57+
reject(error);
58+
}
59+
});
60+
4761
ffplay.on('close', (code: number) => {
4862
if (code !== 0) {
4963
reject(new Error(`ffplay process exited with code ${code}`));

tests/helpers/audio.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
2+
3+
import { spawn } from 'node:child_process';
4+
import { Readable, Writable } from 'node:stream';
5+
import { playAudio } from 'openai/helpers/audio';
6+
7+
const spawnMock = spawn as jest.MockedFunction<typeof spawn>;
8+
9+
function mockFfplay() {
10+
const chunks: Buffer[] = [];
11+
const stdin = new Writable({
12+
write(chunk, _encoding, callback) {
13+
chunks.push(Buffer.from(chunk));
14+
callback();
15+
},
16+
});
17+
const ffplay = {
18+
stdin,
19+
kill: jest.fn(),
20+
on: jest.fn(),
21+
};
22+
23+
ffplay.on.mockImplementation((event: string, listener: (code: number) => void) => {
24+
if (event === 'close') {
25+
if (stdin.writableEnded) {
26+
queueMicrotask(() => listener(0));
27+
} else {
28+
stdin.on('finish', () => listener(0));
29+
}
30+
}
31+
return ffplay;
32+
});
33+
spawnMock.mockReturnValue(ffplay as any);
34+
35+
return { chunks, ffplay };
36+
}
37+
38+
describe('playAudio', () => {
39+
afterEach(() => {
40+
jest.resetAllMocks();
41+
});
42+
43+
it('pipes a Response Web ReadableStream body to ffplay', async () => {
44+
const { chunks } = mockFfplay();
45+
46+
await playAudio(new Response('hello'));
47+
48+
expect(spawnMock).toHaveBeenCalledWith('ffplay', ['-autoexit', '-nodisp', '-i', 'pipe:0']);
49+
expect(Buffer.concat(chunks).toString()).toBe('hello');
50+
});
51+
52+
it('keeps Node Readable response bodies on the Node stream path', async () => {
53+
const { chunks } = mockFfplay();
54+
const response = { body: Readable.from(['hello']) };
55+
56+
await playAudio(response as any);
57+
58+
expect(Buffer.concat(chunks).toString()).toBe('hello');
59+
});
60+
61+
it('rejects and stops ffplay when a Response Web ReadableStream errors', async () => {
62+
const { ffplay } = mockFfplay();
63+
const response = new Response(
64+
new ReadableStream({
65+
start(controller) {
66+
controller.error(new Error('stream failed'));
67+
},
68+
}),
69+
);
70+
71+
await expect(playAudio(response)).rejects.toThrow('stream failed');
72+
73+
expect(ffplay.kill).toHaveBeenCalled();
74+
});
75+
});

0 commit comments

Comments
 (0)