Add: VibeVoice ASR セットアップスクリプト一式
Deploy Docusaurus Site / deploy (push) Successful in 27s

This commit is contained in:
2026-02-24 01:21:33 +00:00
parent 2d753f114f
commit 1fb76254e9
15 changed files with 4531 additions and 0 deletions
@@ -0,0 +1,899 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibeVoice Realtime ASR Client</title>
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent: #e94560;
--success: #4ade80;
--warning: #fbbf24;
--info: #60a5fa;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.subtitle {
color: var(--text-secondary);
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.main-grid {
grid-template-columns: 1fr;
}
}
.card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
}
.card-title {
font-size: 1.1rem;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
/* Controls */
.controls {
display: flex;
flex-direction: column;
gap: 15px;
}
.control-row {
display: flex;
gap: 10px;
align-items: center;
}
input[type="text"], input[type="number"] {
background: var(--bg-tertiary);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 10px 15px;
color: var(--text-primary);
flex: 1;
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
background: var(--accent);
border: none;
border-radius: 8px;
padding: 12px 24px;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
filter: brightness(1.1);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background: var(--bg-tertiary);
}
button.success {
background: var(--success);
color: #000;
}
button.stop {
background: #ef4444;
}
/* Status */
.status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 15px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-secondary);
}
.status-indicator.connected {
background: var(--success);
box-shadow: 0 0 10px var(--success);
}
.status-indicator.recording {
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Transcription output */
.transcription-box {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 15px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
font-family: 'Consolas', monospace;
line-height: 1.6;
}
.segment {
margin-bottom: 15px;
padding: 10px;
background: rgba(255,255,255,0.05);
border-radius: 6px;
border-left: 3px solid var(--accent);
}
.segment.partial {
border-left-color: var(--warning);
opacity: 0.8;
}
.segment-meta {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 5px;
}
.segment-text {
font-size: 1rem;
}
/* Audio visualizer */
.visualizer-container {
height: 80px;
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
}
#visualizer {
width: 100%;
height: 100%;
}
/* VAD indicator */
.vad-indicator {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 15px;
}
.vad-bar {
flex: 1;
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
overflow: hidden;
}
.vad-level {
height: 100%;
background: var(--success);
width: 0%;
transition: width 0.1s;
}
.vad-level.speech {
background: var(--accent);
}
/* Stats */
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 15px;
}
.stat-item {
background: var(--bg-tertiary);
padding: 10px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--accent);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Log */
.log-box {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 10px;
height: 150px;
overflow-y: auto;
font-family: 'Consolas', monospace;
font-size: 0.8rem;
}
.log-entry {
margin-bottom: 2px;
color: var(--text-secondary);
}
.log-entry.error {
color: #ef4444;
}
.log-entry.success {
color: var(--success);
}
.log-entry.info {
color: var(--info);
}
/* Settings panel */
.settings-group {
margin-top: 15px;
padding: 15px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.settings-group h4 {
margin-bottom: 10px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.setting-item {
margin-bottom: 12px;
}
.setting-item label {
display: block;
font-size: 0.85rem;
margin-bottom: 5px;
color: var(--text-secondary);
}
.setting-item input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255,255,255,0.1);
outline: none;
-webkit-appearance: none;
}
.setting-item input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.setting-value {
font-size: 0.8rem;
color: var(--accent);
float: right;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎙️ VibeVoice Realtime ASR</h1>
<p class="subtitle">リアルタイム音声認識デモ</p>
</header>
<div class="status-bar">
<div class="status-indicator" id="statusIndicator"></div>
<span id="statusText">未接続</span>
</div>
<div class="main-grid">
<!-- Left column: Controls -->
<div class="card">
<h2 class="card-title">⚙️ 設定</h2>
<div class="controls">
<div class="control-row">
<input type="text" id="serverUrl" placeholder="WebSocket URL"
value="ws://localhost:8000/ws/asr/demo">
</div>
<div class="control-row">
<button id="connectBtn" onclick="toggleConnection()">接続</button>
<button id="recordBtn" class="success" onclick="toggleRecording()" disabled>
🎤 録音開始
</button>
</div>
</div>
<div class="visualizer-container">
<canvas id="visualizer"></canvas>
</div>
<div class="vad-indicator">
<span>VAD:</span>
<div class="vad-bar">
<div class="vad-level" id="vadLevel"></div>
</div>
<span id="vadStatus">待機中</span>
</div>
<div class="settings-group">
<h4>🎚️ VAD設定</h4>
<div class="setting-item">
<label>
音声検出閾値
<span class="setting-value" id="vadThresholdValue">0.5</span>
</label>
<input type="range" id="vadThreshold" min="0.1" max="0.9" step="0.1" value="0.5"
onchange="updateConfig()">
</div>
<div class="setting-item">
<label>
最小発話時間 (ms)
<span class="setting-value" id="minSpeechValue">250</span>
</label>
<input type="range" id="minSpeechDuration" min="100" max="1000" step="50" value="250"
onchange="updateConfig()">
</div>
<div class="setting-item">
<label>
無音判定時間 (ms)
<span class="setting-value" id="minSilenceValue">500</span>
</label>
<input type="range" id="minSilenceDuration" min="200" max="2000" step="100" value="500"
onchange="updateConfig()">
</div>
<div class="setting-item">
<label>
最小音量閾値
<span class="setting-value" id="minVolumeValue">0.01</span>
</label>
<input type="range" id="minVolumeThreshold" min="0.001" max="0.1" step="0.001" value="0.01"
onchange="updateConfig()">
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="statDuration">0.0</div>
<div class="stat-label">録音時間 (秒)</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statSegments">0</div>
<div class="stat-label">セグメント数</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statLatency">-</div>
<div class="stat-label">レイテンシ (ms)</div>
</div>
</div>
<h3 class="card-title" style="margin-top: 20px;">📋 ログ</h3>
<div class="log-box" id="logBox"></div>
</div>
<!-- Right column: Transcription -->
<div class="card">
<h2 class="card-title">📝 認識結果</h2>
<div class="control-row" style="margin-bottom: 15px;">
<button class="secondary" onclick="clearTranscription()">クリア</button>
<button class="secondary" onclick="copyTranscription()">コピー</button>
</div>
<div class="transcription-box" id="transcriptionBox">
<p style="color: var(--text-secondary); text-align: center; padding: 50px;">
接続して録音を開始すると、ここに認識結果が表示されます
</p>
</div>
</div>
</div>
</div>
<script>
// State
let websocket = null;
let mediaStream = null;
let audioContext = null;
let processor = null;
let analyser = null;
let isRecording = false;
let recordingStartTime = null;
let segmentCount = 0;
let currentPartialText = '';
// DOM elements
const statusIndicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
const connectBtn = document.getElementById('connectBtn');
const recordBtn = document.getElementById('recordBtn');
const transcriptionBox = document.getElementById('transcriptionBox');
const logBox = document.getElementById('logBox');
const vadLevel = document.getElementById('vadLevel');
const vadStatus = document.getElementById('vadStatus');
// Logging
function log(message, type = 'info') {
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logBox.appendChild(entry);
logBox.scrollTop = logBox.scrollHeight;
console.log(`[${type}] ${message}`);
}
// Update config display values
function updateConfigDisplay() {
document.getElementById('vadThresholdValue').textContent =
document.getElementById('vadThreshold').value;
document.getElementById('minSpeechValue').textContent =
document.getElementById('minSpeechDuration').value;
document.getElementById('minSilenceValue').textContent =
document.getElementById('minSilenceDuration').value;
document.getElementById('minVolumeValue').textContent =
document.getElementById('minVolumeThreshold').value;
}
// Send config to server
function updateConfig() {
updateConfigDisplay();
if (websocket && websocket.readyState === WebSocket.OPEN) {
const config = {
type: 'config',
config: {
vad_threshold: parseFloat(document.getElementById('vadThreshold').value),
min_speech_duration_ms: parseInt(document.getElementById('minSpeechDuration').value),
min_silence_duration_ms: parseInt(document.getElementById('minSilenceDuration').value),
min_volume_threshold: parseFloat(document.getElementById('minVolumeThreshold').value),
}
};
websocket.send(JSON.stringify(config));
log(`VAD設定を更新: 閾値=${config.config.vad_threshold}, 最小発話=${config.config.min_speech_duration_ms}ms, 無音判定=${config.config.min_silence_duration_ms}ms, 最小音量=${config.config.min_volume_threshold}`, 'info');
}
}
// Initialize config display on load
document.addEventListener('DOMContentLoaded', updateConfigDisplay);
// Connection
function toggleConnection() {
if (websocket && websocket.readyState === WebSocket.OPEN) {
disconnect();
} else {
connect();
}
}
function connect() {
const url = document.getElementById('serverUrl').value;
log(`接続中: ${url}`, 'info');
try {
websocket = new WebSocket(url);
websocket.onopen = () => {
log('接続成功', 'success');
statusIndicator.classList.add('connected');
statusText.textContent = '接続済み';
connectBtn.textContent = '切断';
recordBtn.disabled = false;
};
websocket.onclose = () => {
log('切断されました', 'info');
handleDisconnect();
};
websocket.onerror = (error) => {
log(`エラー: ${error}`, 'error');
};
websocket.onmessage = (event) => {
handleMessage(JSON.parse(event.data));
};
} catch (error) {
log(`接続エラー: ${error}`, 'error');
}
}
function disconnect() {
if (isRecording) {
stopRecording();
}
if (websocket) {
websocket.close();
}
handleDisconnect();
}
function handleDisconnect() {
statusIndicator.classList.remove('connected', 'recording');
statusText.textContent = '未接続';
connectBtn.textContent = '接続';
recordBtn.disabled = true;
websocket = null;
}
// Message handling
function handleMessage(data) {
switch (data.type) {
case 'status':
log(`ステータス: ${data.message}`, 'info');
break;
case 'partial_result':
updatePartialResult(data);
break;
case 'final_result':
addFinalResult(data);
break;
case 'vad_event':
handleVADEvent(data);
break;
case 'error':
log(`サーバーエラー: ${data.error}`, 'error');
break;
case 'pong':
// Heartbeat response
break;
default:
log(`不明なメッセージ: ${data.type}`, 'info');
}
}
function updatePartialResult(data) {
currentPartialText = data.text;
updateTranscriptionDisplay();
if (data.latency_ms) {
document.getElementById('statLatency').textContent =
Math.round(data.latency_ms);
}
}
function addFinalResult(data) {
currentPartialText = '';
segmentCount++;
document.getElementById('statSegments').textContent = segmentCount;
if (data.latency_ms) {
document.getElementById('statLatency').textContent =
Math.round(data.latency_ms);
}
// Add final segment to display
const segment = document.createElement('div');
segment.className = 'segment';
let metaText = '';
if (data.segments && data.segments.length > 0) {
const seg = data.segments[0];
metaText = `[${seg.start_time?.toFixed(2) || '?'}s - ${seg.end_time?.toFixed(2) || '?'}s] ${seg.speaker_id || ''}`;
}
segment.innerHTML = `
<div class="segment-meta">${metaText}</div>
<div class="segment-text">${data.text}</div>
`;
// Remove placeholder if exists
const placeholder = transcriptionBox.querySelector('p');
if (placeholder) placeholder.remove();
transcriptionBox.appendChild(segment);
transcriptionBox.scrollTop = transcriptionBox.scrollHeight;
log(`認識完了: "${data.text.substring(0, 30)}..."`, 'success');
}
function updateTranscriptionDisplay() {
// Update or create partial display
let partialDiv = transcriptionBox.querySelector('.segment.partial');
if (currentPartialText) {
if (!partialDiv) {
partialDiv = document.createElement('div');
partialDiv.className = 'segment partial';
transcriptionBox.appendChild(partialDiv);
}
partialDiv.innerHTML = `
<div class="segment-meta">認識中...</div>
<div class="segment-text">${currentPartialText}</div>
`;
transcriptionBox.scrollTop = transcriptionBox.scrollHeight;
} else if (partialDiv) {
partialDiv.remove();
}
}
function handleVADEvent(data) {
if (data.event === 'speech_start') {
vadLevel.classList.add('speech');
vadStatus.textContent = '発話中';
log(`発話開始 @ ${data.audio_timestamp_sec?.toFixed(2)}s`, 'info');
} else if (data.event === 'speech_end') {
vadLevel.classList.remove('speech');
vadStatus.textContent = '待機中';
log(`発話終了 @ ${data.audio_timestamp_sec?.toFixed(2)}s`, 'info');
}
}
// Recording
async function toggleRecording() {
if (isRecording) {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
log('マイクアクセスをリクエスト中...', 'info');
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
}
});
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
const source = audioContext.createMediaStreamSource(mediaStream);
// Analyser for visualization
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
// ScriptProcessor for sending audio
const bufferSize = 4096;
processor = audioContext.createScriptProcessor(bufferSize, 1, 1);
processor.onaudioprocess = (e) => {
if (!isRecording || !websocket || websocket.readyState !== WebSocket.OPEN) {
return;
}
const inputData = e.inputBuffer.getChannelData(0);
// Convert to 16-bit PCM
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));
}
// Send as binary
websocket.send(pcmData.buffer);
};
source.connect(processor);
processor.connect(audioContext.destination);
isRecording = true;
recordingStartTime = Date.now();
statusIndicator.classList.add('recording');
recordBtn.textContent = '⏹️ 録音停止';
recordBtn.classList.remove('success');
recordBtn.classList.add('stop');
// Start visualization
visualize();
updateDuration();
log('録音開始', 'success');
} catch (error) {
log(`マイクエラー: ${error}`, 'error');
}
}
function stopRecording() {
isRecording = false;
if (processor) {
processor.disconnect();
processor = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
statusIndicator.classList.remove('recording');
recordBtn.textContent = '🎤 録音開始';
recordBtn.classList.remove('stop');
recordBtn.classList.add('success');
// Send stop message
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ type: 'stop' }));
}
log('録音停止', 'info');
}
// Visualization
function visualize() {
if (!analyser || !isRecording) return;
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
const width = canvas.width = canvas.offsetWidth;
const height = canvas.height = canvas.offsetHeight;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
if (!isRecording) return;
requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
ctx.fillStyle = 'rgb(15, 52, 96)';
ctx.fillRect(0, 0, width, height);
const barWidth = (width / bufferLength) * 2.5;
let x = 0;
// Calculate average for VAD indicator
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * height;
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight);
gradient.addColorStop(0, '#e94560');
gradient.addColorStop(1, '#4ade80');
ctx.fillStyle = gradient;
ctx.fillRect(x, height - barHeight, barWidth, barHeight);
x += barWidth + 1;
sum += dataArray[i];
}
// Update VAD level indicator
const avgLevel = (sum / bufferLength / 255) * 100;
vadLevel.style.width = `${avgLevel}%`;
}
draw();
}
function updateDuration() {
if (!isRecording) return;
const duration = (Date.now() - recordingStartTime) / 1000;
document.getElementById('statDuration').textContent = duration.toFixed(1);
requestAnimationFrame(updateDuration);
}
// Utility functions
function clearTranscription() {
transcriptionBox.innerHTML = `
<p style="color: var(--text-secondary); text-align: center; padding: 50px;">
接続して録音を開始すると、ここに認識結果が表示されます
</p>
`;
segmentCount = 0;
currentPartialText = '';
document.getElementById('statSegments').textContent = '0';
document.getElementById('statLatency').textContent = '-';
log('認識結果をクリアしました', 'info');
}
function copyTranscription() {
const segments = transcriptionBox.querySelectorAll('.segment:not(.partial) .segment-text');
const text = Array.from(segments).map(s => s.textContent).join('\n');
if (text) {
navigator.clipboard.writeText(text).then(() => {
log('認識結果をコピーしました', 'success');
});
}
}
// Heartbeat
setInterval(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
</script>
</body>
</html>