From a2a4fb393dae9d72660e06e793dea149db2e7d1d Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:09:40 +0800 Subject: [PATCH 1/2] Add speech voice clone and design commands --- src/client/endpoints.ts | 8 +++ src/commands/speech/clone.ts | 96 +++++++++++++++++++++++++++++ src/commands/speech/design.ts | 55 +++++++++++++++++ src/registry.ts | 4 ++ src/sdk/speech/index.ts | 66 +++++++++++++++++++- src/types/api.ts | 16 +++++ test/commands/aliases.test.ts | 5 ++ test/commands/speech/clone.test.ts | 92 +++++++++++++++++++++++++++ test/commands/speech/design.test.ts | 62 +++++++++++++++++++ test/sdk/speech.test.ts | 62 +++++++++++++++++++ 10 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 src/commands/speech/clone.ts create mode 100644 src/commands/speech/design.ts create mode 100644 test/commands/speech/clone.test.ts create mode 100644 test/commands/speech/design.test.ts 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', () => { From b52a59587aeaf600d8858651681da2522068835a Mon Sep 17 00:00:00 2001 From: Octopus Date: Tue, 25 Aug 2026 19:15:58 +0800 Subject: [PATCH 2/2] Align voice clone and design API contracts --- src/commands/speech/clone.ts | 24 ++++++++++++++--------- src/commands/speech/design.ts | 21 +++++++++++--------- src/sdk/speech/index.ts | 22 ++++++++++----------- src/types/api.ts | 30 ++++++++++++++++++++++++----- test/commands/speech/clone.test.ts | 11 +++++------ test/commands/speech/design.test.ts | 6 +++--- test/sdk/speech.test.ts | 21 ++++++++++---------- 7 files changed, 82 insertions(+), 53 deletions(-) diff --git a/src/commands/speech/clone.ts b/src/commands/speech/clone.ts index eb3097e7..802b212d 100644 --- a/src/commands/speech/clone.ts +++ b/src/commands/speech/clone.ts @@ -6,7 +6,7 @@ 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 type { FileUploadResponse, VoiceCloneRequest, VoiceCloneResponse } from '../../types/api'; import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import { basename, resolve } from 'path'; @@ -34,12 +34,13 @@ 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 ]', + usage: 'mmx speech clone --file-id --voice-id [--text ] [--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)' }, + { flag: '--text ', description: 'Optional preview text; enables model requirement' }, + { flag: '--model ', description: 'Clone model (required with --text)' }, ], examples: [ 'mmx file upload --file sample.wav --purpose voice_clone', @@ -57,12 +58,17 @@ export default defineCommand({ if (!fileId && !filePath) { throw new CLIError('--file-id or --file is required.', ExitCode.USAGE, 'mmx speech clone --file-id --voice-id '); } + if (fileId && (!Number.isSafeInteger(Number(fileId)) || Number(fileId) <= 0)) { + throw new CLIError('--file-id must be a positive integer.', ExitCode.USAGE); + } - const model = (flags.model as string) || DEFAULT_VOICE_CLONE_MODEL; + const text = flags.text as string | undefined; + const model = (flags.model as string | undefined) || (text ? DEFAULT_VOICE_CLONE_MODEL : undefined); const body: VoiceCloneRequest = { - file_id: fileId || '', + file_id: fileId ? Number(fileId) : 0, voice_id: voiceId, - model, + ...(text ? { text } : {}), + ...(model ? { model } : {}), }; const format = detectOutputFormat(config.output); @@ -77,17 +83,17 @@ export default defineCommand({ if (!fileId && filePath) { const upload = await uploadCloneAudio(config, filePath); fileId = upload.file.file_id; - body.file_id = fileId; + body.file_id = Number(fileId); } - const response = await requestJson(config, { + const response = await requestJson(config, { url: voiceCloneEndpoint(config.baseUrl), method: 'POST', body, }); if (config.quiet) { - process.stdout.write(response.voice_id + '\n'); + process.stdout.write((response.demo_audio || '') + '\n'); return; } diff --git a/src/commands/speech/design.ts b/src/commands/speech/design.ts index f0a6cad2..41e8e2a2 100644 --- a/src/commands/speech/design.ts +++ b/src/commands/speech/design.ts @@ -6,47 +6,50 @@ 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'; +import type { VoiceDesignRequest, VoiceDesignResponse } 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 ', + usage: 'mmx speech design --prompt --preview-text [--voice-id ]', options: [ { flag: '--prompt ', description: 'Voice design prompt', required: true }, - { flag: '--voice-id ', description: 'Voice ID to create', required: true }, + { flag: '--preview-text ', description: 'Text for the generated voice preview', required: true }, + { flag: '--voice-id ', description: 'Optional voice ID to create' }, ], examples: [ 'mmx file upload --file prompt.wav --purpose prompt_audio', - 'mmx speech design --prompt "Warm and clear narrator" --voice-id narrator_voice', + 'mmx speech design --prompt "Warm and clear narrator" --preview-text "Welcome to the show"', ], async run(config: Config, flags: GlobalFlags) { const prompt = flags.prompt as string | undefined; + const previewText = flags.previewText 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 '); + if (!previewText) { + throw new CLIError('--preview-text is required.', ExitCode.USAGE, 'mmx speech design --prompt --preview-text '); } const body: VoiceDesignRequest = { prompt, - voice_id: voiceId, + preview_text: previewText, + ...(voiceId ? { voice_id: voiceId } : {}), }; if (dryRun(config, body)) return; - const response = await requestJson(config, { + const response = await requestJson(config, { url: voiceDesignEndpoint(config.baseUrl), method: 'POST', body, }); if (config.quiet) { - process.stdout.write(response.voice_id + '\n'); + process.stdout.write((response.voice_id || '') + '\n'); return; } diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index e5c2a2aa..7bf30354 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { resolve, dirname, basename } from 'node:path'; import { Client } from "../client"; import { fileUploadEndpoint, speechEndpoint, voiceCloneEndpoint, voiceDesignEndpoint, voicesEndpoint } from "../../client/endpoints"; -import { FileUploadResponse, SpeechRequest, SpeechResponse, VoiceCloneRequest, VoiceDesignRequest, VoiceListResponse, VoiceResponse } from "../../types/api"; +import { FileUploadResponse, SpeechRequest, SpeechResponse, VoiceCloneRequest, VoiceCloneResponse, VoiceDesignRequest, VoiceDesignResponse, VoiceListResponse } from "../../types/api"; import { filterByLanguage } from "../../commands/speech/voices"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; @@ -82,33 +82,33 @@ export class SpeechSDK extends Client { return this.uploadVoiceAudio(filePath, 'prompt_audio'); } - async clone(request: VoiceCloneRequest): Promise { - if (!request.file_id) { - throw new SDKError('file_id is required', ExitCode.USAGE); + async clone(request: VoiceCloneRequest): Promise { + if (!Number.isSafeInteger(request.file_id) || request.file_id <= 0) { + throw new SDKError('file_id must be a positive integer', 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); + if (request.text && !request.model) { + throw new SDKError('model is required when text is provided', ExitCode.USAGE); } - return this.requestJson({ + return this.requestJson({ url: voiceCloneEndpoint(this.config.baseUrl), method: 'POST', body: request, }); } - async design(request: VoiceDesignRequest): Promise { + 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); + if (!request.preview_text) { + throw new SDKError('preview_text is required', ExitCode.USAGE); } - return this.requestJson({ + return this.requestJson({ url: voiceDesignEndpoint(this.config.baseUrl), method: 'POST', body: request, diff --git a/src/types/api.ts b/src/types/api.ts index 0b0d4a8d..2d0d4779 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -143,18 +143,38 @@ export interface VoiceListResponse { } export interface VoiceCloneRequest { - file_id: string; + file_id: number; voice_id: string; - model: string; + clone_prompt?: { + prompt_audio?: number; + prompt_text?: string; + }; + text?: string; + model?: string; + text_validation?: string; + accuracy?: number; + need_noise_reduction?: boolean; + need_volume_normalization?: boolean; + aigc_watermark?: boolean; } export interface VoiceDesignRequest { prompt: string; - voice_id: string; + preview_text: string; + voice_id?: string; } -export interface VoiceResponse { - voice_id: string; +export interface VoiceCloneResponse { + input_sensitive?: boolean; + input_sensitive_type?: number; + demo_audio?: string; + extra_info?: Record; + base_resp: BaseResp; +} + +export interface VoiceDesignResponse { + trial_audio?: string; + voice_id?: string; base_resp: BaseResp; } diff --git a/test/commands/speech/clone.test.ts b/test/commands/speech/clone.test.ts index 99ed392d..7e3508ce 100644 --- a/test/commands/speech/clone.test.ts +++ b/test/commands/speech/clone.test.ts @@ -58,16 +58,15 @@ describe('speech clone command', () => { const output = await captureStdout(async () => { await cloneCommand.execute(baseConfig, { ...baseFlags, - fileId: 'file-123', + fileId: '123', voiceId: 'my_voice', }); }); const parsed = JSON.parse(output); expect(parsed.request).toEqual({ - file_id: 'file-123', + file_id: 123, voice_id: 'my_voice', - model: 'speech-2.8-hd', }); }); @@ -84,9 +83,9 @@ describe('speech clone command', () => { 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', + file_id: 0, + 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 index d234cf0b..dc652122 100644 --- a/test/commands/speech/design.test.ts +++ b/test/commands/speech/design.test.ts @@ -34,7 +34,7 @@ describe('speech design command', () => { it('requires prompt', async () => { await expect( - designCommand.execute(baseConfig, { ...baseFlags, voiceId: 'designed_voice' }), + designCommand.execute(baseConfig, { ...baseFlags, previewText: 'Hello' }), ).rejects.toThrow('--prompt is required'); }); @@ -47,13 +47,13 @@ describe('speech design command', () => { await designCommand.execute(baseConfig, { ...baseFlags, prompt: 'Warm and clear narrator', - voiceId: 'designed_voice', + previewText: 'Welcome to the show', }); const parsed = JSON.parse(output); expect(parsed.request).toEqual({ prompt: 'Warm and clear narrator', - voice_id: 'designed_voice', + preview_text: 'Welcome to the show', }); } finally { console.log = originalLog; diff --git a/test/sdk/speech.test.ts b/test/sdk/speech.test.ts index d805d3ba..a900913a 100644 --- a/test/sdk/speech.test.ts +++ b/test/sdk/speech.test.ts @@ -75,12 +75,14 @@ describe('MiniMaxSDK.speech', () => { '/v1/voice_clone': async (req) => { const body = await req.json() as Record; expect(body).toEqual({ - file_id: 'file-123', + file_id: 123, voice_id: 'my_voice', - model: 'speech-2.8-hd', }); return jsonResponse({ - voice_id: 'my_voice', + input_sensitive: false, + input_sensitive_type: 0, + demo_audio: '', + extra_info: { audio_length: 1000 }, base_resp: { status_code: 0, status_msg: 'success' }, }); }, @@ -93,12 +95,11 @@ describe('MiniMaxSDK.speech', () => { }); const result = await sdk.speech.clone({ - file_id: 'file-123', + file_id: 123, voice_id: 'my_voice', - model: 'speech-2.8-hd', }); - expect(result.voice_id).toBe('my_voice'); + expect(result.input_sensitive).toBe(false); }); it('should design a voice successfully', async () => { @@ -108,10 +109,10 @@ describe('MiniMaxSDK.speech', () => { const body = await req.json() as Record; expect(body).toEqual({ prompt: 'Warm and clear narrator', - voice_id: 'designed_voice', + preview_text: 'Welcome to the show', }); return jsonResponse({ - voice_id: 'designed_voice', + trial_audio: 'hex-audio', base_resp: { status_code: 0, status_msg: 'success' }, }); }, @@ -125,10 +126,10 @@ describe('MiniMaxSDK.speech', () => { const result = await sdk.speech.design({ prompt: 'Warm and clear narrator', - voice_id: 'designed_voice', + preview_text: 'Welcome to the show', }); - expect(result.voice_id).toBe('designed_voice'); + expect(result.trial_audio).toBe('hex-audio'); }); });