範例

Javascript

目錄

  1. API Client 設定
  2. Ticket 取得與 WebSocket 連線
  3. 即時語音翻譯
  4. 任務匯出(音檔/逐字稿下載)
  5. 音檔匯入
  6. 歷史紀錄載入(SSE)
  7. 廣播觀眾端(SSE)
  8. TTS 播放
  9. 錯誤處理
  10. Webhook 處理
  11. TypeScript 型別定義

API Client 設定

封裝 fetch,統一使用 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
 * 所有 REST / SSE 請求統一透過 X-API-Key header 認證
 */
class VasApiClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.baseUrl = BASE_URL;
  }

  /** 通用請求方法 */
  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' });
  }
}

// 使用範例
const api = new VasApiClient('vas_YOUR_API_KEY_HERE_32_CHARACTERS');

Ticket 取得與 WebSocket 連線

WebSocket 使用 Ticket 機制認證,Ticket 透過 Sec-WebSocket-Protocol 傳遞。

/**
 * 取得 Ticket 並建立 WebSocket 連線
 */
async function connectWebSocket(api) {
  // 步驟 1:取得一次性 Ticket
  const { ticket } = await api.getTicket();
  console.log('取得 Ticket,有效期 60 秒');

  // 步驟 2:使用 Ticket 連線 WebSocket
  const ws = new WebSocket('wss://vas-poc.vurbo.ai/ws', [`ticket.${ticket}`]);

  return new Promise((resolve, reject) => {
    ws.onopen = () => {
      console.log('WebSocket 連線成功,Protocol:', ws.protocol);
      resolve(ws);
    };

    ws.onerror = (error) => {
      console.error('WebSocket 連線失敗:', error);
      reject(error);
    };
  });
}

// Node.js 環境
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 連線成功');
      resolve(ws);
    });
    ws.on('error', reject);
  });
}

即時語音翻譯

完整的 VurboClient 類別,支援 start → audio → result → stop 完整流程。

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;
  }

  /** 連線 WebSocket(使用 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 連線成功');
        this._startHeartbeat();
        resolve();
      };

      this.ws.onerror = (error) => {
        console.error('Vurbo.ai 連線錯誤:', error);
        reject(error);
      };

      this.ws.onmessage = (event) => {
        const msg = JSON.parse(event.data);
        this._handleMessage(msg);
      };

      this.ws.onclose = () => {
        console.log('Vurbo.ai 連線關閉');
        this._stopHeartbeat();
      };
    });
  }

  /** 開始語音翻譯 */
  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',
        }),
      },
    });
  }

  /** 設定術語庫 */
  setConfig(config) {
    this._send({
      type: 'voice-translation',
      data: {
        action: 'config',
        ...config,
      },
    });
  }

  /** 傳送音訊(Base64 編碼) */
  sendAudio(base64Audio) {
    this._send({
      type: 'voice-translation',
      data: { action: 'audio', payload: base64Audio },
    });
  }

  /** 暫停翻譯 */
  pause() {
    this._send({ type: 'voice-translation', data: { action: 'pause' } });
  }

  /** 恢復翻譯 */
  resume() {
    this._send({ type: 'voice-translation', data: { action: 'resume' } });
  }

  /** 停止翻譯 */
  stop() {
    this._send({ type: 'voice-translation', data: { action: 'stop' } });
  }

  /** 設定錄音名稱 */
  setName(name) {
    this._send({
      type: 'voice-translation',
      data: { action: 'set_name', name },
    });
  }

  /** 切換翻譯語言 */
  switchLanguage(targetLanguages) {
    this._send({
      type: 'voice-translation',
      data: { action: 'switch_language', translation_languages: targetLanguages },
    });
  }

  /** 全域重命名說話者 */
  renameSpeaker(speakerId, newLabel) {
    this._send({
      type: 'voice-translation',
      data: { action: 'rename_speaker', speaker_id: speakerId, new_label: newLabel },
    });
  }

  /** 重新指派單句語者 */
  reassignSpeaker(sid, targetSpeakerId) {
    this._send({
      type: 'voice-translation',
      data: { action: 'reassign_speaker', sid, target_speaker_id: targetSpeakerId },
    });
  }

  /** 合併語者 */
  mergeSpeakers(sourceSpeakerId, targetSpeakerId) {
    this._send({
      type: 'voice-translation',
      data: { action: 'merge_speakers', source_speaker_id: sourceSpeakerId, target_speaker_id: targetSpeakerId },
    });
  }

  /** TTS 播放 */
  ttsPlay(sid, length = 1) {
    this._send({
      type: 'voice-translation',
      data: { action: 'tts_play', sid, length },
    });
  }

  /** TTS 停止 */
  ttsStop() {
    this._send({ type: 'voice-translation', data: { action: 'tts_stop' } });
  }

  /** 中斷連線 */
  disconnect() {
    this._stopHeartbeat();
    if (this.ws) {
      this.ws.close();
    }
  }

  // === 內部方法 ===

  _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('錯誤:', 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;
      }
    }
  }
}

