diff --git a/static/js/stt.js b/static/js/stt.js index b166413..edeb2b5 100644 --- a/static/js/stt.js +++ b/static/js/stt.js @@ -39,12 +39,13 @@ async function refreshSttBackends(selected = '') { } catch (_) { _sttBackends = []; } - const sel = $('stt-tts-stt-backend'); - if (sel) { + ['stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => { + const sel = $(id); + if (!sel) return; const prev = selected || sel.value || 'configured'; sel.innerHTML = sttBackendOptionHtml(prev); sel.disabled = !_sttBackends.some(b => b.available); - } + }); updateSttBackendHelp(); } @@ -148,7 +149,7 @@ $('stt-tts-rec-start')?.addEventListener('click', async () => { sttTtsRecordSecs++; $('stt-tts-rec-time').textContent = Math.floor(sttTtsRecordSecs / 60) + ':' + String(sttTtsRecordSecs % 60).padStart(2, '0'); }, 1000); - sttTtsRecorder = new MediaRecorder(sttTtsRecordStream); + sttTtsRecorder = new MediaRecorder(sttTtsRecordStream, {audioBitsPerSecond: 256000}); sttTtsRecorder.ondataavailable = e => { if (e.data.size) sttTtsRecordChunks.push(e.data); }; sttTtsRecorder.onstop = async () => { clearInterval(sttTtsRecordTimer); diff --git a/static/js/voice-clone.js b/static/js/voice-clone.js index 6aada29..fbfa37c 100644 --- a/static/js/voice-clone.js +++ b/static/js/voice-clone.js @@ -1,25 +1,29 @@ // ── Clone: recommended sample sentences ────────────────────────────────── const CLONE_SAMPLE_TEXTS = { - EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', - DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', - IT: "La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d'argento, una mela rossa e il ritmo leggero della pioggia alla finestra.", - ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.', - FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.', - PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.', - NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.', - PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.', + EN: 'Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain — these are the things I love. Can you hear how clearly I speak?', + DE: 'Hallo! Ich heiße Alex und das ist meine Stimme. Ich kann leise flüstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster — das liebe ich. Hörst du, wie deutlich ich spreche?', + IT: "Ciao! Mi chiamo Marco e questa è la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia — queste sono le cose che amo. Senti come parlo chiaramente?", + ES: '¡Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire frío del invierno, la cálida luz del fuego y el suave sonido de la lluvia — estas son las cosas que amo. ¿Puedes oír lo claramente que hablo?', + FR: "Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumière du feu et le son de la pluie — voilà ce que j'aime. Entends-tu comme je parle clairement ?", + PT: 'Olá! Meu nome é Ana e esta é a minha voz. Posso falar suavemente ou com grande força. O ar fresco do inverno, a luz quente do fogo e o som da chuva — estas são as coisas que amo. Consegues ouvir como falo claramente?', + NL: 'Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen — dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?', + PL: 'Cześć! Mam na imię Anna i to jest mój głos. Mogę mówić cicho lub z całą mocą. Mroźne zimowe powietrze, ciepłe światło ognia i dźwięk deszczu za oknem — to są rzeczy, które kocham. Czy słyszysz, jak wyraźnie mówię?', }; -(function initCloneSampleText() { +window.initCloneSampleText = function () { const sel = $('clone-sample-lang'); const txt = $('clone-sample-text'); if (!sel || !txt) return; - txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; - sel.addEventListener('change', () => { - txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; - }); -})(); + if (!txt.value.trim()) txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; + if (!sel._cloneSampleBound) { + sel.addEventListener('change', () => { + txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; + }); + sel._cloneSampleBound = true; + } +}; +initCloneSampleText(); // ── WaveSurfer ──────────────────────────────────────────────────────────── @@ -209,23 +213,135 @@ async function requestMicrophoneStream(options = {}) { } } +// ── Clone mic monitor ───────────────────────────────────────────────────── + +let _cloneMonState = { + stream:null, recordStream:null, audioCtx:null, sourceNode:null, + gainNode:null, analyser:null, meterRaf:null, waveRing:null, monitoring:false +}; + +function _cloneRenderMeter(level = 0, db = -Infinity, clipped = false) { + const meter = $('clone-mic-meter'); + if (!meter) return; + if (!meter.children.length) { + for (let i = 0; i < 18; i++) { const b = document.createElement('div'); b.className = 'bar'; meter.appendChild(b); } + } + const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length); + [...meter.children].forEach((bar, i) => { + bar.className = 'bar'; + bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px'; + if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); } + }); + const el = $('clone-db-readout'); + if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; +} + +function _cloneStartMeter() { + if (!_cloneMonState.analyser) return; + if (_cloneMonState.meterRaf) cancelAnimationFrame(_cloneMonState.meterRaf); + const data = new Float32Array(_cloneMonState.analyser.fftSize); + const canvas = $('clone-live-wave'); + const RING = 300, ADD = 10; + _cloneMonState.waveRing = new Float32Array(RING); + const tick = () => { + _cloneMonState.analyser.getFloatTimeDomainData(data); + let sum = 0, peak = 0; + for (const s of data) { sum += s * s; peak = Math.max(peak, Math.abs(s)); } + const rms = Math.sqrt(sum / data.length); + const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; + const level = Number.isFinite(db) ? (db + 60) / 60 : 0; + _cloneRenderMeter(level, db, peak > 0.98); + if (canvas && _cloneMonState.waveRing) { + const ring = _cloneMonState.waveRing; + ring.copyWithin(0, ADD); + for (let i = 0; i < ADD; i++) ring[RING - ADD + i] = data[Math.floor(i * data.length / ADD)]; + const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + ctx.beginPath(); + ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1'; + ctx.lineWidth = 1.5; + const mid = h / 2; + for (let i = 0; i < RING; i++) { + const x = (i / RING) * w, y = mid - ring[i] * mid * 0.85; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + _cloneMonState.meterRaf = requestAnimationFrame(tick); + }; + tick(); +} + +async function _cloneStartMonitor() { + if (_cloneMonState.recordStream) return; + const AudioCtx = window.AudioContext || window.webkitAudioContext; + _cloneMonState.stream = await requestMicrophoneStream({raw:true}); + if (AudioCtx) { + _cloneMonState.audioCtx = new AudioCtx(); + _cloneMonState.sourceNode = _cloneMonState.audioCtx.createMediaStreamSource(_cloneMonState.stream); + _cloneMonState.gainNode = _cloneMonState.audioCtx.createGain(); + _cloneMonState.analyser = _cloneMonState.audioCtx.createAnalyser(); + _cloneMonState.analyser.fftSize = 1024; + const dest = _cloneMonState.audioCtx.createMediaStreamDestination(); + const gainVal = parseFloat($('clone-mic-gain')?.value) || 1; + _cloneMonState.gainNode.gain.value = gainVal; + _cloneMonState.sourceNode.connect(_cloneMonState.gainNode); + _cloneMonState.gainNode.connect(_cloneMonState.analyser); + _cloneMonState.gainNode.connect(dest); + _cloneMonState.recordStream = dest.stream; + _cloneStartMeter(); + } else { + _cloneMonState.recordStream = _cloneMonState.stream; + } + _cloneMonState.monitoring = true; + if ($('clone-monitor-btn')) $('clone-monitor-btn').disabled = true; + if ($('clone-monitor-stop')) $('clone-monitor-stop').disabled = false; +} + +function _cloneStopMonitor() { + if (_cloneMonState.meterRaf) cancelAnimationFrame(_cloneMonState.meterRaf); + _cloneMonState.meterRaf = null; + [_cloneMonState.sourceNode, _cloneMonState.gainNode, _cloneMonState.analyser].forEach(n => { try { if(n) n.disconnect(); } catch(e){} }); + if (_cloneMonState.stream) _cloneMonState.stream.getTracks().forEach(t => t.stop()); + if (_cloneMonState.recordStream) _cloneMonState.recordStream.getTracks().forEach(t => t.stop()); + if (_cloneMonState.audioCtx) _cloneMonState.audioCtx.close().catch(()=>{}); + Object.assign(_cloneMonState, {stream:null, recordStream:null, sourceNode:null, gainNode:null, analyser:null, audioCtx:null, monitoring:false, waveRing:null}); + _cloneRenderMeter(0, -Infinity, false); + const wc = $('clone-live-wave'); if (wc) wc.getContext('2d').clearRect(0, 0, wc.width, wc.height); + if ($('clone-monitor-btn')) $('clone-monitor-btn').disabled = false; + if ($('clone-monitor-stop')) $('clone-monitor-stop').disabled = true; +} + +$('clone-monitor-btn')?.addEventListener('click', async () => { + try { await _cloneStartMonitor(); status('Mic level monitor active'); } + catch(e) { const m = await microphoneErrorMessage(e); toast(m, 'error'); } +}); +$('clone-monitor-stop')?.addEventListener('click', () => { _cloneStopMonitor(); status('Mic level monitor stopped'); }); +$('clone-mic-gain')?.addEventListener('input', () => { + const v = parseFloat($('clone-mic-gain').value) || 0; + if ($('clone-mic-gain-value')) $('clone-mic-gain-value').textContent = v.toFixed(2) + 'x'; + if (_cloneMonState.gainNode) _cloneMonState.gainNode.gain.value = v; +}); +_cloneRenderMeter(); + let mediaRec=null, recChunks=[], recTimer=null, recSecs=0; $('rec-start-btn').addEventListener('click', async () => { try { - const stream = await requestMicrophoneStream(); + await _cloneStartMonitor(); recChunks=[]; recSecs=0; $('rec-time').textContent='0:00'; $('rec-indicator').classList.add('active'); $('rec-start-btn').disabled=true; $('rec-stop-btn').disabled=false; recTimer = setInterval(() => { recSecs++; $('rec-time').textContent=Math.floor(recSecs/60)+':'+String(recSecs%60).padStart(2,'0'); }, 1000); - mediaRec = new MediaRecorder(stream); + mediaRec = new MediaRecorder(_cloneMonState.recordStream || _cloneMonState.stream, {audioBitsPerSecond: 256000}); mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); }; mediaRec.onstop = async () => { - clearInterval(recTimer); $('rec-indicator').classList.remove('active'); stream.getTracks().forEach(t=>t.stop()); + clearInterval(recTimer); $('rec-indicator').classList.remove('active'); const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'}); const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm'; + _cloneStopMonitor(); await uploadFile(new File([blob], 'recording'+ext, {type:blob.type})); }; mediaRec.start(100); status('Recording…'); - } catch(e) { toast(await microphoneErrorMessage(e), 'error'); } + } catch(e) { _cloneStopMonitor(); toast(await microphoneErrorMessage(e), 'error'); } }); $('rec-stop-btn').addEventListener('click', () => { if(mediaRec&&mediaRec.state!=='inactive') mediaRec.stop(); @@ -782,12 +898,19 @@ $('design-download-btn').addEventListener('click', () => { // ── Transcribe ──────────────────────────────────────────────────────────── +$('clone-refresh-stt-btn')?.addEventListener('click', async () => { + $('clone-refresh-stt-btn').disabled = true; + try { await refreshSttBackends($('clone-stt-backend')?.value); } + finally { $('clone-refresh-stt-btn').disabled = false; } +}); + $('transcribe-btn').addEventListener('click', async () => { const id = trimmedFileId||designedFileId||currentFileId; if (!id) { toast('No audio to transcribe','error'); return; } $('transcribe-btn').disabled=true; $('transcribe-status').textContent='Transcribing…'; try { - const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}); + const backend = $('clone-stt-backend')?.value || 'configured'; + const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id, backend})}); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); $('transcript-area').value=d.text; $('transcribe-status').textContent='Done'; toast('Transcription complete','success'); diff --git a/static/nav.js b/static/nav.js index 702c8db..79af4d0 100644 --- a/static/nav.js +++ b/static/nav.js @@ -21,6 +21,7 @@ const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; function runSideEffects(name) { + if ((name === 'source' || name === 'save') && typeof initCloneSampleText === 'function') initCloneSampleText(); if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary(); if ((name === 'integrations' || name === 'howto') && typeof renderIntegrationSnippets === 'function') { if (typeof loadVoiceLibrary === 'function' && !(window._voices && window._voices.length)) loadVoiceLibrary(); @@ -158,4 +159,56 @@ showSection(_startSection); var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library'; runSideEffects(_startTab); + + // ── Mobile sidebar drawer ──────────────────────────────────────────────── + var sidebar = document.getElementById('sidebar'); + var backdrop = document.getElementById('sidebar-backdrop'); + var toggleBtn = document.getElementById('sidebar-toggle'); + + function openSidebar() { + if (!sidebar) return; + sidebar.classList.add('open'); + if (backdrop) backdrop.classList.add('open'); + document.body.style.overflow = 'hidden'; // prevent scroll behind drawer + } + function closeSidebar() { + if (!sidebar) return; + sidebar.classList.remove('open'); + if (backdrop) backdrop.classList.remove('open'); + document.body.style.overflow = ''; + } + + if (toggleBtn) toggleBtn.addEventListener('click', openSidebar); + if (backdrop) backdrop.addEventListener('click', closeSidebar); + + // Close drawer when a nav item is tapped on mobile + document.querySelectorAll('#sidebar .nav-item, #sidebar .nav-tree-item').forEach(function (el) { + el.addEventListener('click', function () { + if (window.innerWidth <= 767) closeSidebar(); + }); + }); + + // ── Mobile voice inspector: back button ────────────────────────────────── + // When a voice row is selected on mobile, show inspector full-height. + // The inspector JS will call window.onMobileInspectorOpen/Close as hooks. + window.onMobileInspectorOpen = function () { + var wb = document.querySelector('.voices-workbench'); + if (!wb || window.innerWidth > 767) return; + wb.classList.add('mobile-inspector-open'); + // Inject back button if not already there + var insp = document.getElementById('voices-inspector'); + if (insp && !insp.querySelector('.insp-mobile-back')) { + var back = document.createElement('div'); + back.className = 'insp-mobile-back'; + back.innerHTML = ' All voices'; + back.addEventListener('click', function () { window.onMobileInspectorClose && window.onMobileInspectorClose(); }); + insp.insertBefore(back, insp.firstChild); + } + }; + window.onMobileInspectorClose = function () { + var wb = document.querySelector('.voices-workbench'); + if (wb) wb.classList.remove('mobile-inspector-open'); + var back = document.querySelector('.insp-mobile-back'); + if (back) back.remove(); + }; })(); diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html index a11d524..c600fee 100644 --- a/static/sections/s-clone.html +++ b/static/sections/s-clone.html @@ -15,7 +15,7 @@ Drop an audio / video file here WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM or click to browse - +
@@ -47,10 +47,26 @@
+ +
0:00
+
+
+ Input level + -∞ dB +
+ + +
+ + + 1.00x +
+
Best peaks: −18 to −9 dB. Never red.
+