fix(synthesize): validate clone-path instruct client-side so non-EN/ZH prose can't 400 (#612) (#658)

A Vietnamese user typed a free-form Vietnamese description into the voice style
(instruct) field and got "400 Bad Request: Unsupported instruct items found in
quảng cáo, sôi nổi và thu hút". The instruct field is a fixed EN/ZH style-tag
vocabulary (the model's trained tokens: gender/age/pitch/accent/dialect/whisper);
the backend _resolve_instruct deliberately *raises* on unknown items.

The design path already guarded this: it runs the free-text through
buildDesignInstruct(), keeping valid tags, dropping the rest, and surfacing a
localized warning toast (#115/#114). But the *clone* path
(defineMethod === 'audio') appended the raw `instruct` string straight to the
request — so a clone + free-text style in any non-EN/ZH language round-tripped to
a 400 instead of being handled locally.

Fix (localized client-side guard, the chosen approach): route the clone path's
free-text through the same buildDesignInstruct({}, instruct) guard. Valid style
tags survive (a clone can still ask for "whisper"); unsupported items drop with
the existing localized `tts_errors.ignored_unsupported` toast; synthesis proceeds
in the user's language without style control instead of failing outright. No
backend/engine change — the model genuinely can't honor non-EN/ZH instructs, so
this makes the failure graceful and understandable rather than a raw 400.

Test: two cases in voiceInstruct.test.js pin the clone scenario — a fully
Vietnamese instruct yields "" + all items in the unsupported bucket, and a mixed
"whisper, sôi nổi" keeps "whisper" while flagging the prose.

Closes #612

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-24 03:02:39 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent e87c13e919
commit 10b9d6950d
2 changed files with 32 additions and 1 deletions
+16 -1
View File
@@ -121,7 +121,22 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
formData.append("ref_audio", safeBlob, refAudio.name || "audio.wav");
formData.append("ref_text", refText);
}
if (instruct) formData.append("instruct", instruct);
// #612: a clone's free-text style field also passes through the backend
// instruct whitelist, so raw prose (e.g. a non-EN/ZH description like the
// Vietnamese report) 400s with "Unsupported instruct items". Apply the
// SAME validator-safe guard the design path uses: keep valid style tags,
// drop the rest, and surface a localized warning toast — never round-trip
// a 400. vdStates is empty here (clone has no design sliders).
if (instruct) {
const { instruct: safeInstruct, unsupported, duplicates } = buildDesignInstruct({}, instruct);
if (unsupported.length) {
toast(t('tts_errors.ignored_unsupported', { items: unsupported.join(', ') }), { icon: '⚠️' });
}
if (duplicates.length) {
toast(t('tts_errors.ignored_duplicate', { items: duplicates.join(', ') }), { icon: '⚠️' });
}
if (safeInstruct) formData.append("instruct", safeInstruct);
}
} else {
// #526: reuse the pinned seed when "keep this seed" is on (stable
// tweaks), else roll a fresh one. The backend echoes the seed it used
+16
View File
@@ -27,6 +27,22 @@ describe('buildDesignInstruct', () => {
expect(duplicates).toEqual([]);
});
it('clone path (#612): non-EN/ZH free-text yields no instruct, all items flagged unsupported', () => {
// The clone synthesize path runs free-text through buildDesignInstruct({}, …)
// exactly like this. A Vietnamese description must NOT reach the backend (it
// 400s with "Unsupported instruct items"); it drops to "" + a warn bucket so
// the UI shows a localized toast and synthesis still proceeds (no style).
const { instruct, unsupported } = buildDesignInstruct({}, 'quảng cáo, sôi nổi và thu hút');
expect(instruct).toBe('');
expect(unsupported).toEqual(['quảng cáo', 'sôi nổi và thu hút']);
});
it('clone path keeps valid style tags while dropping prose in the same field', () => {
const { instruct, unsupported } = buildDesignInstruct({}, 'whisper, sôi nổi');
expect(instruct).toBe('whisper');
expect(unsupported).toEqual(['sôi nổi']);
});
it('buckets a valid tag outranked by a dropdown as a duplicate, not unsupported (#114)', () => {
const { instruct, unsupported, duplicates } = buildDesignInstruct({ Pitch: 'low pitch' }, 'high pitch');
expect(instruct).toBe('low pitch'); // dropdown wins the category