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
8 changes: 8 additions & 0 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export function voicesEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/get_voice`;
}

export function voiceCloneEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/voice_clone`;
}

export function voiceDesignEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/voice_design`;
}

export function imageEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/image_generation`;
}
Expand Down
96 changes: 96 additions & 0 deletions src/commands/speech/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { defineCommand } from '../../command';
import { requestJson } from '../../client/http';
import { fileUploadEndpoint, voiceCloneEndpoint } from '../../client/endpoints';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { detectOutputFormat, formatOutput } from '../../output/formatter';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { FileUploadResponse, VoiceCloneRequest, VoiceResponse } from '../../types/api';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { basename, resolve } from 'path';

const DEFAULT_VOICE_CLONE_MODEL = 'speech-2.8-hd';

async function uploadCloneAudio(config: Config, filePath: string): Promise<FileUploadResponse> {
const fullPath = resolve(filePath);
if (!existsSync(fullPath)) {
throw new CLIError(`File not found: ${fullPath}`, ExitCode.USAGE);
}

const formData = new FormData();
formData.append('file', new Blob([await readFile(fullPath)]), basename(fullPath));
formData.append('purpose', 'voice_clone');

return requestJson<FileUploadResponse>(config, {
url: fileUploadEndpoint(config.baseUrl),
method: 'POST',
body: formData,
});
}

export default defineCommand({
name: 'speech clone',
description: 'Clone a voice from uploaded audio',
apiDocs: '/docs/api-reference/voice-cloning-clone',
usage: 'mmx speech clone --file-id <id> --voice-id <id> [--model <model>]',
options: [
{ flag: '--file-id <id>', description: 'Uploaded clone audio file ID' },
{ flag: '--file <path>', description: 'Upload local clone audio before cloning' },
{ flag: '--voice-id <id>', description: 'Voice ID to create', required: true },
{ flag: '--model <model>', description: 'Clone model (default: speech-2.8-hd)' },
],
examples: [
'mmx file upload --file sample.wav --purpose voice_clone',
'mmx speech clone --file-id 123 --voice-id my_voice',
'mmx speech clone --file sample.wav --voice-id my_voice --model speech-2.6-hd',
],
async run(config: Config, flags: GlobalFlags) {
const voiceId = flags.voiceId as string | undefined;
const filePath = flags.file as string | undefined;
let fileId = flags.fileId as string | undefined;

if (!voiceId) {
throw new CLIError('--voice-id is required.', ExitCode.USAGE, 'mmx speech clone --file-id <id> --voice-id <id>');
}
if (!fileId && !filePath) {
throw new CLIError('--file-id or --file is required.', ExitCode.USAGE, 'mmx speech clone --file-id <id> --voice-id <id>');
}

const model = (flags.model as string) || DEFAULT_VOICE_CLONE_MODEL;
const body: VoiceCloneRequest = {
file_id: fileId || '<uploaded-file-id>',
voice_id: voiceId,
model,
};
const format = detectOutputFormat(config.output);

if (config.dryRun) {
const request = filePath
? { upload: { file: resolve(filePath), purpose: 'voice_clone' }, clone: body }
: body;
process.stdout.write(formatOutput({ request }, format) + '\n');
return;
}

if (!fileId && filePath) {
const upload = await uploadCloneAudio(config, filePath);
fileId = upload.file.file_id;
body.file_id = fileId;
}

const response = await requestJson<VoiceResponse>(config, {
url: voiceCloneEndpoint(config.baseUrl),
method: 'POST',
body,
});

if (config.quiet) {
process.stdout.write(response.voice_id + '\n');
return;
}

process.stdout.write(formatOutput(response, format) + '\n');
},
});
55 changes: 55 additions & 0 deletions src/commands/speech/design.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { defineCommand } from '../../command';
import { requestJson } from '../../client/http';
import { voiceDesignEndpoint } from '../../client/endpoints';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { detectOutputFormat, dryRun, formatOutput } from '../../output/formatter';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { VoiceDesignRequest, VoiceResponse } from '../../types/api';

