207 lines
7.8 KiB
TypeScript
207 lines
7.8 KiB
TypeScript
import {
|
|
estimateDefaultDownloadBytes,
|
|
formatModelSize,
|
|
type WhisperModel,
|
|
} from "../lib/models";
|
|
import type { DevicePreference, InferenceDevice } from "../worker/protocol";
|
|
|
|
interface ModelPickerProps {
|
|
models: readonly WhisperModel[];
|
|
selectedId: string;
|
|
onSelect: (modelId: string) => void;
|
|
devicePreference: DevicePreference;
|
|
onDevicePreferenceChange: (preference: DevicePreference) => void;
|
|
/** Backend the worker actually selected; null until the first load (D-004). */
|
|
activeDevice: InferenceDevice | null;
|
|
/** Model ids with weights already in the browser cache (D-005). */
|
|
downloaded: ReadonlySet<string>;
|
|
/** True when the service worker controls the page — app shell cached (M3). */
|
|
offlineReady: boolean;
|
|
/** True while a transcription is running — switching is disabled then. */
|
|
disabled?: boolean;
|
|
/** Explicitly download the selected model's weights (D-020). */
|
|
onDownloadSelected: () => void;
|
|
/** True while a model load/download is in flight. */
|
|
downloading: boolean;
|
|
/** True when transcription will run on the server — no download needed (D-020). */
|
|
serverEngine: boolean;
|
|
}
|
|
|
|
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
|
|
{ value: "auto", label: "Auto (WebNN → WebGPU → WASM)" },
|
|
{ value: "webgpu", label: "WebGPU" },
|
|
{ value: "webnn", label: "WebNN" },
|
|
{ value: "wasm", label: "WASM (CPU)" },
|
|
];
|
|
|
|
/** Human-readable backend names, shared with the active-device badge. */
|
|
export const DEVICE_LABELS: Record<InferenceDevice, string> = {
|
|
webnn: "WebNN",
|
|
webgpu: "WebGPU",
|
|
wasm: "WASM",
|
|
};
|
|
|
|
/**
|
|
* Curated model list (D-006): size estimate before download, English-only vs
|
|
* multilingual grouping, downloaded badge from the Cache API (D-005), and a
|
|
* device preference select (default "auto", D-004).
|
|
*/
|
|
export function ModelPicker({
|
|
models,
|
|
selectedId,
|
|
onSelect,
|
|
devicePreference,
|
|
onDevicePreferenceChange,
|
|
activeDevice,
|
|
downloaded,
|
|
offlineReady,
|
|
disabled = false,
|
|
onDownloadSelected,
|
|
downloading,
|
|
serverEngine,
|
|
}: ModelPickerProps) {
|
|
// Size estimates depend on the dtype the worker will pick, which depends on
|
|
// the device. Until the worker has reported one, assume WASM unless the
|
|
// user explicitly asked for WebGPU.
|
|
const estimateDevice: InferenceDevice =
|
|
activeDevice ??
|
|
(devicePreference === "webgpu"
|
|
? "webgpu"
|
|
: devicePreference === "webnn"
|
|
? "webnn"
|
|
: "wasm");
|
|
|
|
const groups: { title: string; items: WhisperModel[] }[] = [
|
|
{
|
|
title: "English-only",
|
|
items: models.filter((model) => !model.multilingual),
|
|
},
|
|
{
|
|
title: "Multilingual",
|
|
items: models.filter((model) => model.multilingual),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<section className="model-picker" aria-label="Model selection">
|
|
<div className="model-picker-header">
|
|
<h2>Model</h2>
|
|
<label className="device-select">
|
|
Backend
|
|
<select
|
|
value={devicePreference}
|
|
disabled={disabled}
|
|
onChange={(event) =>
|
|
onDevicePreferenceChange(event.target.value as DevicePreference)
|
|
}
|
|
>
|
|
{DEVICE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{/* Which backend the worker actually selected (D-004/D-017) — the
|
|
user's preference is only a preference, so show the outcome. */}
|
|
{activeDevice && (
|
|
<span
|
|
className="model-badge device-active-badge"
|
|
role="status"
|
|
title="The inference backend the worker actually selected for this model."
|
|
>
|
|
Running on {DEVICE_LABELS[activeDevice]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{disabled && (
|
|
<p className="model-picker-hint">
|
|
Model switching is disabled while a transcription is running.
|
|
</p>
|
|
)}
|
|
{serverEngine && (
|
|
<p className="model-picker-hint server-engine-hint" role="status">
|
|
Transcription is set to run on the server — the selected model runs
|
|
there, so nothing is downloaded to this device. Downloading is only
|
|
useful if you plan to transcribe on-device or offline.
|
|
</p>
|
|
)}
|
|
<p
|
|
className={`offline-status${offlineReady && downloaded.size > 0 ? " offline-status-ready" : ""}`}
|
|
>
|
|
{offlineReady
|
|
? downloaded.size > 0
|
|
? "Offline ready — the app and the models marked “available offline” work without a network connection."
|
|
: "The app is cached for offline use. Download a model (or run one on-device transcription) to make it available offline too."
|
|
: "Offline support is not active yet (the service worker is still installing, or this browser does not support it)."}
|
|
</p>
|
|
{groups.map((group) => (
|
|
<fieldset key={group.title} className="model-group" disabled={disabled}>
|
|
<legend>{group.title}</legend>
|
|
<ul>
|
|
{group.items.map((model) => {
|
|
const size = formatModelSize(
|
|
estimateDefaultDownloadBytes(model, estimateDevice),
|
|
);
|
|
const isDownloaded = downloaded.has(model.id);
|
|
const isSelected = model.id === selectedId;
|
|
return (
|
|
<li key={model.id}>
|
|
<label
|
|
className={`model-option${isSelected ? " model-option-selected" : ""}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="model"
|
|
value={model.id}
|
|
checked={isSelected}
|
|
disabled={disabled}
|
|
onChange={() => onSelect(model.id)}
|
|
/>
|
|
<span className="model-option-name">
|
|
{model.displayName}
|
|
{model.family === "parakeet" && (
|
|
// Not Whisper (D-WS7a): say what it is good for and
|
|
// what it can't do, in plain words.
|
|
<span className="model-option-note">
|
|
Not Whisper · English only · fast on long recordings
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="model-option-meta">
|
|
{isDownloaded && (
|
|
<span
|
|
className="model-badge"
|
|
title="Weights are cached in this browser — this model loads and transcribes without a network connection."
|
|
>
|
|
available offline
|
|
</span>
|
|
)}
|
|
<span className="model-option-size">~{size}</span>
|
|
{isSelected && !isDownloaded && (
|
|
<button
|
|
type="button"
|
|
className="model-download-button"
|
|
disabled={disabled || downloading}
|
|
title={
|
|
serverEngine
|
|
? "Not needed while transcribing on the server — download only to prepare on-device or offline use."
|
|
: "Download this model now so it is available offline. Otherwise it downloads automatically when an on-device transcription starts."
|
|
}
|
|
onClick={onDownloadSelected}
|
|
>
|
|
{downloading ? "Downloading…" : "Download"}
|
|
</button>
|
|
)}
|
|
</span>
|
|
</label>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</fieldset>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|