Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ listenhub music generate --prompt "Chill lo-fi beats" --style "lo-fi" --title "S
# Text-to-speech
listenhub tts create --text "Hello, world" --lang en

# Text-to-speech at 1.25x generation speed (--speed accepts 0.5-2.0, max two decimals)
listenhub tts create --text "Hello, world" --lang en --speed 1.25

# Generate an AI image
listenhub image create --prompt "a dragon in watercolor style" --reference ./sketch.png
```
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ listenhub music generate --prompt "轻松的lo-fi节拍" --style "lo-fi" --title
# 语音合成
listenhub tts create --text "你好世界" --lang zh

# 以 1.25 倍语速生成(--speed 取值 0.5-2.0,最多两位小数)
listenhub tts create --text "你好世界" --lang zh --speed 1.25

# AI 生图
listenhub image create --prompt "水彩风格的小龙" --reference ./sketch.png
```
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@marswave/listenhub-cli",
"version": "0.0.18",
"version": "0.0.19",
"description": "Command-line interface for ListenHub",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -28,7 +28,7 @@
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@marswave/listenhub-sdk": "^0.0.20",
"@marswave/listenhub-sdk": "^0.0.21",
"commander": "^14.0.3",
"open": "^10.0.0",
"ora": "^8.0.0"
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions source/_shared/speed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {InvalidArgumentError} from 'commander';

/** Generation speed range shared with the API and the TTS engine. */
export const SPEED_MIN = 0.5;
export const SPEED_MAX = 2;
export const SPEED_DEFAULT = 1;

export const SPEED_FLAG_DESCRIPTION = `Generation speed multiplier ${SPEED_MIN}-${SPEED_MAX} (max two decimals, default ${SPEED_DEFAULT})`;

/**
* Commander option parser for `--speed`. Rejects out-of-range, over-precise and
* non-numeric input instead of silently correcting it, so the CLI never sends a
* speed the caller did not ask for.
*/
export function parseSpeed(value: string): number {
// Number('') and Number(' ') are 0, which would otherwise surface as a range error.
const speed = value.trim() === '' ? Number.NaN : Number(value);
if (!Number.isFinite(speed)) {
throw new InvalidArgumentError('--speed must be a number');
}

if (speed < SPEED_MIN || speed > SPEED_MAX) {
throw new InvalidArgumentError(`--speed must be between ${SPEED_MIN} and ${SPEED_MAX}`);
}

if (Math.round(speed * 100) !== speed * 100) {
throw new InvalidArgumentError('--speed accepts at most two decimals');
}

return speed;
}
7 changes: 7 additions & 0 deletions source/openapi/flow-speech.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Command} from 'commander';
import type {OpenAPIFlowSpeechDetail} from '@marswave/listenhub-sdk';
import {handleError, printJson, printDetail} from '../_shared/output.js';
import {SPEED_FLAG_DESCRIPTION, parseSpeed} from '../_shared/speed.js';
import {getOpenAPIClient} from './client.js';
import {pollOpenAPI} from './polling.js';

