Skip to content

Node server example

Two small programs for your own Node server:

  1. The mint route (src/server.ts): POST /api/voice-session creates a session with your API key and hands your page only the session ID, the client secret and the connection addresses. The key never leaves your server.
  2. The relay (src/relay.ts): a server-to-server client for toys and backends. It creates a session, connects over a WebSocket, sends a typed turn and/or a WAV file at real time, prints the events and saves the reply audio.

src/kheelona.ts holds createSession: one idempotency key per session, up to 3 attempts on 429 and 503, and errors that carry the status, the code and the request ID.

You need Node 24 or newer and pnpm, and the example folder: the examples you received from the Kheelona team, with the SDK package.

Variable Default Meaning
KHEELONA_API_KEY none (exit 2 with a hint when missing) Your API key. Server side only.
KHEELONA_API_URL https://api.voice.kheelona.com The API
KHEELONA_VOICE_ID lumi-dragon The voice
HOST / PORT 127.0.0.1 / 5181 Where the mint route listens
ALLOWED_ORIGIN http://localhost:5180 The one page origin allowed to call the mint route (blank or null is refused at start)
Terminal window
cd examples/node-server
pnpm install
export KHEELONA_API_KEY=kv_live_…
pnpm start # the mint route on port 5181
pnpm relay --text "Hello Lumi" # a typed turn
pnpm relay --text "Hello" --wav question.wav --out reply.wav

Relay flags: --text <s>, --wav <file> (at least one), --out <file> (default reply.wav), --voice, --languages en,hi and --no-interruptions. The WAV must be 16 kHz, 16-bit, mono: ffmpeg -i in.wav -ac 1 -ar 16000 -sample_fmt s16 out.wav.

The relay prints each event on one line and ends with session <id> ended <reason> replies <n> audio <s>s saved <file>. Errors go to standard error as error <code> request_id <id>. Exit codes: 0 ended normally, 1 API or session error, 2 usage error. See the server quickstart for a walk through.

  • The mint route is same-origin by design and sends no CORS headers. It refuses a request whose Origin is missing or not ALLOWED_ORIGIN, and anything but JSON.
  • Add your own sign-in check where server.ts says Authenticate your user here. Pass that user’s opaque ID as end_user_ref. See Security.
  • Never print, log or return the API key. Errors show the code, the status and the request ID only.
