範例
Javascript
目錄
- API Client 設定
- Ticket 取得與 WebSocket 連線
- 即時語音翻譯
- 任務匯出(音檔/逐字稿下載)
- 音檔匯入
- 歷史紀錄載入(SSE)
- 廣播觀眾端(SSE)
- TTS 播放
- 錯誤處理
- Webhook 處理
- 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();
任務匯出(音檔/逐字稿下載)
下載已完成任務的音檔或逐字稿。逐字稿支援 txt、srt、sbv、vtt、csv 五種格式,輸出包含原文與所有翻譯語言。
/**
* 從 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