使用範例:會議錄音

const api = new VasApiClient('vas_YOUR_API_KEY_HERE_32_CHARACTERS');
const client = new VurboClient(api);

// 設定回調
client.onSessionStarted = (data) => {
  console.log('Session 已啟動:', data.session_id);
  console.log('錄音 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(`  翻譯 (${lang}): ${result.text}`);
      }
    });
  }
};

client.onTaskComplete = (taskId) => {
  console.log('任務完成,Task ID:', taskId);
};

client.onError = (error) => {
  console.error('錯誤:', error.message);
};

// 連線並開始翻譯
await client.connect();

// (可選)設定術語庫
client.setConfig({
  terminology: {
    'zh-TW': [
      { term: '語者分離', boost: 1.5 },
      { term: 'WebSocket', boost: 2.0 },
    ],
  },
});

client.startTranslation({
  sourceLang: ['zh-TW'],
  targetLang: ['en-US'],
  type: 'transcribe',
  summaryTemplate: 'meeting',
  recognitionMode: 'multi_speaker',
});

// 開始錄音並傳送音訊
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);

// 停止時
// client.stop();
// stream.getTracks().forEach(track => track.stop());
// await audioContext.close();
// client.disconnect();

任務匯出(音檔/逐字稿下載)

下載已完成任務的音檔或逐字稿。逐字稿支援 txtsrtsbvvttcsv 五種格式,輸出包含原文與所有翻譯語言。

/**
 * 從 Content-Disposition 取得 RFC 5987 UTF-8 編碼檔名
 */
function parseFilename(response, fallback) {
  const disposition = response.headers.get('Content-Disposition') || '';
  const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
  return match ? decodeURIComponent(match[1]) : fallback;
}

/**
 * 觸發瀏覽器下載
 */
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);
}

/**
 * 下載任務音檔(一律為 M4A 容器,副檔名 .m4a)
 */
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);
}

/**
 * 下載逐字稿(format: txt | srt | sbv | vtt | csv,預設 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 可能為:
    //   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);
}

// 使用範例
// await downloadAudio('550e8400-e29b-41d4-a716-446655440000');
// await downloadTranscript('550e8400-e29b-41d4-a716-446655440000', 'csv');

注意:匯出需 processing_status = completed;未就緒會回 422 recording_transcript_not_ready


音檔匯入

使用 FormData 上傳音檔,並輪詢處理狀態。

/**
 * 上傳音檔並等待處理完成
 */
async function importAudio(api, file, options = {}) {
  // 步驟 1:檢查預算(可選)
  if (options.durationMs) {
    const quota = await api.checkQuota(options.durationMs);
    if (!quota.data.allowed) {
      throw new Error(`預算不足:剩餘 $${quota.data.remaining_budget} USD`);
    }
    console.log(`預算充足:剩餘 $${quota.data.remaining_budget} USD`);
  }

  // 步驟 2:上傳音檔
  console.log(`上傳音檔: ${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(`匯入 ID: ${importId}`);

  // 步驟 3:輪詢狀態
  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} | 階段: ${stage} | 進度: ${progress}%`);

        if (status === 'completed') {
          clearInterval(pollInterval);
          console.log(`處理完成!Task ID: ${task_id}`);
          resolve({ importId, taskId: task_id });
        }

        if (status === 'failed') {
          clearInterval(pollInterval);
          reject(new Error(`處理失敗: ${error_message}`));
        }
      } catch (err) {
        clearInterval(pollInterval);
        reject(err);
      }
    }, 5000); // 每 5 秒查詢一次
  });
}

