feat(tts): emit events and track messageId for third-party integrations (#5309) * feat(tts): add event signals and messageId tracking for third-party integrations * feat(tts): move events to core, centralize clone and id tracking * Apply review suggestions --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

e4ad48944806de61ef87ad22ea5262a4ae3cdcec

Xiangzhe <32761048+xz-dev@users.noreply.github.com>

Signed
2 files changed, +51 -19Ignore whitespace
public/scripts/events.js+3 -0
@@ -97,6 +97,9 @@ export const event_types = {
97 WORLDINFO_SCAN_DONE: 'worldinfo_scan_done',97 WORLDINFO_SCAN_DONE: 'worldinfo_scan_done',
98 MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',98 MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
99 PERSONA_CHANGED: 'persona_changed',99 PERSONA_CHANGED: 'persona_changed',
100 TTS_JOB_STARTED: 'tts_job_started',
101 TTS_AUDIO_READY: 'tts_audio_ready',
102 TTS_JOB_COMPLETE: 'tts_job_complete',
100};103};
101104
102export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]);105export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]);
public/scripts/extensions/tts/index.js+48 -19
@@ -165,7 +165,7 @@ async function onNarrateOneMessage() {
165 }165 }
166166
167 resetTtsPlayback();167 resetTtsPlayback();
168 processAndQueueTtsMessage(message);168 processAndQueueTtsMessage(message, Number(id));
169 moduleWorker();169 moduleWorker();
170}170}
171171
@@ -245,17 +245,27 @@ function isTtsProcessing() {
245}245}
246246
247/**247/**
248 * Splits a message into lines and adds each non-empty line to the TTS job queue.248 * @typedef {ChatMessage & { id?: number }} TtsMessage
249 */
250
251/**
252 * Clones a message, attaches the given message ID, then splits by paragraphs
253 * (if enabled) and adds each part to the TTS job queue.
249 * @param {ChatMessage} message - The message object to be processed.254 * @param {ChatMessage} message - The message object to be processed.
255 * @param {number|null} [messageId=null] - The chat message index to associate with TTS events.
250 * @returns {void}256 * @returns {void}
251 */257 */
252function processAndQueueTtsMessage(message) {258function processAndQueueTtsMessage(message, messageId = null) {
259 /** @type {TtsMessage} */
260 const clone = structuredClone(message);
261 clone.id = messageId ?? null;
262
253 if (!extension_settings.tts.narrate_by_paragraphs) {263 if (!extension_settings.tts.narrate_by_paragraphs) {
254 ttsJobQueue.push(message);264 ttsJobQueue.push(clone);
255 return;265 return;
256 }266 }
257267
258 const lines = message.mes.split('\n');268 const lines = clone.mes.split('\n');
259269
260 for (let i = 0; i < lines.length; i++) {270 for (let i = 0; i < lines.length; i++) {
261 const line = lines[i];271 const line = lines[i];
@@ -265,7 +275,7 @@ function processAndQueueTtsMessage(message) {
265 }275 }
266276
267 ttsJobQueue.push(277 ttsJobQueue.push(
268 Object.assign({}, message, {278 Object.assign({}, clone, {
269 mes: line,279 mes: line,
270 }),280 }),
271 );281 );
@@ -301,7 +311,7 @@ audioElement.autoplay = true;
301 * @type AudioJob[] Audio job queue311 * @type AudioJob[] Audio job queue
302 * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object312 * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object
303 */313 */
304let audioJobQueue = [];314const audioJobQueue = [];
305/**315/**
306 * @type AudioJob Current audio job316 * @type AudioJob Current audio job
307 */317 */
@@ -431,18 +441,24 @@ function completeCurrentAudioJob() {
431/**441/**
432 * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback442 * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback
433 * @param {Response} response443 * @param {Response} response
444 * @param {string} char
445 * @returns {Promise<{audioBlob: Blob|string, mimeType: string}>}
434 */446 */
435async function addAudioJob(response, char) {447async function addAudioJob(response, char) {
448 let audioBlob, mimeType;
436 if (typeof response === 'string') {449 if (typeof response === 'string') {
437 audioJobQueue.push({ audioBlob: response, char: char });450 audioBlob = response;
451 mimeType = '';
438 } else {452 } else {
439 const audioData = await response.blob();453 audioBlob = await response.blob();
440 if (!audioData.type.startsWith('audio/')) {454 if (!audioBlob.type.startsWith('audio/')) {
441 throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioData.type}`;455 throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioBlob.type}`;
442 }456 }
443 audioJobQueue.push({ audioBlob: audioData, char: char });457 mimeType = audioBlob.type;
444 }458 }
459 audioJobQueue.push({ audioBlob, char });
445 console.debug('Pushed audio job to queue.');460 console.debug('Pushed audio job to queue.');
461 return { audioBlob, mimeType };
446}462}
447463
448async function processAudioJobQueue() {464async function processAudioJobQueue() {
@@ -465,7 +481,7 @@ async function processAudioJobQueue() {
465// TTS Control //481// TTS Control //
466//################//482//################//
467483
468let ttsJobQueue = [];484const ttsJobQueue = [];
469let currentTtsJob; // Null if nothing is currently being processed485let currentTtsJob; // Null if nothing is currently being processed
470486
471function completeTtsJob() {487function completeTtsJob() {
@@ -474,12 +490,18 @@ function completeTtsJob() {
474}490}
475491
476async function tts(text, voiceId, char, voiceMapKey = null) {492async function tts(text, voiceId, char, voiceMapKey = null) {
493 const messageId = currentTtsJob?.id ?? null;
494
495 await eventSource.emit(event_types.TTS_JOB_STARTED, { messageId, characterName: char, text, voiceId });
496
477 async function processResponse(response) {497 async function processResponse(response) {
478 // RVC injection498 // RVC injection
479 if (typeof globalThis.rvcVoiceConversion === 'function' && extension_settings.rvc.enabled)499 if (typeof globalThis.rvcVoiceConversion === 'function' && extension_settings.rvc.enabled)
480 response = await globalThis.rvcVoiceConversion(response, char, text);500 response = await globalThis.rvcVoiceConversion(response, char, text);
481501
482 await addAudioJob(response, char);502 const audioResult = await addAudioJob(response, char);
503 const eventData = { messageId, characterName: char, text, audio: audioResult.audioBlob, mimeType: audioResult.mimeType };
504 await eventSource.emit(event_types.TTS_AUDIO_READY, eventData);
483 }505 }
484506
485 // voiceMapKey can also include segment qualifiers, e.g. '{char} ("Quotes")'507 // voiceMapKey can also include segment qualifiers, e.g. '{char} ("Quotes")'
@@ -494,6 +516,7 @@ async function tts(text, voiceId, char, voiceMapKey = null) {
494 await processResponse(response);516 await processResponse(response);
495 }517 }
496518
519 await eventSource.emit(event_types.TTS_JOB_COMPLETE, { messageId, characterName: char });
497 completeTtsJob();520 completeTtsJob();
498}521}
499522
@@ -700,6 +723,7 @@ async function processTtsQueue() {
700 is_user: currentTtsJob.is_user,723 is_user: currentTtsJob.is_user,
701 mes: currentTtsJob.mes,724 mes: currentTtsJob.mes,
702 extra: currentTtsJob.extra,725 extra: currentTtsJob.extra,
726 id: currentTtsJob.id,
703 };727 };
704 ttsJobQueue.unshift(segmentJob);728 ttsJobQueue.unshift(segmentJob);
705 }729 }
@@ -797,13 +821,16 @@ async function playFullConversation() {
797 }821 }
798822
799 const context = getContext();823 const context = getContext();
800 const chat = context.chat.filter(x => !x.is_system && x.mes !== '...' && x.mes !== '');
801824
802 if (chat.length === 0) {825 context.chat.forEach((msg, i) => {
826 if (!msg.is_system && msg.mes !== '...' && msg.mes !== '') {
827 processAndQueueTtsMessage(msg, i);
828 }
829 });
830
831 if (ttsJobQueue.length === 0) {
803 return toastr.info('No messages to narrate.');832 return toastr.info('No messages to narrate.');
804 }833 }
805
806 ttsJobQueue = chat;
807}834}
808835
809globalThis.playFullConversation = playFullConversation;836globalThis.playFullConversation = playFullConversation;
@@ -1076,6 +1103,7 @@ async function onMessageEvent(messageId, lastCharIndex) {
1076 }1103 }
10771104
1078 // clone message object, as things go haywire if message object is altered below (it's passed by reference)1105 // clone message object, as things go haywire if message object is altered below (it's passed by reference)
1106 /** @type {TtsMessage} */
1079 const message = structuredClone(context.chat[messageId]);1107 const message = structuredClone(context.chat[messageId]);
1080 const hashNew = getStringHash(message?.mes ?? '');1108 const hashNew = getStringHash(message?.mes ?? '');
10811109
@@ -1133,9 +1161,10 @@ async function onMessageEvent(messageId, lastCharIndex) {
1133 console.debug(`Adding message from ${message.name} for TTS processing: "${message.mes}"`);1161 console.debug(`Adding message from ${message.name} for TTS processing: "${message.mes}"`);
11341162
1135 if (extension_settings.tts.periodic_auto_generation && isStreamingEnabled()) {1163 if (extension_settings.tts.periodic_auto_generation && isStreamingEnabled()) {
1164 message.id = messageId;
1136 ttsJobQueue.push(message);1165 ttsJobQueue.push(message);
1137 } else {1166 } else {
1138 processAndQueueTtsMessage(message);1167 processAndQueueTtsMessage(message, messageId);
1139 }1168 }
1140}1169}
11411170