Skip to content

Commit 96e4b92

Browse files
committed
feat: add audio format validation aligned with latest T2A API
Separate format sets per API domain (T2A supports 7 formats including pcmu_raw, pcmu_wav, opus; Music supports mp3, wav, pcm, flac) with streaming constraint enforcement for T2A wav.
1 parent e24422b commit 96e4b92

7 files changed

Lines changed: 181 additions & 3 deletions

File tree

src/commands/music/cover.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { request, requestJson } from '../../client/http';
66
import { musicEndpoint } from '../../client/endpoints';
77
import { formatOutput, detectOutputFormat } from '../../output/formatter';
88
import { saveAudioOutput } from '../../output/audio';
9+
import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats';
910
import type { Config } from '../../config/schema';
1011
import type { GlobalFlags } from '../../types/flags';
1112
import type { MusicRequest, MusicResponse } from '../../types/api';
@@ -24,7 +25,7 @@ export default defineCommand({
2425
{ flag: '--lyrics <text>', description: 'Cover lyrics. If omitted, extracted from reference audio via ASR.' },
2526
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin)' },
2627
{ flag: '--seed <number>', description: 'Random seed 0–1000000 for reproducible results', type: 'number' },
27-
{ flag: '--format <fmt>', description: 'Audio format: mp3, wav, pcm (default: mp3)' },
28+
{ flag: '--format <fmt>', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` },
2829
{ flag: '--sample-rate <hz>', description: 'Sample rate: 16000, 24000, 32000, 44100 (default: 44100)', type: 'number' },
2930
{ flag: '--bitrate <bps>', description: 'Bitrate: 32000, 64000, 128000, 256000 (default: 256000)', type: 'number' },
3031
{ flag: '--channel <n>', description: 'Channels: 1 (mono) or 2 (stereo, default)', type: 'number' },
@@ -65,6 +66,7 @@ export default defineCommand({
6566

6667
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
6768
const ext = (flags.format as string) || 'mp3';
69+
validateAudioFormat(ext, MUSIC_FORMATS);
6870
const outPath = (flags.out as string | undefined) ?? `cover_${ts}.${ext}`;
6971
const format = detectOutputFormat(config.output);
7072

src/commands/music/generate.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { musicEndpoint } from '../../client/endpoints';
66
import { formatOutput, detectOutputFormat } from '../../output/formatter';
77
import { saveAudioOutput } from '../../output/audio';
88
import { readTextFromPathOrStdin } from '../../utils/fs';
9+
import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats';
910
import type { Config } from '../../config/schema';
1011
import type { GlobalFlags } from '../../types/flags';
1112
import type { MusicRequest, MusicResponse } from '../../types/api';
@@ -37,7 +38,7 @@ export default defineCommand({
3738
{ flag: '--model <model>', description: 'Model: music-2.6 (recommended), music-2.6-free (default, unlimited), music-2.5+, or music-2.5.' },
3839
{ flag: '--output-format <fmt>', description: 'Return format: hex (default, saved to file) or url (24h expiry, download promptly). When --stream, only hex.' },
3940
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' },
40-
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
41+
{ flag: '--format <fmt>', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` },
4142
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)', type: 'number' },
4243
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)', type: 'number' },
4344
{ flag: '--stream', description: 'Stream raw audio to stdout' },
@@ -121,6 +122,7 @@ export default defineCommand({
121122

122123
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
123124
const ext = (flags.format as string) || 'mp3';
125+
validateAudioFormat(ext, MUSIC_FORMATS);
124126
const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`;
125127
const format = detectOutputFormat(config.output);
126128

src/commands/speech/synthesize.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { detectOutputFormat, formatOutput } from '../../output/formatter';
88
import { saveAudioOutput } from '../../output/audio';
99
import { writeFileSync } from 'fs';
1010
import { readTextFromPathOrStdin } from '../../utils/fs';
11+
import { T2A_FORMATS, formatList, validateAudioFormat, validateT2AStreaming } from '../../utils/audio-formats';
1112
import type { Config } from '../../config/schema';
1213
import type { GlobalFlags } from '../../types/flags';
1314
import type { SpeechRequest, SpeechResponse } from '../../types/api';
@@ -25,7 +26,7 @@ export default defineCommand({
2526
{ flag: '--speed <n>', description: 'Speech speed multiplier', type: 'number' },
2627
{ flag: '--volume <n>', description: 'Volume level', type: 'number' },
2728
{ flag: '--pitch <n>', description: 'Pitch adjustment', type: 'number' },
28-
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
29+
{ flag: '--format <fmt>', description: `Audio format: ${formatList(T2A_FORMATS)} (default: mp3)` },
2930
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)', type: 'number' },
3031
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)', type: 'number' },
3132
{ flag: '--channels <n>', description: 'Audio channels (default: 1)', type: 'number' },
@@ -63,6 +64,8 @@ export default defineCommand({
6364
const voice = (flags.voice as string) || 'English_expressive_narrator';
6465
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
6566
const ext = (flags.format as string) || 'mp3';
67+
validateAudioFormat(ext, T2A_FORMATS);
68+
validateT2AStreaming(ext, flags.stream === true);
6669
const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`;
6770
const outFormat = 'hex';
6871
const format = detectOutputFormat(config.output);

src/utils/audio-formats.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { CLIError } from '../errors/base';
2+
import { ExitCode } from '../errors/codes';
3+
4+
export const T2A_FORMATS = ['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'] as const;
5+
export const MUSIC_FORMATS = ['mp3', 'wav', 'pcm', 'flac'] as const;
6+
7+
export type T2AFormat = (typeof T2A_FORMATS)[number];
8+
export type MusicFormat = (typeof MUSIC_FORMATS)[number];
9+
10+
export function formatList(formats: readonly string[]): string {
11+
return formats.join(', ');
12+
}
13+
14+
export function validateAudioFormat(format: string, formats: readonly string[]): void {
15+
if (!(formats as readonly string[]).includes(format)) {
16+
throw new CLIError(
17+
`Invalid audio format "${format}". Supported: ${formatList(formats)}`,
18+
ExitCode.USAGE,
19+
);
20+
}
21+
}
22+
23+
export function validateT2AStreaming(format: string, stream: boolean): void {
24+
if (stream && format === 'wav') {
25+
throw new CLIError(
26+
'wav format is not supported in streaming mode.',
27+
ExitCode.USAGE,
28+
'Use mp3, pcm, flac, pcmu_raw, pcmu_wav, or opus for streaming.',
29+
);
30+
}
31+
}

test/commands/music/generate.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,32 @@ describe('music generate command', () => {
180180
const parsed = JSON.parse(captured);
181181
expect(parsed.request.model).toBe('music-2.5');
182182
});
183+
184+
it('rejects invalid audio format', async () => {
185+
await expect(
186+
generateCommand.execute(
187+
{ ...baseConfig, dryRun: true },
188+
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'la la', format: 'opus' },
189+
),
190+
).rejects.toThrow('Invalid audio format "opus"');
191+
});
192+
193+
it.each(['mp3', 'wav', 'pcm', 'flac'])(
194+
'accepts %s format in dry-run',
195+
async (fmt) => {
196+
const origLog = console.log;
197+
let captured = '';
198+
console.log = (msg: string) => { captured += msg; };
199+
try {
200+
await generateCommand.execute(
201+
{ ...baseConfig, dryRun: true, output: 'json' as const },
202+
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'la la', format: fmt },
203+
);
204+
const parsed = JSON.parse(captured);
205+
expect(parsed.request.audio_setting.format).toBe(fmt);
206+
} finally {
207+
console.log = origLog;
208+
}
209+
},
210+
);
183211
});

