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] 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'); + }); +});