Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>实时直播音频转写系统</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| .waveform { | |
| height: 100px; | |
| background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .waveform::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: linear-gradient(90deg, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 50%, rgba(255,255,255,0.3) 100%); | |
| animation: wave 2s linear infinite; | |
| opacity: 0.8; | |
| } | |
| @keyframes wave { | |
| 0% { | |
| transform: translateX(-100%); | |
| } | |
| 100% { | |
| transform: translateX(100%); | |
| } | |
| } | |
| .subtitle-display { | |
| min-height: 120px; | |
| transition: all 0.3s ease; | |
| } | |
| .subtitle-line { | |
| animation: fadeIn 0.5s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .language-flag { | |
| width: 24px; | |
| height: 16px; | |
| display: inline-block; | |
| margin-right: 8px; | |
| background-size: cover; | |
| border-radius: 2px; | |
| } | |
| .cn { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 24"><rect width="36" height="24" fill="%23de2910"/><path fill="%23ffde00" d="M9.6,4.8l1.2,3.6H15L12,9.6l1.2,3.6L9.6,9.6L6,13.2L7.2,9.6L4.2,8.4h4.2Z"/></svg>'); } | |
| .jp { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 24"><rect width="36" height="24" fill="%23fff"/><circle cx="18" cy="12" r="6.4" fill="%23bc002d"/></svg>'); } | |
| .en { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30"><clipPath id="a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="b"><path d="M30 15h30v15zv15H0zH0V0zV0h60z"/></clipPath><g clip-path="url(#a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>'); } | |
| .api-selector.active { | |
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5); | |
| } | |
| .recording-indicator { | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| 100% { opacity: 1; } | |
| } | |
| /* 响应式调整 */ | |
| @media (max-width: 768px) { | |
| .controls-grid { | |
| grid-template-columns: 1fr; | |
| gap: 1rem; | |
| } | |
| .api-selectors { | |
| flex-direction: column; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8 max-w-6xl"> | |
| <header class="mb-8 text-center"> | |
| <h1 class="text-3xl md:text-4xl font-bold text-gray-800 mb-2"> | |
| <i class="fas fa-broadcast-tower text-blue-500 mr-2"></i> | |
| 实时直播音频转写系统 | |
| </h1> | |
| <p class="text-gray-600">选择直播音频流,实时转写为中日英文字幕</p> | |
| </header> | |
| <div class="bg-white rounded-xl shadow-lg overflow-hidden mb-8"> | |
| <!-- 音频波形显示 --> | |
| <div class="waveform" id="waveform"> | |
| <div class="absolute inset-0 flex items-center justify-center" id="noAudioIndicator"> | |
| <div class="text-white bg-black bg-opacity-40 px-4 py-2 rounded-full"> | |
| <i class="fas fa-microphone-slash mr-2"></i> | |
| 未检测到音频输入 | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 控制面板 --> | |
| <div class="p-6"> | |
| <div class="grid controls-grid md:grid-cols-3 gap-6 mb-6"> | |
| <!-- 音频源选择 --> | |
| <div class="space-y-2"> | |
| <label class="block text-sm font-medium text-gray-700"> | |
| <i class="fas fa-signal mr-2"></i>音频源选择 | |
| </label> | |
| <div class="flex space-x-2"> | |
| <select id="audioSource" class="flex-1 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md border"> | |
| <option value="">-- 选择音频源 --</option> | |
| <option value="mic">麦克风输入</option> | |
| <option value="system">系统音频</option> | |
| <option value="custom">自定义流URL</option> | |
| </select> | |
| <button id="testAudioBtn" class="mt-1 px-3 py-2 bg-gray-200 hover:bg-gray-300 rounded-md text-gray-700" title="测试音频"> | |
| <i class="fas fa-volume-up"></i> | |
| </button> | |
| </div> | |
| <div id="customUrlContainer" class="mt-2 hidden"> | |
| <input type="text" id="streamUrl" placeholder="输入音频流URL" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md border"> | |
| </div> | |
| </div> | |
| <!-- 语言选择 --> | |
| <div class="space-y-2"> | |
| <label class="block text-sm font-medium text-gray-700"> | |
| <i class="fas fa-language mr-2"></i>转写语言 | |
| </label> | |
| <select id="language" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md border"> | |
| <option value="zh">中文</option> | |
| <option value="ja">日本語</option> | |
| <option value="en">English</option> | |
| </select> | |
| </div> | |
| <!-- 操作按钮 --> | |
| <div class="flex items-end space-x-3"> | |
| <button id="startBtn" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md flex items-center justify-center"> | |
| <i class="fas fa-play mr-2"></i> 开始转写 | |
| </button> | |
| <button id="stopBtn" class="flex-1 bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md flex items-center justify-center" disabled> | |
| <i class="fas fa-stop mr-2"></i> 停止 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- API选择器 --> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2"> | |
| <i class="fas fa-cloud mr-2"></i>选择语音转写API | |
| </label> | |
| <div class="api-selectors flex flex-wrap gap-3"> | |
| <div class="api-selector flex-1 min-w-[200px] bg-white border border-gray-200 rounded-lg p-4 cursor-pointer hover:border-blue-300 active" data-api="azure"> | |
| <div class="flex items-center"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg" alt="Azure" class="h-8 mr-3"> | |
| <div> | |
| <h3 class="font-medium">Azure Speech</h3> | |
| <p class="text-xs text-gray-500">高精度,支持多语言</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="api-selector flex-1 min-w-[200px] bg-white border border-gray-200 rounded-lg p-4 cursor-pointer hover:border-blue-300" data-api="google"> | |
| <div class="flex items-center"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/2/2f/Google_2015_logo.svg" alt="Google" class="h-8 mr-3"> | |
| <div> | |
| <h3 class="font-medium">Google Cloud</h3> | |
| <p class="text-xs text-gray-500">快速响应,准确率高</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="api-selector flex-1 min-w-[200px] bg-white border border-gray-200 rounded-lg p-4 cursor-pointer hover:border-blue-300" data-api="aws"> | |
| <div class="flex items-center"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/9/93/Amazon_Web_Services_Logo.svg" alt="AWS" class="h-8 mr-3"> | |
| <div> | |
| <h3 class="font-medium">AWS Transcribe</h3> | |
| <p class="text-xs text-gray-500">实时流式处理</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="api-selector flex-1 min-w-[200px] bg-white border border-gray-200 rounded-lg p-4 cursor-pointer hover:border-blue-300" data-api="iflytek"> | |
| <div class="flex items-center"> | |
| <img src="https://www.iflytek.com/favicon.ico" alt="iFlytek" class="h-8 mr-3"> | |
| <div> | |
| <h3 class="font-medium">讯飞语音</h3> | |
| <p class="text-xs text-gray-500">中文识别准确率高</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- API密钥输入 --> | |
| <div class="mb-6" id="apiKeyContainer"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1"> | |
| <i class="fas fa-key mr-2"></i>API密钥 | |
| </label> | |
| <div class="flex space-x-2"> | |
| <input type="password" id="apiKey" placeholder="输入API密钥" class="flex-1 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md border"> | |
| <button id="saveKeyBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md"> | |
| <i class="fas fa-save mr-1"></i>保存 | |
| </button> | |
| </div> | |
| <div class="mt-2 text-xs text-gray-500 space-y-1"> | |
| <p><i class="fas fa-info-circle mr-1"></i>密钥仅保存在本地浏览器中</p> | |
| <p><i class="fas fa-exclamation-triangle mr-1 text-yellow-500"></i>请确保使用正确的API服务密钥</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 字幕显示区域 --> | |
| <div class="bg-white rounded-xl shadow-lg overflow-hidden mb-8"> | |
| <div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center"> | |
| <h2 class="text-lg font-medium text-gray-800"> | |
| <i class="fas fa-closed-captioning text-blue-500 mr-2"></i> | |
| 实时字幕 | |
| </h2> | |
| <div class="flex items-center space-x-3"> | |
| <div class="flex items-center"> | |
| <span class="text-sm text-gray-500 mr-2">字幕大小:</span> | |
| <select id="fontSize" class="text-sm border-gray-300 rounded"> | |
| <option value="sm">小</option> | |
| <option value="md" selected>中</option> | |
| <option value="lg">大</option> | |
| <option value="xl">特大</option> | |
| </select> | |
| </div> | |
| <button id="clearSubsBtn" class="text-sm text-gray-500 hover:text-gray-700"> | |
| <i class="fas fa-trash-alt mr-1"></i>清空 | |
| </button> | |
| <button id="copySubsBtn" class="text-sm text-blue-500 hover:text-blue-700"> | |
| <i class="fas fa-copy mr-1"></i>复制 | |
| </button> | |
| </div> | |
| </div> | |
| <div class="subtitle-display p-6" id="subtitleDisplay"> | |
| <div class="text-center text-gray-400 py-10" id="emptySubtitleMessage"> | |
| <i class="fas fa-comment-dots text-3xl mb-3"></i> | |
| <p>字幕将显示在这里</p> | |
| </div> | |
| <div class="space-y-4 hidden" id="subtitleContent"> | |
| <!-- 字幕内容将在这里动态添加 --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 转写历史 --> | |
| <div class="bg-white rounded-xl shadow-lg overflow-hidden"> | |
| <div class="px-6 py-4 border-b border-gray-200"> | |
| <h2 class="text-lg font-medium text-gray-800"> | |
| <i class="fas fa-history text-blue-500 mr-2"></i> | |
| 转写历史 | |
| </h2> | |
| </div> | |
| <div class="p-4"> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50"> | |
| <tr> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日期</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">语言</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">API</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时长</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th> | |
| </tr> | |
| </thead> | |
| <tbody class="bg-white divide-y divide-gray-200" id="historyTableBody"> | |
| <tr> | |
| <td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">暂无历史记录</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 状态提示弹窗 --> | |
| <div id="statusToast" class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg hidden flex items-center"> | |
| <i class="fas fa-info-circle mr-2"></i> | |
| <span id="toastMessage"></span> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // 元素引用 | |
| const audioSource = document.getElementById('audioSource'); | |
| const customUrlContainer = document.getElementById('customUrlContainer'); | |
| const streamUrl = document.getElementById('streamUrl'); | |
| const language = document.getElementById('language'); | |
| const startBtn = document.getElementById('startBtn'); | |
| const stopBtn = document.getElementById('stopBtn'); | |
| const apiSelectors = document.querySelectorAll('.api-selector'); | |
| const apiKeyContainer = document.getElementById('apiKeyContainer'); | |
| const apiKey = document.getElementById('apiKey'); | |
| const saveKeyBtn = document.getElementById('saveKeyBtn'); | |
| const subtitleDisplay = document.getElementById('subtitleDisplay'); | |
| const subtitleContent = document.getElementById('subtitleContent'); | |
| const emptySubtitleMessage = document.getElementById('emptySubtitleMessage'); | |
| const fontSize = document.getElementById('fontSize'); | |
| const clearSubsBtn = document.getElementById('clearSubsBtn'); | |
| const copySubsBtn = document.getElementById('copySubsBtn'); | |
| const historyTableBody = document.getElementById('historyTableBody'); | |
| const statusToast = document.getElementById('statusToast'); | |
| const toastMessage = document.getElementById('toastMessage'); | |
| const noAudioIndicator = document.getElementById('noAudioIndicator'); | |
| // 状态变量 | |
| let selectedApi = 'azure'; | |
| let isTranscribing = false; | |
| let audioContext; | |
| let analyser; | |
| let microphone; | |
| let mediaStream; | |
| let subtitles = []; | |
| let currentApiKey = ''; | |
| // 初始化 | |
| init(); | |
| // Test audio sample | |
| const testAudio = new Audio('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'); | |
| function init() { | |
| // 从本地存储加载API密钥 | |
| // Test audio button | |
| document.getElementById('testAudioBtn').addEventListener('click', function() { | |
| if (testAudio.paused) { | |
| testAudio.currentTime = 0; | |
| testAudio.play(); | |
| this.innerHTML = '<i class="fas fa-volume-mute"></i>'; | |
| showToast('正在播放测试音频'); | |
| } else { | |
| testAudio.pause(); | |
| this.innerHTML = '<i class="fas fa-volume-up"></i>'; | |
| showToast('测试音频已停止'); | |
| } | |
| }); | |
| const savedKey = localStorage.getItem(`${selectedApi}_api_key`); | |
| if (savedKey) { | |
| apiKey.value = savedKey; | |
| currentApiKey = savedKey; | |
| } | |
| // 从本地存储加载历史记录 | |
| loadHistory(); | |
| // 事件监听器 | |
| audioSource.addEventListener('change', function() { | |
| if (this.value === 'custom') { | |
| customUrlContainer.classList.remove('hidden'); | |
| } else { | |
| customUrlContainer.classList.add('hidden'); | |
| } | |
| }); | |
| // API选择器 | |
| apiSelectors.forEach(selector => { | |
| selector.addEventListener('click', function() { | |
| apiSelectors.forEach(s => s.classList.remove('active')); | |
| this.classList.add('active'); | |
| selectedApi = this.dataset.api; | |
| // 确保API密钥输入可见 | |
| apiKeyContainer.classList.remove('hidden'); | |
| // 加载保存的密钥 | |
| const savedKey = localStorage.getItem(`${selectedApi}_api_key`); | |
| apiKey.value = savedKey || ''; | |
| currentApiKey = savedKey || ''; | |
| }); | |
| }); | |
| // 保存API密钥 | |
| saveKeyBtn.addEventListener('click', function() { | |
| const key = apiKey.value.trim(); | |
| if (key) { | |
| localStorage.setItem(`${selectedApi}_api_key`, key); | |
| currentApiKey = key; | |
| showToast('API密钥已保存'); | |
| } else { | |
| showToast('请输入有效的API密钥', 'error'); | |
| } | |
| }); | |
| // 开始转写 | |
| startBtn.addEventListener('click', startTranscription); | |
| // 停止转写 | |
| stopBtn.addEventListener('click', stopTranscription); | |
| // 字幕大小调整 | |
| fontSize.addEventListener('change', function() { | |
| const size = this.value; | |
| let sizeClass = ''; | |
| switch(size) { | |
| case 'sm': sizeClass = 'text-sm'; break; | |
| case 'md': sizeClass = 'text-base'; break; | |
| case 'lg': sizeClass = 'text-lg'; break; | |
| case 'xl': sizeClass = 'text-xl'; break; | |
| } | |
| subtitleContent.className = `space-y-4 ${sizeClass}`; | |
| }); | |
| // 清空字幕 | |
| clearSubsBtn.addEventListener('click', function() { | |
| subtitleContent.innerHTML = ''; | |
| subtitles = []; | |
| emptySubtitleMessage.classList.remove('hidden'); | |
| subtitleContent.classList.add('hidden'); | |
| }); | |
| // 复制字幕 | |
| copySubsBtn.addEventListener('click', function() { | |
| if (subtitles.length === 0) { | |
| showToast('没有可复制的字幕内容', 'error'); | |
| return; | |
| } | |
| const textToCopy = subtitles.map(sub => sub.text).join('\n'); | |
| navigator.clipboard.writeText(textToCopy) | |
| .then(() => showToast('字幕已复制到剪贴板')) | |
| .catch(err => showToast('复制失败: ' + err, 'error')); | |
| }); | |
| } | |
| // 开始转写 | |
| async function startTranscription() { | |
| if (isTranscribing) return; | |
| // 验证API密钥 | |
| if (!currentApiKey) { | |
| showToast('请先输入并保存API密钥', 'error'); | |
| return; | |
| } | |
| // 验证音频源 | |
| if (audioSource.value === '') { | |
| showToast('请选择音频源', 'error'); | |
| return; | |
| } | |
| if (audioSource.value === 'custom' && !streamUrl.value.trim()) { | |
| showToast('请输入有效的音频流URL', 'error'); | |
| return; | |
| } | |
| try { | |
| // 初始化音频上下文 | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| // 根据选择的音频源获取音频流 | |
| if (audioSource.value === 'mic') { | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| microphone = audioContext.createMediaStreamSource(mediaStream); | |
| microphone.connect(analyser); | |
| noAudioIndicator.classList.add('hidden'); | |
| } else if (audioSource.value === 'system') { | |
| // 注意: 系统音频捕获通常需要浏览器扩展或特定API | |
| // 这里只是模拟 | |
| showToast('系统音频捕获需要特定权限或扩展', 'warning'); | |
| simulateAudio(); | |
| } else if (audioSource.value === 'custom') { | |
| // 自定义音频流处理 | |
| processCustomStream(); | |
| } | |
| // 开始可视化 | |
| visualizeAudio(); | |
| // 开始API调用 | |
| startAPICall(); | |
| // 更新UI状态 | |
| isTranscribing = true; | |
| startBtn.disabled = true; | |
| stopBtn.disabled = false; | |
| startBtn.classList.remove('bg-blue-600', 'hover:bg-blue-700'); | |
| startBtn.classList.add('bg-green-500', 'hover:bg-green-600'); | |
| startBtn.innerHTML = '<i class="fas fa-microphone recording-indicator mr-2"></i> 转写中...'; | |
| showToast('转写已开始'); | |
| // 隐藏空字幕消息 | |
| emptySubtitleMessage.classList.add('hidden'); | |
| subtitleContent.classList.remove('hidden'); | |
| } catch (error) { | |
| console.error('Error starting transcription:', error); | |
| showToast('启动转写失败: ' + error.message, 'error'); | |
| stopTranscription(); | |
| } | |
| } | |
| // 停止转写 | |
| function stopTranscription() { | |
| if (!isTranscribing) return; | |
| // 停止所有音频流 | |
| if (mediaStream) { | |
| mediaStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (audioContext) { | |
| audioContext.close(); | |
| } | |
| // 关闭所有WebSocket连接 | |
| if (this.ws) { | |
| this.ws.close(); | |
| this.ws = null; | |
| } | |
| // 清除可视化 | |
| cancelAnimationFrame(animationId); | |
| // 更新UI状态 | |
| isTranscribing = false; | |
| startBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| startBtn.classList.remove('bg-green-500', 'hover:bg-green-600'); | |
| startBtn.classList.add('bg-blue-600', 'hover:bg-blue-700'); | |
| startBtn.innerHTML = '<i class="fas fa-play mr-2"></i> 开始转写'; | |
| showToast('转写已停止'); | |
| // 保存到历史记录 | |
| if (subtitles.length > 0) { | |
| saveToHistory(); | |
| } | |
| } | |
| // 音频可视化 | |
| let animationId; | |
| function visualizeAudio() { | |
| const bufferLength = analyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| const waveform = document.getElementById('waveform'); | |
| function draw() { | |
| animationId = requestAnimationFrame(draw); | |
| analyser.getByteTimeDomainData(dataArray); | |
| // 创建波形效果 | |
| let waveformHTML = ''; | |
| for (let i = 0; i < bufferLength; i++) { | |
| const value = dataArray[i] / 128.0; | |
| const height = value * 50; | |
| waveformHTML += `<div class="absolute bottom-0 bg-white bg-opacity-70" style="left: ${i * (100 / bufferLength)}%; width: ${100 / bufferLength}%; height: ${height}%"></div>`; | |
| } | |
| waveform.innerHTML = waveformHTML; | |
| } | |
| draw(); | |
| } | |
| // 模拟音频输入 (用于演示) | |
| function simulateAudio() { | |
| const oscillator = audioContext.createOscillator(); | |
| const gainNode = audioContext.createGain(); | |
| oscillator.type = 'sine'; | |
| oscillator.frequency.value = 440; | |
| gainNode.gain.value = 0.1; | |
| oscillator.connect(gainNode); | |
| gainNode.connect(analyser); | |
| oscillator.start(); | |
| // 随机添加字幕 | |
| const languages = { | |
| zh: ["大家好,欢迎来到我的直播间", "今天我们要讨论人工智能", "语音识别技术非常有趣", "感谢大家的观看"], | |
| ja: ["こんにちは、ライブストリームへようこそ", "今日はAIについて話します", "音声認識技術はとても面白いです", "ご視聴ありがとうございました"], | |
| en: ["Hello everyone, welcome to my live stream", "Today we'll discuss AI", "Speech recognition is fascinating", "Thank you for watching"] | |
| }; | |
| let count = 0; | |
| const interval = setInterval(() => { | |
| if (!isTranscribing) { | |
| clearInterval(interval); | |
| oscillator.stop(); | |
| return; | |
| } | |
| const lang = language.value; | |
| const texts = languages[lang]; | |
| const text = texts[count % texts.length]; | |
| addSubtitle(text, lang); | |
| count++; | |
| }, 5000); | |
| } | |
| // 处理自定义音频流 (模拟) | |
| function processCustomStream() { | |
| // 实际应用中这里应该处理真正的音频流 | |
| // 这里只是模拟 | |
| simulateAudio(); | |
| } | |
| // API调用 | |
| function startAPICall() { | |
| if (selectedApi === 'iflytek') { | |
| connectIflytekWebSocket(); | |
| } else { | |
| // 其他API的模拟调用 | |
| simulateAPICall(); | |
| } | |
| } | |
| // 连接讯飞WebSocket | |
| function connectIflytekWebSocket() { | |
| if (!currentApiKey) { | |
| showToast('请先输入并保存讯飞API密钥', 'error'); | |
| return; | |
| } | |
| // 生成请求参数 | |
| const appId = currentApiKey.split('.')[0]; // 假设API key格式是 appid.key | |
| const ts = Math.floor(Date.now() / 1000); | |
| const signa = generateIflytekSignature(appId, ts); | |
| const wsUrl = `wss://rtasr.xfyun.cn/v1/ws?appid=${appId}&ts=${ts}&signa=${encodeURIComponent(signa)}`; | |
| const ws = new WebSocket(wsUrl); | |
| ws.onopen = function() { | |
| console.log('讯飞WebSocket连接已建立'); | |
| // 开始发送音频数据 | |
| startSendingAudio(ws); | |
| }; | |
| ws.onmessage = function(e) { | |
| const data = JSON.parse(e.data); | |
| if (data.action === 'result') { | |
| // 处理识别结果 | |
| const text = data.data.result; | |
| if (text) { | |
| addSubtitle(text, language.value); | |
| } | |
| } | |
| }; | |
| ws.onerror = function(e) { | |
| console.error('讯飞WebSocket错误:', e); | |
| showToast('讯飞连接错误', 'error'); | |
| stopTranscription(); | |
| }; | |
| ws.onclose = function() { | |
| console.log('讯飞WebSocket连接已关闭'); | |
| }; | |
| return ws; | |
| } | |
| // 生成讯飞签名 | |
| function generateIflytekSignature(appId, ts) { | |
| // 这里需要实现讯飞的签名算法 | |
| // 实际应用中应该使用更安全的服务器端生成 | |
| const key = currentApiKey.split('.')[1] || ''; | |
| const baseString = `${appId}${ts}`; | |
| // 简单示例,实际应该使用HMAC-SHA1 | |
| return btoa(baseString + key).slice(0, 20); | |
| } | |
| // 开始发送音频数据 | |
| function startSendingAudio(ws) { | |
| const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1); | |
| analyser.connect(scriptProcessor); | |
| scriptProcessor.onaudioprocess = function(e) { | |
| if (!isTranscribing) return; | |
| const inputData = e.inputBuffer.getChannelData(0); | |
| // 将音频数据发送到WebSocket | |
| ws.send(inputData); | |
| }; | |
| scriptProcessor.connect(audioContext.destination); | |
| } | |
| // 添加字幕 | |
| function addSubtitle(text, lang) { | |
| if (!text) return; | |
| const timestamp = new Date().toLocaleTimeString(); | |
| const langClass = { | |
| zh: 'cn', | |
| ja: 'jp', | |
| en: 'en' | |
| }[lang] || 'en'; | |
| const subtitle = { | |
| text, | |
| lang, | |
| timestamp | |
| }; | |
| subtitles.push(subtitle); | |
| // 创建字幕元素 | |
| const subtitleElement = document.createElement('div'); | |
| subtitleElement.className = 'subtitle-line bg-gray-50 p-3 rounded-lg'; | |
| subtitleElement.innerHTML = ` | |
| <div class="flex items-center mb-1"> | |
| <span class="language-flag ${langClass}"></span> | |
| <span class="text-xs text-gray-500">${timestamp}</span> | |
| </div> | |
| <p>${text}</p> | |
| `; | |
| subtitleContent.appendChild(subtitleElement); | |
| // 自动滚动到底部 | |
| subtitleDisplay.scrollTop = subtitleDisplay.scrollHeight; | |
| } | |
| // 保存到历史记录 | |
| function saveToHistory() { | |
| const history = JSON.parse(localStorage.getItem('transcription_history') || '[]'); | |
| const newEntry = { | |
| id: Date.now(), | |
| date: new Date().toLocaleString(), | |
| language: language.options[language.selectedIndex].text, | |
| api: selectedApi, | |
| duration: Math.floor(subtitles.length * 5) + '秒', // 模拟时长 | |
| subtitles: [...subtitles] | |
| }; | |
| history.unshift(newEntry); | |
| localStorage.setItem('transcription_history', JSON.stringify(history)); | |
| // 重新加载历史记录 | |
| loadHistory(); | |
| } | |
| // 加载历史记录 | |
| function loadHistory() { | |
| const history = JSON.parse(localStorage.getItem('transcription_history') || '[]'); | |
| if (history.length === 0) { | |
| historyTableBody.innerHTML = ` | |
| <tr> | |
| <td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">暂无历史记录</td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| let html = ''; | |
| history.forEach(entry => { | |
| html += ` | |
| <tr> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${entry.date}</td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| <span class="language-flag ${entry.language === '中文' ? 'cn' : entry.language === '日本語' ? 'jp' : 'en'}"></span> | |
| ${entry.language} | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${entry.api}</td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${entry.duration}</td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| <button class="text-blue-500 hover:text-blue-700 mr-3 view-history" data-id="${entry.id}"> | |
| <i class="fas fa-eye mr-1"></i>查看 | |
| </button> | |
| <button class="text-red-500 hover:text-red-700 delete-history" data-id="${entry.id}"> | |
| <i class="fas fa-trash-alt mr-1"></i>删除 | |
| </button> | |
| </td> | |
| </tr> | |
| `; | |
| }); | |
| historyTableBody.innerHTML = html; | |
| // 添加历史记录按钮事件 | |
| document.querySelectorAll('.view-history').forEach(btn => { | |
| btn.addEventListener('click', function() { | |
| const id = parseInt(this.dataset.id); | |
| viewHistory(id); | |
| }); | |
| }); | |
| document.querySelectorAll('.delete-history').forEach(btn => { | |
| btn.addEventListener('click', function() { | |
| const id = parseInt(this.dataset.id); | |
| deleteHistory(id); | |
| }); | |
| }); | |
| } | |
| // 查看历史记录 | |
| function viewHistory(id) { | |
| const history = JSON.parse(localStorage.getItem('transcription_history') || '[]'); | |
| const entry = history.find(e => e.id === id); | |
| if (!entry) { | |
| showToast('未找到历史记录', 'error'); | |
| return; | |
| } | |
| // 清空当前字幕 | |
| subtitleContent.innerHTML = ''; | |
| subtitles = []; | |
| // 添加历史字幕 | |
| entry.subtitles.forEach(sub => { | |
| addSubtitle(sub.text, sub.lang); | |
| }); | |
| // 更新语言选择 | |
| language.value = entry.language === '中文' ? 'zh' : entry.language === '日本語' ? 'ja' : 'en'; | |
| showToast('已加载历史记录'); | |
| // 隐藏空字幕消息 | |
| emptySubtitleMessage.classList.add('hidden'); | |
| subtitleContent.classList.remove('hidden'); | |
| } | |
| // 删除历史记录 | |
| function deleteHistory(id) { | |
| if (!confirm('确定要删除这条历史记录吗?')) return; | |
| let history = JSON.parse(localStorage.getItem('transcription_history') || '[]'); | |
| history = history.filter(e => e.id !== id); | |
| localStorage.setItem('transcription_history', JSON.stringify(history)); | |
| loadHistory(); | |
| showToast('历史记录已删除'); | |
| } | |
| // 显示状态提示 | |
| function showToast(message, type = 'info') { | |
| toastMessage.textContent = message; | |
| // 设置颜色 | |
| statusToast.className = 'fixed bottom-4 right-4 text-white px-4 py-2 rounded-lg shadow-lg hidden flex items-center'; | |
| switch(type) { | |
| case 'error': | |
| statusToast.classList.add('bg-red-500'); | |
| break; | |
| case 'warning': | |
| statusToast.classList.add('bg-yellow-500'); | |
| break; | |
| case 'success': | |
| statusToast.classList.add('bg-green-500'); | |
| break; | |
| default: | |
| statusToast.classList.add('bg-gray-800'); | |
| } | |
| statusToast.classList.remove('hidden'); | |
| // 3秒后自动隐藏 | |
| setTimeout(() => { | |
| statusToast.classList.add('hidden'); | |
| }, 3000); | |
| } | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=AntheaLaffey/test" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |