## 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>
216 lines
7.5 KiB
JavaScript
216 lines
7.5 KiB
JavaScript
// ── Integration samples ──────────────────────────────────────────────────
|
|
|
|
function cleanBaseUrl(url) {
|
|
return String(url || '').trim().replace(/\/+$/, '');
|
|
}
|
|
function getTtsBaseUrl() {
|
|
return cleanBaseUrl($('s-tts-url')?.value) || 'http://localhost:8020';
|
|
}
|
|
function getTtsV1Url() {
|
|
const base = getTtsBaseUrl();
|
|
return base.endsWith('/v1') ? base : base + '/v1';
|
|
}
|
|
function getTtsStreamBaseUrl() {
|
|
return cleanBaseUrl($('s-tts-stream-url')?.value || _appSettings.tts_stream_url) || 'http://localhost:8023';
|
|
}
|
|
function getTtsStreamV1Url() {
|
|
const base = getTtsStreamBaseUrl();
|
|
return base.endsWith('/v1') ? base : base + '/v1';
|
|
}
|
|
function getCreatorV1Url() {
|
|
const loc = window.location;
|
|
const protocol = loc.protocol || 'http:';
|
|
const port = loc.port ? ':' + loc.port : '';
|
|
const host = loc.hostname === '0.0.0.0' ? 'localhost' : loc.hostname;
|
|
return `${protocol}//${host}${port}/v1`;
|
|
}
|
|
function updateCreatorUrlHints() {
|
|
const warning = $('routing-url-warning');
|
|
const badBindHost = window.location.hostname === '0.0.0.0';
|
|
if (warning) warning.classList.toggle('show', badBindHost);
|
|
}
|
|
function activeVoiceIds() {
|
|
return (_voices || [])
|
|
.filter(v => v.enabled !== false)
|
|
.slice()
|
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
.map(v => v.id);
|
|
}
|
|
function integrationVoiceExample() {
|
|
return activeVoiceIds()[0] || 'EN_F_ExampleVoice';
|
|
}
|
|
function integrationVoiceList() {
|
|
const ids = activeVoiceIds();
|
|
return ids.length ? ids.join(', ') : 'EN_F_ExampleVoice, DE_M_ExampleVoice';
|
|
}
|
|
function virtualDesignVoiceIds() {
|
|
return Object.keys(loadDesignPresets ? loadDesignPresets() : {})
|
|
.sort((a,b)=>a.localeCompare(b))
|
|
.map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, ''));
|
|
}
|
|
function renderIntegrationSnippets() {
|
|
if (!$('snippet-sillytavern')) return;
|
|
const base = getTtsBaseUrl();
|
|
const v1 = getTtsV1Url();
|
|
const streamV1 = getTtsStreamV1Url();
|
|
const proxyV1 = getCreatorV1Url();
|
|
const proxyBase = proxyV1.replace(/\/v1$/, '');
|
|
updateCreatorUrlHints();
|
|
const voice = integrationVoiceExample();
|
|
const voices = integrationVoiceList();
|
|
const vdVoices = virtualDesignVoiceIds();
|
|
const vdVoice = vdVoices[0] || 'vd_EN_F_Warm_Narrator';
|
|
$('integration-url-label').textContent = 'TTS backend: ' + base;
|
|
$('snippet-sillytavern').textContent =
|
|
`Provider: OpenAI compatible TTS
|
|
API base URL: ${v1}
|
|
API key: dummy
|
|
Model: qwen3-tts
|
|
Voice: ${voice}
|
|
Custom voices: ${voices}
|
|
|
|
Streaming backend, if your SillyTavern TTS extension supports progressive playback:
|
|
API base URL: ${streamV1}
|
|
Endpoint: /audio/speech
|
|
Format: wav`;
|
|
$('snippet-streaming-howto').textContent =
|
|
`Direct streaming backend, no creator routing:
|
|
API base URL: ${streamV1}
|
|
Endpoint: /audio/speech
|
|
Model: tts-1
|
|
Voice: ${voice}
|
|
Response format: wav
|
|
Requirement: the app must start playback while the HTTP response is still arriving.
|
|
Current 8023 streaming service ignores per-request instruct/style text.
|
|
|
|
Streaming through TTS Voice Creator routing:
|
|
API base URL: ${proxyV1}
|
|
Voice: default or another incoming route voice
|
|
Routing tab: set Backend = Streaming for the matching rule
|
|
Response format: wav
|
|
Avoid before/after sounds for true streaming; route sounds and MP3 require buffering.
|
|
|
|
SillyTavern note:
|
|
Use OpenAI-compatible TTS if your extension supports progressive audio responses. If it waits for the whole file before playing, streaming works technically but will feel like buffered TTS.`;
|
|
$('snippet-open-webui').textContent =
|
|
`Admin settings -> Audio -> Text-to-Speech
|
|
Engine/provider: OpenAI compatible
|
|
API base URL: ${proxyV1}
|
|
API key: dummy
|
|
Model: tts-1
|
|
Voice: default
|
|
|
|
Routing tab example:
|
|
default + EN -> ${voice}
|
|
default + DE -> DE_M_YourGermanVoice`;
|
|
$('snippet-home-assistant').textContent =
|
|
`Home Assistant OpenAI TTS agent:
|
|
Base URL: ${proxyV1}
|
|
API key: dummy
|
|
Model: tts-1
|
|
Voice: default
|
|
Extra JSON payload: {"app":"Home Assistant"}
|
|
Audio format: mp3 or wav
|
|
|
|
REST command example using routing:
|
|
rest_command:
|
|
routed_tts:
|
|
url: "${proxyV1}/audio/speech"
|
|
method: POST
|
|
content_type: "application/json"
|
|
headers:
|
|
Authorization: "Bearer dummy"
|
|
payload: >
|
|
{"model":"tts-1","voice":"default","input":"{{ text }}","response_format":"mp3","app":"Home Assistant"}
|
|
|
|
Important: use this Creator proxy URL, not the direct Qwen3 backend URL ${v1}. Routing only runs through the Creator proxy.`;
|
|
$('snippet-curl').textContent =
|
|
`curl -s "${v1}/models"
|
|
|
|
curl -s "${v1}/audio/speech" \\
|
|
-H "Authorization: Bearer dummy" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"model":"qwen3-tts","voice":"${voice}","input":"Hello from Qwen3 TTS","response_format":"wav"}' \\
|
|
--output qwen3-tts-test.wav`;
|
|
$('snippet-voice-design-proxy').textContent =
|
|
`Virtual VoiceDesign mode - no WAV export needed
|
|
|
|
Use this app as the OpenAI-compatible TTS endpoint:
|
|
API base URL: ${proxyV1}
|
|
API key: dummy
|
|
Model: tts-1
|
|
Voice: ${vdVoice}
|
|
|
|
Available virtual voices:
|
|
${vdVoices.length ? vdVoices.join(', ') : 'Save a Voice Design prompt preset first.'}
|
|
|
|
curl -s "${proxyV1}/models"
|
|
|
|
curl -s "${proxyV1}/audio/speech" \\
|
|
-H "Authorization: Bearer dummy" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"model":"tts-1","voice":"${vdVoice}","input":"This line is generated through the VoiceDesign container.","response_format":"wav"}' \\
|
|
--output voicedesign-virtual.wav`;
|
|
|
|
if ($('snippet-mcp-claude-cmd')) $('snippet-mcp-claude-cmd').textContent =
|
|
`claude mcp add voice-creator \\
|
|
--transport http \\
|
|
--url ${proxyBase}/mcp \\
|
|
--header "X-Voice-Creator-Client-Id: claude-code"`;
|
|
|
|
if ($('snippet-mcp-claude-config')) $('snippet-mcp-claude-config').textContent =
|
|
`{
|
|
"mcpServers": {
|
|
"voice-creator": {
|
|
"url": "${proxyBase}/mcp",
|
|
"headers": { "X-Voice-Creator-Client-Id": "my-agent" }
|
|
}
|
|
}
|
|
}`;
|
|
|
|
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 =
|
|
`# Bind your client ID to a voice once (persisted in Settings)
|
|
curl -X PUT ${proxyBase}/speak/bindings/my-script \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"voice":"${voice}"}'
|
|
|
|
# Generate speech — voice from binding, persona rewrite optional
|
|
curl -X POST ${proxyBase}/speak \\
|
|
-H "Content-Type: application/json" \\
|
|
-H "X-Voice-Creator-Client-Id: my-script" \\
|
|
-d '{"text":"Hello world","apply_persona":true}' \\
|
|
--output speech.wav
|
|
|
|
# Or specify voice directly
|
|
curl -X POST ${proxyBase}/speak \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"text":"Hello world","voice":"${voice}"}' \\
|
|
--output speech.wav`;
|
|
|
|
}
|
|
document.querySelectorAll('.copy-snippet').forEach(btn => btn.addEventListener('click', async () => {
|
|
const el = $(btn.dataset.snippet);
|
|
if (!el) return;
|
|
await copyText(el.textContent);
|
|
toast('Snippet copied', 'success');
|
|
}));
|
|
$('show-api-btn')?.addEventListener('click', () => {
|
|
window.open('/docs', '_blank', 'noopener');
|
|
});
|
|
$('integration-refresh-btn')?.addEventListener('click', () => {
|
|
renderIntegrationSnippets();
|
|
toast('Integration examples refreshed', 'success');
|
|
});
|
|
$('copy-active-voices-btn-integrations')?.addEventListener('click', async () => {
|
|
const active = activeVoiceIds();
|
|
if (!active.length) { toast('No active voices to copy', 'error'); return; }
|
|
await copyText(active.join(', '));
|
|
toast('Copied ' + active.length + ' active voices', 'success');
|
|
status('Copied active voices to clipboard');
|
|
});
|
|
|