Node server example
Two small programs for your own Node server:
- The mint route (
src/server.ts):POST /api/voice-sessioncreates 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. - 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.
Settings
Section titled “Settings”| 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) |
cd examples/node-serverpnpm installexport KHEELONA_API_KEY=kv_live_…pnpm start # the mint route on port 5181
pnpm relay --text "Hello Lumi" # a typed turnpnpm relay --text "Hello" --wav question.wav --out reply.wavRelay 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.
Before you copy it
Section titled “Before you copy it”- The mint route is same-origin by design and sends no CORS headers. It refuses a request whose
Originis missing or notALLOWED_ORIGIN, and anything but JSON. - Add your own sign-in check where
server.tssaysAuthenticate your user here. Pass that user’s opaque ID asend_user_ref. See Security. - Never print, log or return the API key. Errors show the code, the status and the request ID only.
The files
Section titled “The files”node-server/src/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
// 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
// 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 bytesconst 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
// 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
{ "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.