Xenova HF Staff commited on
Commit
be54278
·
verified ·
1 Parent(s): deedc3b

Upload 20 files

Browse files
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
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
+ });