| 1 | import { getRequestHeaders } from '../../../script.js'; |
| 2 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 3 | import { splitRecursive } from '../../utils.js'; |
| 4 | import { getPreviewString, saveTtsProviderSettings } from './index.js'; |
| 5 | import { initVoiceMap } from './index.js'; |
| 6 | |
| 7 | export { NovelTtsProvider }; |
| 8 | |
| 9 | class NovelTtsProvider { |
| 10 | //########// |
| 11 | // Config // |
| 12 | //########// |
| 13 | |
| 14 | settings; |
| 15 | voices = []; |
| 16 | separator = ' . '; |
| 17 | audioElement = document.createElement('audio'); |
| 18 | |
| 19 | defaultSettings = { |
| 20 | voiceMap: {}, |
| 21 | customVoices: [], |
| 22 | }; |
| 23 | |
| 24 | /** |
| 25 | * Perform any text processing before passing to TTS engine. |
| 26 | * @param {string} text Input text |
| 27 | * @returns {string} Processed text |
| 28 | */ |
| 29 | processText(text) { |
| 30 | // Novel reads tilde as a word. Replace with full stop |
| 31 | text = text.replace(/~/g, '.'); |
| 32 | // Novel reads asterisk as a word. Remove it |
| 33 | text = text.replace(/\*/g, ''); |
| 34 | return text; |
| 35 | } |
| 36 | |
| 37 | get settingsHtml() { |
| 38 | let html = ` |
| 39 | <div class="novel_tts_hints"> |
| 40 | <div>Use NovelAI's TTS engine.</div> |
| 41 | <div> |
| 42 | The default Voice IDs are only examples. Add custom voices and Novel will create a new random voice for it. |
| 43 | Feel free to try different options! |
| 44 | </div> |
| 45 | <i>Hint: Save an API key in the NovelAI API settings to use it here.</i> |
| 46 | </div> |
| 47 | <label for="tts-novel-custom-voices-add">Custom Voices</label> |
| 48 | <div class="tts_custom_voices"> |
| 49 | <select id="tts-novel-custom-voices-select"><select> |
| 50 | <i id="tts-novel-custom-voices-add" class="tts-button fa-solid fa-plus fa-xl success" title="Add"></i> |
| 51 | <i id="tts-novel-custom-voices-delete" class="tts-button fa-solid fa-xmark fa-xl failure" title="Delete"></i> |
| 52 | </div> |
| 53 | `; |
| 54 | return html; |
| 55 | } |
| 56 | |
| 57 | |
| 58 | // Add a new Novel custom voice to provider |
| 59 | async addCustomVoice() { |
| 60 | const voiceName = await callGenericPopup('Custom Voice name:', POPUP_TYPE.INPUT); |
| 61 | this.settings.customVoices.push(voiceName); |
| 62 | this.populateCustomVoices(); |
| 63 | initVoiceMap(); // Update TTS extension voiceMap |
| 64 | saveTtsProviderSettings(); |
| 65 | } |
| 66 | |
| 67 | // Delete selected custom voice from provider |
| 68 | deleteCustomVoice() { |
| 69 | const selected = $('#tts-novel-custom-voices-select').find(':selected').val(); |
| 70 | const voiceIndex = this.settings.customVoices.indexOf(selected); |
| 71 | |
| 72 | if (voiceIndex !== -1) { |
| 73 | this.settings.customVoices.splice(voiceIndex, 1); |
| 74 | } |
| 75 | this.populateCustomVoices(); |
| 76 | initVoiceMap(); // Update TTS extension voiceMap |
| 77 | saveTtsProviderSettings(); |
| 78 | } |
| 79 | |
| 80 | // Create the UI dropdown list of voices in provider |
| 81 | populateCustomVoices() { |
| 82 | let voiceSelect = $('#tts-novel-custom-voices-select'); |
| 83 | voiceSelect.empty(); |
| 84 | this.settings.customVoices.forEach(voice => { |
| 85 | voiceSelect.append(`<option>${voice}</option>`); |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | async loadSettings(settings) { |
| 90 | // Populate Provider UI given input settings |
| 91 | if (Object.keys(settings).length == 0) { |
| 92 | console.info('Using default TTS Provider settings'); |
| 93 | } |
| 94 | $('#tts-novel-custom-voices-add').on('click', () => (this.addCustomVoice())); |
| 95 | $('#tts-novel-custom-voices-delete').on('click', () => (this.deleteCustomVoice())); |
| 96 | |
| 97 | // Only accept keys defined in defaultSettings |
| 98 | this.settings = this.defaultSettings; |
| 99 | |
| 100 | for (const key in settings) { |
| 101 | if (key in this.settings) { |
| 102 | this.settings[key] = settings[key]; |
| 103 | } else { |
| 104 | throw `Invalid setting passed to TTS Provider: ${key}`; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | this.populateCustomVoices(); |
| 109 | await this.checkReady(); |
| 110 | console.debug('NovelTTS: Settings loaded'); |
| 111 | } |
| 112 | |
| 113 | // Perform a simple readiness check by trying to fetch voiceIds |
| 114 | // Doesnt really do much for Novel, not seeing a good way to test this at the moment. |
| 115 | async checkReady() { |
| 116 | await this.fetchTtsVoiceObjects(); |
| 117 | } |
| 118 | |
| 119 | async onRefreshClick() { |
| 120 | return; |
| 121 | } |
| 122 | |
| 123 | //#################// |
| 124 | // TTS Interfaces // |
| 125 | //#################// |
| 126 | |
| 127 | async getVoice(voiceName) { |
| 128 | if (!voiceName) { |
| 129 | throw 'TTS Voice name not provided'; |
| 130 | } |
| 131 | |
| 132 | return { name: voiceName, voice_id: voiceName, lang: 'en-US', preview_url: false }; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Generate TTS audio for the given text using the specified voice. |
| 137 | * @param {string} text Text to generate |
| 138 | * @param {string} voiceId Voice ID |
| 139 | * @returns {AsyncGenerator<Response>} Audio response generator |
| 140 | */ |
| 141 | generateTts(text, voiceId) { |
| 142 | return this.fetchTtsGeneration(text, voiceId); |
| 143 | } |
| 144 | |
| 145 | //###########// |
| 146 | // API CALLS // |
| 147 | //###########// |
| 148 | async fetchTtsVoiceObjects() { |
| 149 | let voices = [ |
| 150 | { name: 'Ligeia', voice_id: 'Ligeia', lang: 'en-US', preview_url: false }, |
| 151 | { name: 'Aini', voice_id: 'Aini', lang: 'en-US', preview_url: false }, |
| 152 | { name: 'Orea', voice_id: 'Orea', lang: 'en-US', preview_url: false }, |
| 153 | { name: 'Claea', voice_id: 'Claea', lang: 'en-US', preview_url: false }, |
| 154 | { name: 'Lim', voice_id: 'Lim', lang: 'en-US', preview_url: false }, |
| 155 | { name: 'Aurae', voice_id: 'Aurae', lang: 'en-US', preview_url: false }, |
| 156 | { name: 'Naia', voice_id: 'Naia', lang: 'en-US', preview_url: false }, |
| 157 | { name: 'Aulon', voice_id: 'Aulon', lang: 'en-US', preview_url: false }, |
| 158 | { name: 'Elei', voice_id: 'Elei', lang: 'en-US', preview_url: false }, |
| 159 | { name: 'Ogma', voice_id: 'Ogma', lang: 'en-US', preview_url: false }, |
| 160 | { name: 'Raid', voice_id: 'Raid', lang: 'en-US', preview_url: false }, |
| 161 | { name: 'Pega', voice_id: 'Pega', lang: 'en-US', preview_url: false }, |
| 162 | { name: 'Lam', voice_id: 'Lam', lang: 'en-US', preview_url: false }, |
| 163 | ]; |
| 164 | |
| 165 | // Add in custom voices to the map |
| 166 | let addVoices = this.settings.customVoices.map(voice => |
| 167 | ({ name: voice, voice_id: voice, lang: 'en-US', preview_url: false }), |
| 168 | ); |
| 169 | voices = voices.concat(addVoices); |
| 170 | |
| 171 | return voices; |
| 172 | } |
| 173 | |
| 174 | |
| 175 | async previewTtsVoice(id) { |
| 176 | this.audioElement.pause(); |
| 177 | this.audioElement.currentTime = 0; |
| 178 | |
| 179 | const text = getPreviewString('en-US'); |
| 180 | for await (const response of this.generateTts(text, id)) { |
| 181 | const audio = await response.blob(); |
| 182 | const url = URL.createObjectURL(audio); |
| 183 | await new Promise(resolve => { |
| 184 | const audioElement = new Audio(); |
| 185 | audioElement.src = url; |
| 186 | audioElement.play(); |
| 187 | audioElement.onended = () => resolve(); |
| 188 | }); |
| 189 | URL.revokeObjectURL(url); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | async* fetchTtsGeneration(inputText, voiceId) { |
| 194 | const MAX_LENGTH = 1000; |
| 195 | console.info(`Generating new TTS for voice_id ${voiceId}`); |
| 196 | const chunks = splitRecursive(inputText, MAX_LENGTH); |
| 197 | for (const chunk of chunks) { |
| 198 | const response = await fetch('/api/novelai/generate-voice', |
| 199 | { |
| 200 | method: 'POST', |
| 201 | headers: getRequestHeaders(), |
| 202 | body: JSON.stringify({ |
| 203 | 'text': chunk, |
| 204 | 'voice': voiceId, |
| 205 | }), |
| 206 | }, |
| 207 | ); |
| 208 | if (!response.ok) { |
| 209 | toastr.error(response.statusText, 'TTS Generation Failed'); |
| 210 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); |
| 211 | } |
| 212 | yield response; |
| 213 | } |
| 214 | } |
| 215 | } |