Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 8 additions & 7 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { musicEndpoint } from '../../client/endpoints';
import { formatOutput, detectOutputFormat } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { pipeAudioSseToStdout } from '../../utils/audio-stream';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { MusicRequest, MusicResponse } from '../../types/api';
Expand Down Expand Up @@ -149,14 +150,14 @@ export default defineCommand({

if (flags.stream) {
const res = await request(config, { url, method: 'POST', body, stream: true });
const reader = res.body?.getReader();
if (!reader) throw new CLIError('No response body', ExitCode.GENERAL);
while (true) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(value);
try {
await pipeAudioSseToStdout(res.body);
} catch (err) {
Comment on lines 151 to +155
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --stream path now decodes SSE->JSON->hex->bytes, but there’s no automated test coverage for this behavior. Consider adding a test that serves an SSE response via the existing mock server helper and verifies the generated bytes written to stdout for music generate --stream (including chunk-boundary buffering).

Copilot uses AI. Check for mistakes.
if (err instanceof Error && err.message === 'No response body') {
throw new CLIError('No response body', ExitCode.GENERAL);
}
throw err;
}
Comment on lines +153 to 160
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error handling relies on matching err.message === 'No response body' from the helper. Using error-message strings for control flow is brittle; consider exporting a dedicated error class/type (e.g., NoResponseBodyError) from audio-stream.ts, or have pipeAudioSseToStdout throw CLIError directly so callers don’t need to pattern-match messages.

Copilot uses AI. Check for mistakes.
reader.releaseLock();
return;
}

Expand Down
15 changes: 8 additions & 7 deletions src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { speechEndpoint } from '../../client/endpoints';
import { detectOutputFormat, formatOutput } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { pipeAudioSseToStdout } from '../../utils/audio-stream';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { SpeechRequest, SpeechResponse } from '../../types/api';
Expand Down Expand Up @@ -98,14 +99,14 @@ export default defineCommand({

if (flags.stream) {
const res = await request(config, { url, method: 'POST', body, stream: true });
const reader = res.body?.getReader();
if (!reader) throw new CLIError('No response body', ExitCode.GENERAL);
while (true) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(value);
try {
await pipeAudioSseToStdout(res.body);
} catch (err) {
Comment on lines 100 to +104
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --stream path now decodes SSE->JSON->hex->bytes, but there’s no automated test coverage for this behavior. Consider adding a test that mocks an SSE response (the test suite already has sseResponse in test/helpers/mock-server.ts) and asserts that the command writes the expected raw bytes to stdout and stops on [DONE].

Copilot uses AI. Check for mistakes.
if (err instanceof Error && err.message === 'No response body') {
throw new CLIError('No response body', ExitCode.GENERAL);
}
throw err;
Comment on lines +102 to +108
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error handling relies on matching err.message === 'No response body' from the helper. Using error-message strings for control flow is brittle; consider exporting a dedicated error class/type (e.g., NoResponseBodyError) from audio-stream.ts, or have pipeAudioSseToStdout throw CLIError directly so callers don’t need to pattern-match messages.

Copilot uses AI. Check for mistakes.
}
reader.releaseLock();
return;
}

Expand Down
88 changes: 88 additions & 0 deletions src/utils/audio-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Helpers for piping streamed TTS / music responses to stdout as raw audio.
*
* The MiniMax streaming endpoints return a Server-Sent Events stream of JSON
* envelopes whose `data.audio` field is a hex-encoded chunk of the target
* audio format. The `--stream` CLI flag is documented as writing *raw audio*
* to stdout (so it can be piped directly into players such as `mpv -`), so
* this helper parses the SSE frames, decodes the hex payloads, and writes
* the decoded bytes to stdout.
*/

/**
* Install a one-shot EPIPE handler on stdout so that downstream consumers
* closing the pipe early (e.g. `... | head`, or a player that exits) does
* not crash the process with an unhandled `'error'` event.
*/
export function installStdoutEpipeHandler(): void {
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err && err.code === 'EPIPE') {
process.exit(0);
}
throw err;
});
Comment on lines +44 to +52
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

installStdoutEpipeHandler is described as “one-shot”, but it uses process.stdout.on('error', ...) and is called from pipeAudioSseToStdout, so repeated uses will register multiple listeners (risking MaxListenersExceededWarning in tests or long-lived processes). Consider guarding with a module-level flag and/or using once instead of on.

Copilot uses AI. Check for mistakes.
}

/**
* Consume a fetch-style ReadableStream of SSE bytes and write the decoded
* raw audio bytes (from `data.audio` hex fields) to stdout.
*/
export async function pipeAudioSseToStdout(
body: ReadableStream<Uint8Array> | null | undefined,
): Promise<void> {
const reader = body?.getReader();
if (!reader) {
throw new Error('No response body');
}

installStdoutEpipeHandler();

const decoder = new TextDecoder();
let buffer = '';

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });

// SSE events are separated by blank lines.
let sep: number;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
writeEvent(event);
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSE framing here only looks for \n\n as the event separator. If the upstream stream uses CRLF (\r\n\r\n), events may never be split and writeEvent will receive strings containing \r, causing JSON.parse failures. Consider normalizing \r\n to \n before parsing and/or reusing the existing parseSSE helper in src/client/stream.ts to avoid duplicate (and more spec-complete) SSE parsing logic.

Copilot uses AI. Check for mistakes.
}

// Flush any trailing event without a terminating blank line.
buffer += decoder.decode();
if (buffer.length > 0) {
writeEvent(buffer);
}
} finally {
reader.releaseLock();
}
}

function writeEvent(event: string): void {
for (const rawLine of event.split('\n')) {
if (!rawLine.startsWith('data:')) continue;
// Per SSE spec, an optional single space after `data:` should be stripped.
const payload = rawLine.slice(5).replace(/^ /, '');
if (!payload || payload === '[DONE]') continue;

let parsed: { data?: { audio?: string } };
try {
parsed = JSON.parse(payload);
} catch {
// Non-JSON keepalive or comment — skip.
continue;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeEvent parses each data: line independently. Per SSE spec, a single event can contain multiple data: lines that must be concatenated with \n; parsing line-by-line can drop valid payloads (e.g., pretty-printed JSON) and also fails if lines end with \r. Using the shared parseSSE generator (which already concatenates data lines) or trimming rawLine/payload would make this more robust.

Copilot uses AI. Check for mistakes.
}

const hex = parsed?.data?.audio;
if (typeof hex === 'string' && hex.length > 0) {
process.stdout.write(Buffer.from(hex, 'hex'));
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.stdout.write(Buffer.from(hex, 'hex')) ignores backpressure. For long streams this can buffer large amounts in memory if stdout can’t keep up. Consider making the write path async and awaiting drain when stdout.write() returns false (similar to how file download handles backpressure elsewhere in the codebase).

Copilot uses AI. Check for mistakes.
}
}
Loading