test/commands/speech/synthesize.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,60 @@ describe('speech synthesize command', () => {
203203
}
204204
});
205205
});
206+
207+
describe('speech synthesize format validation', () => {
208+
const config = {
209+
apiKey: 'test-key',
210+
region: 'global' as const,
211+
baseUrl: 'https://api.mmx.io',
212+
output: 'json' as const,
213+
timeout: 10,
214+
verbose: false,
215+
quiet: false,
216+
noColor: true,
217+
yes: false,
218+
dryRun: true,
219+
nonInteractive: true,
220+
async: false,
221+
};
222+
223+
const flags = {
224+
text: 'Hello',
225+
quiet: false,
226+
verbose: false,
227+
noColor: true,
228+
yes: false,
229+
dryRun: true,
230+
help: false,
231+
nonInteractive: true,
232+
async: false,
233+
};
234+
235+
it('rejects invalid audio format', async () => {
236+
await expect(
237+
synthesizeCommand.execute(config, { ...flags, format: 'aac' }),
238+
).rejects.toThrow('Invalid audio format "aac"');
239+
});
240+
241+
it.each(['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'])(
242+
'accepts %s format in dry-run',
243+
async (fmt) => {
244+
const originalLog = console.log;
245+
let output = '';
246+
console.log = (msg: string) => { output += msg; };
247+
try {
248+
await synthesizeCommand.execute(config, { ...flags, format: fmt });
249+
const parsed = JSON.parse(output);
250+
expect(parsed.request.audio_setting.format).toBe(fmt);
251+
} finally {
252+
console.log = originalLog;
253+
}
254+
},
255+
);
256+
257+
it('rejects wav in streaming mode', async () => {
258+
await expect(
259+
synthesizeCommand.execute(config, { ...flags, format: 'wav', stream: true }),
260+
).rejects.toThrow('wav format is not supported in streaming');
261+
});
262+
});