Expand All @@ -14,6 +15,7 @@ type CreateOptions = {
speakerId: string[];
mode: string;
lang?: string;
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand All @@ -27,6 +29,7 @@ type TtsOptions = {
script: string[];
speakerId: string[];
title?: string;
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand Down Expand Up @@ -64,6 +67,7 @@ export function register(openapi: Command) {
)
.option('--mode <mode>', 'Generation mode: smart, direct', 'smart')
.option('--lang <lang>', 'Language code')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Do not wait for completion')
.option('--timeout <seconds>', 'Polling timeout in seconds', '300')
.option('-j, --json', 'Output JSON', false)
Expand Down Expand Up @@ -93,6 +97,7 @@ export function register(openapi: Command) {
speakers,
mode: options.mode as 'smart' | 'direct',
language: options.lang,
speed: options.speed,
});

if (!options.wait) {
Expand Down Expand Up @@ -162,6 +167,7 @@ export function register(openapi: Command) {
[] as string[],
)
.option('--title <title>', 'Episode title')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Do not wait for completion')
.option('--timeout <seconds>', 'Polling timeout in seconds', '300')
.option('-j, --json', 'Output JSON', false)
Expand All @@ -187,6 +193,7 @@ export function register(openapi: Command) {
const {episodeId} = await client.createFlowSpeechTTS({
scripts,
title: options.title,
speed: options.speed,
});

if (!options.wait) {
Expand Down
11 changes: 10 additions & 1 deletion source/openapi/podcast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Command} from 'commander';
import type {OpenAPIPodcastDetail} from '@marswave/listenhub-sdk';
import {handleError, printJson, printDetail} from '../_shared/output.js';
import {SPEED_FLAG_DESCRIPTION, parseSpeed} from '../_shared/speed.js';
import {getOpenAPIClient} from './client.js';
import {pollOpenAPI} from './polling.js';

Expand All @@ -15,6 +16,7 @@ type CreateOptions = {
speakerId: string[];
mode?: string;
lang?: string;
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand All @@ -36,6 +38,7 @@ type TextContentOptions = {
};

type GenerateAudioOptions = {
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand Down Expand Up @@ -75,6 +78,7 @@ export function register(openapi: Command) {
)
.option('--mode <mode>', 'Generation mode')
.option('--lang <lang>', 'Language code')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Do not wait for completion')
.option('--timeout <seconds>', 'Polling timeout in seconds', '300')
.option('-j, --json', 'Output JSON', false)
Expand All @@ -100,6 +104,7 @@ export function register(openapi: Command) {
speakers,
mode: options.mode,
language: options.lang,
speed: options.speed,
});

if (!options.wait) {
Expand Down Expand Up @@ -234,13 +239,17 @@ export function register(openapi: Command) {
podcast
.command('generate-audio <episodeId>')
.description('Generate audio for an existing podcast text episode')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Do not wait for completion')
.option('--timeout <seconds>', 'Polling timeout in seconds', '300')
.option('-j, --json', 'Output JSON', false)
.action(async (episodeId: string, options: GenerateAudioOptions) => {
try {
const client = await getOpenAPIClient();
await client.generatePodcastAudio(episodeId);
await client.generatePodcastAudio(
episodeId,
options.speed === undefined ? undefined : {speed: options.speed},
);

if (!options.wait) {
if (options.json) {
Expand Down
9 changes: 9 additions & 0 deletions source/openapi/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
import type {Command} from 'commander';
import type {OpenAPIClient} from '@marswave/listenhub-sdk';
import {handleError, printJson} from '../_shared/output.js';
import {SPEED_FLAG_DESCRIPTION, parseSpeed} from '../_shared/speed.js';
import {getOpenAPIClient} from './client.js';

type TtsOptions = {
text: string;
voice: string;
output: string;
format: string;
speed?: number;
};

type SpeechOptions = {
script: string;
speakerId: string;
speed?: number;
json: boolean;
};

Expand All @@ -40,6 +43,7 @@ async function runTts(client: OpenAPIClient, options: TtsOptions): Promise<void>
input: options.text,
voice: options.voice,
response_format: options.format as 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' | undefined,
speed: options.speed,
});
await saveStreamingAudio(response, options.output);
}
Expand All @@ -49,13 +53,15 @@ async function runAudioSpeech(client: OpenAPIClient, options: TtsOptions): Promi
input: options.text,
voice: options.voice,
response_format: options.format as 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' | undefined,
speed: options.speed,
});
await saveStreamingAudio(response, options.output);
}

async function runSpeech(client: OpenAPIClient, options: SpeechOptions): Promise<void> {
const result = await client.speech({
scripts: [{content: options.script, speakerId: options.speakerId}],
speed: options.speed,
});

if (options.json) {
Expand Down Expand Up @@ -86,6 +92,7 @@ export function register(openapi: Command) {
.requiredOption('--voice <speakerId>', 'Speaker ID')
.requiredOption('--output <file>', 'Output file path')
.option('--format <format>', 'Audio format: mp3, opus, aac, flac, wav, pcm', 'mp3')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.action(async (options: TtsOptions) => {
try {
const client = await getOpenAPIClient();
Expand All @@ -102,6 +109,7 @@ export function register(openapi: Command) {
.requiredOption('--voice <speakerId>', 'Speaker ID')
.requiredOption('--output <file>', 'Output file path')
.option('--format <format>', 'Audio format: mp3, opus, aac, flac, wav, pcm', 'mp3')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.action(async (options: TtsOptions) => {
try {
const client = await getOpenAPIClient();
Expand All @@ -116,6 +124,7 @@ export function register(openapi: Command) {
.description('Create speech (returns audio URL)')
.requiredOption('--script <content>', 'Script text')
.requiredOption('--speaker-id <id>', 'Speaker ID')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('-j, --json', 'Output JSON', false)
.action(async (options: SpeechOptions) => {
try {
Expand Down
2 changes: 2 additions & 0 deletions source/podcast/_cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Command} from 'commander';
import {getClient} from '../_shared/client.js';
import {handleError} from '../_shared/output.js';
import {SPEED_FLAG_DESCRIPTION, parseSpeed} from '../_shared/speed.js';
import {
type PodcastCreateOptions,
type PodcastListOptions,
Expand All @@ -25,6 +26,7 @@ export function register(program: Command) {
.option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)')
.option('--speaker <name>', 'Speaker name (repeatable)', collect, [])
.option('--speaker-id <id>', 'Speaker inner ID (repeatable)', collect, [])
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Return immediately without polling')
.option('--timeout <seconds>', 'Polling timeout', Number, 300)
.option('-j, --json', 'Output JSON', false)
Expand Down
2 changes: 2 additions & 0 deletions source/podcast/podcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type PodcastCreateOptions = {
lang?: Language;
speaker?: string[];
speakerId?: string[];
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand All @@ -39,6 +40,7 @@ export async function createPodcast(
speakers,
language: lang,
},
speed: options.speed,
});

if (!options.wait) {
Expand Down
2 changes: 2 additions & 0 deletions source/tts/_cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Command} from 'commander';
import {getClient} from '../_shared/client.js';
import {handleError} from '../_shared/output.js';
import {SPEED_FLAG_DESCRIPTION, parseSpeed} from '../_shared/speed.js';
import {type TtsCreateOptions, type TtsListOptions, createTts, listTts} from './tts.js';

export function register(program: Command) {
Expand All @@ -16,6 +17,7 @@ export function register(program: Command) {
.option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)')
.option('--speaker <name>', 'Speaker name')
.option('--speaker-id <id>', 'Speaker inner ID')
.option('--speed <multiplier>', SPEED_FLAG_DESCRIPTION, parseSpeed)
.option('--no-wait', 'Return immediately without polling')
.option('--timeout <seconds>', 'Polling timeout', Number, 300)
.option('-j, --json', 'Output JSON', false)
Expand Down
2 changes: 2 additions & 0 deletions source/tts/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type TtsCreateOptions = {
lang?: Language;
speaker?: string;
speakerId?: string;
speed?: number;
wait: boolean;
timeout: number;
json: boolean;
Expand All @@ -39,6 +40,7 @@ export async function createTts(client: ListenHubClient, options: TtsCreateOptio
speakers,
language: lang,
},
speed: options.speed,
});

if (!options.wait) {
Expand Down
33 changes: 33 additions & 0 deletions tests/speed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {describe, expect, it} from 'vitest';
import {SPEED_MAX, SPEED_MIN, parseSpeed} from '../source/_shared/speed.js';

describe('parseSpeed', () => {
it('accepts the shortcut values and the range boundaries', () => {
for (const value of ['0.5', '0.75', '1', '1.25', '1.5', '2']) {
expect(parseSpeed(value)).toBe(Number(value));
}

expect(parseSpeed(String(SPEED_MIN))).toBe(SPEED_MIN);
expect(parseSpeed(String(SPEED_MAX))).toBe(SPEED_MAX);
});

it('accepts values that are not shortcut values', () => {
expect(parseSpeed('0.85')).toBe(0.85);
expect(parseSpeed('1.35')).toBe(1.35);
});

it('rejects out-of-range values instead of clamping', () => {
expect(() => parseSpeed('0.49')).toThrow(/between 0.5 and 2/);
expect(() => parseSpeed('2.01')).toThrow(/between 0.5 and 2/);
expect(() => parseSpeed('0')).toThrow(/between 0.5 and 2/);
});

it('rejects more than two decimals instead of rounding', () => {
expect(() => parseSpeed('1.234')).toThrow(/at most two decimals/);
});

it('rejects non-numeric input', () => {
expect(() => parseSpeed('fast')).toThrow(/must be a number/);
expect(() => parseSpeed('')).toThrow(/must be a number/);
});
});
Loading