Offer Whisper's Translate to English only on models trained for it (canTranslate)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
036635e854
commit
02a5eab119
@@ -141,7 +141,7 @@ environment). See `WorkBreakdown.md` for the itemized checklist.)
|
||||
`AudioContext.decodeAudioData`, downmixes to mono, resamples to 16 kHz,
|
||||
transfers the `Float32Array` to the worker. For multilingual models the
|
||||
`transcribe` message also carries `language` (or auto-detect) and `task`
|
||||
(transcribe | translate).
|
||||
(transcribe | translate — translate only for models with `canTranslate`, D-WS4a).
|
||||
5. Worker runs the pipeline (chunked, with timestamps), streaming partial results
|
||||
back via `{type:"partial"}` messages; final result as `{type:"result"}`.
|
||||
|
||||
|
||||
@@ -1224,3 +1224,20 @@ whole-file output is equally bad, which is exactly what the absolute bar
|
||||
exists to catch.
|
||||
**Consequence:** full `bun test` ≈ 19 min on CPU (en_alien ≈ 20 min of it
|
||||
on a cold run); the fast suite excludes this file.
|
||||
|
||||
## D-WS4a — Whisper's "Translate to English" only on models trained for it (`canTranslate`)
|
||||
**Status:** Accepted (2026-10-29) · spec 001 Goal 4a
|
||||
`WhisperModel` gains `canTranslate: boolean` (`src/lib/models.ts`): **true** for multilingual
|
||||
tiny / base / small / medium, **false** for every `.en` model (no task token) and for
|
||||
`whisper-large-v3-turbo` — OpenAI's model card says turbo was fine-tuned on transcription data only
|
||||
and is not trained for translation, so its "translate" output is unreliable. Any future turbo-based
|
||||
fine-tune defaults to false until tested. `TranscriptionOptions` renders the Task control only where
|
||||
true (language stays). **Stale task:** the `task` state is kept, but `taskForModel(modelId, task)`
|
||||
maps "translate" to "transcribe" for a model that can't translate; App applies it on model switch
|
||||
(picker and the server-engine auto-switch to turbo) and again when building every file / live run's
|
||||
options, so a leftover "translate" can never reach the worker or the server. **Not changed:** the
|
||||
server's `parseLanguageAndTask` (D-032) still accepts "translate" for any model — the browser never
|
||||
sends it for turbo now, and a direct API caller asking faster-whisper to translate with turbo gets
|
||||
what faster-whisper does; rejecting it server-side would be a behaviour change for API users with no
|
||||
UI benefit. Speech in another language than English can still end up in English (or Danish) through
|
||||
text translation of the finished transcript (D-WS4b), for every model.
|
||||
|
||||
@@ -65,6 +65,8 @@ Notes (M1):
|
||||
surface active backend in UI
|
||||
- [x] Handle model switching (dispose old pipeline, load new)
|
||||
- [x] Language / task options for multilingual models (transcribe vs translate)
|
||||
- [x] Whisper translate only on models trained for it (`canTranslate`; hidden for
|
||||
large-v3-turbo and `.en`, stale "translate" resets to transcribe — D-WS4a)
|
||||
- [~] Verify quantized variants (q4/q8/fp16) on both backends; record working
|
||||
combinations in `models.ts`
|
||||
|
||||
|
||||
+12
-3
@@ -37,6 +37,7 @@ import {
|
||||
formatModelSize,
|
||||
getModel,
|
||||
resolveDefaultDtype,
|
||||
taskForModel,
|
||||
} from "./lib/models";
|
||||
import { shouldWarnBeforeLoad, type MobileMemoryHint } from "./lib/mobileMemoryGuard";
|
||||
import {
|
||||
@@ -499,6 +500,9 @@ export function App() {
|
||||
function handleModelSelect(nextModelId: string) {
|
||||
if (busy || liveStatus !== "idle" || nextModelId === modelId) return;
|
||||
setModelId(nextModelId);
|
||||
// A "Translate to English" chosen on a model that can translate must not
|
||||
// silently carry over to one that cannot (D-WS4a).
|
||||
setTask((current) => taskForModel(nextModelId, current));
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
setPartialText("");
|
||||
@@ -675,7 +679,7 @@ export function App() {
|
||||
// Language/task only apply to multilingual models; `.en` variants
|
||||
// take neither. The browser engine ignores modelId (worker holds it).
|
||||
modelId,
|
||||
...(multilingual && { language, task }),
|
||||
...(multilingual && { language, task: taskForModel(modelId, task) }),
|
||||
onPartial: (fragment) => {
|
||||
if (fileRun.current === run) setPartialText((text) => text + fragment);
|
||||
},
|
||||
@@ -752,7 +756,10 @@ export function App() {
|
||||
setLivePending(0);
|
||||
setLiveStatus("starting");
|
||||
|
||||
const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {};
|
||||
const options =
|
||||
(getModel(modelId)?.multilingual ?? false)
|
||||
? { language, task: taskForModel(modelId, task) }
|
||||
: {};
|
||||
|
||||
if (useServer) {
|
||||
const wsUrl = new URL(`/api/transcribe/live?model=${modelId}`, window.location.href);
|
||||
@@ -988,6 +995,7 @@ export function App() {
|
||||
const allowed = ["onnx-community/whisper-large-v3-turbo", "onnx-community/whisper-medium.en_timestamped"];
|
||||
if (!allowed.includes(modelId) && !busy && liveStatus === "idle") {
|
||||
setModelId("onnx-community/whisper-large-v3-turbo");
|
||||
setTask((current) => taskForModel("onnx-community/whisper-large-v3-turbo", current));
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
setPartialText("");
|
||||
@@ -1036,8 +1044,9 @@ export function App() {
|
||||
<TranscriptionOptions
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
task={task}
|
||||
task={taskForModel(modelId, task)}
|
||||
onTaskChange={setTask}
|
||||
canTranslate={model.canTranslate}
|
||||
disabled={busy || liveActive}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,18 +6,26 @@ interface TranscriptionOptionsProps {
|
||||
onLanguageChange: (language: string) => void;
|
||||
task: WhisperTask;
|
||||
onTaskChange: (task: WhisperTask) => void;
|
||||
/**
|
||||
* Whether the model supports Whisper's own speech → English translation
|
||||
* (`WhisperModel.canTranslate`, D-WS4a). When false the Task control is not
|
||||
* rendered at all — transcribing is the only thing such a model does.
|
||||
*/
|
||||
canTranslate: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language + task controls for multilingual models. The parent renders this
|
||||
* only when the selected model is multilingual — `.en` models take neither.
|
||||
* The task control only appears where the model can translate (D-WS4a).
|
||||
*/
|
||||
export function TranscriptionOptions({
|
||||
language,
|
||||
onLanguageChange,
|
||||
task,
|
||||
onTaskChange,
|
||||
canTranslate,
|
||||
disabled = false,
|
||||
}: TranscriptionOptionsProps) {
|
||||
return (
|
||||
@@ -37,17 +45,19 @@ export function TranscriptionOptions({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Task
|
||||
<select
|
||||
value={task}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onTaskChange(event.target.value as WhisperTask)}
|
||||
>
|
||||
<option value="transcribe">Transcribe</option>
|
||||
<option value="translate">Translate to English</option>
|
||||
</select>
|
||||
</label>
|
||||
{canTranslate && (
|
||||
<label>
|
||||
Task
|
||||
<select
|
||||
value={task}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onTaskChange(event.target.value as WhisperTask)}
|
||||
>
|
||||
<option value="transcribe">Transcribe</option>
|
||||
<option value="translate">Translate to English</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ export interface WhisperModel {
|
||||
displayName: string;
|
||||
/** False for `.en` (English-only) variants. */
|
||||
multilingual: boolean;
|
||||
/**
|
||||
* Whether Whisper's own `task: "translate"` (speech → English text) is
|
||||
* offered (D-WS4a). False for every `.en` model (no task token) and for
|
||||
* large-v3-turbo, which OpenAI did not train for translation — and for any
|
||||
* turbo-based fine-tune not tested for it. The UI hides "Translate to
|
||||
* English" where false; text translation of a finished transcript
|
||||
* (D-WS4b) works for every model regardless.
|
||||
*/
|
||||
canTranslate: boolean;
|
||||
/** Encoder parameter count, in millions (drives size estimates). */
|
||||
encoderParamsM: number;
|
||||
/** Decoder parameter count, in millions (drives size estimates). */
|
||||
@@ -174,6 +183,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "tiny.en",
|
||||
displayName: "Whisper Tiny (English)",
|
||||
multilingual: false,
|
||||
canTranslate: false,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: {
|
||||
@@ -188,6 +198,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "tiny",
|
||||
displayName: "Whisper Tiny",
|
||||
multilingual: true,
|
||||
canTranslate: true,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32, webnn: WEBNN_FP16 },
|
||||
@@ -197,6 +208,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "base.en",
|
||||
displayName: "Whisper Base (English)",
|
||||
multilingual: false,
|
||||
canTranslate: false,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32, webnn: WEBNN_FP16 },
|
||||
@@ -206,6 +218,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "base",
|
||||
displayName: "Whisper Base",
|
||||
multilingual: true,
|
||||
canTranslate: true,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32, webnn: WEBNN_FP16 },
|
||||
@@ -215,6 +228,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "small.en",
|
||||
displayName: "Whisper Small (English)",
|
||||
multilingual: false,
|
||||
canTranslate: false,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4, webnn: WEBNN_FP16 },
|
||||
@@ -224,6 +238,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "small",
|
||||
displayName: "Whisper Small",
|
||||
multilingual: true,
|
||||
canTranslate: true,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4, webnn: WEBNN_FP16 },
|
||||
@@ -237,6 +252,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "medium.en",
|
||||
displayName: "Whisper Medium (English)",
|
||||
multilingual: false,
|
||||
canTranslate: false,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: ACCEL_Q4, webnn: ACCEL_Q4 },
|
||||
@@ -246,6 +262,7 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "medium",
|
||||
displayName: "Whisper Medium",
|
||||
multilingual: true,
|
||||
canTranslate: true,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: ACCEL_Q4, webnn: ACCEL_Q4 },
|
||||
@@ -255,6 +272,9 @@ export const MODELS: readonly WhisperModel[] = [
|
||||
fasterWhisperModel: "large-v3-turbo",
|
||||
displayName: "Whisper Large v3 Turbo",
|
||||
multilingual: true,
|
||||
// Not trained for translation (OpenAI's turbo model card); its
|
||||
// "translate" output is unreliable (D-WS4a).
|
||||
canTranslate: false,
|
||||
encoderParamsM: 635,
|
||||
decoderParamsM: 174,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4, webnn: WEBNN_FP16 },
|
||||
@@ -274,6 +294,16 @@ export function getFasterWhisperModel(id: string): string | undefined {
|
||||
return getModel(id)?.fasterWhisperModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task a run on `modelId` should actually use (D-WS4a): a "translate"
|
||||
* left over from a model that could translate falls back to "transcribe" on
|
||||
* one that cannot (large-v3-turbo, `.en`, unknown ids). App applies it on
|
||||
* every model switch and again when building each run's options.
|
||||
*/
|
||||
export function taskForModel(modelId: string, task: WhisperTask): WhisperTask {
|
||||
return task === "translate" && getModel(modelId)?.canTranslate !== true ? "transcribe" : task;
|
||||
}
|
||||
|
||||
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
|
||||
export function resolveDefaultDtype(
|
||||
modelId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getFasterWhisperModel,
|
||||
getModel,
|
||||
resolveDefaultDtype,
|
||||
taskForModel,
|
||||
} from "../src/lib/models";
|
||||
|
||||
// Pure catalog data (D-006) — no browser, no network.
|
||||
@@ -209,3 +210,39 @@ describe("WHISPER_LANGUAGES", () => {
|
||||
expect(WHISPER_LANGUAGES.some((entry) => entry.code === "auto")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canTranslate (D-WS4a)", () => {
|
||||
test("Whisper translate is offered only where OpenAI trained it", () => {
|
||||
const translating = MODELS.filter((model) => model.canTranslate).map((model) => model.id);
|
||||
// large-v3-turbo was not trained for translation (OpenAI model card);
|
||||
// `.en` models are English-only and have no task token.
|
||||
expect(translating).toEqual([
|
||||
"onnx-community/whisper-tiny",
|
||||
"onnx-community/whisper-base",
|
||||
"onnx-community/whisper-small",
|
||||
"onnx-community/whisper-medium-ONNX",
|
||||
]);
|
||||
});
|
||||
|
||||
test("canTranslate implies multilingual", () => {
|
||||
for (const model of MODELS) {
|
||||
if (model.canTranslate) expect(model.multilingual).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("taskForModel (D-WS4a)", () => {
|
||||
test("a stale translate resets to transcribe on a model that cannot translate", () => {
|
||||
expect(taskForModel("onnx-community/whisper-large-v3-turbo", "translate")).toBe("transcribe");
|
||||
expect(taskForModel("onnx-community/whisper-small.en", "translate")).toBe("transcribe");
|
||||
});
|
||||
|
||||
test("translate survives on a model that can translate", () => {
|
||||
expect(taskForModel("onnx-community/whisper-small", "translate")).toBe("translate");
|
||||
expect(taskForModel("onnx-community/whisper-small", "transcribe")).toBe("transcribe");
|
||||
});
|
||||
|
||||
test("unknown ids never translate", () => {
|
||||
expect(taskForModel("someone/unknown", "translate")).toBe("transcribe");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { TranscriptionOptions } from "../src/components/TranscriptionOptions";
|
||||
|
||||
// D-WS4a: Whisper's own "Translate to English" only where the model can do it.
|
||||
function render(canTranslate: boolean): string {
|
||||
return renderToStaticMarkup(
|
||||
<TranscriptionOptions
|
||||
language="auto"
|
||||
onLanguageChange={() => {}}
|
||||
task="transcribe"
|
||||
onTaskChange={() => {}}
|
||||
canTranslate={canTranslate}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TranscriptionOptions", () => {
|
||||
test("offers Translate to English when the model can translate", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain("Translate to English");
|
||||
expect(html).toContain("Task");
|
||||
});
|
||||
|
||||
test("hides the task control entirely when the model cannot translate", () => {
|
||||
const html = render(false);
|
||||
expect(html).not.toContain("Translate to English");
|
||||
expect(html).not.toContain("Task");
|
||||
expect(html).toContain("Language");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user