| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Echoes β Family Voice Archive</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" /> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| colors: { |
| void: '#0d0b18', |
| surface: 'rgba(20,16,35,0.85)', |
| rose: '#ff6b9d', |
| amber: '#ffb347', |
| violet: '#c084fc', |
| indigo: '#818cf8', |
| mint: '#34d399', |
| sky: '#38bdf8', |
| muted: '#7a6a9a', |
| ghost: '#3e305a', |
| }, |
| } |
| } |
| } |
| </script> |
| <style> |
| * { box-sizing: border-box; } |
| body { margin: 0; background: #0d0b18; font-family: 'Inter', sans-serif; color: #f0eaff; overflow-x: hidden; } |
| |
| @keyframes shimmer { 0%{background-position:0% 50%} 100%{background-position:300% 50%} } |
| @keyframes slideInLeft{ from{opacity:0;transform:translateX(-40px)} to{opacity:1;transform:translateX(0)} } |
| @keyframes fadeSlideIn{ from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} } |
| @keyframes wbar { 0%{height:4px} 100%{height:18px} } |
| @keyframes ripple { 0%{transform:scale(1);opacity:.7} 100%{transform:scale(2.4);opacity:0} } |
| @keyframes dotPulse { 0%,80%,100%{opacity:.2;transform:scale(.8)} 40%{opacity:1;transform:scale(1)} } |
| @keyframes borderFlash{ 0%,100%{border-color:rgba(192,132,252,.2)} 50%{border-color:rgba(52,211,153,.8)} } |
| @keyframes spin { to{transform:rotate(360deg)} } |
| |
| .shimmer-text { |
| background: linear-gradient(90deg,#ff6b9d,#ffb347,#c084fc,#818cf8,#ff6b9d); |
| background-size: 300%; |
| -webkit-background-clip: text; background-clip: text; |
| -webkit-text-fill-color: transparent; |
| animation: shimmer 5s linear infinite; |
| } |
| |
| .card { |
| backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); |
| background: rgba(20,16,35,0.85); |
| border: 1px solid rgba(192,132,252,0.2); |
| border-radius: 1rem; |
| transition: border-color .3s; |
| } |
| .card:hover { border-color: rgba(192,132,252,0.4); } |
| .card-flash { animation: borderFlash .6s ease 2; } |
| |
| .btn-primary { |
| background: linear-gradient(135deg,#ff6b9d,#ffb347); |
| color: white; border: none; border-radius: 9999px; |
| font-weight: 600; font-size: 11px; letter-spacing: .18em; |
| text-transform: uppercase; cursor: pointer; |
| padding: 12px 28px; |
| display: inline-flex; align-items: center; gap: 8px; justify-content: center; |
| transition: transform .15s, box-shadow .2s; |
| } |
| .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(255,107,157,.4); } |
| .btn-primary:active { transform: scale(.97); } |
| .btn-primary:disabled { opacity: .6; cursor: not-allowed; transform: none; } |
| |
| .btn-ghost { |
| background: transparent; |
| border: 1px solid rgba(192,132,252,.3); |
| color: #7a6a9a; border-radius: 9999px; font-size: 12px; |
| cursor: pointer; padding: 8px 18px; |
| display: inline-flex; align-items: center; gap: 6px; |
| transition: border-color .2s, color .2s; |
| } |
| .btn-ghost:hover { border-color: rgba(192,132,252,.6); color: #c084fc; } |
| .btn-ghost:active { transform: scale(.97); } |
| |
| .form-input { |
| width: 100%; |
| background: rgba(20,16,35,.8); |
| border: 1px solid rgba(192,132,252,.2); |
| border-radius: .75rem; color: #f0eaff; |
| padding: 12px 16px; font-family: 'Inter',sans-serif; font-size: 14px; |
| outline: none; transition: border-color .2s, box-shadow .2s; |
| } |
| .form-input::placeholder { color: #3e305a; } |
| .form-input:focus { border-color: rgba(192,132,252,.5); box-shadow: 0 0 0 3px rgba(192,132,252,.1); } |
| select.form-input option { background: #1a1030; color: #f0eaff; } |
| |
| .eyebrow { font-size: 10px; letter-spacing: .28em; text-transform: uppercase; color: #ff6b9d; font-weight: 500; } |
| |
| .wbar { width: 3px; background: currentColor; border-radius: 2px; animation: wbar .8s ease-in-out infinite alternate; } |
| |
| .dot-loader span { display:inline-block; width:5px; height:5px; background:white; border-radius:50%; animation:dotPulse 1.2s infinite; } |
| .dot-loader span:nth-child(2) { animation-delay:.2s; } |
| .dot-loader span:nth-child(3) { animation-delay:.4s; } |
| |
| .mic-btn { |
| width:72px; height:72px; border-radius:50%; |
| background:linear-gradient(135deg,#ff6b9d,#c084fc); |
| border:none; cursor:pointer; position:relative; |
| display:flex; align-items:center; justify-content:center; |
| font-size:28px; transition:transform .15s; flex-shrink:0; |
| } |
| .mic-btn:hover { transform:scale(1.05); } |
| .mic-btn.recording::before { |
| content:''; position:absolute; width:100%; height:100%; border-radius:50%; |
| border:2px solid #ff6b9d; animation:ripple 1.4s ease-out infinite; |
| } |
| .mic-btn.recording::after { |
| content:''; position:absolute; width:100%; height:100%; border-radius:50%; |
| border:2px solid #c084fc; animation:ripple 1.4s ease-out .6s infinite; |
| } |
| |
| .profile-table { width:100%; border-collapse:collapse; } |
| .profile-table th { background:rgba(192,132,252,.1); color:#c084fc; font-size:10px; letter-spacing:.18em; text-transform:uppercase; padding:10px 14px; text-align:left; font-weight:500; } |
| .profile-table td { color:#7a6a9a; font-size:13px; padding:10px 14px; border-bottom:1px solid rgba(192,132,252,.08); } |
| .profile-table tr:hover td { background:rgba(192,132,252,.04); } |
| |
| .nav { position:sticky; top:0; z-index:50; backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px); background:rgba(13,11,24,.8); border-bottom:1px solid rgba(192,132,252,.1); } |
| |
| .tab-btn { padding:14px 24px; font-size:14px; font-weight:500; background:none; border:none; border-bottom:2px solid transparent; cursor:pointer; color:#7a6a9a; transition:color .2s,border-color .2s; } |
| .tab-btn.active { color:#c084fc; border-bottom-color:#c084fc; } |
| .tab-btn:hover:not(.active) { color:#f0eaff; } |
| |
| .page-transition { transition: all .5s ease-in-out; } |
| .page-hidden { opacity:0; transform:translateY(16px); pointer-events:none; position:absolute; width:100%; } |
| |
| audio { width:100%; accent-color:#c084fc; } |
| |
| ::-webkit-scrollbar { width:5px; } |
| ::-webkit-scrollbar-track { background:transparent; } |
| ::-webkit-scrollbar-thumb { background:rgba(192,132,252,.3); border-radius:4px; } |
| |
| canvas#bg { position:fixed; inset:0; z-index:0; pointer-events:none; } |
| .content-layer { position:relative; z-index:1; } |
| |
| .memory-card { animation:slideInLeft .6s ease forwards; opacity:0; } |
| .memory-card:nth-child(1) { animation-delay:.2s; } |
| .memory-card:nth-child(2) { animation-delay:.4s; } |
| .memory-card:nth-child(3) { animation-delay:.6s; } |
| |
| .tab-content { animation:fadeSlideIn .35s ease forwards; } |
| |
| .transcription-preview { border-left:2px solid rgba(192,132,252,.4); padding-left:1rem; font-style:italic; color:#7a6a9a; font-size:13px; line-height:1.6; } |
| |
| .response-quote { border-left:4px solid #c084fc; border-radius:0 .75rem .75rem 0; background:rgba(20,16,35,.85); padding:20px; font-family:'Playfair Display',serif; font-style:italic; color:#c8b8f0; font-size:15px; line-height:1.7; } |
| |
| .step-number { width:32px; height:32px; border-radius:50%; background:rgba(192,132,252,.15); border:1px solid rgba(192,132,252,.3); color:#c084fc; font-size:13px; font-weight:600; display:flex; align-items:center; justify-content:center; flex-shrink:0; } |
| |
| .status-ok { color:#34d399; background:rgba(52,211,153,.1); padding:8px 12px; border-radius:8px; font-size:13px; } |
| .status-err { color:#ff6b9d; background:rgba(255,107,157,.1); padding:8px 12px; border-radius:8px; font-size:13px; } |
| |
| |
| .modal-overlay { |
| position: fixed; inset: 0; z-index: 100; |
| background: rgba(13,11,24,.85); backdrop-filter: blur(8px); |
| display: flex; align-items: center; justify-content: center; |
| opacity: 0; pointer-events: none; transition: opacity .3s; |
| } |
| .modal-overlay.active { opacity: 1; pointer-events: all; } |
| |
| .modal-card { |
| background: rgba(20,16,35,.95); border: 1px solid rgba(192,132,252,.3); |
| border-radius: 1rem; padding: 2rem; max-width: 520px; width: 90%; |
| transform: translateY(20px); transition: transform .3s; |
| } |
| .modal-overlay.active .modal-card { transform: translateY(0); } |
| |
| .prompt-text { |
| font-family: 'Playfair Display', serif; font-style: italic; |
| color: #c8b8f0; font-size: 16px; line-height: 1.8; |
| background: rgba(13,11,24,.6); border-radius: .75rem; |
| padding: 1.25rem; border-left: 3px solid #c084fc; |
| margin: 1rem 0; |
| } |
| |
| .timer-display { |
| font-family: 'Inter', monospace; font-size: 14px; font-weight: 600; |
| color: #ff6b9d; |
| } |
| |
| .record-progress { |
| height: 4px; background: rgba(192,132,252,.15); border-radius: 2px; |
| overflow: hidden; margin: .75rem 0; |
| } |
| .record-progress-bar { |
| height: 100%; background: linear-gradient(90deg,#ff6b9d,#c084fc); |
| border-radius: 2px; transition: width .3s; width: 0%; |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <canvas id="bg"></canvas> |
|
|
| <div class="content-layer min-h-screen"> |
|
|
| |
| <div id="loginPage" class="page-transition min-h-screen flex items-center px-8 py-12"> |
| <div class="max-w-6xl mx-auto w-full flex gap-16 items-center"> |
|
|
| |
| <div class="flex-1 min-w-0"> |
| <p class="eyebrow mb-6">Family Voice Archive</p> |
| <h1 class="font-['Playfair_Display'] text-[52px] leading-[1.15] font-semibold mb-6"> |
| Your family's voice,<br> |
| <em class="shimmer-text">forever.</em> |
| </h1> |
| <p class="text-muted text-[15px] leading-relaxed mb-10 max-w-md"> |
| Preserve the voices you love. Save memories, capture stories, and hear them again β spoken in the voice that means most to you. |
| </p> |
|
|
| <div class="space-y-3"> |
| <div class="memory-card card p-4 flex items-center gap-4"> |
| <div class="w-10 h-10 rounded-full flex items-center justify-center text-xl flex-shrink-0" style="background:rgba(255,107,157,.15)">π΅</div> |
| <div class="flex-1"><p class="text-sm font-medium text-[#f0eaff]">Grandmother</p><p class="text-xs text-muted">42 memories Β· 3 voice profiles</p></div> |
| <div class="flex items-end gap-[3px] h-5" style="color:#ff6b9d"> |
| <div class="wbar" style="animation-delay:0s"></div><div class="wbar" style="animation-delay:.15s"></div><div class="wbar" style="animation-delay:.3s"></div><div class="wbar" style="animation-delay:.45s"></div><div class="wbar" style="animation-delay:.6s"></div> |
| </div> |
| </div> |
| <div class="memory-card card p-4 flex items-center gap-4"> |
| <div class="w-10 h-10 rounded-full flex items-center justify-center text-xl flex-shrink-0" style="background:rgba(129,140,248,.15)">π¨</div> |
| <div class="flex-1"><p class="text-sm font-medium text-[#f0eaff]">Father</p><p class="text-xs text-muted">28 memories Β· 1 voice profile</p></div> |
| <div class="flex items-end gap-[3px] h-5" style="color:#818cf8"> |
| <div class="wbar" style="animation-delay:.1s"></div><div class="wbar" style="animation-delay:.25s"></div><div class="wbar" style="animation-delay:.4s"></div><div class="wbar" style="animation-delay:.55s"></div><div class="wbar" style="animation-delay:.7s"></div> |
| </div> |
| </div> |
| <div class="memory-card card p-4 flex items-center gap-4"> |
| <div class="w-10 h-10 rounded-full flex items-center justify-center text-xl flex-shrink-0" style="background:rgba(52,211,153,.15)">π©</div> |
| <div class="flex-1"><p class="text-sm font-medium text-[#f0eaff]">Mother</p><p class="text-xs text-muted">67 memories Β· 2 voice profiles</p></div> |
| <div class="flex items-end gap-[3px] h-5" style="color:#34d399"> |
| <div class="wbar" style="animation-delay:.05s"></div><div class="wbar" style="animation-delay:.2s"></div><div class="wbar" style="animation-delay:.35s"></div><div class="wbar" style="animation-delay:.5s"></div><div class="wbar" style="animation-delay:.65s"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="w-[420px] flex-shrink-0 border-l border-[rgba(192,132,252,.1)] pl-14"> |
| <p class="eyebrow mb-4">Begin Your Archive</p> |
| <h2 class="font-['Playfair_Display'] text-3xl mb-2">Welcome back, <em style="color:#c084fc">family.</em></h2> |
| <p class="text-muted text-sm mb-8">Enter your Hugging Face username to access your voice archive.</p> |
| <div class="space-y-4"> |
| <input id="loginInput" type="text" placeholder="your-hf-username" class="form-input" |
| autocomplete="off" onkeydown="if(event.key==='Enter') doLogin()" /> |
| <button id="loginBtn" class="btn-primary w-full" onclick="doLogin()">β¦ Enter Your Archive</button> |
| <div id="loginStatus" class="hidden"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="mainApp" class="page-hidden page-transition"> |
|
|
| |
| <nav class="nav px-6 flex items-center justify-between h-14"> |
| <div class="flex items-center gap-3"> |
| <div class="w-7 h-7 rounded-full flex items-center justify-center text-sm" style="background:linear-gradient(135deg,#ff6b9d,#c084fc)">βͺ</div> |
| <span class="font-['Playfair_Display'] font-semibold text-[#f0eaff] tracking-wide">Echoes</span> |
| </div> |
| <div class="flex items-center gap-3"> |
| <div class="flex items-center gap-2 px-3 py-1.5 rounded-full" style="background:rgba(192,132,252,.1);border:1px solid rgba(192,132,252,.2)"> |
| <div id="userAvatar" class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold text-void" style="background:linear-gradient(135deg,#ff6b9d,#c084fc)"></div> |
| <span id="userLabel" class="text-xs font-medium text-violet"></span> |
| </div> |
| <button class="btn-ghost text-xs" onclick="doLogout()">Log Out</button> |
| </div> |
| </nav> |
|
|
| |
| <div class="border-b border-[rgba(192,132,252,.1)] px-6 flex"> |
| <button class="tab-btn active" id="tab1btn" onclick="switchTab(0)">π Voice & Memories</button> |
| <button class="tab-btn" id="tab2btn" onclick="switchTab(1)">β¨ Listen Mode</button> |
| </div> |
|
|
| <div class="px-6 py-6"> |
|
|
| |
| <div id="tab1" class="tab-content"> |
| <div class="grid grid-cols-2 gap-6 mb-6"> |
|
|
| |
| <div class="card p-6" id="profileCard"> |
| <p class="eyebrow mb-1">Save Their Voice</p> |
| <h3 class="font-['Playfair_Display'] text-xl font-semibold mb-1">Voice Profile</h3> |
| <p class="text-muted text-sm mb-5">Record a clear voice sample β at least 20 seconds works best.</p> |
|
|
| <label class="text-xs text-muted uppercase tracking-widest mb-1.5 block">Relationship</label> |
| <input id="profileRelInput" type="text" placeholder="grandmother, father, siblingβ¦" class="form-input mb-5" /> |
|
|
| <div class="rounded-xl p-4 mb-4" style="background:rgba(13,11,24,.6);border:1px solid rgba(192,132,252,.15)"> |
| <div class="flex items-center gap-4 mb-3"> |
| <button id="micBtnA" class="mic-btn" onclick="openRecordModal()">π€</button> |
| <div class="flex-1"> |
| <p id="recTimerA" class="text-lg font-mono font-semibold text-[#f0eaff]">0:00</p> |
| <p id="recLabelA" class="text-xs text-muted">Tap to start recording</p> |
| </div> |
| <div id="waveA" class="flex items-end gap-[3px] h-6 opacity-30" style="color:#ff6b9d"> |
| <div class="wbar" style="animation-delay:0s"></div><div class="wbar" style="animation-delay:.12s"></div><div class="wbar" style="animation-delay:.24s"></div><div class="wbar" style="animation-delay:.36s"></div><div class="wbar" style="animation-delay:.48s"></div> |
| </div> |
| </div> |
| <div id="playbackA" class="hidden mb-2"><audio id="audioA" controls class="w-full"></audio></div> |
| <div class="flex gap-2 mt-2"> |
| <label class="btn-ghost text-xs cursor-pointer">π Upload file<input type="file" accept="audio/*" class="hidden" onchange="handleFileUpload(event,'A')" /></label> |
| <button id="clearBtnA" class="btn-ghost text-xs hidden" onclick="clearRecording('A')">β Clear</button> |
| </div> |
| </div> |
|
|
| <button id="saveProfileBtn" class="btn-primary w-full mb-3" onclick="saveProfile()">πΎ Save Voice Profile</button> |
| <div id="profileStatus" class="hidden"></div> |
| </div> |
|
|
| |
| <div class="card p-6" id="storyCard"> |
| <p class="eyebrow mb-1">Capture a Memory</p> |
| <h3 class="font-['Playfair_Display'] text-xl font-semibold mb-1">Story Memory</h3> |
| <p class="text-muted text-sm mb-5">Record a story or memory. It will be transcribed and stored for recall.</p> |
|
|
| <label class="text-xs text-muted uppercase tracking-widest mb-1.5 block">Choose Relationship</label> |
| <select id="storyRelSelect" class="form-input mb-5"> |
| <option value="">β select a relationship β</option> |
| </select> |
|
|
| <div class="rounded-xl p-4 mb-4" style="background:rgba(13,11,24,.6);border:1px solid rgba(192,132,252,.15)"> |
| <div class="flex items-center gap-4 mb-3"> |
| <button id="micBtnB" class="mic-btn" onclick="toggleRecording('B')">π€</button> |
| <div class="flex-1"> |
| <p id="recTimerB" class="text-lg font-mono font-semibold text-[#f0eaff]">0:00</p> |
| <p id="recLabelB" class="text-xs text-muted">Tap to start recording</p> |
| </div> |
| <div id="waveB" class="flex items-end gap-[3px] h-6 opacity-30" style="color:#c084fc"> |
| <div class="wbar" style="animation-delay:.1s"></div><div class="wbar" style="animation-delay:.22s"></div><div class="wbar" style="animation-delay:.34s"></div><div class="wbar" style="animation-delay:.46s"></div><div class="wbar" style="animation-delay:.58s"></div> |
| </div> |
| </div> |
| <div id="playbackB" class="hidden mb-2"><audio id="audioB" controls class="w-full"></audio></div> |
| <div class="flex gap-2 mt-2"> |
| <label class="btn-ghost text-xs cursor-pointer">π Upload file<input type="file" accept="audio/*" class="hidden" onchange="handleFileUpload(event,'B')" /></label> |
| <button id="clearBtnB" class="btn-ghost text-xs hidden" onclick="clearRecording('B')">β Clear</button> |
| </div> |
| </div> |
|
|
| <button id="saveStoryBtn" class="btn-primary w-full mb-3" onclick="saveStory()">πΎ Save Story Memory</button> |
| <div id="storyStatus" class="hidden"></div> |
| <div id="transcriptBox" class="hidden mt-4"> |
| <p class="text-xs text-muted uppercase tracking-widest mb-2">Transcription</p> |
| <div id="transcriptText" class="transcription-preview"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="card p-6"> |
| <div class="flex items-center justify-between mb-5"> |
| <div><p class="eyebrow mb-1">Archive</p><h3 class="font-['Playfair_Display'] text-xl font-semibold">Saved Voice Profiles</h3></div> |
| <button class="btn-ghost" onclick="doRefresh()">β» Refresh</button> |
| </div> |
| <div class="rounded-xl overflow-hidden" style="background:rgba(13,11,24,.6)"> |
| <table class="profile-table"> |
| <thead><tr><th>ID</th><th>Relationship</th><th>Saved At</th></tr></thead> |
| <tbody id="profileTableBody"> |
| <tr><td colspan="3" class="text-center py-8 text-muted text-sm">No profiles saved yet.</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <details class="mt-4 card p-4"> |
| <summary class="btn-ghost text-xs cursor-pointer select-none inline-flex items-center">π Debug Audio</summary> |
| <div class="mt-4"> |
| <button class="btn-ghost text-xs mb-3" onclick="runDebug()">Run Diagnostics</button> |
| <textarea id="debugOutput" class="form-input text-xs font-mono" rows="6" readonly placeholder="Diagnostics will appear hereβ¦"></textarea> |
| </div> |
| </details> |
| </div> |
|
|
| |
| <div id="tab2" class="tab-content hidden"> |
| <div class="max-w-2xl mx-auto"> |
| <div class="mb-8"> |
| <p class="eyebrow mb-3">Listen Mode</p> |
| <h2 class="font-['Playfair_Display'] text-4xl font-semibold leading-tight mb-3">Hear them <em class="shimmer-text">again.</em></h2> |
| <p class="text-muted text-sm">Choose a relationship, ask a question, and hear a response in their synthesized voice.</p> |
| </div> |
|
|
| |
| <div class="card p-5 mb-4"> |
| <div class="flex items-center gap-3 mb-4"> |
| <div class="step-number">1</div> |
| <div><p class="font-medium text-[#f0eaff] text-sm">Choose who to hear</p><p class="text-xs text-muted">Select a saved relationship</p></div> |
| </div> |
| <select id="listenRelSelect" class="form-input"> |
| <option value="">β select a relationship β</option> |
| </select> |
| </div> |
|
|
| |
| <div class="card p-5 mb-4"> |
| <div class="flex items-center gap-3 mb-4"> |
| <div class="step-number">2</div> |
| <div><p class="font-medium text-[#f0eaff] text-sm">Ask your question</p><p class="text-xs text-muted">What would you like to hear from them?</p></div> |
| </div> |
| <div class="relative"> |
| <textarea id="questionInput" class="form-input" rows="3" |
| placeholder="e.g., What is your favourite childhood memory?" |
| oninput="document.getElementById('charCount').textContent=this.value.length"></textarea> |
| <span id="charCount" class="absolute bottom-2 right-3 text-[#3e305a] text-xs">0</span> |
| </div> |
| </div> |
|
|
| |
| <div class="card p-5 mb-4"> |
| <div class="flex items-center gap-3 mb-4"> |
| <div class="step-number">3</div> |
| <div><p class="font-medium text-[#f0eaff] text-sm">Listen</p><p class="text-xs text-muted">Play original voice or generate a synthesized response</p></div> |
| </div> |
| <div class="flex gap-3 mb-5"> |
| <button id="playOriginalBtn" class="btn-ghost flex-1" onclick="playOriginal()">βΆ Play original voice</button> |
| <button id="generateBtn" class="btn-primary flex-1" onclick="doGenerate()">β¦ Ask & Listen</button> |
| </div> |
|
|
| <div id="originalAudioWrap" class="hidden mb-4"> |
| <p class="text-xs text-muted mb-1">Original Voice</p> |
| <audio id="originalAudio" controls class="w-full"></audio> |
| </div> |
|
|
| <div id="responseBlock" class="hidden"> |
| <div id="responseQuote" class="response-quote mb-4"></div> |
| <div class="mb-3"> |
| <p class="text-xs text-muted mb-1">Synthesized in <span id="synthLabel" class="text-violet"></span>'s voice</p> |
| <audio id="generatedAudio" controls class="w-full"></audio> |
| </div> |
| <p id="genStatus" class="text-[#3e305a] text-xs"></p> |
| </div> |
|
|
| <div id="listenStatus" class="hidden mt-3"></div> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="recordModal" class="modal-overlay"> |
| <div class="modal-card"> |
| <div class="flex items-center justify-between mb-2"> |
| <p class="eyebrow">Record Voice Profile</p> |
| <button class="btn-ghost text-xs" onclick="closeRecordModal()">β Close</button> |
| </div> |
| <h3 class="font-['Playfair_Display'] text-xl font-semibold mb-1">Read this passage aloud</h3> |
| <p class="text-muted text-sm mb-4">Speak clearly and naturally. Aim for 20β30 seconds.</p> |
| |
| <div class="prompt-text" id="promptText"> |
| The warmth of family is the greatest gift we share. Every laugh, every tear, every quiet moment together becomes a treasure in our hearts. Time may pass, but these memories remain forever, glowing softly like stars in the night sky, guiding us home. |
| </div> |
| |
| <div class="flex items-center justify-between mb-3"> |
| <div class="flex items-center gap-2"> |
| <button id="modalMicBtn" class="mic-btn" style="width:56px;height:56px;font-size:20px;" onclick="toggleModalRecording()">π€</button> |
| <div> |
| <p id="modalRecTimer" class="timer-display">0:00</p> |
| <p id="modalRecLabel" class="text-xs text-muted">Tap to start</p> |
| </div> |
| </div> |
| <div class="text-right"> |
| <p class="text-xs text-muted">Target: 20β30s</p> |
| <p id="modalRecDuration" class="text-xs" style="color:#34d399">0s recorded</p> |
| </div> |
| </div> |
| |
| <div class="record-progress"> |
| <div id="recordProgressBar" class="record-progress-bar"></div> |
| </div> |
| |
| <div id="modalPlayback" class="hidden mb-3"> |
| <audio id="modalAudio" controls class="w-full"></audio> |
| </div> |
| |
| <div class="flex gap-2"> |
| <button id="modalSaveBtn" class="btn-primary flex-1 hidden" onclick="saveModalRecording()">β Use This Recording</button> |
| <button id="modalRetryBtn" class="btn-ghost hidden" onclick="retryModalRecording()">β» Retry</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script type="module"> |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| |
| |
| |
| |
| (function() { |
| const canvas = document.getElementById('bg'); |
| const ctx = canvas.getContext('2d'); |
| let W, H, frame = 0; |
| const PALETTE = ['#ff6b9d','#ffb347','#c084fc','#818cf8','#34d399','#38bdf8']; |
| const NOTES = ['βͺ','β«','β¬','β©']; |
| let particles = [], rings = [], notes = []; |
| |
| function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } |
| resize(); window.addEventListener('resize', resize); |
| |
| for (let i = 0; i < 65; i++) { |
| particles.push({ x:Math.random()*1920, y:Math.random()*1080, vx:(Math.random()-.5)*.3, vy:-Math.random()*.5-.1, r:Math.random()*2.5+1, color:PALETTE[Math.floor(Math.random()*PALETTE.length)], phase:Math.random()*Math.PI*2, speed:Math.random()*.02+.01 }); |
| } |
| |
| function draw() { |
| frame++; |
| ctx.clearRect(0,0,W,H); |
| ctx.fillStyle='#0d0b18'; ctx.fillRect(0,0,W,H); |
| |
| const g0=ctx.createRadialGradient(W*.25,H*.5,0,W*.25,H*.5,W*.45); |
| g0.addColorStop(0,'rgba(120,60,180,.18)'); g0.addColorStop(1,'transparent'); |
| ctx.fillStyle=g0; ctx.fillRect(0,0,W,H); |
| |
| [{x:0,y:0,c:'rgba(255,107,157,.1)'},{x:W,y:0,c:'rgba(129,140,248,.1)'},{x:0,y:H,c:'rgba(255,179,71,.08)'},{x:W,y:H,c:'rgba(52,211,153,.08)'}].forEach(({x,y,c})=>{ |
| const g=ctx.createRadialGradient(x,y,0,x,y,W*.35); g.addColorStop(0,c); g.addColorStop(1,'transparent'); |
| ctx.fillStyle=g; ctx.fillRect(0,0,W,H); |
| }); |
| |
| particles.forEach(p=>{ |
| p.x+=p.vx; p.y+=p.vy; |
| if(p.y<-10)p.y=H+10; if(p.x<-10)p.x=W+10; if(p.x>W+10)p.x=-10; |
| const alpha=(Math.sin(frame*p.speed+p.phase)+1)/2*.6+.1; |
| ctx.globalAlpha=alpha; ctx.fillStyle=p.color; |
| ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fill(); |
| }); |
| ctx.globalAlpha=1; |
| |
| if(frame%65===0) rings.push({x:W*.3,y:H*.5,r:0,alpha:.5}); |
| rings=rings.filter(r=>r.alpha>.01); |
| rings.forEach(r=>{ r.r+=1.8; r.alpha*=.97; ctx.strokeStyle=`rgba(192,132,252,${r.alpha})`; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(r.x,r.y,r.r,0,Math.PI*2); ctx.stroke(); }); |
| |
| const cx=W*.3,cy=H*.5,radius=70,bars=40; |
| for(let i=0;i<bars;i++){ |
| const angle=(i/bars)*Math.PI*2, amp=Math.sin(frame*.04+i*.4)*14+16; |
| const t=i/bars, r=Math.round(255*(1-t)+192*t), g=Math.round(107*(1-t)+132*t), b=Math.round(157*(1-t)+252*t); |
| ctx.strokeStyle=`rgb(${r},${g},${b})`; ctx.lineWidth=2; ctx.globalAlpha=.5; |
| ctx.beginPath(); ctx.moveTo(cx+Math.cos(angle)*radius,cy+Math.sin(angle)*radius); |
| ctx.lineTo(cx+Math.cos(angle)*(radius+amp),cy+Math.sin(angle)*(radius+amp)); ctx.stroke(); |
| } |
| ctx.globalAlpha=1; |
| |
| if(frame%40===0) notes.push({x:20,y:Math.random()*H,vy:-(Math.random()*.8+.3),alpha:0,char:NOTES[Math.floor(Math.random()*NOTES.length)],color:PALETTE[Math.floor(Math.random()*PALETTE.length)],life:0,maxLife:120+Math.random()*80}); |
| notes=notes.filter(n=>n.alpha>0||n.life<5); |
| notes.forEach((n,i)=>{ n.y+=n.vy; n.life++; if(n.alpha<.8)n.alpha+=.04; if(n.life>n.maxLife*.7)n.alpha-=.03; if(n.alpha<0){notes.splice(i,1);return;} ctx.globalAlpha=Math.max(0,n.alpha); ctx.fillStyle=n.color; ctx.font='16px serif'; ctx.fillText(n.char,n.x,n.y); }); |
| ctx.globalAlpha=1; |
| requestAnimationFrame(draw); |
| } |
| draw(); |
| })(); |
| |
| |
| |
| |
| const STATE = { |
| username: "", relationships: [], profileTable: [], |
| profileBlob: null, storyBlob: null, |
| recorders: { A: null, B: null }, |
| recTimers: { A: null, B: null }, |
| recSeconds: { A: 0, B: 0 }, |
| isRecording:{ A: false, B: false }, |
| }; |
| |
| let client = null; |
| |
| async function getClient() { |
| if (!client) client = await Client.connect(window.location.origin); |
| return client; |
| } |
| |
| |
| |
| |
| function showStatus(elId, msg, ok) { |
| if (typeof msg !== 'string' || !msg) return; |
| const el = document.getElementById(elId); |
| el.textContent = msg; |
| el.className = ok ? 'status-ok' : 'status-err'; |
| el.classList.remove('hidden'); |
| } |
| |
| function hideStatus(elId) { |
| document.getElementById(elId).classList.add('hidden'); |
| } |
| |
| function setBtnLoading(id, loading) { |
| const btn = document.getElementById(id); |
| if (!btn) return; |
| if (loading) { |
| btn._orig = btn.innerHTML; |
| btn.innerHTML = `<span class="dot-loader"><span></span><span></span><span></span></span>`; |
| btn.disabled = true; |
| } else { |
| btn.innerHTML = btn._orig || btn.innerHTML; |
| btn.disabled = false; |
| } |
| } |
| |
| function renderProfileTable(rows) { |
| const tbody = document.getElementById('profileTableBody'); |
| if (!rows || rows.length === 0) { |
| tbody.innerHTML = '<tr><td colspan="3" class="text-center py-8 text-muted text-sm">No profiles saved yet.</td></tr>'; |
| return; |
| } |
| tbody.innerHTML = rows.map(r => { |
| const id = r[0] || r.id || ''; |
| const rel = r[1] || r.relationship || ''; |
| const at = r[2] || r.created_at || ''; |
| return `<tr><td class="font-mono text-xs" title="${id}">${id.slice(-8)}</td><td>${rel}</td><td>${at}</td></tr>`; |
| }).join(''); |
| } |
| |
| function updateRelSelects(choices) { |
| STATE.relationships = Array.isArray(choices) ? choices : []; |
| ['storyRelSelect','listenRelSelect'].forEach(id => { |
| const sel = document.getElementById(id); |
| const cur = sel.value; |
| sel.innerHTML = '<option value="">β select a relationship β</option>' + |
| STATE.relationships.map(r => `<option value="${r}" ${r===cur?'selected':''}>${r}</option>`).join(''); |
| }); |
| } |
| |
| function flashCard(id) { |
| const el = document.getElementById(id); |
| el.classList.remove('card-flash'); |
| void el.offsetWidth; |
| el.classList.add('card-flash'); |
| } |
| |
| |
| function extractChoices(raw) { |
| if (!raw) return []; |
| if (Array.isArray(raw)) return raw; |
| if (raw.choices) return raw.choices.map(c => Array.isArray(c) ? c[0] : c); |
| return []; |
| } |
| |
| |
| |
| |
| function encodeWAV(samples, sampleRate) { |
| const buf = new ArrayBuffer(44 + samples.length * 2); |
| const view = new DataView(buf); |
| function str(off, s) { for (let i=0;i<s.length;i++) view.setUint8(off+i, s.charCodeAt(i)); } |
| str(0,'RIFF'); view.setUint32(4, 36+samples.length*2, true); |
| str(8,'WAVE'); str(12,'fmt '); view.setUint32(16,16,true); |
| view.setUint16(20,1,true); view.setUint16(22,1,true); |
| view.setUint32(24,sampleRate,true); view.setUint32(28,sampleRate*2,true); |
| view.setUint16(32,2,true); view.setUint16(34,16,true); |
| str(36,'data'); view.setUint32(40,samples.length*2,true); |
| let off=44; |
| for (let i=0;i<samples.length;i++) { |
| const s=Math.max(-1,Math.min(1,samples[i])); |
| view.setInt16(off, s<0?s*0x8000:s*0x7FFF, true); off+=2; |
| } |
| return new Blob([buf], {type:'audio/wav'}); |
| } |
| |
| async function blobToWavFile(blob, name) { |
| const arrayBuf = await blob.arrayBuffer(); |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const decoded = await audioCtx.decodeAudioData(arrayBuf); |
| const mono = decoded.getChannelData(0); |
| const wavBlob = encodeWAV(mono, decoded.sampleRate); |
| return new File([wavBlob], `${name}.wav`, {type:'audio/wav'}); |
| } |
| |
| |
| |
| |
| async function toggleRecording(id) { |
| STATE.isRecording[id] ? stopRecording(id) : await startRecording(id); |
| } |
| |
| async function startRecording(id) { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| const mr = new MediaRecorder(stream); |
| const chunks = []; |
| mr.ondataavailable = e => chunks.push(e.data); |
| mr.onstop = () => { |
| const blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' }); |
| if (id==='A') STATE.profileBlob=blob; else STATE.storyBlob=blob; |
| const url = URL.createObjectURL(blob); |
| document.getElementById(`audio${id}`).src = url; |
| document.getElementById(`playback${id}`).classList.remove('hidden'); |
| document.getElementById(`clearBtn${id}`).classList.remove('hidden'); |
| document.getElementById(`wave${id}`).style.opacity = '1'; |
| }; |
| mr.start(); |
| STATE.recorders[id] = mr; |
| STATE.isRecording[id] = true; |
| STATE.recSeconds[id] = 0; |
| document.getElementById(`micBtn${id}`).classList.add('recording'); |
| document.getElementById(`recLabel${id}`).textContent = 'Recordingβ¦'; |
| document.getElementById(`wave${id}`).style.opacity = '1'; |
| STATE.recTimers[id] = setInterval(() => { |
| STATE.recSeconds[id]++; |
| const m=Math.floor(STATE.recSeconds[id]/60), s=STATE.recSeconds[id]%60; |
| document.getElementById(`recTimer${id}`).textContent = `${m}:${s.toString().padStart(2,'0')}`; |
| }, 1000); |
| } catch(e) { |
| const msgs = { NotAllowedError:'β Microphone permission denied β check browser/system settings.', NotFoundError:'β No microphone found on this device.', NotReadableError:'β Microphone is in use by another app.', SecurityError:'β Page must be served from localhost or HTTPS.' }; |
| showStatus(id==='A'?'profileStatus':'storyStatus', msgs[e.name]||`β Mic error: ${e.name} β ${e.message}`, false); |
| } |
| } |
| |
| function stopRecording(id) { |
| if (STATE.recorders[id]) { |
| STATE.recorders[id].stop(); |
| STATE.recorders[id].stream?.getTracks().forEach(t=>t.stop()); |
| STATE.recorders[id] = null; |
| } |
| STATE.isRecording[id] = false; |
| clearInterval(STATE.recTimers[id]); |
| document.getElementById(`micBtn${id}`).classList.remove('recording'); |
| document.getElementById(`recLabel${id}`).textContent = 'Recording complete'; |
| } |
| |
| function clearRecording(id) { |
| if (STATE.isRecording[id]) stopRecording(id); |
| if (id==='A') STATE.profileBlob=null; else STATE.storyBlob=null; |
| document.getElementById(`audio${id}`).src = ''; |
| document.getElementById(`playback${id}`).classList.add('hidden'); |
| document.getElementById(`clearBtn${id}`).classList.add('hidden'); |
| document.getElementById(`recTimer${id}`).textContent = '0:00'; |
| document.getElementById(`recLabel${id}`).textContent = 'Tap to start recording'; |
| STATE.recSeconds[id] = 0; |
| } |
| |
| function handleFileUpload(event, id) { |
| const file = event.target.files[0]; |
| if (!file) return; |
| if (id==='A') STATE.profileBlob=file; else STATE.storyBlob=file; |
| document.getElementById(`audio${id}`).src = URL.createObjectURL(file); |
| document.getElementById(`playback${id}`).classList.remove('hidden'); |
| document.getElementById(`clearBtn${id}`).classList.remove('hidden'); |
| document.getElementById(`recLabel${id}`).textContent = file.name; |
| } |
| |
| |
| |
| |
| |
| const VOICE_PROMPT_TEXT = "The warmth of family is the greatest gift we share. Every laugh, every tear, every quiet moment together becomes a treasure in our hearts. Time may pass, but these memories remain forever, glowing softly like stars in the night sky, guiding us home."; |
| |
| let modalRecorder = null; |
| let modalRecTimer = null; |
| let modalRecSeconds = 0; |
| let modalBlob = null; |
| |
| window.openRecordModal = function() { |
| document.getElementById('recordModal').classList.add('active'); |
| document.getElementById('promptText').textContent = VOICE_PROMPT_TEXT; |
| resetModalState(); |
| }; |
| |
| window.closeRecordModal = function() { |
| if (modalRecorder && modalRecorder.state === 'recording') { |
| stopModalRecording(); |
| } |
| document.getElementById('recordModal').classList.remove('active'); |
| }; |
| |
| function resetModalState() { |
| modalBlob = null; |
| modalRecSeconds = 0; |
| document.getElementById('modalRecTimer').textContent = '0:00'; |
| document.getElementById('modalRecLabel').textContent = 'Tap to start'; |
| document.getElementById('modalRecDuration').textContent = '0s recorded'; |
| document.getElementById('recordProgressBar').style.width = '0%'; |
| document.getElementById('modalPlayback').classList.add('hidden'); |
| document.getElementById('modalSaveBtn').classList.add('hidden'); |
| document.getElementById('modalRetryBtn').classList.add('hidden'); |
| document.getElementById('modalMicBtn').classList.remove('recording'); |
| document.getElementById('modalAudio').src = ''; |
| } |
| |
| window.toggleModalRecording = async function() { |
| if (modalRecorder && modalRecorder.state === 'recording') { |
| stopModalRecording(); |
| } else { |
| await startModalRecording(); |
| } |
| }; |
| |
| async function startModalRecording() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| const mr = new MediaRecorder(stream); |
| const chunks = []; |
| mr.ondataavailable = e => chunks.push(e.data); |
| mr.onstop = () => { |
| modalBlob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' }); |
| const url = URL.createObjectURL(modalBlob); |
| document.getElementById('modalAudio').src = url; |
| document.getElementById('modalPlayback').classList.remove('hidden'); |
| document.getElementById('modalSaveBtn').classList.remove('hidden'); |
| document.getElementById('modalRetryBtn').classList.remove('hidden'); |
| document.getElementById('modalRecLabel').textContent = 'Recording complete'; |
| document.getElementById('modalMicBtn').classList.remove('recording'); |
| }; |
| mr.start(); |
| modalRecorder = mr; |
| modalRecSeconds = 0; |
| document.getElementById('modalMicBtn').classList.add('recording'); |
| document.getElementById('modalRecLabel').textContent = 'Recordingβ¦'; |
| |
| modalRecTimer = setInterval(() => { |
| modalRecSeconds++; |
| const m = Math.floor(modalRecSeconds / 60); |
| const s = modalRecSeconds % 60; |
| document.getElementById('modalRecTimer').textContent = `${m}:${s.toString().padStart(2, '0')}`; |
| document.getElementById('modalRecDuration').textContent = `${modalRecSeconds}s recorded`; |
| |
| const pct = Math.min((modalRecSeconds / 30) * 100, 100); |
| document.getElementById('recordProgressBar').style.width = pct + '%'; |
| }, 1000); |
| } catch (e) { |
| document.getElementById('modalRecLabel').textContent = 'β Mic error: ' + e.message; |
| } |
| } |
| |
| function stopModalRecording() { |
| if (modalRecorder) { |
| modalRecorder.stop(); |
| modalRecorder.stream?.getTracks().forEach(t => t.stop()); |
| } |
| clearInterval(modalRecTimer); |
| document.getElementById('modalMicBtn').classList.remove('recording'); |
| } |
| |
| window.saveModalRecording = async function() { |
| if (!modalBlob) return; |
| |
| STATE.profileBlob = modalBlob; |
| |
| |
| document.getElementById('audioA').src = URL.createObjectURL(modalBlob); |
| document.getElementById('playbackA').classList.remove('hidden'); |
| document.getElementById('clearBtnA').classList.remove('hidden'); |
| document.getElementById('recLabelA').textContent = 'Voice profile recorded β'; |
| document.getElementById('recTimerA').textContent = document.getElementById('modalRecTimer').textContent; |
| document.getElementById('waveA').style.opacity = '1'; |
| |
| closeRecordModal(); |
| flashCard('profileCard'); |
| }; |
| |
| window.retryModalRecording = function() { |
| resetModalState(); |
| }; |
| |
| |
| document.getElementById('recordModal').addEventListener('click', (e) => { |
| if (e.target.id === 'recordModal') closeRecordModal(); |
| }); |
| |
| |
| |
| |
| window.doLogin = async function() { |
| const val = document.getElementById('loginInput').value.trim(); |
| if (!val) { showStatus('loginStatus','Please enter your Hugging Face username.', false); return; } |
| setBtnLoading('loginBtn', true); |
| hideStatus('loginStatus'); |
| try { |
| const c = await getClient(); |
| const result = await c.predict("/login", { hf_username: val }); |
| |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| |
| if (!res.success) { |
| showStatus('loginStatus', res.status || 'β Login failed.', false); |
| setBtnLoading('loginBtn', false); |
| return; |
| } |
| |
| STATE.username = res.username; |
| STATE.profileTable = res.profiles || []; |
| updateRelSelects(res.relationships || []); |
| renderProfileTable(STATE.profileTable); |
| |
| document.getElementById('userAvatar').textContent = res.username.slice(0,2).toUpperCase(); |
| document.getElementById('userLabel').textContent = res.username; |
| |
| |
| const loginPage = document.getElementById('loginPage'); |
| const mainApp = document.getElementById('mainApp'); |
| loginPage.style.opacity = '0'; |
| loginPage.style.transform = 'translateY(-16px)'; |
| setTimeout(() => { |
| loginPage.classList.add('hidden'); |
| mainApp.classList.remove('page-hidden'); |
| mainApp.style.position = ''; |
| mainApp.style.width = ''; |
| }, 400); |
| } catch(e) { |
| showStatus('loginStatus', 'β Connection error: ' + e.message, false); |
| } |
| setBtnLoading('loginBtn', false); |
| }; |
| |
| window.doLogout = function() { |
| STATE.username=''; STATE.relationships=[]; STATE.profileTable=[]; |
| STATE.profileBlob=null; STATE.storyBlob=null; |
| clearRecording('A'); clearRecording('B'); |
| const mainApp = document.getElementById('mainApp'); |
| const loginPage = document.getElementById('loginPage'); |
| mainApp.classList.add('page-hidden'); |
| loginPage.classList.remove('hidden'); |
| loginPage.style.opacity='0'; loginPage.style.transform='translateY(16px)'; |
| setTimeout(()=>{ loginPage.style.opacity='1'; loginPage.style.transform=''; }, 50); |
| document.getElementById('loginInput').value = ''; |
| }; |
| |
| |
| |
| |
| window.switchTab = function(idx) { |
| ['tab1','tab2'].forEach((id,i) => { |
| const el=document.getElementById(id), btn=document.getElementById(`tab${i+1}btn`); |
| if (i===idx) { el.classList.remove('hidden'); el.classList.add('tab-content'); btn.classList.add('active'); } |
| else { el.classList.add('hidden'); el.classList.remove('tab-content'); btn.classList.remove('active'); } |
| }); |
| }; |
| |
| |
| |
| |
| window.saveProfile = async function() { |
| const rel = document.getElementById('profileRelInput').value.trim(); |
| if (!STATE.username) { showStatus('profileStatus','Please log in first.', false); return; } |
| if (!rel) { showStatus('profileStatus','Enter a relationship label.', false); return; } |
| if (!STATE.profileBlob) { showStatus('profileStatus','Record or upload a voice sample first.', false); return; } |
| setBtnLoading('saveProfileBtn', true); |
| hideStatus('profileStatus'); |
| try { |
| const wavFile = await blobToWavFile(STATE.profileBlob, 'profile'); |
| const c = await getClient(); |
| const result = await c.predict("/save_profile", { hf_username: STATE.username, relationship: rel, audio: wavFile }); |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| |
| showStatus('profileStatus', res.status || 'Done.', res.success); |
| if (res.success) { |
| if (res.profiles) { STATE.profileTable=res.profiles; renderProfileTable(res.profiles); } |
| if (res.relationships) updateRelSelects(res.relationships); |
| flashCard('profileCard'); |
| clearRecording('A'); |
| } |
| } catch(e) { showStatus('profileStatus','β Error: '+e.message, false); } |
| setBtnLoading('saveProfileBtn', false); |
| }; |
| |
| |
| |
| |
| window.saveStory = async function() { |
| const rel = document.getElementById('storyRelSelect').value; |
| if (!STATE.username) { showStatus('storyStatus','Please log in first.', false); return; } |
| if (!rel) { showStatus('storyStatus','Select a relationship.', false); return; } |
| if (!STATE.storyBlob) { showStatus('storyStatus','Record or upload a story first.', false); return; } |
| setBtnLoading('saveStoryBtn', true); |
| hideStatus('storyStatus'); |
| document.getElementById('transcriptBox').classList.add('hidden'); |
| try { |
| const wavFile = await blobToWavFile(STATE.storyBlob, 'story'); |
| const c = await getClient(); |
| const result = await c.predict("/save_story", { hf_username: STATE.username, relationship: rel, audio: wavFile }); |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| |
| showStatus('storyStatus', res.status || 'Done.', res.success); |
| if (res.success) { |
| if (res.transcript) { |
| document.getElementById('transcriptText').textContent = res.transcript; |
| document.getElementById('transcriptBox').classList.remove('hidden'); |
| } |
| flashCard('storyCard'); |
| clearRecording('B'); |
| } |
| } catch(e) { showStatus('storyStatus','β Error: '+e.message, false); } |
| setBtnLoading('saveStoryBtn', false); |
| }; |
| |
| |
| |
| |
| window.doRefresh = async function() { |
| if (!STATE.username) return; |
| try { |
| const c = await getClient(); |
| const result = await c.predict("/refresh_profiles", { hf_username: STATE.username }); |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| if (res.profiles) { STATE.profileTable=res.profiles; renderProfileTable(res.profiles); } |
| if (res.relationships) updateRelSelects(res.relationships); |
| } catch(e) { console.error('Refresh failed', e); } |
| }; |
| |
| |
| |
| |
| window.playOriginal = async function() { |
| const rel = document.getElementById('listenRelSelect').value; |
| if (!STATE.username) { showStatus('listenStatus','Please log in first.', false); return; } |
| if (!rel) { showStatus('listenStatus','Choose a relationship first.', false); return; } |
| setBtnLoading('playOriginalBtn', true); |
| hideStatus('listenStatus'); |
| try { |
| const c = await getClient(); |
| const result = await c.predict("/load_voice", { hf_username: STATE.username, relationship: rel }); |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| |
| if (res.success && res.audio_url) { |
| document.getElementById('originalAudio').src = res.audio_url; |
| document.getElementById('originalAudioWrap').classList.remove('hidden'); |
| document.getElementById('originalAudio').play(); |
| } else { |
| showStatus('listenStatus', res.status || 'β No voice profile found.', false); |
| } |
| } catch(e) { showStatus('listenStatus','β Error: '+e.message, false); } |
| setBtnLoading('playOriginalBtn', false); |
| }; |
| |
| |
| |
| |
| window.doGenerate = async function() { |
| const rel = document.getElementById('listenRelSelect').value; |
| const q = document.getElementById('questionInput').value.trim(); |
| if (!STATE.username) { showStatus('listenStatus','Please log in first.', false); return; } |
| if (!rel) { showStatus('listenStatus','Choose a relationship first.', false); return; } |
| setBtnLoading('generateBtn', true); |
| hideStatus('listenStatus'); |
| document.getElementById('responseBlock').classList.add('hidden'); |
| try { |
| const c = await getClient(); |
| const result = await c.predict("/generate", { hf_username: STATE.username, relationship: rel, question: q }); |
| const raw = result.data ? result.data[0] : result[0]; |
| const res = typeof raw === 'string' ? JSON.parse(raw) : raw; |
| |
| if (res.success && res.audio_url) { |
| document.getElementById('generatedAudio').src = res.audio_url; |
| document.getElementById('responseQuote').textContent = res.text || ''; |
| document.getElementById('synthLabel').textContent = rel; |
| document.getElementById('genStatus').textContent = res.status || ''; |
| document.getElementById('responseBlock').classList.remove('hidden'); |
| document.getElementById('generatedAudio').play(); |
| } else { |
| showStatus('listenStatus', res.status || 'β Generation failed.', false); |
| } |
| } catch(e) { showStatus('listenStatus','β Error: '+e.message, false); } |
| setBtnLoading('generateBtn', false); |
| }; |
| |
| |
| |
| |
| window.runDebug = async function() { |
| try { |
| const c = await getClient(); |
| const pFile = STATE.profileBlob ? await blobToWavFile(STATE.profileBlob,'debug_p') : null; |
| const sFile = STATE.storyBlob ? await blobToWavFile(STATE.storyBlob,'debug_s') : null; |
| const result = await c.predict("/debug_audio", { audio: pFile, story_audio: sFile }); |
| const raw = result.data ? result.data[0] : result[0]; |
| document.getElementById('debugOutput').value = (typeof raw==='string'?raw:JSON.stringify(raw,null,2)) || '(no output)'; |
| } catch(e) { document.getElementById('debugOutput').value = 'Error: '+e.message; } |
| }; |
| |
| |
| window.toggleRecording = toggleRecording; |
| window.clearRecording = clearRecording; |
| window.handleFileUpload = handleFileUpload; |
| </script> |
| </body> |
| </html> |