Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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