// 使用範例(瀏覽器)
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('可使用 Task ID 載入歷史紀錄:', result.taskId);

歷史紀錄載入(SSE)

使用 EventSource 處理 SSE 串流。由於瀏覽器原生 EventSource 不支援自訂 header,需使用 fetch + 手動解析 SSE。

方法一:使用 fetch 手動解析 SSE(推薦)

/**
 * 載入歷史紀錄(支援 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(); // 保留未完成的行

    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('任務資訊:', 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 };
}

// 使用範例
const history = await loadHistory(
  '550e8400-e29b-41d4-a716-446655440000',
  'vas_YOUR_API_KEY_HERE_32_CHARACTERS'
);

console.log(`共有 ${history.sentences.length} 句`);
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}`);
    });
  }
});

重新翻譯全文

/**
 * 重新翻譯全文為指定語言
 */
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;
}

// 使用範例
const translations = await retranslate(
  '550e8400-e29b-41d4-a716-446655440000',
  'ja-JP',
  'vas_YOUR_API_KEY_HERE_32_CHARACTERS',
  (progress) => console.log(`翻譯中: 句子 ${progress.sid}`)
);

廣播觀眾端(SSE)

觀眾透過 SSE 接收即時字幕和翻譯。

/**
 * 廣播觀眾端 SSE 連線
 * 觀眾端 API 不需要 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;
  }

  /** 取得廣播公開資訊(不需認證) */
  async getBroadcastInfo() {
    const response = await fetch(
      `https://vas-poc.vurbo.ai/api/v1/viewer/broadcasts/${this.token}`
    );
    return response.json();
  }

  /** 密碼驗證(若需要) */
  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();
  }

  /** 連線 SSE 接收即時字幕 */
  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 連線錯誤:', e);
    };
  }

  disconnect() {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }
  }
}

// 使用範例
const viewer = new BroadcastViewer('a3f9');

// 取得廣播資訊
const info = await viewer.getBroadcastInfo();
console.log('廣播名稱:', info.data.name);
console.log('需要密碼:', info.data.requires_password);

// 若需要密碼驗證
let viewerAccessToken = null;
if (info.data.requires_password) {
  const verifyResult = await viewer.verifyPassword('mySecret123');
  viewerAccessToken = verifyResult.data.viewer_access_token;
}

// 連線 SSE
viewer.onSentence = (data) => {
  console.log(`[原文] ${data.text}`);
};

viewer.onTranslation = (data) => {
  console.log(`[翻譯] ${data.text}`);
};

viewer.onTtsReady = (data) => {
  // 播放 TTS 音訊
  const audioBlob = base64ToBlob(data.audio, 'audio/mpeg');
  const audioUrl = URL.createObjectURL(audioBlob);
  const audio = new Audio(audioUrl);
  audio.play();
};

viewer.onAnnouncement = (data) => {
  console.log(`[公告] ${data.message}`);
};

viewer.connect({
  lang: 'en-US',
  viewerAccessToken,
});

TTS 播放

TTS 音訊以 Base64 編碼的 MP3 格式傳送,需轉換為 Blob 後播放。

/**
 * Base64 轉 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 播放器(含 Word Boundary 同步高亮)
 */
class TtsPlayer {
  constructor() {
    this.audio = null;
    this.boundaries = [];
    this.onWordHighlight = null;
    this._animationFrame = null;
  }

  /** 播放 TTS 音訊 */
  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();
    };

    // 啟動 Word Boundary 同步
    if (this.boundaries.length > 0 && this.onWordHighlight) {
      this._startHighlight();
    }

    this.audio.play();
  }

  /** 停止播放 */
  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;
    }
  }
}

