Node.js · Realtime preview · September 6, 2026
Stream transcription and phrase emotion with Node.js
Use Realtime when your application needs words as audio arrives. A separate acoustic path returns emotion scores for completed phrases. This preview supports 32 locales and returns variable-length emotion score arrays. For the full 15-emotion and 16-style output from English recordings, use the Resonance file API.
Prepare one recording
Install Node.js 22 or later and FFmpeg. Keep your API key on your server. This example reads a saved recording so you can reproduce the protocol before connecting a microphone. Convert the recording to raw 16 kHz mono signed PCM16; sending a WAV header as audio introduces invalid samples.
ffmpeg -i recording.wav -ar 16000 -ac 1 -f s16le recording.pcm
curl --fail -O https://oruk.ai/examples/realtime-phrase-emotions.mjs
# Set ORUK_API_KEY in your environment, then run:
node realtime-phrase-emotions.mjs recording.pcmThe example uses Node’s built-in WebSocket client, with no additional npm dependency. The key is carried in the supported authentication subprotocol, never in the URL. Use a short-lived credential when building a browser client; do not ship a permanent key in client-side code.
Connect, configure, send, and commit
After session.created, configure the session, send binary PCM chunks, then commit the recording. The complete script prints final transcripts, phrase results, and usage. It stops when session.usage arrives and reports an error if the socket closes early.
// Node.js 22+; run on your server, where ORUK_API_KEY can remain private.
// Usage: ORUK_API_KEY=... node realtime-phrase-emotions.mjs recording.pcm
// Input: raw mono signed PCM16, little-endian, 16,000 Hz (no WAV header).
import { readFile } from 'node:fs/promises'
const file = process.argv[2]
const key = process.env.ORUK_API_KEY
if (!key || !file) throw new Error('Set ORUK_API_KEY and pass a .pcm filename.')
const pcm = await readFile(file)
if (!pcm.length || pcm.length % 2) throw new Error('Expected nonempty PCM16 audio.')
const socket = new WebSocket(
'wss://speech-api.oruk.ai/v1/realtime?model=oruk-realtime',
['oruk-realtime', `oruk-api-key.${key}`],
)
await new Promise((resolve, reject) => {
let settled = false
let started = false
const timer = setTimeout(() => finish(new Error('Realtime session timed out.')), 60_000)
function finish(error) {
if (settled) return
settled = true
clearTimeout(timer)
socket.close()
if (error) reject(error)
else resolve()
}
socket.addEventListener('message', ({ data }) => {
if (typeof data !== 'string') return
let event
try { event = JSON.parse(data) } catch { return }
if (event.type === 'session.created' && !started) {
started = true
socket.send(JSON.stringify({
type: 'session.update',
session: {
model: 'oruk-realtime', language: 'auto', sample_rate: 16000,
word_timestamps: true, phrase_emotions: true,
phrase_silence_ms: 600, diarize: false,
},
}))
// A saved clip can be uploaded immediately. A microphone sends chunks
// as they become available, using the same binary-frame format.
for (let offset = 0; offset < pcm.length; offset += 10240) {
socket.send(pcm.subarray(offset, offset + 10240))
}
socket.send(JSON.stringify({ type: 'input_audio_buffer.commit' }))
}
if (['conversation.item.input_audio_transcription.completed',
'conversation.item.input_audio_emotion.completed',
'conversation.item.input_audio_emotion.failed', 'session.usage'].includes(event.type)) {
console.log(JSON.stringify(event))
}
if (event.type === 'error') finish(new Error(event.error?.code ?? 'realtime_error'))
if (event.type === 'session.usage') finish()
})
socket.addEventListener('error', () => finish(new Error('Realtime connection failed.')))
socket.addEventListener('close', () => {
if (!settled) finish(new Error('Realtime closed before session.usage.'))
})
})
Read events without confusing their timing
conversation.item.input_audio_transcription.deltacarries provisional words. The completed event carries the final transcript.conversation.item.input_audio_emotion.completedcarries a phrase’s timestamps, text,phrase_id, and emotion scores. Join asynchronous results by phrase ID and time, rather than assuming they arrive beside a matching text delta.- Read each returned label and score rather than hard-coding a seven-class list. The live preview can return richer labels such as frustrated. These are model annotations of vocal expression; a score does not establish a speaker’s private feelings.
- A phrase emotion failure does not stop transcription. Handle
conversation.item.input_audio_emotion.failedseparately. Retain a missing result instead of silently substituting neutral.
Tune the phrase boundary, then measure your workload
The default silence boundary is 600 ms; the supported range is 200–2000 ms. The default maximum phrase is eight seconds, configurable from one to 15 seconds. Shorter boundaries can produce incomplete phrases; longer boundaries delay an emotion result. Measure end-to-end latency on your microphones, network, language, and phrase lengths before setting a service target.
Sessions last at most ten minutes. One minute of audio uses one subscription plan minute, including optional live diarization. Set session.diarize: true when you need local speaker labels; early phrases may have a null speaker while diarization warms up. Review current plans and the full protocol reference.
Supported locales
en-US, en-GB, es-US, es-ES, fr-FR, fr-CA, it-IT, pt-BR, pt-PT, nl-NL, de-DE, tr-TR, ru-RU, ar-AR, hi-IN, ja-JP, ko-KR, vi-VN, uk-UA, pl-PL, sv-SE, cs-CZ, nb-NO, da-DK, bg-BG, fi-FI, hr-HR, sk-SK, zh-CN, hu-HU, ro-RO, et-EE
