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
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