@molecule/app-ai-voice-parakeet
Provider bond · ai-voice · App (browser) · v1.0.2 · Apache-2.0
On-device NVIDIA Parakeet ai-voice provider for molecule.dev — leaderboard-accuracy speech-to-text in the browser via parakeet.js (WebGPU/WASM)
npm install @molecule/app-ai-voice-parakeetnpm · Source on GitHub · Implements @molecule/app-ai-voice
How it works
@molecule/app-ai-voice-parakeet is a provider bond on the app (browser) side: it implements the ai-voice core interface (@molecule/app-ai-voice) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/app-ai-voice'
import { createProvider, supportsRecognitionLanguage } from '@molecule/app-ai-voice-parakeet'
if (await supportsRecognitionLanguage(navigator.language)) {
setProvider(
createProvider({
onModelProgress: (e) => console.log(e.status, e.progress),
}),
)
}Works with: @molecule/app-ai-voice
Reference
On-device NVIDIA Parakeet voice provider using parakeet.js.
Leaderboard-topping speech-to-text that runs entirely in the browser (WebGPU encoder + WASM decoder) — no cloud speech service and no Web Speech API backend required. Works in Brave, ungoogled Chromium, and Firefox, where the native SpeechRecognition API is a non-functional stub.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/app-ai-voice'
import { createProvider, supportsRecognitionLanguage } from '@molecule/app-ai-voice-parakeet'
if (await supportsRecognitionLanguage(navigator.language)) {
setProvider(
createProvider({
onModelProgress: (e) => console.log(e.status, e.progress),
}),
)
}
Type
provider
Installation
npm install @molecule/app-ai-voice-parakeet @molecule/app-ai-voice parakeet.js
API
Interfaces
ModelProgressEvent
Progress event emitted while the speech model downloads/initializes.
interface ModelProgressEvent {
/** Lifecycle stage of the model load. */
status: 'downloading' | 'loading' | 'ready' | 'error'
/** Overall download progress from 0 to 100, when known. */
progress?: number
/** The file currently being fetched, when known. */
file?: string
}
ParakeetVoiceConfig
Configuration for the on-device Parakeet voice provider.
interface ParakeetVoiceConfig {
/**
* parakeet.js model key or Hugging Face repo id. Default:
* 'parakeet-tdt-0.6b-v3' (multilingual). 'parakeet-tdt-0.6b-v2' is
* English-only but slightly smaller/faster.
*/
model?: string
/**
* Inference backend. 'auto' (default) picks WebGPU when available and
* falls back to WASM.
*/
backend?: 'auto' | 'webgpu' | 'wasm'
/**
* URL prefix (directory) the ONNX Runtime WASM/JS runtime files are served
* from — e.g. '/ort/'. Without it, parakeet.js loads the runtime from the
* jsdelivr CDN, which any app with a `script-src 'self'` CSP (correctly)
* blocks. Serve the `ort-wasm-simd-threaded*` files from parakeet.js's own
* onnxruntime-web dependency (version must match). Applied through the
* lib's global ort instance with one retry, because parakeet.js's own
* wasmPaths option is ignored upstream.
*/
wasmPaths?: string
/** Encoder weight quantization. Default 'int8' (smallest download). */
encoderQuant?: 'int8' | 'fp16' | 'fp32'
/** Decoder weight quantization. Default 'int8'. */
decoderQuant?: 'int8' | 'fp16' | 'fp32'
/**
* Called with model download/initialization progress — wire this to a UI
* indicator, since the first use downloads the model (hundreds of MB for
* the 0.6B models; cached by the browser afterwards).
*/
onModelProgress?: (event: ModelProgressEvent) => void
/**
* RMS amplitude above which a frame counts as speech. Default 0.01.
*/
speechThreshold?: number
/**
* Milliseconds of silence after speech that closes a chunk and sends it
* for transcription. Default 800.
*/
silenceMs?: number
/**
* Hard cap on a single chunk's length in seconds — a chunk is flushed at
* this size even without a pause. Default 15.
*/
maxChunkSeconds?: number
}
Classes
ParakeetVoiceProvider
On-device NVIDIA Parakeet speech-to-text provider.
Captures microphone audio, segments it on pauses, and transcribes each segment locally with a parakeet.js ONNX model.
class ParakeetVoiceProvider implements AIVoiceProvider {
readonly name = 'parakeet'
private state: VoiceState = 'idle'
private config: ParakeetVoiceConfig
private handlers: VoiceEventHandlers = {}
private disposed = false
private modelPromise: Promise<ParakeetModelLike> | null = null
private mediaStream: MediaStream | null = null
private audioContext: AudioContext | null = null
private processor: ScriptProcessorNode | null = null
private sourceNode: MediaStreamAudioSourceNode | null = null
private listening = false
// VAD/chunking state
private speechFrames: Float32Array[] = []
private inSpeech = false
private silentSamples = 0
private speechSamples = 0
// Serialized transcription queue
private transcribeQueue: Float32Array[] = []
private transcribing = false
/**
* Creates a new ParakeetVoiceProvider.
* @param config - Provider configuration (model, backend, VAD tuning).
*/
constructor(config: ParakeetVoiceConfig = {}) {
this.config = config
}
/**
* Loads (or returns the cached) parakeet.js model, reporting download
* progress through `config.onModelProgress`.
* @returns The ready-to-use Parakeet model.
*/
private loadModel(): Promise<ParakeetModelLike> {
if (this.modelPromise) return this.modelPromise
const progress = (event: ModelProgressEvent): void => {
this.config.onModelProgress?.(event)
}
this.modelPromise = (async () => {
progress({ status: 'loading' })
const { fromHub } = await import('parakeet.js')
const backend =
this.config.backend && this.config.backend !== 'auto'
? this.config.backend
: (await hasUsableWebGpu())
? 'webgpu'
: 'wasm'
const model = this.config.model ?? DEFAULT_MODEL
const progressCallback = (p: { loaded: number; total: number; file: string }): void => {
progress({
status: 'downloading',
progress: p.total > 0 ? Math.round((p.loaded / p.total) * 100) : undefined,
file: p.file,
})
}
// int8 encoders are rejected by the WebGPU execution provider —
// parakeet.js silently force-upgrades them to fp32, a 2.4 GB download
// (verified live). fp16 (1.24 GB) is the WebGPU sweet spot; int8
// (652 MB) works on the WASM path.
const encoderQuantFor = (b: 'webgpu' | 'wasm'): 'int8' | 'fp16' | 'fp32' =>
this.config.encoderQuant ?? (b === 'webgpu' ? 'fp16' : 'int8')
// WebGPU init failures (driver/adapter issues) fall back to WASM.
const backends: Array<'webgpu' | 'wasm'> =
backend === 'webgpu' ? ['webgpu', 'wasm'] : ['wasm']
let parakeet: ParakeetModelLike | null = null
let lastError: unknown = null
for (const b of backends) {
const options = {
backend: b,
encoderQuant: encoderQuantFor(b),
decoderQuant: this.config.decoderQuant ?? ('int8' as const),
...(this.config.wasmPaths ? { wasmPaths: this.config.wasmPaths } : {}),
progress: progressCallback,
}
// Up to two tries per backend: parakeet.js's initOrt IGNORES its
// wasmPaths option (upstream bug) and defaults the ORT runtime to the
// jsdelivr CDN, which any `script-src 'self'` CSP blocks. The failed
// first try exposes the lib's ort instance on globalThis — point its
// env at our same-origin files there and retry.
for (let attempt = 0; attempt < 2 && !parakeet; attempt++) {
try {
parakeet = (await fromHub(model, options)) as unknown as ParakeetModelLike
} catch (error) {
lastError = error
if (!this.applyGlobalWasmPaths()) break
}
}
if (parakeet) break
}
if (!parakeet) {
progress({ status: 'error' })
throw lastError
}
progress({ status: 'ready' })
return parakeet
})()
this.modelPromise.catch(() => {
// Allow a retry on the next startListening call; the caller already
// receives this rejection via startListening's error handling.
this.modelPromise = null
})
return this.modelPromise
}
/**
* Points parakeet.js's own ONNX Runtime instance (exposed on
* `globalThis.ort` after its first init) at the configured same-origin
* wasmPaths. Needed because parakeet.js's initOrt ignores the wasmPaths
* option and we cannot import its NESTED onnxruntime-web copy directly.
* @returns True when the path was applied (a retry is worthwhile).
*/
private applyGlobalWasmPaths(): boolean {
if (!this.config.wasmPaths) return false
const ort = (globalThis as { ort?: { env?: { wasm?: { wasmPaths?: string } } } }).ort
if (!ort?.env?.wasm) return false
ort.env.wasm.wasmPaths = this.config.wasmPaths
return true
}
/**
* Starts on-device speech recognition: opens the microphone, loads the
* model if needed, and emits a final transcript after each pause.
* @param options - Recognition options. `language` is not passed to the
* model (Parakeet auto-detects) — check coverage up front with
* `supportsRecognitionLanguage()`.
* @param handlers - Callbacks for transcripts, state changes, and errors.
*/
startListening(options?: VoiceRecognitionOptions, handlers?: VoiceEventHandlers): void {
if (this.disposed || this.listening) return
void options
this.handlers = handlers ?? {}
const getUserMedia = getGetUserMedia()
if (!getUserMedia) {
this.handlers.onError?.({
code: 'not-supported',
message: 'Microphone capture is not supported in this environment',
})
return
}
this.listening = true
// 'processing' until the model is ready — consumers use the transition to
// 'listening' to clear their "preparing dictation" indicator. Speech is
// captured during the load and transcribed once the model arrives.
this.setState('processing')
void (async () => {
try {
// Kick off the model load and mic open in parallel — the model can
// take a while on first use and the mic prompt needs to appear now.
const modelReady = this.loadModel()
const stream = await getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
if (!this.listening || this.disposed) {
for (const track of stream.getTracks()) track.stop()
return
}
this.mediaStream = stream
const context = new AudioContext({ sampleRate: MODEL_SAMPLE_RATE })
this.audioContext = context
this.sourceNode = context.createMediaStreamSource(stream)
// ScriptProcessorNode is deprecated but has no replacement that works
// without shipping a separate AudioWorklet module file, which npm
// consumers' bundlers would each need to handle. Revisit when
// AudioWorklet.addModule accepts inline modules everywhere.
this.processor = context.createScriptProcessor(FRAME_SIZE, 1, 1)
this.processor.onaudioprocess = (event) => {
this.handleFrame(event.inputBuffer.getChannelData(0), context.sampleRate)
}
this.sourceNode.connect(this.processor)
this.processor.connect(context.destination)
await modelReady
if (this.listening && !this.disposed) {
this.setState('listening')
}
} catch (error) {
this.teardownCapture()
this.listening = false
this.setState('error')
const isPermission = error instanceof DOMException && error.name === 'NotAllowedError'
this.handlers.onError?.({
code: isPermission ? 'not-allowed' : 'start-failed',
message: error instanceof Error ? error.message : 'Failed to start speech recognition',
})
}
})()
}
/**
// … truncated — the full declaration is in node_modules/@molecule/app-ai-voice-parakeet/dist/provider.d.ts
Functions
supportsRecognitionLanguage(language, model)
Checks whether a Parakeet model can transcribe the given BCP-47 language. Use this at wiring time to decide between this provider and a multilingual Whisper provider.
function supportsRecognitionLanguage(language: string, model?: string): Promise<boolean>
language— BCP-47 language tag (e.g. 'en-US', 'ja').model— parakeet.js model key or repo id. Defaults to the provider's default model.
Returns: A promise resolving to true when the model covers the language.
createProvider(config)
Creates a ParakeetVoiceProvider instance.
function createProvider(config?: ParakeetVoiceConfig): ParakeetVoiceProvider
config— Optional configuration (model, backend, VAD tuning).
Returns: A ParakeetVoiceProvider running speech-to-text on-device.
Constants
provider
The provider implementation — the fleet-standard typed provider const.
Wire it once at startup: setProvider(provider) from @molecule/app-ai-voice.
It is a lazy proxy: construction is deferred to the first property access, so
importing this module never throws and needs no config up front. Use
createProvider(config) instead when you need a custom model, backend, or
VAD tuning.
const provider: AIVoiceProvider
Core Interface
Implements @molecule/app-ai-voice interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/app-ai-voice'
import { provider } from '@molecule/app-ai-voice-parakeet'
export function setupAiVoiceParakeet(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/app-ai-voice^1.0.1
Runtime Dependencies
@molecule/app-ai-voiceparakeet.js
The first use downloads the model (hundreds of MB at int8, cached by the
browser afterwards) — always wire onModelProgress to a visible
indicator. The default parakeet-tdt-0.6b-v3 model covers en, fr, de,
es, it, pt, nl, pl, ru, uk, ja, ko and zh with automatic language
detection; for other languages wire @molecule/app-ai-voice-whisper
instead (smaller download, broader language coverage, lower accuracy).
Transcripts arrive as final chunks after each pause; there are no
interim results.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Pressing the mic / press-to-talk control calls
startListening()and speech appears as a live transcript in the UI — interim VoiceTranscriptEvent updates (isFinal: false) refresh the text as you speak, and the final one (isFinal: true) commits the recognized text viaonTranscript. - Stopping (
stopListening()) halts recognition cleanly: the transcript stops updating, the mic control returns to idle, and no stray final result fires afterward. - Denying mic permission (or unavailable hardware) fires
onErrorwith a VoiceErrorEvent (code: 'not-allowed') and shows a visible message — the mic control never sits as a silent dead button. - The app's text-to-speech action calls
speak(text, ...)and you actually hear the given text; the chosen VoiceDescriptor / VoiceSynthesisOptions are honored (voice,language, andratechange the audible output), andstopSpeaking()cuts it off. - The recognition VoiceRecognitionOptions
languageis respected — setting it to a non-default locale (e.g. 'fr-FR') recognizes in that language rather than always defaulting to English. - A visible listening/speaking indicator tracks
getState()/onStateChange— it reads 'listening' while the mic is open and 'speaking' during synthesis, and returns to 'idle' when each ends. - Voice UI is feature-gated on
isRecognitionSupported()/isSynthesisSupported()(andgetAvailableVoices()is awaited, not read synchronously) so an unsupported browser hides the control instead of throwing. - Microphone access is requested only from a user gesture, its denial is handled gracefully, and captured audio/transcripts stay within the session — nothing is logged or sent anywhere the app didn't intend.