FamilyLegacy / static /index.html
pkheria's picture
Update static/index.html
6dc1728 verified
Raw
History Blame Contribute Delete
55 kB
<!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; }
/* Recording Prompt Modal */
.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">
<!-- ══════════════ LOGIN PAGE ══════════════ -->
<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">
<!-- Left hero -->
<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>
<!-- Right login form -->
<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>
<!-- ══════════════ MAIN APP ══════════════ -->
<div id="mainApp" class="page-hidden page-transition">
<!-- Nav -->
<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>
<!-- Tabs -->
<div class="border-b border-[rgba(192,132,252,.1)] px-6 flex">
<button class="tab-btn active" id="tab1btn" onclick="switchTab(0)">πŸŽ™ Voice &amp; Memories</button>
<button class="tab-btn" id="tab2btn" onclick="switchTab(1)">✨ Listen Mode</button>
</div>
<div class="px-6 py-6">
<!-- ── Tab 1 ── -->
<div id="tab1" class="tab-content">
<div class="grid grid-cols-2 gap-6 mb-6">
<!-- Save Voice Profile -->
<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>
<!-- Capture Memory -->
<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>
<!-- Profiles table -->
<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>
<!-- Debug -->
<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>
<!-- ── Tab 2 ── -->
<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>
<!-- Step 1 -->
<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>
<!-- Step 2 -->
<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>
<!-- Step 3 -->
<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 &amp; 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>
<!-- ══ Voice Recording Prompt Modal ══ -->
<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";
// ═══════════════════════════════════════════════════
// CANVAS BACKGROUND
// ═══════════════════════════════════════════════════
(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();
})();
// ═══════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════
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;
}
// ═══════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════
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');
}
// Extract choices from Gradio dropdown update object or plain array
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 [];
}
// ═══════════════════════════════════════════════════
// WAV ENCODER
// ═══════════════════════════════════════════════════
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'});
}
// ═══════════════════════════════════════════════════
// RECORDING (Story Memory - unchanged)
// ═══════════════════════════════════════════════════
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;
}
// ═══════════════════════════════════════════════════
// VOICE PROFILE RECORDING MODAL
// ═══════════════════════════════════════════════════
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`;
// Progress bar: target 30s = 100%
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;
// Transfer to main profile blob
STATE.profileBlob = modalBlob;
// Update main UI
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();
};
// Close modal on overlay click
document.getElementById('recordModal').addEventListener('click', (e) => {
if (e.target.id === 'recordModal') closeRecordModal();
});
// ═══════════════════════════════════════════════════
// AUTH
// ═══════════════════════════════════════════════════
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 });
// result.data[0] is the dict returned by api_login
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;
// Page transition
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 = '';
};
// ═══════════════════════════════════════════════════
// TABS
// ═══════════════════════════════════════════════════
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'); }
});
};
// ═══════════════════════════════════════════════════
// SAVE PROFILE
// ═══════════════════════════════════════════════════
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);
};
// ═══════════════════════════════════════════════════
// SAVE STORY
// ═══════════════════════════════════════════════════
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);
};
// ═══════════════════════════════════════════════════
// REFRESH
// ═══════════════════════════════════════════════════
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); }
};
// ═══════════════════════════════════════════════════
// PLAY ORIGINAL
// ═══════════════════════════════════════════════════
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);
};
// ═══════════════════════════════════════════════════
// GENERATE
// ═══════════════════════════════════════════════════
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);
};
// ═══════════════════════════════════════════════════
// DEBUG
// ═══════════════════════════════════════════════════
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; }
};
// Expose to global
window.toggleRecording = toggleRecording;
window.clearRecording = clearRecording;
window.handleFileUpload = handleFileUpload;
</script>
</body>
</html>