/* Studio 76 — The Imaginarium. Talk to "Eve", Studio 76's designer, using the
browser's built-in speech (no external voice key needed): the browser
listens, Gemini is Eve's brain (imaginarium.php?action=chat), and the
browser speaks her replies. She renders the piece live with Nano Banana 2.
Typed flow stays as a fallback. */
const IMAG_EXAMPLES = [
'A fine rose-gold pendant with a single pearl — modern and minimal',
'Delicate diamond ear-climbers, contemporary, barely-there',
'A slim stacking ring with a tiny emerald, new-age and clean',
'A thread-thin gold chain with one floating sapphire',
];
const IMAG_TONES = [
{ k: 'original', label: 'As designed' },
{ k: 'yellow', label: 'Yellow gold' },
{ k: 'white', label: 'White gold' },
{ k: 'rose', label: 'Rose gold' },
];
async function imagApi(action, payload) {
const r = await fetch('imaginarium.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ action: action }, payload)),
});
let j = {}; try { j = await r.json(); } catch (e) {}
if (!r.ok || j.error) throw new Error(j.error || ('Something went wrong (' + r.status + ')'));
return j;
}
function getEveVoice() {
const vs = (window.speechSynthesis && window.speechSynthesis.getVoices()) || [];
if (!vs.length) return null;
const inFemale = vs.find(v => /en-IN/i.test(v.lang) && /female|woman|priya|veena|raveena|aditi|heera|neerja/i.test(v.name));
const inAny = vs.find(v => /en-IN/i.test(v.lang));
const enFemale = vs.find(v => /^en/i.test(v.lang) && /female|samantha|victoria|karen|tessa|moira|fiona|serena|zira|google uk english female/i.test(v.name));
const enAny = vs.find(v => /^en/i.test(v.lang));
return inFemale || inAny || enFemale || enAny || vs[0];
}
function SpecRow({ label, value }) {
if (!value) return null;
return
{label}{value}
;
}
function Imaginarium({ onBegin }) {
const [mode, setMode] = React.useState('console'); // console | voice
const [voiceReady, setVoiceReady] = React.useState(false);
const [vstate, setV] = React.useState('idle'); // idle | connecting | live | ended | error
const [speaking, setSpeaking] = React.useState(false);
const [listening, setListening] = React.useState(false);
const [interim, setInterim] = React.useState('');
const [transcript, setTranscript] = React.useState([]);
const [design, setDesign] = React.useState(null);
const [hero, setHero] = React.useState(null);
const [variants, setVariants] = React.useState({});
const [wire, setWire] = React.useState(null);
const [active, setActive] = React.useState('original');
const [view, setView] = React.useState('render');
const [err, setErr] = React.useState('');
const [dream, setDream] = React.useState('');
const [phase, setPhase] = React.useState('idle');
const heroRef = React.useRef(null);
const tEndRef = React.useRef(null);
const taRef = React.useRef(null);
const histRef = React.useRef([]);
const recogRef = React.useRef(null);
const endedRef = React.useRef(false);
const audioRef = React.useRef(null);
const speakingRef = React.useRef(false);
React.useEffect(() => { heroRef.current = hero; }, [hero]);
React.useEffect(() => { if (tEndRef.current) tEndRef.current.scrollTop = tEndRef.current.scrollHeight; }, [transcript, interim]);
React.useEffect(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
setVoiceReady(!!(SR && window.speechSynthesis));
if (window.speechSynthesis) { try { window.speechSynthesis.getVoices(); window.speechSynthesis.onvoiceschanged = () => window.speechSynthesis.getVoices(); } catch (e) {} }
return () => { endedRef.current = true; try { if (recogRef.current) recogRef.current.abort(); } catch (e) {} try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (e) {} };
}, []);
const fmtStone = (s) => s ? [s.carat_estimate, s.cut, s.color, s.type].filter(Boolean).join(' ') : '';
const fmtAccents = (a) => (a && a.length) ? a.map(x => (typeof x === 'string' ? x : [x.cut, x.color, x.type].filter(Boolean).join(' '))).filter(Boolean).join(', ') : '';
const push = (role, text) => setTranscript(t => t.concat([{ role, text }]));
/* ---------- render helpers ---------- */
async function renderExtras(id) {
setWire('loading');
try { const w = await imagApi('wireframe', { id }); setWire(w.dataUrl); } catch (e) { setWire(null); }
for (const t of ['yellow', 'white', 'rose']) {
setVariants(v => Object.assign({}, v, { [t]: 'loading' }));
try { const r = await imagApi('variant', { id, tone: t }); setVariants(v => Object.assign({}, v, { [t]: r.dataUrl })); }
catch (e) { setVariants(v => { const n = Object.assign({}, v); delete n[t]; return n; }); }
}
}
async function renderHero(spec) {
setHero(null); setVariants({}); setWire(null); setActive('original'); setView('render');
const h = await imagApi('hero', { spec });
setHero(h); setVariants({ original: h.dataUrl });
return h.id;
}
function applyVariant(tone) {
const id = heroRef.current && heroRef.current.id;
const t = ({ rose: 'rose', yellow: 'yellow', white: 'white' })[tone] || '';
if (!id || !t) return;
setActive(t); setView('render'); setVariants(v => Object.assign({}, v, { [t]: 'loading' }));
imagApi('variant', { id, tone: t }).then(r => setVariants(v => Object.assign({}, v, { [t]: r.dataUrl }))).catch(() => {});
}
/* ---------- browser speech ---------- */
function browserSpeak(text) {
return new Promise(res => {
if (!window.speechSynthesis || !text) return res();
try {
const u = new SpeechSynthesisUtterance(text);
const v = getEveVoice(); if (v) { u.voice = v; u.lang = v.lang; } else { u.lang = 'en-IN'; }
u.rate = 1.0; u.pitch = 1.06;
u.onend = () => res(); u.onerror = () => res();
window.speechSynthesis.cancel(); window.speechSynthesis.speak(u);
} catch (e) { res(); }
});
}
function playAudio(dataUrl) {
return new Promise(res => {
try {
const a = audioRef.current || new Audio(); audioRef.current = a;
a.muted = false; a.src = dataUrl;
a.onended = () => res('ok'); a.onerror = () => res('err');
const p = a.play(); if (p && p.catch) p.catch(() => res('err'));
} catch (e) { res('err'); }
});
}
async function speak(text) {
if (!text) return;
speakingRef.current = true; setSpeaking(true);
let done = false;
try { const r = await imagApi('say', { text }); if (r && r.audio) { const out = await playAudio(r.audio); done = (out === 'ok'); } } catch (e) {}
if (!done && !endedRef.current) { await browserSpeak(text); } // fallback: browser voice
speakingRef.current = false; setSpeaking(false);
}
function listenOnce() {
return new Promise(res => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return res('');
const r = new SR(); recogRef.current = r;
r.lang = 'en-IN'; r.interimResults = true; r.maxAlternatives = 1; r.continuous = false;
let finalText = '';
r.onresult = (e) => {
let intr = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const seg = e.results[i]; if (seg.isFinal) finalText += seg[0].transcript; else intr += seg[0].transcript;
}
setInterim(intr);
};
r.onerror = () => {};
r.onend = () => { setListening(false); setInterim(''); res(finalText.trim()); };
setListening(true);
try { r.start(); } catch (e) { setListening(false); res(''); }
});
}
function waitSpeak() { return new Promise(res => { const c = () => { if (endedRef.current || !speakingRef.current) res(); else setTimeout(c, 150); }; c(); }); }
async function runConversation() {
let empties = 0;
while (!endedRef.current) {
await waitSpeak(); // let Eve finish before we listen (no echo)
if (endedRef.current) break;
const said = await listenOnce();
if (endedRef.current) break;
if (!said) { empties++; if (empties >= 3) { const n = 'I’m still here whenever you’re ready.'; push('eve', n); await speak(n); empties = 0; } continue; }
empties = 0;
push('you', said); histRef.current.push({ role: 'you', text: said });
let r;
try { r = await imagApi('chat', { history: histRef.current }); }
catch (e) { const m = 'Sorry, I lost that for a moment — could you say it again?'; push('eve', m); await speak(m); continue; }
push('eve', r.reply); histRef.current.push({ role: 'eve', text: r.reply });
if (r.ready && r.spec) {
setDesign({ concept: r.concept || { name: 'Your piece', description: '' }, spec: r.spec });
(async () => { try { const id = await renderHero(r.spec); renderExtras(id); } catch (e) {} })();
}
if (r.variant) applyVariant(r.variant);
if (endedRef.current) break;
await speak(r.reply);
}
}
async function startVoice() {
setErr('');
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR || !window.speechSynthesis) {
setVoiceReady(false);
if (taRef.current) { taRef.current.focus(); taRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
return;
}
setMode('voice'); setV('connecting'); setTranscript([]); setInterim(''); endedRef.current = false;
setDesign(null); setHero(null); setVariants({}); setWire(null);
// Unlock