Skip to content

Explore oruk

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.

Terminal
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.pcm

The 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.

realtime-phrase-emotions.mjs
// 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.'))
  })
})
Download the complete example

Read events without confusing their timing

  • conversation.item.input_audio_transcription.delta carries provisional words. The completed event carries the final transcript.
  • conversation.item.input_audio_emotion.completed carries 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.failed separately. 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