From 96e4b9207f52945f789cd427c5ac64b4b627b8dc Mon Sep 17 00:00:00 2001 From: yuanhe Date: Mon, 11 May 2026 10:10:18 +0800 Subject: [PATCH 1/2] 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. --- src/commands/music/cover.ts | 4 +- src/commands/music/generate.ts | 4 +- src/commands/speech/synthesize.ts | 5 ++- src/utils/audio-formats.ts | 31 ++++++++++++++ test/commands/music/generate.test.ts | 28 ++++++++++++ test/commands/speech/synthesize.test.ts | 57 +++++++++++++++++++++++++ test/utils/audio-formats.test.ts | 55 ++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/utils/audio-formats.ts create mode 100644 test/utils/audio-formats.test.ts diff --git a/src/commands/music/cover.ts b/src/commands/music/cover.ts index 54e2778..610f575 100644 --- a/src/commands/music/cover.ts +++ b/src/commands/music/cover.ts @@ -6,6 +6,7 @@ import { request, requestJson } from '../../client/http'; import { musicEndpoint } from '../../client/endpoints'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; +import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { MusicRequest, MusicResponse } from '../../types/api'; @@ -24,7 +25,7 @@ export default defineCommand({ { flag: '--lyrics ', description: 'Cover lyrics. If omitted, extracted from reference audio via ASR.' }, { flag: '--lyrics-file ', description: 'Read lyrics from file (use - for stdin)' }, { flag: '--seed ', description: 'Random seed 0–1000000 for reproducible results', type: 'number' }, - { flag: '--format ', description: 'Audio format: mp3, wav, pcm (default: mp3)' }, + { flag: '--format ', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` }, { flag: '--sample-rate ', description: 'Sample rate: 16000, 24000, 32000, 44100 (default: 44100)', type: 'number' }, { flag: '--bitrate ', description: 'Bitrate: 32000, 64000, 128000, 256000 (default: 256000)', type: 'number' }, { flag: '--channel ', description: 'Channels: 1 (mono) or 2 (stereo, default)', type: 'number' }, @@ -65,6 +66,7 @@ export default defineCommand({ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const ext = (flags.format as string) || 'mp3'; + validateAudioFormat(ext, MUSIC_FORMATS); const outPath = (flags.out as string | undefined) ?? `cover_${ts}.${ext}`; const format = detectOutputFormat(config.output); diff --git a/src/commands/music/generate.ts b/src/commands/music/generate.ts index 3a0c046..286e4ef 100644 --- a/src/commands/music/generate.ts +++ b/src/commands/music/generate.ts @@ -6,6 +6,7 @@ import { musicEndpoint } from '../../client/endpoints'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; import { readTextFromPathOrStdin } from '../../utils/fs'; +import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { MusicRequest, MusicResponse } from '../../types/api'; @@ -37,7 +38,7 @@ export default defineCommand({ { flag: '--model ', description: 'Model: music-2.6 (recommended), music-2.6-free (default, unlimited), music-2.5+, or music-2.5.' }, { flag: '--output-format ', description: 'Return format: hex (default, saved to file) or url (24h expiry, download promptly). When --stream, only hex.' }, { flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' }, - { flag: '--format ', description: 'Audio format (default: mp3)' }, + { flag: '--format ', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` }, { flag: '--sample-rate ', description: 'Sample rate (default: 44100)', type: 'number' }, { flag: '--bitrate ', description: 'Bitrate (default: 256000)', type: 'number' }, { flag: '--stream', description: 'Stream raw audio to stdout' }, @@ -121,6 +122,7 @@ export default defineCommand({ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const ext = (flags.format as string) || 'mp3'; + validateAudioFormat(ext, MUSIC_FORMATS); const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`; const format = detectOutputFormat(config.output); diff --git a/src/commands/speech/synthesize.ts b/src/commands/speech/synthesize.ts index 756eff0..4c32b84 100644 --- a/src/commands/speech/synthesize.ts +++ b/src/commands/speech/synthesize.ts @@ -8,6 +8,7 @@ import { detectOutputFormat, formatOutput } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; import { writeFileSync } from 'fs'; import { readTextFromPathOrStdin } from '../../utils/fs'; +import { T2A_FORMATS, formatList, validateAudioFormat, validateT2AStreaming } from '../../utils/audio-formats'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { SpeechRequest, SpeechResponse } from '../../types/api'; @@ -25,7 +26,7 @@ export default defineCommand({ { flag: '--speed ', description: 'Speech speed multiplier', type: 'number' }, { flag: '--volume ', description: 'Volume level', type: 'number' }, { flag: '--pitch ', description: 'Pitch adjustment', type: 'number' }, - { flag: '--format ', description: 'Audio format (default: mp3)' }, + { flag: '--format ', description: `Audio format: ${formatList(T2A_FORMATS)} (default: mp3)` }, { flag: '--sample-rate ', description: 'Sample rate (default: 32000)', type: 'number' }, { flag: '--bitrate ', description: 'Bitrate (default: 128000)', type: 'number' }, { flag: '--channels ', description: 'Audio channels (default: 1)', type: 'number' }, @@ -63,6 +64,8 @@ export default defineCommand({ const voice = (flags.voice as string) || 'English_expressive_narrator'; const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const ext = (flags.format as string) || 'mp3'; + validateAudioFormat(ext, T2A_FORMATS); + validateT2AStreaming(ext, flags.stream === true); const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`; const outFormat = 'hex'; const format = detectOutputFormat(config.output); diff --git a/src/utils/audio-formats.ts b/src/utils/audio-formats.ts new file mode 100644 index 0000000..5545961 --- /dev/null +++ b/src/utils/audio-formats.ts @@ -0,0 +1,31 @@ +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; + +export const T2A_FORMATS = ['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'] as const; +export const MUSIC_FORMATS = ['mp3', 'wav', 'pcm', 'flac'] as const; + +export type T2AFormat = (typeof T2A_FORMATS)[number]; +export type MusicFormat = (typeof MUSIC_FORMATS)[number]; + +export function formatList(formats: readonly string[]): string { + return formats.join(', '); +} + +export function validateAudioFormat(format: string, formats: readonly string[]): void { + if (!(formats as readonly string[]).includes(format)) { + throw new CLIError( + `Invalid audio format "${format}". Supported: ${formatList(formats)}`, + ExitCode.USAGE, + ); + } +} + +export function validateT2AStreaming(format: string, stream: boolean): void { + if (stream && format === 'wav') { + throw new CLIError( + 'wav format is not supported in streaming mode.', + ExitCode.USAGE, + 'Use mp3, pcm, flac, pcmu_raw, pcmu_wav, or opus for streaming.', + ); + } +} diff --git a/test/commands/music/generate.test.ts b/test/commands/music/generate.test.ts index a347bd3..04738e0 100644 --- a/test/commands/music/generate.test.ts +++ b/test/commands/music/generate.test.ts @@ -180,4 +180,32 @@ describe('music generate command', () => { const parsed = JSON.parse(captured); expect(parsed.request.model).toBe('music-2.5'); }); + + it('rejects invalid audio format', async () => { + await expect( + generateCommand.execute( + { ...baseConfig, dryRun: true }, + { ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'la la', format: 'opus' }, + ), + ).rejects.toThrow('Invalid audio format "opus"'); + }); + + it.each(['mp3', 'wav', 'pcm', 'flac'])( + 'accepts %s format in dry-run', + async (fmt) => { + const origLog = console.log; + let captured = ''; + console.log = (msg: string) => { captured += msg; }; + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'la la', format: fmt }, + ); + const parsed = JSON.parse(captured); + expect(parsed.request.audio_setting.format).toBe(fmt); + } finally { + console.log = origLog; + } + }, + ); }); diff --git a/test/commands/speech/synthesize.test.ts b/test/commands/speech/synthesize.test.ts index 38dc4f7..cf8db6b 100644 --- a/test/commands/speech/synthesize.test.ts +++ b/test/commands/speech/synthesize.test.ts @@ -203,3 +203,60 @@ describe('speech synthesize command', () => { } }); }); + +describe('speech synthesize format validation', () => { + const config = { + 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 flags = { + text: 'Hello', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }; + + it('rejects invalid audio format', async () => { + await expect( + synthesizeCommand.execute(config, { ...flags, format: 'aac' }), + ).rejects.toThrow('Invalid audio format "aac"'); + }); + + it.each(['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'])( + 'accepts %s format in dry-run', + async (fmt) => { + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + try { + await synthesizeCommand.execute(config, { ...flags, format: fmt }); + const parsed = JSON.parse(output); + expect(parsed.request.audio_setting.format).toBe(fmt); + } finally { + console.log = originalLog; + } + }, + ); + + it('rejects wav in streaming mode', async () => { + await expect( + synthesizeCommand.execute(config, { ...flags, format: 'wav', stream: true }), + ).rejects.toThrow('wav format is not supported in streaming'); + }); +}); \ No newline at end of file diff --git a/test/utils/audio-formats.test.ts b/test/utils/audio-formats.test.ts new file mode 100644 index 0000000..a4f6369 --- /dev/null +++ b/test/utils/audio-formats.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'bun:test'; +import { + T2A_FORMATS, + MUSIC_FORMATS, + formatList, + validateAudioFormat, + validateT2AStreaming, +} from '../../src/utils/audio-formats'; + +describe('audio-formats', () => { + describe('T2A_FORMATS', () => { + it.each(['mp3', 'pcm', 'flac', 'wav', 'pcmu_raw', 'pcmu_wav', 'opus'] as const)( + 'accepts %s', + (fmt) => expect(() => validateAudioFormat(fmt, T2A_FORMATS)).not.toThrow(), + ); + + it.each(['aac', 'ogg', 'wma', 'mp4', ''])( + 'rejects %s', + (fmt) => expect(() => validateAudioFormat(fmt, T2A_FORMATS)).toThrow(/Invalid audio format/), + ); + }); + + describe('MUSIC_FORMATS', () => { + it.each(['mp3', 'wav', 'pcm', 'flac'] as const)( + 'accepts %s', + (fmt) => expect(() => validateAudioFormat(fmt, MUSIC_FORMATS)).not.toThrow(), + ); + + it.each(['opus', 'pcmu_raw', 'pcmu_wav', 'aac'])( + 'rejects %s', + (fmt) => expect(() => validateAudioFormat(fmt, MUSIC_FORMATS)).toThrow(/Invalid audio format/), + ); + }); + + describe('validateT2AStreaming', () => { + it('rejects wav in streaming mode', () => { + expect(() => validateT2AStreaming('wav', true)).toThrow(/wav format is not supported in streaming/); + }); + + it('allows wav in non-streaming mode', () => { + expect(() => validateT2AStreaming('wav', false)).not.toThrow(); + }); + + it.each(['mp3', 'pcm', 'flac', 'pcmu_raw', 'pcmu_wav', 'opus'])( + 'allows %s in streaming mode', + (fmt) => expect(() => validateT2AStreaming(fmt, true)).not.toThrow(), + ); + }); + + describe('formatList', () => { + it('joins formats with comma-space', () => { + expect(formatList(['a', 'b', 'c'])).toBe('a, b, c'); + }); + }); +}); From cfbeb9140f191ee554e4e6b79edc879dfb9acbb8 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Mon, 11 May 2026 10:13:09 +0800 Subject: [PATCH 2/2] fix: resolve all lint errors in test files - Replace require() with dynamic import in timeout-fix test - Fix unused variable in stream test - Replace no-explicit-any casts with Config type in models test --- test/auth/timeout-fix.test.ts | 4 ++-- test/client/stream.test.ts | 2 +- test/commands/music/models.test.ts | 19 +++++++++---------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/test/auth/timeout-fix.test.ts b/test/auth/timeout-fix.test.ts index 22257d8..0b49b41 100644 --- a/test/auth/timeout-fix.test.ts +++ b/test/auth/timeout-fix.test.ts @@ -192,8 +192,8 @@ describe('refreshAccessToken: timeout and error handling', () => { // --------------------------------------------------------------------------- describe('handleError: timeout message includes region/auth hint', () => { - it('AbortError message contains region override hint', () => { - const { handleError } = require('../../src/errors/handler'); + it('AbortError message contains region override hint', async () => { + const { handleError } = await import('../../src/errors/handler'); const abortErr = new DOMException('The operation was aborted.', 'AbortError'); diff --git a/test/client/stream.test.ts b/test/client/stream.test.ts index c67a84a..ba94e9d 100644 --- a/test/client/stream.test.ts +++ b/test/client/stream.test.ts @@ -231,7 +231,7 @@ describe('parseSSE', () => { // First request — consume all events const response1 = await fetch(`${server.url}/stream`); - for await (const _ of parseSSE(response1)) { /* consume */ } + for await (const _event of parseSSE(response1)) { void _event; } // Second request — should work since lock released const response2 = await fetch(`${server.url}/stream`); diff --git a/test/commands/music/models.test.ts b/test/commands/music/models.test.ts index ac9b9e7..235ab31 100644 --- a/test/commands/music/models.test.ts +++ b/test/commands/music/models.test.ts @@ -1,40 +1,39 @@ import { describe, it, expect } from 'bun:test'; import { musicGenerateModel, musicCoverModel, isCodingPlan } from '../../../src/commands/music/models'; +import type { Config } from '../../../src/config/schema'; describe('music models', () => { it('isCodingPlan returns true for sk-cp- key', () => { - expect(isCodingPlan({ apiKey: 'sk-cp-abc' } as any)).toBe(true); + expect(isCodingPlan({ apiKey: 'sk-cp-abc' } as Config)).toBe(true); }); it('isCodingPlan returns false for sk-api- key', () => { - expect(isCodingPlan({ apiKey: 'sk-api-xyz' } as any)).toBe(false); + expect(isCodingPlan({ apiKey: 'sk-api-xyz' } as Config)).toBe(false); }); it('musicGenerateModel uses defaultMusicModel when set', () => { - const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as any; + const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as Config; expect(musicGenerateModel(config)).toBe('music-2.6'); }); it('musicGenerateModel falls back to key-type default when no defaultMusicModel', () => { - const cpConfig = { apiKey: 'sk-cp-abc' } as any; + const cpConfig = { apiKey: 'sk-cp-abc' } as Config; expect(musicGenerateModel(cpConfig)).toBe('music-2.6'); - const apiConfig = { apiKey: 'sk-api-xyz' } as any; + const apiConfig = { apiKey: 'sk-api-xyz' } as Config; expect(musicGenerateModel(apiConfig)).toBe('music-2.6-free'); }); it('musicCoverModel ignores defaultMusicModel for non-cover models', () => { - // defaultMusicModel is 'music-2.6' (a generate model, not a cover model) - // cover should still use key-type default - const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as any; + const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as Config; expect(musicCoverModel(config)).toBe('music-cover-free'); }); it('musicCoverModel uses key-type default when no defaultMusicModel', () => { - const cpConfig = { apiKey: 'sk-cp-abc' } as any; + const cpConfig = { apiKey: 'sk-cp-abc' } as Config; expect(musicCoverModel(cpConfig)).toBe('music-cover'); - const apiConfig = { apiKey: 'sk-api-xyz' } as any; + const apiConfig = { apiKey: 'sk-api-xyz' } as Config; expect(musicCoverModel(apiConfig)).toBe('music-cover-free'); }); });