diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index bde656df..f68514f6 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -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`; } diff --git a/src/commands/speech/clone.ts b/src/commands/speech/clone.ts new file mode 100644 index 00000000..eb3097e7 --- /dev/null +++ b/src/commands/speech/clone.ts @@ -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 { + 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(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 --voice-id [--model ]', + options: [ + { flag: '--file-id ', description: 'Uploaded clone audio file ID' }, + { flag: '--file ', description: 'Upload local clone audio before cloning' }, + { flag: '--voice-id ', description: 'Voice ID to create', required: true }, + { flag: '--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 --voice-id '); + } + if (!fileId && !filePath) { + throw new CLIError('--file-id or --file is required.', ExitCode.USAGE, 'mmx speech clone --file-id --voice-id '); + } + + const model = (flags.model as string) || DEFAULT_VOICE_CLONE_MODEL; + const body: VoiceCloneRequest = { + file_id: fileId || '', + 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(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'); + }, +}); diff --git a/src/commands/speech/design.ts b/src/commands/speech/design.ts new file mode 100644 index 00000000..f0a6cad2 --- /dev/null +++ b/src/commands/speech/design.ts @@ -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 --voice-id ', + options: [ + { flag: '--prompt ', description: 'Voice design prompt', required: true }, + { flag: '--voice-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 --voice-id '); + } + if (!voiceId) { + throw new CLIError('--voice-id is required.', ExitCode.USAGE, 'mmx speech design --prompt --voice-id '); + } + + const body: VoiceDesignRequest = { + prompt, + voice_id: voiceId, + }; + + if (dryRun(config, body)) return; + + const response = await requestJson(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'); + }, +}); diff --git a/src/registry.ts b/src/registry.ts index d34b6b10..dc020af4 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -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'; @@ -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, diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index 5c4844cf..e5c2a2aa 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -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"; @@ -73,6 +74,47 @@ export class SpeechSDK extends Client { return voices; } + async uploadCloneAudio(filePath: string): Promise { + return this.uploadVoiceAudio(filePath, 'voice_clone'); + } + + async uploadPromptAudio(filePath: string): Promise { + return this.uploadVoiceAudio(filePath, 'prompt_audio'); + } + + async clone(request: VoiceCloneRequest): Promise { + 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({ + url: voiceCloneEndpoint(this.config.baseUrl), + method: 'POST', + body: request, + }); + } + + async design(request: VoiceDesignRequest): Promise { + 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({ + 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 @@ -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 { + 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({ + url: fileUploadEndpoint(this.config.baseUrl), + method: 'POST', + body: formData, + }); + } } diff --git a/src/types/api.ts b/src/types/api.ts index badf5073..0b0d4a8d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 { diff --git a/test/commands/aliases.test.ts b/test/commands/aliases.test.ts index 93416a2b..0e491f35 100644 --- a/test/commands/aliases.test.ts +++ b/test/commands/aliases.test.ts @@ -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', () => { diff --git a/test/commands/speech/clone.test.ts b/test/commands/speech/clone.test.ts new file mode 100644 index 00000000..99ed392d --- /dev/null +++ b/test/commands/speech/clone.test.ts @@ -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): Promise { + 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: '', + voice_id: 'my_voice', + model: 'speech-2.6-hd', + }); + }); +}); diff --git a/test/commands/speech/design.test.ts b/test/commands/speech/design.test.ts new file mode 100644 index 00000000..d234cf0b --- /dev/null +++ b/test/commands/speech/design.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'bun:test'; +import { default as designCommand } from '../../../src/commands/speech/design'; + +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, +}; + +describe('speech design command', () => { + it('has correct name', () => { + expect(designCommand.name).toBe('speech design'); + }); + + it('requires prompt', async () => { + await expect( + designCommand.execute(baseConfig, { ...baseFlags, voiceId: 'designed_voice' }), + ).rejects.toThrow('--prompt is required'); + }); + + it('builds design request', async () => { + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await designCommand.execute(baseConfig, { + ...baseFlags, + prompt: 'Warm and clear narrator', + voiceId: 'designed_voice', + }); + + const parsed = JSON.parse(output); + expect(parsed.request).toEqual({ + prompt: 'Warm and clear narrator', + voice_id: 'designed_voice', + }); + } finally { + console.log = originalLog; + } + }); +}); diff --git a/test/sdk/speech.test.ts b/test/sdk/speech.test.ts index 7f8a886e..d805d3ba 100644 --- a/test/sdk/speech.test.ts +++ b/test/sdk/speech.test.ts @@ -68,6 +68,68 @@ describe('MiniMaxSDK.speech', () => { expect(voices).toHaveLength(1); expect(voices[0].voice_id).toBe('voice-1'); }); + + it('should clone a voice successfully', async () => { + server = createMockServer({ + routes: { + '/v1/voice_clone': async (req) => { + const body = await req.json() as Record; + expect(body).toEqual({ + file_id: 'file-123', + voice_id: 'my_voice', + model: 'speech-2.8-hd', + }); + return jsonResponse({ + voice_id: 'my_voice', + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.speech.clone({ + file_id: 'file-123', + voice_id: 'my_voice', + model: 'speech-2.8-hd', + }); + + expect(result.voice_id).toBe('my_voice'); + }); + + it('should design a voice successfully', async () => { + server = createMockServer({ + routes: { + '/v1/voice_design': async (req) => { + const body = await req.json() as Record; + expect(body).toEqual({ + prompt: 'Warm and clear narrator', + voice_id: 'designed_voice', + }); + return jsonResponse({ + voice_id: 'designed_voice', + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.speech.design({ + prompt: 'Warm and clear narrator', + voice_id: 'designed_voice', + }); + + expect(result.voice_id).toBe('designed_voice'); + }); }); describe('SpeechSDK.save', () => {