| <!doctype html> |
| <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 !important; |
| 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 !important; |
| animation-duration: 0.001ms !important; |
| animation-iteration-count: 1 !important; |
| transition-duration: 0.001ms !important; |
| } |
| } |
| </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> |
|
|