STT / index.html
github-actions[bot]
Auto-deploy from GitHub: 04cc223e18cbd02ac0c9c51c435c666a643d8207
c7f658d
Raw
History Blame Contribute Delete
51.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>STT - Speech to Text</title>
<!-- External Assets -->
<script src="/static/js/tailwind.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Caveat:wght@400..700&display=swap"
rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet" />
<!-- Tailwind Configuration -->
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
surface: "#e6f0fd",
"crayon-blue": "#2563eb",
"crayon-red": "#dc2626",
"crayon-green": "#16a34a",
"crayon-yellow": "#ca8a04",
"crayon-orange": "#ea580c",
"crayon-purple": "#7c3aed",
"crayon-dark": "#1A1A1A"
},
fontFamily: {
"fredoka": ["Fredoka", "sans-serif"],
"caveat": ["Caveat", "cursive"]
},
fontSize: {
"headline-lg": ["42px", { lineHeight: "1.1", fontWeight: "700" }],
"headline-md": ["28px", { lineHeight: "1.2", fontWeight: "600" }],
"body-lg": ["24px", { lineHeight: "1.5", fontWeight: "500" }],
"label-sm": ["18px", { lineHeight: "1.2", letterSpacing: "0.01em", fontWeight: "500" }],
"body-md": ["20px", { lineHeight: "1.5", fontWeight: "400" }]
}
},
},
}
</script>
<svg height="0" style="position: absolute;" width="0">
<filter height="120%" id="crayon-texture" width="120%" x="-10%" y="-10%">
<feTurbulence baseFrequency="0.4" numOctaves="3" result="noise" type="fractalNoise"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G">
</feDisplacementMap>
</filter>
<filter height="120%" id="crayon-heavy" width="120%" x="-10%" y="-10%">
<feTurbulence baseFrequency="0.5" numOctaves="4" result="noise" type="fractalNoise"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="4" xChannelSelector="R" yChannelSelector="G">
</feDisplacementMap>
</filter>
</svg>
<style>
/* Sketchbook Styles */
body {
background-color: #e6f0fd;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.08'/%3E%3C/svg%3E");
color: #1A1A1A;
font-family: 'Fredoka', sans-serif;
}
.bg-surface {
background-color: rgb(255 255 255 / 0%) !important;
backdrop-filter: blur(2px);
}
.crayon-filter {
filter: url('#crayon-texture');
}
.crayon-heavy {
filter: url('#crayon-heavy');
}
.crayon-border-green {
border: 4px solid #16a34a;
border-radius: 12px 8px 15px 10px / 8px 14px 10px 12px;
filter: url('#crayon-texture');
}
.task-card {
border: 3px solid rgba(124, 58, 237, 0.4);
border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px;
filter: url('#crayon-texture');
}
.crayon-border-blue {
border: 4px dashed #2563eb;
border-radius: 15px 10px 12px 18px / 12px 18px 15px 10px;
filter: url('#crayon-texture');
}
.crayon-border-purple {
border: 4px solid #7c3aed;
border-radius: 10px 16px 12px 14px / 16px 12px 14px 10px;
filter: url('#crayon-texture');
}
.crayon-button {
border: 4px solid #2563eb;
border-radius: 12px 8px 14px 10px / 8px 14px 10px 12px;
transition: all 0.2s ease;
filter: url('#crayon-texture');
cursor: pointer;
}
.crayon-button:hover {
transform: scale(1.05) rotate(1deg);
box-shadow: 6px 6px 0px 0px rgba(0, 0, 0, 0.1);
}
.crayon-button:hover .material-symbols-outlined.spin-on-hover {
animation: spin 2s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes drift {
0% {
transform: translateX(0);
}
50% {
transform: translateX(20px);
}
100% {
transform: translateX(0);
}
}
.drift-slow {
animation: drift 8s ease-in-out infinite;
}
.drift-medium {
animation: drift 5s ease-in-out infinite;
}
.organic-shape {
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
filter: url('#crayon-texture');
}
.scribble-fill-green {
background: repeating-linear-gradient(60deg, #16a34a, #16a34a 2px, #15803d 3px, #16a34a 4px);
}
.progress-fill {
background: repeating-linear-gradient(80deg, #2563eb, #2563eb 2px, #1d4ed8 3px, #2563eb 5px);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
filter: url('#crayon-texture');
}
/* --- Modals --- */
.modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
padding: 2rem;
}
.modal.active {
display: flex;
}
.modal-content {
border: 4px solid #2563eb;
width: 100%;
max-width: 900px;
border-radius: 24px;
display: flex;
flex-direction: column;
max-height: 85vh;
box-shadow: 12px 12px 0px 0px rgba(0, 0, 0, 0.1);
position: relative;
}
.modal-sketch-bg {
position: absolute;
inset: -8px;
border: 6px solid #2563eb;
backdrop-filter: blur(10px);
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
z-index: -1;
filter: url('#crayon-texture');
pointer-events: none;
}
.modal-header {
padding: 1.5rem 2rem;
border-bottom: 3px dashed #adc6ff;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 10;
}
.modal-body {
padding: 2rem;
overflow-y: auto;
position: relative;
z-index: 10;
}
#resultText,
pre {
border: 3px dashed #adc6ff !important;
padding: 2rem !important;
border-radius: 20px !important;
font-family: 'Fredoka', sans-serif !important;
font-size: 1.5rem !important;
font-weight: 600 !important;
color: #fff !important;
white-space: pre-wrap !important;
word-break: break-all !important;
line-height: 1.6 !important;
filter: url('#crayon-texture');
}
.close-modal {
background: transparent;
border: none;
color: #dc2626;
font-size: 3rem;
cursor: pointer;
font-weight: 700;
}
.text-headline-lg {
filter: url('#crayon-texture');
}
.copy-btn {
background: #16a34a;
color: white;
padding: 0.5rem 1.5rem;
border-radius: 12px;
font-weight: 700;
box-shadow: 4px 4px 0px 0px #15803d;
transition: all 0.2s;
filter: url('#crayon-texture');
}
.copy-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0px 0px #15803d;
}
.live-btn {
border: 4px solid #dc2626;
border-radius: 12px 8px 14px 10px / 8px 14px 10px 12px;
transition: all 0.2s ease;
filter: url('#crayon-texture');
cursor: pointer;
animation: livePulse 2s ease-in-out infinite;
}
.live-btn:hover {
transform: scale(1.05) rotate(-1deg);
box-shadow: 6px 6px 0px 0px rgba(0, 0, 0, 0.1);
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
#liveModal .live-transcript {
border: 3px dashed #fca5a5 !important;
padding: 1.5rem !important;
border-radius: 20px !important;
font-family: 'Fredoka', sans-serif !important;
font-size: 1.3rem !important;
font-weight: 500 !important;
color: #fff !important;
white-space: pre-wrap !important;
word-break: break-all !important;
line-height: 1.6 !important;
filter: url('#crayon-texture');
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
#liveModal select {
border: 3px solid #7c3aed;
border-radius: 12px;
padding: 0.5rem 1rem;
font-family: 'Fredoka', sans-serif;
font-size: 1.1rem;
font-weight: 600;
background: white;
filter: url('#crayon-texture');
outline: none;
}
#liveModal select:focus {
border-color: #2563eb;
}
/* --- Specific UI Elements --- */
.status-modal-content {
max-width: 450px;
text-align: center;
padding: 3rem 2rem;
}
.status-modal-bg {
position: absolute;
inset: -12px;
border: 8px solid #2563eb;
background: #e6f0fd;
border-radius: 20px 40px 15px 35px / 35px 15px 40px 20px;
z-index: -1;
filter: url('#crayon-texture');
pointer-events: none;
}
.status-icon-container {
width: 100px;
height: 100px;
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px;
border: 4px solid currentColor;
filter: url('#crayon-texture');
position: relative;
z-index: 20;
}
.status-icon-bg {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0.15;
z-index: -1;
border-radius: inherit;
}
.modal-decoration {
position: absolute;
pointer-events: none;
opacity: 0.3;
z-index: 5;
filter: url('#crayon-texture');
}
.table-container::-webkit-scrollbar {
width: 10px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 10px;
}
.table-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
border: 2px solid #f1f5f9;
}
.dragging {
border-color: #16a34a !important;
background-color: #f0fdf4 !important;
}
</style>
</head>
<body class="h-screen flex flex-col overflow-hidden relative">
<!-- Main Background Decorations -->
<div class="fixed inset-0 pointer-events-none overflow-hidden -z-10 opacity-40">
<!-- Stars -->
<span
class="material-symbols-outlined absolute text-5xl text-crayon-yellow top-20 left-[10%] rotate-12 crayon-heavy animate-pulse">star</span>
<span
class="material-symbols-outlined absolute text-3xl text-crayon-orange top-[40%] left-[5%] -rotate-12 crayon-filter">star</span>
<span
class="material-symbols-outlined absolute text-4xl text-crayon-yellow bottom-[20%] left-[15%] rotate-45 animate-pulse">star</span>
<span
class="material-symbols-outlined absolute text-6xl text-crayon-orange top-[15%] right-[15%] rotate-[-15deg] crayon-heavy">star</span>
<span
class="material-symbols-outlined absolute text-3xl text-crayon-yellow bottom-[30%] right-[10%] rotate-12 animate-pulse">star</span>
<!-- Clouds -->
<span
class="material-symbols-outlined absolute text-[120px] text-crayon-blue top-[10%] left-[25%] opacity-20 drift-slow">cloud</span>
<span
class="material-symbols-outlined absolute text-[80px] text-crayon-purple bottom-[15%] left-[40%] opacity-10 drift-medium">cloud</span>
<span
class="material-symbols-outlined absolute text-[100px] text-crayon-blue top-[60%] right-[25%] opacity-15 drift-slow">cloud</span>
<span
class="material-symbols-outlined absolute text-[150px] text-crayon-purple top-[30%] right-[5%] opacity-10 drift-medium">cloud</span>
<!-- Hearts -->
<span
class="material-symbols-outlined absolute text-4xl text-crayon-red top-[25%] left-[18%] rotate-[-15deg] crayon-filter animate-pulse">favorite</span>
<span
class="material-symbols-outlined absolute text-2xl text-crayon-red bottom-[10%] right-[20%] rotate-12 crayon-filter">favorite</span>
<span
class="material-symbols-outlined absolute text-5xl text-crayon-red top-[70%] left-[8%] rotate-[10deg] animate-pulse">favorite</span>
</div>
<!-- SVG Filters -->
<svg height="0" width="0" style="position: absolute;">
<filter id="crayon-texture" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id="crayon-heavy" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.5" numOctaves="4" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="4" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
<!-- Header -->
<header
class="bg-surface flex justify-between items-center w-[calc(100%-48px)] mx-6 mt-6 px-8 py-5 crayon-border-green z-10 shrink-0 organic-shape shadow-sm">
<div class="flex items-center gap-5">
<div
class="bg-crayon-green text-white w-14 h-14 rounded-2xl flex items-center justify-center border-[3px] border-crayon-green rotate-[-4deg] crayon-filter scribble-fill-green shadow-md">
<span class="material-symbols-outlined text-4xl">mic</span>
</div>
<div class="flex flex-col -rotate-1">
<h1 class="text-headline-lg text-[#4c1d95] leading-none mb-1">STT</h1>
<span class="text-label-sm text-[#4b5563] font-bold">Speech to Text</span>
</div>
</div>
<div class="flex items-center gap-10 relative">
<div class="absolute -left-48 top-2 rotate-12 crayon-heavy">
<span class="material-symbols-outlined text-4xl text-crayon-yellow">star</span>
</div>
<div class="absolute -left-24 top-0 -rotate-6 crayon-heavy opacity-80">
<span class="material-symbols-outlined text-5xl text-crayon-blue">cloud</span>
</div>
<button id="apiDocBtn"
class="flex items-center gap-2 text-headline-md text-crayon-purple px-8 py-3 bg-surface crayon-button rotate-1 shadow-md">
<span class="material-symbols-outlined text-3xl">menu_book</span>
API DOC
</button>
<div
class="flex items-center gap-4 bg-surface px-6 py-3 rounded-full border-[4px] border-crayon-green organic-shape shadow-md">
<div id="healthDot" class="w-4 h-4 rounded-full bg-crayon-green shadow-[0_0_12px_rgba(22,163,74,0.5)]">
</div>
<span id="healthText" class="text-headline-md text-crayon-green text-2xl">Service Online</span>
<button id="liveBtn"
class="hidden items-center gap-2 px-6 py-2 bg-crayon-red text-white text-headline-md font-bold live-btn shadow-md">
<span class="material-symbols-outlined text-3xl">mic</span>
LIVE
</button>
<div class="text-crayon-orange flex items-center justify-center rotate-[15deg]">
<span class="material-symbols-outlined text-4xl">light_mode</span>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex overflow-hidden p-8 gap-8 mx-auto w-full relative">
<!-- Input Section -->
<section class="w-[450px] flex flex-col gap-8">
<div id="uploadZone"
class="flex flex-col gap-6 p-8 bg-surface crayon-border-blue flex-1 relative organic-shape cursor-pointer group shadow-sm hover:shadow-md transition-shadow">
<div class="flex items-center gap-4 mb-2 rotate-1">
<div
class="bg-crayon-blue text-white rounded-full w-14 h-14 flex items-center justify-center border-2 border-crayon-blue crayon-filter shadow-md">
<span class="material-symbols-outlined text-4xl">upload</span>
</div>
<div>
<h2 class="text-headline-md text-crayon-blue leading-none mb-1 flex items-center gap-2">
INPUT
<span class="material-symbols-outlined text-crayon-red text-2xl rotate-12">favorite</span>
</h2>
<p class="text-label-sm text-[#6b7280]">Upload your file</p>
</div>
</div>
<div
class="flex-1 flex flex-col items-center justify-center relative border-[4px] border-dashed border-[#adc6ff] rounded-[32px] bg-surface p-6 group-hover:bg-blue-50 transition-colors">
<div class="relative w-48 h-48 mb-8 flex items-center justify-center">
<div
class="absolute w-36 h-44 bg-blue-100 border-[3px] border-crayon-blue rounded-xl rotate-[12deg] right-4 bottom-4 organic-shape opacity-60">
</div>
<div
class="absolute w-36 h-44 bg-surface border-[3px] border-crayon-blue rounded-xl z-10 flex flex-col items-center p-4 organic-shape rotate-[-4deg] shadow-sm">
<div
class="w-8 h-8 rounded-full bg-crayon-yellow self-start mb-4 border-[2px] border-[#1A1A1A]">
</div>
<div class="w-full h-2 bg-[#adc6ff] rounded-full mb-3"></div>
<div class="w-2/3 h-2 bg-[#adc6ff] rounded-full self-start"></div>
</div>
<div
class="absolute -bottom-4 -right-4 bg-crayon-blue text-white rounded-full w-16 h-16 flex items-center justify-center border-[4px] border-white z-20 shadow-xl group-hover:scale-110 transition-transform rotate-12 crayon-filter">
<span class="material-symbols-outlined text-4xl">arrow_upward</span>
</div>
</div>
<p class="text-headline-md text-[#1A1A1A] mb-1 font-bold">Drag & drop here</p>
<p class="text-label-sm text-crayon-purple mb-8 font-bold">or click to browse</p>
<div class="flex gap-2 mt-auto w-full justify-center flex-wrap">
<span
class="px-4 py-1.5 bg-surface border-[2px] border-crayon-blue text-crayon-blue text-lg font-bold rounded-xl organic-shape -rotate-2">WAV</span>
<span
class="px-4 py-1.5 bg-surface border-[2px] border-crayon-green text-crayon-green text-lg font-bold rounded-xl organic-shape rotate-1">MP3</span>
<span
class="px-4 py-1.5 bg-surface border-[2px] border-crayon-red text-crayon-red text-lg font-bold rounded-xl organic-shape -rotate-1">FLAC</span>
</div>
</div>
<input type="file" id="fileInput" hidden accept="audio/*">
</div>
</section>
<!-- Activity Section -->
<section
class="flex-1 flex flex-col bg-surface crayon-border-purple p-8 relative organic-shape overflow-hidden shadow-sm">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4 rotate-1">
<div
class="bg-crayon-purple text-white rounded-full w-14 h-14 flex items-center justify-center border-[3px] border-crayon-purple shadow-md">
<span class="material-symbols-outlined text-4xl">schedule</span>
</div>
<div>
<h2 class="text-headline-lg text-[#4c1d95] leading-none mb-1">ACTIVITY</h2>
<p class="text-label-sm text-[#6b7280] font-bold">Recent tasks</p>
</div>
</div>
<div class="relative w-40 h-20 flex items-center">
<svg class="absolute left-[-30px] top-4 w-32 h-16 crayon-filter text-crayon-yellow" fill="none"
stroke="currentColor" stroke-dasharray="6 6" stroke-linecap="round" stroke-width="3"
viewBox="0 0 100 50">
<path d="M10,40 Q40,50 60,30 T90,10" />
</svg>
<span
class="material-symbols-outlined text-5xl text-crayon-orange absolute right-0 top-0 rotate-12">send</span>
</div>
<button onclick="loadTasks()"
class="flex items-center gap-2 text-headline-md text-crayon-blue px-8 py-3 bg-surface crayon-button border-[4px] -rotate-1 shadow-md">
<span class="material-symbols-outlined text-3xl spin-on-hover">sync</span>
Refresh
</button>
</div>
<!-- List Header -->
<div class="flex w-full px-6 pb-4 border-b-[5px] text-[#4c1d95] font-bold text-xl uppercase tracking-wider"
style="border-color: rgba(124, 58, 237, 0.4); border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px; filter: url(#crayon-texture);">
<div class="w-1/2">CONTENT</div>
<div class="w-1/6 text-center">STATUS</div>
<div class="w-1/4 text-center">PROGRESS</div>
<div class="w-[10%] text-center">ACTION</div>
</div>
<!-- Task List Body -->
<div id="queueBody" class="flex flex-col gap-5 mt-6 overflow-y-auto flex-1 table-container pr-4">
<div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No tasks found
yet...</div>
</div>
</section>
</main>
<!-- Modals -->
<!-- Result & API Modal -->
<div id="resultModal" class="modal">
<div class="modal-content">
<div class="modal-sketch-bg"></div>
<!-- Decorations -->
<span
class="material-symbols-outlined modal-decoration text-6xl text-crayon-yellow top-4 left-4 -rotate-12">star</span>
<span
class="material-symbols-outlined modal-decoration text-8xl text-crayon-blue top-12 right-20 opacity-20">cloud</span>
<span
class="material-symbols-outlined modal-decoration text-4xl text-crayon-orange bottom-10 left-10 rotate-45">star</span>
<span
class="material-symbols-outlined modal-decoration text-7xl text-crayon-purple bottom-4 right-4 -rotate-6 opacity-40">cloud</span>
<div class="modal-header">
<div class="flex items-center gap-6">
<span id="modalTitle" class="text-headline-lg text-[#1e1b4b]">Transcription Result</span>
<button id="copyBtn" onclick="copyResult()" class="copy-btn">📋 Copy Text</button>
</div>
<button class="close-modal" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="resultText"></pre>
</div>
</div>
</div>
<!-- Status Modal -->
<div id="statusModal" class="modal">
<div class="modal-content status-modal-content">
<div id="statusBg" class="status-modal-bg"></div>
<span
class="material-symbols-outlined modal-decoration text-4xl text-crayon-yellow top-6 right-6 rotate-12">star</span>
<span
class="material-symbols-outlined modal-decoration text-6xl text-crayon-blue top-10 left-4 opacity-30">cloud</span>
<span
class="material-symbols-outlined modal-decoration text-3xl text-crayon-orange bottom-8 right-10 -rotate-12">star</span>
<span
class="material-symbols-outlined modal-decoration text-5xl text-crayon-purple bottom-10 left-8 rotate-6 opacity-30">cloud</span>
<div id="statusIconContainer" class="status-icon-container text-crayon-blue">
<div class="status-icon-bg"></div>
<span id="statusIcon" class="material-symbols-outlined text-6xl animate-bounce">upload</span>
</div>
<h2 id="statusMessage" class="text-headline-lg text-crayon-blue mb-2">Uploading...</h2>
<p id="statusSubMessage" class="text-body-lg text-[#4b5563]">Processing your request, please wait.</p>
</div>
</div>
<!-- Live STT Modal -->
<div id="liveModal" class="modal">
<div class="modal-content">
<div class="modal-sketch-bg"></div>
<span class="material-symbols-outlined modal-decoration text-6xl text-crayon-red top-4 left-4 -rotate-12">mic</span>
<span class="material-symbols-outlined modal-decoration text-8xl text-crayon-blue top-12 right-20 opacity-20">cloud</span>
<span class="material-symbols-outlined modal-decoration text-4xl text-crayon-orange bottom-10 left-10 rotate-45">star</span>
<span class="material-symbols-outlined modal-decoration text-7xl text-crayon-purple bottom-4 right-4 -rotate-6 opacity-40">cloud</span>
<div class="modal-header">
<div class="flex items-center gap-6">
<span class="text-headline-lg text-[#1e1b4b]">🔴 Live STT</span>
</div>
<button class="close-modal" onclick="closeLiveModal()">&times;</button>
</div>
<div class="modal-body">
<div class="flex items-center gap-6 mb-6 flex-wrap">
<label class="text-label-sm text-[#4b5563] font-bold">Model:</label>
<select id="liveModelSelect">
<option value="tiny">tiny</option>
<option value="base" selected>base</option>
<option value="small">small</option>
<option value="medium">medium</option>
<option value="large-v3">large-v3</option>
</select>
<button id="startLiveBtn"
class="flex items-center gap-2 px-8 py-3 bg-crayon-green text-white text-headline-md font-bold crayon-button border-crayon-green shadow-md">
<span class="material-symbols-outlined text-3xl">play_arrow</span>
Start
</button>
<button id="stopLiveBtn"
class="hidden items-center gap-2 px-8 py-3 bg-crayon-red text-white text-headline-md font-bold crayon-button border-crayon-red shadow-md">
<span class="material-symbols-outlined text-3xl">stop</span>
Stop
</button>
<div id="liveStatusBadge" class="hidden items-center gap-2 px-4 py-2 bg-crayon-red text-white text-label-sm font-bold rounded-full animate-pulse">
<span class="material-symbols-outlined text-2xl">mic</span>
RECORDING
</div>
</div>
<div id="liveTranscript" class="live-transcript">Waiting to start...</div>
</div>
</div>
</div>
<!-- Application Logic -->
<script>
// --- Configuration ---
const API_BASE = '/api';
// --- DOM Elements ---
const UI = {
uploadZone: document.getElementById('uploadZone'),
fileInput: document.getElementById('fileInput'),
queueBody: document.getElementById('queueBody'),
resultModal: document.getElementById('resultModal'),
statusModal: document.getElementById('statusModal'),
resultText: document.getElementById('resultText'),
modalTitle: document.getElementById('modalTitle'),
statusMessage: document.getElementById('statusMessage'),
statusSubMessage: document.getElementById('statusSubMessage'),
statusIcon: document.getElementById('statusIcon'),
statusIconContainer: document.getElementById('statusIconContainer'),
statusBg: document.getElementById('statusBg'),
healthDot: document.getElementById('healthDot'),
healthText: document.getElementById('healthText'),
apiDocBtn: document.getElementById('apiDocBtn')
};
// --- UI Helpers ---
function updateStatusModal(type, msg, subMsg) {
UI.statusMessage.innerText = msg;
UI.statusSubMessage.innerText = subMsg || "Processing your request, please wait.";
UI.statusIconContainer.className = "status-icon-container";
UI.statusIcon.className = "material-symbols-outlined text-6xl";
UI.statusBg.style.borderColor = "";
if (type === 'uploading') {
UI.statusIconContainer.classList.add('text-crayon-blue');
UI.statusIcon.innerText = "upload";
UI.statusIcon.classList.add('animate-bounce');
UI.statusBg.style.borderColor = "#2563eb";
} else if (type === 'success') {
UI.statusIconContainer.classList.add('text-crayon-green');
UI.statusIcon.innerText = "check_circle";
UI.statusBg.style.borderColor = "#16a34a";
} else if (type === 'error') {
UI.statusIconContainer.classList.add('text-crayon-red');
UI.statusIcon.innerText = "error";
UI.statusBg.style.borderColor = "#dc2626";
}
}
function closeModal() {
UI.resultModal.classList.remove('active');
}
function copyResult() {
const text = UI.resultText.innerText;
const btn = document.getElementById('copyBtn');
navigator.clipboard.writeText(text).then(() => {
const orig = btn.innerText;
btn.innerText = '✓ Copied!';
setTimeout(() => { btn.innerText = orig; }, 2000);
});
}
// --- API Functions ---
async function loadTasks() {
try {
const res = await fetch(`${API_BASE}/tasks`);
const data = await res.json();
renderQueue(data);
} catch (err) {
console.error("Load tasks error:", err);
}
}
async function handleFile(file) {
if (!file) return;
UI.statusModal.classList.add('active');
updateStatusModal('uploading', "Uploading...", "Sending file to server...");
const formData = new FormData();
formData.append('audio', file);
try {
const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData });
if (res.ok) {
updateStatusModal('success', "Success! ✨", "File uploaded and task created.");
setTimeout(() => {
UI.statusModal.classList.remove('active');
loadTasks();
}, 1200);
} else {
updateStatusModal('error', "Upload Failed ❌", "Something went wrong on our end.");
setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
}
} catch (err) {
console.error("Upload error:", err);
updateStatusModal('error', "Connection Error ⚠️", "Could not reach the server.");
setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
}
}
async function showResult(id) {
try {
const res = await fetch(`${API_BASE}/tasks/${id}`);
const data = await res.json();
const text = data.result;
UI.modalTitle.innerText = "Transcription Result";
let formatted = text;
try {
const parsed = JSON.parse(text);
formatted = JSON.stringify(parsed, null, 2);
} catch (e) { /* Not JSON, use raw text */ }
UI.resultText.innerText = formatted;
UI.resultModal.classList.add('active');
} catch (err) {
console.error("Show result error:", err);
}
}
async function checkHealth() {
try {
const res = await fetch('/health');
const data = await res.json();
const healthy = data.status === 'healthy';
UI.healthDot.className = `w-4 h-4 rounded-full ${healthy ? 'bg-crayon-green' : 'bg-crayon-red'} shadow-md`;
UI.healthText.innerText = healthy ? 'Service Online' : 'Service Down';
UI.healthText.className = `text-headline-md font-bold ${healthy ? 'text-crayon-green' : 'text-crayon-red'}`;
} catch (e) {
UI.healthDot.className = 'w-4 h-4 rounded-full bg-crayon-red shadow-md';
UI.healthText.innerText = 'Connection Error';
UI.healthText.className = 'text-headline-md font-bold text-crayon-red';
}
}
// --- Renderers ---
function renderQueue(tasks) {
if (tasks.length === 0) {
UI.queueBody.innerHTML = '<div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No tasks found yet...</div>';
return;
}
UI.queueBody.innerHTML = tasks.map((t, i) => {
const rotate = i % 2 === 0 ? 'rotate-[0.3deg]' : '-rotate-[0.3deg]';
const status = t.status.toLowerCase();
const colors = {
completed: { text: 'crayon-green', bg: 'bg-[#f0fdf4]' },
failed: { text: 'crayon-red', bg: 'bg-[#fef2f2]' },
processing: { text: 'crayon-blue', bg: 'bg-[#eff6ff]' },
pending: { text: 'crayon-purple', bg: 'bg-[#f5f3ff]' }
};
const theme = colors[status] || colors.pending;
return `
<div class="task-card flex items-center p-6 bg-surface hover:border-crayon-purple transition-colors shadow-sm ${rotate}">
<div class="flex items-center gap-5 w-1/2">
<div class="w-16 h-20 border-[3px] border-crayon-blue rounded-xl p-2 bg-surface flex shadow-sm organic-shape rotate-[-3deg]">
<div class="w-1/2 h-full border-r-[2px] border-[#adc6ff] flex flex-col gap-1.5 p-0.5">
<div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div>
<div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div>
<div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div>
</div>
<div class="w-1/2 h-full flex flex-col gap-1.5 p-0.5 relative">
<div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div>
<div class="mt-auto flex gap-1.5 bottom-1 absolute">
<div class="w-2.5 h-2.5 bg-crayon-yellow rounded-sm"></div>
<div class="w-2.5 h-2.5 bg-crayon-green rounded-sm"></div>
</div>
</div>
</div>
<div class="flex flex-col">
<span class="text-headline-md text-[#1A1A1A] leading-tight font-bold mb-1">${t.filename}</span>
<div class="text-label-sm text-[#94a3b8] font-bold">${t.id.substring(0, 12)}</div>
</div>
</div>
<div class="w-1/6 flex justify-center">
<div class="px-4 py-2 ${theme.bg} border-[3px] border-${theme.text} text-${theme.text} font-bold rounded-2xl uppercase tracking-tight crayon-filter">
${status.replace('_', ' ')}
</div>
</div>
<div class="w-1/4 flex items-center justify-center gap-4">
<div class="flex-1 h-6 border-[3px] border-[#adc6ff] rounded-full overflow-hidden bg-surface p-[2px] crayon-filter">
<div class="h-full rounded-full progress-fill shadow-sm" style="width:${t.progress}%"></div>
</div>
<span class="text-headline-md text-2xl text-[#1A1A1A] font-bold w-12 text-right">${status === 'completed' ? '' : t.progress + '%'}</span>
</div>
<div class="w-[10%] flex justify-center">
${status === 'completed' ? `
<button onclick="showResult('${t.id}')" class="flex items-center gap-2 px-5 py-2.5 bg-white border-[3px] border-crayon-blue text-crayon-blue font-bold rounded-2xl hover:bg-crayon-blue hover:text-white transition-all shadow-sm crayon-filter">
<span class="material-symbols-outlined text-2xl">visibility</span>
VIEW
</button>
` : '—'}
</div>
</div>
`;
}).join('');
}
// --- Event Listeners ---
UI.uploadZone.onclick = () => UI.fileInput.click();
UI.uploadZone.ondragover = (e) => { e.preventDefault(); UI.uploadZone.classList.add('dragging'); };
UI.uploadZone.ondragleave = () => UI.uploadZone.classList.remove('dragging');
UI.uploadZone.ondrop = (e) => {
e.preventDefault();
UI.uploadZone.classList.remove('dragging');
handleFile(e.dataTransfer.files[0]);
};
UI.fileInput.onchange = (e) => handleFile(e.target.files[0]);
UI.apiDocBtn.onclick = () => {
const doc = {
base_url: window.location.origin,
endpoints: [
{ method: "POST", path: "/api/tasks/upload", desc: "Upload audio for STT" },
{ method: "GET", path: "/api/tasks", desc: "List all tasks" },
{ method: "GET", path: "/api/tasks/{task_id}", desc: "Get STT result" },
{ method: "GET", path: "/health", desc: "Service health" }
],
example_usage: `curl -X POST -F 'audio=@file.wav' ${window.location.origin}/api/tasks/upload`
};
UI.resultText.innerText = JSON.stringify(doc, null, 2);
UI.modalTitle.innerText = "API Documentation";
UI.resultModal.classList.add('active');
};
// --- Live STT (WebSocket) ---
const liveBtn = document.getElementById('liveBtn');
const liveModal = document.getElementById('liveModal');
const liveModelSelect = document.getElementById('liveModelSelect');
const startLiveBtn = document.getElementById('startLiveBtn');
const stopLiveBtn = document.getElementById('stopLiveBtn');
const liveTranscript = document.getElementById('liveTranscript');
const liveStatusBadge = document.getElementById('liveStatusBadge');
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('live') === 'true') {
liveBtn.classList.remove('hidden');
}
let liveWs = null;
let liveMicStream = null;
let liveAudioCtx = null;
let liveProcessor = null;
let isLiveStreaming = false;
let liveResampleBuf = [];
let livePreBuffer = [];
let wsReady = false;
let liveFinalizeTimer = null;
let liveCommitted = '';
let liveTentative = '';
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function renderLiveTranscript() {
const committed = liveCommitted ? escapeHtml(liveCommitted) : '';
const tentative = liveTentative
? `<span style="opacity:0.45">${escapeHtml(liveTentative)}</span>` : '';
if (!committed && !tentative) {
liveTranscript.innerText = '🎤 Listening...';
} else {
liveTranscript.innerHTML = (committed + (committed && tentative ? ' ' : '') + tentative);
}
liveTranscript.scrollTop = liveTranscript.scrollHeight;
}
const WS_SAMPLE_RATE = 16000;
function openLiveModal() {
liveModal.classList.add('active');
liveTranscript.innerText = 'Select a model and click Start.';
startLiveBtn.classList.remove('hidden');
stopLiveBtn.classList.add('hidden');
liveStatusBadge.classList.add('hidden');
}
function closeLiveModal() {
stopLiveStreaming();
liveModal.classList.remove('active');
}
function float32ToInt16(float32) {
const int16 = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16;
}
function resampleAndSend(floatData, nativeRate) {
const ratio = WS_SAMPLE_RATE / nativeRate;
const outLen = Math.floor(floatData.length * ratio);
for (let outPos = 0; outPos < outLen; outPos++) {
const srcPos = outPos / ratio;
const idx = Math.floor(srcPos);
const frac = srcPos - idx;
const next = Math.min(idx + 1, floatData.length - 1);
liveResampleBuf.push(floatData[idx] * (1 - frac) + floatData[next] * frac);
}
const targetSize = Math.floor(WS_SAMPLE_RATE * 0.4);
while (liveResampleBuf.length >= targetSize) {
const chunk = liveResampleBuf.splice(0, targetSize);
const int16 = float32ToInt16(new Float32Array(chunk));
if (liveWs && liveWs.readyState === WebSocket.OPEN) {
liveWs.send(int16.buffer);
}
}
}
async function startLiveStreaming() {
try {
// Set up mic + AudioContext in the click handler (user gesture)
liveAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
liveMicStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = liveAudioCtx.createMediaStreamSource(liveMicStream);
liveProcessor = liveAudioCtx.createScriptProcessor(4096, 1, 1);
liveProcessor.onaudioprocess = (e) => {
if (!isLiveStreaming) return;
const data = e.inputBuffer.getChannelData(0);
if (wsReady) {
resampleAndSend(data, liveAudioCtx.sampleRate);
} else {
livePreBuffer.push(...data);
}
};
source.connect(liveProcessor);
liveProcessor.connect(liveAudioCtx.destination);
isLiveStreaming = true;
// Connect WebSocket
const model = liveModelSelect.value;
const wsUrl = `${window.location.origin.replace(/^http/, 'ws')}/ws/transcribe`;
liveWs = new WebSocket(wsUrl);
liveWs.binaryType = 'arraybuffer';
liveWs.onopen = () => {
liveWs.send(JSON.stringify({ model }));
};
liveWs.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ready') {
wsReady = true;
// Flush pre-buffered audio
if (livePreBuffer.length > 0) {
resampleAndSend(
new Float32Array(livePreBuffer.splice(0)),
liveAudioCtx.sampleRate
);
}
liveCommitted = '';
liveTentative = '';
renderLiveTranscript();
} else if (msg.type === 'commit') {
liveCommitted += (liveCommitted ? ' ' : '') + msg.text;
liveTentative = '';
renderLiveTranscript();
} else if (msg.type === 'tentative') {
liveTentative = msg.text || '';
renderLiveTranscript();
} else if (msg.type === 'done') {
teardownLiveStreaming();
} else if (msg.type === 'error') {
liveTranscript.innerText = '❌ Error: ' + msg.message;
}
} catch (e) { /* ignore non-JSON */ }
};
liveWs.onerror = () => {
liveTranscript.innerText = '❌ WebSocket error. Check if the server is running.';
teardownLiveStreaming();
};
liveWs.onclose = () => {
teardownLiveStreaming();
};
liveTranscript.innerText = 'Connecting...';
startLiveBtn.classList.add('hidden');
stopLiveBtn.classList.remove('hidden');
liveStatusBadge.classList.remove('hidden');
liveModelSelect.disabled = true;
} catch (err) {
liveTranscript.innerText = '❌ Error: ' + err.message;
stopLiveStreaming();
}
}
function stopMicCapture() {
isLiveStreaming = false;
wsReady = false;
livePreBuffer = [];
liveResampleBuf = [];
if (liveProcessor) { liveProcessor.disconnect(); liveProcessor = null; }
if (liveMicStream) { liveMicStream.getTracks().forEach(t => t.stop()); liveMicStream = null; }
if (liveAudioCtx) { liveAudioCtx.close().catch(() => {}); liveAudioCtx = null; }
}
function resetLiveUI() {
startLiveBtn.classList.remove('hidden');
stopLiveBtn.classList.add('hidden');
stopLiveBtn.disabled = false;
liveStatusBadge.classList.add('hidden');
liveModelSelect.disabled = false;
}
// Immediate teardown: closes the socket, stops the mic, resets the UI.
// Idempotent so it's safe to call from onclose/onerror/done/timeout.
function teardownLiveStreaming() {
if (liveFinalizeTimer) { clearTimeout(liveFinalizeTimer); liveFinalizeTimer = null; }
if (liveWs) {
try { liveWs.close(); } catch (e) {}
liveWs = null;
}
stopMicCapture();
resetLiveUI();
}
// Graceful stop: stop sending audio but keep the socket open so the
// server can flush the trailing segment before we tear down.
function stopLiveStreaming() {
// Flush the partial resample buffer before stopMicCapture clears it,
// so the tail isn't dropped.
if (liveWs && liveWs.readyState === WebSocket.OPEN && liveResampleBuf.length > 0) {
try { liveWs.send(float32ToInt16(new Float32Array(liveResampleBuf.splice(0))).buffer); } catch (e) {}
}
stopMicCapture();
if (liveWs && liveWs.readyState === WebSocket.OPEN) {
try { liveWs.send(JSON.stringify({ type: 'stop' })); } catch (e) {}
liveStatusBadge.classList.add('hidden');
stopLiveBtn.disabled = true;
// Fallback in case the server never replies with 'done'.
liveFinalizeTimer = setTimeout(teardownLiveStreaming, 5000);
} else {
teardownLiveStreaming();
}
}
liveBtn.onclick = openLiveModal;
liveModal.onclick = (e) => { if (e.target === liveModal) closeLiveModal(); };
startLiveBtn.onclick = () => { isLiveStreaming = true; startLiveStreaming(); };
stopLiveBtn.onclick = stopLiveStreaming;
// --- Lifecycle ---
loadTasks();
setInterval(loadTasks, 5000);
setInterval(checkHealth, 10000);
</script>
</body>
</html>