Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta | |
| name="description" | |
| content="S1-mini by Superwhisper turns raw speech transcripts into polished written text, locally in your browser with WebGPU." | |
| /> | |
| <meta name="theme-color" content="#0a0a0a" /> | |
| <title>S1 WebGPU Dictation</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| font-family: | |
| Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", | |
| sans-serif; | |
| --page: #090909; | |
| --line: rgba(255, 255, 255, 0.14); | |
| --line-strong: rgba(255, 255, 255, 0.28); | |
| --text: #f4f4f2; | |
| --blue: #56a8ff; | |
| --yellow: #f4b846; | |
| --green: #55ca82; | |
| --red: #ff514c; | |
| --ease: cubic-bezier(0.22, 1, 0.36, 1); | |
| --topbar-height: 78px; | |
| --footer-height: 48px; | |
| --chrome-height: calc(var(--topbar-height) + var(--footer-height)); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html { | |
| height: 100%; | |
| min-width: 320px; | |
| overflow: hidden; | |
| background: var(--page); | |
| } | |
| body { | |
| display: grid; | |
| height: 100svh; | |
| min-height: 0; | |
| margin: 0; | |
| overflow: hidden; | |
| background: | |
| radial-gradient(circle at 50% -15%, rgba(255, 255, 255, 0.075), transparent 38rem), | |
| linear-gradient(180deg, #101011 0%, var(--page) 64%); | |
| color: var(--text); | |
| grid-template-rows: var(--topbar-height) minmax(0, 1fr) var(--footer-height); | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| button, | |
| select { | |
| font: inherit; | |
| } | |
| button { | |
| color: inherit; | |
| } | |
| button:focus-visible, | |
| select:focus-visible, | |
| summary:focus-visible { | |
| outline: 2px solid rgba(86, 168, 255, 0.85); | |
| outline-offset: 3px; | |
| } | |
| .topbar { | |
| position: relative; | |
| z-index: 3; | |
| display: flex; | |
| width: min(1120px, calc(100% - 48px)); | |
| height: 100%; | |
| margin: 0 auto; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .brand, | |
| .local-badge, | |
| .pipeline-note, | |
| .keyboard-hint { | |
| display: inline-flex; | |
| align-items: center; | |
| } | |
| .brand { | |
| gap: 10px; | |
| color: #ededeb; | |
| font-size: 14px; | |
| font-weight: 620; | |
| letter-spacing: -0.01em; | |
| } | |
| .brand-mark { | |
| display: grid; | |
| width: 27px; | |
| height: 27px; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-radius: 9px; | |
| background: linear-gradient(145deg, #262627, #101011); | |
| box-shadow: | |
| inset 0 1px rgba(255, 255, 255, 0.08), | |
| 0 5px 16px rgba(0, 0, 0, 0.32); | |
| place-items: center; | |
| } | |
| .brand-mark-bars { | |
| display: flex; | |
| height: 12px; | |
| align-items: center; | |
| gap: 2px; | |
| } | |
| .brand-mark-bars i { | |
| display: block; | |
| width: 2px; | |
| border-radius: 3px; | |
| background: #f5f5f3; | |
| } | |
| .brand-mark-bars i:nth-child(1), | |
| .brand-mark-bars i:nth-child(5) { | |
| height: 4px; | |
| opacity: 0.5; | |
| } | |
| .brand-mark-bars i:nth-child(2), | |
| .brand-mark-bars i:nth-child(4) { | |
| height: 8px; | |
| } | |
| .brand-mark-bars i:nth-child(3) { | |
| height: 12px; | |
| } | |
| .local-badge { | |
| gap: 7px; | |
| color: #b0b0ae; | |
| font-size: 12px; | |
| } | |
| .lock-icon { | |
| width: 14px; | |
| height: 14px; | |
| flex: 0 0 auto; | |
| color: #777778; | |
| stroke-width: 1.8; | |
| } | |
| .stage { | |
| position: relative; | |
| z-index: 2; | |
| display: flex; | |
| width: min(760px, calc(100% - 40px)); | |
| height: 100%; | |
| min-height: 0; | |
| margin: 0 auto; | |
| overflow: hidden; | |
| padding: clamp(14px, 5vh, 52px) 0 clamp(10px, 3vh, 28px); | |
| flex-direction: column; | |
| align-items: center; | |
| transition: padding 750ms var(--ease); | |
| } | |
| .intro-slot { | |
| display: grid; | |
| width: 100%; | |
| overflow: hidden; | |
| grid-template-rows: 1fr; | |
| opacity: 1; | |
| filter: blur(0); | |
| transform: translateY(0) scale(1); | |
| transform-origin: 50% 0; | |
| transition: | |
| grid-template-rows 750ms var(--ease), | |
| opacity 420ms ease, | |
| filter 600ms ease, | |
| transform 700ms var(--ease); | |
| } | |
| .intro { | |
| min-height: 0; | |
| overflow: hidden; | |
| max-width: 660px; | |
| margin: 0 auto; | |
| text-align: center; | |
| } | |
| .eyebrow { | |
| margin: 0 0 17px; | |
| color: #9b9b9a; | |
| font-size: 11px; | |
| font-weight: 660; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| } | |
| h1 { | |
| margin: 0; | |
| font-size: clamp(42px, 7vw, 69px); | |
| font-weight: 590; | |
| letter-spacing: -0.057em; | |
| line-height: 0.98; | |
| } | |
| .headline-primary, | |
| .headline-secondary { | |
| display: block; | |
| } | |
| .headline-primary { | |
| color: var(--text); | |
| } | |
| .headline-secondary { | |
| color: #8a8a8a; | |
| } | |
| .lede { | |
| max-width: 590px; | |
| margin: 22px auto 0; | |
| color: #969696; | |
| font-size: clamp(14px, 2vw, 16px); | |
| line-height: 1.6; | |
| } | |
| .recorder-wrap { | |
| position: relative; | |
| width: 100%; | |
| margin-top: clamp(24px, 5vh, 52px); | |
| transition: margin-top 750ms var(--ease); | |
| } | |
| .recorder-glow { | |
| position: absolute; | |
| inset: 8% 8% 16%; | |
| border-radius: 48px; | |
| background: rgba(0, 0, 0, 0.7); | |
| filter: blur(34px); | |
| opacity: 0.8; | |
| pointer-events: none; | |
| } | |
| .recorder { | |
| position: relative; | |
| overflow: visible; | |
| border: 1px solid var(--line-strong); | |
| border-radius: 27px; | |
| background: rgba(11, 11, 12, 0.95); | |
| box-shadow: | |
| inset 0 1px rgba(255, 255, 255, 0.035), | |
| 0 32px 80px rgba(0, 0, 0, 0.54); | |
| transition: | |
| border-color 350ms ease, | |
| box-shadow 350ms ease; | |
| animation: panel-reveal 900ms 520ms var(--ease) both; | |
| } | |
| body[data-state="recording"] .recorder { | |
| border-color: rgba(255, 81, 76, 0.38); | |
| box-shadow: | |
| inset 0 1px rgba(255, 255, 255, 0.04), | |
| 0 28px 86px rgba(0, 0, 0, 0.58), | |
| 0 0 48px rgba(255, 81, 76, 0.05); | |
| } | |
| .visual { | |
| position: relative; | |
| display: grid; | |
| height: 166px; | |
| overflow: hidden; | |
| border-radius: 26px 26px 0 0; | |
| place-items: center; | |
| } | |
| .visual::after { | |
| position: absolute; | |
| inset: 0; | |
| background: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, 0.19) 100%); | |
| content: ""; | |
| pointer-events: none; | |
| } | |
| #waveform { | |
| display: block; | |
| width: calc(100% - 72px); | |
| height: 104px; | |
| opacity: 0.96; | |
| } | |
| .visual-status { | |
| position: absolute; | |
| left: 50%; | |
| bottom: 17px; | |
| z-index: 2; | |
| display: flex; | |
| width: calc(100% - 72px); | |
| transform: translateX(-50%); | |
| align-items: center; | |
| justify-content: center; | |
| color: #777778; | |
| font-size: 11px; | |
| font-variant-numeric: tabular-nums; | |
| letter-spacing: 0.015em; | |
| pointer-events: none; | |
| } | |
| .visual-status strong { | |
| color: #b6b6b5; | |
| font-weight: 530; | |
| } | |
| .visual-status .separator { | |
| margin: 0 7px; | |
| color: #3e3e3f; | |
| } | |
| .download-progress { | |
| position: absolute; | |
| left: 36px; | |
| right: 36px; | |
| bottom: 0; | |
| z-index: 3; | |
| height: 2px; | |
| overflow: hidden; | |
| border-radius: 2px; | |
| background: rgba(255, 255, 255, 0.07); | |
| opacity: 0; | |
| transition: opacity 200ms ease; | |
| } | |
| .download-progress.visible { | |
| opacity: 1; | |
| } | |
| .download-progress > span { | |
| display: block; | |
| width: 0%; | |
| height: 100%; | |
| border-radius: inherit; | |
| background: #dfdfdd; | |
| transition: width 180ms ease; | |
| } | |
| .download-progress.indeterminate > span { | |
| width: 32%; | |
| animation: indeterminate 1.25s var(--ease) infinite; | |
| } | |
| @keyframes indeterminate { | |
| from { | |
| transform: translateX(-115%); | |
| } | |
| to { | |
| transform: translateX(330%); | |
| } | |
| } | |
| .controls { | |
| position: relative; | |
| display: flex; | |
| min-height: 76px; | |
| padding: 13px 17px; | |
| border-top: 1px solid var(--line); | |
| border-radius: 0 0 26px 26px; | |
| background: linear-gradient(180deg, #1c1c1d, #19191a); | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .mode-group, | |
| .actions { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .mode-group { | |
| position: relative; | |
| gap: 3px; | |
| } | |
| .state-dot { | |
| width: 9px; | |
| height: 9px; | |
| margin: 0 8px; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-radius: 999px; | |
| background: #575759; | |
| box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.025); | |
| transition: | |
| background 250ms ease, | |
| box-shadow 250ms ease; | |
| } | |
| body[data-state="loading"] .state-dot { | |
| background: var(--yellow); | |
| box-shadow: 0 0 0 3px rgba(244, 184, 70, 0.09); | |
| } | |
| body[data-state="recording"] .state-dot { | |
| background: var(--red); | |
| box-shadow: 0 0 0 3px rgba(255, 81, 76, 0.1); | |
| animation: dot-pulse 1.5s ease-in-out infinite; | |
| } | |
| body[data-state="transcribing"] .state-dot, | |
| body[data-state="normalizing"] .state-dot { | |
| background: var(--blue); | |
| box-shadow: 0 0 0 3px rgba(86, 168, 255, 0.1); | |
| } | |
| body[data-state="ready"] .state-dot, | |
| body[data-state="done"] .state-dot { | |
| background: var(--green); | |
| box-shadow: 0 0 0 3px rgba(85, 202, 130, 0.09); | |
| } | |
| body[data-state="error"] .state-dot { | |
| background: var(--red); | |
| } | |
| @keyframes dot-pulse { | |
| 50% { | |
| box-shadow: 0 0 0 6px rgba(255, 81, 76, 0.05); | |
| } | |
| } | |
| .mode-button, | |
| .text-button, | |
| .copy-button, | |
| .primary-button { | |
| border: 0; | |
| cursor: pointer; | |
| } | |
| .mode-button { | |
| display: inline-flex; | |
| min-height: 43px; | |
| padding: 0 10px; | |
| border-radius: 12px; | |
| background: transparent; | |
| align-items: center; | |
| gap: 8px; | |
| color: #d0d0ce; | |
| font-size: 14px; | |
| font-weight: 560; | |
| transition: background 180ms ease; | |
| } | |
| .mode-button:hover, | |
| .mode-button[aria-expanded="true"] { | |
| background: rgba(255, 255, 255, 0.055); | |
| } | |
| .chevron { | |
| width: 6px; | |
| height: 6px; | |
| border-right: 1.5px solid #777778; | |
| border-bottom: 1.5px solid #777778; | |
| transform: translateY(-2px) rotate(45deg); | |
| transition: transform 180ms ease; | |
| } | |
| .mode-button[aria-expanded="true"] .chevron { | |
| transform: translateY(2px) rotate(225deg); | |
| } | |
| .mode-popover { | |
| position: absolute; | |
| left: 10px; | |
| bottom: calc(100% + 18px); | |
| z-index: 10; | |
| width: min(330px, calc(100vw - 64px)); | |
| padding: 9px; | |
| border: 1px solid rgba(255, 255, 255, 0.19); | |
| border-radius: 17px; | |
| background: rgba(24, 24, 25, 0.98); | |
| box-shadow: 0 22px 50px rgba(0, 0, 0, 0.48); | |
| opacity: 0; | |
| transform: translateY(7px) scale(0.985); | |
| transform-origin: 44px 100%; | |
| visibility: hidden; | |
| transition: | |
| opacity 160ms ease, | |
| transform 200ms var(--ease), | |
| visibility 160ms; | |
| } | |
| .mode-popover.open { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| visibility: visible; | |
| } | |
| .setting-row { | |
| display: grid; | |
| min-height: 52px; | |
| padding: 7px 9px 7px 11px; | |
| border-radius: 11px; | |
| grid-template-columns: 1fr auto; | |
| align-items: center; | |
| } | |
| .setting-row + .setting-row { | |
| border-top: 1px solid rgba(255, 255, 255, 0.07); | |
| border-radius: 0; | |
| } | |
| .setting-copy span { | |
| display: block; | |
| color: #d6d6d4; | |
| font-size: 13px; | |
| font-weight: 540; | |
| } | |
| .setting-copy small { | |
| display: block; | |
| margin-top: 3px; | |
| color: #6f6f70; | |
| font-size: 10px; | |
| } | |
| .setting-row select { | |
| min-width: 116px; | |
| height: 32px; | |
| padding: 0 27px 0 10px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 9px; | |
| appearance: none; | |
| background: | |
| linear-gradient(45deg, transparent 50%, #777 50%) calc(100% - 13px) 13px / 5px 5px no-repeat, | |
| linear-gradient(135deg, #777 50%, transparent 50%) calc(100% - 9px) 13px / 5px 5px no-repeat, | |
| #101011; | |
| color: #bdbdbc; | |
| font-size: 11px; | |
| cursor: pointer; | |
| } | |
| .actions { | |
| gap: 4px; | |
| } | |
| .text-button, | |
| .primary-button { | |
| min-height: 43px; | |
| border-radius: 12px; | |
| font-size: 13px; | |
| font-weight: 580; | |
| transition: | |
| background 180ms ease, | |
| color 180ms ease, | |
| opacity 180ms ease, | |
| transform 180ms ease; | |
| } | |
| .text-button { | |
| padding: 0 14px; | |
| background: transparent; | |
| color: #8f8f90; | |
| } | |
| .text-button:hover:not(:disabled) { | |
| background: rgba(255, 255, 255, 0.05); | |
| color: #d5d5d4; | |
| } | |
| .primary-button { | |
| min-width: 116px; | |
| padding: 0 18px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| background: #eeeeeb; | |
| box-shadow: inset 0 -1px rgba(0, 0, 0, 0.15); | |
| color: #111112; | |
| } | |
| .primary-button:hover:not(:disabled) { | |
| background: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .primary-button:active:not(:disabled) { | |
| transform: translateY(0); | |
| } | |
| .primary-button:disabled, | |
| .text-button:disabled { | |
| cursor: default; | |
| opacity: 0.38; | |
| } | |
| body[data-state="recording"] .primary-button { | |
| border-color: rgba(255, 95, 89, 0.35); | |
| background: #8a2826; | |
| color: #fff; | |
| } | |
| body[data-state="recording"] .primary-button:hover:not(:disabled) { | |
| border-color: rgba(255, 255, 255, 0.18); | |
| background: #f5f5f2; | |
| color: #111112; | |
| } | |
| .error-message { | |
| display: none; | |
| margin: 14px 24px 0; | |
| color: #e99c98; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| text-align: center; | |
| } | |
| .error-message.visible { | |
| display: block; | |
| } | |
| .result-panel { | |
| width: calc(100% - 38px); | |
| max-height: 0; | |
| margin: 14px auto 0; | |
| overflow: hidden; | |
| border: 1px solid transparent; | |
| border-radius: 21px; | |
| background: rgba(14, 14, 15, 0.94); | |
| opacity: 0; | |
| transform: translateY(-10px) scale(0.99); | |
| transition: | |
| max-height 550ms var(--ease), | |
| opacity 250ms ease, | |
| transform 450ms var(--ease), | |
| border-color 250ms ease; | |
| } | |
| .result-panel.visible { | |
| max-height: clamp(120px, calc(50svh - 180px), 340px); | |
| overflow-y: auto; | |
| border-color: rgba(255, 255, 255, 0.14); | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| overscroll-behavior: contain; | |
| scrollbar-color: #3e3e3f transparent; | |
| scrollbar-width: thin; | |
| } | |
| .result-header { | |
| display: flex; | |
| min-height: 52px; | |
| padding: 0 18px 0 21px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.075); | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .result-title { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: #949494; | |
| font-size: 11px; | |
| font-weight: 620; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .spark { | |
| position: relative; | |
| width: 10px; | |
| height: 10px; | |
| } | |
| .spark::before, | |
| .spark::after { | |
| position: absolute; | |
| inset: 0; | |
| margin: auto; | |
| border-radius: 999px; | |
| background: #d7d7d5; | |
| content: ""; | |
| } | |
| .spark::before { | |
| width: 2px; | |
| height: 10px; | |
| } | |
| .spark::after { | |
| width: 10px; | |
| height: 2px; | |
| } | |
| .copy-button { | |
| min-width: 63px; | |
| height: 31px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 9px; | |
| background: rgba(255, 255, 255, 0.035); | |
| color: #9b9b9c; | |
| font-size: 11px; | |
| transition: | |
| background 180ms ease, | |
| color 180ms ease; | |
| } | |
| .copy-button:hover:not(:disabled) { | |
| background: rgba(255, 255, 255, 0.075); | |
| color: #e7e7e5; | |
| } | |
| .copy-button:disabled { | |
| cursor: default; | |
| opacity: 0.35; | |
| } | |
| .clean-text { | |
| min-height: 112px; | |
| padding: 23px 22px 25px; | |
| color: #efefed; | |
| font-size: clamp(17px, 2.7vw, 20px); | |
| font-weight: 410; | |
| letter-spacing: -0.015em; | |
| line-height: 1.55; | |
| white-space: pre-wrap; | |
| } | |
| .clean-text.empty { | |
| color: #737374; | |
| font-size: 15px; | |
| font-style: italic; | |
| } | |
| .clean-text.streaming::after { | |
| display: inline-block; | |
| width: 2px; | |
| height: 1em; | |
| margin-left: 3px; | |
| border-radius: 1px; | |
| background: #a6a6a5; | |
| content: ""; | |
| vertical-align: -0.12em; | |
| animation: cursor-blink 0.85s steps(2, end) infinite; | |
| } | |
| @keyframes cursor-blink { | |
| 50% { | |
| opacity: 0; | |
| } | |
| } | |
| .raw-details { | |
| border-top: 1px solid rgba(255, 255, 255, 0.07); | |
| } | |
| .raw-details summary { | |
| padding: 13px 22px; | |
| color: #686869; | |
| font-size: 11px; | |
| cursor: pointer; | |
| list-style: none; | |
| user-select: none; | |
| } | |
| .raw-details summary::-webkit-details-marker { | |
| display: none; | |
| } | |
| .raw-details summary::before { | |
| display: inline-block; | |
| width: 5px; | |
| height: 5px; | |
| margin: 0 9px 2px 1px; | |
| border-right: 1px solid #626263; | |
| border-bottom: 1px solid #626263; | |
| content: ""; | |
| transform: rotate(-45deg); | |
| transition: transform 160ms ease; | |
| } | |
| .raw-details[open] summary::before { | |
| transform: translateY(-2px) rotate(45deg); | |
| } | |
| .raw-text { | |
| margin: 0; | |
| padding: 2px 22px 20px; | |
| color: #8d8d8e; | |
| font-family: inherit; | |
| font-size: 13px; | |
| line-height: 1.55; | |
| white-space: pre-wrap; | |
| } | |
| .under-card { | |
| display: flex; | |
| width: calc(100% - 44px); | |
| max-height: 32px; | |
| margin: 18px auto 0; | |
| overflow: hidden; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 20px; | |
| opacity: 1; | |
| transform: translateY(0); | |
| transition: | |
| max-height 500ms var(--ease), | |
| margin-top 500ms var(--ease), | |
| opacity 280ms ease, | |
| transform 500ms var(--ease); | |
| } | |
| body:not(.workspace-active) .under-card { | |
| animation: reveal-up 800ms 720ms var(--ease) both; | |
| } | |
| .pipeline-note { | |
| min-width: 0; | |
| gap: 6px; | |
| color: #777778; | |
| font-size: 10.5px; | |
| white-space: nowrap; | |
| } | |
| .pipeline-note a { | |
| color: inherit; | |
| text-decoration: none; | |
| } | |
| .pipeline-note a:hover { | |
| color: #d0d0ce; | |
| } | |
| .pipeline-primary { | |
| color: #b9b9b7 ; | |
| font-weight: 560; | |
| } | |
| .pipeline-separator { | |
| color: #484849; | |
| } | |
| .keyboard-hint { | |
| flex: 0 0 auto; | |
| gap: 6px; | |
| color: #4f4f50; | |
| font-size: 10px; | |
| } | |
| kbd { | |
| display: inline-grid; | |
| min-width: 21px; | |
| height: 19px; | |
| padding: 0 5px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 5px; | |
| background: rgba(255, 255, 255, 0.025); | |
| color: #737374; | |
| font-family: inherit; | |
| font-size: 9px; | |
| place-items: center; | |
| } | |
| .page-footer { | |
| position: relative; | |
| z-index: 2; | |
| display: flex; | |
| width: min(1120px, calc(100% - 48px)); | |
| height: 100%; | |
| min-height: 0; | |
| margin: 0 auto; | |
| align-items: center; | |
| justify-content: center; | |
| color: #3e3e3f; | |
| font-size: 10px; | |
| animation: reveal-up 700ms 820ms var(--ease) both; | |
| } | |
| .page-footer a { | |
| color: #555556; | |
| text-decoration: none; | |
| } | |
| .page-footer a:hover { | |
| color: #8b8b8c; | |
| } | |
| .brand { | |
| animation: reveal-up 650ms 80ms var(--ease) both; | |
| } | |
| .local-badge { | |
| animation: reveal-up 650ms 160ms var(--ease) both; | |
| } | |
| .eyebrow { | |
| animation: reveal-up 650ms 140ms var(--ease) both; | |
| } | |
| .headline-primary { | |
| animation: reveal-up 800ms 220ms var(--ease) both; | |
| } | |
| .headline-secondary { | |
| animation: reveal-up 800ms 310ms var(--ease) both; | |
| } | |
| .lede { | |
| animation: reveal-up 800ms 420ms var(--ease) both; | |
| } | |
| @keyframes reveal-up { | |
| from { | |
| opacity: 0; | |
| filter: blur(5px); | |
| transform: translateY(12px); | |
| } | |
| to { | |
| opacity: 1; | |
| filter: blur(0); | |
| transform: translateY(0); | |
| } | |
| } | |
| @keyframes panel-reveal { | |
| from { | |
| opacity: 0; | |
| transform: translateY(18px) scale(0.985); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| body.workspace-active .stage { | |
| padding-top: 0; | |
| padding-bottom: 0; | |
| } | |
| body.workspace-active .intro-slot { | |
| grid-template-rows: 0fr; | |
| opacity: 0; | |
| filter: blur(7px); | |
| transform: translateY(-22px) scale(0.985); | |
| pointer-events: none; | |
| } | |
| body.workspace-active .recorder-wrap { | |
| margin-top: clamp( | |
| 12px, | |
| calc((100svh - var(--chrome-height)) / 2 - 145px), | |
| 290px | |
| ); | |
| } | |
| body.workspace-active .under-card { | |
| max-height: 0; | |
| margin-top: 0; | |
| opacity: 0; | |
| transform: translateY(-8px); | |
| pointer-events: none; | |
| } | |
| @media (max-width: 620px) { | |
| :root { | |
| --topbar-height: 66px; | |
| --footer-height: 42px; | |
| } | |
| .topbar { | |
| width: calc(100% - 32px); | |
| height: 100%; | |
| } | |
| .local-badge span, | |
| .keyboard-hint { | |
| display: none; | |
| } | |
| .stage { | |
| width: calc(100% - 24px); | |
| padding: clamp(12px, 3.5vh, 24px) 0 clamp(8px, 2vh, 16px); | |
| } | |
| .lede { | |
| padding: 0 18px; | |
| } | |
| h1 { | |
| font-size: clamp(31px, 9.7vw, 44px); | |
| } | |
| .recorder-wrap { | |
| margin-top: clamp(20px, 3.8vh, 30px); | |
| } | |
| .recorder { | |
| border-radius: 23px; | |
| } | |
| .visual { | |
| height: 142px; | |
| border-radius: 22px 22px 0 0; | |
| } | |
| #waveform, | |
| .visual-status { | |
| width: calc(100% - 40px); | |
| } | |
| .download-progress { | |
| left: 20px; | |
| right: 20px; | |
| } | |
| .controls { | |
| min-height: 69px; | |
| padding: 10px; | |
| border-radius: 0 0 22px 22px; | |
| } | |
| .state-dot { | |
| margin: 0 4px; | |
| } | |
| .mode-button { | |
| padding: 0 8px; | |
| } | |
| .text-button { | |
| padding: 0 9px; | |
| } | |
| .primary-button { | |
| min-width: 104px; | |
| padding: 0 13px; | |
| } | |
| .result-panel { | |
| width: calc(100% - 14px); | |
| } | |
| .under-card { | |
| width: calc(100% - 22px); | |
| max-height: 54px; | |
| justify-content: center; | |
| } | |
| .pipeline-note { | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| line-height: 1.5; | |
| white-space: normal; | |
| } | |
| } | |
| @media (max-height: 720px) { | |
| :root { | |
| --footer-height: 36px; | |
| } | |
| .stage { | |
| padding-top: clamp(8px, 2.5vh, 18px); | |
| padding-bottom: 8px; | |
| } | |
| .eyebrow { | |
| margin-bottom: 10px; | |
| } | |
| h1 { | |
| font-size: clamp(31px, min(7.6vh, 9.7vw), 52px); | |
| } | |
| .lede { | |
| margin-top: 13px; | |
| line-height: 1.45; | |
| } | |
| .recorder-wrap { | |
| margin-top: clamp(16px, 3vh, 24px); | |
| } | |
| .visual { | |
| height: 128px; | |
| } | |
| .controls { | |
| min-height: 66px; | |
| } | |
| .result-panel.visible { | |
| max-height: clamp(112px, calc(50svh - 165px), 300px); | |
| } | |
| } | |
| @media (max-height: 620px) { | |
| :root { | |
| --footer-height: 0px; | |
| } | |
| .page-footer { | |
| display: none; | |
| } | |
| .visual { | |
| height: 116px; | |
| } | |
| .controls { | |
| min-height: 62px; | |
| } | |
| .under-card { | |
| margin-top: 10px; | |
| } | |
| } | |
| @media (max-width: 390px) { | |
| .state-dot { | |
| display: none; | |
| } | |
| .mode-button { | |
| font-size: 12px; | |
| } | |
| .text-button, | |
| .primary-button { | |
| font-size: 12px; | |
| } | |
| .primary-button { | |
| min-width: 94px; | |
| } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, | |
| *::before, | |
| *::after { | |
| scroll-behavior: auto ; | |
| animation-duration: 0.001ms ; | |
| animation-iteration-count: 1 ; | |
| transition-duration: 0.001ms ; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body data-state="idle"> | |
| <header class="topbar"> | |
| <div class="brand" aria-label="S1-Mini WebGPU"> | |
| <span class="brand-mark" aria-hidden="true"> | |
| <span class="brand-mark-bars"><i></i><i></i><i></i><i></i><i></i></span> | |
| </span> | |
| <span>S1-Mini WebGPU</span> | |
| </div> | |
| <div class="local-badge" title="Audio and text stay on this device"> | |
| <svg class="lock-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <rect width="18" height="11" x="3" y="11" rx="2" /> | |
| <path d="M7 11V7a5 5 0 0 1 10 0v4" /> | |
| </svg> | |
| <span>Private & local</span> | |
| </div> | |
| </header> | |
| <main class="stage"> | |
| <div id="introSlot" class="intro-slot"> | |
| <section class="intro" aria-labelledby="page-title"> | |
| <p class="eyebrow">S1-mini by Superwhisper</p> | |
| <h1 id="page-title"> | |
| <span class="headline-primary">Speak naturally.</span> | |
| <span class="headline-secondary">S1-mini cleans it up.</span> | |
| </h1> | |
| <p class="lede"> | |
| S1-mini turns raw speech transcripts into polished text—removing filler, | |
| resolving corrections, and formatting the details. A local speech recognition | |
| model captures your words first; everything stays in your browser. | |
| </p> | |
| </section> | |
| </div> | |
| <section class="recorder-wrap" aria-label="Voice recorder"> | |
| <div class="recorder-glow" aria-hidden="true"></div> | |
| <div class="recorder"> | |
| <div class="visual"> | |
| <canvas id="waveform" aria-hidden="true"></canvas> | |
| <div class="visual-status" aria-live="polite"> | |
| <strong id="statusTitle">S1-mini not loaded</strong> | |
| <span class="separator" aria-hidden="true">·</span> | |
| <span id="statusDetail">downloads once, then stays cached</span> | |
| </div> | |
| <div | |
| id="downloadProgress" | |
| class="download-progress" | |
| role="progressbar" | |
| aria-label="Model download progress" | |
| aria-valuemin="0" | |
| aria-valuemax="100" | |
| aria-valuenow="0" | |
| > | |
| <span></span> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <div class="mode-group"> | |
| <span class="state-dot" aria-hidden="true"></span> | |
| <button | |
| id="modeButton" | |
| class="mode-button" | |
| type="button" | |
| aria-expanded="false" | |
| aria-controls="modePopover" | |
| > | |
| <span>S1-mini</span> | |
| <span class="chevron" aria-hidden="true"></span> | |
| </button> | |
| <div id="modePopover" class="mode-popover" aria-hidden="true"> | |
| <label class="setting-row"> | |
| <span class="setting-copy"> | |
| <span>Styling</span> | |
| <small>Writing register</small> | |
| </span> | |
| <select id="stylingSelect" aria-label="Styling"> | |
| <option value="casual">Casual</option> | |
| <option value="semi-casual">Semi-casual</option> | |
| <option value="semi-formal" selected>Semi-formal</option> | |
| <option value="formal">Formal</option> | |
| </select> | |
| </label> | |
| <label class="setting-row"> | |
| <span class="setting-copy"> | |
| <span>Structure</span> | |
| <small>Paragraph or list</small> | |
| </span> | |
| <select id="structureSelect" aria-label="Structure"> | |
| <option value="prose" selected>Prose</option> | |
| <option value="lists">Lists</option> | |
| </select> | |
| </label> | |
| <label class="setting-row"> | |
| <span class="setting-copy"> | |
| <span>Context</span> | |
| <small>Output destination</small> | |
| </span> | |
| <select id="contextSelect" aria-label="Context"> | |
| <option value="general" selected>General</option> | |
| <option value="email">Email</option> | |
| </select> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button id="cancelButton" class="text-button" type="button" hidden>Cancel</button> | |
| <button id="primaryButton" class="primary-button" type="button">Load model</button> | |
| </div> | |
| </div> | |
| </div> | |
| <p id="errorMessage" class="error-message" role="alert"></p> | |
| <article id="resultPanel" class="result-panel" aria-live="polite"> | |
| <header class="result-header"> | |
| <span class="result-title"><span class="spark" aria-hidden="true"></span>S1-mini output</span> | |
| <button id="copyButton" class="copy-button" type="button" disabled>Copy</button> | |
| </header> | |
| <div id="cleanText" class="clean-text"></div> | |
| <details class="raw-details"> | |
| <summary>Raw speech transcript</summary> | |
| <pre id="rawText" class="raw-text"></pre> | |
| </details> | |
| </article> | |
| <div id="underCard" class="under-card"> | |
| <div class="pipeline-note"> | |
| <a class="pipeline-primary" href="https://huggingface.co/superwhisper/s1-mini" target="_blank" rel="noreferrer">S1-mini by Superwhisper</a> | |
| <span class="pipeline-separator" aria-hidden="true">·</span> | |
| <span>speech recognition by</span> | |
| <a href="https://huggingface.co/onnx-community/moonshine-base-ONNX" target="_blank" rel="noreferrer">Moonshine base</a> | |
| </div> | |
| <div class="keyboard-hint" aria-hidden="true"><kbd>Space</kbd> record <kbd>Esc</kbd> cancel</div> | |
| </div> | |
| </section> | |
| </main> | |
| <footer class="page-footer"> | |
| <span>Powered by <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noreferrer">Transformers.js 4.2.0</a> + WebGPU</span> | |
| </footer> | |
| <script type="module"> | |
| import { | |
| pipeline, | |
| env, | |
| TextStreamer, | |
| } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0"; | |
| const ASR_MODEL = "onnx-community/moonshine-base-ONNX"; | |
| const NORMALIZER_MODEL = "onnx-community/s1-mini-ONNX"; | |
| const TARGET_SAMPLE_RATE = 16_000; | |
| const MAX_RECORDING_SECONDS = 90; | |
| const SYSTEM_PROMPT = | |
| "You are a text normalizer for speech-to-text transcripts. The input begins " + | |
| "with a control line specifying the styling, structure, and context settings; " + | |
| "clean the transcript to match those settings and output only the cleaned text."; | |
| env.allowLocalModels = false; | |
| env.useBrowserCache = true; | |
| const page = document.body; | |
| const elements = Object.freeze({ | |
| introSlot: document.querySelector("#introSlot"), | |
| underCard: document.querySelector("#underCard"), | |
| waveform: document.querySelector("#waveform"), | |
| statusTitle: document.querySelector("#statusTitle"), | |
| statusDetail: document.querySelector("#statusDetail"), | |
| statusSeparator: document.querySelector(".visual-status .separator"), | |
| progress: document.querySelector("#downloadProgress"), | |
| progressFill: document.querySelector("#downloadProgress > span"), | |
| primary: document.querySelector("#primaryButton"), | |
| cancel: document.querySelector("#cancelButton"), | |
| modeButton: document.querySelector("#modeButton"), | |
| modePopover: document.querySelector("#modePopover"), | |
| styling: document.querySelector("#stylingSelect"), | |
| structure: document.querySelector("#structureSelect"), | |
| context: document.querySelector("#contextSelect"), | |
| error: document.querySelector("#errorMessage"), | |
| result: document.querySelector("#resultPanel"), | |
| cleanText: document.querySelector("#cleanText"), | |
| rawText: document.querySelector("#rawText"), | |
| copy: document.querySelector("#copyButton"), | |
| }); | |
| const STATE_LABELS = Object.freeze({ | |
| idle: "Load model", | |
| loading: "Loading…", | |
| ready: "Record", | |
| recording: "Stop", | |
| transcribing: "Transcribing…", | |
| normalizing: "Polishing…", | |
| done: "Record again", | |
| }); | |
| const BUSY_STATES = new Set(["loading", "transcribing", "normalizing"]); | |
| const RECORDABLE_STATES = new Set(["ready", "done", "error"]); | |
| let state = "idle"; | |
| let transcriber = null; | |
| let normalizer = null; | |
| let loadPromise = null; | |
| let capture = null; | |
| let timerInterval = null; | |
| let recordingLimitTimeout = null; | |
| let animationFrame = 0; | |
| let canvasScale = 0; | |
| let levels = []; | |
| let envelopes = []; | |
| let displayProgress = 0; | |
| let lastCleanText = ""; | |
| const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| const canvasContext = elements.waveform.getContext("2d", { alpha: true }); | |
| function setState(next, title, detail = "") { | |
| state = next; | |
| page.dataset.state = next; | |
| elements.statusTitle.textContent = title; | |
| elements.statusDetail.textContent = detail; | |
| elements.statusDetail.hidden = !detail; | |
| elements.statusSeparator.hidden = !detail; | |
| elements.error.classList.remove("visible"); | |
| elements.primary.disabled = BUSY_STATES.has(next); | |
| elements.cancel.hidden = next !== "recording"; | |
| elements.primary.textContent = next === "error" | |
| ? (transcriber && normalizer ? "Record" : "Try again") | |
| : (STATE_LABELS[next] ?? "Record"); | |
| } | |
| function activateWorkspace() { | |
| if (page.classList.contains("workspace-active")) return; | |
| page.classList.add("workspace-active"); | |
| elements.introSlot.setAttribute("aria-hidden", "true"); | |
| elements.underCard.setAttribute("aria-hidden", "true"); | |
| elements.introSlot.inert = true; | |
| elements.underCard.inert = true; | |
| } | |
| function showError(error, fallback) { | |
| console.error(error); | |
| const message = error instanceof Error ? error.message : String(error); | |
| const friendly = message.includes("Permission denied") || error?.name === "NotAllowedError" | |
| ? "Microphone access was denied. Allow it in your browser settings, then try again." | |
| : fallback; | |
| setState("error", "Something went wrong", "Ready to retry"); | |
| elements.error.textContent = friendly; | |
| elements.error.classList.add("visible"); | |
| } | |
| function formatFileName(file = "") { | |
| if (file.includes("model_q4f16")) return "model weights"; | |
| if (file.includes("encoder")) return "speech encoder"; | |
| if (file.includes("decoder")) return "speech decoder"; | |
| if (file.includes("tokenizer")) return "tokenizer"; | |
| if (file.includes("config")) return "configuration"; | |
| return "model files"; | |
| } | |
| function formatBytes(bytes) { | |
| if (!Number.isFinite(bytes) || bytes <= 0) return ""; | |
| const units = ["B", "KB", "MB", "GB"]; | |
| const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); | |
| const value = bytes / 1024 ** power; | |
| return `${value >= 100 || power === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[power]}`; | |
| } | |
| function makeProgressCallback(modelLabel) { | |
| return (event) => { | |
| if (event.status === "progress_total") { | |
| const percent = Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null; | |
| updateProgress(percent); | |
| elements.statusTitle.textContent = `Loading ${modelLabel}`; | |
| const loaded = formatBytes(event.loaded); | |
| const total = formatBytes(event.total); | |
| elements.statusDetail.textContent = percent === null | |
| ? "model files" | |
| : `${Math.round(percent)}%${loaded && total ? ` · ${loaded} / ${total}` : ""}`; | |
| } else if (event.status === "initiate" || event.status === "download") { | |
| updateProgress(null); | |
| elements.statusTitle.textContent = `Loading ${modelLabel}`; | |
| elements.statusDetail.textContent = formatFileName(event.file); | |
| } | |
| }; | |
| } | |
| function updateProgress(percent) { | |
| elements.progress.classList.add("visible"); | |
| if (percent === null) { | |
| elements.progress.classList.add("indeterminate"); | |
| elements.progress.removeAttribute("aria-valuenow"); | |
| return; | |
| } | |
| elements.progress.classList.remove("indeterminate"); | |
| displayProgress = percent; | |
| elements.progressFill.style.width = `${percent}%`; | |
| elements.progress.setAttribute("aria-valuenow", String(Math.round(percent))); | |
| } | |
| function hideProgress() { | |
| elements.progress.classList.remove("visible", "indeterminate"); | |
| displayProgress = 0; | |
| elements.progressFill.style.width = "0%"; | |
| elements.progress.setAttribute("aria-valuenow", "0"); | |
| } | |
| async function loadModels() { | |
| if (transcriber && normalizer) return; | |
| if (loadPromise) return loadPromise; | |
| loadPromise = (async () => { | |
| if (!navigator.gpu) { | |
| throw new Error("WebGPU is not available in this browser."); | |
| } | |
| setState("loading", "Preparing WebGPU", "Models are downloaded once and cached"); | |
| updateProgress(null); | |
| if (!transcriber) { | |
| transcriber = await pipeline("automatic-speech-recognition", ASR_MODEL, { | |
| device: "webgpu", | |
| progress_callback: makeProgressCallback("speech recognition"), | |
| }); | |
| } | |
| updateProgress(null); | |
| if (!normalizer) { | |
| normalizer = await pipeline("text-generation", NORMALIZER_MODEL, { | |
| dtype: "q4f16", | |
| device: "webgpu", | |
| progress_callback: makeProgressCallback("S1-mini"), | |
| }); | |
| } | |
| hideProgress(); | |
| setState("ready", "Ready", "Press Record or Space to begin"); | |
| requestAnimationFrame(activateWorkspace); | |
| })(); | |
| try { | |
| await loadPromise; | |
| } finally { | |
| loadPromise = null; | |
| } | |
| } | |
| const RECORDER_WORKLET_SOURCE = ` | |
| class PCMRecorderProcessor extends AudioWorkletProcessor { | |
| constructor() { | |
| super(); | |
| this.chunk = new Float32Array(4096); | |
| this.offset = 0; | |
| this.port.onmessage = (event) => { | |
| if (event.data === "flush") this.flush(); | |
| }; | |
| } | |
| flush() { | |
| if (this.offset > 0) { | |
| const samples = this.chunk.slice(0, this.offset); | |
| this.port.postMessage({ type: "chunk", samples }, [samples.buffer]); | |
| this.offset = 0; | |
| } | |
| this.port.postMessage({ type: "flushed" }); | |
| } | |
| process(inputs, outputs) { | |
| const input = inputs[0]; | |
| const output = outputs[0]; | |
| if (output) { | |
| for (const channel of output) channel.fill(0); | |
| } | |
| if (!input || input.length === 0) return true; | |
| const frameLength = input[0].length; | |
| for (let i = 0; i < frameLength; i++) { | |
| let mono = 0; | |
| for (let channel = 0; channel < input.length; channel++) { | |
| mono += input[channel][i] || 0; | |
| } | |
| this.chunk[this.offset++] = mono / input.length; | |
| if (this.offset === this.chunk.length) { | |
| const samples = this.chunk; | |
| this.port.postMessage({ type: "chunk", samples }, [samples.buffer]); | |
| this.chunk = new Float32Array(4096); | |
| this.offset = 0; | |
| } | |
| } | |
| return true; | |
| } | |
| } | |
| registerProcessor("pcm-recorder", PCMRecorderProcessor); | |
| `; | |
| async function startRecording() { | |
| if (!transcriber || !normalizer) { | |
| await loadModels(); | |
| return; | |
| } | |
| if (!navigator.mediaDevices?.getUserMedia || !window.isSecureContext) { | |
| throw new Error("Microphone capture requires HTTPS or localhost."); | |
| } | |
| hideResult(); | |
| closeModePopover(); | |
| let stream = null; | |
| let context = null; | |
| let workletUrl = null; | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true, | |
| }, | |
| }); | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| context = new AudioContextClass({ latencyHint: "interactive" }); | |
| await context.resume(); | |
| workletUrl = URL.createObjectURL( | |
| new Blob([RECORDER_WORKLET_SOURCE], { type: "text/javascript" }), | |
| ); | |
| await context.audioWorklet.addModule(workletUrl); | |
| URL.revokeObjectURL(workletUrl); | |
| workletUrl = null; | |
| const source = context.createMediaStreamSource(stream); | |
| const analyser = context.createAnalyser(); | |
| analyser.fftSize = 2048; | |
| analyser.smoothingTimeConstant = 0.72; | |
| const recorderNode = new AudioWorkletNode(context, "pcm-recorder", { | |
| numberOfInputs: 1, | |
| numberOfOutputs: 1, | |
| outputChannelCount: [1], | |
| channelCount: 1, | |
| }); | |
| const chunks = []; | |
| let resolveFlush; | |
| const flushPromise = new Promise((resolve) => { | |
| resolveFlush = resolve; | |
| }); | |
| recorderNode.port.onmessage = ({ data }) => { | |
| if (data.type === "chunk") chunks.push(data.samples); | |
| if (data.type === "flushed") resolveFlush?.(); | |
| }; | |
| source.connect(analyser); | |
| analyser.connect(recorderNode); | |
| recorderNode.connect(context.destination); | |
| capture = { | |
| stream, | |
| context, | |
| source, | |
| analyser, | |
| recorderNode, | |
| chunks, | |
| waveformData: new Uint8Array(analyser.fftSize), | |
| sampleRate: context.sampleRate, | |
| startedAt: performance.now(), | |
| flush: async () => { | |
| recorderNode.port.postMessage("flush"); | |
| await Promise.race([flushPromise, new Promise((resolve) => setTimeout(resolve, 160))]); | |
| }, | |
| }; | |
| setState("recording", "Listening", "00:00"); | |
| updateRecordingTimer(); | |
| timerInterval = setInterval(updateRecordingTimer, 250); | |
| recordingLimitTimeout = setTimeout(() => { | |
| stopRecording().catch((error) => { | |
| showError(error, "The local pipeline could not complete. Check the console for details, then try again."); | |
| }); | |
| }, MAX_RECORDING_SECONDS * 1000); | |
| } catch (error) { | |
| if (workletUrl) URL.revokeObjectURL(workletUrl); | |
| if (!capture) { | |
| stream?.getTracks().forEach((track) => track.stop()); | |
| if (context && context.state !== "closed") await context.close().catch(() => {}); | |
| } | |
| throw error; | |
| } | |
| } | |
| function updateRecordingTimer() { | |
| if (!capture) return; | |
| const seconds = Math.floor((performance.now() - capture.startedAt) / 1000); | |
| const minutes = String(Math.floor(seconds / 60)).padStart(2, "0"); | |
| const remainder = String(seconds % 60).padStart(2, "0"); | |
| elements.statusDetail.textContent = `${minutes}:${remainder}`; | |
| } | |
| async function finishCapture(discard = false) { | |
| if (!capture) return null; | |
| clearInterval(timerInterval); | |
| clearTimeout(recordingLimitTimeout); | |
| timerInterval = null; | |
| recordingLimitTimeout = null; | |
| const current = capture; | |
| capture = null; | |
| await current.flush(); | |
| current.source.disconnect(); | |
| current.analyser.disconnect(); | |
| current.recorderNode.disconnect(); | |
| current.stream.getTracks().forEach((track) => track.stop()); | |
| await current.context.close(); | |
| if (discard) return null; | |
| const totalLength = current.chunks.reduce((sum, chunk) => sum + chunk.length, 0); | |
| const samples = new Float32Array(totalLength); | |
| let offset = 0; | |
| for (const chunk of current.chunks) { | |
| samples.set(chunk, offset); | |
| offset += chunk.length; | |
| } | |
| return resample(samples, current.sampleRate, TARGET_SAMPLE_RATE); | |
| } | |
| async function resample(samples, sourceRate, targetRate) { | |
| if (sourceRate === targetRate) return samples; | |
| const frameCount = Math.max(1, Math.ceil(samples.length * targetRate / sourceRate)); | |
| const OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; | |
| const offline = new OfflineContext(1, frameCount, targetRate); | |
| const sourceBuffer = offline.createBuffer(1, samples.length, sourceRate); | |
| sourceBuffer.copyToChannel(samples, 0); | |
| const source = offline.createBufferSource(); | |
| source.buffer = sourceBuffer; | |
| source.connect(offline.destination); | |
| source.start(0); | |
| const rendered = await offline.startRendering(); | |
| return rendered.getChannelData(0).slice(); | |
| } | |
| async function stopRecording() { | |
| if (state !== "recording") return; | |
| setState("transcribing", "Transcribing", "The speech recognition model is listening back"); | |
| const audio = await finishCapture(false); | |
| if (!audio || audio.length < TARGET_SAMPLE_RATE * 0.5) { | |
| setState("ready", "That was too short", "Try a slightly longer recording"); | |
| return; | |
| } | |
| const maxAsrTokens = Math.max(6, Math.floor(audio.length / TARGET_SAMPLE_RATE) * 6); | |
| const asrOutput = await transcriber(audio, { max_new_tokens: maxAsrTokens }); | |
| const raw = (asrOutput?.text ?? "").trim(); | |
| if (!raw) { | |
| showResult("", "", false); | |
| setState("done", "No speech detected", "Ready for another take"); | |
| return; | |
| } | |
| setState("normalizing", "Polishing", "S1-mini is cleaning the transcript"); | |
| await normalize(raw); | |
| setState("done", "Done", "Cleaned locally on WebGPU"); | |
| } | |
| async function normalize(raw) { | |
| const control = | |
| `[Styling: ${elements.styling.value}] ` + | |
| `[Structure: ${elements.structure.value}] ` + | |
| `[Context: ${elements.context.value}]`; | |
| const messages = [ | |
| { role: "system", content: SYSTEM_PROMPT }, | |
| { role: "user", content: `${control}\n${raw}` }, | |
| ]; | |
| const tokenizedRaw = normalizer.tokenizer(raw); | |
| const inputTokens = tokenizedRaw.input_ids?.size ?? tokenizedRaw.input_ids?.dims?.at(-1) ?? 128; | |
| const maxNewTokens = Math.min(1024, Math.max(64, Math.ceil(inputTokens * 1.3) + 32)); | |
| let streamed = ""; | |
| showResult("", raw, true); | |
| const streamer = new TextStreamer(normalizer.tokenizer, { | |
| skip_prompt: true, | |
| skip_special_tokens: true, | |
| callback_function: (text) => { | |
| streamed += text; | |
| elements.cleanText.textContent = streamed; | |
| }, | |
| }); | |
| const output = await normalizer(messages, { | |
| max_new_tokens: maxNewTokens, | |
| do_sample: false, | |
| streamer, | |
| tokenizer_encode_kwargs: { enable_thinking: false }, | |
| }); | |
| const cleaned = (output?.[0]?.generated_text?.at(-1)?.content ?? streamed).trim(); | |
| showResult(cleaned, raw, false); | |
| } | |
| function showResult(cleaned, raw, streaming) { | |
| lastCleanText = cleaned; | |
| elements.result.classList.add("visible"); | |
| elements.cleanText.classList.toggle("streaming", streaming); | |
| elements.cleanText.classList.toggle("empty", !streaming && !cleaned); | |
| elements.cleanText.textContent = cleaned || (streaming ? "" : "No written text — the recording contained only filler or noise."); | |
| elements.rawText.textContent = raw || "No speech detected."; | |
| elements.copy.disabled = streaming || !cleaned; | |
| } | |
| function hideResult() { | |
| elements.result.classList.remove("visible"); | |
| elements.cleanText.classList.remove("streaming", "empty"); | |
| elements.cleanText.textContent = ""; | |
| elements.rawText.textContent = ""; | |
| elements.copy.disabled = true; | |
| lastCleanText = ""; | |
| } | |
| async function cancelRecording() { | |
| if (state !== "recording") return; | |
| await finishCapture(true); | |
| setState("ready", "Cancelled", "Press Record when you’re ready"); | |
| } | |
| async function handlePrimaryAction() { | |
| try { | |
| if (state === "idle" || (state === "error" && (!transcriber || !normalizer))) { | |
| await loadModels(); | |
| } else if (state === "recording") { | |
| await stopRecording(); | |
| } else if (RECORDABLE_STATES.has(state)) { | |
| await startRecording(); | |
| } | |
| } catch (error) { | |
| if (capture) await finishCapture(true); | |
| showError( | |
| error, | |
| error?.message?.includes("WebGPU") | |
| ? "WebGPU is unavailable. Open this page in a current version of Chrome, Edge, or Safari with WebGPU enabled." | |
| : "The local pipeline could not complete. Check the console for details, then try again.", | |
| ); | |
| } | |
| } | |
| function toggleModePopover() { | |
| const open = !elements.modePopover.classList.contains("open"); | |
| elements.modePopover.classList.toggle("open", open); | |
| elements.modePopover.setAttribute("aria-hidden", String(!open)); | |
| elements.modeButton.setAttribute("aria-expanded", String(open)); | |
| } | |
| function closeModePopover() { | |
| elements.modePopover.classList.remove("open"); | |
| elements.modePopover.setAttribute("aria-hidden", "true"); | |
| elements.modeButton.setAttribute("aria-expanded", "false"); | |
| } | |
| async function copyResult() { | |
| if (!lastCleanText) return; | |
| try { | |
| await navigator.clipboard.writeText(lastCleanText); | |
| } catch { | |
| const textarea = document.createElement("textarea"); | |
| textarea.value = lastCleanText; | |
| textarea.style.position = "fixed"; | |
| textarea.style.opacity = "0"; | |
| page.append(textarea); | |
| textarea.select(); | |
| document.execCommand("copy"); | |
| textarea.remove(); | |
| } | |
| elements.copy.textContent = "Copied"; | |
| setTimeout(() => { | |
| elements.copy.textContent = "Copy"; | |
| }, 1400); | |
| } | |
| function resizeCanvas() { | |
| const rect = elements.waveform.getBoundingClientRect(); | |
| const scale = Math.min(devicePixelRatio || 1, 2); | |
| const width = Math.round(rect.width * scale); | |
| const height = Math.round(rect.height * scale); | |
| if ( | |
| elements.waveform.width !== width || | |
| elements.waveform.height !== height || | |
| canvasScale !== scale | |
| ) { | |
| elements.waveform.width = width; | |
| elements.waveform.height = height; | |
| canvasScale = scale; | |
| canvasContext.setTransform(scale, 0, 0, scale, 0, 0); | |
| } | |
| const barCount = Math.max(34, Math.min(84, Math.floor(rect.width / 7))); | |
| if (levels.length !== barCount) { | |
| envelopes = Array.from( | |
| { length: barCount }, | |
| (_, index) => Math.pow(Math.sin((index / (barCount - 1)) * Math.PI), 0.62), | |
| ); | |
| levels = envelopes.map((envelope) => 0.06 + envelope * 0.08); | |
| } | |
| } | |
| function startWaveformAnimation() { | |
| if (!animationFrame && !document.hidden) { | |
| animationFrame = requestAnimationFrame(drawWaveform); | |
| } | |
| } | |
| function stopWaveformAnimation() { | |
| if (!animationFrame) return; | |
| cancelAnimationFrame(animationFrame); | |
| animationFrame = 0; | |
| } | |
| function drawWaveform(time = 0) { | |
| animationFrame = 0; | |
| const width = elements.waveform.clientWidth; | |
| const height = elements.waveform.clientHeight; | |
| canvasContext.clearRect(0, 0, width, height); | |
| if (!width || !height || levels.length === 0) { | |
| startWaveformAnimation(); | |
| return; | |
| } | |
| const isRecording = state === "recording" && capture?.analyser; | |
| const isProcessing = state === "transcribing" || state === "normalizing"; | |
| const isLoading = state === "loading"; | |
| const audioData = isRecording ? capture.waveformData : null; | |
| if (audioData) { | |
| capture.analyser.getByteTimeDomainData(audioData); | |
| } | |
| const levelCount = levels.length; | |
| const lastIndex = levelCount - 1; | |
| const gap = width / levelCount; | |
| const barWidth = Math.max(2, Math.min(3.2, gap * 0.44)); | |
| const center = height / 2; | |
| for (let i = 0; i < levelCount; i++) { | |
| const normalizedPosition = i / lastIndex; | |
| const edgeEnvelope = envelopes[i]; | |
| let target; | |
| if (audioData) { | |
| const from = Math.floor((i / levelCount) * audioData.length); | |
| const to = Math.max(from + 1, Math.floor(((i + 1) / levelCount) * audioData.length)); | |
| let energy = 0; | |
| for (let sample = from; sample < to; sample++) { | |
| const value = (audioData[sample] - 128) / 128; | |
| energy += value * value; | |
| } | |
| target = Math.min(1, Math.sqrt(energy / (to - from)) * 7.5 + 0.035); | |
| } else if (isProcessing) { | |
| const sweep = (Math.sin(time * 0.003 - i * 0.27) + 1) / 2; | |
| target = 0.08 + edgeEnvelope * (0.12 + sweep * 0.27); | |
| } else if (isLoading) { | |
| const pulse = (Math.sin(time * 0.0025 - i * 0.19) + 1) / 2; | |
| target = 0.06 + edgeEnvelope * (0.08 + pulse * 0.14); | |
| } else { | |
| const drift = reducedMotion ? 0.35 : (Math.sin(time * 0.0015 + i * 0.67) + 1) / 2; | |
| target = 0.045 + edgeEnvelope * (0.07 + drift * 0.09); | |
| } | |
| levels[i] += (target - levels[i]) * (audioData ? 0.34 : 0.08); | |
| const barHeight = Math.max(3, levels[i] * height * 0.83); | |
| const x = i * gap + (gap - barWidth) / 2; | |
| const y = center - barHeight / 2; | |
| let alpha = 0.28 + edgeEnvelope * 0.48; | |
| if (isRecording) alpha = 0.48 + levels[i] * 0.52; | |
| if (isLoading && displayProgress > 0) { | |
| alpha = normalizedPosition <= displayProgress / 100 ? 0.82 : 0.18; | |
| } | |
| canvasContext.fillStyle = `rgba(245, 245, 242, ${alpha})`; | |
| canvasContext.beginPath(); | |
| canvasContext.roundRect(x, y, barWidth, barHeight, barWidth / 2); | |
| canvasContext.fill(); | |
| } | |
| startWaveformAnimation(); | |
| } | |
| elements.primary.addEventListener("click", handlePrimaryAction); | |
| elements.cancel.addEventListener("click", () => cancelRecording().catch((error) => showError(error, "Could not cancel the recording cleanly."))); | |
| elements.copy.addEventListener("click", copyResult); | |
| elements.modeButton.addEventListener("click", toggleModePopover); | |
| document.addEventListener("click", (event) => { | |
| if (!event.target.closest?.(".mode-group")) closeModePopover(); | |
| }); | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.hidden) stopWaveformAnimation(); | |
| else startWaveformAnimation(); | |
| }); | |
| window.addEventListener("keydown", (event) => { | |
| const interactive = event.target.closest?.("button, select, input, textarea, summary"); | |
| if (event.key === "Escape") { | |
| if (state === "recording") cancelRecording().catch(console.error); | |
| closeModePopover(); | |
| return; | |
| } | |
| if (event.code === "Space" && !event.repeat && !interactive && !elements.modePopover.classList.contains("open")) { | |
| event.preventDefault(); | |
| handlePrimaryAction(); | |
| } | |
| }); | |
| const resizeObserver = new ResizeObserver(resizeCanvas); | |
| resizeObserver.observe(elements.waveform); | |
| resizeCanvas(); | |
| startWaveformAnimation(); | |
| window.addEventListener("pagehide", () => { | |
| stopWaveformAnimation(); | |
| resizeObserver.disconnect(); | |
| if (capture) { | |
| capture.stream.getTracks().forEach((track) => track.stop()); | |
| capture.context.close().catch(() => {}); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |