@molecule/api-ai-speech-whistle
Provider bond · ai-speech · API (Node) · v1.1.2 · Apache-2.0
Whistle speech provider for molecule.dev — Cactus Compute’s 16.9 MB seven-language speech-to-text running in-process on CPU via the needle WASM engine (no server, no key)
npm install @molecule/api-ai-speech-whistlenpm · Source on GitHub · Implements @molecule/api-ai-speech
How it works
@molecule/api-ai-speech-whistle is a provider bond on the API (Node) side: it implements the ai-speech core interface (@molecule/api-ai-speech) 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 { requireProvider, setProvider } from '@molecule/api-ai-speech'
import { provider } from '@molecule/api-ai-speech-whistle'
setProvider(provider)
const speech = requireProvider()
const result = await speech.transcribe!({
audio: wavBytes,
timestampGranularity: 'word',
prompt: 'Acme\nvialoh', // keyword biasing toward your names
})
for (const word of result.words ?? []) {
console.log(word.start.toFixed(2), word.word)
}Works with: @molecule/api-ai-speech, @molecule/api-secrets
Secrets: WHISTLE_ENGINE_URL (optional), WHISTLE_WEIGHTS_URL (optional), NEEDLE_WHISTLE_WEIGHTS (optional), NEEDLE_ENGINE_PATH (optional), NEEDLE_WASM_PATH (optional)
Reference
Whistle (Cactus Compute) provider for @molecule/api-ai-speech.
Speech-to-text that runs IN-PROCESS under Node on the needle WebAssembly
engine — Cactus Compute's 16.9 MB open-weights Whistle model (Apache-2.0),
CPU-only, in exactly seven languages (en, de, fr, es, it, nl, pl) with
automatic language detection, keyword biasing and per-word timestamps. No
server to run, no API key, no per-minute billing; audio never leaves the
process. Implements transcribe and transcribeStream.
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 { requireProvider, setProvider } from '@molecule/api-ai-speech'
import { provider } from '@molecule/api-ai-speech-whistle'
setProvider(provider)
const speech = requireProvider()
const result = await speech.transcribe!({
audio: wavBytes,
timestampGranularity: 'word',
prompt: 'Acme\nvialoh', // keyword biasing toward your names
})
for (const word of result.words ?? []) {
console.log(word.start.toFixed(2), word.word)
}
Type
provider
Installation
npm install @molecule/api-ai-speech-whistle @molecule/api-ai-speech @molecule/api-secrets
API
Interfaces
ModelProgressEvent
Progress event emitted while the engine and weights download/initialize.
interface ModelProgressEvent {
/** Lifecycle stage of the engine 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
}
WhistleWord
One transcribed word with timing and confidence.
interface WhistleWord {
/** The word (includes its trailing punctuation). */
word: string
/** Start time in seconds from the start of the clip. */
start: number
/** End time in seconds from the start of the clip. */
end: number
/** Decoder confidence for this word, 0 to 1. */
probability: number
}
WhistleTranscript
Result of one needle_transcribe call, as returned by the engine.
interface WhistleTranscript {
/** The transcript. Silence/steady noise gives `''`. */
text: string
/** Detected or forced language (`''` for silence). */
language: string
/** Milliseconds to the first token. */
ttft_ms: number
/** Decoder tokens per second after the first token. */
decode_tps: number
/** Per-word timing, present when word timestamps were requested. */
words?: WhistleWord[]
}
WhistleStreamResult
Result of one needle_stream_transcribe_process / _stop call.
interface WhistleStreamResult {
/** Words this call committed (agreed on by two consecutive passes). */
text: string
/** The committed words with timings. */
words: WhistleWord[]
/** The unconfirmed tail (may still be revised by later passes). */
pending: string
/** Language used for the stream (`''` for silence). */
language: string
/** Seconds of audio the stream has received. */
received: number
/** Wall milliseconds this pass took. */
pass_ms: number
}
WhistleEngineSource
Where each engine file comes from: a URL (fetched) or a local path (read).
interface WhistleEngineSource {
/** `needle.js` — fetched from this URL when `enginePath` is unset. */
jsUrl: string
/** `needle.wasm` — fetched from this URL when `wasmPath` is unset. */
wasmUrl: string
/** `whistle.cact` — fetched from this URL when `weightsPath` is unset. */
weightsUrl: string
/** Local path to `needle.js`, when provided (needs `wasmPath`). */
enginePath?: string
/** Local path to `needle.wasm`, when provided (needs `enginePath`). */
wasmPath?: string
/** Local path to `whistle.cact`, when provided. */
weightsPath?: string
}
WhistleEngineCallOptions
Options the engine calls accept for language and keyword biasing.
interface WhistleEngineCallOptions {
/** ISO 639-1 language to force, or null to detect. */
language?: string | null
/** Keywords as an array or newline-separated string, or null for none. */
keywords?: string[] | string | null
}
WhistleEngine
The loaded engine, ready to transcribe.
interface WhistleEngine {
/** Transcribes 16 kHz mono float PCM (≤ 30 s per call). */
transcribe(
pcm: Float32Array,
options?: WhistleEngineCallOptions & { wordTimestamps?: boolean },
): WhistleTranscript
/** Appends ~1 s of PCM to the live stream; commits agreed words. */
streamProcess(pcm: Float32Array, options?: WhistleEngineCallOptions): WhistleStreamResult
/** Ends the stream, committing the tail; the next process call starts fresh. */
streamStop(): WhistleStreamResult
}
WhistleSpeechConfig
Configuration for the Whistle speech provider. Every field falls back to its env var, read on each call (never at import time). Local paths win over URLs when both are set; with nothing set, the engine and weights are fetched from Hugging Face on first use and cached in memory for the process's lifetime.
interface WhistleSpeechConfig {
/**
* URL of the Cactus `needle.js` WASM glue. `needle.wasm` is resolved from
* the same directory. Defaults to the `WHISTLE_ENGINE_URL` env var, then
* the Hugging Face copy.
*/
engineUrl?: string
/**
* URL of the `whistle.cact` weights (16.9 MB). Defaults to the
* `WHISTLE_WEIGHTS_URL` env var, then the Hugging Face copy.
*/
weightsUrl?: string
/**
* Local path to `needle.js` (requires `wasmPath`). Defaults to the
* `NEEDLE_ENGINE_PATH` env var.
*/
enginePath?: string
/**
* Local path to `needle.wasm` (requires `enginePath`). Defaults to the
* `NEEDLE_WASM_PATH` env var.
*/
wasmPath?: string
/**
* Local path to `whistle.cact` — the vendor's own env name
* (`NEEDLE_WHISTLE_WEIGHTS`) is honoured. Wins over `weightsUrl`.
*/
weightsPath?: string
/**
* ISO 639-1 language forced for every transcription (one of `en de fr es
* it nl pl`). Leave unset to auto-detect.
*/
defaultLanguage?: string
/**
* Words and phrases (or one newline-separated string) fed to Whistle's
* keyword biasing when a call passes no `prompt`. Explicit opt-in only:
* biasing CHANGES transcripts toward the given words.
*/
defaultKeywords?: string[] | string
/** Timeout for engine/weights downloads in milliseconds. Defaults to 120000. */
downloadTimeoutMs?: number
}
DecodedWav
A decoded WAV, ready for the engine.
interface DecodedWav {
/** Mono samples at 16 kHz, values in [-1, 1]. */
samples: Float32Array
/** Always 16000. */
sampleRate: number
/** Duration in seconds. */
duration: number
/** The source's original sample rate. */
sourceSampleRate: number
}
Types
WhistleLanguage
The seven languages Whistle transcribes (auto-detected or forced).
type WhistleLanguage = 'en' | 'de' | 'fr' | 'es' | 'it' | 'nl' | 'pl'
Classes
WhistleEngineError
Error thrown when the Whistle engine cannot be loaded or a call fails.
code names the stage: download-failed, load-failed,
transcribe-failed, stream-failed.
class WhistleEngineError extends Error {
/** Which stage failed. */
readonly code: 'download-failed' | 'load-failed' | 'transcribe-failed' | 'stream-failed'
/**
* Create the error.
* @param code - The failing stage.
* @param message - Human-readable message.
* @param cause - The underlying error, if any.
*/
constructor(code: WhistleEngineError['code'], message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause })
this.name = 'WhistleEngineError'
this.code = code
}
}
WhistleWavError
Error thrown when audio bytes are not decodable uncompressed WAV.
code is not-wav (not a RIFF/WAVE container), unsupported-format
(compressed/exotic fmt payload) or truncated (missing chunks).
class WhistleWavError extends Error {
/** Why the decode failed. */
readonly code: 'not-wav' | 'unsupported-format' | 'truncated'
/**
* Create the error.
* @param code - The failure kind.
* @param message - Human-readable message.
*/
constructor(code: WhistleWavError['code'], message: string) {
super(message)
this.name = 'WhistleWavError'
this.code = code
}
}
FloatBlockResampler
Streaming linear resampler over float samples: push arbitrary-size chunks at the source rate, receive fixed-size blocks at the target rate.
class FloatBlockResampler {
private raw: Float32Array = new Float32Array(0)
/** Absolute input index of `raw[0]`. */
private rawStart = 0
/** Total output samples produced so far. */
private produced = 0
/**
* Creates the resampler.
* @param fromRate - Source sample rate in Hz.
* @param toRate - Target sample rate in Hz.
* @param blockSize - Output samples per emitted block.
* @throws {Error} When either rate is not a finite, positive number, or
* `blockSize` is not a finite, positive number — in every such case the
* emit/identity loop's bound never advances, so `push()` would emit
* blocks forever inside a single call (an event-loop hang with an
* unbounded block array, never a catchable error).
*/
constructor(
private readonly fromRate: number,
private readonly toRate: number,
private readonly blockSize: number,
) {
for (const [name, rate] of [
['fromRate', fromRate],
['toRate', toRate],
] as const) {
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(
`FloatBlockResampler: ${name} must be a finite, positive sample rate (got ${rate})`,
)
}
}
// The same hang the rate guard exists for, one variable over: a
// zero/negative blockSize never advances the identity path's `start`
// (`start += blockSize`) and a NaN one never advances `produced`
// (`produced += blockSize`), so `push()` emits blocks forever. The
// provider's own block size is a constant, but this class is exported.
if (!Number.isFinite(blockSize) || blockSize <= 0) {
throw new Error(
`FloatBlockResampler: blockSize must be a finite, positive number of samples (got ${blockSize})`,
)
}
}
/**
* Pushes one chunk of source-rate samples.
* @param chunk - Input samples.
* @returns Zero or more complete output blocks.
*/
push(chunk: Float32Array): Float32Array[] {
if (this.fromRate === this.toRate) {
// Identity path: no fractional bookkeeping, just fixed-size chopping.
const blocks: Float32Array[] = []
let start = 0
const merged = new Float32Array(this.raw.length + chunk.length)
merged.set(this.raw)
merged.set(chunk, this.raw.length)
while (start + this.blockSize <= merged.length) {
blocks.push(merged.subarray(start, start + this.blockSize))
start += this.blockSize
}
this.raw = merged.slice(start)
return blocks
}
const ratio = this.fromRate / this.toRate
const merged = new Float32Array(this.raw.length + chunk.length)
merged.set(this.raw)
merged.set(chunk, this.raw.length)
this.raw = merged
const blocks: Float32Array[] = []
for (;;) {
// The block's last output sample reads input floor((produced + size - 1)
// * ratio) and needs the sample AFTER it for interpolation.
const lastNeeded = Math.floor((this.produced + this.blockSize - 1) * ratio) + 1
if (lastNeeded > this.rawStart + this.raw.length - 1) break
const block = new Float32Array(this.blockSize)
for (let i = 0; i < this.blockSize; i++) {
const position = (this.produced + i) * ratio - this.rawStart
const index = Math.floor(position)
const frac = position - index
const a = this.raw[index] ?? 0
const b = this.raw[index + 1] ?? a
block[i] = a + (b - a) * frac
}
this.produced += this.blockSize
// The window may only be advanced as far as the data that actually
// exists: `rawStart + raw.length` is the absolute index of the NEXT
// input sample — the first one the next `push()` appends — and the
// block-emit bound above proves only that THIS block's last output had
// its right neighbour buffered, not that the next block's left
// neighbour exists yet. `floor(produced * ratio)` can therefore lie one
// or more samples PAST the end of the buffer (when a chunk boundary
// lands a sample or two short of a block's advance), and slicing the
// window there left `rawStart` larger than the next chunk's first
// absolute index: every later chunk was then attributed that many
// indices too high — a shifted timeline (wrong interpolated values) and
// a spurious extra output sample at the end. Clamping to `raw.length`
// drops exactly the samples that all sit before that position; the
// `rawStart + raw.length` invariant (`=== samples pushed`) is preserved,
// which also keeps the resampler from stalling on `lastNeeded`.
const keepFrom = Math.min(Math.floor(this.produced * ratio) - this.rawStart, this.raw.length)
if (keepFrom > 0) {
this.raw = this.raw.slice(keepFrom)
this.rawStart += keepFrom
}
blocks.push(block)
}
return blocks
}
/**
* Emits the remaining output computable from what is buffered, as one
* final partial block. The consumed window is released (see `rawStart`
* below), so a `push()` after this flush attributes its samples from the
* next input position. In the resampling path the window's LAST sample is
* retained, because the next output sample's input position always lands in
* `(lastAbsolute, lastAbsolute + ratio]` — its left interpolation neighbour
* is that last sample, and dropping it would interpolate the first output
* sample after the flush against a zero.
* @returns The partial block, or null when no further output is computable
* from what is buffered.
*/
flush(): Float32Array | null {
if (this.raw.length === 0) return null
const ratio = this.fromRate / this.toRate
const lastAbsolute = this.rawStart + this.raw.length - 1
const count = Math.floor(lastAbsolute / ratio) - this.produced + 1
if (count <= 0) return null
const block = new Float32Array(count)
for (let i = 0; i < count; i++) {
const position = (this.produced + i) * ratio - this.rawStart
const index = Math.floor(position)
const frac = position - index
const a = this.raw[index] ?? 0
const b = this.raw[index + 1] ?? a
block[i] = a + (b - a) * frac
}
this.produced += count
// Advance the window past the samples this flush consumed, exactly as
// push()'s `keepFrom` does for the samples IT consumes: `rawStart` is the
// absolute input index of `raw[0]`, and leaving it behind means a push()
// after this flush attributes its samples indices that were already used
// — `produced` has moved on, so no future block's `lastNeeded` can ever
// fall inside the buffer again and the resampler answers nothing forever
// (push emits no blocks, flush reports null), silently dropping every
// sample it is fed from then on.
//
// The identity path consumes the whole window. The RESAMPLING path keeps
// its last sample: the next output sample's input position is
// `(produced) * ratio`, which lies in `(lastAbsolute, lastAbsolute + ratio]`,
// so its left interpolation neighbour is exactly the buffer's last sample.
// Releasing it made that sample (and, when it straddles the same input
// slot, the one after it) interpolate against a zero — the flushed-then-
// pushed timeline diverged from the same samples pushed in one go whenever
// `fromRate / toRate` is not an integer, which the 3:1 test ratio could
// not show. Keeping it here is what makes the timeline continuous; the
// anchor (`rawStart = lastAbsolute`) is its absolute index, so the next
// push still attributes its samples from the right position.
if (this.fromRate === this.toRate) {
this.rawStart += this.raw.length
this.raw = new Float32Array(0)
} else {
this.rawStart = lastAbsolute
this.raw = this.raw.slice(this.raw.length - 1)
}
return block
}
}
Functions
normalizeKeywords(keywords)
Normalizes keywords: an array becomes newline-separated (the engine's format); a string is passed through.
function normalizeKeywords(keywords: string | string[] | null | undefined): string | null
keywords— Keywords as array or newline-separated string.
Returns: The newline-separated string, or null when absent/empty.
loadWhistleEngine(source, onProgress, timeoutMs)
Loads (or returns the cached) Whistle engine: one wasm instance per source combination, shared by every provider in the process. A failed load is evicted so the next call retries.
function loadWhistleEngine(
source: WhistleEngineSource,
onProgress?: (event: ModelProgressEvent) => void,
timeoutMs?: number,
): Promise<WhistleEngine>
source— Where the engine files come from.onProgress— Download/initialization progress sink.timeoutMs— Fetch timeout for engine/weights downloads.
Returns: The ready engine.
createProvider(config)
Creates a Whistle speech provider instance.
function createProvider(config?: WhistleSpeechConfig): AISpeechProvider
config— Optional overrides (URLs, local paths, language, keywords).
Returns: An AISpeechProvider transcribing in-process via the WASM engine.
resampleLinear(samples, fromRate, toRate)
Linearly resamples mono float samples to the target rate.
function resampleLinear(
samples: Float32Array<ArrayBufferLike>,
fromRate: number,
toRate: number,
): Float32Array<ArrayBufferLike>
samples— Input samples atfromRateHz.fromRate— The input sample rate.toRate— The wanted sample rate.
Returns: Samples at toRate Hz.
decodeWavToMono16k(audio)
Decodes uncompressed WAV bytes (PCM integer or float) to mono 16 kHz float
samples for the Whistle engine. Source sample rates outside
MIN_SOURCE_SAMPLE_RATE–MAX_SOURCE_SAMPLE_RATE Hz are
rejected: no real audio lives there, and a hostile rate would scale the
16 kHz resample into a fatal allocation.
function decodeWavToMono16k(
audio: Uint8Array<ArrayBufferLike> | Buffer<ArrayBufferLike>,
): DecodedWav
audio— The WAV file bytes.
Returns: The decoded audio.
Constants
DEFAULT_WHISTLE_ENGINE_URL
Default URL of the needle.js WASM glue (Hugging Face mirror).
const DEFAULT_WHISTLE_ENGINE_URL: 'https://huggingface.co/Cactus-Compute/needle3/resolve/main/wasm/needle.js'
DEFAULT_WHISTLE_WEIGHTS_URL
Default URL of the whistle.cact weights (16.9 MB, Hugging Face).
const DEFAULT_WHISTLE_WEIGHTS_URL: 'https://huggingface.co/Cactus-Compute/whistle/resolve/main/whistle.cact'
WHISTLE_SAMPLE_RATE
Sample rate Whistle transcribes (16 kHz mono float PCM).
const WHISTLE_SAMPLE_RATE: 16000
WHISTLE_MAX_CLIP_SECONDS
Longest clip one needle_transcribe pass accepts.
const WHISTLE_MAX_CLIP_SECONDS: 30
provider
The provider implementation.
const provider: AISpeechProvider
aiSpeechWhistleSecretDefinitions
Secret definitions used by the Whistle speech bond (all optional config).
const aiSpeechWhistleSecretDefinitions: SecretDefinition[]
WHISTLE_LANGUAGES
Whistle's full language list, as ISO 639-1 codes.
const WHISTLE_LANGUAGES: readonly WhistleLanguage[]
Core Interface
Implements @molecule/api-ai-speech interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-ai-speech'
import { provider } from '@molecule/api-ai-speech-whistle'
export function setupAiSpeechWhistle(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-ai-speech>=1.1.0@molecule/api-secrets^1.0.1
Environment Variables
WHISTLE_ENGINE_URL(optional) — Whistle engine URL- Setup: URL of the Cactus needle.js WASM glue; needle.wasm is resolved from the same directory. Defaults to the Hugging Face copy.
- Get it here: https://cactuscompute.com/blog/whistle
- Example:
https://huggingface.co/Cactus-Compute/needle3/resolve/main/wasm/needle.js
WHISTLE_WEIGHTS_URL(optional) — Whistle weights URL- Setup: URL of the 16.9 MB whistle.cact model file. Defaults to the Hugging Face copy.
- Get it here: https://huggingface.co/Cactus-Compute/whistle
- Example:
https://huggingface.co/Cactus-Compute/whistle/resolve/main/whistle.cact
NEEDLE_WHISTLE_WEIGHTS(optional) — Whistle weights path- Setup: Local path to whistle.cact (the vendor Python layer uses the same variable). Wins over WHISTLE_WEIGHTS_URL.
- Get it here: https://cactuscompute.com/blog/needle-python-docs
- Example:
/opt/models/whistle.cact
NEEDLE_ENGINE_PATH(optional) — Whistle engine JS path- Setup: Local path to needle.js; set together with NEEDLE_WASM_PATH to run fully offline.
- Get it here: https://cactuscompute.com/blog/needle-supported-devices
- Example:
/opt/needle/wasm/needle.js
NEEDLE_WASM_PATH(optional) — Whistle engine wasm path- Setup: Local path to needle.wasm; set together with NEEDLE_ENGINE_PATH to run fully offline.
- Get it here: https://cactuscompute.com/blog/needle-supported-devices
- Example:
/opt/needle/wasm/needle.wasm
Runtime Dependencies
-
@molecule/api-ai-speech -
@molecule/api-secrets -
First call downloads ~18 MB (engine ~1 MB +
whistle.cact16.9 MB) from Hugging Face unless local paths are configured (NEEDLE_WHISTLE_WEIGHTSfor the weights — the vendor's own env name — andNEEDLE_ENGINE_PATH+NEEDLE_WASM_PATHfor the engine, fully offline). The download is cached in memory for the process lifetime; self-host or pin URLs withWHISTLE_ENGINE_URL/WHISTLE_WEIGHTS_URL. -
Input must be uncompressed PCM WAV (8/16/24/32-bit integer or 32-bit float, any rate, any channel count): it is mixed to mono and resampled to the 16 kHz float the engine takes. webm/m4a/mp3 and μ-law/ADPCM WAV THROW — decode to WAV first (e.g.
ffmpeg -i in.webm out.wav); never passed through. -
Seven languages, full stop (
en de fr es it nl pl): a call with any otherlanguagethrows rather than transcribing in the wrong language — use@molecule/api-ai-speech-openai(~99 languages) for the rest. Leavelanguageunset to auto-detect. -
30 s per pass (Whistle's window): longer audio is transcribed in consecutive 30 s windows and joined — words carry corrected absolute times, but each window caps at 320 transcript tokens, so very dense long-form audio can truncate per window (meetings/long-form are not Whistle's strength).
-
Silence or steady noise returns EMPTY text and EMPTY language — that is the engine's "nothing spoken" answer, not a failure; do not render
''as a detected language. -
Keyword biasing deliberately CHANGES transcripts toward the given words (
prompt, newline-separated). Pass identifiers you actually care about; a large "helpful" list distorts neutral words. -
Streaming is append-only (
streamingAppendOnly: true):deltaevents carry words two consecutive passes agreed on (never revised);partialevents carry the still-unconfirmed tail; onefinalwith the full text ends the stream. Audio is PCM16 LE mono atsampleRate(default 16 kHz, resampled here), buffered into ~1 s passes. A stream session HOLDS the engine's one stream from its first block until its stop, so a concurrenttranscribeStreamcall queues behind the running one instead of interleaving its audio into it (audio already delivered waits in the resampler; audio not yet pulled waits in the caller's transport). -
One model per process, not thread-safe: the wasm engine is shared by every provider instance in the process; calls are synchronous and serialize naturally. The first in-flight call blocks the event loop for the download + load; transcription of a 10 s clip takes roughly a second of CPU.
-
STT only:
synthesize*,translate,diarizeandlistVoicesare not implemented (feature-detect per the core docs). -
Errors are
WhistleEngineError(code:download-failed/load-failed/transcribe-failed/stream-failed) orWhistleWavError(code:not-wav/unsupported-format/truncated).
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. The wired provider implements a subset (TTS, STT, or both) — feature-detect and run only the direction(s) it actually exposes:
- TTS: each read-aloud / narration / voice-note flow the app defines
returns real PLAYABLE audio — the response carries an audio Content-Type
(e.g.
audio/mpeg) and non-trivial bytes (not a 0-byte file, not a JSON-encoded blob), and the UI's<audio>/player actually plays it. The spoken audio reflects the input text you sent. - TTS: synthesizing two different strings yields two audibly different clips (distinct byte payloads) — proves the text drives synthesis and you are not hearing a cached/placeholder file.
- TTS: any exposed voice/format/speed option takes effect — switching
voice (
voice/voiceId) or output format (responseFormat/outputFormat) changes the returned audio (audible voice, Content-Type, or file extension); an unknown voice/format surfaces a visible error rather than silently falling back. - STT: transcribing a KNOWN spoken clip returns text that MATCHES the words spoken (not empty, not garbage) and renders in the UI; translating a non-English clip returns English text. Any timestamps the UI shows line up with the audio.
- STT: a silent / empty / too-short clip is handled gracefully — an empty or "no speech detected" result shown in the UI, never a crash or a spinner that hangs forever.
- The audio artifact is served from the app's OWN origin (a stored upload or a route the app serves), NOT a raw expiring provider URL — copy the audio src, reload it later, and confirm it still plays.
- A provider error (bad key, quota, unsupported format/voice, or a capability the wired provider lacks) surfaces as a readable message in the UI, not an unhandled crash or a blank/broken player.
- Synthesis/transcription runs SERVER-SIDE and authorized: the provider key never reaches the browser (check the network tab / client bundle), and no unauthenticated or unbounded endpoint lets a caller fire arbitrary synthesize/transcribe calls — each is billed per character / per minute of audio, so an open endpoint is a cost-DoS vector.