@molecule/api-ai-speech-confucius4
Provider bond · ai-speech · API (Node) · v1.0.1 · Apache-2.0
Confucius4-R2T2 speech provider for molecule.dev — self-hosted append-only streaming speech-to-text
npm install @molecule/api-ai-speech-confucius4npm · Source on GitHub · Implements @molecule/api-ai-speech
How it works
@molecule/api-ai-speech-confucius4 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-confucius4'
// Reads CONFUCIUS4_WS_URL (default ws://127.0.0.1:8272/asr_stream_api_v1) per call.
setProvider(provider)
const speech = requireProvider()
// micChunks: AsyncIterable<Uint8Array> of PCM16 mono audio forwarded by your API.
for await (const event of speech.transcribeStream!(micChunks, { sampleRate: 48000 })) {
if (event.type === 'delta') process.stdout.write(event.text) // never revised
if (event.type === 'turn-end') process.stdout.write('\n')
}Works with: @molecule/api-ai-speech, @molecule/api-secrets
Secrets: CONFUCIUS4_WS_URL (optional), CONFUCIUS4_SECRET_KEY (optional), CONFUCIUS4_SYSTEM_PROMPT (optional)
Reference
Confucius4-R2T2 provider for @molecule/api-ai-speech.
Append-only streaming speech-to-text on your own GPU (NetEase Youdao's 2B
Confucius4-R2T2, built on Qwen3-ASR): text it has sent never changes, which
suits live captions that must not rewrite themselves. Tuned for Chinese and
English; supports context/hotword prompts. Implements transcribeStream
(streamingAppendOnly: true) and a buffer transcribe built on it.
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-confucius4'
// Reads CONFUCIUS4_WS_URL (default ws://127.0.0.1:8272/asr_stream_api_v1) per call.
setProvider(provider)
const speech = requireProvider()
// micChunks: AsyncIterable<Uint8Array> of PCM16 mono audio forwarded by your API.
for await (const event of speech.transcribeStream!(micChunks, { sampleRate: 48000 })) {
if (event.type === 'delta') process.stdout.write(event.text) // never revised
if (event.type === 'turn-end') process.stdout.write('\n')
}
Type
provider
Installation
npm install @molecule/api-ai-speech-confucius4 @molecule/api-ai-speech @molecule/api-secrets
API
Interfaces
DecodedWav
Decoded PCM16 audio.
interface DecodedWav {
/** Sample rate of the decoded audio in Hz. */
sampleRate: number
/** Number of interleaved channels in the source file. */
channels: number
/** Mono PCM16 samples (multi-channel input is averaged down). */
samples: Int16Array
}
RealtimeSocket
An open (or opening) realtime socket.
interface RealtimeSocket {
/**
* Send a text or binary frame.
*
* @param data - JSON text or raw bytes.
*/
send(data: string | Uint8Array): void
/**
* Close the socket.
*
* @param code - Close code (default 1000).
* @param reason - Close reason.
*/
close(code?: number, reason?: string): void
}
RealtimeSocketHandlers
Callbacks a RealtimeSocketFactory reports socket activity to.
interface RealtimeSocketHandlers {
/** The connection is open. */
onOpen(): void
/** A frame arrived (text frames as strings, binary frames as bytes). */
onMessage(data: string | Uint8Array): void
/** The connection closed. */
onClose(code: number, reason: string): void
/** The connection failed. */
onError(message: string): void
}
Confucius4SpeechConfig
Configuration for the Confucius4-R2T2 speech provider. Every field falls back to its env var, read on each call (never at import time).
interface Confucius4SpeechConfig {
/**
* WebSocket URL of `ws_server.py`. Defaults to the `CONFUCIUS4_WS_URL` env
* var, then `ws://127.0.0.1:8272/asr_stream_api_v1`.
*/
wsUrl?: string
/**
* The `secret_key` sent in the header message. Defaults to
* `CONFUCIUS4_SECRET_KEY`, then `test0102` (the server's hardcoded whitelist).
*/
secretKey?: string
/**
* Context / hotword prompt (`system_prompt`, at most 4000 characters) used
* when a call passes no `prompt`. Defaults to `CONFUCIUS4_SYSTEM_PROMPT`.
*/
systemPrompt?: string
/** `language` header value when a call passes none. Defaults to `'zhen'` (auto Chinese/English). */
defaultLanguage?: string
/** `use_vad` header value: let the server's Stream-VAD cut segments. Defaults to `false`. */
useVad?: boolean
/** `mode` header value. Defaults to `'slow'` (the reference client's value). */
mode?: string
/** `smooth` header value. Omitted unless set. */
smooth?: boolean
/**
* How long a stream waits, after sending end-of-stream, for the server to
* close the socket before it gives up. Defaults to 30000 ms.
*/
finalTimeoutMs?: number
/** WebSocket factory. Defaults to the runtime's global `WebSocket`. */
socketFactory?: RealtimeSocketFactory
}
Types
RealtimeSocketFactory
Opens a realtime socket.
type RealtimeSocketFactory = (
url: string,
headers: Record<string, string>,
handlers: RealtimeSocketHandlers,
) => RealtimeSocket
url—ws://orwss://URL.headers— Extra handshake headers (e.g.Authorization).handlers— Activity callbacks.
Returns: The socket.
Classes
Pcm16Resampler
Streaming PCM16 little-endian resampler (linear interpolation). Keeps the state a chunked stream needs: an odd trailing byte and the fractional read position carry over to the next chunk, so chunk boundaries add no clicks.
class Pcm16Resampler {
private readonly step: number
private tail = new Int16Array(0)
private position = 0
private carry: number | null = null
/**
* Create a resampler.
*
* @param fromRate - Input sample rate in Hz.
* @param toRate - Output sample rate in Hz.
*/
constructor(
private readonly fromRate: number,
private readonly toRate: number,
) {
if (!Number.isFinite(fromRate) || fromRate <= 0 || !Number.isFinite(toRate) || toRate <= 0) {
throw new Error(`Invalid sample rate: ${fromRate} → ${toRate}`)
}
this.step = fromRate / toRate
}
/**
* Resample one chunk of PCM16 LE bytes.
*
* @param chunk - Raw bytes (may split a sample; the odd byte is carried).
* @returns Resampled PCM16 LE bytes (possibly empty).
*/
push(chunk: Uint8Array): Uint8Array {
return samplesToBytes(this.pushSamples(this.decode(chunk)))
}
/**
* Emit whatever the last chunk left pending.
*
* @returns Remaining PCM16 LE bytes.
*/
flush(): Uint8Array {
return samplesToBytes(this.flushSamples())
}
/**
* Resample decoded samples.
*
* @param samples - Mono samples.
* @returns Resampled samples.
*/
pushSamples(samples: Int16Array): Int16Array {
if (this.fromRate === this.toRate) return samples
const buffer = new Int16Array(this.tail.length + samples.length)
buffer.set(this.tail)
buffer.set(samples, this.tail.length)
const out: number[] = []
for (;;) {
const index = Math.floor(this.position)
if (index + 1 >= buffer.length) break
const fraction = this.position - index
out.push(Math.round(buffer[index] * (1 - fraction) + buffer[index + 1] * fraction))
this.position += this.step
}
const drop = Math.min(Math.floor(this.position), Math.max(buffer.length - 1, 0))
this.tail = buffer.slice(drop)
this.position -= drop
return Int16Array.from(out)
}
/**
* Emit the final samples held back for interpolation.
*
* @returns Remaining samples.
*/
flushSamples(): Int16Array {
if (this.fromRate === this.toRate) return new Int16Array(0)
const out: number[] = []
while (this.tail.length > 0 && this.position <= this.tail.length - 1) {
out.push(this.tail[Math.floor(this.position)])
this.position += this.step
}
this.tail = new Int16Array(0)
this.position = 0
return Int16Array.from(out)
}
/**
* Decode LE bytes into samples, carrying an odd trailing byte.
*
* @param chunk - Raw bytes.
* @returns Decoded samples.
*/
private decode(chunk: Uint8Array): Int16Array {
let bytes = chunk
if (this.carry !== null) {
bytes = new Uint8Array(chunk.length + 1)
bytes[0] = this.carry
bytes.set(chunk, 1)
this.carry = null
}
if (bytes.length % 2 === 1) {
this.carry = bytes[bytes.length - 1]
bytes = bytes.subarray(0, bytes.length - 1)
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const samples = new Int16Array(bytes.length / 2)
for (let i = 0; i < samples.length; i++) samples[i] = view.getInt16(i * 2, true)
return samples
}
}
Confucius4SpeechError
Error thrown when a Confucius4 stream fails during a buffer transcribe.
status is the WebSocket close code (4401 = secret key rejected; 0 when the
failure was not a close). Deliberately NOT tagged with statusCode/
errorKey, so API middleware answers its generic 500.
class Confucius4SpeechError extends Error {
/** WebSocket close code, or 0. */
readonly status: number
/**
* Create the error.
*
* @param message - Human-readable message.
* @param status - WebSocket close code, or 0.
*/
constructor(message: string, status = 0) {
super(message)
this.name = 'Confucius4SpeechError'
this.status = status
}
}
AsyncQueue
A single-consumer async queue: producers push, the consumer iterates, and
end() finishes the iteration once everything pushed has been read.
class AsyncQueue<T> implements AsyncIterable<T> {
private readonly items: T[] = []
private waiting: ((result: IteratorResult<T>) => void) | null = null
private ended = false
/**
* Add an item.
*
* @param item - The item (ignored after `end()`).
*/
push(item: T): void {
if (this.ended) return
if (this.waiting) {
const resolve = this.waiting
this.waiting = null
resolve({ value: item, done: false })
} else {
this.items.push(item)
}
}
/** Finish the queue; the consumer drains what is left, then stops. */
end(): void {
if (this.ended) return
this.ended = true
if (this.waiting) {
const resolve = this.waiting
this.waiting = null
resolve({ value: undefined, done: true })
}
}
/**
* Whether `end()` has been called.
*
* @returns True once ended.
*/
get isEnded(): boolean {
return this.ended
}
/**
* Iterate the queue.
*
* @returns An async iterator over pushed items.
*/
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
if (this.items.length > 0) {
return Promise.resolve({ value: this.items.shift() as T, done: false })
}
if (this.ended) return Promise.resolve({ value: undefined, done: true })
return new Promise((resolve) => {
this.waiting = resolve
})
},
}
}
}
Functions
isWav(bytes)
Checks whether the bytes start with a RIFF/WAVE header.
function isWav(bytes: Uint8Array<ArrayBufferLike>): boolean
bytes— Audio bytes.
Returns: True for a WAV container.
decodeWav(bytes)
Decodes an uncompressed 16-bit PCM WAV file to mono samples.
function decodeWav(bytes: Uint8Array<ArrayBufferLike>): DecodedWav
bytes— The WAV file bytes.
Returns: The sample rate, source channel count and mono samples.
resample(samples, fromRate, toRate)
Resamples mono PCM16 with linear interpolation.
function resample(
samples: Int16Array<ArrayBufferLike>,
fromRate: number,
toRate: number,
): Int16Array<ArrayBufferLike>
samples— Mono input samples.fromRate— Input sample rate in Hz.toRate— Output sample rate in Hz.
Returns: Resampled samples (the input itself when the rates match).
encodeWav(samples, sampleRate)
Encodes mono PCM16 samples as a WAV file.
function encodeWav(
samples: Int16Array<ArrayBufferLike>,
sampleRate: number,
): Uint8Array<ArrayBufferLike>
samples— Mono samples.sampleRate— Sample rate in Hz.
Returns: The WAV file bytes.
toTargetWav(bytes)
Converts any 16-bit PCM WAV into a 16 kHz mono WAV.
function toTargetWav(bytes: Uint8Array<ArrayBufferLike>): Uint8Array<ArrayBufferLike>
bytes— The source WAV bytes.
Returns: A 16 kHz mono 16-bit PCM WAV.
wavToTargetPcm(bytes)
Decodes a WAV file into 16 kHz mono PCM16 little-endian bytes.
function wavToTargetPcm(bytes: Uint8Array<ArrayBufferLike>): Uint8Array<ArrayBufferLike>
bytes— The source WAV bytes.
Returns: Raw 16 kHz mono PCM16 LE bytes.
samplesToBytes(samples)
Serializes samples as PCM16 little-endian bytes.
function samplesToBytes(samples: Int16Array<ArrayBufferLike>): Uint8Array<ArrayBufferLike>
samples— PCM16 samples.
Returns: Little-endian bytes.
toTargetBlocks(audio, sampleRate, blockBytes)
Converts a PCM16 LE stream at any sample rate to 16 kHz and re-chunks it into fixed-size blocks (the last block may be shorter).
function toTargetBlocks(
audio: AsyncIterable<Uint8Array<ArrayBufferLike>>,
sampleRate: number,
blockBytes: number,
): AsyncGenerator<Uint8Array<ArrayBufferLike>, any, any>
audio— Raw PCM16 LE mono chunks.sampleRate— Input sample rate in Hz.blockBytes— Output block size in bytes (even).
toBase64(bytes)
Encodes bytes as base64.
function toBase64(bytes: Uint8Array<ArrayBufferLike>): string
bytes— Raw bytes.
Returns: Base64 text.
createProvider(config)
Creates a Confucius4-R2T2 speech provider instance.
function createProvider(config?: Confucius4SpeechConfig): AISpeechProvider
config— Optional overrides (URL, secret key, prompt, header options, socket factory).
Returns: An AISpeechProvider backed by a self-hosted Confucius4 server.
openGlobalWebSocket(url, headers, handlers)
Default socket factory over the runtime's global WebSocket. Handshake
headers use the Node (undici) WebSocketInit.headers extension.
function openGlobalWebSocket(
url: string,
headers: Record<string, string>,
handlers: RealtimeSocketHandlers,
): {
send: (data: string | Uint8Array<ArrayBufferLike>) => void
close: (code: number | undefined, reason: string | undefined) => void
}
url—ws://orwss://URL.headers— Extra handshake headers.handlers— Activity callbacks.
Returns: The socket.
toWebSocketUrl(baseUrl)
Turns an http(s):// base URL into its ws(s):// equivalent.
function toWebSocketUrl(baseUrl: string): string
baseUrl— HTTP base URL.
Returns: The WebSocket base URL.
parseFrame(data)
Decodes a text frame as JSON.
function parseFrame(data: string | Uint8Array<ArrayBufferLike>): Record<string, unknown> | null
data— The frame.
Returns: The parsed object, or null for binary/non-JSON frames.
Constants
TARGET_SAMPLE_RATE
The sample rate every self-hosted speech server in this family expects.
const TARGET_SAMPLE_RATE: 16000
DEFAULT_CONFUCIUS4_WS_URL
Default WebSocket URL of ws_server.py.
const DEFAULT_CONFUCIUS4_WS_URL: 'ws://127.0.0.1:8272/asr_stream_api_v1'
DEFAULT_CONFUCIUS4_SECRET_KEY
The server's hardcoded secret-key whitelist entry.
const DEFAULT_CONFUCIUS4_SECRET_KEY: 'test0102'
CONFUCIUS4_EOS
End-of-audio text frame.
const CONFUCIUS4_EOS: 'YOUDAO_ONETIME_ASR_STREAM_EOS'
MAX_SYSTEM_PROMPT_CHARS
Longest system_prompt the server accepts.
const MAX_SYSTEM_PROMPT_CHARS: 4000
CLOSE_BAD_SECRET_KEY
Close code the server uses for a rejected secret key.
const CLOSE_BAD_SECRET_KEY: 4401
DEFAULT_FINAL_TIMEOUT_MS
Default wait for the server to close after end-of-stream.
const DEFAULT_FINAL_TIMEOUT_MS: 30000
provider
The provider implementation.
const provider: AISpeechProvider
aiSpeechConfucius4SecretDefinitions
Secret definitions used by the Confucius4-R2T2 speech bond.
const aiSpeechConfucius4SecretDefinitions: SecretDefinition[]
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-confucius4'
export function setupAiSpeechConfucius4(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-ai-speech>=1.1.0@molecule/api-secrets^1.0.1
Environment Variables
CONFUCIUS4_WS_URL(optional) — Confucius4-R2T2 WebSocket URL- Setup: The URL of your own ws_server.py (./run_start_server.sh start … --port 8272). Defaults to ws://127.0.0.1:8272/asr_stream_api_v1.
- Get it here: https://github.com/netease-youdao/Confucius4-R2T2
- Example:
ws://127.0.0.1:8272/asr_stream_api_v1
CONFUCIUS4_SECRET_KEY(optional) — Confucius4-R2T2 secret key- Setup: The secret_key the server whitelists (hardcoded to test0102 in ws_server.py; it is not real auth). Defaults to test0102.
- Get it here: https://github.com/netease-youdao/Confucius4-R2T2
- Example:
test0102
CONFUCIUS4_SYSTEM_PROMPT(optional) — Confucius4-R2T2 context prompt- Setup: Optional context and hotwords sent as system_prompt (at most 4000 characters).
- Get it here: https://github.com/netease-youdao/Confucius4-R2T2
- Example:
Speakers discuss molecule.dev, Synthase and Fly.io.
Runtime Dependencies
-
@molecule/api-ai-speech -
@molecule/api-secrets -
WEIGHT LICENCE: the NetEase Model Use License Agreement, not Apache-2.0 (only the code is). Commercial use is allowed, but a company with >100M MAU or >RMB 1B annual revenue needs a separate licence; derivative works must carry a no-endorsement notice; it is banned in "high-risk scenarios" including large-scale biometric surveillance and automated decision-making; you may not use it to improve other AI models (except non-commercial ones); PRC law and CIETAC arbitration apply. Check the automated-decision-making clause before offering it as a hosted service.
-
The "secret key" is not auth.
ws_server.pycheckssecret_keyagainst a hardcoded["test0102"](a mismatch closes with code 4401), so anyone who can reach port 8272 uses your GPU. Keep it on a private network or behind an authenticating reverse proxy; never expose it publicly. -
You run the server, on a CUDA GPU (vLLM with strict CUDA/PyTorch versions, single process, one GPU):
./run_start_server.sh start --model_path <ckpt> --vad_model_path <Stream-VAD dir> --port 8272 --gpu 0. -
16 kHz only, and the server does NOT resample — 48 kHz browser audio produces garbage with no error. The bond resamples PCM16 input from
sampleRateto 16 kHz for you; never bypass it with raw frames.transcribeaccepts 16-bit PCM WAV only and throws for m4a/webm/mp3. -
Finality has no flag. Every server message is an append-only increment (
deltaevents).reset: true(VAD end of speech, a detected hallucination, or the 60 s soft / 90 s hard reset timeout) is a segment boundary, NOT an error: the bond emitsfinal(that segment's text, equal to its deltas) thenturn-end. The stream ends when the server closes the socket after end-of-stream — never wait for a final flag. SetcreateProvider({ useVad: true })for VAD-driven segments. -
languageis sent verbatim; only'zhen'(auto Chinese/English, the default) is documented for the WebSocket server.prompt(orCONFUCIUS4_SYSTEM_PROMPT) becomessystem_prompt, at most 4000 characters — longer throws. No word timestamps or speakers over the WebSocket (diarizeis ignored). -
Server-side only. A browser microphone streams to YOUR API, which calls
transcribeStream; never connect the browser to the model host. The bond needs a globalWebSocket(Node 22+) or asocketFactory. -
A failed buffer
transcribethrowsConfucius4SpeechError(status= close code; 4401 = secret key rejected); streams report failures as anerrorevent and end.
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.