diff --git a/package.json b/package.json index 0147cff..7d6c2c6 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "@marswave/listenhub-sdk": "^0.0.15", + "@marswave/listenhub-sdk": "^0.0.16", "commander": "^14.0.3", "open": "^10.0.0", "ora": "^8.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bf9445..e430da5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@marswave/listenhub-sdk': - specifier: ^0.0.15 - version: 0.0.15 + specifier: ^0.0.16 + version: 0.0.16 commander: specifier: ^14.0.3 version: 14.0.3 @@ -57,8 +57,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@marswave/listenhub-sdk@0.0.15': - resolution: {integrity: sha512-lvu+yCK/gMTW4/J+ao50sxvcN+qDmQKRXACmey1toNThHXJK2YwAX2g337JL4txc+V2OKvZ31bLhfGNz/RpehQ==} + '@marswave/listenhub-sdk@0.0.16': + resolution: {integrity: sha512-RqFTDRH7n26Hl8ANyJHPO34vuOP8cto0pMl8zoYep6F9vhdD1uWSKIUjpjWbZXcKqYkDSCqtMtkDPT2VoKzw/w==} engines: {node: '>=20'} '@napi-rs/wasm-runtime@1.1.4': @@ -1118,7 +1118,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@marswave/listenhub-sdk@0.0.15': + '@marswave/listenhub-sdk@0.0.16': dependencies: ky: 1.14.3 diff --git a/source/openapi/_cli.ts b/source/openapi/_cli.ts index b25119f..ce81782 100644 --- a/source/openapi/_cli.ts +++ b/source/openapi/_cli.ts @@ -8,6 +8,7 @@ import {register as registerStorybook} from './storybook.js'; import {register as registerImage} from './image.js'; import {register as registerVideo} from './video.js'; import {register as registerMusic} from './music.js'; +import {register as registerListenHubVoice} from './listenhub-voice.js'; import {register as registerContent} from './content.js'; import {register as registerSubscription} from './subscription.js'; @@ -22,6 +23,7 @@ export function register(program: Command) { registerImage(openapi); registerVideo(openapi); registerMusic(openapi); + registerListenHubVoice(openapi); registerContent(openapi); registerSubscription(openapi); } diff --git a/source/openapi/listenhub-voice.ts b/source/openapi/listenhub-voice.ts new file mode 100644 index 0000000..96ccdb5 --- /dev/null +++ b/source/openapi/listenhub-voice.ts @@ -0,0 +1,237 @@ +import type {Command} from 'commander'; +import type { + OpenAPICreateListenHubVoiceParams, + OpenAPIListenHubVoiceTaskDetail, + OpenAPIListListenHubVoiceTasksParams, + OpenAPIListenHubVoiceTaskStatus, +} from '@marswave/listenhub-sdk'; +import {handleError, printDetail, printJson, printTable} from '../_shared/output.js'; +import {getOpenAPIClient} from './client.js'; +import {pollOpenAPI} from './polling.js'; + +// ListenHub Voice — end-to-end audio generation (text / reference voices / +// image → audio). Authenticates with an OpenAPI key (lh_sk_…) against the +// /openapi base. Async: create a task, then poll GET /v1/listenhub-voice/tasks/:id. +// Only the three publicly-covered endpoints are exposed (generate / tasks / task); +// /voices and /estimate-credits are intentionally not surfaced. + +type WaitOptions = {wait: boolean; timeout: string; json: boolean}; + +function printVoiceDetail(task: OpenAPIListenHubVoiceTaskDetail): void { + printDetail('ListenHub Voice task', [ + ['Task ID', task.id], + ['Status', task.status], + ['Model', task.model], + ['Audio URL', task.audioUrl ?? '-'], + ['Duration', task.audioDuration ? `${task.audioDuration}s` : '-'], + ['Credits charged', task.creditCharged], + ['Credits refunded', task.creditRefunded || undefined], + ['Error', task.errorMessage], + ]); +} + +async function emitTask(taskId: string, options: WaitOptions): Promise { + const client = await getOpenAPIClient(); + if (!options.wait) { + if (options.json) { + printJson({taskId}); + } else { + console.log(`✓ ListenHub Voice task submitted: ${taskId}`); + } + + return; + } + + const task = await pollOpenAPI({ + getStatus: async () => client.getListenHubVoiceTask(taskId), + isDone: (r) => r.status === 'success', + isFailed: (r) => r.status === 'failed', + getErrorMessage: (r) => r.errorMessage ?? 'ListenHub Voice generation failed', + options: {timeout: Number(options.timeout), label: 'Generating audio', json: options.json}, + }); + + if (options.json) { + printJson(task); + } else { + printVoiceDetail(task); + } +} + +export function register(openapi: Command) { + const voice = openapi + .command('listenhub-voice') + .description('ListenHub Voice end-to-end audio generation (text / reference / image → audio)'); + + voice + .command('generate') + .description('Generate end-to-end audio from a text script') + .requiredOption( + '--text ', + 'Script text (max 1400 chars). Use @音频1 / @音频2 to assign lines in multi-voice mode', + ) + .option( + '--voice ', + 'Voice(s), 1-3 items. Each: "speaker:" (official preset, single-voice only) or "reference:" (custom reference audio). Repeat or space-separate for multi-voice', + ) + .option( + '--image-url ', + 'Reference image URL for image→audio (mutually exclusive with --voice)', + ) + .option('--speech-rate ', 'Speech rate [-50, 100]') + .option('--loudness-rate ', 'Loudness rate [-50, 100]') + .option('--pitch-rate ', 'Pitch rate [-12, 12]') + .option('--format ', 'Output format: mp3 (default), wav, pcm, ogg_opus') + .option('--duration-hint ', 'Target duration hint [1, 110]') + .option('--watermark', 'Add audio watermark', false) + .option('--no-wait', 'Return immediately without polling') + .option('--timeout ', 'Polling timeout', '300') + .option('-j, --json', 'Output JSON', false) + .action( + async ( + options: WaitOptions & { + text: string; + voice?: string[]; + imageUrl?: string; + speechRate?: string; + loudnessRate?: string; + pitchRate?: string; + format?: string; + durationHint?: string; + watermark: boolean; + }, + ) => { + try { + if (options.voice && options.imageUrl) { + throw new Error('--voice and --image-url are mutually exclusive'); + } + + const voices = (options.voice ?? []).map((spec) => { + const idx = spec.indexOf(':'); + const kind = idx === -1 ? '' : spec.slice(0, idx); + const value = idx === -1 ? '' : spec.slice(idx + 1); + if (kind === 'speaker' && value) { + return {type: 'speaker' as const, id: value}; + } + + if (kind === 'reference' && value) { + return {type: 'reference' as const, url: value}; + } + + throw new Error( + `Invalid --voice "${spec}". Use "speaker:" or "reference:"`, + ); + }); + + const audioConfig: NonNullable = {}; + if (options.speechRate !== undefined) audioConfig.speechRate = Number(options.speechRate); + if (options.loudnessRate !== undefined) + audioConfig.loudnessRate = Number(options.loudnessRate); + if (options.pitchRate !== undefined) audioConfig.pitchRate = Number(options.pitchRate); + if (options.format) + audioConfig.format = options.format as NonNullable< + OpenAPICreateListenHubVoiceParams['audioConfig'] + >['format']; + + const params: OpenAPICreateListenHubVoiceParams = { + model: 'listenhub-voice-1.0', + text: options.text, + ...(voices.length > 0 && {voices}), + ...(options.imageUrl && {image: {url: options.imageUrl}}), + ...(Object.keys(audioConfig).length > 0 && {audioConfig}), + ...(options.durationHint !== undefined && {durationHint: Number(options.durationHint)}), + ...(options.watermark && {watermark: true}), + }; + + const client = await getOpenAPIClient(); + const {taskId} = await client.createListenHubVoice(params); + await emitTask(taskId, options); + } catch (error) { + handleError(error, options.json); + } + }, + ); + + voice + .command('tasks') + .description('List your ListenHub Voice tasks, newest first') + .option('--page ', 'Page number', '1') + .option('--page-size ', 'Page size (max 100)', '20') + .option( + '--status ', + 'Filter by status: pending, generating, uploading, success, failed', + ) + .option('--keyword ', 'Filter by keyword') + .option('-j, --json', 'Output JSON', false) + .action( + async (options: { + page: string; + pageSize: string; + status?: string; + keyword?: string; + json: boolean; + }) => { + try { + const params: OpenAPIListListenHubVoiceTasksParams = { + page: Number(options.page), + pageSize: Number(options.pageSize), + ...(options.status && {status: options.status as OpenAPIListenHubVoiceTaskStatus}), + ...(options.keyword && {keyword: options.keyword}), + }; + const client = await getOpenAPIClient(); + const result = await client.listListenHubVoiceTasks(params); + if (options.json) { + printJson(result); + return; + } + + printTable( + ['Task ID', 'Status', 'Duration', 'Credits', 'Created'], + result.items.map((t) => [ + t.id, + t.status, + t.audioDuration ? `${t.audioDuration}s` : '-', + String(t.creditCharged), + new Date(t.createdAt).toISOString(), + ]), + ); + console.log(`\nPage ${result.page} · ${result.items.length}/${result.total} tasks`); + } catch (error) { + handleError(error, options.json); + } + }, + ); + + voice + .command('task ') + .description('Get a ListenHub Voice task detail / poll until done') + .option('--wait', 'Poll until the task reaches a terminal state', false) + .option('--timeout ', 'Polling timeout', '300') + .option('-j, --json', 'Output JSON', false) + .action(async (taskId: string, options: {wait: boolean; timeout: string; json: boolean}) => { + try { + const client = await getOpenAPIClient(); + if (options.wait) { + const task = await pollOpenAPI({ + getStatus: async () => client.getListenHubVoiceTask(taskId), + isDone: (r) => r.status === 'success', + isFailed: (r) => r.status === 'failed', + getErrorMessage: (r) => r.errorMessage ?? 'ListenHub Voice generation failed', + options: { + timeout: Number(options.timeout), + label: 'Generating audio', + json: options.json, + }, + }); + if (options.json) printJson(task); + else printVoiceDetail(task); + return; + } + + const task = await client.getListenHubVoiceTask(taskId); + if (options.json) printJson(task); + else printVoiceDetail(task); + } catch (error) { + handleError(error, options.json); + } + }); +}