Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Whisper 語音轉文字</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| /* 深邃墨藍色系 */ | |
| --ink-deep: #0a0f1a; | |
| --ink-dark: #121829; | |
| --ink-medium: #1a2238; | |
| --ink-light: #252f4a; | |
| /* 琥珀金點綴 */ | |
| --amber: #f59e0b; | |
| --amber-glow: #fbbf24; | |
| --amber-soft: rgba(245, 158, 11, 0.15); | |
| /* 青色漣漪 */ | |
| --ripple: #06b6d4; | |
| --ripple-soft: rgba(6, 182, 212, 0.12); | |
| /* 紫色錄音 */ | |
| --record: #a855f7; | |
| --record-soft: rgba(168, 85, 247, 0.15); | |
| /* 文字色 */ | |
| --text-bright: #f8fafc; | |
| --text-soft: #94a3b8; | |
| --text-muted: #64748b; | |
| /* 玻璃效果 */ | |
| --glass: rgba(255, 255, 255, 0.03); | |
| --glass-border: rgba(255, 255, 255, 0.08); | |
| --glass-hover: rgba(255, 255, 255, 0.06); | |
| /* 狀態色 */ | |
| --success: #10b981; | |
| --error: #ef4444; | |
| /* 圓角 */ | |
| --radius-sm: 12px; | |
| --radius-md: 20px; | |
| --radius-lg: 28px; | |
| --radius-xl: 40px; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Noto Sans TC', 'Space Grotesk', sans-serif; | |
| background: var(--ink-deep); | |
| min-height: 100vh; | |
| color: var(--text-bright); | |
| line-height: 1.6; | |
| -webkit-font-smoothing: antialiased; | |
| overflow-x: hidden; | |
| } | |
| /* 動態背景 */ | |
| .bg-canvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 0; | |
| pointer-events: none; | |
| overflow: hidden; | |
| } | |
| .wave-layer { | |
| position: absolute; | |
| width: 200%; | |
| height: 200%; | |
| top: -50%; | |
| left: -50%; | |
| background: | |
| radial-gradient(ellipse 80% 50% at 20% 40%, var(--ripple-soft) 0%, transparent 50%), | |
| radial-gradient(ellipse 60% 40% at 80% 60%, var(--amber-soft) 0%, transparent 50%); | |
| animation: drift 25s ease-in-out infinite; | |
| } | |
| .wave-layer:nth-child(2) { | |
| animation-delay: -8s; | |
| animation-duration: 30s; | |
| opacity: 0.7; | |
| } | |
| .wave-layer:nth-child(3) { | |
| animation-delay: -16s; | |
| animation-duration: 35s; | |
| opacity: 0.5; | |
| } | |
| @keyframes drift { | |
| 0%, 100% { transform: translate(0, 0) rotate(0deg); } | |
| 25% { transform: translate(2%, 3%) rotate(1deg); } | |
| 50% { transform: translate(-1%, 2%) rotate(-0.5deg); } | |
| 75% { transform: translate(-3%, -1%) rotate(0.5deg); } | |
| } | |
| .grid-texture { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| background-image: | |
| linear-gradient(var(--glass-border) 1px, transparent 1px), | |
| linear-gradient(90deg, var(--glass-border) 1px, transparent 1px); | |
| background-size: 60px 60px; | |
| opacity: 0.3; | |
| mask-image: radial-gradient(ellipse at center, black 0%, transparent 70%); | |
| } | |
| .page { | |
| position: relative; | |
| z-index: 1; | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 80px 32px; | |
| } | |
| /* 標題區 */ | |
| .header { | |
| text-align: center; | |
| margin-bottom: 64px; | |
| } | |
| .header-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 16px; | |
| background: var(--amber-soft); | |
| border: 1px solid rgba(245, 158, 11, 0.3); | |
| border-radius: 100px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--amber-glow); | |
| margin-bottom: 24px; | |
| letter-spacing: 0.02em; | |
| } | |
| .header-badge svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| .header h1 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: clamp(42px, 8vw, 72px); | |
| font-weight: 700; | |
| letter-spacing: -0.03em; | |
| line-height: 1.1; | |
| margin-bottom: 20px; | |
| background: linear-gradient(135deg, var(--text-bright) 0%, var(--text-soft) 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .header p { | |
| font-size: 18px; | |
| color: var(--text-muted); | |
| font-weight: 400; | |
| max-width: 440px; | |
| margin: 0 auto; | |
| } | |
| /* 輸入模式切換 */ | |
| .input-mode-tabs { | |
| display: flex; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-bottom: 32px; | |
| } | |
| .mode-tab { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 12px 24px; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 100px; | |
| color: var(--text-soft); | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .mode-tab:hover { | |
| background: var(--glass-hover); | |
| color: var(--text-bright); | |
| } | |
| .mode-tab.active { | |
| background: var(--ripple-soft); | |
| border-color: var(--ripple); | |
| color: var(--ripple); | |
| } | |
| .mode-tab.active.record-tab { | |
| background: var(--record-soft); | |
| border-color: var(--record); | |
| color: var(--record); | |
| } | |
| .mode-tab svg { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| /* 主要容器 */ | |
| .main-container { | |
| display: grid; | |
| grid-template-columns: 1fr 340px; | |
| gap: 24px; | |
| } | |
| /* 玻璃卡片 */ | |
| .glass-card { | |
| background: var(--glass); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border: 1px solid var(--glass-border); | |
| border-radius: var(--radius-lg); | |
| padding: 32px; | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .glass-card:hover { | |
| background: var(--glass-hover); | |
| border-color: rgba(255, 255, 255, 0.12); | |
| transform: translateY(-4px); | |
| box-shadow: | |
| 0 20px 40px rgba(0, 0, 0, 0.3), | |
| 0 0 60px var(--ripple-soft); | |
| } | |
| /* 輸入區域容器 */ | |
| .input-zone { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .input-panel { | |
| display: none; | |
| } | |
| .input-panel.active { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 1; | |
| } | |
| /* 上傳區域 */ | |
| .upload-area { | |
| flex: 1; | |
| min-height: 280px; | |
| border: 2px dashed var(--glass-border); | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .upload-area::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: radial-gradient(circle at center, var(--ripple-soft) 0%, transparent 70%); | |
| opacity: 0; | |
| transition: opacity 0.4s ease; | |
| } | |
| .upload-area:hover::before { | |
| opacity: 1; | |
| } | |
| .upload-area:hover { | |
| border-color: var(--ripple); | |
| border-style: solid; | |
| } | |
| .upload-area.dragover { | |
| border-color: var(--amber); | |
| background: var(--amber-soft); | |
| } | |
| .upload-area.has-file { | |
| border-style: solid; | |
| border-color: var(--success); | |
| } | |
| .upload-area.has-file::before { | |
| background: radial-gradient(circle at center, rgba(16, 185, 129, 0.1) 0%, transparent 70%); | |
| opacity: 1; | |
| } | |
| /* 聲波圖示 */ | |
| .wave-icon { | |
| position: relative; | |
| width: 80px; | |
| height: 60px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| margin-bottom: 24px; | |
| } | |
| .wave-bar { | |
| width: 4px; | |
| height: 20px; | |
| background: var(--text-muted); | |
| border-radius: 4px; | |
| transition: all 0.3s ease; | |
| } | |
| .upload-area:hover .wave-bar { | |
| background: var(--ripple); | |
| animation: wave-pulse 1.2s ease-in-out infinite; | |
| } | |
| .wave-bar:nth-child(1) { height: 24px; animation-delay: 0s; } | |
| .wave-bar:nth-child(2) { height: 40px; animation-delay: 0.1s; } | |
| .wave-bar:nth-child(3) { height: 32px; animation-delay: 0.2s; } | |
| .wave-bar:nth-child(4) { height: 48px; animation-delay: 0.3s; } | |
| .wave-bar:nth-child(5) { height: 28px; animation-delay: 0.4s; } | |
| .wave-bar:nth-child(6) { height: 36px; animation-delay: 0.5s; } | |
| .wave-bar:nth-child(7) { height: 20px; animation-delay: 0.6s; } | |
| @keyframes wave-pulse { | |
| 0%, 100% { transform: scaleY(1); } | |
| 50% { transform: scaleY(0.5); } | |
| } | |
| .upload-area.has-file .wave-bar { | |
| background: var(--success); | |
| animation: wave-success 0.6s ease forwards; | |
| } | |
| @keyframes wave-success { | |
| 0% { transform: scaleY(1); } | |
| 50% { transform: scaleY(1.3); } | |
| 100% { transform: scaleY(1); } | |
| } | |
| .upload-title { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: var(--text-bright); | |
| margin-bottom: 8px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .upload-subtitle { | |
| font-size: 14px; | |
| color: var(--text-muted); | |
| position: relative; | |
| z-index: 1; | |
| } | |
| #fileInput { | |
| display: none; | |
| } | |
| /* 檔案資訊 */ | |
| .file-info { | |
| display: none; | |
| margin-top: 20px; | |
| padding: 16px 20px; | |
| background: rgba(16, 185, 129, 0.1); | |
| border: 1px solid rgba(16, 185, 129, 0.2); | |
| border-radius: var(--radius-sm); | |
| } | |
| .file-info.active { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| animation: slideUp 0.3s ease; | |
| } | |
| @keyframes slideUp { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .file-icon { | |
| width: 20px; | |
| height: 20px; | |
| color: var(--success); | |
| } | |
| .file-name { | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: var(--text-bright); | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .file-remove { | |
| width: 20px; | |
| height: 20px; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| padding: 2px; | |
| border-radius: 4px; | |
| } | |
| .file-remove:hover { | |
| color: var(--error); | |
| background: rgba(239, 68, 68, 0.1); | |
| } | |
| /* 錄音區域 */ | |
| .record-area { | |
| flex: 1; | |
| min-height: 280px; | |
| border: 2px solid var(--glass-border); | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .record-area::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: radial-gradient(circle at center, var(--record-soft) 0%, transparent 70%); | |
| opacity: 0.5; | |
| } | |
| /* 錄音按鈕 */ | |
| .record-btn { | |
| position: relative; | |
| width: 120px; | |
| height: 120px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, var(--record) 0%, #7c3aed 100%); | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| margin-bottom: 24px; | |
| z-index: 1; | |
| } | |
| .record-btn::before { | |
| content: ''; | |
| position: absolute; | |
| inset: -4px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, var(--record) 0%, #7c3aed 100%); | |
| opacity: 0.3; | |
| transition: all 0.3s ease; | |
| } | |
| .record-btn:hover::before { | |
| inset: -8px; | |
| opacity: 0.4; | |
| } | |
| .record-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 0 40px rgba(168, 85, 247, 0.4); | |
| } | |
| .record-btn.recording { | |
| animation: pulse-record 1.5s ease-in-out infinite; | |
| } | |
| .record-btn.recording::before { | |
| animation: pulse-ring 1.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse-record { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| } | |
| @keyframes pulse-ring { | |
| 0%, 100% { inset: -4px; opacity: 0.3; } | |
| 50% { inset: -16px; opacity: 0.1; } | |
| } | |
| .record-btn svg { | |
| width: 48px; | |
| height: 48px; | |
| color: white; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .record-btn.recording svg { | |
| display: none; | |
| } | |
| .record-btn.recording::after { | |
| content: ''; | |
| width: 32px; | |
| height: 32px; | |
| background: white; | |
| border-radius: 6px; | |
| } | |
| .record-title { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: var(--text-bright); | |
| margin-bottom: 8px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .record-subtitle { | |
| font-size: 14px; | |
| color: var(--text-muted); | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .record-timer { | |
| display: none; | |
| font-family: 'Space Grotesk', monospace; | |
| font-size: 32px; | |
| font-weight: 600; | |
| color: var(--record); | |
| margin-top: 16px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .record-timer.active { | |
| display: block; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| /* 錄音可視化 */ | |
| .record-visualizer { | |
| display: none; | |
| position: absolute; | |
| bottom: 40px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| height: 60px; | |
| gap: 3px; | |
| align-items: center; | |
| } | |
| .record-visualizer.active { | |
| display: flex; | |
| } | |
| .viz-bar { | |
| width: 4px; | |
| height: 20px; | |
| background: var(--record); | |
| border-radius: 4px; | |
| transition: height 0.1s ease; | |
| } | |
| /* 設定面板 */ | |
| .settings-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| .panel-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding-bottom: 16px; | |
| border-bottom: 1px solid var(--glass-border); | |
| margin-bottom: 4px; | |
| } | |
| .panel-icon { | |
| width: 20px; | |
| height: 20px; | |
| color: var(--amber); | |
| } | |
| .panel-title { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--text-soft); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .form-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .form-group label { | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: var(--text-bright); | |
| } | |
| select { | |
| appearance: none; | |
| background: var(--ink-medium); | |
| border: 1px solid var(--glass-border); | |
| border-radius: var(--radius-sm); | |
| padding: 14px 44px 14px 16px; | |
| font-size: 15px; | |
| font-family: inherit; | |
| color: var(--text-bright); | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; | |
| background-position: right 14px center; | |
| } | |
| select:focus { | |
| outline: none; | |
| border-color: var(--ripple); | |
| box-shadow: 0 0 0 3px var(--ripple-soft); | |
| } | |
| select:hover { | |
| border-color: var(--text-muted); | |
| } | |
| select option { | |
| background: var(--ink-dark); | |
| color: var(--text-bright); | |
| } | |
| /* 動作區 */ | |
| .action-section { | |
| grid-column: 1 / -1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 24px 32px; | |
| } | |
| .action-hint { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| color: var(--text-muted); | |
| font-size: 14px; | |
| } | |
| .hint-icon { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| .btn-primary { | |
| position: relative; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 16px 32px; | |
| background: linear-gradient(135deg, var(--amber) 0%, #d97706 100%); | |
| color: var(--ink-deep); | |
| border: none; | |
| border-radius: var(--radius-xl); | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 15px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| overflow: hidden; | |
| } | |
| .btn-primary::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(135deg, var(--amber-glow) 0%, var(--amber) 100%); | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .btn-primary:hover:not(:disabled)::before { | |
| opacity: 1; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: | |
| 0 10px 30px rgba(245, 158, 11, 0.3), | |
| 0 0 40px rgba(245, 158, 11, 0.2); | |
| } | |
| .btn-primary:disabled { | |
| background: var(--ink-light); | |
| color: var(--text-muted); | |
| cursor: not-allowed; | |
| } | |
| .btn-icon { | |
| width: 18px; | |
| height: 18px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .btn-text { | |
| position: relative; | |
| z-index: 1; | |
| } | |
| /* 載入狀態 */ | |
| .loading-section { | |
| display: none; | |
| grid-column: 1 / -1; | |
| padding: 60px 32px; | |
| text-align: center; | |
| } | |
| .loading-section.active { | |
| display: block; | |
| animation: fadeIn 0.4s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .loading-waves { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| margin-bottom: 24px; | |
| } | |
| .loading-bar { | |
| width: 6px; | |
| height: 40px; | |
| background: linear-gradient(to top, var(--ripple), var(--amber)); | |
| border-radius: 6px; | |
| animation: loading-wave 1s ease-in-out infinite; | |
| } | |
| .loading-bar:nth-child(1) { animation-delay: 0s; } | |
| .loading-bar:nth-child(2) { animation-delay: 0.1s; } | |
| .loading-bar:nth-child(3) { animation-delay: 0.2s; } | |
| .loading-bar:nth-child(4) { animation-delay: 0.3s; } | |
| .loading-bar:nth-child(5) { animation-delay: 0.4s; } | |
| .loading-bar:nth-child(6) { animation-delay: 0.5s; } | |
| .loading-bar:nth-child(7) { animation-delay: 0.6s; } | |
| @keyframes loading-wave { | |
| 0%, 100% { transform: scaleY(0.4); opacity: 0.5; } | |
| 50% { transform: scaleY(1); opacity: 1; } | |
| } | |
| .loading-text { | |
| font-size: 16px; | |
| color: var(--text-soft); | |
| } | |
| .loading-subtext { | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| margin-top: 8px; | |
| } | |
| /* 錯誤狀態 */ | |
| .error-section { | |
| display: none; | |
| grid-column: 1 / -1; | |
| background: rgba(239, 68, 68, 0.1); | |
| border: 1px solid rgba(239, 68, 68, 0.2); | |
| padding: 20px 24px; | |
| border-radius: var(--radius-md); | |
| } | |
| .error-section.active { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| animation: shake 0.4s ease; | |
| } | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 20%, 60% { transform: translateX(-6px); } | |
| 40%, 80% { transform: translateX(6px); } | |
| } | |
| .error-icon { | |
| width: 22px; | |
| height: 22px; | |
| color: var(--error); | |
| flex-shrink: 0; | |
| } | |
| .error-text { | |
| font-size: 14px; | |
| color: var(--error); | |
| } | |
| /* 結果區域 */ | |
| .result-section { | |
| display: none; | |
| margin-top: 48px; | |
| } | |
| .result-section.active { | |
| display: block; | |
| animation: slideUp 0.5s ease; | |
| } | |
| .result-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 24px; | |
| flex-wrap: wrap; | |
| gap: 16px; | |
| } | |
| .result-title { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 28px; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, var(--text-bright) 0%, var(--ripple) 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .result-actions { | |
| display: flex; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .result-meta { | |
| display: flex; | |
| gap: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .meta-tag { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 16px; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 100px; | |
| font-size: 13px; | |
| color: var(--text-soft); | |
| } | |
| .meta-tag svg { | |
| width: 14px; | |
| height: 14px; | |
| color: var(--amber); | |
| } | |
| /* 下載按鈕 */ | |
| .btn-download { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 20px; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 100px; | |
| color: var(--text-soft); | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-family: inherit; | |
| } | |
| .btn-download:hover { | |
| background: var(--ripple-soft); | |
| border-color: var(--ripple); | |
| color: var(--ripple); | |
| } | |
| .btn-download svg { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .result-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 360px; | |
| gap: 24px; | |
| } | |
| /* 轉錄內容卡片 */ | |
| .transcript-card .panel-header { | |
| margin-bottom: 20px; | |
| } | |
| .transcript-content { | |
| font-size: 17px; | |
| line-height: 1.9; | |
| color: var(--text-bright); | |
| white-space: pre-wrap; | |
| } | |
| /* 時間軸卡片 */ | |
| .timeline-card { | |
| max-height: 500px; | |
| overflow-y: auto; | |
| } | |
| .timeline-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .timeline-item { | |
| padding: 16px; | |
| background: var(--ink-medium); | |
| border-radius: var(--radius-sm); | |
| border-left: 3px solid transparent; | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .timeline-item:hover { | |
| background: var(--ink-light); | |
| border-left-color: var(--ripple); | |
| transform: translateX(4px); | |
| } | |
| .timeline-item.playing { | |
| background: var(--ripple-soft); | |
| border-left-color: var(--amber); | |
| } | |
| .timeline-item.playing .timeline-time { | |
| color: var(--amber); | |
| } | |
| .timeline-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| } | |
| .timeline-time { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-family: 'Space Grotesk', monospace; | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--ripple); | |
| } | |
| .timeline-time svg { | |
| width: 12px; | |
| height: 12px; | |
| } | |
| .play-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 28px; | |
| height: 28px; | |
| background: var(--ripple-soft); | |
| border: 1px solid var(--ripple); | |
| border-radius: 50%; | |
| color: var(--ripple); | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| flex-shrink: 0; | |
| } | |
| .play-btn:hover { | |
| background: var(--ripple); | |
| color: var(--ink-deep); | |
| transform: scale(1.1); | |
| } | |
| .play-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| .timeline-item.playing .play-btn { | |
| background: var(--amber); | |
| border-color: var(--amber); | |
| color: var(--ink-deep); | |
| } | |
| .timeline-text { | |
| font-size: 14px; | |
| color: var(--text-soft); | |
| line-height: 1.6; | |
| } | |
| /* 音訊播放器 */ | |
| .audio-player-section { | |
| display: none; | |
| margin-bottom: 24px; | |
| padding: 20px; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: var(--radius-md); | |
| } | |
| .audio-player-section.active { | |
| display: block; | |
| animation: slideUp 0.3s ease; | |
| } | |
| .audio-player-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 16px; | |
| } | |
| .audio-player-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: var(--text-soft); | |
| } | |
| .audio-player-title svg { | |
| width: 18px; | |
| height: 18px; | |
| color: var(--amber); | |
| } | |
| .audio-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .audio-main-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 48px; | |
| height: 48px; | |
| background: linear-gradient(135deg, var(--amber) 0%, #d97706 100%); | |
| border: none; | |
| border-radius: 50%; | |
| color: var(--ink-deep); | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .audio-main-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 0 20px rgba(245, 158, 11, 0.4); | |
| } | |
| .audio-main-btn svg { | |
| width: 24px; | |
| height: 24px; | |
| } | |
| .audio-progress { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .audio-time { | |
| font-family: 'Space Grotesk', monospace; | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| min-width: 45px; | |
| } | |
| .audio-time.current { | |
| color: var(--amber); | |
| } | |
| .progress-bar { | |
| flex: 1; | |
| height: 6px; | |
| background: var(--ink-light); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--ripple), var(--amber)); | |
| border-radius: 3px; | |
| transition: width 0.1s linear; | |
| } | |
| .progress-bar:hover .progress-fill { | |
| background: var(--amber); | |
| } | |
| /* 滾動條 */ | |
| .timeline-card::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .timeline-card::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .timeline-card::-webkit-scrollbar-thumb { | |
| background: var(--ink-light); | |
| border-radius: 3px; | |
| } | |
| .timeline-card::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-muted); | |
| } | |
| /* 響應式 */ | |
| @media (max-width: 900px) { | |
| .main-container { | |
| grid-template-columns: 1fr; | |
| } | |
| .result-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .header h1 { | |
| font-size: 36px; | |
| } | |
| .timeline-card { | |
| max-height: none; | |
| } | |
| } | |
| @media (max-width: 600px) { | |
| .page { | |
| padding: 48px 20px; | |
| } | |
| .header { | |
| margin-bottom: 40px; | |
| } | |
| .glass-card { | |
| padding: 24px; | |
| } | |
| .action-section { | |
| flex-direction: column; | |
| gap: 20px; | |
| padding: 24px; | |
| } | |
| .action-hint { | |
| text-align: center; | |
| } | |
| .btn-primary { | |
| width: 100%; | |
| justify-content: center; | |
| } | |
| .result-header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .input-mode-tabs { | |
| flex-direction: column; | |
| } | |
| .mode-tab { | |
| justify-content: center; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 動態背景 --> | |
| <div class="bg-canvas"> | |
| <div class="wave-layer"></div> | |
| <div class="wave-layer"></div> | |
| <div class="wave-layer"></div> | |
| <div class="grid-texture"></div> | |
| </div> | |
| <div class="page"> | |
| <header class="header"> | |
| <div class="header-badge"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="m5 12 7-7 7 7"/> | |
| <path d="M12 19V5"/> | |
| </svg> | |
| Powered by Whisper | |
| </div> | |
| <h1>語音轉文字</h1> | |
| <p>上傳音訊檔案或直接錄音,AI 為您精準轉錄並生成字幕</p> | |
| </header> | |
| <!-- 輸入模式切換 --> | |
| <div class="input-mode-tabs"> | |
| <button class="mode-tab active" data-mode="upload"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="17 8 12 3 7 8"/> | |
| <line x1="12" y1="3" x2="12" y2="15"/> | |
| </svg> | |
| 上傳檔案 | |
| </button> | |
| <button class="mode-tab record-tab" data-mode="record"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"/> | |
| <line x1="12" y1="19" x2="12" y2="22"/> | |
| </svg> | |
| 即時錄音 | |
| </button> | |
| </div> | |
| <div class="main-container"> | |
| <!-- 輸入區域 --> | |
| <div class="glass-card input-zone"> | |
| <!-- 上傳面板 --> | |
| <div class="input-panel active" id="uploadPanel"> | |
| <div class="upload-area" id="uploadArea"> | |
| <div class="wave-icon"> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| <div class="wave-bar"></div> | |
| </div> | |
| <p class="upload-title" id="uploadTitle">拖放或點擊上傳音訊</p> | |
| <p class="upload-subtitle">支援 MP3、WAV、OGG、FLAC、M4A、WebM</p> | |
| <input type="file" id="fileInput" accept="audio/*"> | |
| </div> | |
| <div class="file-info" id="fileInfo"> | |
| <svg class="file-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M9 18V5l12-2v13"/> | |
| <circle cx="6" cy="18" r="3"/> | |
| <circle cx="18" cy="16" r="3"/> | |
| </svg> | |
| <span class="file-name" id="fileName"></span> | |
| <svg class="file-remove" id="fileRemove" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="18" y1="6" x2="6" y2="18"/> | |
| <line x1="6" y1="6" x2="18" y2="18"/> | |
| </svg> | |
| </div> | |
| </div> | |
| <!-- 錄音面板 --> | |
| <div class="input-panel" id="recordPanel"> | |
| <div class="record-area"> | |
| <button class="record-btn" id="recordBtn"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"/> | |
| <line x1="12" y1="19" x2="12" y2="22"/> | |
| </svg> | |
| </button> | |
| <p class="record-title" id="recordTitle">點擊開始錄音</p> | |
| <p class="record-subtitle" id="recordSubtitle">錄製完成後將自動進行轉錄</p> | |
| <div class="record-timer" id="recordTimer">00:00</div> | |
| <div class="record-visualizer" id="recordVisualizer"> | |
| <!-- 動態生成的可視化條 --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 設定面板 --> | |
| <div class="glass-card settings-panel"> | |
| <div class="panel-header"> | |
| <svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="3"/> | |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/> | |
| </svg> | |
| <span class="panel-title">轉錄設定</span> | |
| </div> | |
| <div class="form-group"> | |
| <label for="language">音訊語言</label> | |
| <select id="language"> | |
| <option value="">自動偵測</option> | |
| <option value="zh">中文</option> | |
| <option value="en">English</option> | |
| <option value="ja">日本語</option> | |
| <option value="ko">한국어</option> | |
| <option value="es">Español</option> | |
| <option value="fr">Français</option> | |
| <option value="de">Deutsch</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="beamSize">辨識精度</label> | |
| <select id="beamSize"> | |
| <option value="1">快速模式</option> | |
| <option value="3">標準模式</option> | |
| <option value="5" selected>平衡模式</option> | |
| <option value="8">精確模式</option> | |
| <option value="10">最高精度</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="toTraditional">中文字體</label> | |
| <select id="toTraditional"> | |
| <option value="true" selected>轉換繁體</option> | |
| <option value="false">保持原樣</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- 動作區 --> | |
| <div class="glass-card action-section"> | |
| <div class="action-hint"> | |
| <svg class="hint-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <path d="M12 16v-4"/> | |
| <path d="M12 8h.01"/> | |
| </svg> | |
| <span id="actionInfoText">選擇音訊檔案或開始錄音</span> | |
| </div> | |
| <button class="btn-primary" id="transcribeBtn" disabled> | |
| <svg class="btn-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <polygon points="5 3 19 12 5 21 5 3"/> | |
| </svg> | |
| <span class="btn-text">開始轉錄</span> | |
| </button> | |
| </div> | |
| <!-- 載入狀態 --> | |
| <div class="glass-card loading-section" id="loading"> | |
| <div class="loading-waves"> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| <div class="loading-bar"></div> | |
| </div> | |
| <p class="loading-text">正在分析音訊內容</p> | |
| <p class="loading-subtext">這可能需要一些時間,取決於音訊長度</p> | |
| </div> | |
| <!-- 錯誤狀態 --> | |
| <div class="error-section" id="error"> | |
| <svg class="error-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <line x1="12" y1="8" x2="12" y2="12"/> | |
| <line x1="12" y1="16" x2="12.01" y2="16"/> | |
| </svg> | |
| <span class="error-text" id="errorText"></span> | |
| </div> | |
| </div> | |
| <!-- 結果區域 --> | |
| <section class="result-section" id="result"> | |
| <div class="result-header"> | |
| <div> | |
| <h2 class="result-title">轉錄結果</h2> | |
| <div class="result-meta" style="margin-top: 12px;"> | |
| <div class="meta-tag"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <path d="M2 12h20"/> | |
| <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> | |
| </svg> | |
| <span id="detectedLanguage">-</span> | |
| </div> | |
| <div class="meta-tag"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <polyline points="12 6 12 12 16 14"/> | |
| </svg> | |
| <span id="duration">-</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="result-actions"> | |
| <button class="btn-download" id="downloadSRT"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="7 10 12 15 17 10"/> | |
| <line x1="12" y1="15" x2="12" y2="3"/> | |
| </svg> | |
| 下載 SRT | |
| </button> | |
| <button class="btn-download" id="downloadVTT"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="7 10 12 15 17 10"/> | |
| <line x1="12" y1="15" x2="12" y2="3"/> | |
| </svg> | |
| 下載 VTT | |
| </button> | |
| <button class="btn-download" id="downloadTXT"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="7 10 12 15 17 10"/> | |
| <line x1="12" y1="15" x2="12" y2="3"/> | |
| </svg> | |
| 下載 TXT | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 音訊播放器 --> | |
| <div class="audio-player-section" id="audioPlayerSection"> | |
| <div class="audio-player-header"> | |
| <div class="audio-player-title"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M9 18V5l12-2v13"/> | |
| <circle cx="6" cy="18" r="3"/> | |
| <circle cx="18" cy="16" r="3"/> | |
| </svg> | |
| <span>音訊播放器</span> | |
| <span id="playingSegmentInfo" style="color: var(--amber); margin-left: 8px;"></span> | |
| </div> | |
| </div> | |
| <div class="audio-controls"> | |
| <button class="audio-main-btn" id="audioPlayBtn"> | |
| <svg id="audioPlayIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> | |
| <polygon points="5 3 19 12 5 21 5 3"/> | |
| </svg> | |
| <svg id="audioPauseIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;"> | |
| <rect x="6" y="4" width="4" height="16"/> | |
| <rect x="14" y="4" width="4" height="16"/> | |
| </svg> | |
| </button> | |
| <div class="audio-progress"> | |
| <span class="audio-time current" id="audioCurrentTime">0:00</span> | |
| <div class="progress-bar" id="audioProgressBar"> | |
| <div class="progress-fill" id="audioProgressFill" style="width: 0%;"></div> | |
| </div> | |
| <span class="audio-time" id="audioTotalTime">0:00</span> | |
| </div> | |
| </div> | |
| <audio id="audioPlayer" style="display: none;"></audio> | |
| </div> | |
| <div class="result-grid"> | |
| <div class="glass-card transcript-card"> | |
| <div class="panel-header"> | |
| <svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/> | |
| <polyline points="14 2 14 8 20 8"/> | |
| <line x1="16" y1="13" x2="8" y2="13"/> | |
| <line x1="16" y1="17" x2="8" y2="17"/> | |
| </svg> | |
| <span class="panel-title">完整內容</span> | |
| </div> | |
| <div class="transcript-content" id="fullText"></div> | |
| </div> | |
| <div class="glass-card timeline-card"> | |
| <div class="panel-header"> | |
| <svg class="panel-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="8" y1="6" x2="21" y2="6"/> | |
| <line x1="8" y1="12" x2="21" y2="12"/> | |
| <line x1="8" y1="18" x2="21" y2="18"/> | |
| <line x1="3" y1="6" x2="3.01" y2="6"/> | |
| <line x1="3" y1="12" x2="3.01" y2="12"/> | |
| <line x1="3" y1="18" x2="3.01" y2="18"/> | |
| </svg> | |
| <span class="panel-title">時間軸</span> | |
| </div> | |
| <div class="timeline-list" id="segments"></div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <script> | |
| // DOM 元素 | |
| const uploadArea = document.getElementById('uploadArea'); | |
| const uploadTitle = document.getElementById('uploadTitle'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const fileInfo = document.getElementById('fileInfo'); | |
| const fileName = document.getElementById('fileName'); | |
| const fileRemove = document.getElementById('fileRemove'); | |
| const transcribeBtn = document.getElementById('transcribeBtn'); | |
| const actionInfoText = document.getElementById('actionInfoText'); | |
| const loading = document.getElementById('loading'); | |
| const result = document.getElementById('result'); | |
| const error = document.getElementById('error'); | |
| const errorText = document.getElementById('errorText'); | |
| // 錄音相關元素 | |
| const recordBtn = document.getElementById('recordBtn'); | |
| const recordTitle = document.getElementById('recordTitle'); | |
| const recordSubtitle = document.getElementById('recordSubtitle'); | |
| const recordTimer = document.getElementById('recordTimer'); | |
| const recordVisualizer = document.getElementById('recordVisualizer'); | |
| // 模式切換 | |
| const modeTabs = document.querySelectorAll('.mode-tab'); | |
| const uploadPanel = document.getElementById('uploadPanel'); | |
| const recordPanel = document.getElementById('recordPanel'); | |
| // 狀態變數 | |
| let selectedFile = null; | |
| let currentMode = 'upload'; | |
| let transcriptData = null; | |
| let audioUrl = null; | |
| // 音訊播放器相關 | |
| const audioPlayer = document.getElementById('audioPlayer'); | |
| const audioPlayerSection = document.getElementById('audioPlayerSection'); | |
| const audioPlayBtn = document.getElementById('audioPlayBtn'); | |
| const audioPlayIcon = document.getElementById('audioPlayIcon'); | |
| const audioPauseIcon = document.getElementById('audioPauseIcon'); | |
| const audioCurrentTime = document.getElementById('audioCurrentTime'); | |
| const audioTotalTime = document.getElementById('audioTotalTime'); | |
| const audioProgressBar = document.getElementById('audioProgressBar'); | |
| const audioProgressFill = document.getElementById('audioProgressFill'); | |
| const playingSegmentInfo = document.getElementById('playingSegmentInfo'); | |
| let currentSegmentEnd = null; | |
| let currentPlayingItem = null; | |
| // 錄音相關變數 | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| let recordingStartTime = null; | |
| let timerInterval = null; | |
| let audioContext = null; | |
| let analyser = null; | |
| let dataArray = null; | |
| // 初始化可視化條 | |
| function initVisualizer() { | |
| recordVisualizer.innerHTML = ''; | |
| for (let i = 0; i < 32; i++) { | |
| const bar = document.createElement('div'); | |
| bar.className = 'viz-bar'; | |
| recordVisualizer.appendChild(bar); | |
| } | |
| } | |
| initVisualizer(); | |
| // 模式切換 | |
| modeTabs.forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| const mode = tab.dataset.mode; | |
| if (mode === currentMode) return; | |
| currentMode = mode; | |
| modeTabs.forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| if (mode === 'upload') { | |
| uploadPanel.classList.add('active'); | |
| recordPanel.classList.remove('active'); | |
| updateActionHint(); | |
| } else { | |
| uploadPanel.classList.remove('active'); | |
| recordPanel.classList.add('active'); | |
| actionInfoText.textContent = '點擊錄音按鈕開始'; | |
| transcribeBtn.disabled = true; | |
| } | |
| result.classList.remove('active'); | |
| error.classList.remove('active'); | |
| }); | |
| }); | |
| // ============ 上傳功能 ============ | |
| uploadArea.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', (e) => { | |
| handleFile(e.target.files[0]); | |
| }); | |
| uploadArea.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.add('dragover'); | |
| }); | |
| uploadArea.addEventListener('dragleave', () => { | |
| uploadArea.classList.remove('dragover'); | |
| }); | |
| uploadArea.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.remove('dragover'); | |
| handleFile(e.dataTransfer.files[0]); | |
| }); | |
| fileRemove.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| clearFile(); | |
| }); | |
| function handleFile(file) { | |
| if (!file) return; | |
| selectedFile = file; | |
| fileName.textContent = file.name; | |
| fileInfo.classList.add('active'); | |
| uploadArea.classList.add('has-file'); | |
| uploadTitle.textContent = '檔案已準備就緒'; | |
| // 創建音訊 URL 供播放器使用 | |
| if (audioUrl) { | |
| URL.revokeObjectURL(audioUrl); | |
| } | |
| audioUrl = URL.createObjectURL(file); | |
| transcribeBtn.disabled = false; | |
| actionInfoText.textContent = '準備就緒,點擊按鈕開始轉錄'; | |
| result.classList.remove('active'); | |
| error.classList.remove('active'); | |
| audioPlayerSection.classList.remove('active'); | |
| } | |
| function clearFile() { | |
| selectedFile = null; | |
| fileInput.value = ''; | |
| fileName.textContent = ''; | |
| fileInfo.classList.remove('active'); | |
| uploadArea.classList.remove('has-file'); | |
| uploadTitle.textContent = '拖放或點擊上傳音訊'; | |
| transcribeBtn.disabled = true; | |
| updateActionHint(); | |
| } | |
| function updateActionHint() { | |
| if (currentMode === 'upload') { | |
| actionInfoText.textContent = selectedFile ? '準備就緒,點擊按鈕開始轉錄' : '選擇音訊檔案後即可開始轉錄'; | |
| transcribeBtn.disabled = !selectedFile; | |
| } | |
| } | |
| // ============ 錄音功能 ============ | |
| recordBtn.addEventListener('click', toggleRecording); | |
| async function toggleRecording() { | |
| if (isRecording) { | |
| stopRecording(); | |
| } else { | |
| await startRecording(); | |
| } | |
| } | |
| async function startRecording() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| // 設定音訊可視化 | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| analyser = audioContext.createAnalyser(); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| source.connect(analyser); | |
| analyser.fftSize = 64; | |
| dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| // 設定 MediaRecorder | |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); | |
| audioChunks = []; | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) { | |
| audioChunks.push(e.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| selectedFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' }); | |
| // 創建音訊 URL 供播放器使用 | |
| if (audioUrl) { | |
| URL.revokeObjectURL(audioUrl); | |
| } | |
| audioUrl = URL.createObjectURL(audioBlob); | |
| // 清理 | |
| stream.getTracks().forEach(track => track.stop()); | |
| if (audioContext) { | |
| audioContext.close(); | |
| audioContext = null; | |
| } | |
| // 自動開始轉錄 | |
| await transcribe(); | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| recordingStartTime = Date.now(); | |
| // 更新 UI | |
| recordBtn.classList.add('recording'); | |
| recordTitle.textContent = '錄音中...'; | |
| recordSubtitle.textContent = '再次點擊停止錄音'; | |
| recordTimer.classList.add('active'); | |
| recordVisualizer.classList.add('active'); | |
| // 啟動計時器 | |
| timerInterval = setInterval(updateTimer, 100); | |
| // 啟動可視化 | |
| visualize(); | |
| } catch (err) { | |
| showError('無法存取麥克風:' + err.message); | |
| } | |
| } | |
| function stopRecording() { | |
| if (mediaRecorder && isRecording) { | |
| mediaRecorder.stop(); | |
| isRecording = false; | |
| // 停止計時器 | |
| clearInterval(timerInterval); | |
| // 更新 UI | |
| recordBtn.classList.remove('recording'); | |
| recordTitle.textContent = '處理中...'; | |
| recordSubtitle.textContent = '正在準備轉錄'; | |
| recordVisualizer.classList.remove('active'); | |
| } | |
| } | |
| function updateTimer() { | |
| const elapsed = Date.now() - recordingStartTime; | |
| const seconds = Math.floor(elapsed / 1000); | |
| const minutes = Math.floor(seconds / 60); | |
| const secs = seconds % 60; | |
| recordTimer.textContent = `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; | |
| } | |
| function visualize() { | |
| if (!isRecording || !analyser) return; | |
| analyser.getByteFrequencyData(dataArray); | |
| const bars = recordVisualizer.querySelectorAll('.viz-bar'); | |
| bars.forEach((bar, i) => { | |
| const value = dataArray[i] || 0; | |
| const height = Math.max(4, (value / 255) * 50); | |
| bar.style.height = `${height}px`; | |
| }); | |
| requestAnimationFrame(visualize); | |
| } | |
| // ============ 轉錄功能 ============ | |
| transcribeBtn.addEventListener('click', transcribe); | |
| async function transcribe() { | |
| if (!selectedFile) return; | |
| const formData = new FormData(); | |
| formData.append('audio', selectedFile); | |
| formData.append('language', document.getElementById('language').value); | |
| formData.append('beam_size', document.getElementById('beamSize').value); | |
| formData.append('to_traditional', document.getElementById('toTraditional').value); | |
| transcribeBtn.disabled = true; | |
| loading.classList.add('active'); | |
| result.classList.remove('active'); | |
| error.classList.remove('active'); | |
| try { | |
| const response = await fetch('/api/transcribe', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| transcriptData = data; | |
| displayResult(data); | |
| } else { | |
| showError(data.error || '轉錄失敗'); | |
| } | |
| } catch (err) { | |
| showError('網路錯誤:' + err.message); | |
| } finally { | |
| loading.classList.remove('active'); | |
| transcribeBtn.disabled = false; | |
| // 重置錄音 UI | |
| if (currentMode === 'record') { | |
| recordTitle.textContent = '點擊開始錄音'; | |
| recordSubtitle.textContent = '錄製完成後將自動進行轉錄'; | |
| recordTimer.classList.remove('active'); | |
| recordTimer.textContent = '00:00'; | |
| } | |
| } | |
| } | |
| function displayResult(data) { | |
| const langMap = { | |
| 'zh': '中文', | |
| 'en': 'English', | |
| 'ja': '日本語', | |
| 'ko': '한국어', | |
| 'es': 'Español', | |
| 'fr': 'Français', | |
| 'de': 'Deutsch' | |
| }; | |
| document.getElementById('detectedLanguage').textContent = langMap[data.language] || data.language.toUpperCase(); | |
| document.getElementById('duration').textContent = formatDuration(data.duration); | |
| document.getElementById('fullText').textContent = data.full_text; | |
| const segmentsDiv = document.getElementById('segments'); | |
| segmentsDiv.innerHTML = ''; | |
| data.segments.forEach((segment, index) => { | |
| const segmentDiv = document.createElement('div'); | |
| segmentDiv.className = 'timeline-item'; | |
| segmentDiv.dataset.start = segment.start; | |
| segmentDiv.dataset.end = segment.end; | |
| segmentDiv.dataset.index = index; | |
| segmentDiv.innerHTML = ` | |
| <div class="timeline-header"> | |
| <div class="timeline-time"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <polyline points="12 6 12 12 16 14"/> | |
| </svg> | |
| ${formatTime(segment.start)} → ${formatTime(segment.end)} | |
| </div> | |
| <button class="play-btn" title="播放此片段"> | |
| <svg class="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> | |
| <polygon points="5 3 19 12 5 21 5 3"/> | |
| </svg> | |
| <svg class="pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;"> | |
| <rect x="6" y="4" width="4" height="16"/> | |
| <rect x="14" y="4" width="4" height="16"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="timeline-text">${segment.text}</div> | |
| `; | |
| // 點擊片段播放 | |
| segmentDiv.addEventListener('click', (e) => { | |
| if (!e.target.closest('.play-btn')) { | |
| playSegment(segment.start, segment.end, segmentDiv, index); | |
| } | |
| }); | |
| // 點擊播放按鈕 | |
| const playBtn = segmentDiv.querySelector('.play-btn'); | |
| playBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| playSegment(segment.start, segment.end, segmentDiv, index); | |
| }); | |
| segmentsDiv.appendChild(segmentDiv); | |
| }); | |
| // 設置音訊播放器 | |
| if (audioUrl) { | |
| audioPlayer.src = audioUrl; | |
| audioPlayerSection.classList.add('active'); | |
| audioTotalTime.textContent = formatTime(data.duration); | |
| } | |
| result.classList.add('active'); | |
| actionInfoText.textContent = '轉錄完成!點擊時間軸片段可播放對應音訊'; | |
| } | |
| // ============ 音訊播放功能 ============ | |
| function playSegment(start, end, itemElement, index) { | |
| // 如果點擊的是正在播放的片段,則暫停 | |
| if (currentPlayingItem === itemElement && !audioPlayer.paused) { | |
| audioPlayer.pause(); | |
| return; | |
| } | |
| // 移除之前的 playing 狀態 | |
| document.querySelectorAll('.timeline-item.playing').forEach(item => { | |
| item.classList.remove('playing'); | |
| item.querySelector('.play-icon').style.display = ''; | |
| item.querySelector('.pause-icon').style.display = 'none'; | |
| }); | |
| // 設置當前片段 | |
| currentSegmentEnd = end; | |
| currentPlayingItem = itemElement; | |
| // 更新 UI | |
| itemElement.classList.add('playing'); | |
| itemElement.querySelector('.play-icon').style.display = 'none'; | |
| itemElement.querySelector('.pause-icon').style.display = ''; | |
| playingSegmentInfo.textContent = `片段 ${index + 1}`; | |
| // 播放 | |
| audioPlayer.currentTime = start; | |
| audioPlayer.play(); | |
| updatePlayPauseButton(true); | |
| } | |
| // 音訊播放器事件 | |
| audioPlayer.addEventListener('timeupdate', () => { | |
| // 更新進度條 | |
| const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; | |
| audioProgressFill.style.width = `${progress}%`; | |
| audioCurrentTime.textContent = formatTime(audioPlayer.currentTime); | |
| // 如果到達片段結束時間,暫停播放 | |
| if (currentSegmentEnd !== null && audioPlayer.currentTime >= currentSegmentEnd) { | |
| audioPlayer.pause(); | |
| currentSegmentEnd = null; | |
| } | |
| }); | |
| audioPlayer.addEventListener('play', () => { | |
| updatePlayPauseButton(true); | |
| }); | |
| audioPlayer.addEventListener('pause', () => { | |
| updatePlayPauseButton(false); | |
| // 更新片段按鈕狀態 | |
| if (currentPlayingItem) { | |
| currentPlayingItem.querySelector('.play-icon').style.display = ''; | |
| currentPlayingItem.querySelector('.pause-icon').style.display = 'none'; | |
| } | |
| }); | |
| audioPlayer.addEventListener('ended', () => { | |
| updatePlayPauseButton(false); | |
| resetPlayingState(); | |
| }); | |
| audioPlayer.addEventListener('loadedmetadata', () => { | |
| audioTotalTime.textContent = formatTime(audioPlayer.duration); | |
| }); | |
| // 主播放按鈕 | |
| audioPlayBtn.addEventListener('click', () => { | |
| if (audioPlayer.paused) { | |
| currentSegmentEnd = null; // 不限制結束時間 | |
| playingSegmentInfo.textContent = '完整播放'; | |
| resetPlayingState(); | |
| audioPlayer.play(); | |
| } else { | |
| audioPlayer.pause(); | |
| } | |
| }); | |
| // 進度條點擊 | |
| audioProgressBar.addEventListener('click', (e) => { | |
| const rect = audioProgressBar.getBoundingClientRect(); | |
| const percent = (e.clientX - rect.left) / rect.width; | |
| audioPlayer.currentTime = percent * audioPlayer.duration; | |
| currentSegmentEnd = null; // 手動跳轉後不限制結束時間 | |
| playingSegmentInfo.textContent = ''; | |
| resetPlayingState(); | |
| }); | |
| function updatePlayPauseButton(isPlaying) { | |
| audioPlayIcon.style.display = isPlaying ? 'none' : ''; | |
| audioPauseIcon.style.display = isPlaying ? '' : 'none'; | |
| } | |
| function resetPlayingState() { | |
| document.querySelectorAll('.timeline-item.playing').forEach(item => { | |
| item.classList.remove('playing'); | |
| item.querySelector('.play-icon').style.display = ''; | |
| item.querySelector('.pause-icon').style.display = 'none'; | |
| }); | |
| currentPlayingItem = null; | |
| } | |
| // ============ 字幕下載功能 ============ | |
| document.getElementById('downloadSRT').addEventListener('click', () => downloadSubtitle('srt')); | |
| document.getElementById('downloadVTT').addEventListener('click', () => downloadSubtitle('vtt')); | |
| document.getElementById('downloadTXT').addEventListener('click', () => downloadSubtitle('txt')); | |
| function downloadSubtitle(format) { | |
| if (!transcriptData) return; | |
| let content = ''; | |
| let filename = `transcript.${format}`; | |
| let mimeType = 'text/plain'; | |
| if (format === 'srt') { | |
| content = generateSRT(transcriptData.segments); | |
| mimeType = 'text/srt'; | |
| } else if (format === 'vtt') { | |
| content = generateVTT(transcriptData.segments); | |
| mimeType = 'text/vtt'; | |
| } else { | |
| content = transcriptData.full_text; | |
| } | |
| const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function generateSRT(segments) { | |
| return segments.map((seg, i) => { | |
| const startTime = formatSRTTime(seg.start); | |
| const endTime = formatSRTTime(seg.end); | |
| return `${i + 1}\n${startTime} --> ${endTime}\n${seg.text}\n`; | |
| }).join('\n'); | |
| } | |
| function generateVTT(segments) { | |
| let vtt = 'WEBVTT\n\n'; | |
| vtt += segments.map((seg, i) => { | |
| const startTime = formatVTTTime(seg.start); | |
| const endTime = formatVTTTime(seg.end); | |
| return `${i + 1}\n${startTime} --> ${endTime}\n${seg.text}\n`; | |
| }).join('\n'); | |
| return vtt; | |
| } | |
| function formatSRTTime(seconds) { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = Math.floor(seconds % 60); | |
| const ms = Math.round((seconds % 1) * 1000); | |
| return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`; | |
| } | |
| function formatVTTTime(seconds) { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = Math.floor(seconds % 60); | |
| const ms = Math.round((seconds % 1) * 1000); | |
| return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}.${pad(ms, 3)}`; | |
| } | |
| function pad(num, size) { | |
| return num.toString().padStart(size, '0'); | |
| } | |
| // ============ 工具函數 ============ | |
| function formatTime(seconds) { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs.toString().padStart(2, '0')}`; | |
| } | |
| function formatDuration(seconds) { | |
| if (seconds < 60) { | |
| return `${Math.round(seconds)} 秒`; | |
| } | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.round(seconds % 60); | |
| return `${mins} 分 ${secs} 秒`; | |
| } | |
| function showError(message) { | |
| errorText.textContent = message; | |
| error.classList.add('active'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |