Javascript
Table of Contents
- API Client Setup
- Obtaining a Ticket and Connecting via WebSocket
- Real-Time Speech Translation
- Task Export (Audio / Transcript Download)
- Audio Import
- Loading History (SSE)
- Broadcast Viewer (SSE)
- TTS Playback
- Error Handling
- Webhook Handling
- TypeScript Type Definitions
API Client Setup
Wraps fetch and authenticates uniformly with the X-API-Key header.
const API_KEY = 'vas_YOUR_API_KEY_HERE_32_CHARACTERS';
const BASE_URL = 'https://vas-poc.vurbo.ai/api/v1';
const WS_URL = 'wss://vas-poc.vurbo.ai/ws';
/**
* VAS API Client
* All REST / SSE requests are authenticated uniformly via the X-API-Key header
*/
class VasApiClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = BASE_URL;
}
/** Generic request method */
async request(path, options = {}) {
const { method = 'GET', body, headers = {} } = options;
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'X-API-Key': this.apiKey,
...(body && !(body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
...headers,
},
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new VasApiError(
error.message || error.error_code || `HTTP ${response.status}`,
response.status,
error.error_code
);
}
return response.json();
}
// === Tasks API ===
async getTasks(status = 'completed') {
return this.request(`/tasks?status=${status}`);
}
async deleteTask(taskId) {
return this.request(`/tasks/${taskId}`, { method: 'DELETE' });
}
async pinTask(taskId, isPinned) {
return this.request(`/tasks/${taskId}/pin`, {
method: 'PUT',
body: { is_pinned: isPinned },
});
}
async markAsRead(taskId) {
return this.request(`/tasks/${taskId}/read`, { method: 'PUT' });
}
async renameTask(taskId, name) {
return this.request(`/tasks/${taskId}/name`, {
method: 'PATCH',
body: { name },
});
}
// === Imports API ===
async checkQuota(durationMs) {
return this.request('/imports/check-quota', {
method: 'POST',
body: { duration_ms: durationMs },
});
}
async uploadAudio(file, options) {
const formData = new FormData();
formData.append('file', file);
formData.append('transcription_languages', JSON.stringify(options.transcriptionLanguages));
formData.append('recognition_mode', options.recognitionMode || 'single');
if (options.translationLanguages) {
formData.append('translation_languages', JSON.stringify(options.translationLanguages));
}
if (options.summaryTemplate) {
formData.append('summary_template', options.summaryTemplate);
}
if (options.callbackUrl) {
formData.append('callback_url', options.callbackUrl);
}
return this.request('/imports', { method: 'POST', body: formData });
}
async getImportStatus(importId) {
return this.request(`/imports/${importId}`);
}
async getImports(perPage = 20) {
return this.request(`/imports?per_page=${perPage}`);
}
// === Broadcasts API ===
async createBroadcast(options) {
return this.request('/broadcasts', { method: 'POST', body: options });
}
async getBroadcast(id) {
return this.request(`/broadcasts/${id}`);
}
async getBroadcasts(page = 1, perPage = 20) {
return this.request(`/broadcasts?page=${page}&per_page=${perPage}`);
}
async updateBroadcast(id, options) {
return this.request(`/broadcasts/${id}`, { method: 'PATCH', body: options });
}
async revokeBroadcast(id) {
return this.request(`/broadcasts/${id}`, { method: 'DELETE' });
}
// === TTS API ===
async getTtsVoices(language) {
return this.request(`/tts/voices?language=${language}`);
}
// === Speakers API ===
async renameSpeaker(recordingId, speakerId, newLabel) {
return this.request(`/recordings/${recordingId}/speakers/rename`, {
method: 'PATCH',
body: { speaker_id: speakerId, new_label: newLabel },
});
}
async reassignSpeaker(recordingId, sid, targetSpeakerId) {
return this.request(`/recordings/${recordingId}/speakers/reassign`, {
method: 'PATCH',
body: { sid, target_speaker_id: targetSpeakerId },
});
}
// === Summary Templates API ===
async getSummaryTemplates() {
return this.request('/summary-templates');
}
// === Auth API ===
async getTicket() {
return this.request('/auth/ticket', { method: 'POST' });
}
}
// Usage example
const api = new VasApiClient('vas_YOUR_API_KEY_HERE_32_CHARACTERS');
Obtaining a Ticket and Connecting via WebSocket
The WebSocket connection authenticates using a Ticket mechanism, where the Ticket is passed through Sec-WebSocket-Protocol.
/**
* Obtain a Ticket and establish a WebSocket connection
*/
async function connectWebSocket(api) {
// Step 1: Obtain a one-time Ticket
const { ticket } = await api.getTicket();
console.log('Ticket obtained, valid for 60 seconds');
// Step 2: Connect to the WebSocket using the Ticket
const ws = new WebSocket('wss://vas-poc.vurbo.ai/ws', [`ticket.${ticket}`]);
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log('WebSocket connected, Protocol:', ws.protocol);
resolve(ws);
};
ws.onerror = (error) => {
console.error('WebSocket connection failed:', error);
reject(error);
};
});
}
// Node.js environment
async function connectWebSocketNode(api) {
const WebSocket = require('ws');
const { ticket } = await api.getTicket();
const ws = new WebSocket('wss://vas-poc.vurbo.ai/ws', [`ticket.${ticket}`]);
return new Promise((resolve, reject) => {
ws.on('open', () => {
console.log('WebSocket connected');
resolve(ws);
});
ws.on('error', reject);
});
}
Real-Time Speech Translation
A complete VurboClient class supporting the full start → audio → result → stop flow.
class VurboClient {
constructor(api) {
this.api = api;
this.ws = null;
this.onResult = null;
this.onError = null;
this.onTaskComplete = null;
this.onSessionStarted = null;
this._heartbeatInterval = null;
}
/** Connect the WebSocket (authenticated with a Ticket) */
async connect() {
const { ticket } = await this.api.getTicket();
this.ws = new WebSocket('wss://vas-poc.vurbo.ai/ws', [`ticket.${ticket}`]);
return new Promise((resolve, reject) => {
this.ws.onopen = () => {
console.log('Vurbo.ai connected');
this._startHeartbeat();
resolve();
};
this.ws.onerror = (error) => {
console.error('Vurbo.ai connection error:', error);
reject(error);
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this._handleMessage(msg);
};
this.ws.onclose = () => {
console.log('Vurbo.ai connection closed');
this._stopHeartbeat();
};
});
}
/** Start speech translation */
startTranslation(options) {
this._send({
type: 'voice-translation',
data: {
action: 'start',
transcription_languages: options.sourceLang,
translation_languages: options.targetLang || [],
realtime_translation: options.realtimeTranslation || false,
recognition_mode: options.recognitionMode || 'single',
type: options.type || 'transcribe',
audio_format: options.audioFormat || 'pcm',
summary_template: options.summaryTemplate || 'meeting',
...(options.ttsEnabled && {
tts_enabled: true,
tts_language: options.ttsLanguage,
tts_voice: options.ttsVoice,
tts_mode: options.ttsMode || 'sync',
}),
},
});
}
/** Configure the terminology list */
setConfig(config) {
this._send({
type: 'voice-translation',
data: {
action: 'config',
...config,
},
});
}
/** Send audio (Base64-encoded) */
sendAudio(base64Audio) {
this._send({
type: 'voice-translation',
data: { action: 'audio', payload: base64Audio },
});
}
/** Pause translation */
pause() {
this._send({ type: 'voice-translation', data: { action: 'pause' } });
}
/** Resume translation */
resume() {
this._send({ type: 'voice-translation', data: { action: 'resume' } });
}
/** Stop translation */
stop() {
this._send({ type: 'voice-translation', data: { action: 'stop' } });
}
/** Set the recording name */
setName(name) {
this._send({
type: 'voice-translation',
data: { action: 'set_name', name },
});
}
/** Switch the translation language */
switchLanguage(targetLanguages) {
this._send({
type: 'voice-translation',
data: { action: 'switch_language', translation_languages: targetLanguages },
});
}
/** Rename a speaker globally */
renameSpeaker(speakerId, newLabel) {
this._send({
type: 'voice-translation',
data: { action: 'rename_speaker', speaker_id: speakerId, new_label: newLabel },
});
}
/** Reassign a single sentence to another speaker */
reassignSpeaker(sid, targetSpeakerId) {
this._send({
type: 'voice-translation',
data: { action: 'reassign_speaker', sid, target_speaker_id: targetSpeakerId },
});
}
/** Merge speakers */
mergeSpeakers(sourceSpeakerId, targetSpeakerId) {
this._send({
type: 'voice-translation',
data: { action: 'merge_speakers', source_speaker_id: sourceSpeakerId, target_speaker_id: targetSpeakerId },
});
}
/** TTS playback */
ttsPlay(sid, length = 1) {
this._send({
type: 'voice-translation',
data: { action: 'tts_play', sid, length },
});
}
/** Stop TTS */
ttsStop() {
this._send({ type: 'voice-translation', data: { action: 'tts_stop' } });
}
/** Disconnect */
disconnect() {
this._stopHeartbeat();
if (this.ws) {
this.ws.close();
}
}
// === Internal methods ===
_send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
_startHeartbeat() {
this._heartbeatInterval = setInterval(() => {
this._send({ type: 'health', data: { action: 'ping' } });
}, 30000);
}
_stopHeartbeat() {
if (this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
this._heartbeatInterval = null;
}
}
_handleMessage(msg) {
if (msg.type === 'error') {
console.error('Error:', msg.data.message);
this.onError?.(msg.data);
return;
}
if (msg.type === 'voice-translation') {
const { action } = msg.data;
switch (action) {
case 'session_started':
this.onSessionStarted?.(msg.data);
break;
case 'result':
this.onResult?.({
origin: msg.data.origin,
translations: msg.data.translations,
});
break;
case 'task_complete':
this.onTaskComplete?.(msg.data.task_id);
break;
case 'tts_ready':
this.onTtsReady?.(msg.data);
break;
case 'speaker_renamed':
this.onSpeakerRenamed?.(msg.data);
break;
case 'speaker_reassigned':
this.onSpeakerReassigned?.(msg.data);
break;
case 'viewer_count':
this.onViewerCount?.(msg.data);
break;
}
}
}
}
Usage Example: Meeting Recording
const api = new VasApiClient('vas_YOUR_API_KEY_HERE_32_CHARACTERS');
const client = new VurboClient(api);
// Set up callbacks
client.onSessionStarted = (data) => {
console.log('Session started:', data.session_id);
console.log('Recording ID:', data.recording_id);
};
client.onResult = ({ origin, translations }) => {
if (origin?.is_final) {
console.log(`[${origin.start_time}] ${origin.text}`);
}
if (translations) {
Object.entries(translations).forEach(([lang, result]) => {
if (result.is_final) {
console.log(` Translation (${lang}): ${result.text}`);
}
});
}
};
client.onTaskComplete = (taskId) => {
console.log('Task complete, Task ID:', taskId);
};
client.onError = (error) => {
console.error('Error:', error.message);
};
// Connect and start translation
await client.connect();
// (Optional) Configure the terminology list
client.setConfig({
terminology: {
'zh-TW': [
{ term: 'Speaker Diarization', boost: 1.5 },
{ term: 'WebSocket', boost: 2.0 },
],
},
});
client.startTranslation({
sourceLang: ['zh-TW'],
targetLang: ['en-US'],
type: 'transcribe',
summaryTemplate: 'meeting',
recognitionMode: 'multi_speaker',
});
// Start recording and send audio
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioContext = new AudioContext({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcmData[i] = Math.max(-32768, Math.min(32767, inputData[i] * 32768));
}
const base64 = btoa(String.fromCharCode(...new Uint8Array(pcmData.buffer)));
client.sendAudio(base64);
};
source.connect(processor);
processor.connect(audioContext.destination);
// When stopping
// client.stop();
// stream.getTracks().forEach(track => track.stop());
// await audioContext.close();
// client.disconnect();
Task Export (Audio / Transcript Download)
Download the audio or transcript of a completed task. Transcripts support five formats — txt, srt, sbv, vtt, and csv — and the output includes the original text along with all translation languages.
/**
* Extract the RFC 5987 UTF-8-encoded filename from Content-Disposition
*/
function parseFilename(response, fallback) {
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
return match ? decodeURIComponent(match[1]) : fallback;
}
/**
* Trigger a browser download
*/
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Download task audio (always an M4A container, .m4a extension)
*/
async function downloadAudio(taskId) {
const response = await fetch(
`${BASE_URL}/tasks/${taskId}/audio/export`,
{ headers: { 'X-API-Key': API_KEY } }
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Download failed: ${error.data?.message}`);
}
const filename = parseFilename(response, `audio-${taskId}.m4a`);
triggerDownload(await response.blob(), filename);
}
/**
* Download the transcript (format: txt | srt | sbv | vtt | csv, defaults to txt)
*/
async function downloadTranscript(taskId, format = 'srt') {
const allowed = ['txt', 'srt', 'sbv', 'vtt', 'csv'];
if (!allowed.includes(format)) {
throw new Error(`Unsupported format: ${format}`);
}
const response = await fetch(
`${BASE_URL}/tasks/${taskId}/transcript/export?format=${format}`,
{ headers: { 'X-API-Key': API_KEY } }
);
if (!response.ok) {
const error = await response.json();
// error.data.error_code may be:
// recording_not_found (404) / recording_transcript_not_ready (422)
// validation_failed (422) / storage_download_failed (500)
throw new Error(`[${error.data?.error_code}] ${error.data?.message}`);
}
const filename = parseFilename(response, `transcript-${taskId}.${format}`);
triggerDownload(await response.blob(), filename);
}
// Usage example
// await downloadAudio('550e8400-e29b-41d4-a716-446655440000');
// await downloadTranscript('550e8400-e29b-41d4-a716-446655440000', 'csv');
Note: Export requires
processing_status = completed; if it is not ready, the API returns422 recording_transcript_not_ready.
Audio Import
Upload an audio file using FormData and poll for the processing status.
/**
* Upload an audio file and wait for processing to complete
*/
async function importAudio(api, file, options = {}) {
// Step 1: Check the budget (optional)
if (options.durationMs) {
const quota = await api.checkQuota(options.durationMs);
if (!quota.data.allowed) {
throw new Error(`Insufficient budget: $${quota.data.remaining_budget} USD remaining`);
}
console.log(`Sufficient budget: $${quota.data.remaining_budget} USD remaining`);
}
// Step 2: Upload the audio file
console.log(`Uploading audio file: ${file.name}`);
const uploadResult = await api.uploadAudio(file, {
transcriptionLanguages: options.transcriptionLanguages || ['zh-TW'],
translationLanguages: options.translationLanguages || ['en-US'],
recognitionMode: options.recognitionMode || 'multi_speaker',
summaryTemplate: options.summaryTemplate || 'meeting',
});
const importId = uploadResult.data.import_id;
console.log(`Import ID: ${importId}`);
// Step 3: Poll the status
return new Promise((resolve, reject) => {
const pollInterval = setInterval(async () => {
try {
const statusResult = await api.getImportStatus(importId);
const { status, stage, progress, task_id, error_message } = statusResult.data;
console.log(`Status: ${status} | Stage: ${stage} | Progress: ${progress}%`);
if (status === 'completed') {
clearInterval(pollInterval);
console.log(`Processing complete! Task ID: ${task_id}`);
resolve({ importId, taskId: task_id });
}
if (status === 'failed') {
clearInterval(pollInterval);
reject(new Error(`Processing failed: ${error_message}`));
}
} catch (err) {
clearInterval(pollInterval);
reject(err);
}
}, 5000); // Poll every 5 seconds
});
}
// Usage example (browser)
const fileInput = document.getElementById('audioFile');
const file = fileInput.files[0];
const result = await importAudio(api, file, {
transcriptionLanguages: ['zh-TW'],
translationLanguages: ['en-US', 'ja-JP'],
recognitionMode: 'multi_speaker',
summaryTemplate: 'meeting',
});
console.log('Use the Task ID to load history:', result.taskId);
Loading History (SSE)
Use EventSource to process the SSE stream. Because the browser's native EventSource does not support custom headers, you need to use fetch and parse the SSE manually.
Method 1: Use fetch and parse SSE manually (recommended)
/**
* Load history (supports the X-API-Key header)
*/
async function loadHistory(taskId, apiKey) {
const response = await fetch(
`https://vas-poc.vurbo.ai/api/v1/sse/history/transcribe/${taskId}`,
{
headers: { 'X-API-Key': apiKey },
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let metadata = null;
const sentences = [];
let summary = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep the incomplete line
let currentEvent = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7);
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
switch (currentEvent) {
case 'init_metadata':
metadata = data;
console.log('Task info:', metadata.title);
break;
case 'init_sentence':
sentences.push(data);
break;
case 'init_summary':
summary = data.text;
break;
case 'init_done':
reader.cancel();
return { metadata, sentences, summary };
}
}
}
}
return { metadata, sentences, summary };
}
// Usage example
const history = await loadHistory(
'550e8400-e29b-41d4-a716-446655440000',
'vas_YOUR_API_KEY_HERE_32_CHARACTERS'
);
console.log(`Total of ${history.sentences.length} sentences`);
history.sentences.forEach((s) => {
console.log(`[${s.start_time}] ${s.origin}`);
if (s.translations) {
Object.entries(s.translations).forEach(([lang, text]) => {
console.log(` [${lang}] ${text}`);
});
}
});
Retranslate the Entire Transcript
/**
* Retranslate the entire transcript into a specified language
*/
async function retranslate(taskId, targetLang, apiKey, onProgress) {
const response = await fetch(
`https://vas-poc.vurbo.ai/api/v1/sse/retranslate/${taskId}?targetLang=${targetLang}`,
{
headers: { 'X-API-Key': apiKey },
}
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
const results = [];
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
let currentEvent = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7);
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (currentEvent === 'translation') {
results.push(data);
onProgress?.(data);
}
if (currentEvent === 'done') {
reader.cancel();
return results;
}
}
}
}
return results;
}
// Usage example
const translations = await retranslate(
'550e8400-e29b-41d4-a716-446655440000',
'ja-JP',
'vas_YOUR_API_KEY_HERE_32_CHARACTERS',
(progress) => console.log(`Translating: sentence ${progress.sid}`)
);
Broadcast Viewer (SSE)
Viewers receive real-time subtitles and translations over SSE.
/**
* Broadcast viewer SSE connection
* The viewer API does not require an API Key
*/
class BroadcastViewer {
constructor(token) {
this.token = token;
this.eventSource = null;
this.onSentence = null;
this.onTranslation = null;
this.onTtsReady = null;
this.onAnnouncement = null;
this.onStandby = null;
this.onLive = null;
this.onEnded = null;
}
/** Get public broadcast info (no authentication required) */
async getBroadcastInfo() {
const response = await fetch(
`https://vas-poc.vurbo.ai/api/v1/viewer/broadcasts/${this.token}`
);
return response.json();
}
/** Verify the password (if required) */
async verifyPassword(password) {
const response = await fetch(
`https://vas-poc.vurbo.ai/api/v1/viewer/broadcasts/${this.token}/verify`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}
);
return response.json();
}
/** Connect via SSE to receive real-time subtitles */
connect(options = {}) {
let url = `https://vas-poc.vurbo.ai/broadcast/${this.token}/text`;
const params = new URLSearchParams();
if (options.lang) params.set('lang', options.lang);
if (options.viewerAccessToken) params.set('viewer_access_token', options.viewerAccessToken);
if (options.ttsVoice) params.set('tts_voice', options.ttsVoice);
if (params.toString()) {
url += `?${params.toString()}`;
}
this.eventSource = new EventSource(url);
this.eventSource.addEventListener('sentence', (e) => {
const data = JSON.parse(e.data);
this.onSentence?.(data);
});
this.eventSource.addEventListener('translation', (e) => {
const data = JSON.parse(e.data);
this.onTranslation?.(data);
});
this.eventSource.addEventListener('tts_ready', (e) => {
const data = JSON.parse(e.data);
this.onTtsReady?.(data);
});
this.eventSource.addEventListener('announcement', (e) => {
const data = JSON.parse(e.data);
this.onAnnouncement?.(data);
});
this.eventSource.addEventListener('standby', (e) => {
const data = JSON.parse(e.data);
this.onStandby?.(data);
});
this.eventSource.addEventListener('live', (e) => {
this.onLive?.();
});
this.eventSource.addEventListener('ended', (e) => {
this.onEnded?.();
this.disconnect();
});
this.eventSource.onerror = (e) => {
console.error('SSE connection error:', e);
};
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
// Usage example
const viewer = new BroadcastViewer('a3f9');
// Get broadcast info
const info = await viewer.getBroadcastInfo();
console.log('Broadcast name:', info.data.name);
console.log('Requires password:', info.data.requires_password);
// If password verification is required
let viewerAccessToken = null;
if (info.data.requires_password) {
const verifyResult = await viewer.verifyPassword('mySecret123');
viewerAccessToken = verifyResult.data.viewer_access_token;
}
// Connect via SSE
viewer.onSentence = (data) => {
console.log(`[Original] ${data.text}`);
};
viewer.onTranslation = (data) => {
console.log(`[Translation] ${data.text}`);
};
viewer.onTtsReady = (data) => {
// Play the TTS audio
const audioBlob = base64ToBlob(data.audio, 'audio/mpeg');
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.play();
};
viewer.onAnnouncement = (data) => {
console.log(`[Announcement] ${data.message}`);
};
viewer.connect({
lang: 'en-US',
viewerAccessToken,
});
TTS Playback
TTS audio is sent as a Base64-encoded MP3 and must be converted to a Blob before playback.
/**
* Base64 to Blob
*/
function base64ToBlob(base64, mimeType = 'audio/mpeg') {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
}
/**
* TTS player (with Word Boundary synchronized highlighting)
*/
class TtsPlayer {
constructor() {
this.audio = null;
this.boundaries = [];
this.onWordHighlight = null;
this._animationFrame = null;
}
/** Play TTS audio */
play(ttsData) {
this.stop();
const blob = base64ToBlob(ttsData.audio, 'audio/mpeg');
const url = URL.createObjectURL(blob);
this.audio = new Audio(url);
this.boundaries = ttsData.boundaries || [];
this.audio.onended = () => {
URL.revokeObjectURL(url);
this._stopHighlight();
};
// Start Word Boundary synchronization
if (this.boundaries.length > 0 && this.onWordHighlight) {
this._startHighlight();
}
this.audio.play();
}
/** Stop playback */
stop() {
if (this.audio) {
this.audio.pause();
this.audio = null;
}
this._stopHighlight();
}
_startHighlight() {
let lastIndex = -1;
const update = () => {
if (!this.audio) return;
const currentMs = this.audio.currentTime * 1000;
for (let i = this.boundaries.length - 1; i >= 0; i--) {
if (currentMs >= this.boundaries[i].offset_ms) {
if (i !== lastIndex) {
lastIndex = i;
this.onWordHighlight?.(this.boundaries[i], i);
}
break;
}
}
this._animationFrame = requestAnimationFrame(update);
};
this._animationFrame = requestAnimationFrame(update);
}
_stopHighlight() {
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
this._animationFrame = null;
}
}
}
// Usage example
const ttsPlayer = new TtsPlayer();
ttsPlayer.onWordHighlight = (boundary, index) => {
console.log(`Highlight: "${boundary.text}" (${boundary.offset_ms}ms)`);
};
// Use within VurboClient
client.onTtsReady = (data) => {
console.log(`TTS ready: SID ${data.sid}, language ${data.language}`);
ttsPlayer.play(data);
};
Playing an Audio File
/**
* Play the audio of a task recording
*/
async function playTaskAudio(taskId, apiKey) {
const response = await fetch(
`https://vas-poc.vurbo.ai/api/v1/sse/audio/${taskId}`,
{
headers: { 'X-API-Key': apiKey },
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.onended = () => URL.revokeObjectURL(audioUrl);
audio.play();
return audio;
}
Webhook Handling
Receive and verify VAS Webhook events.
const crypto = require('crypto');
const express = require('express');
const WEBHOOK_SECRET = 'your_webhook_secret_here';
/**
* Verify the Webhook signature
*/
function verifySignature(req) {
const timestamp = req.headers['x-vas-timestamp'];
const signature = req.headers['x-vas-signature'];
const signaturePayload = `${timestamp}.${req.rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signaturePayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expected)
);
}
// Express middleware
const app = express();
app.use('/webhooks/vas', express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));
app.post('/webhooks/vas', (req, res) => {
// Verify the signature
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).json({ received: true });
// Process the event asynchronously
const { event, data, delivery_id } = req.body;
switch (event) {
case 'recording.completed':
console.log(`Recording completed: ${data.task_id}`);
// Use task_id to load the transcript...
break;
case 'import.completed':
console.log(`Import completed: ${data.import_id} → ${data.task_id}`);
break;
case 'recording.failed':
case 'import.failed':
console.error(`Processing failed: ${data.error || data.error_message}`);
break;
}
});
For the complete Webhook guide, see the Webhook Callback Guide.
Error Handling
/**
* VAS API error class
*/
class VasApiError extends Error {
constructor(message, statusCode, errorCode) {
super(message);
this.name = 'VasApiError';
this.statusCode = statusCode;
this.errorCode = errorCode;
}
}
/**
* Unified error handling
*/
function handleApiError(error) {
if (error instanceof VasApiError) {
switch (error.errorCode) {
// Authentication errors
case 'auth_missing_api_key':
case 'auth_invalid_api_key':
case 'auth_key_expired':
console.error('Authentication failed, please check your API Key');
break;
// Budget errors
case 'auth_budget_exceeded':
console.error('Monthly budget exceeded; wait for next month\'s reset or adjust the budget');
break;
// Resource errors
case 'recording_not_found':
case 'import_not_found':
console.error('The specified resource was not found');
break;
// Broadcast errors
case 'broadcast_session_not_found':
console.error('The specified broadcast was not found');
break;
case 'broadcast_cannot_revoke':
console.error('Only broadcasts in the pending state can be revoked');
break;
// Validation errors
case 'validation_failed':
console.error('Parameter validation failed');
break;
default:
console.error(`API error [${error.errorCode}]: ${error.message}`);
}
} else {
console.error('Unknown error:', error.message);
}
}
// Usage example
try {
const tasks = await api.getTasks();
} catch (error) {
handleApiError(error);
}
WebSocket Reconnection Mechanism
/**
* VurboClient with automatic reconnection
*/
class ResilientVurboClient extends VurboClient {
constructor(api, options = {}) {
super(api);
this.maxRetries = options.maxRetries || 5;
this.retryDelay = options.retryDelay || 3000;
this._retryCount = 0;
}
async connect() {
try {
await super.connect();
this._retryCount = 0;
this.ws.onclose = () => {
console.log('WebSocket connection closed');
this._stopHeartbeat();
this._tryReconnect();
};
} catch (error) {
this._tryReconnect();
}
}
async _tryReconnect() {
if (this._retryCount >= this.maxRetries) {
console.error(`Maximum number of reconnection attempts reached (${this.maxRetries})`);
return;
}
this._retryCount++;
const delay = this.retryDelay * Math.pow(2, this._retryCount - 1);
console.log(`Reconnecting in ${delay / 1000} seconds (attempt ${this._retryCount})...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await this.connect();
}
}
TypeScript Type Definitions
// === API Client types ===
interface VasApiClientOptions {
apiKey: string;
baseUrl?: string;
}
// === Authentication ===
interface TicketResponse {
ticket: string;
expires_in: number;
}
// === Tasks ===
interface Task {
task_id: string;
title: string;
type: 'transcribe' | 'conversation' | 'record' | 'broadcast';
duration_ms: number;
duration_formatted: string;
source_lang: string;
target_lang: string;
created_at: string;
audio_status: string;
is_pinned: boolean;
is_unread: boolean;
}
interface TaskListResponse {
tasks: Task[];
}
interface RenameTaskResponse {
message: string;
data: {
task_id: string;
name: string;
name_source: 'default' | 'llm' | 'user';
};
}
// === Imports ===
interface CheckQuotaResponse {
success: boolean;
data: {
allowed: boolean;
remaining_budget: number | null;
is_exceeded: boolean;
};
}
type ImportStatus = 'pending' | 'processing' | 'completed' | 'failed';
type ImportStage = 'converting' | 'transcribing' | 'translating' | 'summarizing' | null;
interface ImportData {
import_id: string;
status: ImportStatus;
stage: ImportStage;
progress: number;
message: string | null;
original_filename: string;
file_size: string;
task_id: string | null;
error_code: string | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
interface UploadOptions {
transcriptionLanguages: string[];
translationLanguages?: string[];
recognitionMode?: 'single' | 'multi_speaker';
summaryTemplate?: string;
callbackUrl?: string;
}
// === Broadcasts ===
type BroadcastStatus = 'pending' | 'active' | 'paused' | 'ended' | 'revoked';
interface TtsConfig {
[language: string]: {
voice: string;
speaking_rate?: number;
};
}
interface Broadcast {
id: string;
token: string;
name: string;
share_url: string;
transcription_language: string;
translation_languages: string[];
tts_config: TtsConfig | null;
speaker_diarization: boolean;
summary_template: string | null;
summary_language: string | null;
max_viewers: number;
access_type: 'public' | 'password';
pass_code: string | null;
status: BroadcastStatus;
is_live: boolean;
session_id: string | null;
current_recording_id: string | null;
recordings_count: number;
peak_viewers: number;
total_viewers: number;
duration_ms: number;
duration_formatted: string;
started_at: string | null;
ended_at: string | null;
revoked_at: string | null;
created_at: string;
}
interface CreateBroadcastOptions {
transcription_language: string;
translation_languages?: string[];
name?: string;
access_type?: 'public' | 'password';
pass_code?: string;
max_viewers?: number;
speaker_diarization?: boolean;
tts_config?: TtsConfig;
summary_template?: string;
summary_language?: string;
callback_url?: string;
}
// === Webhook ===
interface WebhookPayload {
event: 'recording.completed' | 'recording.failed' | 'import.completed' | 'import.failed';
timestamp: string;
delivery_id: string;
data: Record<string, unknown>;
}
interface RecordingCompletedData {
task_id: string;
name: string;
duration_ms: number;
type_source: 'realtime' | 'import';
transcription_languages: string[];
translation_languages: string[];
}
interface ImportCompletedData {
import_id: string;
task_id: string;
name: string;
duration_ms: number;
}
// === TTS ===
interface TtsVoice {
voice_name: string;
display_name: string;
gender: 'Female' | 'Male';
is_default: boolean;
sample_url: string;
}
interface TtsLanguage {
code: string;
name: string;
voices_count: number;
}
// === WebSocket messages ===
interface WsMessage {
type: 'health' | 'voice-translation' | 'error';
data: Record<string, unknown>;
}
interface SessionStartedData {
action: 'session_started';
session_id: string;
recording_id: string;
recording_type: 'transcribe' | 'conversation' | 'record' | 'broadcast';
recognition_mode: 'single' | 'multi_speaker';
phase?: 'standby' | 'live';
viewer_count?: number;
message: string;
}
interface OriginResult {
sid: number;
language: string;
text: string;
is_final: boolean;
speaker_id: string;
detected_language: string;
start_time: string;
}
interface TranslationResult {
sid: number;
text: string;
is_final: boolean;
is_retranslation?: boolean;
}
interface TtsReadyData {
action: 'tts_ready';
sid: number;
language: string;
transcript: string;
text: string;
audio: string;
format: 'mp3';
duration_ms: number;
boundaries: WordBoundary[];
}
interface WordBoundary {
offset_ms: number;
duration_ms: number;
text_offset: number;
word_length: number;
text: string;
}
interface StartTranslationOptions {
sourceLang: string[];
targetLang?: string[];
realtimeTranslation?: boolean;
recognitionMode?: 'single' | 'multi_speaker';
type?: 'transcribe' | 'conversation' | 'record' | 'broadcast';
audioFormat?: 'pcm' | 'webm';
summaryTemplate?: string;
ttsEnabled?: boolean;
ttsLanguage?: string;
ttsVoice?: string;
ttsMode?: 'sync' | 'async';
broadcastToken?: string;
}
// === SSE history ===
interface HistorySentence {
sid: number;
origin: string;
translation: string;
start_time: string;
speaker?: string;
}
interface HistoryMetadata {
task_id: string;
title: string;
created_at: string;
type: string;
}
interface HistoryResult {
metadata: HistoryMetadata | null;
sentences: HistorySentence[];
summary: string | null;
}
// === Errors ===
interface VasApiErrorData {
error_code: string;
message: string;
severity?: 'fatal' | 'error' | 'warning';
context?: string;
request_id?: string;
timestamp?: string;
}
// === Summary Templates ===
interface SummaryTemplate {
slug: string;
name: string;
description: string | null;
}
// === Speakers ===
interface SpeakerRenameResult {
speaker_id: string;
new_label: string;
affected_sids: number[];
}
interface SpeakerReassignResult {
sid: number;
old_speaker_id: string;
new_speaker_id: string;
new_speaker_label: string;
}
Version: V1.5.7 Last Updated: 2026-05-20