From ed939d18e8a986134bfb4656bb4cb7c8a7ba09dd Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:46:11 +0000 Subject: [PATCH 1/2] feat(speech): add async and WebSocket TTS operations Add the missing MiniMax TTS operations to the speech command and SDK: - mmx speech async: create a long-form async TTS task (POST /v1/t2a_async_v2) - mmx speech task get: query async task status (GET /v1/query/t2a_async_query_v2) - mmx speech websocket: synchronous streaming TTS over WSS (/ws/v1/t2a_v2) - SpeechSDK: createAsync, queryAsync, downloadAsyncFile, synthesizeWebSocket The async flow polls the task to completion and downloads the resulting audio file; the WebSocket flow streams hex-encoded audio chunks as they arrive and saves or pipes them. --- README.md | 4 + SDK.md | 14 ++ docs/cli-design.md | 7 +- src/client/endpoints.ts | 16 +++ src/commands/speech/async.ts | 157 ++++++++++++++++++++++ src/commands/speech/task-get.ts | 53 ++++++++ src/commands/speech/websocket.ts | 124 ++++++++++++++++++ src/registry.ts | 6 + src/sdk/speech/index.ts | 123 ++++++++++++++++- src/types/api.ts | 36 +++++ src/utils/tts-websocket.ts | 174 +++++++++++++++++++++++++ test/commands/speech/tts-async.test.ts | 95 ++++++++++++++ test/sdk/speech-async.test.ts | 117 +++++++++++++++++ test/sdk/speech-websocket.test.ts | 101 ++++++++++++++ 14 files changed, 1024 insertions(+), 3 deletions(-) create mode 100644 src/commands/speech/async.ts create mode 100644 src/commands/speech/task-get.ts create mode 100644 src/commands/speech/websocket.ts create mode 100644 src/utils/tts-websocket.ts create mode 100644 test/commands/speech/tts-async.test.ts create mode 100644 test/sdk/speech-async.test.ts create mode 100644 test/sdk/speech-websocket.test.ts diff --git a/README.md b/README.md index e0326237..db0172d6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ mmx speech synthesize --text "Hello!" --out hello.mp3 mmx speech synthesize --text "Stream me" --stream | mpv - mmx speech synthesize --text "Hi" --voice English_magnetic_voiced_man --speed 1.2 echo "Breaking news" | mmx speech synthesize --text-file - --out news.mp3 +mmx speech websocket --text "Hello!" --out hello.mp3 +mmx speech websocket --text "Stream me" --stream | mpv - +mmx speech async --text "Long text..." --wait --out long.mp3 +mmx speech task get --task-id 95157322514444 mmx speech voices ``` diff --git a/SDK.md b/SDK.md index ed669983..49a3477c 100644 --- a/SDK.md +++ b/SDK.md @@ -120,6 +120,20 @@ for await (const chunk of stream) { // List voices const voices = await sdk.speech.voices(); const englishVoices = await sdk.speech.voices('en'); + +// WebSocket TTS (streaming audio bytes) +const wsStream = await sdk.speech.synthesizeWebSocket({ + text: 'Stream me', + stream: true, +}); + +// WebSocket TTS (single buffer) +const wsAudio = await sdk.speech.synthesizeWebSocket({ text: 'Hello, world!' }); + +// Asynchronous TTS for long-form text +const task = await sdk.speech.createAsync({ text: 'Long text...' }); +const status = await sdk.speech.queryAsync(task.task_id); // { status: 'Success', file_id } +const saved = await sdk.speech.downloadAsyncFile(status.file_id, 'long.mp3'); ``` ### Music diff --git a/docs/cli-design.md b/docs/cli-design.md index c8292dc9..ce1ec293 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -20,7 +20,12 @@ mmx ├── text │ └── chat Send a chat completion (M3) ├── speech -│ └── synthesize Synchronous TTS, ≤10k chars +│ ├── synthesize Synchronous TTS over HTTP, ≤10k chars +│ ├── websocket Synchronous TTS over WebSocket (streaming) +│ ├── async Create an asynchronous TTS task (long-form) +│ ├── task +│ │ └── get Query an asynchronous TTS task status +│ └── voices List system voices ├── image │ └── generate Generate images (image-01) ├── video diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index bde656df..4f0e2ed7 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -6,6 +6,22 @@ export function speechEndpoint(baseUrl: string): string { return `${baseUrl}/v1/t2a_v2`; } +export function speechAsyncEndpoint(baseUrl: string): string { + return `${baseUrl}/v1/t2a_async_v2`; +} + +export function speechAsyncQueryEndpoint(baseUrl: string, taskId: string | number): string { + return `${baseUrl}/v1/query/t2a_async_query_v2?task_id=${taskId}`; +} + +export function speechAsyncFileEndpoint(baseUrl: string, fileId: string | number): string { + return `${baseUrl}/v1/files/retrieve_content?file_id=${fileId}`; +} + +export function speechWsEndpoint(baseUrl: string): string { + return `${baseUrl.replace(/^http/, 'ws')}/ws/v1/t2a_v2`; +} + export function voicesEndpoint(baseUrl: string): string { return `${baseUrl}/v1/get_voice`; } diff --git a/src/commands/speech/async.ts b/src/commands/speech/async.ts new file mode 100644 index 00000000..6ed9b7cc --- /dev/null +++ b/src/commands/speech/async.ts @@ -0,0 +1,157 @@ +import { defineCommand } from '../../command'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; +import { requestJson } from '../../client/http'; +import { + speechAsyncEndpoint, + speechAsyncFileEndpoint, + speechAsyncQueryEndpoint, +} from '../../client/endpoints'; +import { poll } from '../../polling/poll'; +import { downloadFile } from '../../files/download'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; +import { readTextFromPathOrStdin } from '../../utils/fs'; +import { T2A_FORMATS, formatList, validateAudioFormat, t2aDefaultSampleRate } from '../../utils/audio-formats'; +import type { Config } from '../../config/schema'; +import type { GlobalFlags } from '../../types/flags'; +import type { + SpeechAsyncRequest, + SpeechAsyncQueryResponse, + SpeechAsyncResponse, +} from '../../types/api'; + +export default defineCommand({ + name: 'speech async', + description: 'Create an asynchronous TTS task (long-form, up to 1M chars)', + apiDocs: '/docs/api-reference/speech-t2a-async-create', + usage: 'mmx speech async --text [--wait] [--out ] [flags]', + options: [ + { flag: '--model ', description: 'Model ID (default: speech-2.8-hd)' }, + { flag: '--text ', description: 'Text to synthesize' }, + { flag: '--text-file ', description: 'Read text from file (use - for stdin)' }, + { flag: '--voice ', description: 'Voice ID (default: English_expressive_narrator)' }, + { flag: '--speed ', description: 'Speech speed multiplier', type: 'number' }, + { flag: '--volume ', description: 'Volume level', type: 'number' }, + { flag: '--pitch ', description: 'Pitch adjustment', type: 'number' }, + { flag: '--format ', description: `Audio format: ${formatList(T2A_FORMATS)} (default: mp3)` }, + { flag: '--sample-rate ', description: 'Sample rate (default: 32000)', type: 'number' }, + { flag: '--bitrate ', description: 'Bitrate (default: 128000)', type: 'number' }, + { flag: '--channels ', description: 'Audio channels (default: 1)', type: 'number' }, + { flag: '--language ', description: 'Language boost' }, + { flag: '--pronunciation ', description: 'Custom pronunciation (repeatable)', type: 'array' }, + { flag: '--wait', description: 'Poll until the task completes, then download the audio' }, + { flag: '--poll-interval ', description: 'Polling interval when waiting (default: 5)', type: 'number' }, + { flag: '--out ', description: 'Save audio to file (used with --wait)' }, + ], + examples: [ + 'mmx speech async --text "Long text to synthesize..."', + 'mmx speech async --text "Long text..." --wait --out long.mp3', + 'mmx speech async --text "Hello" --output json', + ], + async run(config: Config, flags: GlobalFlags) { + let text = (flags.text ?? (flags._positional as string[] | undefined)?.[0]) as string | undefined; + + if (flags.textFile) { + text = readTextFromPathOrStdin(flags.textFile as string); + } + + if (!text) { + throw new CLIError( + '--text or --text-file is required.', + ExitCode.USAGE, + 'mmx speech async --text "Long text" --wait --out long.mp3', + ); + } + + const model = (flags.model as string) + || config.defaultSpeechModel + || 'speech-2.8-hd'; + const voice = (flags.voice as string) || 'English_expressive_narrator'; + const ext = (flags.format as string) || 'mp3'; + validateAudioFormat(ext, T2A_FORMATS); + + const body: SpeechAsyncRequest = { + model, + text, + voice_setting: { + voice_id: voice, + speed: (flags.speed as number) ?? undefined, + vol: (flags.volume as number) ?? undefined, + pitch: (flags.pitch as number) ?? undefined, + }, + audio_setting: { + format: ext, + sample_rate: (flags.sampleRate as number) ?? t2aDefaultSampleRate(ext, 32000), + bitrate: (flags.bitrate as number) ?? 128000, + channel: (flags.channels as number) ?? 1, + }, + }; + + if (flags.language) body.language_boost = flags.language as string; + + if (flags.pronunciation) { + body.pronunciation_dict = { + tone: flags.pronunciation as string[], + }; + } + + if (dryRun(config, body)) return; + + const format = detectOutputFormat(config.output); + const url = speechAsyncEndpoint(config.baseUrl); + + const response = await requestJson(config, { + url, + method: 'POST', + body, + }); + + const taskId = response.task_id; + + if (!flags.wait) { + console.log(formatOutput({ + task_id: taskId, + file_id: response.file_id, + usage_characters: response.usage_characters, + }, format)); + return; + } + + if (!config.quiet) process.stderr.write(`[Model: ${model}]\n`); + + const result = await poll(config, { + url: speechAsyncQueryEndpoint(config.baseUrl, taskId), + intervalSec: (flags.pollInterval as number) ?? 5, + timeoutSec: config.timeout, + isComplete: (d) => (d as SpeechAsyncQueryResponse).status === 'Success', + isFailed: (d) => ['Failed', 'Expired'].includes((d as SpeechAsyncQueryResponse).status), + getStatus: (d) => (d as SpeechAsyncQueryResponse).status, + }); + + const fileId = result.file_id; + if (!fileId) { + throw new CLIError( + 'Task completed but no file_id returned.', + ExitCode.GENERAL, + ); + } + + const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); + const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`; + + await downloadFile(speechAsyncFileEndpoint(config.baseUrl, fileId), outPath, { + quiet: config.quiet, + }); + + if (config.quiet) { + console.log(outPath); + } else { + console.log(formatOutput({ + task_id: taskId, + status: result.status, + file_id: fileId, + saved: outPath, + }, format)); + } + }, +}); diff --git a/src/commands/speech/task-get.ts b/src/commands/speech/task-get.ts new file mode 100644 index 00000000..7907e635 --- /dev/null +++ b/src/commands/speech/task-get.ts @@ -0,0 +1,53 @@ +import { defineCommand } from '../../command'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; +import { requestJson } from '../../client/http'; +import { speechAsyncQueryEndpoint } from '../../client/endpoints'; +import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import type { Config } from '../../config/schema'; +import type { GlobalFlags } from '../../types/flags'; +import type { SpeechAsyncQueryResponse } from '../../types/api'; + +export default defineCommand({ + name: 'speech task get', + description: 'Query an asynchronous TTS task status', + apiDocs: '/docs/api-reference/speech-t2a-async-query', + usage: 'mmx speech task get --task-id ', + options: [ + { flag: '--task-id ', description: 'Asynchronous TTS task ID' }, + ], + examples: [ + 'mmx speech task get --task-id 95157322514444', + 'mmx speech task get --task-id 95157322514444 --output json', + ], + async run(config: Config, flags: GlobalFlags) { + const taskId = flags.taskId as string | undefined; + if (!taskId) { + throw new CLIError( + '--task-id is required.', + ExitCode.USAGE, + 'mmx speech task get --task-id ', + ); + } + + if (config.dryRun) { + console.log(`Would query task: ${taskId}`); + return; + } + + const format = detectOutputFormat(config.output); + const url = speechAsyncQueryEndpoint(config.baseUrl, taskId); + const response = await requestJson(config, { url }); + + if (config.quiet) { + console.log(response.status); + return; + } + + console.log(formatOutput({ + task_id: response.task_id, + status: response.status, + file_id: response.file_id, + }, format)); + }, +}); diff --git a/src/commands/speech/websocket.ts b/src/commands/speech/websocket.ts new file mode 100644 index 00000000..b2f63dc4 --- /dev/null +++ b/src/commands/speech/websocket.ts @@ -0,0 +1,124 @@ +import { defineCommand } from '../../command'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; +import { writeFileSync } from 'fs'; +import { speechWsEndpoint } from '../../client/endpoints'; +import { resolveCredential } from '../../auth/resolver'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; +import { readTextFromPathOrStdin } from '../../utils/fs'; +import { T2A_FORMATS, formatList, validateAudioFormat, t2aDefaultSampleRate } from '../../utils/audio-formats'; +import { ttsWebSocketAudioStream, type SpeechWebSocketRequest } from '../../utils/tts-websocket'; +import type { Config } from '../../config/schema'; +import type { GlobalFlags } from '../../types/flags'; + +export default defineCommand({ + name: 'speech websocket', + description: 'Synchronous TTS over WebSocket (streaming)', + apiDocs: '/docs/api-reference/speech-t2a-websocket', + usage: 'mmx speech websocket --text [--out ] [flags]', + options: [ + { flag: '--model ', description: 'Model ID (default: speech-2.8-hd)' }, + { flag: '--text ', description: 'Text to synthesize' }, + { flag: '--text-file ', description: 'Read text from file (use - for stdin)' }, + { flag: '--voice ', description: 'Voice ID (default: English_expressive_narrator)' }, + { flag: '--speed ', description: 'Speech speed multiplier', type: 'number' }, + { flag: '--volume ', description: 'Volume level', type: 'number' }, + { flag: '--pitch ', description: 'Pitch adjustment', type: 'number' }, + { flag: '--format ', description: `Audio format: ${formatList(T2A_FORMATS)} (default: mp3)` }, + { flag: '--sample-rate ', description: 'Sample rate (default: 32000)', type: 'number' }, + { flag: '--bitrate ', description: 'Bitrate (default: 128000)', type: 'number' }, + { flag: '--channels ', description: 'Audio channels (default: 1)', type: 'number' }, + { flag: '--language ', description: 'Language boost' }, + { flag: '--pronunciation ', description: 'Custom pronunciation (repeatable)', type: 'array' }, + { flag: '--out ', description: 'Save audio to file' }, + { flag: '--stream', description: 'Stream raw audio to stdout as it arrives' }, + ], + examples: [ + 'mmx speech websocket --text "Hello, world!" --out hello.mp3', + 'mmx speech websocket --text "Stream" --stream | mpv --no-terminal -', + ], + async run(config: Config, flags: GlobalFlags) { + let text = (flags.text ?? (flags._positional as string[] | undefined)?.[0]) as string | undefined; + + if (flags.textFile) { + text = readTextFromPathOrStdin(flags.textFile as string); + } + + if (!text) { + throw new CLIError( + '--text or --text-file is required.', + ExitCode.USAGE, + 'mmx speech websocket --text "Hello" --out hello.mp3', + ); + } + + const model = (flags.model as string) + || config.defaultSpeechModel + || 'speech-2.8-hd'; + const voice = (flags.voice as string) || 'English_expressive_narrator'; + const ext = (flags.format as string) || 'mp3'; + validateAudioFormat(ext, T2A_FORMATS); + + const request: SpeechWebSocketRequest = { + model, + text, + voice_setting: { + voice_id: voice, + speed: (flags.speed as number) ?? undefined, + vol: (flags.volume as number) ?? undefined, + pitch: (flags.pitch as number) ?? undefined, + }, + audio_setting: { + format: ext, + sample_rate: (flags.sampleRate as number) ?? t2aDefaultSampleRate(ext, 32000), + bitrate: (flags.bitrate as number) ?? 128000, + channel: (flags.channels as number) ?? 1, + }, + }; + + if (flags.language) request.language_boost = flags.language as string; + + if (flags.pronunciation) { + request.pronunciation_dict = { + tone: flags.pronunciation as string[], + }; + } + + if (dryRun(config, request)) return; + + const format = detectOutputFormat(config.output); + const credential = await resolveCredential(config); + const url = speechWsEndpoint(config.baseUrl); + + if (!config.quiet) process.stderr.write(`[Model: ${model}]\n`); + + if (flags.stream) { + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0); + throw err; + }); + + for await (const chunk of ttsWebSocketAudioStream(url, credential.token, request)) { + if (!process.stdout.write(chunk)) { + await new Promise(r => process.stdout.once('drain', r)); + } + } + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of ttsWebSocketAudioStream(url, credential.token, request)) { + chunks.push(Buffer.from(chunk)); + } + + const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); + const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`; + writeFileSync(outPath, Buffer.concat(chunks)); + + if (config.quiet) { + console.log(outPath); + } else { + console.log(formatOutput({ saved: outPath }, format)); + } + }, +}); diff --git a/src/registry.ts b/src/registry.ts index d34b6b10..e21d7402 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -11,6 +11,9 @@ import textChat from './commands/text/chat'; import textRepl from './commands/text/repl'; import speechSynthesize from './commands/speech/synthesize'; import speechVoices from './commands/speech/voices'; +import speechAsync from './commands/speech/async'; +import speechTaskGet from './commands/speech/task-get'; +import speechWebSocket from './commands/speech/websocket'; import imageGenerate from './commands/image/generate'; import videoGenerate from './commands/video/generate'; import videoTaskGet from './commands/video/task-get'; @@ -290,6 +293,9 @@ export const registry = new CommandRegistry({ 'speech synthesize': speechSynthesize, 'speech generate': speechSynthesize, 'speech voices': speechVoices, + 'speech async': speechAsync, + 'speech task get': speechTaskGet, + 'speech websocket': speechWebSocket, 'image generate': imageGenerate, 'video generate': videoGenerate, 'video task get': videoTaskGet, diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index 5c4844cf..e555e24b 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -1,9 +1,25 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { Client } from "../client"; -import { speechEndpoint, voicesEndpoint } from "../../client/endpoints"; -import { SpeechRequest, SpeechResponse, VoiceListResponse } from "../../types/api"; +import { + speechAsyncEndpoint, + speechAsyncFileEndpoint, + speechAsyncQueryEndpoint, + speechEndpoint, + speechWsEndpoint, + voicesEndpoint, +} from "../../client/endpoints"; +import { + SpeechAsyncQueryResponse, + SpeechAsyncRequest, + SpeechAsyncResponse, + SpeechRequest, + SpeechResponse, + VoiceListResponse, +} from "../../types/api"; import { filterByLanguage } from "../../commands/speech/voices"; +import { resolveCredential } from "../../auth/resolver"; +import { ttsWebSocketAudioStream, type SpeechWebSocketRequest } from "../../utils/tts-websocket"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; import { toMerged } from "es-toolkit/object"; @@ -73,6 +89,90 @@ export class SpeechSDK extends Client { return voices; } + /** + * Create an asynchronous TTS task. The task is processed in the background + * and can be polled with `queryAsync()`. Supports long-form text (up to + * 1M characters). + * + * @param request — Model, text, voice and audio settings. + * @returns The created task, including its `task_id` and `file_id`. + */ + async createAsync(request: ModelPartial): Promise { + const body = this.validateAsyncParams(request); + const url = speechAsyncEndpoint(this.config.baseUrl); + return this.requestJson({ + url, + method: 'POST', + body, + }); + } + + /** + * Query the status of an asynchronous TTS task created with `createAsync()`. + * + * @param taskId — The task ID returned by `createAsync()`. + * @returns The current task status (`Processing`, `Success`, `Failed`, or + * `Expired`) and, when complete, the resulting `file_id`. + */ + async queryAsync(taskId: string | number): Promise { + const url = speechAsyncQueryEndpoint(this.config.baseUrl, taskId); + return this.requestJson({ url }); + } + + /** + * Download the audio produced by a completed asynchronous TTS task. + * + * @param fileId — The `file_id` returned by `queryAsync()`. + * @param outPath — Target file path. Defaults to `speech_.mp3`. + * @returns The absolute path of the saved file. + */ + async downloadAsyncFile(fileId: string | number, outPath?: string, ext = 'mp3'): Promise { + const dest = resolve(outPath || defaultFilename('speech', ext)); + const url = speechAsyncFileEndpoint(this.config.baseUrl, fileId); + const res = await this.request({ url }); + + const data = new Uint8Array(await res.arrayBuffer()); + const dir = dirname(dest); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + try { + writeFileSync(dest, data); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOSPC') { + throw new SDKError('Disk full — cannot write audio file.', ExitCode.GENERAL); + } + throw err; + } + + return dest; + } + + async synthesizeWebSocket(request: ModelPartial & { stream: true }): Promise>; + async synthesizeWebSocket(request: ModelPartial): Promise; + async synthesizeWebSocket(request: ModelPartial): Promise> { + const params = this.validateParams(request); + const wsRequest: SpeechWebSocketRequest = { + model: params.model, + text: params.text, + }; + if (params.voice_setting) wsRequest.voice_setting = params.voice_setting; + if (params.audio_setting) wsRequest.audio_setting = params.audio_setting; + if (params.language_boost) wsRequest.language_boost = params.language_boost; + if (params.pronunciation_dict) wsRequest.pronunciation_dict = params.pronunciation_dict; + + const credential = await resolveCredential(this.config); + const url = speechWsEndpoint(this.config.baseUrl); + + const stream = ttsWebSocketAudioStream(url, credential.token, wsRequest); + if (params.stream) return stream; + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + /** * Save synthesized speech audio to a file. Decodes the hex-encoded audio * from the API response and writes it to disk. Creates intermediate @@ -124,4 +224,23 @@ export class SpeechSDK extends Client { output_format: 'hex', }, params) as SpeechRequest; } + + private validateAsyncParams(params: Partial): SpeechAsyncRequest { + if (!params.text) { + throw new SDKError('text is required', ExitCode.USAGE); + } + + return toMerged({ + model: "speech-2.8-hd", + voice_setting: { + voice_id: "English_expressive_narrator", + }, + audio_setting: { + format: "mp3", + sample_rate: 32000, + bitrate: 128000, + channel: 1, + }, + }, params) as SpeechAsyncRequest; + } } diff --git a/src/types/api.ts b/src/types/api.ts index badf5073..75b8b848 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -129,6 +129,42 @@ export interface SpeechResponse { }; } +// ---- Speech / TTS (async + WebSocket) ---- + +export interface SpeechVoiceModify { + pitch?: number; + intensity?: number; + timbre?: number; + sound_effects?: string; +} + +export interface SpeechAsyncRequest { + model: string; + text: string; + voice_setting?: SpeechRequest['voice_setting']; + audio_setting?: SpeechRequest['audio_setting']; + language_boost?: string; + pronunciation_dict?: SpeechRequest['pronunciation_dict']; + voice_modify?: SpeechVoiceModify; +} + +export interface SpeechAsyncResponse { + task_id: string | number; + task_token?: string; + file_id?: string | number; + usage_characters?: number; + base_resp: BaseResp; +} + +export type SpeechAsyncStatus = 'Processing' | 'Success' | 'Failed' | 'Expired'; + +export interface SpeechAsyncQueryResponse { + task_id: string | number; + status: SpeechAsyncStatus; + file_id?: string | number; + base_resp: BaseResp; +} + // ---- Voice List ---- export interface SystemVoiceInfo { diff --git a/src/utils/tts-websocket.ts b/src/utils/tts-websocket.ts new file mode 100644 index 00000000..014e4b44 --- /dev/null +++ b/src/utils/tts-websocket.ts @@ -0,0 +1,174 @@ +import { WebSocket } from 'undici'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; +import type { SpeechRequest } from '../types/api'; + +/** Request payload for the synchronous T2A WebSocket protocol. */ +export interface SpeechWebSocketRequest { + model: string; + text: string; + voice_setting?: SpeechRequest['voice_setting']; + audio_setting?: SpeechRequest['audio_setting']; + language_boost?: string; + pronunciation_dict?: SpeechRequest['pronunciation_dict']; +} + +interface SpeechWsEvent { + event?: string; + data?: { audio?: string }; + is_final?: boolean; + base_resp?: { status_code?: number; status_msg?: string }; +} + +function decodeHexAudio(hex: string): Uint8Array { + if (!/^[0-9a-fA-F]+$/.test(hex)) { + throw new CLIError( + 'Speech WebSocket returned invalid audio data (not valid hex).', + ExitCode.GENERAL, + ); + } + if (hex.length % 2 !== 0) { + throw new CLIError( + 'Speech WebSocket returned truncated audio data (odd-length hex string).', + ExitCode.GENERAL, + ); + } + return Uint8Array.from(Buffer.from(hex, 'hex')); +} + +function wsError(event: SpeechWsEvent): Error | undefined { + const code = event.base_resp?.status_code; + if (code && code !== 0) { + return new CLIError( + `Speech WebSocket error (${code}): ${event.base_resp?.status_msg ?? 'unknown'}`, + ExitCode.GENERAL, + ); + } + return undefined; +} + +/** Minimal FIFO bridge from WebSocket events to sequential consumers. */ +class WsReceiver { + private queue: SpeechWsEvent[] = []; + private waiters: Array<{ + resolve: (ev: SpeechWsEvent) => void; + reject: (err: Error) => void; + }> = []; + private failure: Error | undefined; + + push(ev: SpeechWsEvent): void { + if (this.failure) return; + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(ev); + else this.queue.push(ev); + } + + fail(err: Error): void { + this.failure = err; + for (const waiter of this.waiters.splice(0)) waiter.reject(err); + } + + next(): Promise { + if (this.queue.length > 0) return Promise.resolve(this.queue.shift()!); + if (this.failure) return Promise.reject(this.failure); + return new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } +} + +/** + * Stream audio chunks for synchronous TTS over WebSocket (`/ws/v1/t2a_v2`). + * + * The protocol sends a `task_start` message carrying the model and voice/audio + * settings, then a `task_continue` message with the text to synthesize. Audio + * arrives as hex-encoded chunks in `task_continued` events until `is_final`. + */ +export async function* ttsWebSocketAudioStream( + url: string, + token: string, + request: SpeechWebSocketRequest, +): AsyncGenerator { + const receiver = new WsReceiver(); + const ws = new WebSocket(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + ws.addEventListener('error', () => { + receiver.fail(new CLIError('Speech WebSocket connection failed.', ExitCode.GENERAL)); + }); + + ws.addEventListener('close', () => { + receiver.fail(new CLIError('Speech WebSocket closed unexpectedly.', ExitCode.GENERAL)); + }); + + ws.addEventListener('message', (event) => { + const raw = (event as { data?: unknown }).data; + let data: string; + if (typeof raw === 'string') { + data = raw; + } else if (raw instanceof ArrayBuffer) { + data = new TextDecoder().decode(raw); + } else { + receiver.fail( + new CLIError('Speech WebSocket returned an unsupported message type.', ExitCode.GENERAL), + ); + return; + } + + let parsed: SpeechWsEvent; + try { + parsed = JSON.parse(data) as SpeechWsEvent; + } catch { + receiver.fail(new CLIError('Speech WebSocket returned a non-JSON message.', ExitCode.GENERAL)); + return; + } + receiver.push(parsed); + }); + + try { + const connected = await receiver.next(); + if (connected.event !== 'connected_success') { + throw wsError(connected) + ?? new CLIError('Speech WebSocket did not acknowledge the connection.', ExitCode.GENERAL); + } + + const start: Record = { event: 'task_start', model: request.model }; + if (request.language_boost) start.language_boost = request.language_boost; + if (request.voice_setting) start.voice_setting = request.voice_setting; + if (request.pronunciation_dict) start.pronunciation_dict = request.pronunciation_dict; + if (request.audio_setting) start.audio_setting = request.audio_setting; + ws.send(JSON.stringify(start)); + + const started = await receiver.next(); + if (started.event !== 'task_started') { + throw wsError(started) + ?? new CLIError('Speech WebSocket did not start the task.', ExitCode.GENERAL); + } + + ws.send(JSON.stringify({ event: 'task_continue', text: request.text })); + + while (true) { + const ev = await receiver.next(); + if (ev.event === 'task_failed' || ev.event === 'task_finished') { + const err = wsError(ev); + if (err) throw err; + break; + } + const audio = ev.data?.audio; + if (audio) { + const err = wsError(ev); + if (err) throw err; + yield decodeHexAudio(audio); + } + if (ev.is_final) break; + } + } finally { + try { + ws.send(JSON.stringify({ event: 'task_finish' })); + } catch { /* socket may already be closed */ } + try { + ws.close(); + } catch { /* already closed */ } + } +} diff --git a/test/commands/speech/tts-async.test.ts b/test/commands/speech/tts-async.test.ts new file mode 100644 index 00000000..79874d9a --- /dev/null +++ b/test/commands/speech/tts-async.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'bun:test'; +import { registry } from '../../../src/registry'; + +const baseConfig = { + apiKey: 'k', + region: 'global' as const, + baseUrl: 'https://api.minimax.io', + output: 'json' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, +}; + +describe('speech async command', () => { + it('is registered and prints the request body on dry run', async () => { + const { command } = registry.resolve(['speech', 'async']); + expect(command.name).toBe('speech async'); + + let output = ''; + const origLog = console.log; + console.log = (msg: string) => { output += msg; }; + + try { + await command.execute( + baseConfig, + { model: 'speech-2.8-hd', text: 'Hello world', quiet: false, verbose: false, noColor: true, yes: false, dryRun: true, help: false, nonInteractive: true, async: false }, + ); + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('speech-2.8-hd'); + expect(parsed.request.text).toBe('Hello world'); + expect(parsed.request.output_format).toBeUndefined(); + } finally { + console.log = origLog; + } + }); +}); + +describe('speech task get command', () => { + it('is registered and prints the task id on dry run', async () => { + const { command } = registry.resolve(['speech', 'task', 'get']); + expect(command.name).toBe('speech task get'); + + let output = ''; + const origLog = console.log; + console.log = (msg: string) => { output += msg; }; + + try { + await command.execute( + baseConfig, + { taskId: '95157322514444', quiet: false, verbose: false, noColor: true, yes: false, dryRun: true, help: false, nonInteractive: true, async: false }, + ); + expect(output).toContain('95157322514444'); + } finally { + console.log = origLog; + } + }); + + it('requires --task-id', async () => { + const { command } = registry.resolve(['speech', 'task', 'get']); + await expect( + command.execute( + baseConfig, + { quiet: false, verbose: false, noColor: true, yes: false, dryRun: true, help: false, nonInteractive: true, async: false }, + ), + ).rejects.toThrow('--task-id is required'); + }); +}); + +describe('speech websocket command', () => { + it('is registered and prints the request body on dry run', async () => { + const { command } = registry.resolve(['speech', 'websocket']); + expect(command.name).toBe('speech websocket'); + + let output = ''; + const origLog = console.log; + console.log = (msg: string) => { output += msg; }; + + try { + await command.execute( + baseConfig, + { model: 'speech-2.8-hd', text: 'Hello world', quiet: false, verbose: false, noColor: true, yes: false, dryRun: true, help: false, nonInteractive: true, async: false }, + ); + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('speech-2.8-hd'); + expect(parsed.request.text).toBe('Hello world'); + } finally { + console.log = origLog; + } + }); +}); diff --git a/test/sdk/speech-async.test.ts b/test/sdk/speech-async.test.ts new file mode 100644 index 00000000..5670f3a8 --- /dev/null +++ b/test/sdk/speech-async.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; +import type { SpeechAsyncRequest } from '../../src/types/api'; + +describe('MiniMaxSDK.speech.createAsync', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('creates an async TTS task and returns the task id', async () => { + let requestBody: Record | undefined; + server = createMockServer({ + routes: { + '/v1/t2a_async_v2': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ + task_id: 95157322514444, + file_id: 95157322514444, + usage_characters: 101, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + const result = await sdk.speech.createAsync({ + model: 'speech-2.8-hd', + text: 'Hello world', + }); + + expect(result.task_id).toBe(95157322514444); + expect(requestBody?.model).toBe('speech-2.8-hd'); + expect(requestBody?.text).toBe('Hello world'); + expect(requestBody?.output_format).toBeUndefined(); + }); + + it('throws when text is missing', async () => { + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: 'https://x' }); + await expect(sdk.speech.createAsync({} as SpeechAsyncRequest)).rejects.toThrow('text is required'); + }); +}); + +describe('MiniMaxSDK.speech.queryAsync', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('queries the async task status', async () => { + server = createMockServer({ + routes: { + '/v1/query/t2a_async_query_v2': () => jsonResponse({ + task_id: 95157322514444, + status: 'Processing', + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + const result = await sdk.speech.queryAsync('95157322514444'); + + expect(result.task_id).toBe(95157322514444); + expect(result.status).toBe('Processing'); + }); + + it('surfaces the file id when the task completes', async () => { + server = createMockServer({ + routes: { + '/v1/query/t2a_async_query_v2': () => jsonResponse({ + task_id: 95157322514444, + status: 'Success', + file_id: 95157322514496, + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + const result = await sdk.speech.queryAsync('95157322514444'); + + expect(result.status).toBe('Success'); + expect(result.file_id).toBe(95157322514496); + }); +}); + +describe('MiniMaxSDK.speech.downloadAsyncFile', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('downloads the audio bytes to disk', async () => { + server = createMockServer({ + routes: { + '/v1/files/retrieve_content': () => new Response( + Buffer.from('hello audio'), + { headers: { 'Content-Type': 'application/octet-stream' } }, + ), + }, + }); + + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + const dest = await sdk.speech.downloadAsyncFile('95157322514496'); + + expect(await import('node:fs').then(fs => fs.existsSync(dest))).toBe(true); + const { readFileSync, unlinkSync } = await import('node:fs'); + expect(readFileSync(dest).toString()).toBe('hello audio'); + unlinkSync(dest); + }); +}); diff --git a/test/sdk/speech-websocket.test.ts b/test/sdk/speech-websocket.test.ts new file mode 100644 index 00000000..357492a3 --- /dev/null +++ b/test/sdk/speech-websocket.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { MiniMaxSDK } from '../../src/sdk'; + +interface WsTtsServer { + url: string; + close(): void; +} + +/** + * Minimal mock of the T2A WebSocket protocol: acknowledges the connection, + * acknowledges task_start, returns one hex audio chunk for task_continue, + * and emits task_finished before closing on task_finish. + */ +function createWsTtsServer(): WsTtsServer { + const server = Bun.serve({ + port: 0, + fetch(req, srv) { + if (srv.upgrade(req)) return undefined; + return new Response('upgrade failed', { status: 500 }); + }, + websocket: { + open(ws) { + ws.send(JSON.stringify({ + event: 'connected_success', + base_resp: { status_code: 0, status_msg: 'success' }, + })); + }, + message(ws, message) { + const parsed = JSON.parse(String(message)) as { event?: string }; + if (parsed.event === 'task_start') { + ws.send(JSON.stringify({ + event: 'task_started', + base_resp: { status_code: 0, status_msg: 'success' }, + })); + } else if (parsed.event === 'task_continue') { + ws.send(JSON.stringify({ + data: { audio: '414243' }, + is_final: true, + base_resp: { status_code: 0, status_msg: 'success' }, + })); + ws.send(JSON.stringify({ + event: 'task_finished', + base_resp: { status_code: 0, status_msg: 'success' }, + })); + } else if (parsed.event === 'task_finish') { + ws.close(); + } + }, + close() {}, + }, + }); + + return { + url: `ws://localhost:${server.port}`, + close() { server.stop(); }, + }; +} + +describe('MiniMaxSDK.speech.synthesizeWebSocket', () => { + let server: WsTtsServer; + + afterEach(() => { + server?.close(); + }); + + it('collects hex audio chunks into a buffer', async () => { + server = createWsTtsServer(); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url.replace(/^ws/, 'http'), + }); + + const audio = await sdk.speech.synthesizeWebSocket({ + model: 'speech-2.8-hd', + text: 'Hello world', + }); + + expect(Buffer.isBuffer(audio)).toBe(true); + expect(audio.toString()).toBe('ABC'); + }); + + it('streams audio chunks as an async generator', async () => { + server = createWsTtsServer(); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url.replace(/^ws/, 'http'), + }); + + const stream = await sdk.speech.synthesizeWebSocket({ + model: 'speech-2.8-hd', + text: 'Hello world', + stream: true, + }); + + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks).toString()).toBe('ABC'); + }); +}); From d836d4708bea0bd69e2f54f0b3cc3e34be7f094f Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:06:37 +0000 Subject: [PATCH 2/2] fix: align async speech API contracts --- README.md | 1 + SDK.md | 4 ++ src/commands/speech/async.ts | 52 +++++++++++--- src/files/download.ts | 3 +- src/sdk/speech/index.ts | 19 +++-- src/types/api.ts | 10 ++- test/commands/speech/tts-async.test.ts | 97 +++++++++++++++++++++++++- test/files/download.test.ts | 18 +++++ test/sdk/speech-async.test.ts | 40 +++++++++-- 9 files changed, 222 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index db0172d6..8a5912bd 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ echo "Breaking news" | mmx speech synthesize --text-file - --out news.mp3 mmx speech websocket --text "Hello!" --out hello.mp3 mmx speech websocket --text "Stream me" --stream | mpv - mmx speech async --text "Long text..." --wait --out long.mp3 +mmx speech async --text-file long.txt --wait --out long.mp3 mmx speech task get --task-id 95157322514444 mmx speech voices ``` diff --git a/SDK.md b/SDK.md index 49a3477c..5f0c8c40 100644 --- a/SDK.md +++ b/SDK.md @@ -134,6 +134,10 @@ const wsAudio = await sdk.speech.synthesizeWebSocket({ text: 'Hello, world!' }); const task = await sdk.speech.createAsync({ text: 'Long text...' }); const status = await sdk.speech.queryAsync(task.task_id); // { status: 'Success', file_id } const saved = await sdk.speech.downloadAsyncFile(status.file_id, 'long.mp3'); + +// Upload text for documents longer than the direct-text limit +const uploaded = await sdk.file.upload('long.txt', 't2a_async_input'); +const longTask = await sdk.speech.createAsync({ text_file_id: uploaded.file.file_id }); ``` ### Music diff --git a/src/commands/speech/async.ts b/src/commands/speech/async.ts index 6ed9b7cc..b236f38c 100644 --- a/src/commands/speech/async.ts +++ b/src/commands/speech/async.ts @@ -3,10 +3,12 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; import { + fileUploadEndpoint, speechAsyncEndpoint, speechAsyncFileEndpoint, speechAsyncQueryEndpoint, } from '../../client/endpoints'; +import { resolveCredential } from '../../auth/resolver'; import { poll } from '../../polling/poll'; import { downloadFile } from '../../files/download'; import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; @@ -15,11 +17,29 @@ import { T2A_FORMATS, formatList, validateAudioFormat, t2aDefaultSampleRate } fr import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { + FileUploadResponse, SpeechAsyncRequest, SpeechAsyncQueryResponse, SpeechAsyncResponse, } from '../../types/api'; +const DIRECT_TEXT_LIMIT = 50_000; + +async function uploadTextFile(config: Config, path: string): Promise { + const text = readTextFromPathOrStdin(path); + const fileName = path === '-' ? 'stdin.txt' : path.split(/[\\/]/).pop() || 'input.txt'; + const formData = new FormData(); + formData.append('file', new Blob([text], { type: 'text/plain' }), fileName); + formData.append('purpose', 't2a_async_input'); + + const response = await requestJson(config, { + url: fileUploadEndpoint(config.baseUrl), + method: 'POST', + body: formData, + }); + return response.file.file_id; +} + export default defineCommand({ name: 'speech async', description: 'Create an asynchronous TTS task (long-form, up to 1M chars)', @@ -49,13 +69,10 @@ export default defineCommand({ 'mmx speech async --text "Hello" --output json', ], async run(config: Config, flags: GlobalFlags) { - let text = (flags.text ?? (flags._positional as string[] | undefined)?.[0]) as string | undefined; + const text = (flags.text ?? (flags._positional as string[] | undefined)?.[0]) as string | undefined; + const textFile = flags.textFile as string | undefined; - if (flags.textFile) { - text = readTextFromPathOrStdin(flags.textFile as string); - } - - if (!text) { + if (!text && !textFile) { throw new CLIError( '--text or --text-file is required.', ExitCode.USAGE, @@ -63,6 +80,14 @@ export default defineCommand({ ); } + if (text && text.length > DIRECT_TEXT_LIMIT) { + throw new CLIError( + `--text is limited to ${DIRECT_TEXT_LIMIT.toLocaleString('en-US')} characters. Use --text-file for longer input.`, + ExitCode.USAGE, + 'mmx speech async --text-file long.txt --wait --out long.mp3', + ); + } + const model = (flags.model as string) || config.defaultSpeechModel || 'speech-2.8-hd'; @@ -72,7 +97,6 @@ export default defineCommand({ const body: SpeechAsyncRequest = { model, - text, voice_setting: { voice_id: voice, speed: (flags.speed as number) ?? undefined, @@ -81,7 +105,7 @@ export default defineCommand({ }, audio_setting: { format: ext, - sample_rate: (flags.sampleRate as number) ?? t2aDefaultSampleRate(ext, 32000), + audio_sample_rate: (flags.sampleRate as number) ?? t2aDefaultSampleRate(ext, 32000), bitrate: (flags.bitrate as number) ?? 128000, channel: (flags.channels as number) ?? 1, }, @@ -95,6 +119,16 @@ export default defineCommand({ }; } + if (textFile) { + if (config.dryRun) { + body.text_file_id = ''; + } else { + body.text_file_id = await uploadTextFile(config, textFile); + } + } else { + body.text = text; + } + if (dryRun(config, body)) return; const format = detectOutputFormat(config.output); @@ -139,8 +173,10 @@ export default defineCommand({ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`; + const credential = await resolveCredential(config); await downloadFile(speechAsyncFileEndpoint(config.baseUrl, fileId), outPath, { quiet: config.quiet, + headers: { Authorization: `Bearer ${credential.token}` }, }); if (config.quiet) { diff --git a/src/files/download.ts b/src/files/download.ts index b8202c89..d1132ae5 100644 --- a/src/files/download.ts +++ b/src/files/download.ts @@ -7,6 +7,7 @@ export interface DownloadOpts { quiet?: boolean; retries?: number; retryDelayMs?: number; + headers?: Record; } export async function downloadFile( @@ -31,7 +32,7 @@ export async function downloadFile( await new Promise(r => setTimeout(r, delay)); } - const res = await fetch(downloadUrl); + const res = await fetch(downloadUrl, { headers: opts?.headers }); if (!res.ok) { throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index e555e24b..e1e1880a 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -40,6 +40,8 @@ function defaultFilename(prefix: string, ext: string): string { return `${prefix}_${ts}.${ext}`; } +const DIRECT_ASYNC_TEXT_LIMIT = 50_000; + export class SpeechSDK extends Client { async synthesize(request: ModelPartial & { stream: true }): Promise>; async synthesize(request: ModelPartial): Promise; @@ -94,7 +96,7 @@ export class SpeechSDK extends Client { * and can be polled with `queryAsync()`. Supports long-form text (up to * 1M characters). * - * @param request — Model, text, voice and audio settings. + * @param request — Model, text or text_file_id, voice and audio settings. * @returns The created task, including its `task_id` and `file_id`. */ async createAsync(request: ModelPartial): Promise { @@ -226,8 +228,17 @@ export class SpeechSDK extends Client { } private validateAsyncParams(params: Partial): SpeechAsyncRequest { - if (!params.text) { - throw new SDKError('text is required', ExitCode.USAGE); + if (!params.text && !params.text_file_id) { + throw new SDKError('text or text_file_id is required', ExitCode.USAGE); + } + if (params.text && params.text_file_id) { + throw new SDKError('provide either text or text_file_id, not both', ExitCode.USAGE); + } + if (params.text && params.text.length > DIRECT_ASYNC_TEXT_LIMIT) { + throw new SDKError( + 'text is limited to 50,000 characters; upload longer input with purpose t2a_async_input and use text_file_id', + ExitCode.USAGE, + ); } return toMerged({ @@ -237,7 +248,7 @@ export class SpeechSDK extends Client { }, audio_setting: { format: "mp3", - sample_rate: 32000, + audio_sample_rate: 32000, bitrate: 128000, channel: 1, }, diff --git a/src/types/api.ts b/src/types/api.ts index 75b8b848..22e855a8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -140,9 +140,15 @@ export interface SpeechVoiceModify { export interface SpeechAsyncRequest { model: string; - text: string; + text?: string; + text_file_id?: string | number; voice_setting?: SpeechRequest['voice_setting']; - audio_setting?: SpeechRequest['audio_setting']; + audio_setting?: { + format?: string; + audio_sample_rate?: number; + bitrate?: number; + channel?: number; + }; language_boost?: string; pronunciation_dict?: SpeechRequest['pronunciation_dict']; voice_modify?: SpeechVoiceModify; diff --git a/test/commands/speech/tts-async.test.ts b/test/commands/speech/tts-async.test.ts index 79874d9a..0e9422c9 100644 --- a/test/commands/speech/tts-async.test.ts +++ b/test/commands/speech/tts-async.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect } from 'bun:test'; +import { afterEach, describe, it, expect } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { registry } from '../../../src/registry'; +import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; + +const originalFetch = globalThis.fetch; const baseConfig = { apiKey: 'k', @@ -17,6 +23,15 @@ const baseConfig = { }; describe('speech async command', () => { + let server: MockServer; + let tempDir: string | undefined; + + afterEach(() => { + globalThis.fetch = originalFetch; + server?.close(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + it('is registered and prints the request body on dry run', async () => { const { command } = registry.resolve(['speech', 'async']); expect(command.name).toBe('speech async'); @@ -33,11 +48,91 @@ describe('speech async command', () => { const parsed = JSON.parse(output); expect(parsed.request.model).toBe('speech-2.8-hd'); expect(parsed.request.text).toBe('Hello world'); + expect(parsed.request.audio_setting.audio_sample_rate).toBe(32000); + expect(parsed.request.audio_setting.sample_rate).toBeUndefined(); expect(parsed.request.output_format).toBeUndefined(); } finally { console.log = origLog; } }); + + it('uploads --text-file for long-form synthesis', async () => { + const upload = { purpose: null as FormDataEntryValue | null }; + let requestBody: Record | undefined; + server = createMockServer({ + routes: { + '/v1/files/upload': async (req) => { + const form = await req.formData(); + upload.purpose = form.get('purpose'); + return jsonResponse({ + file: { file_id: 'file-123', bytes: 12, created_at: 1, filename: 'long.txt', purpose: upload.purpose }, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + '/v1/t2a_async_v2': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ + task_id: 123, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + tempDir = mkdtempSync(join(tmpdir(), 'mmx-speech-async-')); + const textFile = join(tempDir, 'long.txt'); + writeFileSync(textFile, 'Long content'); + + const { command } = registry.resolve(['speech', 'async']); + await command.execute( + { ...baseConfig, baseUrl: server.url, dryRun: false, quiet: true }, + { textFile, quiet: true, verbose: false, noColor: true, yes: false, dryRun: false, help: false, nonInteractive: true, async: false }, + ); + + expect(upload.purpose).toBe('t2a_async_input'); + expect(requestBody?.text_file_id).toBe('file-123'); + expect(requestBody?.text).toBeUndefined(); + }); + + it('rejects direct text over 50,000 characters', async () => { + const { command } = registry.resolve(['speech', 'async']); + await expect(command.execute( + baseConfig, + { text: 'x'.repeat(50_001), quiet: true, verbose: false, noColor: true, yes: false, dryRun: true, help: false, nonInteractive: true, async: false }, + )).rejects.toThrow('Use --text-file for longer input'); + }); + + it('authenticates the completed task download', async () => { + const download = { authorization: null as string | null }; + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === '/v1/t2a_async_v2') { + return jsonResponse({ task_id: 123, base_resp: { status_code: 0 } }); + } + if (url.pathname === '/v1/query/t2a_async_query_v2') { + return jsonResponse({ + task_id: 123, + status: 'Success', + file_id: 456, + base_resp: { status_code: 0 }, + }); + } + if (url.pathname === '/v1/files/retrieve_content') { + download.authorization = new Headers(init?.headers).get('Authorization'); + return new Response('audio'); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + tempDir = mkdtempSync(join(tmpdir(), 'mmx-speech-async-')); + const outPath = join(tempDir, 'speech.mp3'); + const { command } = registry.resolve(['speech', 'async']); + await command.execute( + { ...baseConfig, baseUrl: 'https://api.example.test', dryRun: false, quiet: true }, + { text: 'Hello', wait: true, out: outPath, pollInterval: 0, quiet: true, verbose: false, noColor: true, yes: false, dryRun: false, help: false, nonInteractive: true, async: false }, + ); + + expect(download.authorization).toBe('Bearer k'); + }); }); describe('speech task get command', () => { diff --git a/test/files/download.test.ts b/test/files/download.test.ts index a4f1029e..b573fd01 100644 --- a/test/files/download.test.ts +++ b/test/files/download.test.ts @@ -108,4 +108,22 @@ describe('downloadFile', () => { expect(readFileSync(destPath, 'utf-8')).toBe('new'); }); + + it('passes request headers to the download', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'audio.mp3'); + const request = { authorization: null as string | null }; + + globalThis.fetch = (async (_input, init) => { + request.authorization = new Headers(init?.headers).get('Authorization'); + return new Response(new TextEncoder().encode('audio'), { status: 200 }); + }) as typeof fetch; + + await downloadFile('https://example.com/audio.mp3', destPath, { + quiet: true, + headers: { Authorization: 'Bearer test-key' }, + }); + + expect(request.authorization).toBe('Bearer test-key'); + }); }); diff --git a/test/sdk/speech-async.test.ts b/test/sdk/speech-async.test.ts index 5670f3a8..2d138b83 100644 --- a/test/sdk/speech-async.test.ts +++ b/test/sdk/speech-async.test.ts @@ -35,12 +35,37 @@ describe('MiniMaxSDK.speech.createAsync', () => { expect(result.task_id).toBe(95157322514444); expect(requestBody?.model).toBe('speech-2.8-hd'); expect(requestBody?.text).toBe('Hello world'); + expect((requestBody?.audio_setting as Record).audio_sample_rate).toBe(32000); + expect((requestBody?.audio_setting as Record).sample_rate).toBeUndefined(); expect(requestBody?.output_format).toBeUndefined(); }); - it('throws when text is missing', async () => { + it('accepts text_file_id for uploaded long text', async () => { + let requestBody: Record | undefined; + server = createMockServer({ + routes: { + '/v1/t2a_async_v2': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ task_id: 123, base_resp: { status_code: 0 } }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + await sdk.speech.createAsync({ text_file_id: 'file-123' }); + + expect(requestBody?.text_file_id).toBe('file-123'); + expect(requestBody?.text).toBeUndefined(); + }); + + it('throws when text and text_file_id are missing', async () => { const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: 'https://x' }); - await expect(sdk.speech.createAsync({} as SpeechAsyncRequest)).rejects.toThrow('text is required'); + await expect(sdk.speech.createAsync({} as SpeechAsyncRequest)).rejects.toThrow('text or text_file_id is required'); + }); + + it('rejects direct text over 50,000 characters', async () => { + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: 'https://x' }); + await expect(sdk.speech.createAsync({ text: 'x'.repeat(50_001) })).rejects.toThrow('text is limited to 50,000 characters'); }); }); @@ -99,10 +124,13 @@ describe('MiniMaxSDK.speech.downloadAsyncFile', () => { it('downloads the audio bytes to disk', async () => { server = createMockServer({ routes: { - '/v1/files/retrieve_content': () => new Response( - Buffer.from('hello audio'), - { headers: { 'Content-Type': 'application/octet-stream' } }, - ), + '/v1/files/retrieve_content': (req) => { + expect(req.headers.get('Authorization')).toBe('Bearer test-key'); + return new Response( + Buffer.from('hello audio'), + { headers: { 'Content-Type': 'application/octet-stream' } }, + ); + }, }, });