Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
14 changes: 14 additions & 0 deletions SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand Down
157 changes: 157 additions & 0 deletions src/commands/speech/async.ts
Original file line number Diff line number Diff line change
@@ -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 <text> [--wait] [--out <path>] [flags]',
options: [
{ flag: '--model <model>', description: 'Model ID (default: speech-2.8-hd)' },
{ flag: '--text <text>', description: 'Text to synthesize' },
{ flag: '--text-file <path>', description: 'Read text from file (use - for stdin)' },
{ flag: '--voice <id>', description: 'Voice ID (default: English_expressive_narrator)' },
{ flag: '--speed <n>', description: 'Speech speed multiplier', type: 'number' },
{ flag: '--volume <n>', description: 'Volume level', type: 'number' },
{ flag: '--pitch <n>', description: 'Pitch adjustment', type: 'number' },
{ flag: '--format <fmt>', description: `Audio format: ${formatList(T2A_FORMATS)} (default: mp3)` },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)', type: 'number' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)', type: 'number' },
{ flag: '--channels <n>', description: 'Audio channels (default: 1)', type: 'number' },
{ flag: '--language <code>', description: 'Language boost' },
{ flag: '--pronunciation <from/to>', description: 'Custom pronunciation (repeatable)', type: 'array' },
{ flag: '--wait', description: 'Poll until the task completes, then download the audio' },
{ flag: '--poll-interval <seconds>', description: 'Polling interval when waiting (default: 5)', type: 'number' },
{ flag: '--out <path>', 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<SpeechAsyncResponse>(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<SpeechAsyncQueryResponse>(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));
}
},
});
53 changes: 53 additions & 0 deletions src/commands/speech/task-get.ts
Original file line number Diff line number Diff line change
@@ -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 <id>',
options: [
{ flag: '--task-id <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 <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<SpeechAsyncQueryResponse>(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));
},
});
Loading
Loading