export default defineCommand({
name: 'speech design',
description: 'Design a voice from a prompt',
apiDocs: '/docs/api-reference/voice-design-design',
usage: 'mmx speech design --prompt <text> --voice-id <id>',
options: [
{ flag: '--prompt <text>', description: 'Voice design prompt', required: true },
{ flag: '--voice-id <id>', description: 'Voice ID to create', required: true },
],
examples: [
'mmx file upload --file prompt.wav --purpose prompt_audio',
'mmx speech design --prompt "Warm and clear narrator" --voice-id narrator_voice',
],
async run(config: Config, flags: GlobalFlags) {
const prompt = flags.prompt as string | undefined;
const voiceId = flags.voiceId as string | undefined;

if (!prompt) {
throw new CLIError('--prompt is required.', ExitCode.USAGE, 'mmx speech design --prompt <text> --voice-id <id>');
}
if (!voiceId) {
throw new CLIError('--voice-id is required.', ExitCode.USAGE, 'mmx speech design --prompt <text> --voice-id <id>');
}

const body: VoiceDesignRequest = {
prompt,
voice_id: voiceId,
};

if (dryRun(config, body)) return;

const response = await requestJson<VoiceResponse>(config, {
url: voiceDesignEndpoint(config.baseUrl),
method: 'POST',
body,
});

if (config.quiet) {
process.stdout.write(response.voice_id + '\n');
return;
}

process.stdout.write(formatOutput(response, detectOutputFormat(config.output)) + '\n');
},
});
4 changes: 4 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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 speechClone from './commands/speech/clone';
import speechDesign from './commands/speech/design';
import imageGenerate from './commands/image/generate';
import videoGenerate from './commands/video/generate';
import videoTaskGet from './commands/video/task-get';
Expand Down Expand Up @@ -290,6 +292,8 @@ export const registry = new CommandRegistry({
'speech synthesize': speechSynthesize,
'speech generate': speechSynthesize,
'speech voices': speechVoices,
'speech clone': speechClone,
'speech design': speechDesign,
'image generate': imageGenerate,
'video generate': videoGenerate,
'video task get': videoTaskGet,
Expand Down
66 changes: 63 additions & 3 deletions src/sdk/speech/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { readFile } from 'node:fs/promises';
import { resolve, dirname, basename } from 'node:path';
import { Client } from "../client";
import { speechEndpoint, voicesEndpoint } from "../../client/endpoints";
import { SpeechRequest, SpeechResponse, VoiceListResponse } from "../../types/api";
import { fileUploadEndpoint, speechEndpoint, voiceCloneEndpoint, voiceDesignEndpoint, voicesEndpoint } from "../../client/endpoints";
import { FileUploadResponse, SpeechRequest, SpeechResponse, VoiceCloneRequest, VoiceDesignRequest, VoiceListResponse, VoiceResponse } from "../../types/api";
import { filterByLanguage } from "../../commands/speech/voices";
import { SDKError } from "../../errors/base";
import { ExitCode } from "../../errors/codes";
Expand Down Expand Up @@ -73,6 +74,47 @@ export class SpeechSDK extends Client {
return voices;
}

async uploadCloneAudio(filePath: string): Promise<FileUploadResponse> {
return this.uploadVoiceAudio(filePath, 'voice_clone');
}

async uploadPromptAudio(filePath: string): Promise<FileUploadResponse> {
return this.uploadVoiceAudio(filePath, 'prompt_audio');
}

async clone(request: VoiceCloneRequest): Promise<VoiceResponse> {
if (!request.file_id) {
throw new SDKError('file_id is required', ExitCode.USAGE);
}
if (!request.voice_id) {
throw new SDKError('voice_id is required', ExitCode.USAGE);
}
if (!request.model) {
throw new SDKError('model is required', ExitCode.USAGE);
}

return this.requestJson<VoiceResponse>({
url: voiceCloneEndpoint(this.config.baseUrl),
method: 'POST',
body: request,
});
}

async design(request: VoiceDesignRequest): Promise<VoiceResponse> {
if (!request.prompt) {
throw new SDKError('prompt is required', ExitCode.USAGE);
}
if (!request.voice_id) {
throw new SDKError('voice_id is required', ExitCode.USAGE);
}

return this.requestJson<VoiceResponse>({
url: voiceDesignEndpoint(this.config.baseUrl),
method: 'POST',
body: request,
});
}

/**
* Save synthesized speech audio to a file. Decodes the hex-encoded audio
* from the API response and writes it to disk. Creates intermediate
Expand Down Expand Up @@ -124,4 +166,22 @@ export class SpeechSDK extends Client {
output_format: 'hex',
}, params) as SpeechRequest;
}

private async uploadVoiceAudio(filePath: string, purpose: 'voice_clone' | 'prompt_audio'): Promise<FileUploadResponse> {
const fullPath = resolve(filePath);
if (!existsSync(fullPath)) {
throw new SDKError(`File not found: ${fullPath}`, ExitCode.USAGE);
}

const fileData = await readFile(fullPath);
const formData = new FormData();
formData.append('file', new Blob([fileData]), basename(fullPath));
formData.append('purpose', purpose);

return this.requestJson<FileUploadResponse>({
url: fileUploadEndpoint(this.config.baseUrl),
method: 'POST',
body: formData,
});
}
}
16 changes: 16 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ export interface VoiceListResponse {
base_resp: BaseResp;
}

export interface VoiceCloneRequest {
file_id: string;
voice_id: string;
model: string;
}

export interface VoiceDesignRequest {
prompt: string;
voice_id: string;
}

export interface VoiceResponse {
voice_id: string;
base_resp: BaseResp;
}

// ---- Image ----

export interface ImageRequest {
Expand Down
5 changes: 5 additions & 0 deletions test/commands/aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ describe('command aliases', () => {
expect(registry.resolve(['file', 'list']).command.name).toBe('file list');
expect(registry.resolve(['file', 'delete']).command.name).toBe('file delete');
});

it('resolves speech voice commands', () => {
expect(registry.resolve(['speech', 'clone']).command.name).toBe('speech clone');
expect(registry.resolve(['speech', 'design']).command.name).toBe('speech design');
});
});

describe('text chat --prompt alias', () => {
Expand Down
92 changes: 92 additions & 0 deletions test/commands/speech/clone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'bun:test';
import { default as cloneCommand } from '../../../src/commands/speech/clone';

const baseConfig = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: 'https://api.mmx.io',
output: 'json' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: true,
nonInteractive: true,
async: false,
};

const baseFlags = {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: true,
help: false,
nonInteractive: true,
async: false,
};

async function captureStdout(fn: () => Promise<void>): Promise<string> {
const originalWrite = process.stdout.write;
let output = '';
process.stdout.write = ((chunk: string | Uint8Array) => {
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
return true;
}) as typeof process.stdout.write;

try {
await fn();
return output;
} finally {
process.stdout.write = originalWrite;
}
}

describe('speech clone command', () => {
it('has correct name', () => {
expect(cloneCommand.name).toBe('speech clone');
});

it('requires clone input audio', async () => {
await expect(
cloneCommand.execute(baseConfig, { ...baseFlags, voiceId: 'my_voice' }),
).rejects.toThrow('--file-id or --file is required');
});

it('builds clone request with the default HD model', async () => {
const output = await captureStdout(async () => {
await cloneCommand.execute(baseConfig, {
...baseFlags,
fileId: 'file-123',
voiceId: 'my_voice',
});
});

const parsed = JSON.parse(output);
expect(parsed.request).toEqual({
file_id: 'file-123',
voice_id: 'my_voice',
model: 'speech-2.8-hd',
});
});

it('includes voice_clone upload purpose when a local file is used', async () => {
const output = await captureStdout(async () => {
await cloneCommand.execute(baseConfig, {
...baseFlags,
file: 'sample.wav',
voiceId: 'my_voice',
model: 'speech-2.6-hd',
});
});

const parsed = JSON.parse(output);
expect(parsed.request.upload.purpose).toBe('voice_clone');
expect(parsed.request.clone).toEqual({
file_id: '<uploaded-file-id>',
voice_id: 'my_voice',
model: 'speech-2.6-hd',
});
});
});
Loading