mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Initial commit
This commit is contained in:
151
public/scripts/extensions/tts/edge.js
Normal file
151
public/scripts/extensions/tts/edge.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { getRequestHeaders } from "../../../script.js"
|
||||
import { getApiUrl } from "../../extensions.js"
|
||||
import { doExtrasFetch, modules } from "../../extensions.js"
|
||||
import { getPreviewString } from "./index.js"
|
||||
|
||||
export { EdgeTtsProvider }
|
||||
|
||||
class EdgeTtsProvider {
|
||||
//########//
|
||||
// Config //
|
||||
//########//
|
||||
|
||||
settings
|
||||
voices = []
|
||||
separator = ' . '
|
||||
audioElement = document.createElement('audio')
|
||||
|
||||
defaultSettings = {
|
||||
voiceMap: {},
|
||||
rate: 0,
|
||||
}
|
||||
|
||||
get settingsHtml() {
|
||||
let html = `Microsoft Edge TTS Provider<br>
|
||||
<label for="edge_tts_rate">Rate: <span id="edge_tts_rate_output"></span></label>
|
||||
<input id="edge_tts_rate" type="range" value="${this.defaultSettings.rate}" min="-100" max="100" step="1" />`
|
||||
return html
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
this.settings.rate = Number($('#edge_tts_rate').val());
|
||||
$('#edge_tts_rate_output').text(this.settings.rate);
|
||||
}
|
||||
|
||||
loadSettings(settings) {
|
||||
// Pupulate Provider UI given input settings
|
||||
if (Object.keys(settings).length == 0) {
|
||||
console.info("Using default TTS Provider settings")
|
||||
}
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings
|
||||
|
||||
for (const key in settings) {
|
||||
if (key in this.settings) {
|
||||
this.settings[key] = settings[key]
|
||||
} else {
|
||||
throw `Invalid setting passed to TTS Provider: ${key}`
|
||||
}
|
||||
}
|
||||
|
||||
$('#edge_tts_rate').val(this.settings.rate || 0);
|
||||
$('#edge_tts_rate_output').text(this.settings.rate || 0);
|
||||
|
||||
console.info("Settings loaded")
|
||||
}
|
||||
|
||||
|
||||
async onApplyClick() {
|
||||
return
|
||||
}
|
||||
|
||||
//#################//
|
||||
// TTS Interfaces //
|
||||
//#################//
|
||||
|
||||
async getVoice(voiceName) {
|
||||
if (this.voices.length == 0) {
|
||||
this.voices = await this.fetchTtsVoiceIds()
|
||||
}
|
||||
const match = this.voices.filter(
|
||||
voice => voice.name == voiceName
|
||||
)[0]
|
||||
if (!match) {
|
||||
throw `TTS Voice name ${voiceName} not found`
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
async generateTts(text, voiceId) {
|
||||
const response = await this.fetchTtsGeneration(text, voiceId)
|
||||
return response
|
||||
}
|
||||
|
||||
//###########//
|
||||
// API CALLS //
|
||||
//###########//
|
||||
async fetchTtsVoiceIds() {
|
||||
throwIfModuleMissing()
|
||||
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = `/api/edge-tts/list`
|
||||
const response = await doExtrasFetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
let responseJson = await response.json()
|
||||
responseJson = responseJson
|
||||
.sort((a, b) => a.Locale.localeCompare(b.Locale) || a.ShortName.localeCompare(b.ShortName))
|
||||
.map(x => ({ name: x.ShortName, voice_id: x.ShortName, preview_url: false, lang: x.Locale }));
|
||||
return responseJson
|
||||
}
|
||||
|
||||
|
||||
async previewTtsVoice(id) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.currentTime = 0;
|
||||
const voice = await this.getVoice(id);
|
||||
const text = getPreviewString(voice.lang);
|
||||
const response = await this.fetchTtsGeneration(text, id)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
const audio = await response.blob();
|
||||
const url = URL.createObjectURL(audio);
|
||||
this.audioElement.src = url;
|
||||
this.audioElement.play();
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(inputText, voiceId) {
|
||||
throwIfModuleMissing()
|
||||
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`)
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = `/api/edge-tts/generate`;
|
||||
const response = await doExtrasFetch(url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
"text": inputText,
|
||||
"voice": voiceId,
|
||||
"rate": Number(this.settings.rate),
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
function throwIfModuleMissing() {
|
||||
if (!modules.includes('edge-tts')) {
|
||||
toastr.error(`Edge TTS module not loaded. Add edge-tts to enable-modules and restart the Extras API.`)
|
||||
throw new Error(`Edge TTS module not loaded.`)
|
||||
}
|
||||
}
|
||||
|
231
public/scripts/extensions/tts/elevenlabs.js
Normal file
231
public/scripts/extensions/tts/elevenlabs.js
Normal file
@@ -0,0 +1,231 @@
|
||||
export { ElevenLabsTtsProvider }
|
||||
|
||||
class ElevenLabsTtsProvider {
|
||||
//########//
|
||||
// Config //
|
||||
//########//
|
||||
|
||||
settings
|
||||
voices = []
|
||||
separator = ' ... ... ... '
|
||||
|
||||
get settings() {
|
||||
return this.settings
|
||||
}
|
||||
|
||||
defaultSettings = {
|
||||
stability: 0.75,
|
||||
similarity_boost: 0.75,
|
||||
apiKey: "",
|
||||
multilingual: false,
|
||||
voiceMap: {}
|
||||
}
|
||||
|
||||
get settingsHtml() {
|
||||
let html = `
|
||||
<label for="elevenlabs_tts_api_key">API Key</label>
|
||||
<input id="elevenlabs_tts_api_key" type="text" class="text_pole" placeholder="<API Key>"/>
|
||||
<label for="elevenlabs_tts_stability">Stability: <span id="elevenlabs_tts_stability_output"></span></label>
|
||||
<input id="elevenlabs_tts_stability" type="range" value="${this.defaultSettings.stability}" min="0" max="1" step="0.05" />
|
||||
<label for="elevenlabs_tts_similarity_boost">Similarity Boost: <span id="elevenlabs_tts_similarity_boost_output"></span></label>
|
||||
<input id="elevenlabs_tts_similarity_boost" type="range" value="${this.defaultSettings.similarity_boost}" min="0" max="1" step="0.05" />
|
||||
<label class="checkbox_label" for="elevenlabs_tts_multilingual">
|
||||
<input id="elevenlabs_tts_multilingual" type="checkbox" value="${this.defaultSettings.multilingual}" />
|
||||
Enable Multilingual
|
||||
</label>
|
||||
`
|
||||
return html
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
// Update dynamically
|
||||
this.settings.stability = $('#elevenlabs_tts_stability').val()
|
||||
this.settings.similarity_boost = $('#elevenlabs_tts_similarity_boost').val()
|
||||
this.settings.multilingual = $('#elevenlabs_tts_multilingual').prop('checked')
|
||||
}
|
||||
|
||||
|
||||
loadSettings(settings) {
|
||||
// Pupulate Provider UI given input settings
|
||||
if (Object.keys(settings).length == 0) {
|
||||
console.info("Using default TTS Provider settings")
|
||||
}
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings
|
||||
|
||||
for (const key in settings){
|
||||
if (key in this.settings){
|
||||
this.settings[key] = settings[key]
|
||||
} else {
|
||||
throw `Invalid setting passed to TTS Provider: ${key}`
|
||||
}
|
||||
}
|
||||
|
||||
$('#elevenlabs_tts_stability').val(this.settings.stability)
|
||||
$('#elevenlabs_tts_similarity_boost').val(this.settings.similarity_boost)
|
||||
$('#elevenlabs_tts_api_key').val(this.settings.apiKey)
|
||||
$('#tts_auto_generation').prop('checked', this.settings.multilingual)
|
||||
console.info("Settings loaded")
|
||||
}
|
||||
|
||||
async onApplyClick() {
|
||||
// Update on Apply click
|
||||
return await this.updateApiKey().catch( (error) => {
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async updateApiKey() {
|
||||
// Using this call to validate API key
|
||||
this.settings.apiKey = $('#elevenlabs_tts_api_key').val()
|
||||
|
||||
await this.fetchTtsVoiceIds().catch(error => {
|
||||
throw `TTS API key validation failed`
|
||||
})
|
||||
this.settings.apiKey = this.settings.apiKey
|
||||
console.debug(`Saved new API_KEY: ${this.settings.apiKey}`)
|
||||
}
|
||||
|
||||
//#################//
|
||||
// TTS Interfaces //
|
||||
//#################//
|
||||
|
||||
async getVoice(voiceName) {
|
||||
if (this.voices.length == 0) {
|
||||
this.voices = await this.fetchTtsVoiceIds()
|
||||
}
|
||||
const match = this.voices.filter(
|
||||
elevenVoice => elevenVoice.name == voiceName
|
||||
)[0]
|
||||
if (!match) {
|
||||
throw `TTS Voice name ${voiceName} not found in ElevenLabs account`
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
|
||||
async generateTts(text, voiceId){
|
||||
const historyId = await this.findTtsGenerationInHistory(text, voiceId)
|
||||
|
||||
let response
|
||||
if (historyId) {
|
||||
console.debug(`Found existing TTS generation with id ${historyId}`)
|
||||
response = await this.fetchTtsFromHistory(historyId)
|
||||
} else {
|
||||
console.debug(`No existing TTS generation found, requesting new generation`)
|
||||
response = await this.fetchTtsGeneration(text, voiceId)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
//###################//
|
||||
// Helper Functions //
|
||||
//###################//
|
||||
|
||||
async findTtsGenerationInHistory(message, voiceId) {
|
||||
const ttsHistory = await this.fetchTtsHistory()
|
||||
for (const history of ttsHistory) {
|
||||
const text = history.text
|
||||
const itemId = history.history_item_id
|
||||
if (message === text && history.voice_id == voiceId) {
|
||||
console.info(`Existing TTS history item ${itemId} found: ${text} `)
|
||||
return itemId
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
//###########//
|
||||
// API CALLS //
|
||||
//###########//
|
||||
async fetchTtsVoiceIds() {
|
||||
const headers = {
|
||||
'xi-api-key': this.settings.apiKey
|
||||
}
|
||||
const response = await fetch(`https://api.elevenlabs.io/v1/voices`, {
|
||||
headers: headers
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
const responseJson = await response.json()
|
||||
return responseJson.voices
|
||||
}
|
||||
|
||||
async fetchTtsVoiceSettings() {
|
||||
const headers = {
|
||||
'xi-api-key': this.settings.apiKey
|
||||
}
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/voices/settings/default`,
|
||||
{
|
||||
headers: headers
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(text, voiceId) {
|
||||
let model = "eleven_monolingual_v1"
|
||||
if (this.settings.multilingual == true) {
|
||||
model = "eleven_multilingual_v1"
|
||||
}
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`)
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': this.settings.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
text: text,
|
||||
voice_settings: this.settings
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async fetchTtsFromHistory(history_item_id) {
|
||||
console.info(`Fetched existing TTS with history_item_id ${history_item_id}`)
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/history/${history_item_id}/audio`,
|
||||
{
|
||||
headers: {
|
||||
'xi-api-key': this.settings.apiKey
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async fetchTtsHistory() {
|
||||
const headers = {
|
||||
'xi-api-key': this.settings.apiKey
|
||||
}
|
||||
const response = await fetch(`https://api.elevenlabs.io/v1/history`, {
|
||||
headers: headers
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
const responseJson = await response.json()
|
||||
return responseJson.history
|
||||
}
|
||||
}
|
706
public/scripts/extensions/tts/index.js
Normal file
706
public/scripts/extensions/tts/index.js
Normal file
@@ -0,0 +1,706 @@
|
||||
import { callPopup, cancelTtsPlay, eventSource, event_types, isMultigenEnabled, is_send_press, saveSettingsDebounced } from '../../../script.js'
|
||||
import { ModuleWorkerWrapper, extension_settings, getContext } from '../../extensions.js'
|
||||
import { escapeRegex, getStringHash } from '../../utils.js'
|
||||
import { EdgeTtsProvider } from './edge.js'
|
||||
import { ElevenLabsTtsProvider } from './elevenlabs.js'
|
||||
import { SileroTtsProvider } from './silerotts.js'
|
||||
import { SystemTtsProvider } from './system.js'
|
||||
import { NovelTtsProvider } from './novel.js'
|
||||
import { isMobile } from '../../RossAscends-mods.js'
|
||||
import { power_user } from '../../power-user.js'
|
||||
|
||||
const UPDATE_INTERVAL = 1000
|
||||
|
||||
let voiceMap = {} // {charName:voiceid, charName2:voiceid2}
|
||||
let audioControl
|
||||
|
||||
let lastCharacterId = null
|
||||
let lastGroupId = null
|
||||
let lastChatId = null
|
||||
let lastMessageHash = null
|
||||
|
||||
export function getPreviewString(lang) {
|
||||
const previewStrings = {
|
||||
'en-US': 'The quick brown fox jumps over the lazy dog',
|
||||
'en-GB': 'Sphinx of black quartz, judge my vow',
|
||||
'fr-FR': 'Portez ce vieux whisky au juge blond qui fume',
|
||||
'de-DE': 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich',
|
||||
'it-IT': "Pranzo d'acqua fa volti sghembi",
|
||||
'es-ES': 'Quiere la boca exhausta vid, kiwi, piña y fugaz jamón',
|
||||
'es-MX': 'Fabio me exige, sin tapujos, que añada cerveza al whisky',
|
||||
'ru-RU': 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!',
|
||||
'pt-BR': 'Vejo xá gritando que fez show sem playback.',
|
||||
'pt-PR': 'Todo pajé vulgar faz boquinha sexy com kiwi.',
|
||||
'uk-UA': "Фабрикуймо гідність, лящім їжею, ґав хапаймо, з'єднавці чаш!",
|
||||
'pl-PL': 'Pchnąć w tę łódź jeża lub ośm skrzyń fig',
|
||||
'cs-CZ': 'Příliš žluťoučký kůň úpěl ďábelské ódy',
|
||||
'sk-SK': 'Vyhŕňme si rukávy a vyprážajme čínske ryžové cestoviny',
|
||||
'hu-HU': 'Árvíztűrő tükörfúrógép',
|
||||
'tr-TR': 'Pijamalı hasta yağız şoföre çabucak güvendi',
|
||||
'nl-NL': 'De waard heeft een kalfje en een pinkje opgegeten',
|
||||
'sv-SE': 'Yxskaftbud, ge vårbygd, zinkqvarn',
|
||||
'da-DK': 'Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen Walther spillede på xylofon',
|
||||
'ja-JP': 'いろはにほへと ちりぬるを わかよたれそ つねならむ うゐのおくやま けふこえて あさきゆめみし ゑひもせす',
|
||||
'ko-KR': '가나다라마바사아자차카타파하',
|
||||
'zh-CN': '我能吞下玻璃而不伤身体',
|
||||
'ro-RO': 'Muzicologă în bej vând whisky și tequila, preț fix',
|
||||
'bg-BG': 'Щъркелите се разпръснаха по цялото небе',
|
||||
'el-GR': 'Ταχίστη αλώπηξ βαφής ψημένη γη, δρασκελίζει υπέρ νωθρού κυνός',
|
||||
'fi-FI': 'Voi veljet, miksi juuri teille myin nämä vehkeet?',
|
||||
'he-IL': 'הקצינים צעקו: "כל הכבוד לצבא הצבאות!"',
|
||||
'id-ID': 'Jangkrik itu memang enak, apalagi kalau digoreng',
|
||||
'ms-MY': 'Muzik penyanyi wanita itu menggambarkan kehidupan yang penuh dengan duka nestapa',
|
||||
'th-TH': 'เป็นไงบ้างครับ ผมชอบกินข้าวผัดกระเพราหมูกรอบ',
|
||||
'vi-VN': 'Cô bé quàng khăn đỏ đang ngồi trên bãi cỏ xanh',
|
||||
'ar-SA': 'أَبْجَدِيَّة عَرَبِيَّة',
|
||||
'hi-IN': 'श्वेता ने श्वेता के श्वेते हाथों में श्वेता का श्वेता चावल पकड़ा',
|
||||
}
|
||||
const fallbackPreview = 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet'
|
||||
|
||||
return previewStrings[lang] ?? fallbackPreview;
|
||||
}
|
||||
|
||||
let ttsProviders = {
|
||||
ElevenLabs: ElevenLabsTtsProvider,
|
||||
Silero: SileroTtsProvider,
|
||||
System: SystemTtsProvider,
|
||||
Edge: EdgeTtsProvider,
|
||||
Novel: NovelTtsProvider,
|
||||
}
|
||||
let ttsProvider
|
||||
let ttsProviderName
|
||||
|
||||
async function onNarrateOneMessage() {
|
||||
audioElement.src = '/sounds/silence.mp3';
|
||||
const context = getContext();
|
||||
const id = $(this).closest('.mes').attr('mesid');
|
||||
const message = context.chat[id];
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetTtsPlayback()
|
||||
ttsJobQueue.push(message);
|
||||
moduleWorker();
|
||||
}
|
||||
|
||||
async function moduleWorker() {
|
||||
// Primarily determining when to add new chat to the TTS queue
|
||||
const enabled = $('#tts_enabled').is(':checked')
|
||||
$('body').toggleClass('tts', enabled);
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const context = getContext()
|
||||
const chat = context.chat
|
||||
|
||||
processTtsQueue()
|
||||
processAudioJobQueue()
|
||||
updateUiAudioPlayState()
|
||||
|
||||
// Auto generation is disabled
|
||||
if (extension_settings.tts.auto_generation == false) {
|
||||
return
|
||||
}
|
||||
|
||||
// no characters or group selected
|
||||
if (!context.groupId && context.characterId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// Multigen message is currently being generated
|
||||
if (is_send_press && isMultigenEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat changed
|
||||
if (
|
||||
context.chatId !== lastChatId
|
||||
) {
|
||||
currentMessageNumber = context.chat.length ? context.chat.length : 0
|
||||
saveLastValues()
|
||||
return
|
||||
}
|
||||
|
||||
// take the count of messages
|
||||
let lastMessageNumber = context.chat.length ? context.chat.length : 0
|
||||
|
||||
// There's no new messages
|
||||
let diff = lastMessageNumber - currentMessageNumber
|
||||
let hashNew = getStringHash((chat.length && chat[chat.length - 1].mes) ?? '')
|
||||
|
||||
if (diff == 0 && hashNew === lastMessageHash) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = chat[chat.length - 1]
|
||||
|
||||
// We're currently swiping or streaming. Don't generate voice
|
||||
if (
|
||||
!message ||
|
||||
message.mes === '...' ||
|
||||
message.mes === '' ||
|
||||
(context.streamingProcessor && !context.streamingProcessor.isFinished)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't generate if message doesn't have a display text
|
||||
if (extension_settings.tts.narrate_translated_only && !(message?.extra?.display_text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// New messages, add new chat to history
|
||||
lastMessageHash = hashNew
|
||||
currentMessageNumber = lastMessageNumber
|
||||
|
||||
console.debug(
|
||||
`Adding message from ${message.name} for TTS processing: "${message.mes}"`
|
||||
)
|
||||
ttsJobQueue.push(message)
|
||||
}
|
||||
|
||||
|
||||
function resetTtsPlayback() {
|
||||
// Stop system TTS utterance
|
||||
cancelTtsPlay();
|
||||
|
||||
// Clear currently processing jobs
|
||||
currentTtsJob = null;
|
||||
currentAudioJob = null;
|
||||
|
||||
// Reset audio element
|
||||
audioElement.currentTime = 0;
|
||||
audioElement.src = '';
|
||||
|
||||
// Clear any queue items
|
||||
ttsJobQueue.splice(0, ttsJobQueue.length);
|
||||
audioJobQueue.splice(0, audioJobQueue.length);
|
||||
|
||||
// Set audio ready to process again
|
||||
audioQueueProcessorReady = true;
|
||||
}
|
||||
|
||||
function isTtsProcessing() {
|
||||
let processing = false
|
||||
|
||||
// Check job queues
|
||||
if (ttsJobQueue.length > 0 || audioJobQueue > 0) {
|
||||
processing = true
|
||||
}
|
||||
// Check current jobs
|
||||
if (currentTtsJob != null || currentAudioJob != null) {
|
||||
processing = true
|
||||
}
|
||||
return processing
|
||||
}
|
||||
|
||||
function debugTtsPlayback() {
|
||||
console.log(JSON.stringify(
|
||||
{
|
||||
"ttsProviderName": ttsProviderName,
|
||||
"currentMessageNumber": currentMessageNumber,
|
||||
"isWorkerBusy": isWorkerBusy,
|
||||
"audioPaused": audioPaused,
|
||||
"audioJobQueue": audioJobQueue,
|
||||
"currentAudioJob": currentAudioJob,
|
||||
"audioQueueProcessorReady": audioQueueProcessorReady,
|
||||
"ttsJobQueue": ttsJobQueue,
|
||||
"currentTtsJob": currentTtsJob,
|
||||
"ttsConfig": extension_settings.tts
|
||||
}
|
||||
))
|
||||
}
|
||||
window.debugTtsPlayback = debugTtsPlayback
|
||||
|
||||
//##################//
|
||||
// Audio Control //
|
||||
//##################//
|
||||
|
||||
let audioElement = new Audio()
|
||||
audioElement.autoplay = true
|
||||
|
||||
let audioJobQueue = []
|
||||
let currentAudioJob
|
||||
let audioPaused = false
|
||||
let audioQueueProcessorReady = true
|
||||
|
||||
let lastAudioPosition = 0
|
||||
|
||||
async function playAudioData(audioBlob) {
|
||||
// Since current audio job can be cancelled, don't playback if it is null
|
||||
if (currentAudioJob == null) {
|
||||
console.log("Cancelled TTS playback because currentAudioJob was null")
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (e) {
|
||||
const srcUrl = e.target.result
|
||||
audioElement.src = srcUrl
|
||||
}
|
||||
reader.readAsDataURL(audioBlob)
|
||||
audioElement.addEventListener('ended', completeCurrentAudioJob)
|
||||
audioElement.addEventListener('canplay', () => {
|
||||
console.debug(`Starting TTS playback`)
|
||||
audioElement.play()
|
||||
})
|
||||
}
|
||||
|
||||
window['tts_preview'] = function (id) {
|
||||
const audio = document.getElementById(id)
|
||||
|
||||
if (audio && !$(audio).data('disabled')) {
|
||||
audio.play()
|
||||
}
|
||||
else {
|
||||
ttsProvider.previewTtsVoice(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function onTtsVoicesClick() {
|
||||
let popupText = ''
|
||||
|
||||
try {
|
||||
const voiceIds = await ttsProvider.fetchTtsVoiceIds()
|
||||
|
||||
for (const voice of voiceIds) {
|
||||
popupText += `
|
||||
<div class="voice_preview">
|
||||
<span class="voice_lang">${voice.lang || ''}</span>
|
||||
<b class="voice_name">${voice.name}</b>
|
||||
<i onclick="tts_preview('${voice.voice_id}')" class="fa-solid fa-play"></i>
|
||||
</div>`
|
||||
if (voice.preview_url) {
|
||||
popupText += `<audio id="${voice.voice_id}" src="${voice.preview_url}" data-disabled="${voice.preview_url == false}"></audio>`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
popupText = 'Could not load voices list. Check your API key.'
|
||||
}
|
||||
|
||||
callPopup(popupText, 'text')
|
||||
}
|
||||
|
||||
function updateUiAudioPlayState() {
|
||||
if (extension_settings.tts.enabled == true) {
|
||||
$('#ttsExtensionMenuItem').show();
|
||||
let img
|
||||
// Give user feedback that TTS is active by setting the stop icon if processing or playing
|
||||
if (!audioElement.paused || isTtsProcessing()) {
|
||||
img = 'fa-solid fa-stop-circle extensionsMenuExtensionButton'
|
||||
} else {
|
||||
img = 'fa-solid fa-circle-play extensionsMenuExtensionButton'
|
||||
}
|
||||
$('#tts_media_control').attr('class', img);
|
||||
} else {
|
||||
$('#ttsExtensionMenuItem').hide();
|
||||
}
|
||||
}
|
||||
|
||||
function onAudioControlClicked() {
|
||||
audioElement.src = '/sounds/silence.mp3';
|
||||
let context = getContext()
|
||||
// Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
|
||||
if (!audioElement.paused || isTtsProcessing()) {
|
||||
resetTtsPlayback()
|
||||
} else {
|
||||
// Default play behavior if not processing or playing is to play the last message.
|
||||
ttsJobQueue.push(context.chat[context.chat.length - 1])
|
||||
}
|
||||
updateUiAudioPlayState()
|
||||
}
|
||||
|
||||
function addAudioControl() {
|
||||
|
||||
$('#extensionsMenu').prepend(`
|
||||
<div id="ttsExtensionMenuItem" class="list-group-item flex-container flexGap5">
|
||||
<div id="tts_media_control" class="extensionsMenuExtensionButton "/></div>
|
||||
TTS Playback
|
||||
</div>`)
|
||||
$('#ttsExtensionMenuItem').attr('title', 'TTS play/pause').on('click', onAudioControlClicked)
|
||||
audioControl = document.getElementById('tts_media_control')
|
||||
updateUiAudioPlayState()
|
||||
}
|
||||
|
||||
function completeCurrentAudioJob() {
|
||||
audioQueueProcessorReady = true
|
||||
currentAudioJob = null
|
||||
lastAudioPosition = 0
|
||||
// updateUiPlayState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback
|
||||
* @param {*} response
|
||||
*/
|
||||
async function addAudioJob(response) {
|
||||
const audioData = await response.blob()
|
||||
if (!audioData.type in ['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/webm']) {
|
||||
throw `TTS received HTTP response with invalid data format. Expecting audio/mpeg, got ${audioData.type}`
|
||||
}
|
||||
audioJobQueue.push(audioData)
|
||||
console.debug('Pushed audio job to queue.')
|
||||
}
|
||||
|
||||
async function processAudioJobQueue() {
|
||||
// Nothing to do, audio not completed, or audio paused - stop processing.
|
||||
if (audioJobQueue.length == 0 || !audioQueueProcessorReady || audioPaused) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
audioQueueProcessorReady = false
|
||||
currentAudioJob = audioJobQueue.pop()
|
||||
playAudioData(currentAudioJob)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
audioQueueProcessorReady = true
|
||||
}
|
||||
}
|
||||
|
||||
//################//
|
||||
// TTS Control //
|
||||
//################//
|
||||
|
||||
let ttsJobQueue = []
|
||||
let currentTtsJob // Null if nothing is currently being processed
|
||||
let currentMessageNumber = 0
|
||||
|
||||
function completeTtsJob() {
|
||||
console.info(`Current TTS job for ${currentTtsJob.name} completed.`)
|
||||
currentTtsJob = null
|
||||
}
|
||||
|
||||
function saveLastValues() {
|
||||
const context = getContext()
|
||||
lastGroupId = context.groupId
|
||||
lastCharacterId = context.characterId
|
||||
lastChatId = context.chatId
|
||||
lastMessageHash = getStringHash(
|
||||
(context.chat.length && context.chat[context.chat.length - 1].mes) ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function tts(text, voiceId) {
|
||||
const response = await ttsProvider.generateTts(text, voiceId)
|
||||
addAudioJob(response)
|
||||
completeTtsJob()
|
||||
}
|
||||
|
||||
async function processTtsQueue() {
|
||||
// Called each moduleWorker iteration to pull chat messages from queue
|
||||
if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {
|
||||
return
|
||||
}
|
||||
|
||||
console.debug('New message found, running TTS')
|
||||
currentTtsJob = ttsJobQueue.shift()
|
||||
let text = extension_settings.tts.narrate_translated_only ? currentTtsJob?.extra?.display_text : currentTtsJob.mes
|
||||
text = extension_settings.tts.narrate_dialogues_only
|
||||
? text.replace(/\*[^\*]*?(\*|$)/g, '').trim() // remove asterisks content
|
||||
: text.replaceAll('*', '').trim() // remove just the asterisks
|
||||
|
||||
if (extension_settings.tts.narrate_quoted_only) {
|
||||
const special_quotes = /[“”]/g; // Extend this regex to include other special quotes
|
||||
text = text.replace(special_quotes, '"');
|
||||
const matches = text.match(/".*?"/g); // Matches text inside double quotes, non-greedily
|
||||
const partJoiner = (ttsProvider?.separator || ' ... ');
|
||||
text = matches ? matches.join(partJoiner) : text;
|
||||
}
|
||||
console.log(`TTS: ${text}`)
|
||||
const char = currentTtsJob.name
|
||||
|
||||
// Remove character name from start of the line if power user setting is disabled
|
||||
if (char && !power_user.allow_name2_display) {
|
||||
const escapedChar = escapeRegex(char);
|
||||
text = text.replace(new RegExp(`^${escapedChar}:`, 'gm'), '');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!text) {
|
||||
console.warn('Got empty text in TTS queue job.');
|
||||
completeTtsJob()
|
||||
return;
|
||||
}
|
||||
|
||||
if (!voiceMap[char]) {
|
||||
throw `${char} not in voicemap. Configure character in extension settings voice map`
|
||||
}
|
||||
const voice = await ttsProvider.getVoice((voiceMap[char]))
|
||||
const voiceId = voice.voice_id
|
||||
if (voiceId == null) {
|
||||
toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`)
|
||||
throw `Unable to attain voiceId for ${char}`
|
||||
}
|
||||
tts(text, voiceId)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
currentTtsJob = null
|
||||
}
|
||||
}
|
||||
|
||||
// Secret function for now
|
||||
async function playFullConversation() {
|
||||
const context = getContext()
|
||||
const chat = context.chat
|
||||
ttsJobQueue = chat
|
||||
}
|
||||
window.playFullConversation = playFullConversation
|
||||
|
||||
//#############################//
|
||||
// Extension UI and Settings //
|
||||
//#############################//
|
||||
|
||||
function loadSettings() {
|
||||
if (Object.keys(extension_settings.tts).length === 0) {
|
||||
Object.assign(extension_settings.tts, defaultSettings)
|
||||
}
|
||||
$('#tts_enabled').prop(
|
||||
'checked',
|
||||
extension_settings.tts.enabled
|
||||
)
|
||||
$('#tts_narrate_dialogues').prop('checked', extension_settings.tts.narrate_dialogues_only)
|
||||
$('#tts_narrate_quoted').prop('checked', extension_settings.tts.narrate_quoted_only)
|
||||
$('#tts_auto_generation').prop('checked', extension_settings.tts.auto_generation)
|
||||
$('#tts_narrate_translated_only').prop('checked', extension_settings.tts.narrate_translated_only);
|
||||
$('body').toggleClass('tts', extension_settings.tts.enabled);
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
voiceMap: '',
|
||||
ttsEnabled: false,
|
||||
currentProvider: "ElevenLabs",
|
||||
auto_generation: true
|
||||
}
|
||||
|
||||
function setTtsStatus(status, success) {
|
||||
$('#tts_status').text(status)
|
||||
if (success) {
|
||||
$('#tts_status').removeAttr('style')
|
||||
} else {
|
||||
$('#tts_status').css('color', 'red')
|
||||
}
|
||||
}
|
||||
|
||||
function parseVoiceMap(voiceMapString) {
|
||||
let parsedVoiceMap = {}
|
||||
for (const [charName, voiceId] of voiceMapString
|
||||
.split(',')
|
||||
.map(s => s.split(':'))) {
|
||||
if (charName && voiceId) {
|
||||
parsedVoiceMap[charName.trim()] = voiceId.trim()
|
||||
}
|
||||
}
|
||||
return parsedVoiceMap
|
||||
}
|
||||
|
||||
async function voicemapIsValid(parsedVoiceMap) {
|
||||
let valid = true
|
||||
for (const characterName in parsedVoiceMap) {
|
||||
const parsedVoiceName = parsedVoiceMap[characterName]
|
||||
try {
|
||||
await ttsProvider.getVoice(parsedVoiceName)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
async function updateVoiceMap() {
|
||||
let isValidResult = false
|
||||
|
||||
const value = $('#tts_voice_map').val()
|
||||
const parsedVoiceMap = parseVoiceMap(value)
|
||||
|
||||
isValidResult = await voicemapIsValid(parsedVoiceMap)
|
||||
if (isValidResult) {
|
||||
ttsProvider.settings.voiceMap = String(value)
|
||||
// console.debug(`ttsProvider.voiceMap: ${ttsProvider.settings.voiceMap}`)
|
||||
voiceMap = parsedVoiceMap
|
||||
console.debug(`Saved new voiceMap: ${value}`)
|
||||
saveSettingsDebounced()
|
||||
} else {
|
||||
throw 'Voice map is invalid, check console for errors'
|
||||
}
|
||||
}
|
||||
|
||||
function onApplyClick() {
|
||||
Promise.all([
|
||||
ttsProvider.onApplyClick(),
|
||||
updateVoiceMap()
|
||||
]).then(() => {
|
||||
extension_settings.tts[ttsProviderName] = ttsProvider.settings
|
||||
saveSettingsDebounced()
|
||||
setTtsStatus('Successfully applied settings', true)
|
||||
console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`)
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
setTtsStatus(error, false)
|
||||
})
|
||||
}
|
||||
|
||||
function onEnableClick() {
|
||||
extension_settings.tts.enabled = $('#tts_enabled').is(
|
||||
':checked'
|
||||
)
|
||||
updateUiAudioPlayState()
|
||||
saveSettingsDebounced()
|
||||
}
|
||||
|
||||
function onAutoGenerationClick() {
|
||||
extension_settings.tts.auto_generation = $('#tts_auto_generation').prop('checked');
|
||||
saveSettingsDebounced()
|
||||
}
|
||||
|
||||
|
||||
function onNarrateDialoguesClick() {
|
||||
extension_settings.tts.narrate_dialogues_only = $('#tts_narrate_dialogues').prop('checked');
|
||||
saveSettingsDebounced()
|
||||
}
|
||||
|
||||
|
||||
function onNarrateQuotedClick() {
|
||||
extension_settings.tts.narrate_quoted_only = $('#tts_narrate_quoted').prop('checked');
|
||||
saveSettingsDebounced()
|
||||
}
|
||||
|
||||
|
||||
function onNarrateTranslatedOnlyClick() {
|
||||
extension_settings.tts.narrate_translated_only = $('#tts_narrate_translated_only').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
//##############//
|
||||
// TTS Provider //
|
||||
//##############//
|
||||
|
||||
function loadTtsProvider(provider) {
|
||||
//Clear the current config and add new config
|
||||
$("#tts_provider_settings").html("")
|
||||
|
||||
if (!provider) {
|
||||
provider
|
||||
}
|
||||
// Init provider references
|
||||
extension_settings.tts.currentProvider = provider
|
||||
ttsProviderName = provider
|
||||
ttsProvider = new ttsProviders[provider]
|
||||
|
||||
// Init provider settings
|
||||
$('#tts_provider_settings').append(ttsProvider.settingsHtml)
|
||||
if (!(ttsProviderName in extension_settings.tts)) {
|
||||
console.warn(`Provider ${ttsProviderName} not in Extension Settings, initiatilizing provider in settings`)
|
||||
extension_settings.tts[ttsProviderName] = {}
|
||||
}
|
||||
|
||||
// Load voicemap settings
|
||||
let voiceMapFromSettings
|
||||
if ("voiceMap" in extension_settings.tts[ttsProviderName]) {
|
||||
voiceMapFromSettings = extension_settings.tts[ttsProviderName].voiceMap
|
||||
voiceMap = parseVoiceMap(voiceMapFromSettings)
|
||||
} else {
|
||||
voiceMapFromSettings = ""
|
||||
voiceMap = {}
|
||||
}
|
||||
$('#tts_voice_map').val(voiceMapFromSettings)
|
||||
$('#tts_provider').val(ttsProviderName)
|
||||
|
||||
ttsProvider.loadSettings(extension_settings.tts[ttsProviderName])
|
||||
}
|
||||
|
||||
function onTtsProviderChange() {
|
||||
const ttsProviderSelection = $('#tts_provider').val()
|
||||
loadTtsProvider(ttsProviderSelection)
|
||||
}
|
||||
|
||||
function onTtsProviderSettingsInput() {
|
||||
ttsProvider.onSettingsChange()
|
||||
|
||||
// Persist changes to SillyTavern tts extension settings
|
||||
|
||||
extension_settings.tts[ttsProviderName] = ttsProvider.setttings
|
||||
saveSettingsDebounced()
|
||||
console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
function addExtensionControls() {
|
||||
const settingsHtml = `
|
||||
<div id="tts_settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b>TTS</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<div>
|
||||
<span>Select TTS Provider</span> </br>
|
||||
<select id="tts_provider">
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="checkbox_label" for="tts_enabled">
|
||||
<input type="checkbox" id="tts_enabled" name="tts_enabled">
|
||||
<small>Enabled</small>
|
||||
</label>
|
||||
<label class="checkbox_label" for="tts_auto_generation">
|
||||
<input type="checkbox" id="tts_auto_generation">
|
||||
<small>Auto Generation</small>
|
||||
</label>
|
||||
<label class="checkbox_label" for="tts_narrate_quoted">
|
||||
<input type="checkbox" id="tts_narrate_quoted">
|
||||
<small>Only narrate "quotes"</small>
|
||||
</label>
|
||||
<label class="checkbox_label" for="tts_narrate_dialogues">
|
||||
<input type="checkbox" id="tts_narrate_dialogues">
|
||||
<small>Ignore *text, even "quotes", inside asterisks*</small>
|
||||
</label>
|
||||
<label class="checkbox_label" for="tts_narrate_translated_only">
|
||||
<input type="checkbox" id="tts_narrate_translated_only">
|
||||
<small>Narrate only the translated text</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>Voice Map</label>
|
||||
<textarea id="tts_voice_map" type="text" class="text_pole textarea_compact" rows="4"
|
||||
placeholder="Enter comma separated map of charName:ttsName. Example: \nAqua:Bella,\nYou:Josh,"></textarea>
|
||||
|
||||
<div id="tts_status">
|
||||
</div>
|
||||
<form id="tts_provider_settings" class="inline-drawer-content">
|
||||
</form>
|
||||
<div class="tts_buttons">
|
||||
<input id="tts_apply" class="menu_button" type="submit" value="Apply" />
|
||||
<input id="tts_voices" class="menu_button" type="submit" value="Available voices" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
$('#extensions_settings').append(settingsHtml)
|
||||
$('#tts_apply').on('click', onApplyClick)
|
||||
$('#tts_enabled').on('click', onEnableClick)
|
||||
$('#tts_narrate_dialogues').on('click', onNarrateDialoguesClick);
|
||||
$('#tts_narrate_quoted').on('click', onNarrateQuotedClick);
|
||||
$('#tts_narrate_translated_only').on('click', onNarrateTranslatedOnlyClick);
|
||||
$('#tts_auto_generation').on('click', onAutoGenerationClick);
|
||||
$('#tts_voices').on('click', onTtsVoicesClick)
|
||||
$('#tts_provider_settings').on('input', onTtsProviderSettingsInput)
|
||||
for (const provider in ttsProviders) {
|
||||
$('#tts_provider').append($("<option />").val(provider).text(provider))
|
||||
}
|
||||
$('#tts_provider').on('change', onTtsProviderChange)
|
||||
$(document).on('click', '.mes_narrate', onNarrateOneMessage);
|
||||
}
|
||||
addExtensionControls() // No init dependencies
|
||||
loadSettings() // Depends on Extension Controls and loadTtsProvider
|
||||
loadTtsProvider(extension_settings.tts.currentProvider) // No dependencies
|
||||
addAudioControl() // Depends on Extension Controls
|
||||
const wrapper = new ModuleWorkerWrapper(moduleWorker);
|
||||
setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL) // Init depends on all the things
|
||||
eventSource.on(event_types.MESSAGE_SWIPED, resetTtsPlayback);
|
||||
})
|
14
public/scripts/extensions/tts/manifest.json
Normal file
14
public/scripts/extensions/tts/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"display_name": "TTS",
|
||||
"loading_order": 10,
|
||||
"requires": [],
|
||||
"optional": [
|
||||
"silero-tts",
|
||||
"edge-tts"
|
||||
],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Ouoertheo#7264",
|
||||
"version": "1.0.0",
|
||||
"homePage": "None"
|
||||
}
|
130
public/scripts/extensions/tts/novel.js
Normal file
130
public/scripts/extensions/tts/novel.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getRequestHeaders } from "../../../script.js"
|
||||
import { getPreviewString } from "./index.js"
|
||||
|
||||
export { NovelTtsProvider }
|
||||
|
||||
class NovelTtsProvider {
|
||||
//########//
|
||||
// Config //
|
||||
//########//
|
||||
|
||||
settings
|
||||
voices = []
|
||||
separator = ' . '
|
||||
audioElement = document.createElement('audio')
|
||||
|
||||
defaultSettings = {
|
||||
voiceMap: {}
|
||||
}
|
||||
|
||||
get settingsHtml() {
|
||||
let html = `Use NovelAI's TTS engine.<br>
|
||||
The Voice IDs in the preview list are only examples, as it can be any string of text. Feel free to try different options!<br>
|
||||
<small><i>Hint: Save an API key in the NovelAI API settings to use it here.</i></small>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
}
|
||||
|
||||
loadSettings(settings) {
|
||||
// Populate Provider UI given input settings
|
||||
if (Object.keys(settings).length == 0) {
|
||||
console.info("Using default TTS Provider settings")
|
||||
}
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings
|
||||
|
||||
for (const key in settings) {
|
||||
if (key in this.settings) {
|
||||
this.settings[key] = settings[key]
|
||||
} else {
|
||||
throw `Invalid setting passed to TTS Provider: ${key}`
|
||||
}
|
||||
}
|
||||
|
||||
console.info("Settings loaded")
|
||||
}
|
||||
|
||||
|
||||
async onApplyClick() {
|
||||
return
|
||||
}
|
||||
|
||||
//#################//
|
||||
// TTS Interfaces //
|
||||
//#################//
|
||||
|
||||
async getVoice(voiceName) {
|
||||
if (!voiceName) {
|
||||
throw `TTS Voice name not provided`
|
||||
}
|
||||
|
||||
return { name: voiceName, voice_id: voiceName, lang: 'en-US', preview_url: false}
|
||||
}
|
||||
|
||||
async generateTts(text, voiceId) {
|
||||
const response = await this.fetchTtsGeneration(text, voiceId)
|
||||
return response
|
||||
}
|
||||
|
||||
//###########//
|
||||
// API CALLS //
|
||||
//###########//
|
||||
async fetchTtsVoiceIds() {
|
||||
const voices = [
|
||||
{ name: 'Ligeia', voice_id: 'Ligeia', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Aini', voice_id: 'Aini', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Orea', voice_id: 'Orea', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Claea', voice_id: 'Claea', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Lim', voice_id: 'Lim', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Aurae', voice_id: 'Aurae', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Naia', voice_id: 'Naia', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Aulon', voice_id: 'Aulon', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Elei', voice_id: 'Elei', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Ogma', voice_id: 'Ogma', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Raid', voice_id: 'Raid', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Pega', voice_id: 'Pega', lang: 'en-US', preview_url: false },
|
||||
{ name: 'Lam', voice_id: 'Lam', lang: 'en-US', preview_url: false },
|
||||
];
|
||||
|
||||
return voices;
|
||||
}
|
||||
|
||||
|
||||
async previewTtsVoice(id) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.currentTime = 0;
|
||||
|
||||
const text = getPreviewString('en-US')
|
||||
const response = await this.fetchTtsGeneration(text, id)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const audio = await response.blob();
|
||||
const url = URL.createObjectURL(audio);
|
||||
this.audioElement.src = url;
|
||||
this.audioElement.play();
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(inputText, voiceId) {
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`)
|
||||
const response = await fetch(`/novel_tts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
"text": inputText,
|
||||
"voice": voiceId,
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
133
public/scripts/extensions/tts/silerotts.js
Normal file
133
public/scripts/extensions/tts/silerotts.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { doExtrasFetch, getApiUrl, modules } from "../../extensions.js"
|
||||
|
||||
export { SileroTtsProvider }
|
||||
|
||||
class SileroTtsProvider {
|
||||
//########//
|
||||
// Config //
|
||||
//########//
|
||||
|
||||
settings
|
||||
voices = []
|
||||
separator = ' .. '
|
||||
|
||||
defaultSettings = {
|
||||
provider_endpoint: "http://localhost:8001/tts",
|
||||
voiceMap: {}
|
||||
}
|
||||
|
||||
get settingsHtml() {
|
||||
let html = `
|
||||
<label for="silero_tts_endpoint">Provider Endpoint:</label>
|
||||
<input id="silero_tts_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>
|
||||
<span>
|
||||
<span>Use <a target="_blank" href="https://github.com/SillyTavern/SillyTavern-extras">SillyTavern Extras API</a> or <a target="_blank" href="https://github.com/ouoertheo/silero-api-server">Silero TTS Server</a>.</span>
|
||||
`
|
||||
return html
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
// Used when provider settings are updated from UI
|
||||
this.settings.provider_endpoint = $('#silero_tts_endpoint').val()
|
||||
}
|
||||
|
||||
loadSettings(settings) {
|
||||
// Pupulate Provider UI given input settings
|
||||
if (Object.keys(settings).length == 0) {
|
||||
console.info("Using default TTS Provider settings")
|
||||
}
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings
|
||||
|
||||
for (const key in settings){
|
||||
if (key in this.settings){
|
||||
this.settings[key] = settings[key]
|
||||
} else {
|
||||
throw `Invalid setting passed to TTS Provider: ${key}`
|
||||
}
|
||||
}
|
||||
|
||||
const apiCheckInterval = setInterval(() => {
|
||||
// Use Extras API if TTS support is enabled
|
||||
if (modules.includes('tts') || modules.includes('silero-tts')) {
|
||||
const baseUrl = new URL(getApiUrl());
|
||||
baseUrl.pathname = '/api/tts';
|
||||
this.settings.provider_endpoint = baseUrl.toString();
|
||||
$('#silero_tts_endpoint').val(this.settings.provider_endpoint);
|
||||
clearInterval(apiCheckInterval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
$('#silero_tts_endpoint').val(this.settings.provider_endpoint)
|
||||
console.info("Settings loaded")
|
||||
}
|
||||
|
||||
|
||||
async onApplyClick() {
|
||||
return
|
||||
}
|
||||
|
||||
//#################//
|
||||
// TTS Interfaces //
|
||||
//#################//
|
||||
|
||||
async getVoice(voiceName) {
|
||||
if (this.voices.length == 0) {
|
||||
this.voices = await this.fetchTtsVoiceIds()
|
||||
}
|
||||
const match = this.voices.filter(
|
||||
sileroVoice => sileroVoice.name == voiceName
|
||||
)[0]
|
||||
if (!match) {
|
||||
throw `TTS Voice name ${voiceName} not found`
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
async generateTts(text, voiceId){
|
||||
const response = await this.fetchTtsGeneration(text, voiceId)
|
||||
return response
|
||||
}
|
||||
|
||||
//###########//
|
||||
// API CALLS //
|
||||
//###########//
|
||||
async fetchTtsVoiceIds() {
|
||||
const response = await doExtrasFetch(`${this.settings.provider_endpoint}/speakers`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
|
||||
}
|
||||
const responseJson = await response.json()
|
||||
return responseJson
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(inputText, voiceId) {
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`)
|
||||
const response = await doExtrasFetch(
|
||||
`${this.settings.provider_endpoint}/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache' // Added this line to disable caching of file so new files are always played - Rolyat 7/7/23
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"text": inputText,
|
||||
"speaker": voiceId
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Interface not used by Silero TTS
|
||||
async fetchTtsFromHistory(history_item_id) {
|
||||
return Promise.resolve(history_item_id);
|
||||
}
|
||||
|
||||
}
|
53
public/scripts/extensions/tts/style.css
Normal file
53
public/scripts/extensions/tts/style.css
Normal file
@@ -0,0 +1,53 @@
|
||||
#tts_media_control {
|
||||
/* order: 100; */
|
||||
/* width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 1px; */
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* transition: 0.3s;
|
||||
opacity: 0.7; */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* justify-content: center; */
|
||||
|
||||
}
|
||||
|
||||
#ttsExtensionMenuItem {
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#ttsExtensionMenuItem:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
#tts_media_control:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.voice_preview {
|
||||
margin: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.voice_preview .voice_name {
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voice_preview .voice_lang {
|
||||
width: 4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.voice_preview .fa-play {
|
||||
cursor: pointer;
|
||||
}
|
227
public/scripts/extensions/tts/system.js
Normal file
227
public/scripts/extensions/tts/system.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { isMobile } from "../../RossAscends-mods.js";
|
||||
import { getPreviewString } from "./index.js";
|
||||
|
||||
export { SystemTtsProvider }
|
||||
|
||||
/**
|
||||
* Chunkify
|
||||
* Google Chrome Speech Synthesis Chunking Pattern
|
||||
* Fixes inconsistencies with speaking long texts in speechUtterance objects
|
||||
* Licensed under the MIT License
|
||||
*
|
||||
* Peter Woolley and Brett Zamir
|
||||
* Modified by Haaris for bug fixes
|
||||
*/
|
||||
|
||||
var speechUtteranceChunker = function (utt, settings, callback) {
|
||||
settings = settings || {};
|
||||
var newUtt;
|
||||
var txt = (settings && settings.offset !== undefined ? utt.text.substring(settings.offset) : utt.text);
|
||||
if (utt.voice && utt.voice.voiceURI === 'native') { // Not part of the spec
|
||||
newUtt = utt;
|
||||
newUtt.text = txt;
|
||||
newUtt.addEventListener('end', function () {
|
||||
if (speechUtteranceChunker.cancel) {
|
||||
speechUtteranceChunker.cancel = false;
|
||||
}
|
||||
if (callback !== undefined) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
var chunkLength = (settings && settings.chunkLength) || 160;
|
||||
var pattRegex = new RegExp('^[\\s\\S]{' + Math.floor(chunkLength / 2) + ',' + chunkLength + '}[.!?,]{1}|^[\\s\\S]{1,' + chunkLength + '}$|^[\\s\\S]{1,' + chunkLength + '} ');
|
||||
var chunkArr = txt.match(pattRegex);
|
||||
|
||||
if (chunkArr == null || chunkArr[0] === undefined || chunkArr[0].length <= 2) {
|
||||
//call once all text has been spoken...
|
||||
if (callback !== undefined) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var chunk = chunkArr[0];
|
||||
newUtt = new SpeechSynthesisUtterance(chunk);
|
||||
var x;
|
||||
for (x in utt) {
|
||||
if (utt.hasOwnProperty(x) && x !== 'text') {
|
||||
newUtt[x] = utt[x];
|
||||
}
|
||||
}
|
||||
newUtt.lang = utt.lang;
|
||||
newUtt.voice = utt.voice;
|
||||
newUtt.addEventListener('end', function () {
|
||||
if (speechUtteranceChunker.cancel) {
|
||||
speechUtteranceChunker.cancel = false;
|
||||
return;
|
||||
}
|
||||
settings.offset = settings.offset || 0;
|
||||
settings.offset += chunk.length;
|
||||
speechUtteranceChunker(utt, settings, callback);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.modifier) {
|
||||
settings.modifier(newUtt);
|
||||
}
|
||||
console.log(newUtt); //IMPORTANT!! Do not remove: Logging the object out fixes some onend firing issues.
|
||||
//placing the speak invocation inside a callback fixes ordering and onend issues.
|
||||
setTimeout(function () {
|
||||
speechSynthesis.speak(newUtt);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
class SystemTtsProvider {
|
||||
//########//
|
||||
// Config //
|
||||
//########//
|
||||
|
||||
settings
|
||||
voices = []
|
||||
separator = ' ... '
|
||||
|
||||
defaultSettings = {
|
||||
voiceMap: {},
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
}
|
||||
|
||||
get settingsHtml() {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
return "Your browser or operating system doesn't support speech synthesis";
|
||||
}
|
||||
|
||||
return `<p>Uses the voices provided by your operating system</p>
|
||||
<label for="system_tts_rate">Rate: <span id="system_tts_rate_output"></span></label>
|
||||
<input id="system_tts_rate" type="range" value="${this.defaultSettings.rate}" min="0.5" max="2" step="0.1" />
|
||||
<label for="system_tts_pitch">Pitch: <span id="system_tts_pitch_output"></span></label>
|
||||
<input id="system_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0" max="2" step="0.1" />`;
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
this.settings.rate = Number($('#system_tts_rate').val());
|
||||
this.settings.pitch = Number($('#system_tts_pitch').val());
|
||||
$('#system_tts_pitch_output').text(this.settings.pitch);
|
||||
$('#system_tts_rate_output').text(this.settings.rate);
|
||||
console.log('Save changes');
|
||||
}
|
||||
|
||||
loadSettings(settings) {
|
||||
// Populate Provider UI given input settings
|
||||
if (Object.keys(settings).length == 0) {
|
||||
console.info("Using default TTS Provider settings");
|
||||
}
|
||||
|
||||
// iOS should only allows speech synthesis trigged by user interaction
|
||||
if (isMobile()) {
|
||||
let hasEnabledVoice = false;
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
if (hasEnabledVoice) {
|
||||
return;
|
||||
}
|
||||
const utterance = new SpeechSynthesisUtterance('hi');
|
||||
utterance.volume = 0;
|
||||
speechSynthesis.speak(utterance);
|
||||
hasEnabledVoice = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings;
|
||||
|
||||
for (const key in settings) {
|
||||
if (key in this.settings) {
|
||||
this.settings[key] = settings[key];
|
||||
} else {
|
||||
throw `Invalid setting passed to TTS Provider: ${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
$('#system_tts_rate').val(this.settings.rate || this.defaultSettings.rate);
|
||||
$('#system_tts_pitch').val(this.settings.pitch || this.defaultSettings.pitch);
|
||||
$('#system_tts_pitch_output').text(this.settings.pitch);
|
||||
$('#system_tts_rate_output').text(this.settings.rate);
|
||||
console.info("Settings loaded");
|
||||
}
|
||||
|
||||
async onApplyClick() {
|
||||
return
|
||||
}
|
||||
|
||||
//#################//
|
||||
// TTS Interfaces //
|
||||
//#################//
|
||||
fetchTtsVoiceIds() {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return speechSynthesis
|
||||
.getVoices()
|
||||
.sort((a, b) => a.lang.localeCompare(b.lang) || a.name.localeCompare(b.name))
|
||||
.map(x => ({ name: x.name, voice_id: x.voiceURI, preview_url: false, lang: x.lang }));
|
||||
}
|
||||
|
||||
previewTtsVoice(voiceId) {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
throw 'Speech synthesis API is not supported';
|
||||
}
|
||||
|
||||
const voice = speechSynthesis.getVoices().find(x => x.voiceURI === voiceId);
|
||||
|
||||
if (!voice) {
|
||||
throw `TTS Voice name ${voiceName} not found`
|
||||
}
|
||||
|
||||
speechSynthesis.cancel();
|
||||
const text = getPreviewString(voice.lang);
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.voice = voice;
|
||||
utterance.rate = 1;
|
||||
utterance.pitch = 1;
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
async getVoice(voiceName) {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
return { voice_id: null }
|
||||
}
|
||||
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const match = voices.find(x => x.name == voiceName);
|
||||
|
||||
if (!match) {
|
||||
throw `TTS Voice name ${voiceName} not found`
|
||||
}
|
||||
|
||||
return { voice_id: match.voiceURI, name: match.name };
|
||||
}
|
||||
|
||||
async generateTts(text, voiceId) {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
throw 'Speech synthesis API is not supported';
|
||||
}
|
||||
|
||||
const silence = await fetch('/sounds/silence.mp3');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const voice = voices.find(x => x.voiceURI === voiceId);
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.voice = voice;
|
||||
utterance.rate = this.settings.rate || 1;
|
||||
utterance.pitch = this.settings.pitch || 1;
|
||||
utterance.onend = () => resolve(silence);
|
||||
utterance.onerror = () => reject();
|
||||
speechUtteranceChunker(utterance, {
|
||||
chunkLength: 200,
|
||||
}, function () {
|
||||
//some code to execute when done
|
||||
resolve(silence);
|
||||
console.log('System TTS done');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user