From 0c7cc04643e07a7458f9ae9f24813d0080db1ce8 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 12:10:00 +0200 Subject: [PATCH] refactor: extract rehearser recording --- CHANGELOG.md | 4 + TODO.md | 2 +- VERSION | 2 +- docs/ARCHITECTURE.md | 1 + static/index.html | 16 ++-- static/js/rehearser-recording.js | 151 +++++++++++++++++++++++++++++++ static/js/rehearser.js | 136 ---------------------------- static/module-manifest.json | 8 +- tests/lazy-loading.spec.js | 3 +- 9 files changed, 172 insertions(+), 151 deletions(-) create mode 100644 static/js/rehearser-recording.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d52905..4779c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Production bundle refresh.** Rebuilt `static/dist/main.min.js` from the current manifest so the release path + includes the validated frontend fixes and retry-history behavior. +- **Rehearser recording extraction.** Microphone recording, level metering, overlay state, and recorded-line controls + now live in `rehearser-recording.js`. - **Audiobook preview/export extraction.** Editable cast review, Rehearser handoff, chapter-aware synthesis/export, pause settings, and export browsing now live in `audiobook-preview-export.js`. - **Audiobook casting-pipeline extraction.** LLM casting, unknown-speaker recast, automatic repair, and cast-view diff --git a/TODO.md b/TODO.md index 9765119..77dd762 100644 --- a/TODO.md +++ b/TODO.md @@ -121,7 +121,7 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training, - voice design, I/O, library, and stage blocks are now isolated, with import/event wiring and remaining Audiobook orchestration + voice design, I/O, library, stage, and recording blocks are now isolated, with import/event wiring and remaining Audiobook orchestration still in the monolith; Audiobook LLM orchestration and preview/export are now isolated from core Reader wiring. - [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`. - [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now diff --git a/VERSION b/VERSION index 3ad2df4..1bbb1cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.53 +1.24.54 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4f0201..1fb62b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,6 +35,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering. - ✅ `rehearser-playback.js` — WebAudio playback, transport state, bounded decoded-audio prefetch, and persistent line-audio cache/synthesis controls. - ✅ `rehearser-io.js` — rehearsal serialization, library save/load, autosave, session/file export, and screenplay/PDF imports. +- ✅ `rehearser-recording.js` — microphone recording overlay, level meter, and recorded-line controls. - ✅ `rehearser-voice-picker.js` — voice preview state, local matching, fish.audio search/import, and candidate assignment; DOM event registration remains in `rehearser-cast.js`. - ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. diff --git a/static/index.html b/static/index.html index 0d923ff..f15d7eb 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-recording.js b/static/js/rehearser-recording.js new file mode 100644 index 0000000..0a92641 --- /dev/null +++ b/static/js/rehearser-recording.js @@ -0,0 +1,151 @@ +// Rehearser microphone recording overlay and controls. +// Loaded before rehearser.js; shared state and playback helpers resolve at call time. + +// ── Recording overlay ─────────────────────────────────────────────────────── + +function showRecOverlay(line) { + const overlay = $('reh-rec-overlay'); if (!overlay) return; + overlay.hidden = false; + const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' }; + if (cue) cue.innerHTML = `${escHtml(line.speaker)} — your line:
${escHtml(stripMarkdown(line.text))}
`; + if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; } + if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true; + if ($('reh-rec-start')) $('reh-rec-start').disabled = false; + if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true; + if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00'; + rehState.lastRecBlob = null; +} + +function hideRecOverlay() { + const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true; + stopRehMic(); +} + +// ── Mic recording ─────────────────────────────────────────────────────────── + +function rehRenderMeter(level=0, db=-Infinity, clipped=false) { + const meter = $('reh-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-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');} + }); + const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB'; +} + +function rehStartMeter() { + if (!rehState.recAnalyser) return; + if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf); + const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10; + rehState.recWaveRing = new Float32Array(RING); + const tick=()=>{ + rehState.recAnalyser.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), db=rms>0?20*Math.log10(rms):-Infinity; + rehRenderMeter((db+60)/60,db,peak>0.98); + if(canvas&&rehState.recWaveRing){ + const ring=rehState.recWaveRing; ring.copyWithin(0,ADD); + for(let i=0;i0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5; + const mid=h/2; + for(let i=0;i{try{if(n)n.disconnect();}catch(_){}}); + if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop()); + if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop()); + if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{}); + Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null}); + rehRenderMeter(); + const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height); +} + +$('reh-rec-start')?.addEventListener('click', async () => { + try { + await startRehMic(); + rehState.recChunks=[]; rehState.recSecs=0; + if($('reh-rec-time'))$('reh-rec-time').textContent='0:00'; + if($('reh-rec-start'))$('reh-rec-start').disabled=true; + if($('reh-rec-stop'))$('reh-rec-stop').disabled=false; + if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true; + rehState.recTimer=setInterval(()=>{ + rehState.recSecs++; + if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0'); + },1000); + rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000}); + rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);}; + rehState.mediaRec.onstop=()=>{ + clearInterval(rehState.recTimer); + if($('reh-rec-start'))$('reh-rec-start').disabled=false; + if($('reh-rec-stop'))$('reh-rec-stop').disabled=true; + const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'}); + const url=URL.createObjectURL(blob); + const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';} + if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false; + rehState.lastRecBlob=blob; + }; + rehState.mediaRec.start(100); + } catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');} +}); + +$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); }); + +$('reh-rec-keep')?.addEventListener('click', () => { + if (rehState.lastRecBlob) + rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob}); + stopRehMic(); hideRecOverlay(); + rehState.lineIndex++; + startPlay(); +}); + +$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); }); + +$('reh-skip-line')?.addEventListener('click', () => { + rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'}); + stopRehMic(); hideRecOverlay(); + rehState.lineIndex++; + startPlay(); +}); + + +window.TTVC=window.TTVC||{}; +window.TTVC.rehearser=window.TTVC.rehearser||{}; +Object.assign(window.TTVC.rehearser,{ + recording:{ + showOverlay:showRecOverlay, + hideOverlay:hideRecOverlay, + start:startRehMic, + stop:stopRehMic, + }, +}); + diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 44fe9ab..c5c8723 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -2386,142 +2386,6 @@ $('reh-page-title')?.addEventListener('keydown', function (e) { if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); } }); -// ── Recording overlay ─────────────────────────────────────────────────────── - -function showRecOverlay(line) { - const overlay = $('reh-rec-overlay'); if (!overlay) return; - overlay.hidden = false; - const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' }; - if (cue) cue.innerHTML = `${escHtml(line.speaker)} — your line:
${escHtml(stripMarkdown(line.text))}
`; - if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; } - if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true; - if ($('reh-rec-start')) $('reh-rec-start').disabled = false; - if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true; - if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00'; - rehState.lastRecBlob = null; -} - -function hideRecOverlay() { - const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true; - stopRehMic(); -} - -// ── Mic recording ─────────────────────────────────────────────────────────── - -function rehRenderMeter(level=0, db=-Infinity, clipped=false) { - const meter = $('reh-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-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');} - }); - const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB'; -} - -function rehStartMeter() { - if (!rehState.recAnalyser) return; - if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf); - const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10; - rehState.recWaveRing = new Float32Array(RING); - const tick=()=>{ - rehState.recAnalyser.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), db=rms>0?20*Math.log10(rms):-Infinity; - rehRenderMeter((db+60)/60,db,peak>0.98); - if(canvas&&rehState.recWaveRing){ - const ring=rehState.recWaveRing; ring.copyWithin(0,ADD); - for(let i=0;i0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5; - const mid=h/2; - for(let i=0;i{try{if(n)n.disconnect();}catch(_){}}); - if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop()); - if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop()); - if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{}); - Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null}); - rehRenderMeter(); - const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height); -} - -$('reh-rec-start')?.addEventListener('click', async () => { - try { - await startRehMic(); - rehState.recChunks=[]; rehState.recSecs=0; - if($('reh-rec-time'))$('reh-rec-time').textContent='0:00'; - if($('reh-rec-start'))$('reh-rec-start').disabled=true; - if($('reh-rec-stop'))$('reh-rec-stop').disabled=false; - if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true; - rehState.recTimer=setInterval(()=>{ - rehState.recSecs++; - if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0'); - },1000); - rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000}); - rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);}; - rehState.mediaRec.onstop=()=>{ - clearInterval(rehState.recTimer); - if($('reh-rec-start'))$('reh-rec-start').disabled=false; - if($('reh-rec-stop'))$('reh-rec-stop').disabled=true; - const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'}); - const url=URL.createObjectURL(blob); - const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';} - if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false; - rehState.lastRecBlob=blob; - }; - rehState.mediaRec.start(100); - } catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');} -}); - -$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); }); - -$('reh-rec-keep')?.addEventListener('click', () => { - if (rehState.lastRecBlob) - rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob}); - stopRehMic(); hideRecOverlay(); - rehState.lineIndex++; - startPlay(); -}); - -$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); }); - -$('reh-skip-line')?.addEventListener('click', () => { - rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'}); - stopRehMic(); hideRecOverlay(); - rehState.lineIndex++; - startPlay(); -}); - // ── Phase 4: Summary ──────────────────────────────────────────────────────── function renderSummary() { diff --git a/static/module-manifest.json b/static/module-manifest.json index ede4860..bb67e8f 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } } diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index 2860743..1ee4d79 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -52,5 +52,6 @@ test('Rehearser playback module publishes transport APIs after loading', async ( synthAll: typeof window.TTVC?.rehearser?.playback?.synthAll, loadRecord: typeof window.TTVC?.rehearser?.io?.loadRecord, exportSession: typeof window.TTVC?.rehearser?.io?.exportSession, - }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function', loadRecord: 'function', exportSession: 'function' }); + recording: typeof window.TTVC?.rehearser?.recording?.start, + }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function', loadRecord: 'function', exportSession: 'function', recording: 'function' }); });