// 使用範例
const ttsPlayer = new TtsPlayer();

ttsPlayer.onWordHighlight = (boundary, index) => {
  console.log(`高亮: "${boundary.text}" (${boundary.offset_ms}ms)`);
};

// 在 VurboClient 中使用
client.onTtsReady = (data) => {
  console.log(`TTS 就緒: SID ${data.sid}, 語言 ${data.language}`);
  ttsPlayer.play(data);
};

音訊檔案播放

/**
 * 播放任務錄音音訊
 */
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 處理

接收並驗證 VAS Webhook 事件。

const crypto = require('crypto');
const express = require('express');

const WEBHOOK_SECRET = 'your_webhook_secret_here';

/**
 * 驗證 Webhook 簽名
 */
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) => {
  // 驗證簽名
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  res.status(200).json({ received: true });

  // 非同步處理事件
  const { event, data, delivery_id } = req.body;

  switch (event) {
    case 'recording.completed':
      console.log(`錄音完成: ${data.task_id}`);
      // 使用 task_id 載入逐字稿...
      break;
    case 'import.completed':
      console.log(`匯入完成: ${data.import_id} → ${data.task_id}`);
      break;
    case 'recording.failed':
    case 'import.failed':
      console.error(`處理失敗: ${data.error || data.error_message}`);
      break;
  }
});

完整的 Webhook 指南請參考 Webhook 回呼指南


錯誤處理

/**
 * VAS API 錯誤類別
 */
class VasApiError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.name = 'VasApiError';
    this.statusCode = statusCode;
    this.errorCode = errorCode;
  }
}

/**
 * 統一錯誤處理
 */
function handleApiError(error) {
  if (error instanceof VasApiError) {
    switch (error.errorCode) {
      // 認證錯誤
      case 'auth_missing_api_key':
      case 'auth_invalid_api_key':
      case 'auth_key_expired':
        console.error('認證失敗,請檢查 API Key');
        break;

      // 預算錯誤
      case 'auth_budget_exceeded':
        console.error('月度預算已超額,請等待下月重置或調整預算');
        break;

      // 資源錯誤
      case 'recording_not_found':
      case 'import_not_found':
        console.error('找不到指定資源');
        break;

      // 廣播錯誤
      case 'broadcast_session_not_found':
        console.error('找不到指定廣播');
        break;
      case 'broadcast_cannot_revoke':
        console.error('僅 pending 狀態的廣播可撤銷');
        break;

      // 驗證錯誤
      case 'validation_failed':
        console.error('參數驗證失敗');
        break;

      default:
        console.error(`API 錯誤 [${error.errorCode}]: ${error.message}`);
    }
  } else {
    console.error('未知錯誤:', error.message);
  }
}

// 使用範例
try {
  const tasks = await api.getTasks();
} catch (error) {
  handleApiError(error);
}

WebSocket 重連機制

/**
 * 帶自動重連的 VurboClient
 */
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 連線關閉');
        this._stopHeartbeat();
        this._tryReconnect();
      };
    } catch (error) {
      this._tryReconnect();
    }
  }

  async _tryReconnect() {
    if (this._retryCount >= this.maxRetries) {
      console.error(`已達到最大重連次數 (${this.maxRetries})`);
      return;
    }

    this._retryCount++;
    const delay = this.retryDelay * Math.pow(2, this._retryCount - 1);
    console.log(`${delay / 1000} 秒後重連(第 ${this._retryCount} 次)...`);

    await new Promise((resolve) => setTimeout(resolve, delay));
    await this.connect();
  }
}

TypeScript 型別定義

// === API Client 型別 ===

interface VasApiClientOptions {
  apiKey: string;
  baseUrl?: string;
}

// === 認證 ===

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 訊息 ===

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 歷史紀錄 ===

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;
}

// === 錯誤 ===

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;
}

版本:V1.5.7 最後更新:2026-05-20

Copyright © 2026