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 timed native emotion/style labels from English recordings, use the Resonance file API.
Using Python and Pipecat? Start with the Pipecat adapter and runnable streaming example. This guide uses Node.js directly against the same realtime protocol.
Prepare one recording
Install Node.js 22.23 or later, ws@8.21.3, 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
npm install --save-exact ws@8.21.3
curl --fail -O https://oruk.ai/examples/realtime-phrase-emotions.mjs
curl --fail -O https://oruk.ai/examples/hume-native-streams.mjs
# Set ORUK_API_KEY and persist an ORUK_REQUEST_ID UUID before running:
node realtime-phrase-emotions.mjs recording.pcmThe shared helper uses ws on your server with authorization and request-ID headers. It bounds file reads and transport queues, propagates cancellation, and never reconnects automatically. Keep its key out of browser code and retain the original recording and request ID to reconcile an uncertain outcome.
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 requires a final transcript, session.usage, and a clean close. Usage can precede a terminal error, so receiving that event alone is not success. Processing and billing can begin before Commit; closing the connection does not promise a refund.
// Node.js 22.23+ on your server. Install ws@8.21.3 and download hume-native-streams.mjs beside this file.
// Set ORUK_API_KEY and a saved ORUK_REQUEST_ID UUID. Run: node realtime-phrase-emotions.mjs recording.pcm
// Input: raw mono PCM16LE, 16,000 Hz, no WAV header. Output contains private transcripts/scores.
import {createReadStream} from 'node:fs'
import {stat} from 'node:fs/promises'
import {streamRealtime, MigrationStreamError} from './hume-native-streams.mjs'
const stop = new AbortController()
const interrupted = () => stop.abort()
process.once('SIGINT', interrupted)
try {
const file = process.argv[2]
if (!file) throw new Error('missing_input')
const info = await stat(file)
if (!info.isFile() || !info.size || info.size % 2 || info.size > 19_200_000) throw new Error('invalid_input')
await streamRealtime({
apiKey: process.env.ORUK_API_KEY,
requestId: process.env.ORUK_REQUEST_ID,
signal: stop.signal,
source: signal => createReadStream(file, {highWaterMark: 10240, signal}),
onEvent: event => {
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))
}
},
})
// Success requires final transcript, usage AND clean close. No automatic reconnect/retry.
} catch (error) {
console.error(JSON.stringify(error instanceof MigrationStreamError
? {code: error.code, requestId: error.requestId, mayHaveBeenBilled: error.mayHaveBeenBilled, action: error.action}
: {code: 'setup_failed', action: 'Check the permitted PCM file and required environment.'}))
process.exitCode = 1
} finally {
process.removeListener('SIGINT', interrupted)
}
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
If your voice agent already uses another transcription service, review the Oruk and Valence Pulse integration comparison. It explains the difference between Oruk’s combined transcription and phrase events and adding emotion to an existing LiveKit transcription pipeline.