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>
Signed| @@ -97,6 +97,9 @@ export const event_types = { | ||
| 97 | 97 | WORLDINFO_SCAN_DONE: 'worldinfo_scan_done', |
| 98 | 98 | MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted', |
| 99 | 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 | }; |
| 101 | 104 | |
| 102 | 105 | export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]); |
| @@ -165,7 +165,7 @@ async function onNarrateOneMessage() { | ||
| 165 | 165 | } |
| 166 | 166 | |
| 167 | 167 | resetTtsPlayback(); |
| 168 | 168 | processAndQueueTtsMessage(message, Number(id)); |
| 169 | 169 | moduleWorker(); |
| 170 | 170 | } |
| 171 | 171 | |
| @@ -245,17 +245,27 @@ function isTtsProcessing() { | ||
| 245 | 245 | } |
| 246 | 246 | |
| 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 | 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 | 256 | * @returns {void} |
| 251 | 257 | */ |
| 252 | 258 | function processAndQueueTtsMessage(message, messageId = null) { |
| 259 | + /** @type {TtsMessage} */ | |
| 260 | + const clone = structuredClone(message); | |
| 261 | + clone.id = messageId ?? null; | |
| 262 | + | |
| 253 | 263 | if (!extension_settings.tts.narrate_by_paragraphs) { |
| 254 | 264 | ttsJobQueue.push(messageclone); |
| 255 | 265 | return; |
| 256 | 266 | } |
| 257 | 267 | |
| 258 | 268 | const lines = messageclone.mes.split('\n'); |
| 259 | 269 | |
| 260 | 270 | for (let i = 0; i < lines.length; i++) { |
| 261 | 271 | const line = lines[i]; |
| @@ -265,7 +275,7 @@ function processAndQueueTtsMessage(message) { | ||
| 265 | 275 | } |
| 266 | 276 | |
| 267 | 277 | ttsJobQueue.push( |
| 268 | 278 | Object.assign({}, messageclone, { |
| 269 | 279 | mes: line, |
| 270 | 280 | }), |
| 271 | 281 | ); |
| @@ -301,7 +311,7 @@ audioElement.autoplay = true; | ||
| 301 | 311 | * @type AudioJob[] Audio job queue |
| 302 | 312 | * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object |
| 303 | 313 | */ |
| 304 | 314 | letconst audioJobQueue = []; |
| 305 | 315 | /** |
| 306 | 316 | * @type AudioJob Current audio job |
| 307 | 317 | */ |
| @@ -431,18 +441,24 @@ function completeCurrentAudioJob() { | ||
| 431 | 441 | /** |
| 432 | 442 | * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback |
| 433 | 443 | * @param {Response} response |
| 444 | + * @param {string} char | |
| 445 | + * @returns {Promise<{audioBlob: Blob|string, mimeType: string}>} | |
| 434 | 446 | */ |
| 435 | 447 | async function addAudioJob(response, char) { |
| 448 | + let audioBlob, mimeType; | |
| 436 | 449 | if (typeof response === 'string') { |
| 437 | - audioJobQueue.push({ audioBlob: response, char: char }); | |
| 450 | + audioBlob = response; | |
| 451 | + mimeType = ''; | |
| 438 | 452 | } else { |
| 439 | 453 | const audioDataaudioBlob = await response.blob(); |
| 440 | 454 | if (!audioDataaudioBlob.type.startsWith('audio/')) { |
| 441 | 455 | throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioDataaudioBlob.type}`; |
| 442 | 456 | } |
| 443 | - audioJobQueue.push({ audioBlob: audioData, char: char }); | |
| 457 | + mimeType = audioBlob.type; | |
| 444 | 458 | } |
| 459 | + audioJobQueue.push({ audioBlob, char }); | |
| 445 | 460 | console.debug('Pushed audio job to queue.'); |
| 461 | + return { audioBlob, mimeType }; | |
| 446 | 462 | } |
| 447 | 463 | |
| 448 | 464 | async function processAudioJobQueue() { |
| @@ -465,7 +481,7 @@ async function processAudioJobQueue() { | ||
| 465 | 481 | // TTS Control // |
| 466 | 482 | //################// |
| 467 | 483 | |
| 468 | 484 | letconst ttsJobQueue = []; |
| 469 | 485 | let currentTtsJob; // Null if nothing is currently being processed |
| 470 | 486 | |
| 471 | 487 | function completeTtsJob() { |
| @@ -474,12 +490,18 @@ function completeTtsJob() { | ||
| 474 | 490 | } |
| 475 | 491 | |
| 476 | 492 | async 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 | 497 | async function processResponse(response) { |
| 478 | 498 | // RVC injection |
| 479 | 499 | if (typeof globalThis.rvcVoiceConversion === 'function' && extension_settings.rvc.enabled) |
| 480 | 500 | response = await globalThis.rvcVoiceConversion(response, char, text); |
| 481 | 501 | |
| 482 | 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 | } |
| 484 | 506 | |
| 485 | 507 | // voiceMapKey can also include segment qualifiers, e.g. '{char} ("Quotes")' |
| @@ -494,6 +516,7 @@ async function tts(text, voiceId, char, voiceMapKey = null) { | ||
| 494 | 516 | await processResponse(response); |
| 495 | 517 | } |
| 496 | 518 | |
| 519 | + await eventSource.emit(event_types.TTS_JOB_COMPLETE, { messageId, characterName: char }); | |
| 497 | 520 | completeTtsJob(); |
| 498 | 521 | } |
| 499 | 522 | |
| @@ -700,6 +723,7 @@ async function processTtsQueue() { | ||
| 700 | 723 | is_user: currentTtsJob.is_user, |
| 701 | 724 | mes: currentTtsJob.mes, |
| 702 | 725 | extra: currentTtsJob.extra, |
| 726 | + id: currentTtsJob.id, | |
| 703 | 727 | }; |
| 704 | 728 | ttsJobQueue.unshift(segmentJob); |
| 705 | 729 | } |
| @@ -797,13 +821,16 @@ async function playFullConversation() { | ||
| 797 | 821 | } |
| 798 | 822 | |
| 799 | 823 | const context = getContext(); |
| 800 | - const chat = context.chat.filter(x => !x.is_system && x.mes !== '...' && x.mes !== ''); | |
| 801 | 824 | |
| 802 | - if (chat.length === 0) { | |
| 825 | + context.chat.forEach((msg, i) => { | |
| 803 | - return toastr.info('No messages to narrate.'); | |
| 826 | + if (!msg.is_system && msg.mes !== '...' && msg.mes !== '') { | |
| 827 | + processAndQueueTtsMessage(msg, i); | |
| 804 | 828 | } |
| 829 | + }); | |
| 805 | 830 | |
| 806 | - ttsJobQueue = chat; | |
| 831 | + if (ttsJobQueue.length === 0) { | |
| 832 | + return toastr.info('No messages to narrate.'); | |
| 833 | + } | |
| 807 | 834 | } |
| 808 | 835 | |
| 809 | 836 | globalThis.playFullConversation = playFullConversation; |
| @@ -1076,6 +1103,7 @@ async function onMessageEvent(messageId, lastCharIndex) { | ||
| 1076 | 1103 | } |
| 1077 | 1104 | |
| 1078 | 1105 | // clone message object, as things go haywire if message object is altered below (it's passed by reference) |
| 1106 | + /** @type {TtsMessage} */ | |
| 1079 | 1107 | const message = structuredClone(context.chat[messageId]); |
| 1080 | 1108 | const hashNew = getStringHash(message?.mes ?? ''); |
| 1081 | 1109 | |
| @@ -1133,9 +1161,10 @@ async function onMessageEvent(messageId, lastCharIndex) { | ||
| 1133 | 1161 | console.debug(`Adding message from ${message.name} for TTS processing: "${message.mes}"`); |
| 1134 | 1162 | |
| 1135 | 1163 | if (extension_settings.tts.periodic_auto_generation && isStreamingEnabled()) { |
| 1164 | + message.id = messageId; | |
| 1136 | 1165 | ttsJobQueue.push(message); |
| 1137 | 1166 | } else { |
| 1138 | 1167 | processAndQueueTtsMessage(message, messageId); |
| 1139 | 1168 | } |
| 1140 | 1169 | } |
| 1141 | 1170 | |