This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user