Spaces:
Running on Zero
Running on Zero
Download studio.html from akhaliq/maya2-native-studio: direct link, hf CLI and curl.
- Browser
- Download file 40.6 kB
-
https://huggingface.co/spaces/akhaliq/maya2-native-studio/resolve/main/studio.html
- Command line
-
hf download hf://spaces/akhaliq/maya2-native-studio/studio.html
-
curl -L -o studio.html https://huggingface.co/spaces/akhaliq/maya2-native-studio/resolve/main/studio.html
40.6 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Maya 2 Native Studio</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css" /> | |
| <style> | |
| :root { | |
| --ink: #0b2a6b; | |
| --deep: #123a9e; | |
| --green: #35c14f; | |
| --glass: rgba(255, 255, 255, 0.10); | |
| --glass-strong: rgba(255, 255, 255, 0.16); | |
| --stroke: rgba(255, 255, 255, 0.24); | |
| --text: rgba(255, 255, 255, 0.95); | |
| --muted: rgba(255, 255, 255, 0.68); | |
| --dim: rgba(255, 255, 255, 0.45); | |
| --radius: 18px; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| [hidden] { display: none ; } | |
| html { height: 100%; } | |
| body { | |
| font-family: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif; | |
| color: var(--text); | |
| min-height: 100vh; | |
| background: linear-gradient(180deg, #0f3aa8 0%, #1a55d6 34%, #4f8fe8 68%, #a9d3f7 100%); | |
| background-attachment: fixed; | |
| padding-bottom: 44px; /* status bar */ | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* ------- sky + hills ------- */ | |
| .cloud { | |
| position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; | |
| background: radial-gradient(ellipse at center, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 68%); | |
| animation: drift linear infinite; | |
| } | |
| .cloud.c1 { width: 420px; height: 130px; top: 9%; left: -6%; animation-duration: 90s; } | |
| .cloud.c2 { width: 560px; height: 170px; top: 3%; right: -8%; animation-duration: 120s; animation-delay: -30s; } | |
| .cloud.c3 { width: 320px; height: 100px; top: 34%; left: 12%; animation-duration: 105s; animation-delay: -60s; opacity: 0.7; } | |
| .cloud.c4 { width: 380px; height: 120px; top: 28%; right: 6%; animation-duration: 80s; animation-delay: -15s; opacity: 0.75; } | |
| @keyframes drift { | |
| from { transform: translateX(-4vw); } | |
| 50% { transform: translateX(4vw); } | |
| to { transform: translateX(-4vw); } | |
| } | |
| .hills { | |
| position: fixed; left: 0; right: 0; bottom: 0; width: 100%; height: 32vh; | |
| z-index: 0; pointer-events: none; | |
| } | |
| /* ------- top bar ------- */ | |
| .topbar { | |
| position: relative; z-index: 10; | |
| display: flex; align-items: center; gap: 14px; padding: 10px 22px; | |
| background: rgba(10, 30, 84, 0.30); | |
| backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.14); | |
| } | |
| .brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 0.95rem; white-space: nowrap; } | |
| .brand .mark { width: 26px; height: 26px; border-radius: 8px; display: block; flex-shrink: 0; } | |
| .brand .brand-sub { color: var(--muted); font-weight: 500; padding-left: 12px; margin-left: 4px; border-left: 1px solid rgba(255,255,255,0.22); } | |
| .mode-tabs { display: flex; gap: 2px; background: rgba(8, 24, 64, 0.35); border: 1px solid rgba(255,255,255,0.16); border-radius: 999px; padding: 3px; margin-left: 10px; } | |
| .mode-tabs button { | |
| border: none; border-radius: 999px; padding: 6px 18px; font: 600 0.82rem/1 inherit; font-family: inherit; | |
| background: transparent; color: var(--muted); cursor: pointer; transition: all 0.15s; | |
| } | |
| .mode-tabs button:hover { color: var(--text); } | |
| .mode-tabs button.active { background: white; color: var(--ink); box-shadow: 0 2px 8px rgba(6, 26, 70, 0.35); } | |
| .spacer { flex: 1; } | |
| .toplink { | |
| color: var(--muted); text-decoration: none; font-size: 0.82rem; font-weight: 550; white-space: nowrap; | |
| transition: color 0.15s; | |
| } | |
| .toplink:hover { color: white; } | |
| .toplink.cta { | |
| color: white; border: 1px solid rgba(255,255,255,0.45); border-radius: 999px; padding: 6px 15px; | |
| transition: background 0.15s, border-color 0.15s; | |
| } | |
| .toplink.cta:hover { background: rgba(255,255,255,0.14); border-color: white; } | |
| /* ------- hero ------- */ | |
| main { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; } | |
| .hero { text-align: center; padding: 52px 20px 26px; max-width: 860px; } | |
| .badge { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| font-size: 0.78rem; font-weight: 550; color: rgba(255,255,255,0.9); | |
| background: rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.30); | |
| backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); | |
| border-radius: 999px; padding: 7px 16px; margin-bottom: 26px; | |
| } | |
| .badge-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); } | |
| .hero h1 { | |
| font-size: clamp(2.4rem, 6vw, 4.2rem); font-weight: 700; letter-spacing: -0.03em; line-height: 1.04; | |
| text-shadow: 0 2px 24px rgba(9, 32, 88, 0.35); | |
| } | |
| .hero .sub { | |
| margin-top: 18px; font-size: clamp(0.95rem, 1.6vw, 1.1rem); color: rgba(255,255,255,0.85); | |
| font-weight: 450; line-height: 1.5; | |
| } | |
| /* ------- console ------- */ | |
| .console { | |
| width: min(780px, calc(100vw - 32px)); | |
| background: var(--glass); border: 1px solid var(--stroke); border-radius: 24px; | |
| backdrop-filter: blur(20px) saturate(1.25); -webkit-backdrop-filter: blur(20px) saturate(1.25); | |
| box-shadow: 0 24px 60px rgba(6, 26, 70, 0.35); | |
| padding: 20px; | |
| } | |
| textarea, select, input[type="number"] { | |
| font-family: inherit; color: var(--text); | |
| background: rgba(8, 24, 64, 0.30); border: 1px solid rgba(255, 255, 255, 0.18); | |
| border-radius: 14px; outline: none; | |
| } | |
| textarea:focus, select:focus, input[type="number"]:focus { border-color: rgba(255,255,255,0.55); box-shadow: 0 0 0 3px rgba(255,255,255,0.12); } | |
| textarea { width: 100%; padding: 14px 16px; font-size: 1rem; line-height: 1.55; resize: vertical; } | |
| textarea::placeholder { color: var(--dim); } | |
| .row { display: flex; gap: 12px; margin-top: 12px; align-items: flex-end; } | |
| .field { display: flex; flex-direction: column; gap: 6px; } | |
| .field.grow { flex: 1; min-width: 0; } | |
| .field label { font-size: 0.72rem; font-weight: 650; letter-spacing: 0.09em; text-transform: uppercase; color: var(--dim); padding-left: 4px; } | |
| select { | |
| appearance: none; -webkit-appearance: none; width: 100%; padding: 11px 36px 11px 14px; font-size: 0.92rem; cursor: pointer; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; background-position: right 14px center; | |
| } | |
| select option { color: #10245c; background: white; } | |
| .persona { margin: 8px 4px 0; font-size: 0.8rem; color: var(--muted); min-height: 1.1em; } | |
| /* clone panel */ | |
| .clone { margin-top: 14px; border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; background: rgba(8, 24, 64, 0.18); } | |
| .clone summary { | |
| list-style: none; cursor: pointer; padding: 12px 16px; font-size: 0.88rem; font-weight: 600; | |
| display: flex; align-items: center; gap: 8px; user-select: none; | |
| } | |
| .clone summary::-webkit-details-marker { display: none; } | |
| .clone summary::after { content: "+"; margin-left: auto; color: var(--muted); font-weight: 500; } | |
| .clone[open] summary::after { content: "-"; } | |
| .clone-state { | |
| font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em; color: #0d3d1c; | |
| background: #7de08f; border-radius: 999px; padding: 3px 9px; text-transform: uppercase; | |
| } | |
| .clone-state:empty { display: none; } | |
| .clone-body { padding: 0 14px 14px; display: flex; flex-direction: column; gap: 10px; } | |
| .dropzone { | |
| border: 1.5px dashed rgba(255,255,255,0.35); border-radius: 14px; padding: 18px; | |
| text-align: center; font-size: 0.86rem; color: var(--muted); cursor: pointer; transition: all 0.15s; | |
| } | |
| .dropzone:hover, .dropzone.over { border-color: white; background: rgba(255,255,255,0.08); color: var(--text); } | |
| .dropzone u { text-decoration-thickness: 1px; } | |
| .dropzone button { | |
| border: 1px solid rgba(255,255,255,0.4); background: transparent; color: white; font: 600 0.76rem/1 inherit; | |
| font-family: inherit; border-radius: 999px; padding: 5px 12px; cursor: pointer; margin-left: 4px; | |
| } | |
| .dropzone button:hover { background: rgba(255,255,255,0.14); } | |
| .clip-row { display: flex; gap: 8px; align-items: center; } | |
| .clip-row audio { flex: 1; height: 36px; min-width: 0; } | |
| .clip-row button { | |
| border: 1px solid rgba(255,255,255,0.3); background: transparent; color: var(--muted); | |
| width: 32px; height: 32px; border-radius: 50%; cursor: pointer; flex-shrink: 0; | |
| } | |
| .clip-row button:hover { color: white; border-color: white; } | |
| #ref-text { font-size: 0.88rem; } | |
| .hint { font-size: 0.76rem; color: var(--dim); line-height: 1.45; padding: 0 2px; } | |
| /* advanced row */ | |
| .adv { align-items: center; flex-wrap: wrap; } | |
| .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.82rem; color: var(--muted); } | |
| .switch input { display: none; } | |
| .switch .sw { | |
| width: 34px; height: 20px; border-radius: 999px; background: rgba(8, 24, 64, 0.5); | |
| border: 1px solid rgba(255,255,255,0.25); position: relative; transition: background 0.15s; flex-shrink: 0; | |
| } | |
| .switch .sw::after { | |
| content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; | |
| background: white; transition: transform 0.15s; | |
| } | |
| .switch input:checked + .sw { background: var(--green); border-color: var(--green); } | |
| .switch input:checked + .sw::after { transform: translateX(14px); } | |
| .seed-box { display: inline-flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); } | |
| .seed-box input { width: 92px; padding: 6px 10px; font-size: 0.82rem; border-radius: 10px; } | |
| /* run button + result */ | |
| #run-btn { | |
| width: 100%; margin-top: 16px; border: none; border-radius: 999px; padding: 15px; | |
| background: white; color: var(--ink); font: 700 1rem/1 inherit; font-family: inherit; | |
| cursor: pointer; transition: transform 0.12s, box-shadow 0.12s, opacity 0.15s; | |
| box-shadow: 0 8px 24px rgba(6, 26, 70, 0.35); | |
| } | |
| #run-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 12px 30px rgba(6, 26, 70, 0.45); } | |
| #run-btn:disabled { opacity: 0.55; cursor: wait; } | |
| .cold-hint { text-align: center; font-size: 0.74rem; color: var(--dim); margin-top: 9px; } | |
| .result { | |
| margin-top: 16px; border: 1px solid rgba(255,255,255,0.22); border-radius: 18px; | |
| background: rgba(8, 24, 64, 0.28); padding: 14px 16px; | |
| } | |
| .result-top { display: flex; align-items: center; gap: 14px; } | |
| #play-btn { | |
| width: 46px; height: 46px; border-radius: 50%; border: none; flex-shrink: 0; | |
| background: white; color: var(--ink); font-size: 1rem; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| box-shadow: 0 4px 14px rgba(6, 26, 70, 0.4); transition: transform 0.12s; | |
| } | |
| #play-btn:hover { transform: scale(1.05); } | |
| #wave { flex: 1; height: 56px; min-width: 0; cursor: pointer; } | |
| .result-meta { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 0.78rem; color: var(--muted); font-weight: 550; } | |
| #result-time { font-variant-numeric: tabular-nums; color: var(--text); } | |
| .result-meta a { | |
| margin-left: auto; color: white; font-weight: 700; text-decoration: none; | |
| border: 1px solid rgba(255,255,255,0.35); border-radius: 999px; padding: 4px 12px; font-size: 0.74rem; | |
| transition: background 0.15s; | |
| } | |
| .result-meta a:hover { background: rgba(255,255,255,0.16); } | |
| /* history */ | |
| .history { margin-top: 14px; display: flex; align-items: center; gap: 10px; } | |
| .hlabel { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); flex-shrink: 0; } | |
| .history-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; } | |
| .hchip { | |
| flex-shrink: 0; border: 1px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.10); | |
| color: var(--text); border-radius: 999px; padding: 6px 13px; font: 550 0.76rem/1 inherit; font-family: inherit; | |
| cursor: pointer; transition: background 0.15s; | |
| } | |
| .hchip:hover { background: rgba(255,255,255,0.22); } | |
| /* examples */ | |
| .examples { | |
| width: min(880px, calc(100vw - 32px)); margin-top: 26px; | |
| display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; position: relative; z-index: 1; | |
| } | |
| .ex-chip { | |
| border: 1px solid rgba(255,255,255,0.28); background: rgba(255,255,255,0.10); | |
| backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); | |
| color: var(--text); border-radius: 999px; padding: 8px 15px; cursor: pointer; | |
| font: 550 0.8rem/1.2 inherit; font-family: inherit; transition: background 0.15s, transform 0.12s; | |
| max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .ex-chip b { font-weight: 700; margin-right: 5px; } | |
| .ex-chip:hover { background: rgba(255,255,255,0.22); transform: translateY(-1px); } | |
| .foot { margin: 30px 0 6px; font-size: 0.74rem; color: var(--dim); text-align: center; position: relative; z-index: 1; } | |
| .foot a { color: var(--muted); } | |
| /* workflow view */ | |
| #workflow-view { position: fixed; inset: 47px 0 36px 0; z-index: 5; background: #0d1526; } | |
| #workflow-view iframe { width: 100%; height: 100%; border: none; } | |
| /* status bar */ | |
| .statusbar { | |
| position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; | |
| display: flex; align-items: center; gap: 10px; padding: 8px 18px; | |
| background: rgba(8, 22, 58, 0.75); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); | |
| border-top: 1px solid rgba(255,255,255,0.12); font-size: 0.78rem; color: var(--muted); | |
| } | |
| .pulse { width: 8px; height: 8px; border-radius: 50%; background: #6be07f; flex-shrink: 0; } | |
| .statusbar.busy .pulse { background: #ffd166; animation: pulse 1s ease-in-out infinite; } | |
| @keyframes pulse { 50% { opacity: 0.35; } } | |
| #seed-out { font-variant-numeric: tabular-nums; } | |
| @media (max-width: 640px) { | |
| .brand .brand-sub { display: none; } | |
| .toplink:not(.cta) { display: none; } | |
| .hero { padding-top: 36px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="cloud c1"></div> | |
| <div class="cloud c2"></div> | |
| <div class="cloud c3"></div> | |
| <div class="cloud c4"></div> | |
| <svg class="hills" viewBox="0 0 1440 360" preserveAspectRatio="none" aria-hidden="true"> | |
| <path d="M0,170 C240,110 430,195 720,160 C1010,128 1210,195 1440,150 L1440,360 L0,360 Z" fill="#84c96f"/> | |
| <path d="M0,235 C210,185 480,255 760,225 C1040,195 1240,255 1440,215 L1440,360 L0,360 Z" fill="#52b25c"/> | |
| <path d="M0,295 C260,255 520,315 820,285 C1120,255 1300,315 1440,285 L1440,360 L0,360 Z" fill="#37944b"/> | |
| </svg> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <svg class="mark" viewBox="0 0 26 26" aria-hidden="true"> | |
| <rect width="26" height="26" rx="7" fill="#35c14f"/> | |
| <path d="M7 8h9M7 13h12M7 18h7" stroke="white" stroke-width="2.4" stroke-linecap="round"/> | |
| </svg> | |
| Maya Research | |
| <span class="brand-sub">Maya 2 Native Studio</span> | |
| </div> | |
| <nav class="mode-tabs"> | |
| <button id="mode-studio" class="active">Studio</button> | |
| <button id="mode-workflow">Workflow</button> | |
| </nav> | |
| <div class="spacer"></div> | |
| <a class="toplink" href="https://huggingface.co/maya-research/maya2-native" target="_blank" rel="noopener">Model card ↗</a> | |
| <a class="toplink" href="https://github.com/MayaResearch/maya2-native" target="_blank" rel="noopener">GitHub ↗</a> | |
| <a class="toplink cta" href="/workflow/?ui=canvas" target="_blank" rel="noopener">Open canvas ↗</a> | |
| </header> | |
| <main id="studio-view"> | |
| <section class="hero"> | |
| <div class="badge"><span class="badge-dot"></span>Apache-2.0 · 12 languages · 20 voices · voice cloning</div> | |
| <h1>Voice models<br />that sound native</h1> | |
| <p class="sub">Real-time speech that carries the accent, rhythm, and emotion of a native speaker —<br />in 11 Indic languages and English.</p> | |
| </section> | |
| <section class="console"> | |
| <textarea id="text" rows="4" placeholder="Type something to say — in any of the 12 supported languages…"></textarea> | |
| <div class="row"> | |
| <div class="field grow"><label for="lang">Language</label><select id="lang"></select></div> | |
| <div class="field grow"><label for="voice">Voice</label><select id="voice"></select></div> | |
| </div> | |
| <div class="persona" id="persona"></div> | |
| <details class="clone" id="clone-panel"> | |
| <summary>🎭 Clone a voice instead <span class="clone-state" id="clone-state"></span></summary> | |
| <div class="clone-body"> | |
| <div class="dropzone" id="dropzone"> | |
| Drop a 3–10 s clip here, or <u>browse files</u> · | |
| <button id="sample-ref" type="button">try the Ritvik sample</button> | |
| </div> | |
| <div class="clip-row" id="clip-row" hidden> | |
| <audio id="clip-player" controls></audio> | |
| <button id="clip-clear" title="Remove reference clip">✕</button> | |
| </div> | |
| <textarea id="ref-text" rows="2" placeholder="Exact transcript of the clip — what is said, word for word…"></textarea> | |
| <div class="row" style="margin-top:0"> | |
| <div class="field grow"><label for="ref-lang">Clip language</label><select id="ref-lang"></select></div> | |
| </div> | |
| <p class="hint">A reference clip overrides the named voice. The clip's language and the output | |
| language can differ — a Hindi reference can speak Tamil. Only clone voices you have permission to use.</p> | |
| </div> | |
| </details> | |
| <div class="row adv"> | |
| <label class="switch"><input type="checkbox" id="normalize" checked /><span class="sw"></span><span>Normalize numbers & dates</span></label> | |
| <div class="spacer"></div> | |
| <div class="seed-box"><label for="seed">Seed</label><input type="number" id="seed" value="0" min="0" max="2147483647" /></div> | |
| <label class="switch"><input type="checkbox" id="randomize" checked /><span class="sw"></span><span>Randomize</span></label> | |
| </div> | |
| <button id="run-btn">▶ Generate speech</button> | |
| <p class="cold-hint">The first run after idle wakes a GPU worker — about a minute. Warm runs take seconds.</p> | |
| <div class="result" id="result" hidden> | |
| <div class="result-top"> | |
| <button id="play-btn" title="Play / pause">▶</button> | |
| <canvas id="wave"></canvas> | |
| </div> | |
| <div class="result-meta"> | |
| <span id="result-time">0:00</span> | |
| <span id="result-meta">—</span> | |
| <a id="download" download="maya2-native.wav">Download ↓</a> | |
| </div> | |
| <audio id="player" hidden></audio> | |
| </div> | |
| <div class="history" id="history" hidden> | |
| <span class="hlabel">History</span> | |
| <div class="history-strip" id="history-strip"></div> | |
| </div> | |
| </section> | |
| <section class="examples" id="examples"></section> | |
| <footer class="foot"> | |
| Unofficial studio · Model by <a href="https://huggingface.co/maya-research" target="_blank" rel="noopener">Maya Research</a> · Apache-2.0 · | |
| Disclose synthetic speech; clone only with permission | |
| </footer> | |
| </main> | |
| <section id="workflow-view" hidden> | |
| <iframe id="workflow-frame" title="Maya 2 Native workflow canvas"></iframe> | |
| </section> | |
| <footer class="statusbar" id="statusbar"> | |
| <div class="pulse"></div> | |
| <span id="status">Idle</span> | |
| <div class="spacer"></div> | |
| <span id="seed-out"></span> | |
| </footer> | |
| <input type="file" id="file-input" accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a" /> | |
| <script type="module"> | |
| import { client } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.7.1/dist/index.min.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const state = { app: null, refPath: null }; | |
| const LANGS = [ | |
| ["hi", "हिन्दी · Hindi"], ["en", "English"], ["as", "অসমীয়া · Assamese"], | |
| ["bn", "বাংলা · Bengali"], ["gu", "ગુજરાતી · Gujarati"], ["kn", "ಕನ್ನಡ · Kannada"], | |
| ["ml", "മലയാളം · Malayalam"], ["mr", "मराठी · Marathi"], ["or", "ଓଡ଼ିଆ · Odia"], | |
| ["pa", "ਪੰਜਾਬੀ · Punjabi"], ["ta", "தமிழ் · Tamil"], ["te", "తెలుగు · Telugu"], | |
| ]; | |
| const LANG_NATIVE = Object.fromEntries(LANGS.map(([c, n]) => [c, n.split(" · ")[0]])); | |
| const VOICES = { | |
| Ahana: ["Young female · bright, youthful", "all 11 Indic languages + English"], | |
| Vaidehi: ["Adult female · calm, gentle", "all 11 Indic languages + English"], | |
| Malini: ["Mature female · steady narrator", "all 11 Indic languages + English"], | |
| Charvi: ["Young female · energetic, upbeat", "all 11 Indic languages + English"], | |
| Jahnavi: ["Adult female · warm, friendly", "10 Indic languages + English"], | |
| Anirban: ["Adult male · steady narrator", "all 11 Indic languages + English"], | |
| Nakul: ["Young male · bright, youthful", "all 11 Indic languages + English"], | |
| Vasant: ["Mature male · steady narrator", "10 Indic languages + English"], | |
| Siddhant: ["Adult male · clear, professional", "all 11 Indic languages + English"], | |
| Satvik: ["Young male · warm, friendly", "all 11 Indic languages + English"], | |
| Abhay: ["Adult male · conversational", "English, Hindi"], | |
| Nivedita: ["Adult female · conversational", "English, Hindi"], | |
| Ishwari: ["Adult female · conversational", "Hindi"], | |
| Ritvik: ["Adult male · conversational", "Hindi"], | |
| Vrinda: ["Young female · conversational", "Hindi"], | |
| Sreekar: ["Adult male · conversational", "English, Hindi, Telugu"], | |
| Kayal: ["Adult female · conversational", "English, Tamil"], | |
| Shaunak: ["Young male · conversational", "English, Hindi"], | |
| Akshat: ["Adult male · conversational", "English, Hindi"], | |
| Tanushree:["Adult female · conversational", "English, Hindi"], | |
| }; | |
| const EXAMPLES = [ | |
| ["क्या आपका वर्तमान पता 45, एमजी रोड, अंधेरी ईस्ट, मुंबई 400069 है? यदि हाँ, तो 1 दबाएं।", "hi", "Ritvik"], | |
| ["The EMI for your home loan is due by 12-10-24; please ensure your account has sufficient funds.", "en", "Siddhant"], | |
| ["এন ই এফ টি লেনদেনটো সম্পূৰ্ণ হ'বলৈ প্ৰায় 2 ঘণ্টা 30 মিনিট সময় লাগিব পাৰে।", "as", "Nakul"], | |
| ["12/08/2023 তারিখে আপনার 1,500 টাকার refund process হয়ে গেছে, status update চেক করুন।", "bn", "Jahnavi"], | |
| ["જી બિલકુલ, મને સિસ્ટમમાં દેખાય છે કે તમારું ₹2,500 નું પેમેન્ટ 15મી તારીખે સક્સેસફુલ થઈ ગયું છે.", "gu", "Vaidehi"], | |
| ["ಹೌದಾ... ಅದು ನಿಜಕ್ಕೂ ಕಷ್ಟ. ನಿಮ್ಮ ಅಂತಿಮ ಬಿಲ್ ₹45,000 ಆಗಿದೆ, ಅದರಲ್ಲಿ ಇನ್ಶೂರೆನ್ಸ್ ಕವರ್ ಆಗಿದೆಯಾ ಅಂತ ಚೆಕ್ ಮಾಡ್ತೀನಿ.", "kn", "Satvik"], | |
| ["റദ്ദാക്കിയ ടിക്കറ്റിന്റെ റീഫണ്ട് തുകയായ ₹1,800 മൂന്ന് ദിവസത്തിനുള്ളിൽ ലഭിക്കും.", "ml", "Anirban"], | |
| ["मला 98765 43210 या नंबरवर OTP आला नाही, कृपया पुन्हा resend करा.", "mr", "Charvi"], | |
| ["ଦୟାକରି ମନେରଖନ୍ତୁ ଯେ EMI ପ୍ରତ୍ୟେକ ମାସର 5 ତାରିଖରେ କଟିବ ଏବଂ ଏହା ଆପଣଙ୍କର 3ୟ କିସ୍ତି।", "or", "Ahana"], | |
| ["NEFT ਰਾਹੀਂ ਫੰਡ ਟਰਾਂਸਫਰ 00:30 ਤੋਂ 23:30 ਤੱਕ ਉਪਲਬਧ ਹਨ।", "pa", "Malini"], | |
| ["கடந்த நிதியாண்டில் வங்கியின் மொத்த லாபம் ₹3.5 கோடி, இதில் ₹45 லட்சம் முதலீட்டாளர்களுக்கு வழங்கப்பட்டது.", "ta", "Abhay"], | |
| ["సేవింగ్స్ తో పోలిస్తే ఫిక్స్డ్ డిపాజిట్ వడ్డీ నిష్పత్తి 3:2 గా ఉండి, 7.25% ఇస్తుంది.", "te", "Nivedita"], | |
| ]; | |
| // Bundled showcase clip from the model repo — handy for a first voice-cloning run. | |
| const SAMPLE_REF = { | |
| url: "https://huggingface.co/maya-research/maya2-native/resolve/main/samples/hi_ritvik.wav", | |
| name: "hi_ritvik.wav", | |
| text: "क्या आपका वर्तमान पता 45, एमजी रोड, अंधेरी ईस्ट, मुंबई 400069 है? यदि हाँ, तो 1 दबाएं।", | |
| lang: "hi", | |
| }; | |
| // ---------- setup ---------- | |
| for (const [code, name] of LANGS) { | |
| $("lang").add(new Option(name, code)); | |
| $("ref-lang").add(new Option(name, code)); | |
| } | |
| $("lang").value = "hi"; | |
| $("ref-lang").value = "hi"; | |
| for (const name of Object.keys(VOICES)) $("voice").add(new Option(name, name)); | |
| $("voice").value = "Ritvik"; | |
| function showPersona() { | |
| const p = VOICES[$("voice").value]; | |
| $("persona").textContent = p ? `${p[0]} — trained on ${p[1]}` : ""; | |
| } | |
| $("voice").onchange = showPersona; | |
| showPersona(); | |
| for (const [text, lang, voice] of EXAMPLES) { | |
| const chip = document.createElement("button"); | |
| chip.className = "ex-chip"; | |
| chip.innerHTML = `<b>${LANG_NATIVE[lang]}</b>${text}`; | |
| chip.title = `${text} · voice: ${voice}`; | |
| chip.onclick = () => { | |
| $("text").value = text; | |
| $("lang").value = lang; | |
| $("voice").value = voice; | |
| showPersona(); | |
| $("text").focus(); | |
| setStatus(`Example loaded — ${voice} reads ${LANG_NATIVE[lang]}. Hit Generate.`); | |
| }; | |
| $("examples").appendChild(chip); | |
| } | |
| // ---------- gradio client ---------- | |
| async function getApp() { | |
| if (!state.app) state.app = await client(location.origin); | |
| return state.app; | |
| } | |
| async function uploadFile(file) { | |
| const form = new FormData(); | |
| form.append("files", file); | |
| const resp = await fetch("/gradio_api/upload", { method: "POST", body: form }); | |
| if (!resp.ok) throw new Error(`Upload failed (${resp.status})`); | |
| const paths = await resp.json(); | |
| return Array.isArray(paths) ? paths[0] : paths; | |
| } | |
| function setStatus(text, busy = false) { | |
| $("status").textContent = text; | |
| $("statusbar").classList.toggle("busy", busy); | |
| $("run-btn").disabled = busy; | |
| } | |
| // ---------- mode tabs ---------- | |
| let frameLoaded = false; | |
| function setMode(mode) { | |
| $("mode-studio").classList.toggle("active", mode === "studio"); | |
| $("mode-workflow").classList.toggle("active", mode === "workflow"); | |
| $("studio-view").style.display = mode === "workflow" ? "none" : ""; | |
| $("workflow-view").hidden = mode !== "workflow"; | |
| if (mode === "workflow" && !frameLoaded) { | |
| $("workflow-frame").src = "/workflow/?ui=canvas"; | |
| frameLoaded = true; | |
| } | |
| } | |
| $("mode-studio").onclick = () => setMode("studio"); | |
| $("mode-workflow").onclick = () => setMode("workflow"); | |
| // ---------- engine pre-warm: build the engine while the user types ---------- | |
| (async () => { | |
| setStatus("Warming up the engine in the background…"); | |
| try { | |
| const app = await getApp(); | |
| await app.predict("/warmup", {}); | |
| setStatus("Engine warm — hit Generate."); | |
| document.querySelector(".cold-hint").textContent = "The engine is warm — runs take a few seconds."; | |
| } catch { | |
| setStatus("Idle — the first run builds the engine (~1 min)."); | |
| } | |
| })(); | |
| // ---------- reference clip ---------- | |
| async function attachClip(file) { | |
| setStatus("Uploading reference clip…", true); | |
| try { | |
| state.refPath = await uploadFile(file); | |
| $("clip-player").src = URL.createObjectURL(file); | |
| $("clip-row").hidden = false; | |
| $("clone-state").textContent = "active"; | |
| $("clone-panel").open = true; | |
| updateRunLabel(); | |
| setStatus("Reference clip ready — add its exact transcript."); | |
| } catch (err) { | |
| setStatus(String(err)); | |
| } | |
| } | |
| $("dropzone").onclick = () => $("file-input").click(); | |
| $("dropzone").addEventListener("dragover", (e) => { e.preventDefault(); $("dropzone").classList.add("over"); }); | |
| $("dropzone").addEventListener("dragleave", () => $("dropzone").classList.remove("over")); | |
| $("dropzone").addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| $("dropzone").classList.remove("over"); | |
| if (e.dataTransfer.files[0]) attachClip(e.dataTransfer.files[0]); | |
| }); | |
| $("file-input").onchange = (e) => { | |
| const file = e.target.files[0]; | |
| e.target.value = ""; | |
| if (file) attachClip(file); | |
| }; | |
| $("sample-ref").onclick = async (e) => { | |
| e.stopPropagation(); | |
| setStatus("Fetching the Ritvik sample clip…", true); | |
| try { | |
| const blob = await (await fetch(SAMPLE_REF.url)).blob(); | |
| await attachClip(new File([blob], SAMPLE_REF.name, { type: "audio/wav" })); | |
| $("ref-text").value = SAMPLE_REF.text; | |
| $("ref-lang").value = SAMPLE_REF.lang; | |
| setStatus("Sample clip + transcript loaded — hit Generate."); | |
| } catch (err) { | |
| setStatus(String(err)); | |
| } | |
| }; | |
| $("clip-clear").onclick = () => { | |
| state.refPath = null; | |
| $("clip-row").hidden = true; | |
| $("clip-player").removeAttribute("src"); | |
| $("clone-state").textContent = ""; | |
| updateRunLabel(); | |
| }; | |
| function updateRunLabel() { | |
| $("run-btn").innerHTML = state.refPath ? "▶ Generate with cloned voice" : "▶ Generate speech"; | |
| } | |
| // ---------- history ---------- | |
| const historyItems = []; | |
| function pushHistory(item) { | |
| historyItems.unshift(item); | |
| if (historyItems.length > 10) historyItems.pop(); | |
| $("history").hidden = false; | |
| const strip = $("history-strip"); | |
| strip.innerHTML = ""; | |
| historyItems.forEach((h, i) => { | |
| const chip = document.createElement("button"); | |
| chip.className = "hchip"; | |
| chip.textContent = `${h.voice} · ${h.lang} · ${h.duration}s`; | |
| chip.title = h.text.slice(0, 120); | |
| chip.onclick = () => showResult(h, false); | |
| strip.appendChild(chip); | |
| }); | |
| } | |
| // ---------- generate ---------- | |
| function unwrap(result) { | |
| const data = Array.isArray(result?.data) ? result.data[0] : result?.data; | |
| return data ?? {}; | |
| } | |
| // ---------- waveform player ---------- | |
| const wave = $("wave"); | |
| const wctx = wave.getContext("2d"); | |
| const player = $("player"); | |
| let wavePeaks = null; | |
| let audioCtx = null; | |
| async function buildWave(url) { | |
| try { | |
| audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); | |
| const buf = await audioCtx.decodeAudioData(await (await fetch(url)).arrayBuffer()); | |
| const data = buf.getChannelData(0); | |
| const bars = 120; | |
| const peaks = new Float32Array(bars); | |
| const step = Math.floor(data.length / bars) || 1; | |
| for (let i = 0; i < bars; i++) { | |
| let peak = 0; | |
| const start = i * step; | |
| for (let j = start; j < Math.min(start + step, data.length); j += 8) { | |
| const v = Math.abs(data[j]); | |
| if (v > peak) peak = v; | |
| } | |
| peaks[i] = peak; | |
| } | |
| wavePeaks = peaks; | |
| } catch { | |
| wavePeaks = null; // decode failed: fall back to a flat progress bar | |
| } | |
| drawWave(); | |
| } | |
| function drawWave() { | |
| const dpr = window.devicePixelRatio || 1; | |
| const w = wave.clientWidth, h = wave.clientHeight; | |
| if (!w) return; | |
| if (wave.width !== Math.round(w * dpr)) { wave.width = Math.round(w * dpr); wave.height = Math.round(h * dpr); } | |
| wctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| wctx.clearRect(0, 0, w, h); | |
| let frac = player.duration ? player.currentTime / player.duration : 0; | |
| if (streamCtl && streamCtl.streamed > 0 && audioCtx) { | |
| frac = Math.min(1, Math.max(0, (audioCtx.currentTime - streamCtl.t0) / streamCtl.streamed)); | |
| } | |
| const bars = wavePeaks ? wavePeaks.length : 120; | |
| const gap = 2; | |
| const bw = Math.max(1.5, (w - gap * (bars - 1)) / bars); | |
| for (let i = 0; i < bars; i++) { | |
| const p = wavePeaks ? Math.max(0.06, wavePeaks[i]) : 0.5; | |
| const bh = Math.max(3, p * (h - 6)); | |
| const x = i * (bw + gap); | |
| wctx.fillStyle = i / bars <= frac ? "#ffffff" : "rgba(255,255,255,0.30)"; | |
| if (wctx.roundRect) { | |
| wctx.beginPath(); | |
| wctx.roundRect(x, (h - bh) / 2, bw, bh, bw / 2); | |
| wctx.fill(); | |
| } else { | |
| wctx.fillRect(x, (h - bh) / 2, bw, bh); | |
| } | |
| } | |
| } | |
| function fmtTime(s) { | |
| if (!isFinite(s)) s = 0; | |
| return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; | |
| } | |
| player.addEventListener("timeupdate", () => { | |
| drawWave(); | |
| $("result-time").textContent = `${fmtTime(player.currentTime)} / ${fmtTime(player.duration)}`; | |
| }); | |
| player.addEventListener("play", () => { $("play-btn").textContent = "⏸"; }); | |
| player.addEventListener("pause", () => { $("play-btn").textContent = "▶"; }); | |
| player.addEventListener("ended", () => { $("play-btn").textContent = "▶"; drawWave(); }); | |
| $("play-btn").onclick = () => { | |
| if (streamCtl && audioCtx) { // a take is streaming: pause the whole context | |
| if (audioCtx.state === "running") { audioCtx.suspend(); $("play-btn").textContent = "▶"; } | |
| else { audioCtx.resume(); $("play-btn").textContent = "⏸"; } | |
| return; | |
| } | |
| if (!player.src) return; | |
| player.paused ? player.play().catch(() => {}) : player.pause(); | |
| }; | |
| wave.addEventListener("click", (e) => { | |
| if (!player.duration) return; | |
| const box = wave.getBoundingClientRect(); | |
| player.currentTime = Math.min(0.999, Math.max(0, (e.clientX - box.left) / box.width)) * player.duration; | |
| }); | |
| window.addEventListener("resize", drawWave); | |
| function showResult(item, isNew, autoplay = true) { | |
| stopLive(); | |
| player.src = item.audio_url; | |
| $("download").href = item.audio_url; | |
| $("result-meta").textContent = `${item.voice} · ${item.langName} · ${item.duration}s`; | |
| $("result-time").textContent = `0:00 / ${fmtTime(item.duration)}`; | |
| $("result").hidden = false; | |
| $("seed-out").textContent = "seed " + item.seed; | |
| $("play-btn").textContent = "▶"; | |
| wavePeaks = null; | |
| drawWave(); | |
| buildWave(item.audio_url); | |
| if (autoplay) player.play().catch(() => {}); | |
| if (isNew) pushHistory(item); | |
| } | |
| // ---------- streaming generation (SSE of PCM chunks -> WebAudio) ---------- | |
| let streamCtl = null; | |
| function stopLive() { | |
| if (streamCtl) { | |
| streamCtl.aborted = true; | |
| try { streamCtl.ac.abort(); } catch {} | |
| streamCtl = null; | |
| if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null; } | |
| } | |
| $("play-btn").textContent = "▶"; | |
| } | |
| function base64ToInt16(b64) { | |
| const bin = atob(b64); | |
| const bytes = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); | |
| return new Int16Array(bytes.buffer); | |
| } | |
| function scheduleChunk(ctl, pcm) { | |
| const buf = audioCtx.createBuffer(1, pcm.length, 24000); | |
| const chan = buf.getChannelData(0); | |
| for (let i = 0; i < pcm.length; i++) chan[i] = pcm[i] / 32768; | |
| const src = audioCtx.createBufferSource(); | |
| src.buffer = buf; | |
| src.connect(audioCtx.destination); | |
| const start = Math.max(audioCtx.currentTime + 0.12, ctl.playTime); | |
| src.start(start); | |
| ctl.playTime = start + buf.duration; | |
| ctl.streamed += buf.duration; | |
| } | |
| async function streamGenerate(payload) { | |
| const ctl = { ac: new AbortController(), streamed: 0, playTime: 0, t0: 0, done: false, aborted: false }; | |
| streamCtl = ctl; | |
| let resp; | |
| try { | |
| resp = await fetch("/api/stream", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| signal: ctl.ac.signal, | |
| }); | |
| } catch (err) { | |
| if (ctl.aborted) return null; | |
| throw err; | |
| } | |
| if (ctl.aborted) return null; | |
| if (!resp.ok || !resp.body) { | |
| let msg = `Stream failed (${resp.status})`; | |
| try { msg = (await resp.json()).detail || msg; } catch {} | |
| throw new Error(msg); | |
| } | |
| audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 }); | |
| await audioCtx.resume(); | |
| ctl.t0 = audioCtx.currentTime + 0.05; | |
| $("result").hidden = false; | |
| $("result-meta").textContent = `${payload.voice || "cloned voice"} · ${LANG_NATIVE[payload.lang] || payload.lang} · live`; | |
| $("result-time").textContent = "0:00"; | |
| $("play-btn").textContent = "⏸"; | |
| wavePeaks = []; | |
| drawWave(); | |
| const reader = resp.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buf = ""; | |
| let finalItem = null; | |
| try { | |
| for (;;) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buf += decoder.decode(value, { stream: true }); | |
| let idx; | |
| while ((idx = buf.indexOf("\n\n")) >= 0) { | |
| const block = buf.slice(0, idx); | |
| buf = buf.slice(idx + 2); | |
| for (const line of block.split("\n")) { | |
| if (!line.startsWith("data: ")) continue; | |
| const ev = JSON.parse(line.slice(6)); | |
| if (ev.type === "audio") { | |
| const pcm = base64ToInt16(ev.audio); | |
| scheduleChunk(ctl, pcm); | |
| let peak = 0; | |
| for (let i = 0; i < pcm.length; i += 16) { | |
| const v = Math.abs(pcm[i]) / 32768; | |
| if (v > peak) peak = v; | |
| } | |
| wavePeaks.push(peak); | |
| drawWave(); | |
| $("result-time").textContent = `${fmtTime(Math.max(0, audioCtx.currentTime - ctl.t0))} / ${fmtTime(ctl.streamed)}`; | |
| setStatus(`Streaming… ${ctl.streamed.toFixed(1)}s of audio`, true); | |
| } else if (ev.type === "done") { | |
| finalItem = ev; | |
| } else if (ev.type === "error") { | |
| throw new Error(ev.message); | |
| } | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| if (ctl.aborted) return null; | |
| throw err; | |
| } | |
| ctl.done = true; | |
| if (ctl.aborted) return null; | |
| return { item: finalItem, ctl }; | |
| } | |
| // Swap the live stream for the file-backed player once playback has drained. | |
| function watchDrain(ctl, item, meta) { | |
| if (streamCtl !== ctl) return; // a newer take replaced this one | |
| if (!audioCtx || audioCtx.currentTime + 0.05 >= ctl.playTime) { | |
| streamCtl = null; | |
| showResult({ ...item, ...meta }, true, false); // no autoplay: already heard | |
| return; | |
| } | |
| drawWave(); | |
| $("result-time").textContent = `${fmtTime(Math.max(0, audioCtx.currentTime - ctl.t0))} / ${fmtTime(ctl.streamed)}`; | |
| setTimeout(() => watchDrain(ctl, item, meta), 200); | |
| } | |
| $("run-btn").onclick = async () => { | |
| const text = $("text").value.trim(); | |
| if (!text) { setStatus("Type something to say first."); $("text").focus(); return; } | |
| const lang = $("lang").value; | |
| const seed = parseInt($("seed").value, 10) || 0; | |
| const randomize = $("randomize").checked; | |
| const normalize = $("normalize").checked; | |
| const cloning = state.refPath != null; | |
| if (cloning && !$("ref-text").value.trim()) { | |
| setStatus("Voice cloning needs the reference clip's exact transcript."); | |
| $("clone-panel").open = true; | |
| $("ref-text").focus(); | |
| return; | |
| } | |
| stopLive(); | |
| const payload = { text, lang, voice: $("voice").value, normalize, seed, randomize }; | |
| if (cloning) Object.assign(payload, { | |
| ref_audio: state.refPath, | |
| ref_text: $("ref-text").value.trim(), | |
| ref_lang: $("ref-lang").value, | |
| }); | |
| const meta = { | |
| text, lang, | |
| langName: LANG_NATIVE[lang] || lang, | |
| voice: cloning ? "cloned voice" : $("voice").value, | |
| }; | |
| try { | |
| setStatus("Waking the GPU worker — first run can take about a minute…", true); | |
| const outcome = await streamGenerate(payload); | |
| if (!outcome) return; // aborted by a newer take | |
| const { item, ctl } = outcome; | |
| if (!item || !item.audio_url) { setStatus("Stream ended without audio (see console)."); return; } | |
| watchDrain(ctl, item, meta); | |
| setStatus("Done"); | |
| } catch (err) { | |
| stopLive(); | |
| setStatus(err?.message || String(err)); | |
| } | |
| }; | |
| // Ctrl/Cmd+Enter generates. | |
| $("text").addEventListener("keydown", (e) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") $("run-btn").click(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |