Upload 20 files
Browse files- dist/assets/index-3TxP28Hy.css +2 -0
- dist/assets/index-XIg_GcZm.js +0 -0
- dist/index.html +17 -0
- eslint.config.js +23 -0
- index.html +14 -17
- package.json +36 -0
- src/App.tsx +29 -0
- src/components/LandingPage.tsx +162 -0
- src/components/LoadingPage.tsx +140 -0
- src/components/RunningPage.tsx +288 -0
- src/components/SharedUI.tsx +113 -0
- src/components/VoxtralContext.tsx +27 -0
- src/components/VoxtralProvider.tsx +384 -0
- src/constants.ts +13 -0
- src/index.css +138 -0
- src/main.tsx +10 -0
- tsconfig.app.json +28 -0
- tsconfig.json +7 -0
- tsconfig.node.json +26 -0
- vite.config.ts +8 -0
dist/assets/index-3TxP28Hy.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
|
| 2 |
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-green-500:oklch(72.3% .219 149.579);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-light:300;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.top-1\/2{top:50%}.top-6{top:calc(var(--spacing) * 6)}.right-6{right:calc(var(--spacing) * 6)}.right-full{right:100%}.z-10{z-index:10}.mx-5{margin-inline:calc(var(--spacing) * 5)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-4{margin-right:calc(var(--spacing) * 4)}.ml-1{margin-left:calc(var(--spacing) * 1)}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-20{height:calc(var(--spacing) * 20)}.h-28{height:calc(var(--spacing) * 28)}.h-full{height:100%}.min-h-\[40px\]{min-height:40px}.min-h-\[76px\]{min-height:76px}.min-h-\[min\(78vh\,720px\)\]{min-height:min(78vh,720px)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-20{width:calc(var(--spacing) * 20)}.w-28{width:calc(var(--spacing) * 28)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.flex-1{flex:1}.translate-x-2{--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-4{--tw-translate-y:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-full{--tw-translate-y:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-start{justify-content:flex-start}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 12) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[1\.6rem\]{border-radius:1.6rem}.rounded-\[2rem\]{border-radius:2rem}.rounded-full{border-radius:3.40282e38px}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-none{--tw-border-style:none;border-style:none}.bg-\[var\(--mistral-orange\)\]{background-color:var(--mistral-orange)}.bg-\[var\(--mistral-red\)\]{background-color:var(--mistral-red)}.bg-green-500{background-color:var(--color-green-500)}.bg-white{background-color:var(--color-white)}.bg-white\/18{background-color:#ffffff2e}@supports (color:color-mix(in lab, red, red)){.bg-white\/18{background-color:color-mix(in oklab, var(--color-white) 18%, transparent)}}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/84{background-color:#ffffffd6}@supports (color:color-mix(in lab, red, red)){.bg-white\/84{background-color:color-mix(in oklab, var(--color-white) 84%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.decoration-2{text-decoration-thickness:2px}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-100{opacity:1}.opacity-\[0\.03\]{opacity:.03}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-100{transition-delay:.1s}.delay-200{transition-delay:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.group-hover\:translate-x-0:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:translate-x-1:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:-translate-y-1:is(:where(.group):hover *){--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:translate-y-0:is(:where(.group):hover *){--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:-translate-y-1:hover{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:bg-black:hover{background-color:var(--color-black)}.hover\:text-black:hover{color:var(--color-black)}.hover\:decoration-\[3px\]:hover{text-decoration-thickness:3px}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}@media (width>=48rem){.md\:h-32{height:calc(var(--spacing) * 32)}.md\:w-32{width:calc(var(--spacing) * 32)}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:p-5{padding:calc(var(--spacing) * 5)}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-\[1\.4rem\]{font-size:1.4rem}}}:root{--mistral-red:#e10500;--mistral-orange-dark:#fa500f;--mistral-orange:#ff8205;--mistral-orange-light:#ffaf00;--mistral-yellow:#ffd800;--mistral-beige-light:#fffaeb;--mistral-beige-medium:#fff0c3;--mistral-beige-dark:#e9e2cb;--mistral-black:#000;--mistral-black-tinted:#1e1e1e;--mistral-white:#fff}body{background-color:var(--mistral-beige-light);color:var(--mistral-black);font-family:Arial,sans-serif}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}.cursor-blink{animation:1s step-end infinite blink}.history-scroll::-webkit-scrollbar{width:6px}.history-scroll::-webkit-scrollbar-track{background:var(--mistral-beige-light)}.history-scroll::-webkit-scrollbar-thumb{background-color:var(--mistral-beige-dark);border-radius:4px}.history-scroll::-webkit-scrollbar-thumb:hover{background-color:var(--mistral-orange)}@keyframes strip-move{0%{background-position:0 0}to{background-position:30px 0}}.progress-stripe{background-image:linear-gradient(45deg,#fff3 25%,#0000 25% 50%,#fff3 50% 75%,#0000 75%,#0000);background-size:30px 30px;animation:1s linear infinite strip-move}@keyframes fadeUp{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.animate-enter{animation:.5s cubic-bezier(.16,1,.3,1) forwards fadeUp}.delay-100{animation-delay:.1s}.delay-200{animation-delay:.2s}.delay-300{animation-delay:.3s}@keyframes meter-idle{0%,to{opacity:.55;transform:scaleY(.45)}50%{opacity:.8;transform:scaleY(.7)}}@keyframes meter-active{0%,to{transform:scaleY(.35)}25%{transform:scaleY(1)}50%{transform:scaleY(.55)}75%{transform:scaleY(.9)}}.voice-meter-bar{transform-origin:bottom;border-radius:999px;width:8px;height:40px;animation:1.6s ease-in-out infinite meter-idle}.voice-meter-bar.is-active{animation-name:meter-active;animation-duration:.9s}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}
|
dist/assets/index-XIg_GcZm.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link
|
| 6 |
+
rel="icon"
|
| 7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💬</text></svg>"
|
| 8 |
+
/>
|
| 9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 10 |
+
<title>Voxtral Realtime | WebGPU</title>
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-XIg_GcZm.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-3TxP28Hy.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from "@eslint/js";
|
| 2 |
+
import globals from "globals";
|
| 3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
| 4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
| 5 |
+
import tseslint from "typescript-eslint";
|
| 6 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(["dist"]),
|
| 10 |
+
{
|
| 11 |
+
files: ["**/*.{ts,tsx}"],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
]);
|
index.html
CHANGED
|
@@ -1,19 +1,16 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link
|
| 6 |
+
rel="icon"
|
| 7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💬</text></svg>"
|
| 8 |
+
/>
|
| 9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 10 |
+
<title>Voxtral Realtime | WebGPU</title>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
|
|
|
|
|
|
|
|
|
| 16 |
</html>
|
package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "voxtral-realtime-webgpu",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@huggingface/transformers": "4.0.0-next.7",
|
| 14 |
+
"@tailwindcss/vite": "^4.2.1",
|
| 15 |
+
"react": "^19.2.0",
|
| 16 |
+
"react-dom": "^19.2.0",
|
| 17 |
+
"tailwindcss": "^4.2.1"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@eslint/js": "^9.39.1",
|
| 21 |
+
"@types/node": "^24.10.1",
|
| 22 |
+
"@types/react": "^19.2.7",
|
| 23 |
+
"@types/react-dom": "^19.2.3",
|
| 24 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 25 |
+
"eslint": "^9.39.1",
|
| 26 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 27 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 28 |
+
"globals": "^16.5.0",
|
| 29 |
+
"typescript": "~5.9.3",
|
| 30 |
+
"typescript-eslint": "^8.48.0",
|
| 31 |
+
"vite": "^8.0.0-beta.13"
|
| 32 |
+
},
|
| 33 |
+
"overrides": {
|
| 34 |
+
"vite": "^8.0.0-beta.13"
|
| 35 |
+
}
|
| 36 |
+
}
|
src/App.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useVoxtral } from "./components/VoxtralContext";
|
| 2 |
+
import { VoxtralProvider } from "./components/VoxtralProvider";
|
| 3 |
+
import { LandingPage } from "./components/LandingPage";
|
| 4 |
+
import { LoadingPage } from "./components/LoadingPage";
|
| 5 |
+
import { RunningPage } from "./components/RunningPage";
|
| 6 |
+
|
| 7 |
+
const PAGE_BY_STATUS = {
|
| 8 |
+
idle: LandingPage,
|
| 9 |
+
loading: LoadingPage,
|
| 10 |
+
error: LoadingPage,
|
| 11 |
+
ready: RunningPage,
|
| 12 |
+
recording: RunningPage,
|
| 13 |
+
} as const;
|
| 14 |
+
|
| 15 |
+
const AppContent = () => {
|
| 16 |
+
const { status } = useVoxtral();
|
| 17 |
+
const Page = PAGE_BY_STATUS[status];
|
| 18 |
+
return <Page />;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const App = () => {
|
| 22 |
+
return (
|
| 23 |
+
<VoxtralProvider>
|
| 24 |
+
<AppContent />
|
| 25 |
+
</VoxtralProvider>
|
| 26 |
+
);
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export default App;
|
src/components/LandingPage.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useVoxtral } from "./VoxtralContext";
|
| 2 |
+
import { THEME } from "../constants";
|
| 3 |
+
import {
|
| 4 |
+
AppGridBackground,
|
| 5 |
+
MicrophoneIcon,
|
| 6 |
+
useMountedTransition,
|
| 7 |
+
} from "./SharedUI";
|
| 8 |
+
|
| 9 |
+
const FEATURES = [
|
| 10 |
+
{
|
| 11 |
+
step: "1",
|
| 12 |
+
title: "Load Model",
|
| 13 |
+
description:
|
| 14 |
+
"This demo downloads and caches Voxtral-Mini-4B, a realtime transcription model optimized for in-browser inference (~2.8 GB).",
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
step: "2",
|
| 18 |
+
title: "Private & Local",
|
| 19 |
+
description:
|
| 20 |
+
"Your audio is processed locally and never sent to a server. All inference runs on-device with Transformers.js and WebGPU.",
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
step: "3",
|
| 24 |
+
title: "Real-time Streaming",
|
| 25 |
+
description:
|
| 26 |
+
"The model is capable of sub-500ms latency with support for 13 languages and a native streaming architecture.",
|
| 27 |
+
},
|
| 28 |
+
] as const;
|
| 29 |
+
|
| 30 |
+
export const LandingPage = () => {
|
| 31 |
+
const { loadModel } = useVoxtral();
|
| 32 |
+
const mounted = useMountedTransition();
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<AppGridBackground
|
| 36 |
+
className="min-h-screen flex items-center justify-center p-6 overflow-y-auto"
|
| 37 |
+
style={{ color: THEME.textBlack }}
|
| 38 |
+
>
|
| 39 |
+
<div
|
| 40 |
+
className={`relative max-w-4xl w-full backdrop-blur-sm p-10 md:p-12 rounded-sm border shadow-2xl transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
|
| 41 |
+
style={{
|
| 42 |
+
backgroundColor: `${THEME.beigeLight}F2`,
|
| 43 |
+
borderColor: THEME.beigeDark,
|
| 44 |
+
}}
|
| 45 |
+
>
|
| 46 |
+
<div className="absolute top-6 right-6 group cursor-help z-10">
|
| 47 |
+
<span className="absolute right-full mr-4 top-1/2 -translate-y-1/2 whitespace-nowrap text-xs font-mono uppercase tracking-widest text-gray-500 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-x-2 group-hover:translate-x-0 pointer-events-none">
|
| 48 |
+
System Ready
|
| 49 |
+
</span>
|
| 50 |
+
<div className="relative flex h-3 w-3">
|
| 51 |
+
<span
|
| 52 |
+
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
| 53 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 54 |
+
/>
|
| 55 |
+
<span
|
| 56 |
+
className="relative inline-flex rounded-sm h-3 w-3"
|
| 57 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 58 |
+
/>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="space-y-12">
|
| 63 |
+
<div className="text-center space-y-4 animate-enter">
|
| 64 |
+
<div className="flex flex-col items-center justify-center space-y-5">
|
| 65 |
+
<div
|
| 66 |
+
className="w-20 h-20 rounded-full flex items-center justify-center shadow-lg"
|
| 67 |
+
style={{ backgroundColor: `${THEME.mistralOrange}1A` }}
|
| 68 |
+
>
|
| 69 |
+
<MicrophoneIcon
|
| 70 |
+
className="w-10 h-10"
|
| 71 |
+
strokeWidth={1.5}
|
| 72 |
+
style={{ color: THEME.mistralOrange }}
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<h1
|
| 77 |
+
className="text-6xl md:text-7xl font-semibold tracking-tighter"
|
| 78 |
+
style={{ color: THEME.textBlack }}
|
| 79 |
+
>
|
| 80 |
+
Voxtral Realtime
|
| 81 |
+
</h1>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<p className="text-xl md:text-2xl text-gray-600 max-w-2xl mx-auto font-light leading-relaxed">
|
| 85 |
+
Real-time speech transcription, entirely in your browser.
|
| 86 |
+
<br />
|
| 87 |
+
Powered by{" "}
|
| 88 |
+
<a
|
| 89 |
+
href="https://huggingface.co/onnx-community/Voxtral-Mini-4B-Realtime-2602-ONNX"
|
| 90 |
+
className="font-medium underline decoration-2 underline-offset-4 transition-all hover:decoration-[3px]"
|
| 91 |
+
style={{
|
| 92 |
+
color: THEME.mistralOrange,
|
| 93 |
+
textDecorationColor: THEME.mistralOrange,
|
| 94 |
+
}}
|
| 95 |
+
target="_blank"
|
| 96 |
+
rel="noopener noreferrer"
|
| 97 |
+
>
|
| 98 |
+
Voxtral-Mini-4B
|
| 99 |
+
</a>
|
| 100 |
+
</p>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div
|
| 104 |
+
className="grid grid-cols-1 md:grid-cols-3 gap-8 border-t border-b py-10 animate-enter delay-100"
|
| 105 |
+
style={{ borderColor: THEME.beigeDark }}
|
| 106 |
+
>
|
| 107 |
+
{FEATURES.map((feature) => (
|
| 108 |
+
<div key={feature.step} className="space-y-3 group">
|
| 109 |
+
<div
|
| 110 |
+
className="w-10 h-10 flex items-center justify-center text-white font-bold text-lg shadow-sm transition-transform duration-300 group-hover:-translate-y-1"
|
| 111 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 112 |
+
>
|
| 113 |
+
{feature.step}
|
| 114 |
+
</div>
|
| 115 |
+
<h4
|
| 116 |
+
className="font-semibold text-xl"
|
| 117 |
+
style={{ color: THEME.textBlack }}
|
| 118 |
+
>
|
| 119 |
+
{feature.title}
|
| 120 |
+
</h4>
|
| 121 |
+
<p className="text-gray-600 leading-relaxed">
|
| 122 |
+
{feature.description}
|
| 123 |
+
</p>
|
| 124 |
+
</div>
|
| 125 |
+
))}
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div className="flex flex-col items-center animate-enter delay-200">
|
| 129 |
+
<button
|
| 130 |
+
onClick={loadModel}
|
| 131 |
+
className="group relative px-8 py-5 text-white overflow-hidden transition-all hover:shadow-2xl hover:-translate-y-0.5 rounded-xl border-none cursor-pointer outline-none"
|
| 132 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 133 |
+
>
|
| 134 |
+
<div className="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-out" />
|
| 135 |
+
<span className="relative font-bold text-xl tracking-wide flex items-center gap-3">
|
| 136 |
+
START TRANSCRIPTION
|
| 137 |
+
<svg
|
| 138 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 139 |
+
fill="none"
|
| 140 |
+
viewBox="0 0 24 24"
|
| 141 |
+
strokeWidth={2.5}
|
| 142 |
+
stroke="currentColor"
|
| 143 |
+
className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1"
|
| 144 |
+
>
|
| 145 |
+
<path
|
| 146 |
+
strokeLinecap="round"
|
| 147 |
+
strokeLinejoin="round"
|
| 148 |
+
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
|
| 149 |
+
/>
|
| 150 |
+
</svg>
|
| 151 |
+
</span>
|
| 152 |
+
</button>
|
| 153 |
+
|
| 154 |
+
<p className="text-xs text-gray-400 mt-4">
|
| 155 |
+
Requires a browser that supports WebGPU (w/ shader-f16)
|
| 156 |
+
</p>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</AppGridBackground>
|
| 161 |
+
);
|
| 162 |
+
};
|
src/components/LoadingPage.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useVoxtral } from "./VoxtralContext";
|
| 2 |
+
import { THEME } from "../constants";
|
| 3 |
+
import {
|
| 4 |
+
AppGridBackground,
|
| 5 |
+
ErrorMessageBox,
|
| 6 |
+
useMountedTransition,
|
| 7 |
+
} from "./SharedUI";
|
| 8 |
+
|
| 9 |
+
export const LoadingPage = () => {
|
| 10 |
+
const { loadingProgress, loadingMessage, error } = useVoxtral();
|
| 11 |
+
const mounted = useMountedTransition();
|
| 12 |
+
const progressClamped = Math.min(100, Math.max(0, loadingProgress));
|
| 13 |
+
const isError = !!error;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<AppGridBackground className="min-h-screen flex items-center justify-center p-8">
|
| 17 |
+
<div
|
| 18 |
+
className={`max-w-md w-full backdrop-blur-sm rounded-sm border shadow-xl transition-all duration-700 transform ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
|
| 19 |
+
style={{
|
| 20 |
+
backgroundColor: `${THEME.beigeLight}F2`,
|
| 21 |
+
borderColor: THEME.beigeDark,
|
| 22 |
+
}}
|
| 23 |
+
>
|
| 24 |
+
<div
|
| 25 |
+
className={`h-1 w-full transition-colors duration-300 ${isError ? "bg-[var(--mistral-red)]" : "bg-[var(--mistral-orange)]"}`}
|
| 26 |
+
/>
|
| 27 |
+
|
| 28 |
+
<div className="p-8 space-y-8">
|
| 29 |
+
<div className="flex justify-center">
|
| 30 |
+
{isError ? (
|
| 31 |
+
<div
|
| 32 |
+
className="w-20 h-20 rounded-full flex items-center justify-center border"
|
| 33 |
+
style={{
|
| 34 |
+
backgroundColor: `${THEME.errorRed}1A`,
|
| 35 |
+
borderColor: `${THEME.errorRed}33`,
|
| 36 |
+
}}
|
| 37 |
+
>
|
| 38 |
+
<svg
|
| 39 |
+
className="w-10 h-10"
|
| 40 |
+
style={{ color: THEME.errorRed }}
|
| 41 |
+
fill="none"
|
| 42 |
+
viewBox="0 0 24 24"
|
| 43 |
+
stroke="currentColor"
|
| 44 |
+
strokeWidth={2}
|
| 45 |
+
>
|
| 46 |
+
<path
|
| 47 |
+
strokeLinecap="round"
|
| 48 |
+
strokeLinejoin="round"
|
| 49 |
+
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
| 50 |
+
/>
|
| 51 |
+
</svg>
|
| 52 |
+
</div>
|
| 53 |
+
) : (
|
| 54 |
+
<div className="relative">
|
| 55 |
+
<div
|
| 56 |
+
className="w-20 h-20 border-4 rounded-full animate-spin"
|
| 57 |
+
style={{
|
| 58 |
+
borderColor: THEME.beigeDark,
|
| 59 |
+
borderTopColor: THEME.mistralOrange,
|
| 60 |
+
}}
|
| 61 |
+
/>
|
| 62 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 63 |
+
<div
|
| 64 |
+
className="w-2 h-2 rounded-full animate-pulse"
|
| 65 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 66 |
+
/>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="text-center space-y-2">
|
| 73 |
+
<h2
|
| 74 |
+
className="text-2xl font-bold tracking-tight"
|
| 75 |
+
style={{ color: THEME.textBlack }}
|
| 76 |
+
>
|
| 77 |
+
{isError ? "Initialization Failed" : "Loading Model"}
|
| 78 |
+
</h2>
|
| 79 |
+
<p className="text-sm text-gray-500 font-mono uppercase tracking-widest">
|
| 80 |
+
{isError ? "Voxtral-Mini-4B-Realtime" : loadingMessage}
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{!isError && (
|
| 85 |
+
<div className="space-y-4">
|
| 86 |
+
<div className="flex justify-between text-xs font-mono font-bold text-gray-500">
|
| 87 |
+
<span>PROGRESS</span>
|
| 88 |
+
<span>{Math.round(progressClamped)}%</span>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div
|
| 92 |
+
className="w-full rounded-full h-4 overflow-hidden border"
|
| 93 |
+
style={{
|
| 94 |
+
backgroundColor: `${THEME.beigeDark}80`,
|
| 95 |
+
borderColor: THEME.beigeDark,
|
| 96 |
+
}}
|
| 97 |
+
>
|
| 98 |
+
<div
|
| 99 |
+
className="h-full progress-stripe transition-all duration-500 ease-out"
|
| 100 |
+
style={{
|
| 101 |
+
width: `${progressClamped}%`,
|
| 102 |
+
backgroundColor: THEME.mistralOrange,
|
| 103 |
+
}}
|
| 104 |
+
/>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div
|
| 108 |
+
className="bg-white border p-3 rounded-sm"
|
| 109 |
+
style={{ borderColor: THEME.beigeDark }}
|
| 110 |
+
>
|
| 111 |
+
<div className="flex items-center space-x-2">
|
| 112 |
+
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
| 113 |
+
<p className="font-mono text-xs text-gray-600 truncate">
|
| 114 |
+
{`> ${loadingMessage}`}
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
)}
|
| 120 |
+
|
| 121 |
+
{isError && (
|
| 122 |
+
<div className="space-y-4">
|
| 123 |
+
<ErrorMessageBox
|
| 124 |
+
className="border p-4 rounded text-left"
|
| 125 |
+
message={error}
|
| 126 |
+
/>
|
| 127 |
+
<button
|
| 128 |
+
onClick={() => window.location.reload()}
|
| 129 |
+
className="w-full py-3 text-white font-bold transition-colors shadow-lg hover:bg-black cursor-pointer"
|
| 130 |
+
style={{ backgroundColor: THEME.textBlack }}
|
| 131 |
+
>
|
| 132 |
+
RELOAD APPLICATION
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</AppGridBackground>
|
| 139 |
+
);
|
| 140 |
+
};
|
src/components/RunningPage.tsx
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useVoxtral } from "./VoxtralContext";
|
| 2 |
+
import { THEME } from "../constants";
|
| 3 |
+
import {
|
| 4 |
+
AppGridBackground,
|
| 5 |
+
ErrorMessageBox,
|
| 6 |
+
MicrophoneIcon,
|
| 7 |
+
VoiceMeter,
|
| 8 |
+
} from "./SharedUI";
|
| 9 |
+
|
| 10 |
+
export const RunningPage = () => {
|
| 11 |
+
const {
|
| 12 |
+
status,
|
| 13 |
+
transcript,
|
| 14 |
+
startRecording,
|
| 15 |
+
stopRecording,
|
| 16 |
+
resetSession,
|
| 17 |
+
error,
|
| 18 |
+
} = useVoxtral();
|
| 19 |
+
const isRecording = status === "recording";
|
| 20 |
+
const transcriptText = transcript.trimStart();
|
| 21 |
+
const hasTranscript = transcriptText.length > 0;
|
| 22 |
+
|
| 23 |
+
const statusConfig = error
|
| 24 |
+
? {
|
| 25 |
+
bg: `${THEME.errorRed}0D`,
|
| 26 |
+
border: THEME.errorRed,
|
| 27 |
+
dot: THEME.errorRed,
|
| 28 |
+
label: "SYSTEM ERROR",
|
| 29 |
+
}
|
| 30 |
+
: isRecording
|
| 31 |
+
? {
|
| 32 |
+
bg: `${THEME.mistralOrange}0D`,
|
| 33 |
+
border: THEME.mistralOrange,
|
| 34 |
+
dot: THEME.mistralOrange,
|
| 35 |
+
label: "LIVE TRANSCRIPTION",
|
| 36 |
+
}
|
| 37 |
+
: {
|
| 38 |
+
bg: "transparent",
|
| 39 |
+
border: THEME.beigeDark,
|
| 40 |
+
dot: "#9CA3AF",
|
| 41 |
+
label: "STANDBY",
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const controlLabel = isRecording ? "Stop" : "Start";
|
| 45 |
+
const helperText = error
|
| 46 |
+
? "Resolve the error and start again."
|
| 47 |
+
: isRecording
|
| 48 |
+
? "Listening live. Tap stop when you're done."
|
| 49 |
+
: "Tap the microphone to begin.";
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<AppGridBackground
|
| 53 |
+
className="min-h-screen flex items-center justify-center px-4 py-4 md:px-6 md:py-6"
|
| 54 |
+
style={{ color: THEME.textBlack }}
|
| 55 |
+
>
|
| 56 |
+
<div
|
| 57 |
+
className="w-full max-w-4xl rounded-[2rem] border bg-white/84 p-4 shadow-2xl backdrop-blur-sm md:p-5"
|
| 58 |
+
style={{ borderColor: error ? `${THEME.errorRed}55` : THEME.beigeDark }}
|
| 59 |
+
>
|
| 60 |
+
<section
|
| 61 |
+
className="flex min-h-[min(78vh,720px)] flex-col overflow-hidden rounded-[1.6rem] border bg-white/90"
|
| 62 |
+
style={{
|
| 63 |
+
borderColor: error ? `${THEME.errorRed}55` : THEME.beigeDark,
|
| 64 |
+
}}
|
| 65 |
+
>
|
| 66 |
+
<div
|
| 67 |
+
className="flex min-h-[76px] items-center justify-between border-b px-5 py-4"
|
| 68 |
+
style={{ borderColor: THEME.beigeDark }}
|
| 69 |
+
>
|
| 70 |
+
<div className="flex min-h-[40px] flex-col justify-center">
|
| 71 |
+
<p className="text-xs font-mono uppercase tracking-[0.3em] text-gray-500">
|
| 72 |
+
Voxtral Realtime
|
| 73 |
+
</p>
|
| 74 |
+
<h1 className="mt-1 text-xl font-semibold leading-none tracking-tight md:text-2xl">
|
| 75 |
+
Real-time transcription
|
| 76 |
+
</h1>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div
|
| 80 |
+
className="flex h-8 items-center gap-2 rounded-full border px-3 py-1.5"
|
| 81 |
+
style={{
|
| 82 |
+
backgroundColor: statusConfig.bg,
|
| 83 |
+
borderColor: `${statusConfig.border}4D`,
|
| 84 |
+
}}
|
| 85 |
+
>
|
| 86 |
+
<span className="relative flex h-2.5 w-2.5">
|
| 87 |
+
{isRecording && (
|
| 88 |
+
<span
|
| 89 |
+
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
| 90 |
+
style={{ backgroundColor: statusConfig.dot }}
|
| 91 |
+
/>
|
| 92 |
+
)}
|
| 93 |
+
<span
|
| 94 |
+
className="relative inline-flex h-2.5 w-2.5 rounded-full"
|
| 95 |
+
style={{ backgroundColor: statusConfig.dot }}
|
| 96 |
+
/>
|
| 97 |
+
</span>
|
| 98 |
+
<span
|
| 99 |
+
className="text-[10px] font-bold tracking-[0.2em]"
|
| 100 |
+
style={{ color: statusConfig.dot }}
|
| 101 |
+
>
|
| 102 |
+
{statusConfig.label}
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div className="flex flex-1 flex-col">
|
| 108 |
+
{error && (
|
| 109 |
+
<ErrorMessageBox
|
| 110 |
+
className="mx-5 mt-5 rounded-2xl border px-4 py-3"
|
| 111 |
+
message={error}
|
| 112 |
+
/>
|
| 113 |
+
)}
|
| 114 |
+
|
| 115 |
+
<div className="relative flex-1 overflow-hidden">
|
| 116 |
+
<div
|
| 117 |
+
className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
| 118 |
+
style={{
|
| 119 |
+
backgroundImage: `linear-gradient(${THEME.black} 1px, transparent 1px), linear-gradient(90deg, ${THEME.black} 1px, transparent 1px)`,
|
| 120 |
+
backgroundSize: "20px 20px",
|
| 121 |
+
}}
|
| 122 |
+
/>
|
| 123 |
+
|
| 124 |
+
<div
|
| 125 |
+
className={`relative z-10 flex h-full flex-col px-5 py-5 md:px-6 md:py-6 ${isRecording ? "justify-start" : "justify-center"}`}
|
| 126 |
+
>
|
| 127 |
+
{!isRecording && !transcriptText ? (
|
| 128 |
+
<div className="mx-auto flex max-w-md flex-col items-center text-center">
|
| 129 |
+
<div className="relative">
|
| 130 |
+
<button
|
| 131 |
+
onClick={startRecording}
|
| 132 |
+
className="relative flex h-28 w-28 cursor-pointer items-center justify-center rounded-full border-none outline-none transition-all duration-300 hover:-translate-y-1 active:scale-95 md:h-32 md:w-32"
|
| 133 |
+
style={{
|
| 134 |
+
background: `linear-gradient(135deg, ${THEME.mistralOrange}, ${THEME.mistralOrangeLight})`,
|
| 135 |
+
boxShadow: `0 22px 44px ${THEME.mistralOrange}30`,
|
| 136 |
+
}}
|
| 137 |
+
aria-label={controlLabel}
|
| 138 |
+
>
|
| 139 |
+
<span
|
| 140 |
+
className="absolute inset-0 rounded-full opacity-25"
|
| 141 |
+
style={{
|
| 142 |
+
boxShadow: `0 0 0 14px ${THEME.mistralOrange}20`,
|
| 143 |
+
}}
|
| 144 |
+
/>
|
| 145 |
+
<MicrophoneIcon className="relative h-12 w-12 text-white" />
|
| 146 |
+
</button>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div className="mt-8 space-y-3">
|
| 150 |
+
<p className="text-2xl font-semibold tracking-tight md:text-3xl">
|
| 151 |
+
Start transcription
|
| 152 |
+
</p>
|
| 153 |
+
<p className="text-sm text-gray-500 md:text-base">
|
| 154 |
+
{helperText}
|
| 155 |
+
</p>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div
|
| 159 |
+
className="mt-8 w-full rounded-2xl border px-4 py-4"
|
| 160 |
+
style={{
|
| 161 |
+
backgroundColor: `${THEME.beigeLight}CC`,
|
| 162 |
+
borderColor: THEME.beigeDark,
|
| 163 |
+
}}
|
| 164 |
+
>
|
| 165 |
+
<VoiceMeter color={THEME.beigeDark} />
|
| 166 |
+
<p className="mt-3 text-xs font-mono uppercase tracking-[0.18em] text-gray-500">
|
| 167 |
+
Ready when you are
|
| 168 |
+
</p>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
) : (
|
| 172 |
+
<>
|
| 173 |
+
<div className="flex items-center justify-between gap-3 pb-4">
|
| 174 |
+
<div className="flex items-center space-x-2">
|
| 175 |
+
<svg
|
| 176 |
+
className="w-4 h-4 text-gray-400"
|
| 177 |
+
fill="none"
|
| 178 |
+
viewBox="0 0 24 24"
|
| 179 |
+
stroke="currentColor"
|
| 180 |
+
>
|
| 181 |
+
<path
|
| 182 |
+
strokeLinecap="round"
|
| 183 |
+
strokeLinejoin="round"
|
| 184 |
+
strokeWidth={2}
|
| 185 |
+
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
| 186 |
+
/>
|
| 187 |
+
</svg>
|
| 188 |
+
<span className="text-xs font-bold text-gray-500 uppercase tracking-widest">
|
| 189 |
+
Transcript
|
| 190 |
+
</span>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div
|
| 194 |
+
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-[0.2em]"
|
| 195 |
+
style={{
|
| 196 |
+
backgroundColor: `${THEME.beigeLight}`,
|
| 197 |
+
color: THEME.textBlack,
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
{transcriptText ? "Live output" : "Waiting for speech"}
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div className="history-scroll flex-1 overflow-y-auto">
|
| 205 |
+
{transcriptText ? (
|
| 206 |
+
<p
|
| 207 |
+
className="max-w-none text-lg font-mono leading-relaxed break-words whitespace-pre-wrap md:text-[1.4rem]"
|
| 208 |
+
style={{ color: THEME.textBlack }}
|
| 209 |
+
>
|
| 210 |
+
<span className="mr-1">{transcriptText}</span>
|
| 211 |
+
{isRecording && (
|
| 212 |
+
<span
|
| 213 |
+
className="inline-block w-2.5 h-5 align-middle cursor-blink ml-1"
|
| 214 |
+
style={{ backgroundColor: THEME.mistralOrange }}
|
| 215 |
+
/>
|
| 216 |
+
)}
|
| 217 |
+
</p>
|
| 218 |
+
) : (
|
| 219 |
+
<div className="flex h-full flex-col items-center justify-center space-y-4 py-12 text-center opacity-70">
|
| 220 |
+
<VoiceMeter color={THEME.mistralOrange} active />
|
| 221 |
+
<div className="space-y-1">
|
| 222 |
+
<p className="text-sm font-mono italic text-gray-500">
|
| 223 |
+
Listening for speech...
|
| 224 |
+
</p>
|
| 225 |
+
<p className="text-xs font-mono uppercase tracking-[0.18em] text-gray-400">
|
| 226 |
+
Local processing · realtime stream
|
| 227 |
+
</p>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
</>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{(isRecording || hasTranscript) && (
|
| 238 |
+
<div className="flex justify-center gap-3 px-5 pb-5 pt-2">
|
| 239 |
+
{!isRecording && hasTranscript && (
|
| 240 |
+
<button
|
| 241 |
+
onClick={resetSession}
|
| 242 |
+
className="rounded-full border px-4 py-2 text-sm font-semibold text-gray-600 transition-all duration-300 hover:-translate-y-0.5 hover:text-black active:scale-95"
|
| 243 |
+
style={{
|
| 244 |
+
borderColor: THEME.beigeDark,
|
| 245 |
+
backgroundColor: `${THEME.beigeLight}`,
|
| 246 |
+
}}
|
| 247 |
+
>
|
| 248 |
+
Reset
|
| 249 |
+
</button>
|
| 250 |
+
)}
|
| 251 |
+
|
| 252 |
+
{isRecording && (
|
| 253 |
+
<button
|
| 254 |
+
onClick={stopRecording}
|
| 255 |
+
className="group inline-flex items-center gap-3 rounded-full px-4 py-2 text-sm font-semibold text-white transition-all duration-300 hover:-translate-y-0.5 active:scale-95"
|
| 256 |
+
style={{
|
| 257 |
+
background: `linear-gradient(135deg, ${THEME.mistralOrangeDark}, ${THEME.mistralOrange})`,
|
| 258 |
+
boxShadow: `0 12px 28px ${THEME.mistralOrange}30`,
|
| 259 |
+
}}
|
| 260 |
+
>
|
| 261 |
+
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/18">
|
| 262 |
+
<svg
|
| 263 |
+
className="h-4 w-4"
|
| 264 |
+
fill="currentColor"
|
| 265 |
+
viewBox="0 0 24 24"
|
| 266 |
+
>
|
| 267 |
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
| 268 |
+
</svg>
|
| 269 |
+
</span>
|
| 270 |
+
Stop
|
| 271 |
+
</button>
|
| 272 |
+
)}
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<div
|
| 278 |
+
className="flex items-center justify-between border-t bg-white/70 px-5 py-3 text-[10px] font-mono text-gray-400"
|
| 279 |
+
style={{ borderColor: THEME.beigeDark }}
|
| 280 |
+
>
|
| 281 |
+
<span>{isRecording ? "stream: live" : "stream: ready"}</span>
|
| 282 |
+
<span>{isRecording ? "mic: active" : "mic: idle"}</span>
|
| 283 |
+
</div>
|
| 284 |
+
</section>
|
| 285 |
+
</div>
|
| 286 |
+
</AppGridBackground>
|
| 287 |
+
);
|
| 288 |
+
};
|
src/components/SharedUI.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
| 2 |
+
import { THEME } from "../constants";
|
| 3 |
+
|
| 4 |
+
const ACTIVITY_BARS = [0, 1, 2, 3, 4];
|
| 5 |
+
|
| 6 |
+
const GRID_BACKGROUND_STYLE: CSSProperties = {
|
| 7 |
+
backgroundColor: THEME.beigeLight,
|
| 8 |
+
backgroundImage: `
|
| 9 |
+
linear-gradient(${THEME.beigeDark} 1px, transparent 1px),
|
| 10 |
+
linear-gradient(90deg, ${THEME.beigeDark} 1px, transparent 1px)
|
| 11 |
+
`,
|
| 12 |
+
backgroundSize: "40px 40px",
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const AppGridBackground = ({
|
| 16 |
+
children,
|
| 17 |
+
className,
|
| 18 |
+
style,
|
| 19 |
+
}: {
|
| 20 |
+
children: ReactNode;
|
| 21 |
+
className: string;
|
| 22 |
+
style?: CSSProperties;
|
| 23 |
+
}) => (
|
| 24 |
+
<div className={className} style={{ ...GRID_BACKGROUND_STYLE, ...style }}>
|
| 25 |
+
{children}
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
export const MicrophoneIcon = ({
|
| 30 |
+
className,
|
| 31 |
+
strokeWidth = 2,
|
| 32 |
+
style,
|
| 33 |
+
}: {
|
| 34 |
+
className: string;
|
| 35 |
+
strokeWidth?: number;
|
| 36 |
+
style?: CSSProperties;
|
| 37 |
+
}) => (
|
| 38 |
+
<svg
|
| 39 |
+
className={className}
|
| 40 |
+
style={style}
|
| 41 |
+
fill="none"
|
| 42 |
+
viewBox="0 0 24 24"
|
| 43 |
+
stroke="currentColor"
|
| 44 |
+
strokeWidth={strokeWidth}
|
| 45 |
+
>
|
| 46 |
+
<path
|
| 47 |
+
strokeLinecap="round"
|
| 48 |
+
strokeLinejoin="round"
|
| 49 |
+
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
|
| 50 |
+
/>
|
| 51 |
+
<path
|
| 52 |
+
strokeLinecap="round"
|
| 53 |
+
strokeLinejoin="round"
|
| 54 |
+
d="M19 10v2a7 7 0 01-14 0v-2"
|
| 55 |
+
/>
|
| 56 |
+
<line x1="12" y1="19" x2="12" y2="23" />
|
| 57 |
+
<line x1="8" y1="23" x2="16" y2="23" />
|
| 58 |
+
</svg>
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
export const VoiceMeter = ({
|
| 62 |
+
color,
|
| 63 |
+
active = false,
|
| 64 |
+
}: {
|
| 65 |
+
color: string;
|
| 66 |
+
active?: boolean;
|
| 67 |
+
}) => (
|
| 68 |
+
<div className="flex items-end justify-center gap-1.5">
|
| 69 |
+
{ACTIVITY_BARS.map((bar) => (
|
| 70 |
+
<span
|
| 71 |
+
key={bar}
|
| 72 |
+
className={`voice-meter-bar${active ? " is-active" : ""}`}
|
| 73 |
+
style={{
|
| 74 |
+
backgroundColor: color,
|
| 75 |
+
animationDelay: `${bar * 120}ms`,
|
| 76 |
+
}}
|
| 77 |
+
/>
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
export const useMountedTransition = () => {
|
| 83 |
+
const [mounted, setMounted] = useState(false);
|
| 84 |
+
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
setMounted(true);
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
+
return mounted;
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
export const ErrorMessageBox = ({
|
| 93 |
+
message,
|
| 94 |
+
className,
|
| 95 |
+
}: {
|
| 96 |
+
message: string;
|
| 97 |
+
className?: string;
|
| 98 |
+
}) => (
|
| 99 |
+
<div
|
| 100 |
+
className={className}
|
| 101 |
+
style={{
|
| 102 |
+
backgroundColor: `${THEME.errorRed}0D`,
|
| 103 |
+
borderColor: `${THEME.errorRed}33`,
|
| 104 |
+
}}
|
| 105 |
+
>
|
| 106 |
+
<p
|
| 107 |
+
className="font-mono text-xs break-words"
|
| 108 |
+
style={{ color: THEME.errorRed }}
|
| 109 |
+
>
|
| 110 |
+
{`> Error: ${message}`}
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
src/components/VoxtralContext.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext } from "react";
|
| 2 |
+
|
| 3 |
+
export type AppStatus = "idle" | "loading" | "ready" | "recording" | "error";
|
| 4 |
+
|
| 5 |
+
export interface VoxtralContextType {
|
| 6 |
+
status: AppStatus;
|
| 7 |
+
loadingProgress: number;
|
| 8 |
+
loadingMessage: string;
|
| 9 |
+
transcript: string;
|
| 10 |
+
error: string | null;
|
| 11 |
+
loadModel: () => void;
|
| 12 |
+
resetSession: () => void;
|
| 13 |
+
startRecording: () => void;
|
| 14 |
+
stopRecording: () => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const VoxtralContext = createContext<VoxtralContextType | undefined>(
|
| 18 |
+
undefined,
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
export const useVoxtral = () => {
|
| 22 |
+
const context = useContext(VoxtralContext);
|
| 23 |
+
if (context === undefined) {
|
| 24 |
+
throw new Error("useVoxtral must be used within a VoxtralProvider");
|
| 25 |
+
}
|
| 26 |
+
return context;
|
| 27 |
+
};
|
src/components/VoxtralProvider.tsx
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useCallback } from "react";
|
| 2 |
+
import {
|
| 3 |
+
BaseStreamer,
|
| 4 |
+
VoxtralRealtimeForConditionalGeneration,
|
| 5 |
+
VoxtralRealtimeProcessor,
|
| 6 |
+
type ProgressInfo,
|
| 7 |
+
} from "@huggingface/transformers";
|
| 8 |
+
import { VoxtralContext, type AppStatus } from "./VoxtralContext";
|
| 9 |
+
|
| 10 |
+
import type { ReactNode } from "react";
|
| 11 |
+
|
| 12 |
+
const MODEL_ID = "onnx-community/Voxtral-Mini-4B-Realtime-2602-ONNX";
|
| 13 |
+
const SAMPLE_RATE = 16000;
|
| 14 |
+
const MODEL_FILE_COUNT = 3;
|
| 15 |
+
const CAPTURE_PROCESSOR_NAME = "capture-processor";
|
| 16 |
+
const CAPTURE_WORKLET_SOURCE = `
|
| 17 |
+
class CaptureProcessor extends AudioWorkletProcessor {
|
| 18 |
+
process(inputs) {
|
| 19 |
+
const input = inputs[0];
|
| 20 |
+
if (input.length > 0 && input[0].length > 0) {
|
| 21 |
+
this.port.postMessage(input[0]);
|
| 22 |
+
}
|
| 23 |
+
return true;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
registerProcessor("capture-processor", CaptureProcessor);
|
| 27 |
+
`;
|
| 28 |
+
|
| 29 |
+
function getErrorMessage(error: unknown, fallback: string) {
|
| 30 |
+
return error instanceof Error ? error.message : fallback;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export const VoxtralProvider = ({ children }: { children: ReactNode }) => {
|
| 34 |
+
const [status, setStatus] = useState<AppStatus>("idle");
|
| 35 |
+
const [loadingProgress, setLoadingProgress] = useState(0);
|
| 36 |
+
const [loadingMessage, setLoadingMessage] = useState("Ready to load model");
|
| 37 |
+
const [transcript, setTranscript] = useState("");
|
| 38 |
+
const [error, setError] = useState<string | null>(null);
|
| 39 |
+
|
| 40 |
+
const modelRef = useRef<any>(null);
|
| 41 |
+
const processorRef = useRef<any>(null);
|
| 42 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 43 |
+
const mediaStreamRef = useRef<MediaStream | null>(null);
|
| 44 |
+
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
|
| 45 |
+
const audioBufferRef = useRef<Float32Array>(new Float32Array(0));
|
| 46 |
+
const isRecordingRef = useRef(false);
|
| 47 |
+
const stopRequestedRef = useRef(false);
|
| 48 |
+
|
| 49 |
+
const cleanupAudio = useCallback(() => {
|
| 50 |
+
isRecordingRef.current = false;
|
| 51 |
+
|
| 52 |
+
workletNodeRef.current?.disconnect();
|
| 53 |
+
workletNodeRef.current = null;
|
| 54 |
+
|
| 55 |
+
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
|
| 56 |
+
mediaStreamRef.current = null;
|
| 57 |
+
|
| 58 |
+
void audioContextRef.current?.close();
|
| 59 |
+
audioContextRef.current = null;
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
const appendAudio = useCallback((newSamples: Float32Array) => {
|
| 63 |
+
if (newSamples.length === 0) {
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const previousSamples = audioBufferRef.current;
|
| 68 |
+
const mergedSamples = new Float32Array(
|
| 69 |
+
previousSamples.length + newSamples.length,
|
| 70 |
+
);
|
| 71 |
+
mergedSamples.set(previousSamples);
|
| 72 |
+
mergedSamples.set(newSamples, previousSamples.length);
|
| 73 |
+
audioBufferRef.current = mergedSamples;
|
| 74 |
+
}, []);
|
| 75 |
+
|
| 76 |
+
const loadModel = useCallback(async () => {
|
| 77 |
+
if (status === "loading" || status === "ready") {
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
setStatus("loading");
|
| 82 |
+
setLoadingProgress(0);
|
| 83 |
+
setLoadingMessage("Preparing model download...");
|
| 84 |
+
setError(null);
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
const progressMap = new Map<string, number>();
|
| 88 |
+
const progressCallback = (info: ProgressInfo) => {
|
| 89 |
+
if (
|
| 90 |
+
info.status !== "progress" ||
|
| 91 |
+
!info.file.endsWith(".onnx_data") ||
|
| 92 |
+
info.total === 0
|
| 93 |
+
) {
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
progressMap.set(info.file, info.loaded / info.total);
|
| 98 |
+
|
| 99 |
+
const totalProgress = Array.from(progressMap.values()).reduce(
|
| 100 |
+
(sum, value) => sum + value,
|
| 101 |
+
0,
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
setLoadingMessage("Downloading model...");
|
| 105 |
+
setLoadingProgress(
|
| 106 |
+
Math.min((totalProgress / MODEL_FILE_COUNT) * 100, 100),
|
| 107 |
+
);
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const model =
|
| 111 |
+
await VoxtralRealtimeForConditionalGeneration.from_pretrained(
|
| 112 |
+
MODEL_ID,
|
| 113 |
+
{
|
| 114 |
+
dtype: {
|
| 115 |
+
audio_encoder: "q4f16",
|
| 116 |
+
embed_tokens: "q4f16",
|
| 117 |
+
decoder_model_merged: "q4f16",
|
| 118 |
+
},
|
| 119 |
+
device: "webgpu",
|
| 120 |
+
progress_callback: progressCallback,
|
| 121 |
+
},
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
setLoadingMessage("Loading processor...");
|
| 125 |
+
const processor =
|
| 126 |
+
await VoxtralRealtimeProcessor.from_pretrained(MODEL_ID);
|
| 127 |
+
|
| 128 |
+
modelRef.current = model;
|
| 129 |
+
processorRef.current = processor;
|
| 130 |
+
setLoadingProgress(100);
|
| 131 |
+
setLoadingMessage("Model ready");
|
| 132 |
+
setStatus("ready");
|
| 133 |
+
} catch (error) {
|
| 134 |
+
console.error("Failed to load model:", error);
|
| 135 |
+
setError(getErrorMessage(error, "Failed to load model"));
|
| 136 |
+
setLoadingMessage("Initialization failed");
|
| 137 |
+
setStatus("error");
|
| 138 |
+
}
|
| 139 |
+
}, [status]);
|
| 140 |
+
|
| 141 |
+
const runTranscription = useCallback(
|
| 142 |
+
async (model: any, processor: any) => {
|
| 143 |
+
const runtimeProcessor = processor as any;
|
| 144 |
+
const audio = () => audioBufferRef.current;
|
| 145 |
+
const numSamplesFirst = runtimeProcessor.num_samples_first_audio_chunk;
|
| 146 |
+
await waitUntil(
|
| 147 |
+
() => audio().length >= numSamplesFirst || stopRequestedRef.current,
|
| 148 |
+
);
|
| 149 |
+
|
| 150 |
+
if (stopRequestedRef.current) {
|
| 151 |
+
cleanupAudio();
|
| 152 |
+
setStatus("ready");
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const firstChunkInputs = await runtimeProcessor(
|
| 157 |
+
audio().subarray(0, numSamplesFirst),
|
| 158 |
+
{ is_streaming: true, is_first_audio_chunk: true },
|
| 159 |
+
);
|
| 160 |
+
|
| 161 |
+
const featureExtractor = runtimeProcessor.feature_extractor;
|
| 162 |
+
const { hop_length, n_fft } = featureExtractor.config;
|
| 163 |
+
const winHalf = Math.floor(n_fft / 2);
|
| 164 |
+
const samplesPerTok = runtimeProcessor.audio_length_per_tok * hop_length;
|
| 165 |
+
|
| 166 |
+
async function* inputFeaturesGenerator() {
|
| 167 |
+
yield firstChunkInputs.input_features;
|
| 168 |
+
|
| 169 |
+
let melFrameIdx = runtimeProcessor.num_mel_frames_first_audio_chunk;
|
| 170 |
+
let startIdx = melFrameIdx * hop_length - winHalf;
|
| 171 |
+
|
| 172 |
+
while (!stopRequestedRef.current) {
|
| 173 |
+
const endNeeded =
|
| 174 |
+
startIdx + runtimeProcessor.num_samples_per_audio_chunk;
|
| 175 |
+
|
| 176 |
+
await waitUntil(
|
| 177 |
+
() => audio().length >= endNeeded || stopRequestedRef.current,
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
if (stopRequestedRef.current) break;
|
| 181 |
+
|
| 182 |
+
const availableSamples = audio().length;
|
| 183 |
+
let batchEndSample = endNeeded;
|
| 184 |
+
while (batchEndSample + samplesPerTok <= availableSamples) {
|
| 185 |
+
batchEndSample += samplesPerTok;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const chunkInputs = await runtimeProcessor(
|
| 189 |
+
audio().slice(startIdx, batchEndSample),
|
| 190 |
+
{ is_streaming: true, is_first_audio_chunk: false },
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
yield chunkInputs.input_features;
|
| 194 |
+
|
| 195 |
+
melFrameIdx += chunkInputs.input_features.dims[2];
|
| 196 |
+
startIdx = melFrameIdx * hop_length - winHalf;
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const tokenizer = runtimeProcessor.tokenizer;
|
| 201 |
+
const specialIds = new Set(tokenizer.all_special_ids.map(BigInt));
|
| 202 |
+
let tokenCache: bigint[] = [];
|
| 203 |
+
let printLen = 0;
|
| 204 |
+
let isPrompt = true;
|
| 205 |
+
|
| 206 |
+
const flushDecodedText = () => {
|
| 207 |
+
if (tokenCache.length === 0) {
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const text = tokenizer.decode(tokenCache, {
|
| 212 |
+
skip_special_tokens: true,
|
| 213 |
+
});
|
| 214 |
+
const printableText = text.slice(printLen);
|
| 215 |
+
printLen = text.length;
|
| 216 |
+
|
| 217 |
+
if (printableText.length > 0) {
|
| 218 |
+
setTranscript((prev) => prev + printableText);
|
| 219 |
+
}
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
const streamer = new (class extends BaseStreamer {
|
| 223 |
+
put(value: bigint[][]) {
|
| 224 |
+
if (stopRequestedRef.current) {
|
| 225 |
+
return;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
if (isPrompt) {
|
| 229 |
+
isPrompt = false;
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const tokens = value[0];
|
| 234 |
+
|
| 235 |
+
if (tokens.length === 1 && specialIds.has(tokens[0])) {
|
| 236 |
+
return;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
tokenCache = tokenCache.concat(tokens);
|
| 240 |
+
flushDecodedText();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
end() {
|
| 244 |
+
if (stopRequestedRef.current) {
|
| 245 |
+
tokenCache = [];
|
| 246 |
+
printLen = 0;
|
| 247 |
+
isPrompt = true;
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
flushDecodedText();
|
| 252 |
+
tokenCache = [];
|
| 253 |
+
printLen = 0;
|
| 254 |
+
isPrompt = true;
|
| 255 |
+
}
|
| 256 |
+
})();
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
await (model as any).generate({
|
| 260 |
+
input_ids: firstChunkInputs.input_ids,
|
| 261 |
+
input_features: inputFeaturesGenerator(),
|
| 262 |
+
max_new_tokens: 4096,
|
| 263 |
+
streamer: streamer as any,
|
| 264 |
+
});
|
| 265 |
+
} catch (error) {
|
| 266 |
+
if (!stopRequestedRef.current) {
|
| 267 |
+
console.error("Transcription error:", error);
|
| 268 |
+
setError(getErrorMessage(error, "Transcription failed"));
|
| 269 |
+
}
|
| 270 |
+
} finally {
|
| 271 |
+
cleanupAudio();
|
| 272 |
+
setStatus("ready");
|
| 273 |
+
}
|
| 274 |
+
},
|
| 275 |
+
[cleanupAudio],
|
| 276 |
+
);
|
| 277 |
+
|
| 278 |
+
const startRecording = useCallback(async () => {
|
| 279 |
+
const model = modelRef.current;
|
| 280 |
+
const processor = processorRef.current;
|
| 281 |
+
|
| 282 |
+
if (!model || !processor || isRecordingRef.current) {
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
setTranscript("");
|
| 287 |
+
setError(null);
|
| 288 |
+
audioBufferRef.current = new Float32Array(0);
|
| 289 |
+
isRecordingRef.current = true;
|
| 290 |
+
stopRequestedRef.current = false;
|
| 291 |
+
setStatus("recording");
|
| 292 |
+
|
| 293 |
+
try {
|
| 294 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 295 |
+
audio: {
|
| 296 |
+
channelCount: 1,
|
| 297 |
+
sampleRate: SAMPLE_RATE,
|
| 298 |
+
},
|
| 299 |
+
});
|
| 300 |
+
mediaStreamRef.current = stream;
|
| 301 |
+
|
| 302 |
+
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
| 303 |
+
audioContextRef.current = audioContext;
|
| 304 |
+
await audioContext.resume();
|
| 305 |
+
|
| 306 |
+
const sourceNode = audioContext.createMediaStreamSource(stream);
|
| 307 |
+
const silentGainNode = audioContext.createGain();
|
| 308 |
+
silentGainNode.gain.value = 0;
|
| 309 |
+
|
| 310 |
+
const workletBlob = new Blob([CAPTURE_WORKLET_SOURCE], {
|
| 311 |
+
type: "application/javascript",
|
| 312 |
+
});
|
| 313 |
+
const workletUrl = URL.createObjectURL(workletBlob);
|
| 314 |
+
await audioContext.audioWorklet.addModule(workletUrl);
|
| 315 |
+
URL.revokeObjectURL(workletUrl);
|
| 316 |
+
|
| 317 |
+
const workletNode = new AudioWorkletNode(
|
| 318 |
+
audioContext,
|
| 319 |
+
CAPTURE_PROCESSOR_NAME,
|
| 320 |
+
);
|
| 321 |
+
workletNode.port.onmessage = (event: MessageEvent<Float32Array>) => {
|
| 322 |
+
if (isRecordingRef.current) {
|
| 323 |
+
appendAudio(new Float32Array(event.data));
|
| 324 |
+
}
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
sourceNode.connect(workletNode);
|
| 328 |
+
workletNode.connect(silentGainNode);
|
| 329 |
+
silentGainNode.connect(audioContext.destination);
|
| 330 |
+
workletNodeRef.current = workletNode;
|
| 331 |
+
|
| 332 |
+
await runTranscription(model, processor);
|
| 333 |
+
} catch (error) {
|
| 334 |
+
console.error("Recording error:", error);
|
| 335 |
+
setError(getErrorMessage(error, "Recording failed"));
|
| 336 |
+
cleanupAudio();
|
| 337 |
+
setStatus("ready");
|
| 338 |
+
}
|
| 339 |
+
}, [appendAudio, cleanupAudio, runTranscription]);
|
| 340 |
+
|
| 341 |
+
const stopRecording = useCallback(() => {
|
| 342 |
+
stopRequestedRef.current = true;
|
| 343 |
+
isRecordingRef.current = false;
|
| 344 |
+
cleanupAudio();
|
| 345 |
+
}, [cleanupAudio]);
|
| 346 |
+
|
| 347 |
+
const resetSession = useCallback(() => {
|
| 348 |
+
stopRequestedRef.current = false;
|
| 349 |
+
audioBufferRef.current = new Float32Array(0);
|
| 350 |
+
setTranscript("");
|
| 351 |
+
setError(null);
|
| 352 |
+
setStatus("ready");
|
| 353 |
+
}, []);
|
| 354 |
+
|
| 355 |
+
return (
|
| 356 |
+
<VoxtralContext.Provider
|
| 357 |
+
value={{
|
| 358 |
+
status,
|
| 359 |
+
loadingProgress,
|
| 360 |
+
loadingMessage,
|
| 361 |
+
transcript,
|
| 362 |
+
error,
|
| 363 |
+
loadModel,
|
| 364 |
+
resetSession,
|
| 365 |
+
startRecording,
|
| 366 |
+
stopRecording,
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
{children}
|
| 370 |
+
</VoxtralContext.Provider>
|
| 371 |
+
);
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
function waitUntil(condition: () => boolean): Promise<void> {
|
| 375 |
+
return new Promise((resolve) => {
|
| 376 |
+
if (condition()) return resolve();
|
| 377 |
+
const interval = setInterval(() => {
|
| 378 |
+
if (condition()) {
|
| 379 |
+
clearInterval(interval);
|
| 380 |
+
resolve();
|
| 381 |
+
}
|
| 382 |
+
}, 50);
|
| 383 |
+
});
|
| 384 |
+
}
|
src/constants.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const THEME = {
|
| 2 |
+
beigeLight: "#FFFAEB",
|
| 3 |
+
beigeMedium: "#FFF0C3",
|
| 4 |
+
beigeDark: "#E9E2CB",
|
| 5 |
+
mistralOrange: "#FF8205",
|
| 6 |
+
mistralOrangeDark: "#FA500F",
|
| 7 |
+
mistralOrangeLight: "#FFAF00",
|
| 8 |
+
mistralYellow: "#FFD800",
|
| 9 |
+
textBlack: "#1E1E1E",
|
| 10 |
+
black: "#000000",
|
| 11 |
+
white: "#FFFFFF",
|
| 12 |
+
errorRed: "#E10500",
|
| 13 |
+
} as const;
|
src/index.css
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--mistral-red: #e10500;
|
| 5 |
+
--mistral-orange-dark: #fa500f;
|
| 6 |
+
--mistral-orange: #ff8205;
|
| 7 |
+
--mistral-orange-light: #ffaf00;
|
| 8 |
+
--mistral-yellow: #ffd800;
|
| 9 |
+
|
| 10 |
+
--mistral-beige-light: #fffaeb;
|
| 11 |
+
--mistral-beige-medium: #fff0c3;
|
| 12 |
+
--mistral-beige-dark: #e9e2cb;
|
| 13 |
+
|
| 14 |
+
--mistral-black: #000000;
|
| 15 |
+
--mistral-black-tinted: #1e1e1e;
|
| 16 |
+
--mistral-white: #ffffff;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
font-family: Arial, sans-serif;
|
| 21 |
+
background-color: var(--mistral-beige-light);
|
| 22 |
+
color: var(--mistral-black);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@keyframes blink {
|
| 26 |
+
0%,
|
| 27 |
+
100% {
|
| 28 |
+
opacity: 1;
|
| 29 |
+
}
|
| 30 |
+
50% {
|
| 31 |
+
opacity: 0;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
.cursor-blink {
|
| 35 |
+
animation: blink 1s step-end infinite;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.history-scroll::-webkit-scrollbar {
|
| 39 |
+
width: 6px;
|
| 40 |
+
}
|
| 41 |
+
.history-scroll::-webkit-scrollbar-track {
|
| 42 |
+
background: var(--mistral-beige-light);
|
| 43 |
+
}
|
| 44 |
+
.history-scroll::-webkit-scrollbar-thumb {
|
| 45 |
+
background-color: var(--mistral-beige-dark);
|
| 46 |
+
border-radius: 4px;
|
| 47 |
+
}
|
| 48 |
+
.history-scroll::-webkit-scrollbar-thumb:hover {
|
| 49 |
+
background-color: var(--mistral-orange);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@keyframes strip-move {
|
| 53 |
+
0% {
|
| 54 |
+
background-position: 0 0;
|
| 55 |
+
}
|
| 56 |
+
100% {
|
| 57 |
+
background-position: 30px 0;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
.progress-stripe {
|
| 61 |
+
background-image: linear-gradient(
|
| 62 |
+
45deg,
|
| 63 |
+
rgba(255, 255, 255, 0.2) 25%,
|
| 64 |
+
transparent 25%,
|
| 65 |
+
transparent 50%,
|
| 66 |
+
rgba(255, 255, 255, 0.2) 50%,
|
| 67 |
+
rgba(255, 255, 255, 0.2) 75%,
|
| 68 |
+
transparent 75%,
|
| 69 |
+
transparent
|
| 70 |
+
);
|
| 71 |
+
background-size: 30px 30px;
|
| 72 |
+
animation: strip-move 1s linear infinite;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@keyframes fadeUp {
|
| 76 |
+
from {
|
| 77 |
+
opacity: 0;
|
| 78 |
+
transform: translateY(10px);
|
| 79 |
+
}
|
| 80 |
+
to {
|
| 81 |
+
opacity: 1;
|
| 82 |
+
transform: translateY(0);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
.animate-enter {
|
| 86 |
+
animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.delay-100 {
|
| 90 |
+
animation-delay: 0.1s;
|
| 91 |
+
}
|
| 92 |
+
.delay-200 {
|
| 93 |
+
animation-delay: 0.2s;
|
| 94 |
+
}
|
| 95 |
+
.delay-300 {
|
| 96 |
+
animation-delay: 0.3s;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
@keyframes meter-idle {
|
| 100 |
+
0%,
|
| 101 |
+
100% {
|
| 102 |
+
transform: scaleY(0.45);
|
| 103 |
+
opacity: 0.55;
|
| 104 |
+
}
|
| 105 |
+
50% {
|
| 106 |
+
transform: scaleY(0.7);
|
| 107 |
+
opacity: 0.8;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@keyframes meter-active {
|
| 112 |
+
0%,
|
| 113 |
+
100% {
|
| 114 |
+
transform: scaleY(0.35);
|
| 115 |
+
}
|
| 116 |
+
25% {
|
| 117 |
+
transform: scaleY(1);
|
| 118 |
+
}
|
| 119 |
+
50% {
|
| 120 |
+
transform: scaleY(0.55);
|
| 121 |
+
}
|
| 122 |
+
75% {
|
| 123 |
+
transform: scaleY(0.9);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.voice-meter-bar {
|
| 128 |
+
width: 8px;
|
| 129 |
+
height: 40px;
|
| 130 |
+
border-radius: 999px;
|
| 131 |
+
transform-origin: bottom;
|
| 132 |
+
animation: meter-idle 1.6s ease-in-out infinite;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.voice-meter-bar.is-active {
|
| 136 |
+
animation-name: meter-active;
|
| 137 |
+
animation-duration: 0.9s;
|
| 138 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import "./index.css";
|
| 4 |
+
import App from "./App.tsx";
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById("root")!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
tsconfig.app.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "ES2022",
|
| 5 |
+
"useDefineForClassFields": true,
|
| 6 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 7 |
+
"module": "ESNext",
|
| 8 |
+
"types": ["vite/client"],
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
|
| 11 |
+
/* Bundler mode */
|
| 12 |
+
"moduleResolution": "bundler",
|
| 13 |
+
"allowImportingTsExtensions": true,
|
| 14 |
+
"verbatimModuleSyntax": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"noEmit": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"strict": true,
|
| 21 |
+
"noUnusedLocals": true,
|
| 22 |
+
"noUnusedParameters": true,
|
| 23 |
+
"erasableSyntaxOnly": true,
|
| 24 |
+
"noFallthroughCasesInSwitch": true,
|
| 25 |
+
"noUncheckedSideEffectImports": true
|
| 26 |
+
},
|
| 27 |
+
"include": ["src"]
|
| 28 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
tsconfig.node.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "ES2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"types": ["node"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"erasableSyntaxOnly": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true,
|
| 23 |
+
"noUncheckedSideEffectImports": true
|
| 24 |
+
},
|
| 25 |
+
"include": ["vite.config.ts"]
|
| 26 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
import tailwindcss from "@tailwindcss/vite";
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [react(), tailwindcss()],
|
| 8 |
+
});
|