グローバル化が進む現在、リアルタイム音声翻訳はビジネスコミュニケーションの必須ツールになりましました。Gemini の Live Translation API は、WebSocket ベースのストリーミング設計により、わずか200ミリ秒以下のレイテンシーで多言語対応を実現します。ここではこの高度なAPI を活用して、本番レベルの音声翻訳アプリを構築する方法を詳しく解説します。
Gemini Live Translation API の概要
Live Translation の全体像
Gemini Live Translation API は、以下の特徴を備えています。
低レイテンシー設計
- WebSocket 経由のストリーミング処理
- 平均遅延:150~200ms
- SIP トランク対応で既存 PBX との統合可能
多言語対応
- 140+ の言語ペアをサポート
- 方言・口語にも対応
- 文脈保持による自然な翻訳
高度な音声処理
- 話者ダイアライゼーション(複数話者の識別)
- バックグラウンドノイズ除去
- 話者ラベリング付き出力
コスト効率性
- 従量課金で初期投資がゼロ
- トークンベースの課金で予測可能なコスト
- キャッシング機構で重複リクエスト削減
従来の翻訳ソリューションとの比較
Gemini Live Translation は、Google Translate、AWS Translate、Azure Translator などと比べ、リアルタイム音声ストリーミング対応と文脈保持機構が強みです。
アーキテクチャ設計
クライアント・サーバー構成
[ユーザー(音声入力)]
↓
[WebRTC/WebSocket]
↓
[Node.js サーバー]
↓
[Gemini Live API]
↓
[多言語テキスト出力 + 音声合成]
↓
[ユーザー(音声出力)]
セッション管理
長時間の通話では、セッションコンテキストを保持して翻訳品質を維持します。
interface TranslationSession {
sessionId: string;
sourceLanguage: string;
targetLanguage: string;
startTime: Date;
speakers: Map<string, {
name: string;
language: string;
translations: string[];
}>;
contextBuffer: string[];
metadata: {
domain?: "business" | "medical" | "legal" | "general";
terminology?: Record<string, string>;
};
}
class SessionManager {
private sessions: Map<string, TranslationSession> = new Map();
private sessionTTL = 4 * 60 * 60 * 1000; // 4時間
createSession(
sourceLanguage: string,
targetLanguage: string
): TranslationSession {
const sessionId = `trans_${Date.now()}_${Math.random().toString(36)}`;
const session: TranslationSession = {
sessionId,
sourceLanguage,
targetLanguage,
startTime: new Date(),
speakers: new Map(),
contextBuffer: [],
metadata: { domain: "general" },
};
this.sessions.set(sessionId, session);
setTimeout(
() => this.sessions.delete(sessionId),
this.sessionTTL
);
return session;
}
getSession(sessionId: string): TranslationSession | undefined {
return this.sessions.get(sessionId);
}
updateContext(sessionId: string, text: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.contextBuffer.push(text);
if (session.contextBuffer.length > 100) {
session.contextBuffer.shift();
}
}
}
}WebSocket ベースのリアルタイム通信
双方向ストリーミング
import WebSocket from "ws";
import { GoogleGenerativeAI } from "@google/generative-ai";
class LiveTranslationServer {
private wss: WebSocket.Server;
private client: GoogleGenerativeAI;
private sessionManager: SessionManager;
constructor(
port: number,
apiKey: string
) {
this.wss = new WebSocket.Server({ port });
this.client = new GoogleGenerativeAI(apiKey);
this.sessionManager = new SessionManager();
this.setupConnections();
}
private setupConnections(): void {
this.wss.on("connection", (ws: WebSocket) => {
console.log("Client connected");
ws.on("message", (data: Buffer) => {
this.handleMessage(ws, data).catch((error) =>
console.error("Error:", error)
);
});
ws.on("close", () => {
console.log("Client disconnected");
});
ws.on("error", (error) => {
console.error("WebSocket error:", error);
});
});
}
private async handleMessage(
ws: WebSocket,
data: Buffer
): Promise<void> {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case "init":
await this.handleInit(ws, message);
break;
case "audio_chunk":
await this.handleAudioChunk(ws, message);
break;
case "end_stream":
await this.handleEndStream(ws, message);
break;
default:
ws.send(
JSON.stringify({
type: "error",
error: "Unknown message type",
})
);
}
} catch (error) {
ws.send(
JSON.stringify({
type: "error",
error: (error as Error).message,
})
);
}
}
private async handleInit(
ws: WebSocket,
message: {
sourceLanguage: string;
targetLanguage: string;
domain?: string;
}
): Promise<void> {
const session = this.sessionManager.createSession(
message.sourceLanguage,
message.targetLanguage
);
if (message.domain) {
session.metadata.domain = message.domain;
}
ws.send(
JSON.stringify({
type: "init_response",
sessionId: session.sessionId,
status: "ready",
})
);
}
private async handleAudioChunk(
ws: WebSocket,
message: {
sessionId: string;
audioData: string; // base64 encoded
speakerId?: string;
}
): Promise<void> {
const session = this.sessionManager.getSession(
message.sessionId
);
if (!session) {
ws.send(
JSON.stringify({
type: "error",
error: "Session not found",
})
);
return;
}
// オーディオデータをデコード
const audioBuffer = Buffer.from(
message.audioData,
"base64"
);
// Live Translation API を呼び出し
const model = this.client.getGenerativeModel({
model: "gemini-2.0-flash-exp",
});
const translationRequest = {
contents: [
{
role: "user",
parts: [
{
inlineData: {
mimeType: "audio/wav",
data: audioBuffer.toString("base64"),
},
},
{
text: `Translate the audio from ${session.sourceLanguage} to ${session.targetLanguage}.
Include speaker identification if available.
Context: ${session.contextBuffer.join(" ")}`,
},
],
},
],
};
const response = await model.generateContent(
translationRequest
);
const translatedText =
response.response.content.parts[0].text || "";
// コンテキスト更新
this.sessionManager.updateContext(
message.sessionId,
translatedText
);
// クライアントに送信
ws.send(
JSON.stringify({
type: "translation",
sessionId: message.sessionId,
translatedText,
speakerId: message.speakerId || "unknown",
timestamp: new Date().toISOString(),
})
);
}
private async handleEndStream(
ws: WebSocket,
message: { sessionId: string }
): Promise<void> {
const session = this.sessionManager.getSession(
message.sessionId
);
if (!session) {
ws.send(
JSON.stringify({
type: "error",
error: "Session not found",
})
);
return;
}
ws.send(
JSON.stringify({
type: "stream_ended",
sessionId: message.sessionId,
duration: Date.now() - session.startTime.getTime(),
})
);
}
}話者認識と文脈保持
話者ダイアライゼーション
interface SpeakerProfile {
speakerId: string;
language: string;
voiceCharacteristics: {
pitch: number;
speed: number;
accent?: string;
};
translations: Array<{
original: string;
translated: string;
timestamp: Date;
}>;
}
class SpeakerIdentifier {
private speakers: Map<string, SpeakerProfile> = new Map();
private voicePrints: Map<string, Float32Array> = new Map();
async identifySpeaker(
audioBuffer: Buffer,
context: TranslationSession
): Promise<string> {
// 音声の周波数特性を抽出
const voicePrint = this.extractVoicePrint(
audioBuffer
);
// 既知の話者と比較
let bestMatch = "unknown";
let bestScore = 0.5; // 閾値
for (const [speakerId, knownPrint] of this.voicePrints) {
const similarity = this.cosineSimilarity(
voicePrint,
knownPrint
);
if (similarity > bestScore) {
bestScore = similarity;
bestMatch = speakerId;
}
}
if (bestMatch === "unknown") {
bestMatch = `speaker_${context.speakers.size + 1}`;
this.voicePrints.set(bestMatch, voicePrint);
}
return bestMatch;
}
private extractVoicePrint(
audioBuffer: Buffer
): Float32Array {
// 簡略版:実装ではより高度な特徴抽出(MFCC等)を使用
const samples = audioBuffer.length / 2; // 16-bit PCM
const voiceData = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const int16 = audioBuffer.readInt16LE(i * 2);
voiceData[i] = int16 / 32768;
}
// RMS値、ゼロクロッシング率など
const rms = Math.sqrt(
voiceData.reduce((sum, v) => sum + v * v, 0) / samples
);
const voicePrint = new Float32Array(128);
voicePrint[0] = rms;
return voicePrint;
}
private cosineSimilarity(
a: Float32Array,
b: Float32Array
): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return (
dotProduct /
(Math.sqrt(normA) * Math.sqrt(normB) + 1e-8)
);
}
}文脈コンテキスト管理
複雑な業界用語や固有表現を正確に翻訳するため、文脈情報を保持します。
class ContextManager {
private glossary: Map<string, Map<string, string>> = new Map();
addDomain(
domain: "medical" | "legal" | "business" | "technical",
glossaryTerms: Record<string, Record<string, string>>
): void {
for (const [term, translations] of Object.entries(
glossaryTerms
)) {
if (!this.glossary.has(domain)) {
this.glossary.set(domain, new Map());
}
this.glossary
.get(domain)!
.set(term, JSON.stringify(translations));
}
}
getTranslationHint(
domain: string,
term: string
): Record<string, string> | null {
const glossary = this.glossary.get(
domain as "medical" | "legal" | "business" | "technical"
);
if (!glossary) return null;
const hint = glossary.get(term);
return hint ? JSON.parse(hint) : null;
}
buildContextPrompt(
session: TranslationSession,
currentUtterance: string
): string {
const domainGlossary = this.getTranslationHint(
session.metadata.domain || "general",
currentUtterance
);
let prompt = `
Translate "${currentUtterance}" from ${session.sourceLanguage} to ${session.targetLanguage}.
Recent context:
${session.contextBuffer.slice(-5).join("\n")}
Domain: ${session.metadata.domain || "general"}
`;
if (domainGlossary) {
prompt += `\nTerminology guide:\n`;
for (const [lang, translation] of Object.entries(
domainGlossary
)) {
prompt += `- ${lang}: ${translation}\n`;
}
}
return prompt;
}
}音声合成(Text-to-Speech)統合
import * as tts from "@google-cloud/text-to-speech";
class SpeechSynthesizer {
private client: tts.TextToSpeechClient;
private voiceCache: Map<string, Buffer> = new Map();
constructor() {
this.client = new tts.TextToSpeechClient();
}
async synthesize(
text: string,
languageCode: string,
voiceName?: string
): Promise<Buffer> {
const cacheKey = `${text}_${languageCode}`;
if (this.voiceCache.has(cacheKey)) {
return this.voiceCache.get(cacheKey)!;
}
const request = {
input: { text },
voice: {
languageCode,
name: voiceName || this.getDefaultVoice(languageCode),
ssmlGender: "NEUTRAL",
},
audioConfig: {
audioEncoding: "LINEAR16",
sampleRateHertz: 16000,
speakingRate: 1.0,
pitch: 0.0,
},
};
const [response] = await this.client.synthesizeSpeech(
request
);
const audio = response.audioContent;
if (Buffer.isBuffer(audio)) {
this.voiceCache.set(cacheKey, audio);
return audio;
}
throw new Error("Failed to synthesize speech");
}
private getDefaultVoice(languageCode: string): string {
const voices: Record<string, string> = {
"en-US": "en-US-Neural2-A",
"ja-JP": "ja-JP-Neural2-B",
"de-DE": "de-DE-Neural2-A",
"fr-FR": "fr-FR-Neural2-A",
"es-ES": "es-ES-Neural2-A",
"zh-CN": "cmn-CN-Neural2-B",
};
return voices[languageCode] || "en-US-Neural2-A";
}
}React Native クライアント実装
import React, { useState, useCallback } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
} from "react-native";
import {
AudioRecorder,
AudioUtils,
} from "react-native-audio";
interface TranslationState {
sessionId: string | null;
sourceLanguage: string;
targetLanguage: string;
isRecording: boolean;
transcript: Array<{
speaker: string;
original: string;
translated: string;
}>;
}
const TranslationApp: React.FC = () => {
const [state, setState] = useState<TranslationState>({
sessionId: null,
sourceLanguage: "en",
targetLanguage: "ja",
isRecording: false,
transcript: [],
});
const [ws, setWs] = useState<WebSocket | null>(null);
const connectWebSocket = useCallback(() => {
const websocket = new WebSocket(
"wss://your-translation-server.com"
);
websocket.onopen = () => {
websocket.send(
JSON.stringify({
type: "init",
sourceLanguage: state.sourceLanguage,
targetLanguage: state.targetLanguage,
domain: "business",
})
);
};
websocket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "init_response") {
setState((prev) => ({
...prev,
sessionId: message.sessionId,
}));
} else if (message.type === "translation") {
setState((prev) => ({
...prev,
transcript: [
...prev.transcript,
{
speaker: message.speakerId,
original: "",
translated: message.translatedText,
},
],
}));
}
};
setWs(websocket);
}, [state.sourceLanguage, state.targetLanguage]);
const startRecording = useCallback(async () => {
const audioPath = AudioUtils.DocumentDirectoryPath +
"/translation_" +
Date.now() +
".wav";
const audioRecorderOptions = {
SampleRate: 16000,
Channels: 1,
AudioQuality: "High",
AudioEncoding: "wav",
OutputFormat: "wav",
};
await AudioRecorder.prepareRecordingAtPath(
audioPath,
audioRecorderOptions
);
AudioRecorder.onProgress = (data) => {
const audioData = require("fs").readFileSync(
audioPath
);
if (ws && state.sessionId) {
ws.send(
JSON.stringify({
type: "audio_chunk",
sessionId: state.sessionId,
audioData: audioData.toString("base64"),
})
);
}
};
await AudioRecorder.startRecording();
setState((prev) => ({ ...prev, isRecording: true }));
}, [ws, state.sessionId]);
const stopRecording = useCallback(async () => {
await AudioRecorder.stopRecording();
setState((prev) => ({ ...prev, isRecording: false }));
if (ws && state.sessionId) {
ws.send(
JSON.stringify({
type: "end_stream",
sessionId: state.sessionId,
})
);
}
}, [ws, state.sessionId]);
return (
<View style={styles.container}>
<Text style={styles.title}>リアルタイム翻訳</Text>
<View style={styles.languageSelector}>
<Text>
{state.sourceLanguage} → {state.targetLanguage}
</Text>
</View>
<View style={styles.transcript}>
{state.transcript.map((item, index) => (
<Text key={index} style={styles.transcriptItem}>
[{item.speaker}] {item.translated}
</Text>
))}
</View>
<TouchableOpacity
style={[
styles.button,
state.isRecording && styles.buttonActive,
]}
onPress={
state.isRecording ? stopRecording : startRecording
}
onLongPress={connectWebSocket}
>
<Text style={styles.buttonText}>
{state.isRecording ? "停止" : "開始"}
</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: "#fff",
},
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 20,
},
languageSelector: {
padding: 10,
backgroundColor: "#f0f0f0",
borderRadius: 8,
marginBottom: 20,
},
transcript: {
flex: 1,
marginBottom: 20,
borderWidth: 1,
borderColor: "#ddd",
padding: 10,
borderRadius: 8,
},
transcriptItem: {
marginBottom: 8,
fontSize: 14,
},
button: {
padding: 15,
backgroundColor: "#007AFF",
borderRadius: 8,
alignItems: "center",
},
buttonActive: {
backgroundColor: "#FF3B30",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "bold",
},
});
export default TranslationApp;本番環境への対応
エラーハンドリング
enum TranslationError {
SESSION_NOT_FOUND = "Session not found",
UNSUPPORTED_LANGUAGE = "Unsupported language pair",
AUDIO_PROCESSING_FAILED = "Audio processing failed",
API_RATE_LIMIT = "API rate limit exceeded",
NETWORK_ERROR = "Network connection lost",
}
class ErrorHandler {
private retryConfig = {
maxAttempts: 3,
backoffMs: 1000,
};
async handleError(
error: TranslationError,
context: TranslationSession
): Promise<void> {
switch (error) {
case TranslationError.NETWORK_ERROR:
// 再接続処理
await this.reconnectWebSocket(context);
break;
case TranslationError.API_RATE_LIMIT:
// バックオフ処理
await this.exponentialBackoff();
break;
case TranslationError.SESSION_NOT_FOUND:
// セッション再初期化
await this.reinitializeSession(context);
break;
}
}
private async reconnectWebSocket(
context: TranslationSession
): Promise<void> {
for (let attempt = 0; attempt < this.retryConfig.maxAttempts; attempt++) {
try {
// WebSocket 再接続ロジック
break;
} catch (error) {
const delay =
this.retryConfig.backoffMs * Math.pow(2, attempt);
await new Promise((resolve) =>
setTimeout(resolve, delay)
);
}
}
}
private async exponentialBackoff(): Promise<void> {
let delay = 1000;
for (let attempt = 0; attempt < 5; attempt++) {
delay *= 2;
await new Promise((resolve) =>
setTimeout(resolve, delay)
);
}
}
private async reinitializeSession(
context: TranslationSession
): Promise<void> {
// セッション再作成ロジック
}
}SaaS 収益化パターン
interface PricingTier {
name: string;
monthlyPrice: number;
minutesIncluded: number;
maxSpeakers: number;
languagePairs: number;
priority: "standard" | "premium" | "enterprise";
}
const PRICING_TIERS: PricingTier[] = [
{
name: "Basic",
monthlyPrice: 9.99,
minutesIncluded: 100,
maxSpeakers: 2,
languagePairs: 5,
priority: "standard",
},
{
name: "Pro",
monthlyPrice: 29.99,
minutesIncluded: 1000,
maxSpeakers: 10,
languagePairs: 50,
priority: "premium",
},
{
name: "Enterprise",
monthlyPrice: 299.99,
minutesIncluded: 10000,
maxSpeakers: 100,
languagePairs: 140,
priority: "enterprise",
},
];
class BillingManager {
async trackUsage(
userId: string,
minutes: number,
tier: PricingTier
): Promise<boolean> {
// 使用量追跡と請求処理
const usage = await this.getUserUsage(userId);
if (usage.minutesUsed + minutes > tier.minutesIncluded) {
const overage = usage.minutesUsed + minutes - tier.minutesIncluded;
const overageCharge = overage * 0.1; // $0.1 per minute overage
await this.chargeOverage(userId, overageCharge);
}
return true;
}
private async getUserUsage(
userId: string
): Promise<{ minutesUsed: number }> {
// データベースから使用量取得
return { minutesUsed: 0 };
}
private async chargeOverage(
userId: string,
amount: number
): Promise<void> {
// Stripe API で追加課金処理
}
}全体を振り返って
Gemini Live Translation API を活用したリアルタイム音声翻訳アプリは、以下の要素で成功します。
- 低レイテンシー設計: WebSocket ベースのストリーミング処理で遅延を最小化
- 話者認識: 複数参加者の識別で自然な会話フロー実現
- 文脈保持: 業界用語・固有表現の正確な翻訳
- エラー回復: ネットワーク障害への堅牢な対応
- SaaS 収益化: 段階的な料金体系で多様なユーザーニーズに対応
これらの実装パターンにより、国際ビジネス・カスタマーサポート・オンライン教育など、多様なユースケースに対応した実用的なプロダクトが実現できます。
個人開発の現場から — 翻訳は「遅延」と「自然さ」の綱引き
個人開発で Dolice Labs のアプリを触っている私自身、Gemini のリアルタイム翻訳で難しかったのは精度より、遅延と自然さのバランスでした。一文を待って正確に訳すと会話のテンポが崩れ、早く出すと訳が揺れます。
私は、確定した区切りごとに小刻みに訳を流し、後から軽く補正する方式に落ち着きました。完璧な一括翻訳より、会話が止まらない心地よさを優先する——多言語の音声では、この割り切りが体験を大きく左右すると感じています。
言語ペアによって難易度が大きく違うのも、実装してみて分かったことです。私は主要なペアから順に品質を確認し、苦手なペアには文脈のヒントを多めに渡すようにしています。すべてを一律に扱わず、弱いところを名指しで補強するほうが、限られた手間で全体の体感品質を上げられます。