Import useWhistle and useWhistleRecorder from @tiny-stt/react.
The separate @tiny-stt/react package has React 19 and @tiny-stt/whistle as
required peer dependencies. Install both in the application to share one SDK and
React instance. The core SDK has no React dependency or /react export. React DOM
belongs to the application's renderer, not the hook package.
Install with pnpm add @tiny-stt/react @tiny-stt/whistle react@^19.
To test an unpublished checkout, run pnpm release:check in the repository, then
install both tarballs in your React application's directory:
pnpm add /path/to/tiny-stt/artifacts/tiny-stt-whistle-0.1.0.tgz /path/to/tiny-stt/artifacts/tiny-stt-react-0.1.0.tgz react@^19
Prepare the full CLI asset directory with whistle download --out public/whistle
and pass its served URL as assetsUrl. The URL belongs to your application, so
include its deployment base path when needed. Omitting the options uses the same
pinned official model download as the browser factory.
This example uses explicit start/stop buttons. For pointer/keyboard push-to-talk, see the demo. The React component is compiled against the package exports during documentation checks:
import { useWhistle, useWhistleRecorder } from '@tiny-stt/react';
import { useState } from 'react';
export function SpeechInput({ assetsUrl }: { assetsUrl: string }) {
const [source, setSource] = useState<'engine' | 'recorder'>('engine');
const whistle = useWhistle({ assetsUrl });
const recorder = useWhistleRecorder(whistle, {
maxDurationSeconds: 30,
transcribeOptions: { wordTimestamps: true },
});
const recording = recorder.status !== 'idle';
const busy = whistle.status !== 'idle' || recording;
const error = source === 'recorder' ? recorder.error : whistle.error;
// Rejections are also exposed in hook state. Always observe the action promises.
const observe = (promise: Promise<unknown>) => {
void promise.catch(() => {});
};
return (
<section>
<button
type="button"
disabled={whistle.ready || busy}
onClick={() => {
setSource('engine');
observe(whistle.load());
}}
>
Load speech model
</button>
<input
type="file"
accept="audio/*"
disabled={!whistle.ready || busy}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = '';
if (file) {
setSource('engine');
observe(whistle.transcribeFile(file, { wordTimestamps: true }));
}
}}
/>
<button
type="button"
disabled={!whistle.ready || busy}
onClick={() => {
setSource('recorder');
observe(recorder.start());
}}
>
Record
</button>
<button type="button" disabled={!recording} onClick={() => observe(recorder.stop())}>
Stop and transcribe
</button>
<button
type="button"
disabled={!busy}
onClick={() => {
recorder.cancel();
whistle.cancel();
}}
>
Cancel
</button>
<p role="status">{recording ? recorder.status : whistle.status}</p>
{error && (
<p role="alert">
{error.code}: {error.message}
</p>
)}
<output>{whistle.result?.text}</output>
</section>
);
}
load() explicitly initializes the engine and reports per-asset progress.
Rendering or importing never starts downloads, inference, audio contexts or capture.
ready indicates a loaded instance; status describes loading, decoding or
transcription. pending counts active and queued file/PCM calls. result retains
the latest successful transcript, including silence as an empty string.
Action promises reject with WhistleError, and the hooks also expose error for
rendering. Observe rejections even when using that state; a void prefix alone
does not handle a rejected promise. Cancellation uses ABORTED, not a partial
transcript. Each hook owns its own error state; applications combining file and
recording controls can select the error for the most recent user action, as the
demo does. Display strings, styling and input events remain application-owned.
Each useWhistle invocation owns one engine. Keep the component mounted to reuse
it; another invocation creates a separate engine. useWhistleRecorder(whistle)
uses the supplied hook's engine and never loads another model. Pass the hook return
value through props or your own context when components should use that engine.
Inline options objects and URL objects with unchanged string values do not reload
the model. Changing an asset URL or cache releases the old instance, rejects its
work with DISPOSED, resets state, and requires another explicit load(). Actions
from the old configuration become unusable. Recording preferences and hints are
snapshotted at start(); changing them affects the next recording.
Unmount, configuration changes, pagehide and React effect cleanup release workers,
capture, timers and decoder resources. Late initialization, permission grants or
results cannot revive a retired operation. React Strict Mode and Fast Refresh may
replay effects; a replay or back/forward restoration returns to unloaded state and
requires load() again. The previous transcript is retained for the same hook
configuration. These lifecycle rules follow React's
effect cleanup model.
The ESM @tiny-stt/react entry preserves 'use client'. Server rendering a Client Component
produces idle state without accessing browser APIs; event-driven loading occurs in
the browser. Do not call these hooks in a React Server Component. Vite 8/React 19
and React DOM server rendering are tested; a Next.js application is not an executed
compatibility claim.
load() and wait for readiness before transcription/recording. Early calls
reject with MODEL_LOAD_FAILED. Concurrent load calls share initialization.transcribe(pcm, options) copies PCM and hints at invocation. It preserves offsets
and caller buffer ownership. transcribeFile(blob, options) queues decoding plus
inference; both methods share one FIFO. Inputs retain the core 30-second limits.signal cancels only that request, including decoding. Cancelling active
WASM inference terminates its worker; surviving work waits for recovery.whistle.cancel() cancels initialization and every pending file/PCM request owned
by that hook. A loaded instance remains reusable. It does not cancel microphone
capture owned by a recorder; cancel both for a whole-form Cancel button.load() explicitly before retrying.recorder.start() resolves to the final transcript after stop/automatic stop.
Repeated starts during the same operation return the same promise. stop() shares
that completion while busy and returns undefined when idle. Stopping during a
permission prompt cancels; tracks from a late grant are stopped immediately.recorder.cancel() discards capture or cancels only its queued/active transcription.
Other calls on the engine survive. The helper auto-stops within its configured
limit, at most 30 seconds; no continuous listening or automatic restart is added.Microphones require HTTPS or localhost and an explicit user action. Focus and pointer behavior are application choices; the demo stops on loss of focus. The core deployment and offline requirements apply unchanged.
Locally verified on 2026-10-10 with React 19.3.0, Vite 8.3.4, Chromium 156.0.8078.4, Firefox 157.0 and WebKit 27.2 on macOS arm64. Eight hook/controller tests cover SSR, initialization, snapshots, FIFO, cancellation, stale completions, recording ownership and worker failure/reload. Browser checks use the real model and licensed speech fixture, with external hosts blocked and deterministic capture instead of microphone hardware. They cover Strict Mode, Fast Refresh, multiple engines, configuration changes, file/PCM queueing, active/queued cancellation, automatic stop, late permission grants and complete cleanup.
Both npm tarballs passed a clean offline pnpm install, declaration checks, SSR
with networking denied, and production Vite inference under /demo/. The core SDK
also installed without React and passed repeated inference/cancellation recovery
in Node 22.12.0 and 24.21.0 Linux arm64 containers using --network none.
The adapter tarball contains 11 files, about 10 kB compressed, with no bundled
React, model or WASM. Live npm trusted publishing, Windows execution and Next.js
were not part of these local checks.