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 | 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 | }; |
| 101 | 104 | ||
| 102 | export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]); | 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 | resetTtsPlayback(); | 167 | resetTtsPlayback(); |
| 168 | processAndQueueTtsMessage(message); | 168 | processAndQueueTtsMessage(message, Number(id)); |
| 169 | moduleWorker(); | 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 | * @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 | */ |
| 252 | function processAndQueueTtsMessage(message) { | 258 | function 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 | } |
| 257 | 267 | ||
| 258 | const lines = message.mes.split('\n'); | 268 | const lines = clone.mes.split('\n'); |
| 259 | 269 | ||
| 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 | } |
| 266 | 276 | ||
| 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 queue | 311 | * @type AudioJob[] Audio job queue |
| 302 | * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object | 312 | * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object |
| 303 | */ | 313 | */ |
| 304 | let audioJobQueue = []; | 314 | const audioJobQueue = []; |
| 305 | /** | 315 | /** |
| 306 | * @type AudioJob Current audio job | 316 | * @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 playback | 442 | * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback |
| 433 | * @param {Response} response | 443 | * @param {Response} response |
| 444 | * @param {string} char | ||
| 445 | * @returns {Promise<{audioBlob: Blob|string, mimeType: string}>} | ||
| 434 | */ | 446 | */ |
| 435 | async function addAudioJob(response, char) { | 447 | async 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 | } |
| 447 | 463 | ||
| 448 | async function processAudioJobQueue() { | 464 | async function processAudioJobQueue() { |
| @@ -465,7 +481,7 @@ async function processAudioJobQueue() { | |||
| 465 | // TTS Control // | 481 | // TTS Control // |
| 466 | //################// | 482 | //################// |
| 467 | 483 | ||
| 468 | let ttsJobQueue = []; | 484 | const ttsJobQueue = []; |
| 469 | let currentTtsJob; // Null if nothing is currently being processed | 485 | let currentTtsJob; // Null if nothing is currently being processed |
| 470 | 486 | ||
| 471 | function completeTtsJob() { | 487 | function completeTtsJob() { |
| @@ -474,12 +490,18 @@ function completeTtsJob() { | |||
| 474 | } | 490 | } |
| 475 | 491 | ||
| 476 | async function tts(text, voiceId, char, voiceMapKey = null) { | 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 | async function processResponse(response) { | 497 | async function processResponse(response) { |
| 478 | // RVC injection | 498 | // 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); |
| 481 | 501 | ||
| 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 | } |
| 484 | 506 | ||
| 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 | } |
| 496 | 518 | ||
| 519 | await eventSource.emit(event_types.TTS_JOB_COMPLETE, { messageId, characterName: char }); | ||
| 497 | completeTtsJob(); | 520 | completeTtsJob(); |
| 498 | } | 521 | } |
| 499 | 522 | ||
| @@ -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 | } |
| 798 | 822 | ||
| 799 | const context = getContext(); | 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 | globalThis.playFullConversation = playFullConversation; | 836 | globalThis.playFullConversation = playFullConversation; |
| @@ -1076,6 +1103,7 @@ async function onMessageEvent(messageId, lastCharIndex) { | |||
| 1076 | } | 1103 | } |
| 1077 | 1104 | ||
| 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 ?? ''); |
| 1081 | 1109 | ||
| @@ -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}"`); |
| 1134 | 1162 | ||
| 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 | } |
| 1141 | 1170 | ||