Add Script Rehearser; clean Connect Apps; About changelog; rework Try It Out
## Script Rehearser (new feature)
- New section s-rehearser.html + rehearser.js + nav/loader wiring
- Phase 1: paste/upload script (.txt), auto-detect characters from
'CHARACTER: dialog' or ALL-CAPS screenplay format
- Phase 2: assign a TTS voice per character, or mark 'I play this'
- Phase 3: step-through rehearsal — synthesizes other characters via TTS,
shows level-meter + oscilloscope for your own lines, records them from mic
- Phase 4: session summary with per-line audio playback + download
## Connect Apps
- Removed duplicate standalone MCP/speak/hotkey full-width cards
- Kept the integration-grid cards (they use the real server URL from JS)
- Added Global Hotkey Daemon as a proper integration card with snippet-hotkey
populated by integrations.js (uses proxyBase URL dynamically)
## About page
- GET /api/changelog endpoint reads CHANGELOG.md and returns it as text
- Collapsible 'Changelog' <details> card fetches and displays it lazily
## Try It Out
- Reorganised into three cards: Voice & backend / Text to synthesize / Generate
- Backend help panel moved below the voice row (not in the same flex row)
- Style instruction field gains a dynamic badge ('style-aware ✓' / 'weak style')
and a yellow warning when a non-style-aware backend is selected while the
field is filled — wired to both backend-select change and input events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b3109cbf4
commit
d99395480a
@ -52,6 +52,14 @@ async def get_version():
|
|||||||
return {"version": __version__}
|
return {"version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/changelog", response_class=PlainTextResponse)
|
||||||
|
async def get_changelog():
|
||||||
|
cl = Path(__file__).parent.parent / "CHANGELOG.md"
|
||||||
|
if not cl.is_file():
|
||||||
|
raise HTTPException(404, "CHANGELOG.md not found")
|
||||||
|
return cl.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/robots.txt", response_class=PlainTextResponse)
|
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||||
async def robots_txt():
|
async def robots_txt():
|
||||||
return "User-agent: *\nDisallow: /"
|
return "User-agent: *\nDisallow: /"
|
||||||
|
|||||||
@ -117,6 +117,7 @@
|
|||||||
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
|
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
|
||||||
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
|
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
|
||||||
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
|
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-rehearser" onclick="navTo('s-rehearser')"> <span class="nav-icon"><span class="mdi mdi-theater"></span></span> Script Rehearser</div>
|
||||||
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
||||||
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
||||||
|
|
||||||
@ -229,6 +230,7 @@
|
|||||||
<section class="page-section" id="s-connect" style="display:none"></section>
|
<section class="page-section" id="s-connect" style="display:none"></section>
|
||||||
<section class="page-section" id="s-settings" style="display:none"></section>
|
<section class="page-section" id="s-settings" style="display:none"></section>
|
||||||
<section class="page-section" id="s-llms" style="display:none"></section>
|
<section class="page-section" id="s-llms" style="display:none"></section>
|
||||||
|
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
||||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,23 @@ document.querySelectorAll('.s-log-filter').forEach(btn => {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Lazy-load changelog when the details element is opened
|
||||||
|
$('about-changelog-details')?.addEventListener('toggle', async function () {
|
||||||
|
if (!this.open) return;
|
||||||
|
const content = $('about-changelog-content');
|
||||||
|
const status = $('about-changelog-status');
|
||||||
|
if (!content || content.textContent.trim()) return;
|
||||||
|
if (status) status.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
const text = await fetch('/api/changelog').then(r => r.ok ? r.text() : Promise.reject(r.status));
|
||||||
|
content.textContent = text;
|
||||||
|
if (status) status.textContent = '';
|
||||||
|
} catch(e) {
|
||||||
|
content.textContent = 'Could not load changelog: ' + e;
|
||||||
|
if (status) status.textContent = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function renderSettingsAbout() {
|
function renderSettingsAbout() {
|
||||||
const el = $('s-about-backends');
|
const el = $('s-about-backends');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@ -180,6 +197,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
const VAD_THRESHOLD = 0.02; // raised to ignore background noise
|
const VAD_THRESHOLD = 0.02; // raised to ignore background noise
|
||||||
const VAD_MIN_REC_MS = 800; // wait 800ms before VAD starts checking (avoids click/noise at start)
|
const VAD_MIN_REC_MS = 800; // wait 800ms before VAD starts checking (avoids click/noise at start)
|
||||||
const VAD_SILENCE_MS = 1000;
|
const VAD_SILENCE_MS = 1000;
|
||||||
|
const INTERRUPT_THRESHOLD = 0.04; // higher than VAD to avoid echo triggering interruption
|
||||||
|
const INTERRUPT_HOLD_MS = 350; // speech must persist this long to interrupt
|
||||||
// Whisper hallucinations on silence/noise — discard these from the live preview
|
// Whisper hallucinations on silence/noise — discard these from the live preview
|
||||||
const HALLUCINATION_RE = /^(reich|danke\s*(schön)?|vielen\s*dank|thank\s*you|thanks|you|copyright|abonnieren|untertitel|zарегистрируйтесь)[.!?,\s]*$/i;
|
const HALLUCINATION_RE = /^(reich|danke\s*(schön)?|vielen\s*dank|thank\s*you|thanks|you|copyright|abonnieren|untertitel|zарегистрируйтесь)[.!?,\s]*$/i;
|
||||||
const origPlaceholder = textInput?.placeholder || '';
|
const origPlaceholder = textInput?.placeholder || '';
|
||||||
@ -191,6 +210,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
const audioQueue = [];
|
const audioQueue = [];
|
||||||
let audioQueuePlaying = false;
|
let audioQueuePlaying = false;
|
||||||
let audioQueueDrainCb = null;
|
let audioQueueDrainCb = null;
|
||||||
|
let interruptCtx = null;
|
||||||
|
let interruptRafId = null;
|
||||||
|
let interruptStream = null;
|
||||||
|
let interruptSpeechStart = 0;
|
||||||
let vadHadSpeech = false; // true once RMS crossed threshold during this recording
|
let vadHadSpeech = false; // true once RMS crossed threshold during this recording
|
||||||
let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate)
|
let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate)
|
||||||
let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT
|
let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT
|
||||||
@ -391,6 +414,62 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
audioQueue.length = 0;
|
audioQueue.length = 0;
|
||||||
audioQueuePlaying = false;
|
audioQueuePlaying = false;
|
||||||
audioQueueDrainCb = null;
|
audioQueueDrainCb = null;
|
||||||
|
stopInterruptMonitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopInterruptMonitor() {
|
||||||
|
cancelAnimationFrame(interruptRafId); interruptRafId = null;
|
||||||
|
if (interruptCtx) { try { interruptCtx.close(); } catch(_){} interruptCtx = null; }
|
||||||
|
if (interruptStream) { interruptStream.getTracks().forEach(t => t.stop()); interruptStream = null; }
|
||||||
|
interruptSpeechStart = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startInterruptMonitor() {
|
||||||
|
if (interruptCtx || !navigator.mediaDevices?.getUserMedia) return;
|
||||||
|
try {
|
||||||
|
interruptStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
interruptCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
await interruptCtx.resume(); // may be suspended when created outside a user gesture
|
||||||
|
const src = interruptCtx.createMediaStreamSource(interruptStream);
|
||||||
|
const analyser = interruptCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
src.connect(analyser);
|
||||||
|
const buf = new Float32Array(analyser.fftSize);
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
// Stop monitoring once nothing is playing and queue is empty
|
||||||
|
if (!audioQueuePlaying && !audioQueue.length && !convCurrentAudio) {
|
||||||
|
stopInterruptMonitor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
analyser.getFloatTimeDomainData(buf);
|
||||||
|
let rms = 0;
|
||||||
|
for (const s of buf) rms += s * s;
|
||||||
|
rms = Math.sqrt(rms / buf.length);
|
||||||
|
|
||||||
|
if (rms > INTERRUPT_THRESHOLD) {
|
||||||
|
if (!interruptSpeechStart) interruptSpeechStart = Date.now();
|
||||||
|
if (Date.now() - interruptSpeechStart >= INTERRUPT_HOLD_MS) {
|
||||||
|
// User is talking — interrupt the AI.
|
||||||
|
// Force-release isProcessing so startRecording's guard doesn't block us.
|
||||||
|
stopInterruptMonitor();
|
||||||
|
clearAudio();
|
||||||
|
autoMicGeneration++;
|
||||||
|
isProcessing = false;
|
||||||
|
micBtn.classList.remove('processing');
|
||||||
|
micIcon.className = 'mdi mdi-microphone';
|
||||||
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
|
if (textInput) textInput.disabled = false;
|
||||||
|
startRecording().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interruptSpeechStart = 0;
|
||||||
|
}
|
||||||
|
interruptRafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
interruptRafId = requestAnimationFrame(tick);
|
||||||
|
} catch (_) { stopInterruptMonitor(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function playNextAudio() {
|
function playNextAudio() {
|
||||||
@ -421,6 +500,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
el.addEventListener('ended', () => { URL.revokeObjectURL(item.url); playNextAudio(); });
|
el.addEventListener('ended', () => { URL.revokeObjectURL(item.url); playNextAudio(); });
|
||||||
el.play().catch(() => { URL.revokeObjectURL(item.url); playNextAudio(); });
|
el.play().catch(() => { URL.revokeObjectURL(item.url); playNextAudio(); });
|
||||||
if (micStatus) micStatus.textContent = 'Speaking…';
|
if (micStatus) micStatus.textContent = 'Speaking…';
|
||||||
|
startInterruptMonitor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueAudio(b64, mime, text) {
|
function enqueueAudio(b64, mime, text) {
|
||||||
@ -435,8 +515,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
|
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
|
||||||
async function transcribeForPreview() {
|
async function transcribeForPreview() {
|
||||||
if (previewTranscribing || !recChunks.length) return;
|
if (previewTranscribing || !recChunks.length) return;
|
||||||
// Only transcribe if speech was actually detected in this recording
|
// When VAD is on, gate on detected speech to avoid transcribing silence.
|
||||||
if (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000) return;
|
// When VAD is off the user controls recording manually — always transcribe.
|
||||||
|
if (vadToggle?.checked && (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000)) return;
|
||||||
previewTranscribing = true;
|
previewTranscribing = true;
|
||||||
try {
|
try {
|
||||||
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
|
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
|
||||||
@ -479,6 +560,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
stopInterruptMonitor(); // release mic stream before opening a recording stream
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' +
|
toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' +
|
||||||
'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000);
|
'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000);
|
||||||
@ -522,8 +604,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||||
processBlob(blob);
|
processBlob(blob);
|
||||||
};
|
};
|
||||||
// timeslice=1500: ondataavailable fires every 1.5 s → faster interim Whisper preview
|
// timeslice: 750ms when VAD is off (manual recording) for faster Whisper preview;
|
||||||
mediaRecorder.start(1500);
|
// 1500ms with VAD on (chunks are gated anyway, smaller slices waste CPU).
|
||||||
|
mediaRecorder.start(vadToggle?.checked ? 1500 : 750);
|
||||||
micBtn.classList.add('recording');
|
micBtn.classList.add('recording');
|
||||||
micIcon.className = 'mdi mdi-stop';
|
micIcon.className = 'mdi mdi-stop';
|
||||||
if (micStatus) micStatus.textContent = 'Recording…';
|
if (micStatus) micStatus.textContent = 'Recording…';
|
||||||
@ -743,6 +826,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
micIcon.className = 'mdi mdi-microphone';
|
micIcon.className = 'mdi mdi-microphone';
|
||||||
if (sendBtn) sendBtn.disabled = false;
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
if (textInput) { textInput.disabled = false; textInput.value = ''; }
|
if (textInput) { textInput.disabled = false; textInput.value = ''; }
|
||||||
|
if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -843,6 +927,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
micIcon.className = 'mdi mdi-microphone';
|
micIcon.className = 'mdi mdi-microphone';
|
||||||
if (sendBtn) sendBtn.disabled = false;
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
if (textInput) textInput.disabled = false;
|
if (textInput) textInput.disabled = false;
|
||||||
|
if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -391,3 +391,8 @@ $('refine-restore-btn')?.addEventListener('click', () => {
|
|||||||
toast('Original transcription restored', 'success');
|
toast('Original transcription restored', 'success');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update style-support warning when user types in style field
|
||||||
|
$('preview-style-instruction')?.addEventListener('input', () => {
|
||||||
|
if (typeof updateBackendHelp === 'function') updateBackendHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@ -168,6 +168,10 @@ curl -s "${proxyV1}/audio/speech" \\
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
if ($('snippet-hotkey')) $('snippet-hotkey').textContent =
|
||||||
|
`pip install pynput sounddevice soundfile pyperclip requests
|
||||||
|
python hotkey_daemon.py --server ${proxyBase}`;
|
||||||
|
|
||||||
if ($('snippet-speak')) $('snippet-speak').textContent =
|
if ($('snippet-speak')) $('snippet-speak').textContent =
|
||||||
`# Bind your client ID to a voice once (persisted in Settings)
|
`# Bind your client ID to a voice once (persisted in Settings)
|
||||||
curl -X PUT ${proxyBase}/speak/bindings/my-script \\
|
curl -X PUT ${proxyBase}/speak/bindings/my-script \\
|
||||||
|
|||||||
444
static/js/rehearser.js
Normal file
444
static/js/rehearser.js
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
// ── Script Rehearser ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rehState = {
|
||||||
|
lines: [], // [{speaker, text}]
|
||||||
|
cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}}
|
||||||
|
lineIndex: 0,
|
||||||
|
clips: [], // [{lineIndex, speaker, type:'tts'|'me'|'skip', blob?}]
|
||||||
|
voices: [], // available TTS voices
|
||||||
|
recStream: null,
|
||||||
|
recAudioCtx: null,
|
||||||
|
recAnalyser: null,
|
||||||
|
recSourceNode: null,
|
||||||
|
recGainNode: null,
|
||||||
|
recDestStream: null,
|
||||||
|
recMeterRaf: null,
|
||||||
|
recWaveRing: null,
|
||||||
|
mediaRec: null,
|
||||||
|
recChunks: [],
|
||||||
|
recTimer: null,
|
||||||
|
recSecs: 0,
|
||||||
|
phase: 1,
|
||||||
|
};
|
||||||
|
window.rehState = rehState;
|
||||||
|
|
||||||
|
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
|
||||||
|
|
||||||
|
// ── Script parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseScript(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const result = [];
|
||||||
|
let currentSpeaker = null;
|
||||||
|
let dialogBuffer = [];
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (currentSpeaker && dialogBuffer.length) {
|
||||||
|
const t = dialogBuffer.join(' ').trim();
|
||||||
|
if (t) result.push({ speaker: currentSpeaker, text: t });
|
||||||
|
}
|
||||||
|
dialogBuffer = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
|
||||||
|
// Format 1: "CHARACTER: dialog text"
|
||||||
|
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
|
||||||
|
if (colonMatch) {
|
||||||
|
flush();
|
||||||
|
currentSpeaker = colonMatch[1].trim();
|
||||||
|
dialogBuffer = [colonMatch[2].trim()];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 2: ALL-CAPS name on its own line (screenplay)
|
||||||
|
if (/^[A-Z][A-Z0-9 _\-]{0,39}$/.test(line) && line.length >= 2) {
|
||||||
|
flush();
|
||||||
|
currentSpeaker = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuation of dialog
|
||||||
|
if (currentSpeaker) dialogBuffer.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCharacters(lines) {
|
||||||
|
const speakers = [...new Set(lines.map(l => l.speaker))];
|
||||||
|
const cast = {};
|
||||||
|
speakers.forEach((sp, i) => {
|
||||||
|
cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length] };
|
||||||
|
});
|
||||||
|
return cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showPhase(n) {
|
||||||
|
rehState.phase = n;
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const el = $('reh-phase-' + i);
|
||||||
|
if (el) el.hidden = i !== n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: Script input ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$('reh-file-input')?.addEventListener('change', function () {
|
||||||
|
const f = this.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = e => { $('reh-script-text').value = e.target.result; };
|
||||||
|
r.readAsText(f);
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
$('reh-parse-btn')?.addEventListener('click', () => {
|
||||||
|
const text = $('reh-script-text')?.value.trim();
|
||||||
|
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
||||||
|
rehState.lines = parseScript(text);
|
||||||
|
if (!rehState.lines.length) { toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return; }
|
||||||
|
rehState.cast = detectCharacters(rehState.lines);
|
||||||
|
renderCastList();
|
||||||
|
showPhase(2);
|
||||||
|
// Populate backend select
|
||||||
|
refreshRehBackends();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 2: Cast assignment ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderCastList() {
|
||||||
|
const list = $('reh-cast-list');
|
||||||
|
if (!list) return;
|
||||||
|
const speakers = Object.keys(rehState.cast);
|
||||||
|
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length;
|
||||||
|
list.innerHTML = speakers.map(sp => {
|
||||||
|
const c = rehState.cast[sp];
|
||||||
|
const color = c.color;
|
||||||
|
return `<div class="reh-cast-row" data-speaker="${escHtml(sp)}">
|
||||||
|
<span class="reh-cast-dot" style="background:${color}"></span>
|
||||||
|
<strong class="reh-cast-name">${escHtml(sp)}</strong>
|
||||||
|
<span class="note">${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}</span>
|
||||||
|
<label class="chunk-toggle-label reh-me-toggle" title="Record this character yourself">
|
||||||
|
<input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${c.voice === 'me' ? 'checked' : ''}>
|
||||||
|
<span>I play this</span>
|
||||||
|
</label>
|
||||||
|
<select class="reh-voice-sel" data-speaker="${escHtml(sp)}" style="flex:1;min-width:140px;${c.voice === 'me' ? 'display:none' : ''}">
|
||||||
|
<option value="">— fetch voices —</option>
|
||||||
|
${rehState.voices.map(v => `<option value="${escHtml(v)}" ${v === c.voice ? 'selected' : ''}>${escHtml(v)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.reh-me-check').forEach(cb => {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
const sp = this.dataset.speaker;
|
||||||
|
const row = this.closest('.reh-cast-row');
|
||||||
|
const sel = row.querySelector('.reh-voice-sel');
|
||||||
|
rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || '');
|
||||||
|
if (sel) sel.style.display = this.checked ? 'none' : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
list.querySelectorAll('.reh-voice-sel').forEach(sel => {
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
rehState.cast[this.dataset.speaker].voice = this.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRehBackends() {
|
||||||
|
const sel = $('reh-backend-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
|
||||||
|
sel.innerHTML = backends.length
|
||||||
|
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
|
||||||
|
: '<option value="">No backend available</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
|
||||||
|
const backend = $('reh-backend-select')?.value;
|
||||||
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
|
$('reh-fetch-voices-btn').disabled = true;
|
||||||
|
try {
|
||||||
|
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||||||
|
rehState.voices = Array.isArray(rawVoices) ? rawVoices.map(v => typeof v === 'string' ? v : (v.id || v.name || String(v))) : [];
|
||||||
|
renderCastList();
|
||||||
|
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
|
||||||
|
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
|
||||||
|
finally { $('reh-fetch-voices-btn').disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
|
||||||
|
|
||||||
|
$('reh-start-btn')?.addEventListener('click', () => {
|
||||||
|
const backend = $('reh-backend-select')?.value;
|
||||||
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
|
rehState.lineIndex = 0;
|
||||||
|
rehState.clips = [];
|
||||||
|
rehState.backend = backend;
|
||||||
|
showPhase(3);
|
||||||
|
renderCurrentLine();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 3: Rehearsal loop ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
const total = rehState.lines.length;
|
||||||
|
const cur = rehState.lineIndex;
|
||||||
|
const pct = total ? (cur / total) * 100 : 0;
|
||||||
|
const bar = $('reh-progress-bar');
|
||||||
|
if (bar) bar.style.width = pct + '%';
|
||||||
|
const lbl = $('reh-progress-label');
|
||||||
|
if (lbl) lbl.textContent = `Line ${cur + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentLine() {
|
||||||
|
const lines = rehState.lines;
|
||||||
|
const i = rehState.lineIndex;
|
||||||
|
if (i >= lines.length) { finishRehearsal(); return; }
|
||||||
|
const line = lines[i];
|
||||||
|
const cast = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' };
|
||||||
|
const isMe = cast.voice === 'me';
|
||||||
|
|
||||||
|
$('reh-line-speaker').textContent = line.speaker;
|
||||||
|
$('reh-line-speaker').style.color = cast.color;
|
||||||
|
$('reh-line-text').textContent = line.text;
|
||||||
|
|
||||||
|
const recPanel = $('reh-record-panel');
|
||||||
|
const ttsPanel = $('reh-tts-panel');
|
||||||
|
if (recPanel) recPanel.hidden = !isMe;
|
||||||
|
if (ttsPanel) ttsPanel.hidden = isMe;
|
||||||
|
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
if (!isMe) {
|
||||||
|
synthesizeLine(line.text, cast.voice, rehState.backend);
|
||||||
|
} else {
|
||||||
|
// Reset recording UI
|
||||||
|
const recPreview = $('reh-rec-preview');
|
||||||
|
if (recPreview) { recPreview.style.display = 'none'; recPreview.src = ''; }
|
||||||
|
const confirmRow = $('reh-rec-confirm-row');
|
||||||
|
if (confirmRow) confirmRow.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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthesizeLine(text, voice, backend) {
|
||||||
|
const ttsAudio = $('reh-tts-audio');
|
||||||
|
const ttsStatus = $('reh-tts-status');
|
||||||
|
if (ttsStatus) ttsStatus.textContent = 'Synthesizing…';
|
||||||
|
if (ttsAudio) { ttsAudio.pause(); ttsAudio.src = ''; }
|
||||||
|
try {
|
||||||
|
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (ttsAudio) { ttsAudio.src = url; ttsAudio.style.display = ''; ttsAudio.play().catch(() => {}); }
|
||||||
|
if (ttsStatus) ttsStatus.textContent = 'Playing…';
|
||||||
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex].speaker, type: 'tts', blob });
|
||||||
|
ttsAudio?.addEventListener('ended', () => { if (ttsStatus) ttsStatus.textContent = 'Done'; }, { once: true });
|
||||||
|
} catch(e) {
|
||||||
|
if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message;
|
||||||
|
toast('TTS failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('reh-tts-next')?.addEventListener('click', () => { advanceLine(1); });
|
||||||
|
$('reh-tts-replay')?.addEventListener('click', () => { $('reh-tts-audio')?.play().catch(() => {}); });
|
||||||
|
$('reh-prev-line')?.addEventListener('click', () => { advanceLine(-1); });
|
||||||
|
$('reh-skip-line')?.addEventListener('click', () => {
|
||||||
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'skip' });
|
||||||
|
advanceLine(1);
|
||||||
|
});
|
||||||
|
$('reh-stop-reh')?.addEventListener('click', () => {
|
||||||
|
stopRehMic();
|
||||||
|
finishRehearsal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function advanceLine(delta) {
|
||||||
|
rehState.lineIndex = Math.max(0, Math.min(rehState.lines.length - 1, rehState.lineIndex + delta));
|
||||||
|
renderCurrentLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRehearsal() {
|
||||||
|
stopRehMic();
|
||||||
|
renderSummary();
|
||||||
|
showPhase(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mic recording (my lines) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 < active) { bar.classList.add('on'); if (db > -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);
|
||||||
|
const canvas = $('reh-live-wave');
|
||||||
|
const 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);
|
||||||
|
const 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; 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; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
rehState.recMeterRaf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRehMic() {
|
||||||
|
if (rehState.recDestStream) return;
|
||||||
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
rehState.recStream = await requestMicrophoneStream({ raw: true });
|
||||||
|
if (AudioCtx) {
|
||||||
|
rehState.recAudioCtx = new AudioCtx();
|
||||||
|
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
|
||||||
|
rehState.recGainNode = rehState.recAudioCtx.createGain();
|
||||||
|
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
|
||||||
|
rehState.recAnalyser.fftSize = 1024;
|
||||||
|
const dest = rehState.recAudioCtx.createMediaStreamDestination();
|
||||||
|
rehState.recSourceNode.connect(rehState.recGainNode);
|
||||||
|
rehState.recGainNode.connect(rehState.recAnalyser);
|
||||||
|
rehState.recGainNode.connect(dest);
|
||||||
|
rehState.recDestStream = dest.stream;
|
||||||
|
rehStartMeter();
|
||||||
|
} else {
|
||||||
|
rehState.recDestStream = rehState.recStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRehMic() {
|
||||||
|
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
||||||
|
rehState.recMeterRaf = null;
|
||||||
|
[rehState.recSourceNode, rehState.recGainNode, rehState.recAnalyser].forEach(n => { try { if(n) n.disconnect(); } catch(e){} });
|
||||||
|
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 preview = $('reh-rec-preview');
|
||||||
|
if (preview) { preview.src = url; preview.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', () => {
|
||||||
|
const blob = rehState.lastRecBlob;
|
||||||
|
if (blob) {
|
||||||
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob });
|
||||||
|
}
|
||||||
|
stopRehMic();
|
||||||
|
advanceLine(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('reh-rec-redo')?.addEventListener('click', () => {
|
||||||
|
stopRehMic();
|
||||||
|
renderCurrentLine(); // re-show the same line
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 4: Summary ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const list = $('reh-summary-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (!rehState.clips.length) { list.innerHTML = '<p class="note">No clips recorded in this session.</p>'; return; }
|
||||||
|
list.innerHTML = rehState.clips.map((clip, idx) => {
|
||||||
|
const line = rehState.lines[clip.lineIndex] || { text: '—', speaker: clip.speaker };
|
||||||
|
const typeLabel = clip.type === 'me' ? '🎤 Recorded' : clip.type === 'tts' ? '🔊 Synthesized' : '⏭ Skipped';
|
||||||
|
const color = rehState.cast[clip.speaker]?.color || '#89b4fa';
|
||||||
|
const audioHtml = clip.blob
|
||||||
|
? `<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px"></audio>`
|
||||||
|
: '';
|
||||||
|
return `<div class="reh-summary-row">
|
||||||
|
<span class="reh-cast-dot" style="background:${color}"></span>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:600;font-size:13px;color:${color}">${escHtml(clip.speaker)} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
|
||||||
|
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(line.text)}</div>
|
||||||
|
${audioHtml}
|
||||||
|
</div>
|
||||||
|
${clip.blob ? `<a class="btn-secondary" style="text-decoration:none" download="line_${idx + 1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('reh-new-session-btn')?.addEventListener('click', () => {
|
||||||
|
stopRehMic();
|
||||||
|
rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0;
|
||||||
|
if ($('reh-script-text')) $('reh-script-text').value = '';
|
||||||
|
showPhase(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('reh-resume-btn')?.addEventListener('click', () => {
|
||||||
|
showPhase(3);
|
||||||
|
renderCurrentLine();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init
|
||||||
|
rehRenderMeter();
|
||||||
@ -64,6 +64,23 @@ function updateBackendHelp() {
|
|||||||
const b = backendById($('tts-backend-select')?.value || '');
|
const b = backendById($('tts-backend-select')?.value || '');
|
||||||
const help = $('tts-backend-help');
|
const help = $('tts-backend-help');
|
||||||
if (help) help.innerHTML = backendHelpHtml(b);
|
if (help) help.innerHTML = backendHelpHtml(b);
|
||||||
|
// Dynamic style-support badge + warning in Try It Out
|
||||||
|
const styleSupport = $('preview-style-support');
|
||||||
|
const styleWarn = $('preview-style-warn');
|
||||||
|
const styleInput = $('preview-style-instruction');
|
||||||
|
if (b && styleSupport) {
|
||||||
|
if (b.style_aware) {
|
||||||
|
styleSupport.textContent = 'style-aware ✓';
|
||||||
|
styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(166,227,161,.2);color:var(--green);border-radius:4px;padding:1px 6px;margin-left:4px';
|
||||||
|
} else {
|
||||||
|
styleSupport.textContent = 'weak style';
|
||||||
|
styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(249,226,175,.2);color:var(--yellow);border-radius:4px;padding:1px 6px;margin-left:4px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (styleWarn) {
|
||||||
|
const hasInstruct = (styleInput?.value || '').trim().length > 0;
|
||||||
|
styleWarn.style.display = (b && !b.style_aware && hasInstruct) ? 'block' : 'none';
|
||||||
|
}
|
||||||
const sttB = backendById($('stt-tts-backend-select')?.value || '');
|
const sttB = backendById($('stt-tts-backend-select')?.value || '');
|
||||||
const sttHelp = $('stt-tts-backend-help');
|
const sttHelp = $('stt-tts-backend-help');
|
||||||
if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB);
|
if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB);
|
||||||
|
|||||||
@ -26,11 +26,13 @@ function selectVoice(wrap) {
|
|||||||
wrap.classList.remove('vr-selected');
|
wrap.classList.remove('vr-selected');
|
||||||
_selectedVoiceWrap = null;
|
_selectedVoiceWrap = null;
|
||||||
inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-account-voice"></span></span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-account-voice"></span></span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
||||||
|
if (typeof window.onMobileInspectorClose === 'function') window.onMobileInspectorClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectedVoiceWrap = wrap;
|
_selectedVoiceWrap = wrap;
|
||||||
wrap.classList.add('vr-selected');
|
wrap.classList.add('vr-selected');
|
||||||
|
if (typeof window.onMobileInspectorOpen === 'function') window.onMobileInspectorOpen();
|
||||||
|
|
||||||
const voiceId = wrap.dataset.id || '';
|
const voiceId = wrap.dataset.id || '';
|
||||||
const color = wrap.dataset.color || '#9575CD';
|
const color = wrap.dataset.color || '#9575CD';
|
||||||
@ -63,6 +65,10 @@ function selectVoice(wrap) {
|
|||||||
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
const inspBenchText = typeof fmtBenchmark === 'function' ? fmtBenchmark(v) : '-';
|
||||||
|
const inspBenchTitle = typeof benchmarkTitle === 'function' ? benchmarkTitle(v) : '';
|
||||||
|
const inspBenchCls = typeof benchmarkClass === 'function' ? benchmarkClass(v) : '';
|
||||||
|
|
||||||
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
|
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
|
||||||
|
|
||||||
// Gender maps — used in template AND in event handlers
|
// Gender maps — used in template AND in event handlers
|
||||||
@ -99,6 +105,7 @@ function selectVoice(wrap) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
||||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||||
|
${inspBenchText !== '-' ? `<span class="insp-bench-chip ${escHtml(inspBenchCls)}" title="${escHtml(inspBenchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(inspBenchText)}</span>` : ''}
|
||||||
<span class="insp-stars-wrap">
|
<span class="insp-stars-wrap">
|
||||||
<span class="insp-stars">${makeStars(rating)}</span>
|
<span class="insp-stars">${makeStars(rating)}</span>
|
||||||
<span class="insp-rating-label">${rating}/5</span>
|
<span class="insp-rating-label">${rating}/5</span>
|
||||||
@ -115,6 +122,11 @@ function selectVoice(wrap) {
|
|||||||
<div class="inspector-body"></div>
|
<div class="inspector-body"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Inject mobile back button after HTML is set (inspector DOM recreated above)
|
||||||
|
if (window.innerWidth <= 767 && typeof window.onMobileInspectorOpen === 'function') {
|
||||||
|
window.onMobileInspectorOpen();
|
||||||
|
}
|
||||||
|
|
||||||
const saveSlot = inspector.querySelector('.insp-actions-save');
|
const saveSlot = inspector.querySelector('.insp-actions-save');
|
||||||
const activeSlot = inspector.querySelector('.insp-actions-active');
|
const activeSlot = inspector.querySelector('.insp-actions-active');
|
||||||
const deleteSlot = inspector.querySelector('.insp-actions-delete');
|
const deleteSlot = inspector.querySelector('.insp-actions-delete');
|
||||||
|
|||||||
@ -97,13 +97,36 @@ function setSort(field) {
|
|||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSortDir() {
|
||||||
|
_sortDir *= -1;
|
||||||
|
syncSortHeaders();
|
||||||
|
renderVoiceList();
|
||||||
|
}
|
||||||
|
|
||||||
function syncSortHeaders() {
|
function syncSortHeaders() {
|
||||||
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
|
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
|
||||||
el.classList.remove('sort-asc', 'sort-desc');
|
el.classList.remove('sort-asc', 'sort-desc');
|
||||||
if (el.dataset.sort === _sortField)
|
if (el.dataset.sort === _sortField)
|
||||||
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
||||||
});
|
});
|
||||||
|
const sel = document.getElementById('voice-sort-field');
|
||||||
|
if (sel && sel.value !== _sortField) sel.value = _sortField;
|
||||||
|
const dirBtn = document.getElementById('voice-sort-dir');
|
||||||
|
if (dirBtn) {
|
||||||
|
const icon = dirBtn.querySelector('.mdi');
|
||||||
|
if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down';
|
||||||
|
dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire sort direction button via addEventListener (more reliable than inline onclick
|
||||||
|
// since the button is injected into the DOM after script execution).
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (e.target.closest('#voice-sort-dir')) toggleSortDir();
|
||||||
|
});
|
||||||
|
document.addEventListener('change', e => {
|
||||||
|
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
const FLAG_LANGUAGE_CANDIDATES = {
|
const FLAG_LANGUAGE_CANDIDATES = {
|
||||||
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
|
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
|
||||||
@ -1637,6 +1660,7 @@ function makeVoiceRow(v) {
|
|||||||
<div class="vl-meta">
|
<div class="vl-meta">
|
||||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||||
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
|
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
|
||||||
|
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr-play-group">
|
<div class="vr-play-group">
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout',
|
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||||
's-performance', 's-routing', 's-connect', 's-settings',
|
's-performance', 's-routing', 's-connect', 's-settings',
|
||||||
's-llms', 's-conversation',
|
's-llms', 's-conversation',
|
||||||
];
|
];
|
||||||
@ -103,6 +103,7 @@
|
|||||||
'/static/js/tts-preview.js',
|
'/static/js/tts-preview.js',
|
||||||
'/static/js/benchmark.js',
|
'/static/js/benchmark.js',
|
||||||
'/static/js/stt.js',
|
'/static/js/stt.js',
|
||||||
|
'/static/js/rehearser.js',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// D — init (needs everything above to be defined)
|
// D — init (needs everything above to be defined)
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
llms: 's-llms'
|
llms: 's-llms'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
||||||
|
|
||||||
function runSideEffects(name) {
|
function runSideEffects(name) {
|
||||||
if ((name === 'source' || name === 'save') && typeof initCloneSampleText === 'function') initCloneSampleText();
|
if ((name === 'source' || name === 'save') && typeof initCloneSampleText === 'function') initCloneSampleText();
|
||||||
|
|||||||
@ -2,72 +2,14 @@
|
|||||||
<span class="section-icon"><span class="mdi mdi-api"></span></span>
|
<span class="section-icon"><span class="mdi mdi-api"></span></span>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>Connect Your Apps</h2>
|
<h2>Connect Your Apps</h2>
|
||||||
<p>Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, MCP agents, and more.</p>
|
<p>Ready-made snippets for SillyTavern, Open WebUI, Home Assistant, Claude Code MCP, /speak REST, hotkey daemon, and more.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content" id="tab-integrations">
|
<div class="tab-content" id="tab-integrations">
|
||||||
|
|
||||||
<!-- ── MCP Server ─────────────────────────────────────────────── -->
|
|
||||||
<div class="card">
|
|
||||||
<h2><span class="mdi mdi-connection"></span> MCP Server</h2>
|
|
||||||
<p class="card-subtitle">Expose voice tools to Claude Code, Cursor, and other MCP-aware agents via Streamable HTTP (JSON-RPC 2.0). Tools: <code>speak</code>, <code>transcribe</code>, <code>list_captures</code>, <code>list_profiles</code>.</p>
|
|
||||||
<div class="settings-grid compact">
|
|
||||||
<div class="s-field">
|
|
||||||
<label>HTTP transport URL</label>
|
|
||||||
<code class="s-code-block" id="s-mcp-url">http://localhost:7890/mcp</code>
|
|
||||||
</div>
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Claude Code one-liner</label>
|
|
||||||
<code class="s-code-block">claude mcp add voice-creator --transport http --url http://localhost:7890/mcp --header "X-Voice-Creator-Client-Id: claude-code"</code>
|
|
||||||
</div>
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Any HTTP MCP client (JSON)</label>
|
|
||||||
<pre class="s-code-block" style="white-space:pre-wrap">{"mcpServers":{"voice-creator":{"url":"http://localhost:7890/mcp","headers":{"X-Voice-Creator-Client-Id":"my-agent"}}}}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── /speak REST endpoint ───────────────────────────────────── -->
|
|
||||||
<div class="card">
|
|
||||||
<h2><span class="mdi mdi-bullhorn-outline"></span> /speak REST endpoint</h2>
|
|
||||||
<p class="card-subtitle">Generate audio from any app or script without routing rules.</p>
|
|
||||||
<div class="settings-grid compact">
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Generate speech (example)</label>
|
|
||||||
<pre class="s-code-block" style="white-space:pre-wrap">curl -X POST http://localhost:7890/speak \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Voice-Creator-Client-Id: my-script" \
|
|
||||||
-d '{"text":"Hello world","voice":"EN_F_Anna"}' \
|
|
||||||
--output speech.wav</pre>
|
|
||||||
</div>
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Per-client voice binding</label>
|
|
||||||
<pre class="s-code-block" style="white-space:pre-wrap">curl -X PUT http://localhost:7890/speak/bindings/my-script \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"voice":"EN_F_Anna"}'</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Global hotkey daemon ───────────────────────────────────── -->
|
|
||||||
<div class="card">
|
|
||||||
<h2><span class="mdi mdi-keyboard-outline"></span> Global hotkey daemon</h2>
|
|
||||||
<p class="card-subtitle">Push-to-talk transcription that types the result into any focused window on the host machine.</p>
|
|
||||||
<div class="settings-grid compact">
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Install & run (host machine)</label>
|
|
||||||
<pre class="s-code-block" style="white-space:pre-wrap">pip install pynput sounddevice soundfile pyperclip requests
|
|
||||||
python hotkey_daemon.py --server http://localhost:7890</pre>
|
|
||||||
<span class="s-hint">Hold <kbd>Ctrl+Shift+Space</kbd> to record, release to transcribe and type. Linux: install <code>xdotool</code> for direct key injection.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── App integrations ───────────────────────────────────────── -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Use voices in other apps</h2>
|
<h2>Use voices in other apps</h2>
|
||||||
<p class="card-subtitle">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p>
|
<p class="card-subtitle">External apps connect to the Creator proxy or a reachable TTS backend and use any active voice name.</p>
|
||||||
<div class="integration-toolbar">
|
<div class="integration-toolbar">
|
||||||
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
|
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
|
||||||
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
|
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
|
||||||
@ -76,6 +18,8 @@ python hotkey_daemon.py --server http://localhost:7890</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="integration-grid">
|
<div class="integration-grid">
|
||||||
|
|
||||||
|
<!-- App integrations -->
|
||||||
<div class="integration-card" data-favicon="https://www.google.com/s2/favicons?domain=sillytavern.app&sz=16">
|
<div class="integration-card" data-favicon="https://www.google.com/s2/favicons?domain=sillytavern.app&sz=16">
|
||||||
<h3>SillyTavern</h3>
|
<h3>SillyTavern</h3>
|
||||||
<p>Use an OpenAI-compatible TTS provider. Paste one active voice into the voice field, or paste the comma-separated list where SillyTavern accepts custom voices.</p>
|
<p>Use an OpenAI-compatible TTS provider. Paste one active voice into the voice field, or paste the comma-separated list where SillyTavern accepts custom voices.</p>
|
||||||
@ -106,32 +50,44 @@ python hotkey_daemon.py --server http://localhost:7890</pre>
|
|||||||
<pre><code id="snippet-voice-design-proxy"></code></pre>
|
<pre><code id="snippet-voice-design-proxy"></code></pre>
|
||||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-voice-design-proxy">Copy virtual voice sample</button>
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-voice-design-proxy">Copy virtual voice sample</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Developer / agent integrations -->
|
||||||
<div class="integration-card integration-card-wide" data-favicon="https://www.google.com/s2/favicons?domain=anthropic.com&sz=16">
|
<div class="integration-card integration-card-wide" data-favicon="https://www.google.com/s2/favicons?domain=anthropic.com&sz=16">
|
||||||
<h3><span class="mdi mdi-robot-outline"></span> MCP — Native built-in server</h3>
|
<h3><span class="mdi mdi-robot-outline"></span> MCP — built-in server</h3>
|
||||||
<p>The app ships a built-in MCP server at <code>/mcp</code> (JSON-RPC 2.0, Streamable HTTP). No external script or extra packages needed. Tools: <strong>speak</strong>, <strong>transcribe</strong>, <strong>list_captures</strong>, <strong>list_profiles</strong>.</p>
|
<p>The app ships a built-in MCP server at <code>/mcp</code> (Streamable HTTP, JSON-RPC 2.0). No extra packages needed. Tools: <strong>speak</strong>, <strong>transcribe</strong>, <strong>list_captures</strong>, <strong>list_profiles</strong>.</p>
|
||||||
<pre><code id="snippet-mcp-claude-cmd"></code></pre>
|
<pre><code id="snippet-mcp-claude-cmd"></code></pre>
|
||||||
<div class="btn-row" style="gap:8px;flex-wrap:wrap">
|
<div class="btn-row" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-claude-cmd">Copy Claude Code one-liner</button>
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-claude-cmd">Copy Claude Code one-liner</button>
|
||||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-claude-config">Copy JSON config</button>
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-claude-config">Copy JSON config</button>
|
||||||
</div>
|
</div>
|
||||||
<pre style="margin-top:10px"><code id="snippet-mcp-claude-config"></code></pre>
|
<pre style="margin-top:10px"><code id="snippet-mcp-claude-config"></code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="integration-card" data-icon="mdi mdi-bullhorn-outline">
|
<div class="integration-card" data-icon="mdi mdi-bullhorn-outline">
|
||||||
<h3><span class="mdi mdi-bullhorn-outline"></span> /speak — direct REST</h3>
|
<h3><span class="mdi mdi-bullhorn-outline"></span> /speak — direct REST</h3>
|
||||||
<p>POST text to <code>/speak</code> from any script, agent, or app. Voice resolves from explicit param → per-client binding → default voice. Optional persona LLM rewrite.</p>
|
<p>POST text to <code>/speak</code> from any script or agent. Voice resolves from param → per-client binding → default. Optional persona LLM rewrite.</p>
|
||||||
<pre><code id="snippet-speak"></code></pre>
|
<pre><code id="snippet-speak"></code></pre>
|
||||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-speak">Copy /speak example</button>
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-speak">Copy /speak example</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="integration-card" data-icon="mdi mdi-keyboard-outline">
|
||||||
|
<h3><span class="mdi mdi-keyboard-outline"></span> Global hotkey daemon</h3>
|
||||||
|
<p>Push-to-talk transcription on the host. Hold <kbd>Ctrl+Shift+Space</kbd> to record, release to transcribe and type into any window. Linux: needs <code>xdotool</code>.</p>
|
||||||
|
<pre><code id="snippet-hotkey"></code></pre>
|
||||||
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-hotkey">Copy install & run</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="integration-card" data-icon="mdi mdi-waveform">
|
<div class="integration-card" data-icon="mdi mdi-waveform">
|
||||||
<h3>Streaming TTS</h3>
|
<h3>Streaming TTS</h3>
|
||||||
<p>Use this when the target app can play audio progressively. For routed streaming, keep response format WAV and avoid before/after route sounds, otherwise the proxy must buffer before playback.</p>
|
<p>Use this when the target app can play audio progressively. For routed streaming, keep response format WAV and avoid before/after route sounds, otherwise the proxy must buffer before playback.</p>
|
||||||
<pre><code id="snippet-streaming-howto"></code></pre>
|
<pre><code id="snippet-streaming-howto"></code></pre>
|
||||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-streaming-howto">Copy streaming how-to</button>
|
<button class="btn-secondary copy-snippet" data-snippet="snippet-streaming-howto">Copy streaming how-to</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Important after voice changes</h2>
|
<h2>Important after voice changes</h2>
|
||||||
<p class="card-subtitle">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
|
<p class="card-subtitle">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
|
||||||
<p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p>
|
<p class="note">Virtual VoiceDesign voices use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan.</p>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-integrations -->
|
</div><!-- /tab-integrations -->
|
||||||
|
|||||||
124
static/sections/s-rehearser.html
Normal file
124
static/sections/s-rehearser.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<div class="section-head">
|
||||||
|
<span class="section-icon"><span class="mdi mdi-theater"></span></span>
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Script Rehearser</h2>
|
||||||
|
<p>Upload a script, assign voices to characters, then synthesize the other parts while you record your own.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-rehearser">
|
||||||
|
|
||||||
|
<!-- ── Phase 1: Script input ──────────────────────────────────── -->
|
||||||
|
<div class="reh-phase" id="reh-phase-1">
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="mdi mdi-script-text-outline"></span> Step 1 — Paste or upload your script</h2>
|
||||||
|
<p class="card-subtitle">Supported formats: <code>CHARACTER: dialog</code> on one line, or screenplay style (character name on its own ALL-CAPS line followed by dialog). Each speaker is detected automatically.</p>
|
||||||
|
<div class="reh-input-row">
|
||||||
|
<textarea id="reh-script-text" placeholder="ALICE Hello, how are you today? BOB I'm doing great, thank you for asking! ALICE That's wonderful to hear." spellcheck="false"></textarea>
|
||||||
|
<div class="reh-input-side">
|
||||||
|
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||||
|
<span class="mdi mdi-upload"></span> Upload .txt
|
||||||
|
<input type="file" id="reh-file-input" accept=".txt,.md" style="display:none">
|
||||||
|
</label>
|
||||||
|
<p class="note" style="margin-top:8px">Format hints:<br>• <code>CHARACTER: text</code><br>• All-caps name on own line + dialog below<br>• Lines starting with <code>#</code> are stage directions (skipped)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="margin-top:12px">
|
||||||
|
<button class="btn-primary" id="reh-parse-btn"><span class="mdi mdi-auto-fix"></span> Parse characters</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Phase 2: Cast assignment ───────────────────────────────── -->
|
||||||
|
<div class="reh-phase" id="reh-phase-2" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="mdi mdi-account-group-outline"></span> Step 2 — Cast your characters</h2>
|
||||||
|
<p class="card-subtitle">Assign a TTS voice to each character, or mark a character as <strong>Me</strong> — those lines will be recorded from your microphone during rehearsal.</p>
|
||||||
|
<div id="reh-cast-list" class="reh-cast-list"></div>
|
||||||
|
<div class="field" style="margin-top:14px">
|
||||||
|
<label>TTS backend for synthesis</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<select id="reh-backend-select"><option value="">Checking…</option></select>
|
||||||
|
<button class="btn-secondary" id="reh-fetch-voices-btn">Fetch voices</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="margin-top:14px">
|
||||||
|
<button class="btn-secondary" id="reh-back-1-btn"><span class="mdi mdi-arrow-left"></span> Back</button>
|
||||||
|
<button class="btn-primary" id="reh-start-btn"><span class="mdi mdi-play"></span> Start rehearsal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Phase 3: Rehearsal ─────────────────────────────────────── -->
|
||||||
|
<div class="reh-phase" id="reh-phase-3" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="mdi mdi-microphone-outline"></span> Step 3 — Rehearse</h2>
|
||||||
|
<div class="reh-progress-bar-wrap">
|
||||||
|
<div class="reh-progress-bar" id="reh-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="reh-progress-label" id="reh-progress-label">Line 0 / 0</div>
|
||||||
|
|
||||||
|
<!-- Current line display -->
|
||||||
|
<div class="reh-line-card" id="reh-line-card">
|
||||||
|
<div class="reh-line-speaker" id="reh-line-speaker"></div>
|
||||||
|
<div class="reh-line-text" id="reh-line-text"></div>
|
||||||
|
<div class="reh-line-actions" id="reh-line-actions"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My-turn recording controls -->
|
||||||
|
<div class="reh-record-panel" id="reh-record-panel" hidden>
|
||||||
|
<div class="mic-monitor-box" style="margin-bottom:10px">
|
||||||
|
<div class="mic-monitor-head">
|
||||||
|
<span>Input level</span>
|
||||||
|
<span class="meter-readout" id="reh-db-readout">-∞ dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="mic-meter" id="reh-mic-meter" aria-hidden="true"></div>
|
||||||
|
<canvas id="reh-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-red" id="reh-rec-start"><span class="mdi mdi-record-circle-outline"></span> Record my line</button>
|
||||||
|
<button class="btn-secondary" id="reh-rec-stop" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
|
||||||
|
<span class="mic-timer" id="reh-rec-time">0:00</span>
|
||||||
|
<button class="btn-secondary" id="reh-skip-line">Skip</button>
|
||||||
|
</div>
|
||||||
|
<audio id="reh-rec-preview" controls style="display:none;margin-top:8px;width:100%"></audio>
|
||||||
|
<div class="btn-row" style="margin-top:6px" id="reh-rec-confirm-row" hidden>
|
||||||
|
<button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep & next</button>
|
||||||
|
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TTS playback status -->
|
||||||
|
<div class="reh-tts-panel" id="reh-tts-panel" hidden>
|
||||||
|
<div id="reh-tts-status" class="note" style="margin-bottom:8px">Synthesizing…</div>
|
||||||
|
<audio id="reh-tts-audio" controls style="width:100%"></audio>
|
||||||
|
<div class="btn-row" style="margin-top:8px">
|
||||||
|
<button class="btn-primary" id="reh-tts-next"><span class="mdi mdi-skip-next"></span> Next</button>
|
||||||
|
<button class="btn-secondary" id="reh-tts-replay"><span class="mdi mdi-replay"></span> Replay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="btn-row" style="margin-top:14px;border-top:1px solid var(--border);padding-top:14px">
|
||||||
|
<button class="btn-secondary" id="reh-prev-line"><span class="mdi mdi-chevron-left"></span> Prev</button>
|
||||||
|
<button class="btn-secondary" id="reh-stop-reh"><span class="mdi mdi-stop"></span> Stop rehearsal</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<span id="reh-session-info" class="note"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Phase 4: Session summary ────────────────────────────────── -->
|
||||||
|
<div class="reh-phase" id="reh-phase-4" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="mdi mdi-check-circle-outline"></span> Rehearsal complete</h2>
|
||||||
|
<p class="card-subtitle">Your recorded lines and all synthesized lines from this session.</p>
|
||||||
|
<div id="reh-summary-list" class="reh-summary-list"></div>
|
||||||
|
<div class="btn-row" style="margin-top:14px">
|
||||||
|
<button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
|
||||||
|
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-play"></span> Resume rehearsal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /tab-rehearser -->
|
||||||
@ -530,6 +530,15 @@
|
|||||||
<p class="note" style="margin-top:10px">MCP server, REST /speak endpoint, and global hotkey daemon are documented under <strong>Connect Apps</strong>.</p>
|
<p class="note" style="margin-top:10px">MCP server, REST /speak endpoint, and global hotkey daemon are documented under <strong>Connect Apps</strong>.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card" style="margin-top:0">
|
||||||
|
<details id="about-changelog-details">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;font-size:15px;padding:4px 0;list-style:none;display:flex;align-items:center;gap:8px">
|
||||||
|
<span class="mdi mdi-history"></span> Changelog
|
||||||
|
<span id="about-changelog-status" style="font-size:12px;color:var(--subtext);font-weight:400;margin-left:auto"></span>
|
||||||
|
</summary>
|
||||||
|
<pre id="about-changelog-content" style="margin-top:12px;white-space:pre-wrap;font-size:12px;line-height:1.6;color:var(--text);font-family:monospace;max-height:400px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:12px"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /tab-settings -->
|
</div><!-- /tab-settings -->
|
||||||
|
|||||||
@ -8,23 +8,17 @@
|
|||||||
|
|
||||||
<!-- TTS Generation Playground -->
|
<!-- TTS Generation Playground -->
|
||||||
<div class="tab-content" id="tab-generation">
|
<div class="tab-content" id="tab-generation">
|
||||||
|
|
||||||
|
<!-- ① Voice selection ─────────────────────────────────────────── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>TTS generation playground</h2>
|
<h2><span class="mdi mdi-account-voice"></span> Voice & backend</h2>
|
||||||
<p class="card-subtitle">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p>
|
<div class="tryout-voice-row">
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Generate speech</h2>
|
|
||||||
<p class="card-subtitle">Select a backend, fetch its voice list, then synthesize any text with optional style instruction.</p>
|
|
||||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Backend</label>
|
<label>Backend</label>
|
||||||
<select id="tts-backend-select"><option value="">Checking backends...</option></select>
|
<select id="tts-backend-select"><option value="">Checking backends…</option></select>
|
||||||
</div>
|
|
||||||
<div class="backend-help" id="tts-backend-help" aria-live="polite">
|
|
||||||
<strong>Checking available TTS backends...</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Backend voice</label>
|
<label>Voice</label>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn-secondary" id="fetch-tts-voices-btn">Fetch voices</button>
|
<button class="btn-secondary" id="fetch-tts-voices-btn">Fetch voices</button>
|
||||||
<select id="tts-voice-select"><option value="">— select after fetch —</option></select>
|
<select id="tts-voice-select"><option value="">— select after fetch —</option></select>
|
||||||
@ -40,8 +34,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="note" style="margin-top:-4px;margin-bottom:10px">After changing active voices, restart the TTS container so the engine reads the updated voice folder.</p>
|
<div class="backend-help" id="tts-backend-help" aria-live="polite" style="margin-top:10px">
|
||||||
<div class="preview-match-panel" id="preview-match-panel" hidden>
|
<strong>Checking available TTS backends…</strong>
|
||||||
|
</div>
|
||||||
|
<!-- Reference voice panel (shown when the selected voice matches the library) -->
|
||||||
|
<div class="preview-match-panel" id="preview-match-panel" hidden style="margin-top:12px">
|
||||||
<div class="preview-match-meta">
|
<div class="preview-match-meta">
|
||||||
<div class="preview-match-title" id="preview-match-title">Reference voice</div>
|
<div class="preview-match-title" id="preview-match-title">Reference voice</div>
|
||||||
<div class="preview-match-detail" id="preview-match-detail"></div>
|
<div class="preview-match-detail" id="preview-match-detail"></div>
|
||||||
@ -57,17 +54,27 @@
|
|||||||
<audio id="preview-ref-audio" controls></audio>
|
<audio id="preview-ref-audio" controls></audio>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label>Target Text (text to synthesize)</label>
|
|
||||||
<textarea id="preview-text-area" placeholder="Enter the text you want to synthesize…">Hello! This is a voice preview from TTS Voice Creator - Clone and Design.</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ② Text & style ────────────────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="mdi mdi-text-long"></span> Text to synthesize</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
|
<textarea id="preview-text-area" placeholder="Enter the text you want to synthesize…" style="min-height:120px">Hello! This is a voice preview from TTS Voice Creator - Clone and Design.</textarea>
|
||||||
<input type="text" id="preview-style-instruction" placeholder="Optional style/emotion instruction, e.g. speak slowly and calmly, excited tone">
|
|
||||||
<span class="note">This is sent as <code>instruct</code>. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row" style="gap:20px">
|
<div class="field" style="margin-top:10px">
|
||||||
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating (requires persona saved on the voice)">
|
<label style="display:flex;align-items:center;gap:8px">
|
||||||
|
Style instruction
|
||||||
|
<span style="font-weight:400;color:var(--subtext)">(optional)</span>
|
||||||
|
<span id="preview-style-support" class="backend-tag" style="font-size:11px;display:none"></span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="preview-style-instruction" placeholder="e.g. whisper softly, sound excited, speak very slowly">
|
||||||
|
<span id="preview-style-warn" class="note" style="color:var(--yellow);display:none">
|
||||||
|
<span class="mdi mdi-alert-outline"></span> The selected backend has <strong>weak style support</strong> — this instruction may be ignored. Switch to a <em>style-aware</em> backend (CustomVoice, VoiceDesign) for full effect.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="gap:20px;margin-top:8px">
|
||||||
|
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating">
|
||||||
<input type="checkbox" id="preview-persona-toggle">
|
<input type="checkbox" id="preview-persona-toggle">
|
||||||
<span>Apply character persona</span>
|
<span>Apply character persona</span>
|
||||||
<span class="note" style="margin-left:2px">(LLM rewrite)</span>
|
<span class="note" style="margin-left:2px">(LLM rewrite)</span>
|
||||||
@ -78,14 +85,19 @@
|
|||||||
<span class="note" style="margin-left:2px">(long text)</span>
|
<span class="note" style="margin-left:2px">(long text)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
</div>
|
||||||
<button class="btn-primary" id="preview-btn"><span class="mdi mdi-play"></span> Generate & play</button>
|
|
||||||
|
<!-- ③ Generate ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="btn-row" style="gap:10px;flex-wrap:wrap">
|
||||||
|
<button class="btn-primary" id="preview-btn" style="padding:10px 24px;font-size:15px"><span class="mdi mdi-play"></span> Generate & play</button>
|
||||||
<button class="btn-secondary" id="save-preview-mp3-btn" disabled><span class="mdi mdi-download"></span> Save MP3</button>
|
<button class="btn-secondary" id="save-preview-mp3-btn" disabled><span class="mdi mdi-download"></span> Save MP3</button>
|
||||||
<button class="btn-secondary" id="save-preview-btn" disabled><span class="mdi mdi-download"></span> Save WAV</button>
|
<button class="btn-secondary" id="save-preview-btn" disabled><span class="mdi mdi-download"></span> Save WAV</button>
|
||||||
<button class="btn-secondary" id="add-to-playlist-btn" disabled><span class="mdi mdi-plus"></span> Playlist</button>
|
<button class="btn-secondary" id="add-to-playlist-btn" disabled><span class="mdi mdi-plus"></span> Playlist</button>
|
||||||
</div>
|
</div>
|
||||||
<audio id="preview-audio" controls style="display:none"></audio>
|
<audio id="preview-audio" controls style="display:none;margin-top:12px;width:100%"></audio>
|
||||||
<div id="preview-chunk-progress" class="chunk-progress" hidden></div>
|
<div id="preview-chunk-progress" class="chunk-progress" hidden></div>
|
||||||
|
<p class="note" style="margin-top:8px">After changing active voices, restart the TTS container so the engine reads the updated voice folder.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio Effects -->
|
<!-- Audio Effects -->
|
||||||
|
|||||||
@ -14,10 +14,11 @@
|
|||||||
<!-- Workbench: list pane + inspector pane -->
|
<!-- Workbench: list pane + inspector pane -->
|
||||||
<div class="voices-workbench" id="tab-library">
|
<div class="voices-workbench" id="tab-library">
|
||||||
|
|
||||||
<!-- LEFT: compact voice list -->
|
<!-- LEFT: compact voice list — two cards -->
|
||||||
<div class="voices-list-pane">
|
<div class="voices-list-pane">
|
||||||
|
|
||||||
<!-- TTS synth mode + preview sentence -->
|
<!-- Card 1: TTS synth mode + preview sentence -->
|
||||||
|
<div class="vl-card vl-card-synth">
|
||||||
<div class="vl-synth-panel">
|
<div class="vl-synth-panel">
|
||||||
<div class="vl-synth-mode-row">
|
<div class="vl-synth-mode-row">
|
||||||
<span class="vl-synth-icon"><span class="mdi mdi-play"></span><span class="mdi mdi-play"></span></span>
|
<span class="vl-synth-icon"><span class="mdi mdi-play"></span><span class="mdi mdi-play"></span></span>
|
||||||
@ -44,13 +45,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /vl-card-synth -->
|
||||||
|
|
||||||
|
<!-- Card 2: filters + sort + voice list + toolbar -->
|
||||||
|
<div class="vl-card vl-card-list">
|
||||||
<div class="vl-filters">
|
<div class="vl-filters">
|
||||||
<input type="search" id="library-filter-text" placeholder="Search voices…" autocomplete="off" class="vl-search">
|
<input type="search" id="library-filter-text" placeholder="Search voices…" autocomplete="off" class="vl-search">
|
||||||
<select id="library-filter-lang" title="Language"><option value="">All</option></select>
|
<select id="library-filter-lang" title="Language"><option value="">All</option></select>
|
||||||
<select id="library-filter-type" title="Type"><option value="">All types</option></select>
|
<select id="library-filter-type" title="Type"><option value="">All types</option></select>
|
||||||
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
|
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vl-sort-bar">
|
||||||
|
<span class="vl-sort-label">Sort</span>
|
||||||
|
<select id="voice-sort-field" title="Sort field">
|
||||||
|
<option value="id">Name</option>
|
||||||
|
<option value="flag">Language</option>
|
||||||
|
<option value="gender">Gender</option>
|
||||||
|
<option value="benchmark">Speed</option>
|
||||||
|
<option value="rating">Rating</option>
|
||||||
|
<option value="duration">Duration</option>
|
||||||
|
<option value="dbfs">Volume dB</option>
|
||||||
|
<option value="enabled">Active</option>
|
||||||
|
</select>
|
||||||
|
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
|
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
|
||||||
<strong id="benchmark-confirm-title">Benchmark active voices?</strong>
|
<strong id="benchmark-confirm-title">Benchmark active voices?</strong>
|
||||||
@ -72,6 +90,7 @@
|
|||||||
<span id="voice-count" class="note"></span>
|
<span id="voice-count" class="note"></span>
|
||||||
<button class="vl-add-btn" id="add-new-voice-btn" style="display:none" aria-hidden="true" tabindex="-1" title="Add a new voice to the library">+ Add voice</button>
|
<button class="vl-add-btn" id="add-new-voice-btn" style="display:none" aria-hidden="true" tabindex="-1" title="Add a new voice to the library">+ Add voice</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /vl-card-list -->
|
||||||
|
|
||||||
</div><!-- /voices-list-pane -->
|
</div><!-- /voices-list-pane -->
|
||||||
|
|
||||||
|
|||||||
@ -316,6 +316,7 @@ audio { width: 100%; }
|
|||||||
.preview-match-panel audio { width: 100%; min-width: 0; }
|
.preview-match-panel audio { width: 100%; min-width: 0; }
|
||||||
.preview-match-transcript { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--subtext); font-family: monospace; font-size: 12px; line-height: 1.4; max-height: 88px; overflow: auto; white-space: pre-wrap; }
|
.preview-match-transcript { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--subtext); font-family: monospace; font-size: 12px; line-height: 1.4; max-height: 88px; overflow: auto; white-space: pre-wrap; }
|
||||||
@media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } }
|
@media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } }
|
||||||
|
.tryout-voice-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 12px; align-items: end; }
|
||||||
|
|
||||||
/* ── Settings pages ─────────────────────────────────────────────────────── */
|
/* ── Settings pages ─────────────────────────────────────────────────────── */
|
||||||
.s-settings-page { display:none; flex-direction:column; gap:16px; }
|
.s-settings-page { display:none; flex-direction:column; gap:16px; }
|
||||||
@ -2299,3 +2300,24 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.conv-hist-time { font-variant-numeric: tabular-nums; font-weight: 700; margin-left: auto; }
|
.conv-hist-time { font-variant-numeric: tabular-nums; font-weight: 700; margin-left: auto; }
|
||||||
.conv-hist-ok { color: var(--green); }
|
.conv-hist-ok { color: var(--green); }
|
||||||
.conv-hist-err { color: var(--red); }
|
.conv-hist-err { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Script Rehearser ───────────────────────────────────────────────────── */
|
||||||
|
.reh-phase[hidden] { display: none; }
|
||||||
|
.reh-input-row { display: grid; grid-template-columns: 1fr 200px; gap: 14px; align-items: start; }
|
||||||
|
.reh-input-row textarea { min-height: 220px; font-family: monospace; font-size: 13px; width: 100%; resize: vertical; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--text); }
|
||||||
|
.reh-input-side { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.reh-cast-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.reh-cast-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--panel); border-radius: 6px; flex-wrap: wrap; }
|
||||||
|
.reh-cast-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.reh-cast-name { font-weight: 700; font-size: 14px; }
|
||||||
|
.reh-me-toggle { margin-left: auto; }
|
||||||
|
.reh-progress-bar-wrap { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
|
||||||
|
.reh-progress-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width .3s ease; width: 0%; }
|
||||||
|
.reh-progress-label { font-size: 12px; color: var(--subtext); margin-bottom: 14px; }
|
||||||
|
.reh-line-card { border: 2px solid var(--accent); border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; background: rgba(137,180,250,.05); }
|
||||||
|
.reh-line-speaker { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 6px; }
|
||||||
|
.reh-line-text { font-size: 18px; line-height: 1.5; color: var(--text); }
|
||||||
|
.reh-tts-panel, .reh-record-panel { padding: 12px; background: var(--panel); border-radius: 6px; }
|
||||||
|
.reh-summary-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.reh-summary-row { display: flex; align-items: flex-start; gap: 10px; padding: 10px; background: var(--panel); border-radius: 6px; }
|
||||||
|
@media (max-width: 700px) { .reh-input-row { grid-template-columns: 1fr; } }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user