node-server/src/kheelona.ts
kheelona.ts
// The Kheelona REST helper shared by the mint route and the relay, and the example's environment.
/** The settings every example reads (see Settings on the example's docs page). */
export interface ExampleEnv {
apiKey: string;
apiUrl: string;
voiceId: string;
/** Mint route only: the one page origin that may call it. */
allowedOrigin: string;
host: string;
port: number;
}
/**
* Reads the settings from environment variables. Throws an Error with a one-line hint when the key is missing or
* `PORT` is not a port number (the commands exit 2 with that line).
*/
export function readEnv(env: Record<string, string | undefined>): ExampleEnv {
const apiKey = env['KHEELONA_API_KEY'];
if (!apiKey) throw new Error('KHEELONA_API_KEY is not set. Create an API key in the Kheelona console and export it.');
const allowedOrigin = (env['ALLOWED_ORIGIN'] ?? 'http://localhost:5180').trim();
if (allowedOrigin === '' || allowedOrigin.toLowerCase() === 'null') {
throw new Error("ALLOWED_ORIGIN must be your page's origin, for example https://app.example.com");
}
const port = Number(env['PORT'] ?? '5181');
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('PORT must be a port number.');
return {
apiKey,
apiUrl: env['KHEELONA_API_URL'] || 'https://api.voice.kheelona.com',
voiceId: env['KHEELONA_VOICE_ID'] || 'lumi-dragon',
allowedOrigin,
host: env['HOST'] || '127.0.0.1',
port,
};
}
const REQUEST_TIMEOUT_MS = 15_000;
/** The longest wait for one retry, whatever `Retry-After` says. */
const MAX_WAIT_S = 30;
export interface CreateSessionOptions {
apiUrl: string;
/** Your API key. Keep it on the server: read it from the environment, never ship it to a browser or a toy. */
apiKey: string;
voiceId: string;
/** Default `["en", "hi"]`. */
languages?: string[];
/** An opaque ID for your end user (not a name, e-mail address or other personal data). */
endUserRef?: string;
/** `false` = the voice is never cut off (for devices without echo cancellation). */
interruptions?: boolean;
}
export interface CreatedSession {
sessionId: string;
/** The single-use client secret (`kvcs_…`). It is valid for 60 seconds. */
clientSecret: string;
websocketUrl: string;
webrtcUrl: string;
}
/** An error answer from the API: the HTTP status, the error code and the request ID to quote to support. */
export class KheelonaApiError extends Error {
readonly status: number;
readonly code: string;
readonly requestId: string | undefined;
/** Seconds from the `Retry-After` header, if the API sent one. */
readonly retryAfterSeconds: number | undefined;
/** For status 0 (no answer): the name of the failure, e.g. `TimeoutError`. Never its message. */
readonly causeName: string | undefined;
constructor(status: number, code: string, requestId?: string, retryAfterSeconds?: number, causeName?: string) {
super(`Kheelona API error ${String(status)} ${code}${requestId ? ` (request_id ${requestId})` : ''}`);
this.name = 'KheelonaApiError';
this.status = status;
this.code = code;
this.requestId = requestId;
this.retryAfterSeconds = retryAfterSeconds;
this.causeName = causeName;
}
}
/** Creates a session with `POST /v1/sessions`. Retries 429 and 503 (up to 3 attempts) with one idempotency key. */
export async function createSession(opts: CreateSessionOptions): Promise<CreatedSession> {
const idempotencyKey = globalThis.crypto.randomUUID(); // one per logical session: a retry never creates a second one
const body = JSON.stringify({
voice_id: opts.voiceId,
languages: opts.languages ?? ['en', 'hi'],
...(opts.endUserRef === undefined ? {} : { end_user_ref: opts.endUserRef }),
...(opts.interruptions === undefined ? {} : { interruptions: opts.interruptions }),
});
for (let attempt = 1; ; attempt += 1) {
let res: Response;
try {
res = await fetch(`${opts.apiUrl.replace(/\/+$/, '')}/v1/sessions`, {
method: 'POST',
headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), // never wait forever for the API
});
} catch (error) {
// Keep only the kind of failure: its message can echo the request.
throw new KheelonaApiError(0, 'upstream_unreachable', undefined, undefined, error instanceof Error ? error.name : 'Error');
}
if (res.ok) {
const created = (await res.json()) as {
session_id: string;
client_secret: { value: string };
connect: { websocket_url: string; webrtc_url: string };
};
return {
sessionId: created.session_id,
clientSecret: created.client_secret.value,
websocketUrl: created.connect.websocket_url,
webrtcUrl: created.connect.webrtc_url,
};
}
const error = await readError(res);
const retryable = res.status === 429 || res.status === 503;
if (!retryable || attempt >= 3) throw error;
await new Promise((resolve) => setTimeout(resolve, Math.min(error.retryAfterSeconds ?? 1, MAX_WAIT_S) * 1000));
}
}
/** Keeps only the error code and request ID from the answer (never the whole body). */
async function readError(res: Response): Promise<KheelonaApiError> {
const header = res.headers.get('retry-after');
const retryAfter = header !== null && /^\d+$/.test(header) ? Math.min(Number(header), Number.MAX_SAFE_INTEGER) : undefined; // a huge value stays a number the cap handles
try {
const parsed = (await res.json()) as { error?: { code?: unknown; request_id?: unknown } };
const code = plain(parsed.error?.code) ?? 'unknown_error';
const requestId = plain(parsed.error?.request_id);
return new KheelonaApiError(res.status, code, requestId, retryAfter);
} catch {
return new KheelonaApiError(res.status, 'unknown_error', undefined, retryAfter);
}
}
/** A short identifier made of plain characters, or undefined: nothing else from an answer is repeated. */
function plain(value: unknown): string | undefined {
return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}
node-server/src/server.ts
server.ts
// The mint route: your server creates the session with your API key and hands the page only a single-use client
// secret. Run it with `npm start` (see Settings on the example's docs page).
import type { Server } from 'node:http';
import { pathToFileURL } from 'node:url';
import express from 'express';
import { createSession, KheelonaApiError, readEnv, type ExampleEnv } from './kheelona.ts';
export function createApp(env: ExampleEnv): express.Express {
const app = express();
app.disable('x-powered-by');
// Same-origin by design (no CORS headers): serve your page and this route from one origin.
// Only your own page may call this route: a request with no Origin, or another one, never reaches Kheelona.
const guard: express.RequestHandler = (req, res, next) => {
const origin = req.get('origin');
if (origin !== env.allowedOrigin) {
res.status(403).json({ error: { code: 'origin_not_allowed' } });
return;
}
res.set({ Vary: 'Origin', 'Cache-Control': 'no-store' });
// JSON only: a cross-site HTML form cannot send it, so it cannot spend your key.
if (!req.is('application/json')) {
res.status(415).json({ error: { code: 'unsupported_media_type' } });
return;
}
next();
};
app.all('/api/voice-session', guard);
// express.json (body-parser) checks Content-Length against the limit before it reads the body.
app.post('/api/voice-session', express.json({ limit: '2kb', type: 'application/json' }), async (req, res) => {
const body = req.body as Record<string, unknown> | undefined;
// Only `languages` is accepted. (Language codes here are 2-3 letters, as the API's are; the API validates too.)
const languages = body?.['languages'];
const knownKeys = typeof body === 'object' && !Array.isArray(body) && Object.keys(body).every((key) => key === 'languages');
const validLanguages =
languages === undefined ||
(Array.isArray(languages) && languages.length >= 1 && languages.length <= 11 && languages.every((l) => typeof l === 'string' && /^[a-z]{2,3}$/.test(l)));
if (!knownKeys || !validLanguages) {
res.status(400).json({ error: { code: 'invalid_request' } });
return;
}
try {
// Authenticate your user here: only signed-in users may start a session.
const session = await createSession({
apiUrl: env.apiUrl,
apiKey: env.apiKey,
voiceId: env.voiceId,
...(languages === undefined ? {} : { languages: languages as string[] }),
endUserRef: 'demo-user', // replace with the signed-in user's opaque ID
});
// Return only what the page needs: never the API key, and nothing else from the answer.
res.status(201).json({
sessionId: session.sessionId,
clientSecret: session.clientSecret,
endpoints: { webrtcUrl: session.webrtcUrl, websocketUrl: session.websocketUrl },
});
} catch (error) {
const api = error instanceof KheelonaApiError ? error : undefined;
console.error(`mint failed: ${api ? `${String(api.status)} ${api.code} request_id ${api.requestId ?? '-'}` : 'unexpected error'}`);
if (api && (api.status === 429 || api.status === 503)) {
if (api.retryAfterSeconds !== undefined) res.set('Retry-After', String(api.retryAfterSeconds));
res.status(api.status);
} else {
res.status(502);
}
res.json({ error: { code: api?.code ?? 'internal', ...(api?.requestId ? { requestId: api.requestId } : {}) } });
}
});
// A malformed JSON body is the caller's mistake, not ours.
// Express recognises an error handler by its four parameters, so `_next` stays.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const tooLarge = (error as { status?: number }).status === 413;
res.status(tooLarge ? 413 : 400).json({ error: { code: tooLarge ? 'payload_too_large' : 'invalid_request' } });
});
return app;
}
/** Starts the mint route on `env.host:env.port` (loopback by default) and resolves when it is listening. */
export function startServer(env: ExampleEnv): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createApp(env).listen(env.port, env.host, () => {
resolve(server);
});
server.once('error', reject);
});
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
let env: ExampleEnv;
try {
env = readEnv(process.env);
} catch (error) {
console.error((error as Error).message);
process.exit(2);
}
const server = await startServer(env);
const address = server.address();
console.log(`mint route on http://${env.host}:${typeof address === 'object' && address ? String(address.port) : String(env.port)}/api/voice-session (page origin ${env.allowedOrigin})`);
}
node-server/src/relay.ts
relay.ts
// A server-to-server relay: creates a session, streams a WAV (and/or a typed turn) to the voice over the WebSocket at
// real time, prints the events, and saves the reply audio to a WAV file. Run it with `pnpm relay --text "Hello"`.
import { readFile, writeFile } from 'node:fs/promises';
import type { IncomingMessage } from 'node:http';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
import { WebSocket, type RawData } from 'ws';
import { createSession, KheelonaApiError, readEnv, type CreatedSession } from './kheelona.ts';
import { readPcm16Wav, writeWav } from './wav.ts';
export interface RelayOptions {
apiUrl: string;
apiKey: string;
voiceId: string;
languages?: string[];
/** A typed turn, sent first. */
text?: string;
/** A 16 kHz mono PCM16 WAV, sent in real time after the typed turn's reply. */
wavFile?: string;
/** Where the reply audio (24 kHz mono WAV) is saved. */
outFile: string;
/** `false` = the voice is never cut off. Default: the API's default (on). */
interruptions?: boolean;
/** Declare `playback_pause` and pause the playback buffer on `reply.paused`. Default: on. `false` = the voice never pauses. */
pausePlayback?: boolean;
/** Where event lines go (default: standard output). */
log?: (line: string) => void;
/** Milliseconds to wait for a reply before giving up (default 20,000). */
replyTimeoutMs?: number;
}
export interface RelaySummary {
sessionId: string;
endReason: string;
replies: number;
audioSeconds: number;
outFile: string;
}
/** A bad command line or input file: the CLI exits 2. */
export class UsageError extends Error {}
/**
* A failed session: the code, the request ID when an HTTP answer carried one, and the session ID once the session
* existed (realtime `error` events carry no request ID: quote the session ID and the code). The CLI exits 1.
*/
export class RelayError extends Error {
readonly code: string;
readonly requestId: string | undefined;
sessionId: string | undefined;
constructor(code: string, message: string, requestId?: string) {
super(message);
this.name = 'RelayError';
this.code = code;
this.requestId = requestId;
}
}
/**
* Opens the WebSocket the way a server does: the client secret in the Authorization header, the `kheelona.v1`
* subprotocol, and no Origin header (a request with one is treated as a browser). Attach your listeners straight
* away: the server's first event can arrive in the same moment the connection opens.
*/
function openSocket(session: CreatedSession): WebSocket {
return new WebSocket(session.websocketUrl, ['kheelona.v1'], {
headers: { Authorization: `Bearer ${session.clientSecret}` },
handshakeTimeout: 20_000,
});
}
/** Before the upgrade, errors come back as HTTP errors with a JSON body: keep the code and the request ID. */
function readRefusal(res: IncomingMessage): Promise<RelayError> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
let code = `http_${String(res.statusCode)}`;
let requestId: string | undefined;
try {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8').slice(0, 8192)) as { error?: { code?: string; request_id?: string } };
if (typeof body.error?.code === 'string' && /^[a-z0-9_]{1,64}$/.test(body.error.code)) code = body.error.code;
if (typeof body.error?.request_id === 'string' && /^[A-Za-z0-9_]{1,64}$/.test(body.error.request_id)) requestId = body.error.request_id;
} catch {
// Not JSON: keep the HTTP status as the code.
}
resolve(new RelayError(code, `The connection was refused (${code})`, requestId));
});
});
}
const FRAME_MS = 20;
const SAMPLES_PER_FRAME = 16 * FRAME_MS; // 16 kHz: 320 samples = 640 bytes
const SILENCE = Buffer.alloc(SAMPLES_PER_FRAME * 2);
/** Splits microphone audio (16 kHz PCM16) into 20 ms frames; the last one is padded with silence. */
export function wavToFrames(samples: Int16Array): Buffer[] {
const frames: Buffer[] = [];
for (let at = 0; at < samples.length; at += SAMPLES_PER_FRAME) {
const frame = Buffer.alloc(SAMPLES_PER_FRAME * 2);
samples.subarray(at, at + SAMPLES_PER_FRAME).forEach((sample, i) => {
frame.writeInt16LE(sample, i * 2);
});
frames.push(frame);
}
return frames;
}
/**
* Sends one frame every 20 ms, in real time. Frame k goes out at start + k × 20 ms, measured against the clock, so a
* late wake-up never makes the stream drift (the server drops audio that arrives faster than 2× real time).
* Stops when `next` returns nothing or `stop` says so.
*/
export async function sendPaced(opts: { next: () => Buffer | undefined; send: (frame: Buffer) => void; stop: () => boolean }): Promise<void> {
const start = performance.now();
for (let k = 0; !opts.stop(); k += 1) {
const frame = opts.next();
if (!frame) return;
opts.send(frame);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, start + (k + 1) * FRAME_MS - performance.now())));
}
}
/**
* The playback buffer, with pause support. Frames wait in `push` until `drain` hands them to the speaker; while the
* voice has paused its own playback (`reply.paused`) `drain` returns nothing and the frames are kept, so nothing is lost.
* A pause ends at the first of: `reply.resumed`, `interrupted` (the buffer is flushed), `reply.done` or the next
* `reply.started`. A pause, resume or done for any reply other than the one that is playing (or one that has already
* ended) is stale and ignored.
*/
export class PausePlayback {
private queue: Buffer[] = [];
private current: string | undefined;
private isPaused = false;
/** Frames thrown away by `interrupted` or by the end of the session. */
dropped = 0;
/** `enabled = false` when the relay did not declare `playback_pause`: the server sends no pause events then. */
private readonly enabled: boolean;
constructor(enabled = true) {
this.enabled = enabled;
}
get paused(): boolean {
return this.isPaused;
}
onEvent(event: { type: string; reply_id?: unknown }): void {
switch (event.type) {
case 'reply.started':
this.current = typeof event.reply_id === 'string' ? event.reply_id : undefined; // a new reply ends any pause
this.isPaused = false;
break;
case 'reply.paused':
if (this.enabled && this.current !== undefined && event.reply_id === this.current) this.isPaused = true;
break;
case 'reply.resumed':
if (event.reply_id === this.current) this.isPaused = false;
break;
case 'interrupted': // flush: what was queued will never play
this.dropped += this.queue.length;
this.queue = [];
this.isPaused = false;
this.current = undefined; // that reply is over: a pause naming it is stale
break;
case 'reply.done':
if (event.reply_id === this.current) {
this.isPaused = false;
this.current = undefined;
}
break;
case 'session.ended':
this.dropped += this.queue.length;
this.queue = [];
this.isPaused = false;
break;
default:
break;
}
}
push(frame: Buffer): void {
this.queue.push(frame);
}
/** The frames to play now: none while paused. */
drain(): Buffer[] {
if (this.isPaused) return [];
const frames = this.queue;
this.queue = [];
return frames;
}
}
/** Collects the reply audio, dropping what the server told us to drop. */
export class ReplyAudio {
private readonly chunks: Buffer[] = [];
private readonly playback: PausePlayback;
private newestSeq = -1;
private droppedThrough = -1;
constructor(playback = new PausePlayback()) {
this.playback = playback;
}
/** A binary frame: an 8-byte header (kind, flags, sentence, reply_seq) then PCM16 24 kHz samples. */
push(frame: Buffer): void {
if (frame.length < 8 || frame.readUInt8(0) !== 0x01) return; // not reply audio: ignore unknown kinds
const seq = frame.readUInt32LE(4);
this.newestSeq = Math.max(this.newestSeq, seq);
if (seq <= this.droppedThrough) return; // a late frame of an interrupted reply
this.playback.push(frame.subarray(8));
this.play();
}
/** Every event goes here first, so the playback buffer pauses, resumes and flushes with the voice. */
onEvent(event: { type: string; reply_id?: unknown }): void {
this.playback.onEvent(event);
this.play(); // a resume, a done or a new reply lets the held frames out
}
/** `interrupted`: drop every later frame of the reply being played. (The playback buffer is flushed by `onEvent`.) */
interrupted(): void {
this.droppedThrough = this.newestSeq;
}
pcm(): Buffer {
this.play();
return Buffer.concat(this.chunks);
}
// A real player would hand these frames to the speaker; this example saves them.
private play(): void {
this.chunks.push(...this.playback.drain());
}
}
/** The lines printed for an event: its type, plus the text of what was heard or said. */
export function describeEvent(event: { type: string; text?: unknown }): string[] {
const lines = [`event ${event.type}`];
if ((event.type === 'transcript.final' || event.type === 'reply.sentence') && typeof event.text === 'string') lines.push(` text: ${event.text}`);
return lines;
}
type ServerEvent = { type: string; [key: string]: unknown };
/** Runs one session: see the file header. Rejects with `KheelonaApiError`, `RelayError` or `UsageError`. */
export async function runRelay(opts: RelayOptions): Promise<RelaySummary> {
const log =
opts.log ??
((line: string) => {
console.log(line);
});
const replyTimeoutMs = opts.replyTimeoutMs ?? 20_000;
if (!opts.text && !opts.wavFile) throw new UsageError('Give --text and/or --wav.');
if (opts.text && Array.from(opts.text).length > 2000) throw new UsageError('--text is at most 2000 characters');
let wavFrames: Buffer[] | undefined;
if (opts.wavFile) {
try {
wavFrames = wavToFrames(readPcm16Wav(await readFile(opts.wavFile)).samples);
} catch (error) {
throw new UsageError(error instanceof Error && 'code' in error ? `Cannot read ${opts.wavFile}.` : (error as Error).message);
}
}
const session = await createSession({
apiUrl: opts.apiUrl,
apiKey: opts.apiKey,
voiceId: opts.voiceId,
...(opts.languages ? { languages: opts.languages } : {}),
...(opts.interruptions === undefined ? {} : { interruptions: opts.interruptions }),
});
const ws = openSocket(session);
const pause = opts.pausePlayback ?? true;
const audio = new ReplyAudio(new PausePlayback(pause));
const queue: Buffer[] = []; // microphone frames waiting to go out; silence fills the gaps
let wavQueued = false;
let turnsSent = 0;
let turnsAnswered = 0;
let replies = 0;
let stopPacer = false;
let pacer: Promise<void> | undefined;
let timer: NodeJS.Timeout | undefined;
let finished = false;
const summary = await new Promise<RelaySummary>((resolve, reject) => {
const finish = (error?: Error, endReason = 'unknown'): void => {
if (finished) return;
finished = true;
stopPacer = true;
clearTimeout(timer);
if (error instanceof RelayError) error.sessionId ??= session.sessionId;
if (error) reject(error);
else resolve({ sessionId: session.sessionId, endReason, replies, audioSeconds: audio.pcm().length / 2 / 24_000, outFile: opts.outFile });
};
// Nothing from the server for a while (waiting for session.ready, a reply or session.ended): give up, so a copy
// of this never hangs. Every message from the server restarts the wait.
const armTimer = (): void => {
clearTimeout(timer);
timer = setTimeout(() => {
finish(new RelayError('no_reply', `Nothing from the server for ${String(replyTimeoutMs / 1000)} s`));
}, replyTimeoutMs);
};
const sendEvent = (event: Record<string, unknown>): void => {
ws.send(JSON.stringify(event));
};
const queueWav = (): void => {
if (!wavFrames || wavQueued) return;
wavQueued = true;
turnsSent += 1;
queue.push(...wavFrames);
};
// Microphone audio at real time for the whole session: your turn's frames, then silence.
const startPacer = (): void => {
pacer = sendPaced({
next: () => {
const frame = queue.shift();
if (frame) return frame;
return SILENCE;
},
send: (frame) => {
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
},
stop: () => stopPacer,
});
};
ws.once('open', armTimer);
ws.on('message', (data: RawData, isBinary: boolean) => {
armTimer();
if (isBinary) {
if (Buffer.isBuffer(data)) audio.push(data);
return;
}
let event: ServerEvent;
try {
event = JSON.parse((data as Buffer).toString('utf8')) as ServerEvent;
} catch {
return;
}
if (typeof event.type !== 'string') return;
for (const line of describeEvent(event)) log(line);
audio.onEvent(event);
switch (event.type) {
case 'session.ready':
// Nothing is sent before this: audio sent earlier is dropped.
if (pause) sendEvent({ type: 'client.capabilities', playback_pause: true });
if (opts.text) {
sendEvent({ type: 'input.text', text: opts.text });
turnsSent += 1;
} else {
queueWav();
}
startPacer();
break;
case 'interrupted':
audio.interrupted();
break;
case 'reply.done':
replies += 1;
if (event['reason'] === 'interrupted') break; // the turn goes on with a new reply
turnsAnswered += 1;
if (turnsAnswered < turnsSent) break;
if (!wavQueued && wavFrames) {
queueWav(); // the typed reply is done: now the WAV
} else {
stopPacer = true;
void (pacer ?? Promise.resolve()).then(() => {
sendEvent({ type: 'session.end' });
});
}
break;
case 'error':
if (event['fatal'] === true) {
finish(
new RelayError(typeof event['code'] === 'string' ? event['code'] : 'error', 'The session failed'),
);
}
break;
case 'session.ended':
finish(undefined, typeof event['reason'] === 'string' ? event['reason'] : 'unknown');
break;
default:
break;
}
});
ws.on('unexpected-response', (_req, res) => {
void readRefusal(res).then((error) => {
finish(error);
});
});
ws.on('close', () => {
finish(new RelayError('disconnected', 'The connection closed before the session ended'));
});
ws.on('error', () => {
finish(new RelayError('connection_failed', 'The WebSocket failed'));
});
}).finally(() => {
ws.terminate();
});
await writeFile(opts.outFile, writeWav(audio.pcm(), 24_000));
return summary;
}
/** The command line. Returns the exit code: 0 ended normally, 1 API or session error, 2 usage error. */
export async function runCli(
argv: string[],
env: Record<string, string | undefined>,
out: (line: string) => void,
err: (line: string) => void,
overrides: { replyTimeoutMs?: number } = {},
): Promise<number> {
try {
const { values } = parseArgs({
args: argv,
options: {
text: { type: 'string' },
wav: { type: 'string' },
out: { type: 'string', default: 'reply.wav' },
voice: { type: 'string' },
languages: { type: 'string' },
'no-interruptions': { type: 'boolean', default: false },
'no-pause': { type: 'boolean', default: false },
},
strict: true,
});
const settings = readEnv(env);
const summary = await runRelay({
apiUrl: settings.apiUrl,
apiKey: settings.apiKey,
voiceId: values.voice ?? settings.voiceId,
...(values.languages ? { languages: values.languages.split(',').map((l) => l.trim()) } : {}),
...(values.text ? { text: values.text } : {}),
...(values.wav ? { wavFile: values.wav } : {}),
outFile: values.out,
...(values['no-interruptions'] ? { interruptions: false } : {}),
...(values['no-pause'] ? { pausePlayback: false } : {}),
log: out,
...(overrides.replyTimeoutMs === undefined ? {} : { replyTimeoutMs: overrides.replyTimeoutMs }),
});
out(`session ${summary.sessionId} ended ${summary.endReason} replies ${String(summary.replies)} audio ${summary.audioSeconds.toFixed(1)}s saved ${summary.outFile}`);
return 0;
} catch (error) {
if (error instanceof KheelonaApiError) {
// No answer at all (status 0): name the failure, like the Python example.
const code = error.status === 0 ? `api_unreachable_${error.causeName ?? 'Error'}` : error.code;
err(`error ${code} request_id ${error.requestId ?? '-'}`);
return 1;
}
if (error instanceof RelayError) {
const session = error.sessionId ? ` session ${error.sessionId}` : '';
err(`error ${error.code}${session} request_id ${error.requestId ?? '-'}`);
return 1;
}
// A usage mistake (bad flag, missing key, unreadable WAV): one line, exit 2.
err(error instanceof Error ? error.message : 'Invalid usage.');
return 2;
}
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
process.exitCode = await runCli(
process.argv.slice(2),
process.env,
(line) => {
console.log(line);
},
(line) => {
console.error(line);
},
);
}
node-server/src/wav.ts
wav.ts
// Minimal WAV reading and writing for the relay: PCM, 16-bit, mono. No dependencies.
const HINT = 'Convert it with: ffmpeg -i in.wav -ac 1 -ar 16000 -sample_fmt s16 out.wav';
/**
* Reads a PCM 16-bit mono WAV. `sampleRate` is the rate the file must have: 16000 for the microphone audio the
* relay sends (the default), 24000 for the reply audio it saves.
* Throws an Error that names the problem and the ffmpeg command that fixes it.
*/
export function readPcm16Wav(buf: Buffer, sampleRate = 16000): { sampleRate: number; samples: Int16Array } {
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error(`This is not a WAV file. ${HINT}`);
}
let fmt: { format: number; channels: number; rate: number; bits: number } | undefined;
let offset = 12;
while (offset + 8 <= buf.length) {
const id = buf.toString('ascii', offset, offset + 4);
const size = buf.readUInt32LE(offset + 4);
const body = offset + 8;
if (id === 'fmt ' && body + 16 <= buf.length) {
fmt = { format: buf.readUInt16LE(body), channels: buf.readUInt16LE(body + 2), rate: buf.readUInt32LE(body + 4), bits: buf.readUInt16LE(body + 14) };
} else if (id === 'data') {
if (!fmt) throw new Error(`The WAV has no fmt chunk before its audio. ${HINT}`);
const problem = formatProblem(fmt, sampleRate);
if (problem) throw new Error(`${problem}. ${HINT}`);
// A streamed WAV can carry an unknown (0xFFFFFFFF) size: take what the file holds, in whole samples.
const end = Math.min(buf.length, body + size);
const bytes = end - body - ((end - body) % 2);
const samples = new Int16Array(bytes / 2);
for (let i = 0; i < samples.length; i += 1) samples[i] = buf.readInt16LE(body + i * 2);
return { sampleRate, samples };
}
offset = body + size + (size % 2); // chunks are padded to an even size
}
throw new Error(`The WAV has no audio data. ${HINT}`);
}
function formatProblem(fmt: { format: number; channels: number; rate: number; bits: number }, rate: number): string | undefined {
if (fmt.format !== 1) return `The WAV is not plain PCM (format ${String(fmt.format)})`;
if (fmt.channels !== 1) return `The WAV has ${String(fmt.channels)} channels (${fmt.channels === 2 ? 'stereo' : 'not mono'}); it must be mono`;
if (fmt.bits !== 16) return `The WAV is ${String(fmt.bits)}-bit; it must be 16-bit`;
if (fmt.rate !== rate) return `The WAV is ${String(fmt.rate)} Hz; it must be ${String(rate)} Hz`;
return undefined;
}
/** Wraps raw PCM 16-bit little-endian mono samples in a WAV container. */
export function writeWav(pcm: Buffer, sampleRate: number): Buffer {
const header = Buffer.alloc(44);
header.write('RIFF', 0, 'ascii');
header.writeUInt32LE(36 + pcm.length, 4);
header.write('WAVE', 8, 'ascii');
header.write('fmt ', 12, 'ascii');
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(1, 22); // mono
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write('data', 36, 'ascii');
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
node-server/package.json
package.json
{
"name": "@kheelona/example-node-server",
"version": "0.0.0",
"private": true,
"description": "Kheelona Voice example: a client-secret mint route (Express) and a server-to-server WebSocket relay. Proprietary; private distribution only.",
"license": "UNLICENSED",
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/server.ts",
"relay": "node src/relay.ts"
},
"dependencies": {
"express": "5.3.0",
"ws": "8.22.0"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/ws": "8.18.2"
}
}

The examples are Kheelona’s own and are given to partners privately. Contact the Kheelona team for licensing.