test/utils/audio-formats.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import {
3+
T2A_FORMATS,
4+
MUSIC_FORMATS,
5+
formatList,
6+
validateAudioFormat,
7+
validateT2AStreaming,
8+
} from '../../src/utils/audio-formats';
9+
10+
describe('audio-formats', () => {
11+
describe('T2A_FORMATS', () => {
12+
it.each(['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'] as const)(
13+
'accepts %s',
14+
(fmt) => expect(() => validateAudioFormat(fmt, T2A_FORMATS)).not.toThrow(),
15+
);
16+
17+
it.each(['aac', 'ogg', 'wma', 'mp4', ''])(
18+
'rejects %s',
19+
(fmt) => expect(() => validateAudioFormat(fmt, T2A_FORMATS)).toThrow(/Invalid audio format/),
20+
);
21+
});
22+
23+
describe('MUSIC_FORMATS', () => {
24+
it.each(['mp3', 'wav', 'pcm', 'flac'] as const)(
25+
'accepts %s',
26+
(fmt) => expect(() => validateAudioFormat(fmt, MUSIC_FORMATS)).not.toThrow(),
27+
);
28+
29+
it.each(['opus', 'pcmu_raw', 'pcmu_wav', 'aac'])(
30+
'rejects %s',
31+
(fmt) => expect(() => validateAudioFormat(fmt, MUSIC_FORMATS)).toThrow(/Invalid audio format/),
32+
);
33+
});
34+
35+
describe('validateT2AStreaming', () => {
36+
it('rejects wav in streaming mode', () => {
37+
expect(() => validateT2AStreaming('wav', true)).toThrow(/wav format is not supported in streaming/);
38+
});
39+
40+
it('allows wav in non-streaming mode', () => {
41+
expect(() => validateT2AStreaming('wav', false)).not.toThrow();
42+
});
43+
44+
it.each(['mp3', 'pcm', 'flac', 'pcmu_raw', 'pcmu_wav', 'opus'])(
45+
'allows %s in streaming mode',
46+
(fmt) => expect(() => validateT2AStreaming(fmt, true)).not.toThrow(),
47+
);
48+
});
49+
50+
describe('formatList', () => {
51+
it('joins formats with comma-space', () => {
52+
expect(formatList(['a', 'b', 'c'])).toBe('a, b, c');
53+
});
54+
});
55+
});

0 commit comments

Comments
 (0)