Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Inflect v2 · studio</title> | |
| <link rel="icon" href="/web_runtime/favicon.svg" type="image/svg+xml" /> | |
| <style> | |
| :root{ | |
| color-scheme:dark; | |
| --bg:#000000;--bg-2:#08090a; | |
| --surface:#16181a;--surface-2:#1c1e20;--inset:#101213; | |
| --well:#0c0d0e; | |
| --ink:#f5f5f7;--muted:#98989d;--dim:#6c6c70; | |
| --line:#2a2c2e;--line-soft:#232527;--line-faint:#1d1f21; | |
| --accent:#0a84ff;--accent-press:#3a9bff;--accent-glow:rgba(10,132,255,.4); | |
| --teal:#30d4c2;--amber:#ff9f0a;--green:#30d158;--warn:#ff453a; | |
| --shadow:0 1px 3px rgba(0,0,0,.4),0 8px 28px rgba(0,0,0,.5); | |
| --shadow-lg:0 2px 8px rgba(0,0,0,.45),0 24px 60px rgba(0,0,0,.6); | |
| --ring:0 0 0 4px rgba(10,132,255,.22); | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;padding:0} | |
| body{ | |
| background: | |
| radial-gradient(90% 60% at 50% -8%,rgba(10,132,255,.08),transparent 60%), | |
| var(--bg); | |
| color:var(--ink); | |
| font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",Inter,ui-sans-serif,"Segoe UI",sans-serif; | |
| line-height:1.5;-webkit-font-smoothing:antialiased;letter-spacing:-.003em; | |
| min-height:100vh;display:flex;flex-direction:column; | |
| } | |
| .mono{font-family:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace} | |
| /* ---- top bar ---- */ | |
| .topbar{ | |
| display:flex;align-items:center;gap:16px;padding:13px clamp(16px,4vw,40px); | |
| background:rgba(0,0,0,.62);backdrop-filter:saturate(180%) blur(20px); | |
| border-bottom:1px solid var(--line-soft);position:sticky;top:0;z-index:20; | |
| } | |
| .brand{display:flex;align-items:center;gap:11px} | |
| .logo{width:32px;height:32px;border-radius:9px;flex-shrink:0;background:linear-gradient(140deg,#2bd4c4,#0a7a6e);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(43,212,196,.35)} | |
| .logo svg{width:17px;height:17px} | |
| .brand .name{font-weight:600;font-size:15px;letter-spacing:-.01em;line-height:1.05} | |
| .brand .name small{display:block;font-weight:400;font-size:11px;color:var(--muted);margin-top:1px} | |
| .spacer{flex:1} | |
| .meters{display:flex;align-items:center;gap:16px} | |
| .meter{display:flex;flex-direction:column;gap:2px;align-items:flex-end} | |
| .meter .lab{font:600 9px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)} | |
| .meter .val{font:600 12px/1 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} | |
| .meter .val.accent{color:var(--teal)} | |
| .led{width:9px;height:9px;border-radius:50%;background:var(--dim);transition:background .2s,box-shadow .2s} | |
| .led.on{background:var(--teal);box-shadow:0 0 10px rgba(48,212,194,.8)} | |
| .led.busy{background:var(--accent);box-shadow:0 0 10px var(--accent-glow);animation:pulse 1s infinite} | |
| @keyframes pulse{50%{opacity:.4}} | |
| .segctrl{display:inline-flex;background:rgba(120,120,128,.18);border-radius:10px;padding:2px;gap:2px} | |
| .segctrl button{appearance:none;border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:13px;font-weight:550;padding:6px 16px;border-radius:8px;transition:color .18s,background .18s,box-shadow .18s} | |
| .segctrl button.active{background:var(--surface-2);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.4),0 0 0 1px var(--line)} | |
| .link{color:var(--muted);font-size:13px;font-weight:450;text-decoration:none;padding:7px 9px;border-radius:8px;transition:color .15s,background .15s} | |
| .link:hover{color:var(--ink);background:rgba(255,255,255,.06)} | |
| /* ---- shell ---- */ | |
| .shell{flex:1;display:flex;flex-direction:column;min-height:0} | |
| .pane{display:none;flex:1;min-height:0;flex-direction:column} | |
| .pane.active{display:flex} | |
| .stage-scroll{flex:1;overflow-y:auto} | |
| .page{max-width:820px;margin:0 auto;padding:28px clamp(16px,4vw,40px) 80px;width:100%} | |
| /* ---- cards ---- */ | |
| .card{background:var(--surface);border:1px solid var(--line-soft);border-radius:18px;box-shadow:var(--shadow);overflow:hidden} | |
| .card+.card{margin-top:16px} | |
| .card-pad{padding:20px 22px} | |
| .card-head{display:flex;align-items:center;gap:11px;padding:15px 20px;border-bottom:1px solid var(--line-soft);background:linear-gradient(180deg,rgba(255,255,255,.02),transparent)} | |
| .card-head .tag{font:600 10px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)} | |
| .card-head .ic{width:26px;height:26px;border-radius:7px;background:var(--inset);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--teal)} | |
| .card-head .grow{flex:1} | |
| .label{font-size:13px;font-weight:600;color:var(--ink);margin-bottom:9px;display:block;letter-spacing:-.01em} | |
| textarea,input[type="text"],input[type="number"]{ | |
| width:100%;background:var(--well);color:var(--ink);border:1px solid var(--line);border-radius:12px; | |
| padding:14px 15px;font-size:16px;font-family:inherit;outline:none;transition:border-color .15s,box-shadow .15s;line-height:1.5; | |
| } | |
| textarea::placeholder,input::placeholder{color:var(--dim)} | |
| textarea:focus,input:focus{border-color:var(--accent);box-shadow:var(--ring)} | |
| textarea{min-height:108px;resize:vertical} | |
| .examples{display:flex;flex-wrap:wrap;gap:8px;margin-top:11px} | |
| .examples button{background:var(--inset);border:1px solid var(--line-soft);border-radius:100px;color:var(--muted);font-size:12px;padding:6px 13px;cursor:pointer;transition:all .15s;font-family:inherit} | |
| .examples button:hover{color:var(--teal);border-color:var(--teal);background:rgba(48,212,194,.08)} | |
| /* segmented (model) */ | |
| .segmented{display:flex;background:var(--inset);border:1px solid var(--line-soft);border-radius:12px;padding:3px;gap:3px} | |
| .segmented label{flex:1;position:relative} | |
| .segmented input{position:absolute;opacity:0;width:0;height:0} | |
| .segmented label span{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px 10px;border-radius:9px;cursor:pointer;transition:background .15s,box-shadow .15s;text-align:center} | |
| .segmented label strong{font-size:13px;font-weight:550;color:var(--muted);transition:color .15s} | |
| .segmented label small{font-size:11px;color:var(--dim);margin-top:2px;font-weight:400;transition:color .15s} | |
| .segmented label input:checked+span{background:var(--surface-2);box-shadow:0 0 0 1px var(--line),0 1px 3px rgba(0,0,0,.4)} | |
| .segmented label input:checked+span strong{color:var(--ink)} | |
| .segmented label input:checked+span small{color:var(--muted)} | |
| /* grouped rows */ | |
| .group{background:var(--inset);border:1px solid var(--line-soft);border-radius:12px;overflow:hidden} | |
| .row{padding:14px 16px;display:flex;flex-direction:column;gap:10px} | |
| .row+.row{border-top:1px solid var(--line-faint)} | |
| .row .top{display:flex;justify-content:space-between;align-items:center} | |
| .row .top label{font-size:15px;font-weight:500;color:var(--ink);margin:0} | |
| .row .top .val{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;font-weight:500} | |
| input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--line);border-radius:4px;outline:none} | |
| input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:26px;height:26px;border-radius:50%;background:var(--surface-2);cursor:pointer;border:0;box-shadow:0 0 0 1px var(--line),0 2px 8px rgba(0,0,0,.5);transition:transform .1s} | |
| input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.08)} | |
| input[type="range"]::-webkit-slider-thumb:active{transform:scale(1.12);box-shadow:0 0 0 1px var(--accent),var(--ring)} | |
| input[type="range"]::-moz-range-thumb{width:22px;height:22px;border:0;border-radius:50%;background:var(--surface-2);cursor:pointer;box-shadow:0 0 0 1px var(--line),0 2px 8px rgba(0,0,0,.5)} | |
| .row small{font-size:12px;color:var(--muted);margin-top:-2px;line-height:1.4} | |
| /* advanced disclosure */ | |
| details.adv{margin-top:16px;border:1px solid var(--line-soft);border-radius:12px;background:var(--surface-2);overflow:hidden} | |
| details.adv summary{list-style:none;padding:14px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;font-size:15px;font-weight:500;color:var(--ink)} | |
| details.adv summary::-webkit-details-marker{display:none} | |
| details.adv summary .chev{margin-left:auto;color:var(--muted);transition:transform .22s ease;font-size:13px} | |
| details.adv[open] summary .chev{transform:rotate(90deg)} | |
| details.adv summary .dot-i{width:7px;height:7px;border-radius:50%;background:var(--dim)} | |
| details.adv .body{padding:0 16px 16px;display:grid;grid-template-columns:1fr;gap:0} | |
| details.adv .body .group{border:1px solid var(--line-soft)} | |
| /* actions */ | |
| .actions{display:flex;gap:12px;margin-top:18px;align-items:stretch} | |
| .btn{display:flex;align-items:center;justify-content:center;gap:9px;border-radius:14px;cursor:pointer;font:inherit;font-size:16px;font-weight:560;border:0;transition:background .15s,transform .05s,box-shadow .15s} | |
| .btn:active{transform:scale(.985)} | |
| .btn:disabled{opacity:.4;cursor:not-allowed} | |
| .btn.primary{flex:1;min-height:52px;background:var(--accent);color:#fff;box-shadow:0 1px 2px rgba(10,132,255,.4),0 6px 20px rgba(10,132,255,.3)} | |
| .btn.primary:hover{background:var(--accent-press)} | |
| .btn.secondary{background:var(--surface-2);color:var(--ink);min-height:52px;padding:0 20px;border:1px solid var(--line)} | |
| .btn.secondary:hover{background:#26292b} | |
| .kbd{font-size:11px;font-weight:500;opacity:.75;padding:2px 6px;border-radius:5px;background:rgba(255,255,255,.16)} | |
| /* status */ | |
| .status{margin-top:14px;font-size:13px;color:var(--muted);line-height:1.55;min-height:1.2em;display:flex;align-items:center;gap:9px} | |
| .status .dot{width:8px;height:8px;border-radius:50%;background:var(--dim);flex-shrink:0;transition:background .2s,box-shadow .2s} | |
| .status.ok .dot{background:var(--green);box-shadow:0 0 0 3px rgba(48,209,88,.2)} | |
| .status.busy .dot{background:var(--accent);animation:pulse 1s infinite} | |
| .status.err .dot{background:var(--warn);box-shadow:0 0 0 3px rgba(255,69,58,.2)} | |
| .status .txt{min-width:0} | |
| .status .txt b{color:var(--ink);font-weight:600} | |
| .status .txt code{font-family:var(--mono);font-size:12px;background:var(--inset);padding:1px 5px;border-radius:5px;color:var(--muted)} | |
| .queue{font-size:12px;color:var(--accent);white-space:nowrap} | |
| .busy-spin{display:inline-block;width:13px;height:13px;border:2.5px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| /* ---- player ---- */ | |
| .player{display:none} | |
| .player.show{display:block;animation:rise .42s cubic-bezier(.22,1,.36,1)} | |
| @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} | |
| .np{display:flex;align-items:center;gap:13px;margin-bottom:16px} | |
| .np-badge{width:44px;height:44px;border-radius:11px;flex-shrink:0;background:linear-gradient(140deg,#34d8c8,#0a7a6e);display:flex;align-items:center;justify-content:center;color:#fff;box-shadow:0 3px 12px rgba(14,143,130,.45);transition:box-shadow .25s} | |
| .np-badge.playing{box-shadow:0 3px 12px rgba(14,143,130,.6),0 0 0 5px rgba(48,212,196,.18)} | |
| .np-text{min-width:0} | |
| .np-text .title{font-size:16px;font-weight:600;letter-spacing:-.01em} | |
| .np-text .sub{font-size:12.5px;color:var(--muted);font-variant-numeric:tabular-nums} | |
| .scrub-row{display:flex;align-items:center;gap:13px;margin-bottom:12px} | |
| .tc{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:38px;text-align:center;font-weight:500} | |
| .tc.now{color:var(--accent)} | |
| .wave{position:relative;flex:1;min-width:0;height:72px;background:var(--well);border:1px solid var(--line-faint);border-radius:10px;overflow:hidden;cursor:pointer} | |
| .wave canvas{position:absolute;inset:0;width:100%;height:100%;display:block} | |
| .wave .ph{position:absolute;top:0;bottom:0;width:2px;background:var(--accent);box-shadow:0 0 10px var(--accent-glow);pointer-events:none;transform:translateX(-1px);border-radius:2px} | |
| .wave .center{position:absolute;left:0;right:0;top:50%;height:1px;background:var(--line-faint);pointer-events:none} | |
| .seek{width:100%} | |
| .tctrl{display:flex;align-items:center;gap:11px;margin-bottom:6px} | |
| .tbtn{width:30px;height:30px;border-radius:50%;border:0;background:transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:color .15s,background .15s;font-size:14px} | |
| .tbtn:hover{color:var(--ink);background:var(--inset)} | |
| .tbtn:disabled{opacity:.35;cursor:not-allowed} | |
| .tbtn.play{width:50px;height:50px;background:var(--accent);color:#fff;font-size:17px;box-shadow:0 2px 10px var(--accent-glow)} | |
| .tbtn.play:hover{background:var(--accent-press)} | |
| .tbtn.play:disabled{background:var(--line);color:var(--dim);box-shadow:none} | |
| /* meters */ | |
| .meterbars{display:flex;gap:4px;align-items:flex-end;height:30px;margin-top:6px;padding:0 2px} | |
| .meterbars .bar{flex:1;background:var(--line);border-radius:2px;min-height:3px;transition:height .06s linear,background .1s} | |
| .meterbars .bar.lev{background:linear-gradient(180deg,var(--teal),#0a7a6e)} | |
| .meterbars .bar.peak{background:var(--amber)} | |
| /* karaoke */ | |
| .script{margin-top:16px;padding:16px 4px 4px;font-size:19px;line-height:1.85;letter-spacing:.001em;color:var(--muted);max-width:680px} | |
| .script .empty{font-size:14px;color:var(--dim)} | |
| .karaoke .chunk{display:inline;color:#5c5d61;transition:color .25s ease,background-color .18s ease;border-radius:3px;padding:0 1px} | |
| .karaoke .chunk.spoken{color:#8a8b90} | |
| .karaoke .chunk.speaking{color:var(--ink)} | |
| .karaoke .chunk.speaking::after{content:"";display:inline-block;width:3px;height:1.1em;background:var(--accent);margin-left:2px;vertical-align:-3px;border-radius:1px;animation:blink 1.05s steps(2) infinite} | |
| @keyframes blink{50%{opacity:.25}} | |
| /* tools */ | |
| .tools{display:flex;align-items:center;gap:14px;margin-top:14px;flex-wrap:wrap} | |
| a.dl{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:100px;background:var(--surface-2);color:var(--ink);font-size:13px;font-weight:500;text-decoration:none;border:1px solid var(--line);transition:all .15s} | |
| a.dl:hover{background:rgba(10,132,255,.12);color:var(--accent);border-color:var(--accent)} | |
| .chip{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} | |
| /* compare */ | |
| .cmp-wrap{display:none} | |
| .cmp-wrap.show{display:block;animation:rise .42s cubic-bezier(.22,1,.36,1)} | |
| .cmp-title{font-size:13px;font-weight:600;color:var(--muted);margin:30px 0 12px;display:flex;align-items:center;gap:9px} | |
| .cmp-title .line{flex:1;height:1px;background:var(--line-soft)} | |
| .compare-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} | |
| .track{background:var(--surface);border:1px solid var(--line-soft);border-radius:16px;box-shadow:var(--shadow);overflow:hidden} | |
| .track .th{padding:14px 16px 0;display:flex;align-items:center;gap:9px} | |
| .track .th .dot{width:9px;height:9px;border-radius:50%} | |
| .track .th .nm{font-size:14px;font-weight:600} | |
| .track .th .nm small{display:block;font-size:11px;color:var(--muted);font-weight:400;margin-top:1px} | |
| .track .tb{padding:10px 16px 16px} | |
| .mini-wave{position:relative;height:50px;background:var(--well);border:1px solid var(--line-faint);border-radius:8px;overflow:hidden;cursor:pointer;margin-bottom:10px} | |
| .mini-wave canvas{position:absolute;inset:0;width:100%;height:100%} | |
| .mini-wave .ph{position:absolute;top:0;bottom:0;width:2px;background:var(--accent);pointer-events:none;transform:translateX(-1px)} | |
| .mini-ctrl{display:flex;align-items:center;gap:10px} | |
| .mini-play{width:34px;height:34px;border-radius:50%;border:0;background:var(--accent);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .1s;font-size:12px;box-shadow:0 2px 8px var(--accent-glow)} | |
| .mini-play:hover{background:var(--accent-press)} | |
| .mini-play:active{transform:scale(.94)} | |
| .mini-play:disabled{background:var(--line);box-shadow:none;cursor:not-allowed} | |
| .mini-tc{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap} | |
| .mini-tc .now{color:var(--accent)} | |
| .mini-seek{flex:1;min-width:0} | |
| .mini-dl{font-size:12px;padding:6px 11px;border-radius:100px} | |
| /* browser pane */ | |
| .browser-pane{flex:1;display:flex;flex-direction:column;min-height:0} | |
| .browser-head{padding:20px clamp(16px,4vw,40px) 0;max-width:820px;margin:0 auto;width:100%} | |
| .browser-head h2{margin:0 0 5px;font-size:17px;font-weight:600} | |
| .browser-head p{margin:0;color:var(--muted);font-size:13px;max-width:640px;line-height:1.5} | |
| .browser-frame-shell{flex:1;min-height:0;margin:16px clamp(16px,4vw,40px);border:1px solid var(--line-soft);border-radius:18px;background:#000;overflow:hidden;box-shadow:var(--shadow-lg)} | |
| .browser-frame{display:block;width:100%;height:580px;border:0;background:#000} | |
| .browser-help{margin:0 clamp(16px,4vw,40px) 28px;color:var(--muted);font-size:12px;line-height:1.55;max-width:820px;margin-left:auto;margin-right:auto} | |
| .fineprint{margin-top:30px;font-size:11.5px;color:var(--dim);line-height:1.6;text-align:center;max-width:560px;margin-left:auto;margin-right:auto} | |
| .hidden{display:none!important} | |
| @media(max-width:680px){ | |
| .page{padding:18px 14px 56px} | |
| .meters{display:none} | |
| .compare-grid{grid-template-columns:1fr} | |
| details.adv .body{grid-template-columns:1fr} | |
| .browser-frame{height:1000px} | |
| .actions{flex-direction:column} | |
| .btn.secondary{padding:0 20px;min-height:48px} | |
| .card-pad{padding:18px 16px} | |
| .script{font-size:17px;line-height:1.8} | |
| .tctrl{flex-wrap:wrap} | |
| a.dl{margin-left:0} | |
| .np-badge{width:40px;height:40px} | |
| } | |
| @media(max-width:520px){ | |
| .topbar{padding:11px 14px;gap:10px} | |
| .brand .name small{display:none} | |
| .link:not(.always){display:none} | |
| .segctrl button{padding:6px 12px;font-size:12px} | |
| .scrub-row{flex-wrap:nowrap;gap:9px} | |
| .wave{height:56px} | |
| .tc{min-width:34px;font-size:11px} | |
| .tbtn{width:28px;height:28px} | |
| .tbtn.play{width:46px;height:46px} | |
| .mini-seek{display:none} | |
| .mini-tc{font-size:11px} | |
| .meterbars{height:24px} | |
| } | |
| @media(max-width:380px){ | |
| .brand .name{font-size:14px} | |
| .logo{width:28px;height:28px} | |
| .segctrl button{padding:6px 10px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <div class="logo" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2.4" fill="#fff"/><circle cx="17" cy="16" r="2.4" fill="#fff"/></svg> | |
| </div> | |
| <div class="name">Inflect v2<small>speech studio</small></div> | |
| </div> | |
| <div class="spacer"></div> | |
| <div class="meters"> | |
| <div class="meter"><span class="lab">Rate</span><span class="val" id="m-sr">24 kHz</span></div> | |
| <div class="meter"><span class="lab">RTF</span><span class="val accent" id="m-rtf">—</span></div> | |
| <div class="led" id="status-led" title="Idle"></div> | |
| </div> | |
| <div class="segctrl" role="tablist"> | |
| <button class="active" id="tab-hosted" role="tab">Hosted</button> | |
| <button id="tab-browser" role="tab">Browser</button> | |
| </div> | |
| <a class="link always" href="https://github.com/owenawsong/Inflect" target="_blank" rel="noopener">GitHub</a> | |
| </header> | |
| <div class="shell"> | |
| <!-- HOSTED --> | |
| <section id="pane-hosted" class="pane active"> | |
| <div class="stage-scroll"> | |
| <div class="page"> | |
| <div class="card"> | |
| <div class="card-head"><span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2" fill="currentColor"/><circle cx="17" cy="16" r="2" fill="currentColor"/></svg></span><span class="tag">Script</span></div> | |
| <div class="card-pad"> | |
| <label class="label" for="text">What should Inflect say?</label> | |
| <textarea id="text" placeholder="Write a sentence, paragraph, or script…">Wait, are you actually being for real? I thought the train left twenty minutes ago!</textarea> | |
| <div class="examples" id="examples"></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-head"><span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M7 10c2-1.5 8-1.5 10 0M8 13c1.6-1 6.4-1 8 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span><span class="tag">Voice & Delivery</span></div> | |
| <div class="card-pad"> | |
| <label class="label">Voice model</label> | |
| <div class="segmented" id="model-seg"> | |
| <label><input type="radio" name="model" value="Inflect Micro v2" checked><span><strong>Micro v2</strong><small>Higher quality · 9.36M</small></span></label> | |
| <label><input type="radio" name="model" value="Inflect Nano v2"><span><strong>Nano v2</strong><small>Lighter · 3.96M</small></span></label> | |
| </div> | |
| <details class="adv"> | |
| <summary><span class="dot-i"></span><span>Advanced</span><span class="chev">▸</span></summary> | |
| <div class="body"> | |
| <div class="group"> | |
| <div class="row"> | |
| <div class="top"><label for="speed">Speaking speed</label><span class="val" id="speed-val">1.00×</span></div> | |
| <input type="range" id="speed" min="0.7" max="1.35" step="0.01" value="1.0"> | |
| </div> | |
| <div class="row"> | |
| <div class="top"><label for="variation">Delivery variation</label><span class="val" id="variation-val">Steady</span></div> | |
| <input type="range" id="variation" min="0" max="1" step="0.001" value="0.667"> | |
| <small>Lower is steadier; higher gives each take more character.</small> | |
| </div> | |
| <div class="row"> | |
| <div class="top"><label for="pitch">Pitch shift</label><span class="val" id="pitch-val">0 st</span></div> | |
| <input type="range" id="pitch" min="-2" max="2" step="0.25" value="0"> | |
| </div> | |
| <div class="row"> | |
| <div class="top"><label for="seed">Repeatable seed</label><span class="val" id="seed-val">0</span></div> | |
| <input type="number" id="seed" value="0" step="1" inputmode="numeric"> | |
| </div> | |
| </div> | |
| </div> | |
| </details> | |
| <div class="actions"> | |
| <button class="btn primary" id="generate">Generate<span class="kbd">⌘↵</span></button> | |
| <button class="btn secondary" id="compare-btn">Compare models</button> | |
| </div> | |
| <div class="status" id="status"><span class="dot"></span><span class="txt" id="status-txt">Ready.</span><span class="queue hidden" id="queue"></span></div> | |
| </div> | |
| </div> | |
| <!-- player --> | |
| <div class="card player" id="player"> | |
| <div class="card-head"><span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M4 12h3l3-7 4 14 3-7h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="tag">Playback</span><div class="grow"></div><span class="chip" id="take-spec">—</span></div> | |
| <div class="card-pad"> | |
| <div class="np"> | |
| <div class="np-badge" id="np-badge"><svg width="19" height="19" viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2.2" fill="currentColor"/><circle cx="17" cy="16" r="2.2" fill="currentColor"/></svg></div> | |
| <div class="np-text"><div class="title" id="np-title">Inflect Micro v2</div><div class="sub" id="np-sub">—</div></div> | |
| </div> | |
| <div class="scrub-row"> | |
| <span class="tc now" id="now">0:00</span> | |
| <div class="wave" id="wave"><canvas id="wave-canvas"></canvas><div class="center"></div><div class="ph" id="playhead" style="left:0%"></div></div> | |
| <span class="tc" id="dur">0:00</span> | |
| </div> | |
| <input type="range" class="seek" id="seek" min="0" max="1000" value="0" disabled aria-label="Seek"> | |
| <div class="tctrl"> | |
| <button class="tbtn play" id="play" title="Play" disabled>▶</button> | |
| <button class="tbtn" id="restart" title="Restart" disabled>⏮</button> | |
| <button class="tbtn" id="stop" title="Stop" disabled>⏹</button> | |
| <div style="flex:1"></div> | |
| <a class="dl" id="download" download="inflect_v2.wav">Download</a> | |
| </div> | |
| <div class="meterbars" id="meters" aria-hidden="true"></div> | |
| <div class="script"><div class="karaoke fade" id="karaoke"><div class="empty">Your text will appear here, highlighting as it's spoken.</div></div></div> | |
| </div> | |
| </div> | |
| <!-- compare (shown after Compare models is used) --> | |
| <div class="cmp-wrap" id="cmp-wrap"> | |
| <div class="cmp-title"><span>Model comparison</span><span class="line"></span><span class="chip" id="cmp-chip">same text · same seed</span></div> | |
| <div class="compare-grid"> | |
| <div class="track"> | |
| <div class="th"><span class="dot" style="background:#30d4c2"></span><span class="nm">Micro v2<small>9.36M · quality first</small></span><div style="flex:1"></div><a class="dl mini-dl" id="micro-dl" download="inflect_micro.wav">WAV</a></div> | |
| <div class="tb"> | |
| <div class="mini-wave" id="micro-wave"><canvas id="micro-canvas"></canvas><div class="ph" id="micro-ph" style="left:0%"></div></div> | |
| <div class="mini-ctrl"> | |
| <button class="mini-play" data-mini="micro" disabled>▶</button> | |
| <span class="mini-tc"><span class="now" id="micro-now">0:00</span> / <span id="micro-dur">0:00</span></span> | |
| <input type="range" id="micro-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Micro seek"> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="track"> | |
| <div class="th"><span class="dot" style="background:#ff9f0a"></span><span class="nm">Nano v2<small>3.96M · footprint first</small></span><div style="flex:1"></div><a class="dl mini-dl" id="nano-dl" download="inflect_nano.wav">WAV</a></div> | |
| <div class="tb"> | |
| <div class="mini-wave" id="nano-wave"><canvas id="nano-canvas"></canvas><div class="ph" id="nano-ph" style="left:0%"></div></div> | |
| <div class="mini-ctrl"> | |
| <button class="mini-play" data-mini="nano" disabled>▶</button> | |
| <span class="mini-tc"><span class="now" id="nano-now">0:00</span> / <span id="nano-dur">0:00</span></span> | |
| <input type="range" id="nano-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Nano seek"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <p class="fineprint">Fixed English voice · English-only · no voice cloning or reference audio. Uncommon names and long passages can still stumble.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- BROWSER --> | |
| <section id="pane-browser" class="pane"> | |
| <div class="browser-pane"> | |
| <div class="browser-head"> | |
| <h2>Run it in your browser</h2> | |
| <p>Your text and audio stay on this device. WebGPU is preferred; compatible browsers fall back to WASM. No queue, no server quota.</p> | |
| </div> | |
| <div class="browser-frame-shell"> | |
| <iframe class="browser-frame" id="webgpu-frame" src="/web_runtime/index.html?embed=1" title="Inflect browser-local runtime" allow="autoplay; webgpu" loading="eager"></iframe> | |
| </div> | |
| <p class="browser-help">The first run downloads the selected ONNX model and compiles it on your device. Streaming and complete-audio modes are both available inside this panel.</p> | |
| </div> | |
| </section> | |
| </div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const fmtTime = (s) => { if (!Number.isFinite(s) || s < 0) s = 0; const m = Math.floor(s/60); return `${m}:${Math.floor(s%60).toString().padStart(2,"0")}`; }; | |
| const variationLabel = (v) => v < 0.2 ? "Very steady" : v < 0.45 ? "Steady" : v < 0.75 ? "Natural" : v < 0.92 ? "Expressive" : "Very expressive"; | |
| /* prompts */ | |
| const PROMPTS = [ | |
| "Wait, are you actually being for real? I thought the train left twenty minutes ago!", | |
| "Does punctuation work? Yes: commas, semicolons, and periods should create sensible boundaries.", | |
| "Professor Nguyen mailed the fluorescent poster to Reykjavik on Thursday.", | |
| "Although the weather changed without warning, the musicians finished packing before anyone opened the enormous glass door.", | |
| ]; | |
| const exEl = $("examples"); | |
| PROMPTS.forEach((p) => { const b=document.createElement("button"); b.type="button"; b.textContent=p.length>42?p.slice(0,40)+"…":p; b.title=p; b.addEventListener("click",()=>{ $("text").value=p; }); exEl.appendChild(b); }); | |
| /* tabs */ | |
| const setTab = (which) => { const b = which === "browser"; | |
| $("tab-hosted").classList.toggle("active",!b); $("tab-browser").classList.toggle("active",b); | |
| $("pane-hosted").classList.toggle("active",!b); $("pane-browser").classList.toggle("active",b); | |
| }; | |
| $("tab-hosted").addEventListener("click",()=>setTab("hosted")); | |
| $("tab-browser").addEventListener("click",()=>setTab("browser")); | |
| /* readouts */ | |
| $("speed").addEventListener("input",()=>{ $("speed-val").textContent=parseFloat($("speed").value).toFixed(2)+"×"; }); | |
| $("speed-val").textContent="1.00×"; | |
| $("variation").addEventListener("input",()=>{ $("variation-val").textContent=variationLabel(parseFloat($("variation").value)); }); | |
| $("variation-val").textContent=variationLabel(parseFloat($("variation").value)); | |
| $("pitch").addEventListener("input",()=>{ $("pitch-val").textContent=parseFloat($("pitch").value).toFixed(2)+" st"; }); | |
| $("pitch-val").textContent="0.00 st"; | |
| $("seed").addEventListener("input",(e)=>{ $("seed-val").textContent=e.target.value||"0"; }); | |
| /* ---- theme the embedded browser runtime ---- */ | |
| (function(){ | |
| const frame = $("webgpu-frame"); | |
| const THEME = ` | |
| :root{color-scheme:dark} | |
| html,body{background:#07080a !important;color:#f5f5f7 !important} | |
| body{background:#07080a !important;color:#f5f5f7 !important;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Inter,ui-sans-serif,sans-serif !important} | |
| #root{background:#07080a !important;color:#f5f5f7 !important} | |
| .surface-panel, .bg-white{background:#16181a !important;border-color:#2a2c2e !important} | |
| .bg-slate-50, .bg-slate-50\\/80, .embedded-sidebar{background:#0e0f10 !important} | |
| .bg-slate-100{background:#1c1e20 !important} | |
| .border-slate-200, .border-slate-300{border-color:#2a2c2e !important} | |
| .border-blue-600{border-color:#0a84ff !important} | |
| .text-slate-950, .text-slate-900{color:#f5f5f7 !important} | |
| .text-slate-700{color:#d1d1d6 !important} | |
| .text-slate-600{color:#98989d !important} | |
| .text-slate-500{color:#86868b !important} | |
| .text-slate-400{color:#6c6c70 !important} | |
| .hover\\:text-blue-600:hover{color:#4d9bff !important} | |
| .text-blue-600{color:#4d9bff !important} | |
| .speech-input{background:#0c0d0e !important;border-color:#2a2c2e !important;color:#f5f5f7 !important} | |
| .speech-input:focus{border-color:#0a84ff !important;box-shadow:0 0 0 4px rgba(10,132,255,.22) !important} | |
| .speech-input::placeholder{color:#6c6c70 !important} | |
| textarea, input[type="text"]{background:#0c0d0e !important;color:#f5f5f7 !important;border-color:#2a2c2e !important} | |
| .model-option-selected{background:rgba(10,132,255,.16) !important;border-color:#0a84ff !important;color:#f5f5f7 !important} | |
| .segmented-selected{background:#0a84ff !important} | |
| .h-full.bg-blue-500{background:#0a84ff !important} | |
| input[type="range"]::-webkit-slider-thumb{background:#1c1e20 !important} | |
| input[type="range"]{background:#2a2c2e !important} | |
| :root{ | |
| --dt-bg:#0c0d0e; --dt-surface-selected:#1c2126; --dt-border:#2a2c2e; | |
| --dt-text:#e5e5ea; --dt-text-muted:#86868b; --dt-accent:#0a84ff; | |
| --dt-live:#30d158; --dt-paused:#ff9f0a; --dt-tree-hover:#1c1e20; | |
| --dt-json-string:#30d158; --dt-json-number:#64d2ff; --dt-json-boolean:#ff9f0a; | |
| --dt-json-null:#86868b; --dt-json-key:#64d2ff; --dt-json-tag:#5ac8fa; | |
| --dt-json-preview:#86868b; --dt-json-arrow:#86868b; --dt-diff-changed:#64d2ff; | |
| } | |
| [style*="#f9fafb"]{background:#0c0d0e !important} | |
| [style*="#ffffff"]{background:#16181a !important} | |
| [style*="#f3f4f6"]{background:#0e0f10 !important;border-color:#2a2c2e !important} | |
| [style*="#e5e7eb"]{border-color:#2a2c2e !important} | |
| [style*="#111827"]{color:#f5f5f7 !important} | |
| [style*="#6b7280"]{color:#98989d !important} | |
| [style*="#dc2626"]{color:#ff453a !important} | |
| ::-webkit-scrollbar-thumb{background:#2a2c2e !important} | |
| /* ADVANCED sections hidden by default; shown when body.studio-adv-open */ | |
| body:not(.studio-adv-open) .voice-controls{display:none !important} | |
| body:not(.studio-adv-open) .playback-setting{display:none !important} | |
| body:not(.studio-adv-open) .session-status{display:none !important} | |
| body:not(.studio-adv-open) .embedded-logs{display:none !important} | |
| /* Compute backend has no stable class — hide by JS tagging via .studio-adv-section */ | |
| .studio-adv-section{display:none !important} | |
| body.studio-adv-open .studio-adv-section{display:flex !important} | |
| /* compact model picker */ | |
| .model-picker{gap:8px !important} | |
| .model-options-grid{grid-template-columns:1fr 1fr !important;gap:8px !important} | |
| .model-option{padding:9px 11px !important;border-radius:10px !important} | |
| .model-option .text-sm{font-size:13px !important} | |
| .model-option .text-xs{font-size:11px !important} | |
| .model-option>div:nth-child(3){display:none !important} | |
| .model-option>div.mt-3{display:none !important} | |
| .model-option .h-2.w-2{height:7px !important;width:7px !important} | |
| /* kill empty space: stop compose/sidebar from stretching */ | |
| .embedded-compose, .embedded-sidebar{align-self:start !important;flex:0 0 auto !important} | |
| .embedded-panel{align-items:start !important} | |
| .embedded-grid{align-items:start !important;gap:0 !important} | |
| .embedded-compose{gap:14px !important;padding:18px !important} | |
| .embedded-sidebar{padding:18px !important;gap:14px !important} | |
| /* hide empty bottom blocks */ | |
| .embedded-compose>div.border-t.border-slate-200.pt-6:empty, | |
| .embedded-compose>div.border-t.border-slate-200.pt-6:not(:has(*)), | |
| .embedded-compose>.flex.w-full:empty, | |
| .embedded-compose>.flex.w-full:not(:has(*)){display:none !important} | |
| .embedded-compose>.flex.w-full:not(:has(a:not(.hidden)):not(button)){display:none !important} | |
| .embedded-compose>div:last-child:not(.flex):empty{display:none !important} | |
| .section-label{font-size:11px !important;letter-spacing:.1em !important;text-transform:uppercase;color:#86868b !important} | |
| .embedded-compose>div:first-child>h2, | |
| .embedded-compose .text-lg{font-size:15px !important;margin-top:2px !important} | |
| .embedded-compose .font-mono.text-\\[11px\\]{display:none !important} | |
| .embedded-compose .flex.items-end.justify-between{margin-bottom:2px !important} | |
| /* Advanced toggle (injected) */ | |
| .studio-adv-toggle{ | |
| appearance:none;border:1px solid #2a2c2e;background:#1c1e20;color:#f5f5f7; | |
| border-radius:12px;padding:13px 15px;width:100%;display:flex;align-items:center;gap:10px; | |
| font:inherit;font-size:14px;font-weight:550;cursor:pointer;margin-top:14px; | |
| } | |
| .studio-adv-toggle:hover{background:#26292b;border-color:#3a3d40} | |
| .studio-adv-toggle .dot{width:7px;height:7px;border-radius:50%;background:#6c6c70;flex-shrink:0} | |
| .studio-adv-toggle .chev{margin-left:auto;color:#98989d;transition:transform .22s ease} | |
| body.studio-adv-open .studio-adv-toggle .chev{transform:rotate(90deg)} | |
| @media(max-width:680px){ | |
| .dt-inspector-pane, .dt-panel, .dt-panel-wide{display:none !important} | |
| .p-5{padding:1rem !important} .p-4{padding:0.85rem !important} | |
| .speech-input{min-height:140px !important;font-size:16px !important} | |
| .embedded-compose{padding:14px !important;gap:12px !important} | |
| .embedded-sidebar{padding:14px !important;gap:12px !important} | |
| .studio-adv-toggle{margin-top:12px;padding:12px 13px;font-size:13px} | |
| } | |
| `; | |
| const tag = () => { | |
| let doc; | |
| try { doc = frame.contentDocument; } catch(_) { return false; } | |
| if (!doc || !doc.getElementById("root")) return false; | |
| if (doc.getElementById("inflect-studio-theme")) return true; | |
| const s = doc.createElement("style"); | |
| s.id = "inflect-studio-theme"; | |
| s.textContent = THEME; | |
| (doc.head || doc.documentElement).appendChild(s); | |
| if (doc.documentElement){ doc.documentElement.style.background="#07080a"; doc.documentElement.style.color="#f5f5f7"; } | |
| if (doc.body){ doc.body.style.background="#07080a"; doc.body.style.color="#f5f5f7"; } | |
| return true; | |
| }; | |
| const tagAdv = () => { | |
| let doc; | |
| try { doc = frame.contentDocument; } catch(_) { return; } | |
| if (!doc) return; | |
| // tag advanced sections by their label text (Compute backend, Voice controls, Runtime, etc.) | |
| doc.querySelectorAll(".section-label").forEach((lab) => { | |
| const t = (lab.textContent || "").trim(); | |
| if (["Compute backend","Runtime","Playback mode","Voice controls","Logs","Activity","Details"].includes(t)) { | |
| let n = lab.parentElement; | |
| if (n) { | |
| n.classList.add("studio-adv-section"); | |
| if (n.parentElement && /flex|grid/.test(n.parentElement.className)) n.parentElement.classList.add("studio-adv-section"); | |
| } | |
| } | |
| }); | |
| // also tag the Auto/WebGPU/WASM grid (no label relationship) by being a sibling of Compute backend label | |
| doc.querySelectorAll(".grid.grid-cols-3").forEach((g) => { | |
| const prev = g.previousElementSibling; | |
| if (prev && (prev.textContent || "").trim() === "Compute backend") { | |
| g.classList.add("studio-adv-section"); | |
| const w = g.closest(".flex.flex-col"); if (w) w.classList.add("studio-adv-section"); | |
| } | |
| }); | |
| // inject the Advanced toggle if not present | |
| if (!doc.getElementById("studio-adv-toggle")) { | |
| const sb = doc.querySelector(".embedded-sidebar") || doc.querySelector(".embedded-compose"); | |
| if (sb) { | |
| const b = doc.createElement("button"); | |
| b.id = "studio-adv-toggle"; b.type = "button"; b.className = "studio-adv-toggle"; | |
| b.innerHTML = '<span class="dot"></span><span>Advanced</span><span class="chev">▸</span>'; | |
| b.addEventListener("click", () => { | |
| const open = doc.body.classList.toggle("studio-adv-open"); | |
| b.setAttribute("aria-expanded", String(open)); | |
| try { frame.contentWindow.postMessage({type:"inflect-web-resize",height:Math.max(doc.body.scrollHeight,doc.documentElement.scrollHeight)}, "*"); } catch (_) {} | |
| }); | |
| sb.appendChild(b); | |
| } | |
| } | |
| }; | |
| const tick = () => { | |
| if (!tag()) return; // iframe not ready yet | |
| tagAdv(); | |
| // re-tag on DOM mutations (foldkit re-renders) | |
| let doc; | |
| try { doc = frame.contentDocument; } catch(_) { return; } | |
| if (!doc._studioMO) { | |
| try { | |
| const mo = new (doc.defaultView.MutationObserver || MutationObserver)(() => tagAdv()); | |
| mo.observe(doc.getElementById("root"), { childList: true, subtree: true }); | |
| doc._studioMO = mo; | |
| } catch (_) {} | |
| } | |
| }; | |
| frame.addEventListener("load", tick); | |
| [60, 200, 600, 1500, 3000, 5000, 8000].forEach((d) => window.setTimeout(tick, d)); | |
| })(); | |
| /* responsive browser frame height */ | |
| (function(){ const frame=$("webgpu-frame"); const apply=(h)=>{ const n=Number(h); if(Number.isFinite(n)&&n>0){ const floor=frame.clientWidth<1024?1020:560; frame.style.height=`${Math.max(floor,Math.min(2400,n+2))}px`; } }; const measure=()=>{ try{ const d=frame.contentDocument; apply(Math.max(d?.body?.scrollHeight||0,d?.documentElement?.scrollHeight||0)); }catch(_){} }; window.addEventListener("message",(ev)=>{ if(ev.source!==frame.contentWindow) return; if(ev.data?.type==="inflect-web-resize") apply(ev.data.height); }); frame.addEventListener("load",measure); [120,500,1000,1800,3000].forEach((d)=>setTimeout(measure,d)); })(); | |
| /* client */ | |
| let client=null; | |
| const getClient = async () => { if(client) return client; client=await Client.connect(window.location.origin); return client; }; | |
| const inputs = () => ({ | |
| text: $("text").value, | |
| speed: parseFloat($("speed").value), | |
| variation: parseFloat($("variation").value), | |
| pitch_steps: parseFloat($("pitch").value), | |
| seed: parseInt($("seed").value||"0",10), | |
| }); | |
| const activeModel = () => document.querySelector('input[name="model"]:checked').value; | |
| /* chunking (mirrors app.py) */ | |
| function splitText(text, limit=280){ | |
| const normalized=text.split(/\s+/).filter(Boolean).join(" "); if(!normalized) return []; | |
| const sentences=normalized.match(/[^.!?;:]+[.!?;:]*/g)||[normalized]; const chunks=[]; | |
| for(let s of sentences){ s=s.trim(); if(!s) continue; | |
| while(s.length>limit){ let cut=-1; for(const m of [",",";",":"]){ const idx=s.slice(0,limit+1).lastIndexOf(m); if(idx>=limit/2){ cut=idx+1; break; } } | |
| if(cut<limit/2){ const sp=s.slice(0,limit+1).lastIndexOf(" "); cut=sp>0?sp:limit; } | |
| chunks.push(s.slice(0,cut).trim()); s=s.slice(cut).trim(); } | |
| if(s) chunks.push(s); | |
| } return chunks; | |
| } | |
| function boundaryPause(chunk){ const end=chunk.trim().slice(-1); return ({ "?":0.28,"!":0.24,".":0.22,";":0.16,":":0.13,",":0.09 })[end] ?? 0.08; } | |
| function chunkTimings(chunks,totalSec){ | |
| const lens=chunks.map(c=>c.length); const pauses=chunks.slice(0,-1).map(c=>boundaryPause(c)); | |
| const lensAdj=lens.map((l,i)=>l+(i<lens.length-1?pauses[i]*40:0)); | |
| const sum=lensAdj.reduce((a,b)=>a+b,0)||1; let t=0; | |
| return chunks.map((c,i)=>{ const dur=(lensAdj[i]/sum)*totalSec; const start=t; t+=dur; if(i<chunks.length-1) t+=pauses[i]; return {text:c,start,dur}; }); | |
| } | |
| /* decode + waveform */ | |
| async function fetchArray(url){ const r=await fetch(url); if(!r.ok) throw new Error(`audio fetch ${r.status}`); return r.arrayBuffer(); } | |
| function wavBlobUrl(buf){ return URL.createObjectURL(new Blob([buf],{type:"audio/wav"})); } | |
| async function decodeWav(buf){ const ctx=new (window.AudioContext||window.webkitAudioContext)(); const d=await ctx.decodeAudioData(buf.slice(0)); ctx.close(); return d; } | |
| function peaksFrom(samples, buckets){ | |
| const out=new Float32Array(buckets*2); const step=Math.floor(samples.length/buckets)||1; | |
| for(let i=0;i<buckets;i++){ const s=i*step; const e=Math.min(s+step,samples.length); let mn=1,mx=-1; | |
| for(let j=s;j<e;j++){ const v=samples[j]; if(v<mn)mn=v; if(v>mx)mx=v; } | |
| out[i*2]=mn; out[i*2+1]=mx; } | |
| return out; | |
| } | |
| function drawWave(canvas, samples, progress, playedColor, baseColor){ | |
| const dpr=window.devicePixelRatio||1; const w=canvas.clientWidth, h=canvas.clientHeight; | |
| canvas.width=Math.max(1,w*dpr); canvas.height=Math.max(1,h*dpr); | |
| const ctx=canvas.getContext("2d"); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h); | |
| const buckets=Math.max(40,Math.floor(w*1.4)); const peaks=peaksFrom(samples,buckets); | |
| const bw=Math.max(1,w/peaks.length*0.55); const mid=h/2; const prog=Math.max(0,Math.min(1,progress||0)); | |
| for(let i=0;i<peaks.length;i++){ const x=(i/peaks.length)*w; const played=(i/peaks.length)<=prog; | |
| ctx.fillStyle=played?playedColor:baseColor; const top=mid-peaks[i*2+1]*mid*0.92; const bot=mid-peaks[i*2]*mid*0.92; | |
| ctx.fillRect(x, top, bw, Math.max(1,bot-top)); | |
| } | |
| } | |
| const drawMain=(c,s,p)=>drawWave(c,s,p,"#0a84ff","#2a2c2e"); | |
| const drawMiniC=(c,s,p)=>drawWave(c,s,p,"#0a84ff","#2a2c2e"); | |
| /* player state */ | |
| let current=null; let raf=null; | |
| const waveCanvas=$("wave-canvas"); const playhead=$("playhead"); const karaoke=$("karaoke"); | |
| const dl=$("download"); | |
| const statusEl=$("status"); const statusTxt=$("status-txt"); const queueEl=$("queue"); const led=$("status-led"); | |
| const genBtn=$("generate"); const cmpBtn=$("compare-btn"); | |
| const playBtn=$("play"); const stopBtn=$("stop"); const restartBtn=$("restart"); const seek=$("seek"); const player=$("player"); | |
| /* meters */ | |
| const metersEl=$("meters"); const METER_BARS=24; for(let i=0;i<METER_BARS;i++){ const b=document.createElement("div"); b.className="bar"; b.style.height="3px"; metersEl.appendChild(b); } | |
| let meterRAF=null; | |
| function runMeters(audio){ cancelAnimationFrame(meterRAF); const bars=[...metersEl.children]; | |
| const tick=()=>{ const lvl=audio.paused?0:0.3+0.45*Math.abs(Math.sin(audio.currentTime*8))+0.2*Math.random(); | |
| bars.forEach((b,i)=>{ const t=i/bars.length; const on=t<=lvl; b.classList.toggle("lev",on&&t<0.85); b.classList.toggle("peak",on&&t>=0.85); b.style.height=on?`${3+t*26}px`:"3px"; }); | |
| meterRAF=requestAnimationFrame(tick); | |
| }; tick(); | |
| const off=()=>{ cancelAnimationFrame(meterRAF); bars.forEach(b=>{ b.classList.remove("lev","peak"); b.style.height="3px"; }); }; | |
| audio.addEventListener("pause",off,{once:true}); audio.addEventListener("ended",off,{once:true}); | |
| } | |
| function renderMarkdown(md){ let s=(md||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); | |
| s=s.replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/`([^`]+)`/g,"<code>$1</code>"); return s.replace(/\n/g,"<br>"); } | |
| function setBusy(b,msg){ genBtn.disabled=b; cmpBtn.disabled=b; led.classList.toggle("busy",b); led.classList.toggle("on",!b&¤t!=null); | |
| statusEl.classList.toggle("busy",b); statusEl.classList.remove("ok","err"); | |
| if(b){ statusTxt.innerHTML=`<span class="busy-spin"></span> ${msg||"Generating…"}`; queueEl.classList.remove("hidden"); queueEl.textContent=""; } } | |
| function setStatus(md,kind){ statusEl.classList.remove("busy","ok","err"); if(kind) statusEl.classList.add(kind); statusTxt.innerHTML=renderMarkdown(md); } | |
| function setError(msg){ statusEl.classList.remove("busy","ok"); statusEl.classList.add("err"); statusTxt.innerHTML=`<span style="color:var(--warn)">${msg}</span>`; led.classList.remove("busy","on"); } | |
| function renderKaraoke(c){ karaoke.innerHTML=""; if(!c||!c.chunks.length){ karaoke.innerHTML='<div class="empty">Your text will appear here, highlighting as it\'s spoken.</div>'; return; } | |
| c.chunks.forEach((t,i)=>{ const sp=document.createElement("span"); sp.className="chunk"; sp.dataset.index=i; sp.textContent=t+" "; karaoke.appendChild(sp); }); karaoke.classList.add("in"); } | |
| function tick(current){ | |
| const a=current.audio; const t=a.currentTime; const p=current.totalSec?Math.min(1,t/current.totalSec):0; | |
| playhead.style.left=`${p*100}%`; drawMain(waveCanvas,current.samples,p); | |
| $("now").textContent=fmtTime(t); seek.value=String(Math.round(p*1000)); | |
| const spans=karaoke.querySelectorAll(".chunk"); let active=-1; | |
| for(let i=0;i<current.timings.length;i++){ const seg=current.timings[i]; if(t>=seg.start && t<seg.start+seg.dur){ active=i; break; } if(t>=seg.start) active=i; } | |
| spans.forEach((sp,i)=>{ sp.classList.remove("speaking"); sp.style.background=""; | |
| const seg=current.timings[i]; const done=seg && t>=seg.start+seg.dur; | |
| sp.classList.toggle("spoken",!!done); if(i===active){ sp.classList.add("speaking"); const rel=Math.max(0,t-seg.start)/Math.max(0.001,seg.dur); | |
| sp.style.background=`linear-gradient(90deg,rgba(10,132,255,.18) ${Math.min(100,rel*100)}%,transparent ${Math.min(100,rel*100)}%)`; } | |
| }); | |
| raf=requestAnimationFrame(()=>tick(current)); | |
| } | |
| function startPlayback(c){ if(!c||!c.audio) return; c.audio.play().then(()=>{ playBtn.textContent="❚❚"; $("np-badge").classList.add("playing"); led.classList.add("on"); cancelAnimationFrame(raf); raf=requestAnimationFrame(()=>tick(c)); runMeters(c.audio); }).catch(()=>{}); } | |
| function pausePlayback(c){ if(!c||!c.audio) return; c.audio.pause(); playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); } | |
| function stopPlayback(c){ if(!c||!c.audio) return; c.audio.pause(); c.audio.currentTime=0; playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent="0:00"; seek.value="0"; playhead.style.left="0%"; drawMain(waveCanvas,c.samples,0); } | |
| function setupPlayer(c){ | |
| current=c; renderKaraoke(c); drawMain(waveCanvas,c.samples,0); | |
| c.audio=new Audio(c.url); const a=c.audio; | |
| $("dur").textContent=fmtTime(c.totalSec); $("now").textContent="0:00"; seek.value="0"; seek.disabled=false; | |
| playBtn.disabled=false; stopBtn.disabled=false; restartBtn.disabled=false; playBtn.textContent="▶"; | |
| $("np-title").textContent=activeModel(); $("np-sub").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} segment${c.chunks.length!==1?"s":""}`; | |
| $("take-spec").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} seg · ${c.samples.length} smp`; | |
| dl.href=c.url; player.classList.add("show"); led.classList.add("on"); | |
| a.addEventListener("loadedmetadata",()=>{ $("dur").textContent=fmtTime(a.duration); }); | |
| a.addEventListener("ended",()=>{ playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent=fmtTime(c.totalSec); seek.value="1000"; playhead.style.left="100%"; drawMain(waveCanvas,c.samples,1); karaoke.querySelectorAll(".chunk").forEach(s=>{ s.classList.remove("speaking"); s.classList.add("spoken"); }); }); | |
| } | |
| playBtn.addEventListener("click",()=>{ if(!current) return; if(current.audio.paused) startPlayback(current); else pausePlayback(current); }); | |
| stopBtn.addEventListener("click",()=>{ if(current) stopPlayback(current); }); | |
| restartBtn.addEventListener("click",()=>{ if(!current) return; current.audio.currentTime=0; if(current.audio.paused) startPlayback(current); }); | |
| seek.addEventListener("input",()=>{ if(!current) return; const t=(parseFloat(seek.value)/1000)*current.totalSec; current.audio.currentTime=t; $("now").textContent=fmtTime(t); playhead.style.left=`${(t/current.totalSec)*100}%`; drawMain(waveCanvas,current.samples,t/current.totalSec); }); | |
| $("wave").addEventListener("click",(e)=>{ if(!current) return; const r=$("wave").getBoundingClientRect(); const t=((e.clientX-r.left)/r.width)*current.totalSec; current.audio.currentTime=t; $("now").textContent=fmtTime(t); }); | |
| /* generate */ | |
| /* Auto-retry transient ZeroGPU failures (hardware ECC / queue blips) up to 2x. */ | |
| const TRANSIENT = /CUDA|ECC|GPU|queue|timeout|connection|fetch|network|503|502|500/i; | |
| async function withRetry(fn, label){ | |
| let lastErr; | |
| for (let attempt = 0; attempt < 3; attempt++) { | |
| try { return await fn(); } | |
| catch (e) { | |
| lastErr = e; | |
| const msg = String(e?.message || e); | |
| const transient = TRANSIENT.test(msg); | |
| if (!transient || attempt === 2) throw e; | |
| const wait = 1200 * (attempt + 1); | |
| statusTxt.innerHTML = `<span class="busy-spin"></span> ${label} hit a transient GPU error — retrying in ${Math.round(wait/1000)}s…`; | |
| led.classList.add("busy"); | |
| await new Promise((r) => setTimeout(r, wait)); | |
| } | |
| } | |
| throw lastErr; | |
| } | |
| async function doGenerate(){ | |
| setBusy(true,"Generating on ZeroGPU…"); queueEl.textContent=""; | |
| $("cmp-wrap").classList.remove("show"); | |
| try{ | |
| await withRetry(async () => { | |
| const c=await getClient(); const res=await c.predict("/synthesize",{ ...inputs(), model_name: activeModel() }); | |
| const fileObj=res.data[0]; const statusText=res.data[1]||"Done."; | |
| const url=(fileObj && (fileObj.url || fileObj.path)) || (typeof fileObj==="string"?fileObj:""); if(!url) throw new Error("server returned no audio url"); | |
| const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf); | |
| const text=$("text").value; const chunks=splitText(text); const timings=chunkTimings(chunks,decoded.duration); | |
| setupPlayer({ url:blobUrl, totalSec:decoded.duration, chunks, timings, samples:decoded.getChannelData(0), audio:null }); | |
| setStatus(statusText,"ok"); queueEl.classList.add("hidden"); | |
| const rtfM=statusText.match(/RTF\s*([0-9.]+)/); if(rtfM) $("m-rtf").textContent=parseFloat(rtfM[1]).toFixed(3); | |
| }); | |
| setBusy(false); startPlayback(current); | |
| } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); } | |
| } | |
| /* compare */ | |
| let cmp={ micro:null, nano:null }; | |
| const cmpCanvas={ micro:$("micro-canvas"), nano:$("nano-canvas") }; | |
| async function doCompare(){ | |
| setBusy(true,"Comparing on ZeroGPU…"); queueEl.textContent=""; | |
| $("cmp-wrap").classList.add("show"); | |
| for(const k of ["micro","nano"]){ if(cmp[k]?.audio) cmp[k].audio.pause(); cmp[k]=null; | |
| $(`${k}-now`).textContent="0:00"; $(`${k}-dur`).textContent="0:00"; $(`${k}-seek`).value="0"; $(`${k}-seek`).disabled=true; | |
| const btn=document.querySelector(`.mini-play[data-mini="${k}"]`); btn.disabled=true; btn.textContent="▶"; $(`${k}-ph`).style.left="0%"; | |
| drawMiniC(cmpCanvas[k], new Float32Array(40),0); } | |
| try{ | |
| const [microObj,nanoObj,statusText] = await withRetry(async () => { | |
| const c=await getClient(); const res=await c.predict("/compare",{ ...inputs() }); | |
| return res.data; | |
| }, "Compare"); | |
| for(const [key,obj,durId,nowId,seekId,dlId,phId,cv] of [ | |
| ["micro",microObj,"micro-dur","micro-now","micro-seek","micro-dl","micro-ph",cmpCanvas.micro], | |
| ["nano",nanoObj,"nano-dur","nano-now","nano-seek","nano-dl","nano-ph",cmpCanvas.nano], | |
| ]){ | |
| const url=(obj && (obj.url || obj.path)) || (typeof obj==="string"?obj:""); if(!url) throw new Error(`server returned no ${key} url`); | |
| const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf); const samples=decoded.getChannelData(0); | |
| $(dlId).href=blobUrl; $(durId).textContent=fmtTime(decoded.duration); $(seekId).value="0"; $(seekId).disabled=false; drawMiniC(cv,samples,0); | |
| const audio=new Audio(blobUrl); const btn=document.querySelector(`.mini-play[data-mini="${key}"]`); btn.disabled=false; | |
| const upd=()=>{ $(nowId).textContent=fmtTime(audio.currentTime); const p=audio.currentTime/decoded.duration; $(seekId).value=String(Math.round(p*1000)); $(phId).style.left=`${p*100}%`; drawMiniC(cv,samples,p); }; | |
| audio.addEventListener("timeupdate",upd); | |
| audio.addEventListener("ended",()=>{ btn.textContent="▶"; $(phId).style.left="100%"; drawMiniC(cv,samples,1); }); | |
| const sk=$(seekId); if(sk._seek) sk.removeEventListener("input",sk._seek); const onSeek=()=>{ audio.currentTime=(parseFloat(sk.value)/1000)*decoded.duration; }; sk._seek=onSeek; sk.addEventListener("input",onSeek); | |
| cmp[key]={ audio, totalSec:decoded.duration, samples, canvas:cv }; | |
| } | |
| setStatus(statusText,"ok"); queueEl.classList.add("hidden"); setBusy(false); | |
| } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); } | |
| } | |
| document.querySelectorAll(".mini-play").forEach((btn)=>{ btn.addEventListener("click",()=>{ const key=btn.dataset.mini; const item=cmp[key]; if(!item||!item.audio) return; | |
| if(item.audio.paused){ const other=key==="micro"?"nano":"micro"; if(cmp[other]?.audio && !cmp[other].audio.paused){ cmp[other].audio.pause(); document.querySelector(`.mini-play[data-mini="${other}"]`).textContent="▶"; } item.audio.play(); btn.textContent="❚❚"; } | |
| else { item.audio.pause(); btn.textContent="▶"; } | |
| }); }); | |
| [ $("micro-wave"), $("nano-wave") ].forEach((el,i)=>{ const key=i?"nano":"micro"; el.addEventListener("click",(e)=>{ const item=cmp[key]; if(!item) return; const r=el.getBoundingClientRect(); item.audio.currentTime=((e.clientX-r.left)/r.width)*item.totalSec; }); }); | |
| genBtn.addEventListener("click",doGenerate); | |
| cmpBtn.addEventListener("click",doCompare); | |
| $("text").addEventListener("keydown",(e)=>{ if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)){ e.preventDefault(); doGenerate(); } }); | |
| window.addEventListener("resize",()=>{ if(current) drawMain(waveCanvas,current.samples, current.audio?current.audio.currentTime/current.totalSec:0); for(const k of ["micro","nano"]){ if(cmp[k]) drawMiniC(cmpCanvas[k],cmp[k].samples, cmp[k].audio?cmp[k].audio.currentTime/cmp[k].totalSec:0); } }); | |
| drawMain(waveCanvas,new Float32Array(200),0); | |
| drawMiniC(cmpCanvas.micro,new Float32Array(40),0); | |
| drawMiniC(cmpCanvas.nano,new Float32Array(40),0); | |
| </script> | |
| </body> | |
| </html> |