yoink talkinghead - goodbye extras dependency

73393a5d5eadc92814d9689091d13c24bc0e3d95

Wolfsblvt <wolfsblvt@gmail.com>

8 files changed, +118 -586Ignore whitespace
public/locales/fr-fr.json+0 -1
@@ -1602,7 +1602,6 @@
1602 "Character Expressions": "Expressions de personnages",1602 "Character Expressions": "Expressions de personnages",
1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
1604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",1604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
1605 "Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
1606 "Classifier API": "API de classification",1605 "Classifier API": "API de classification",
1607 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",1606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
1608 "Main API": "API principale",1607 "Main API": "API principale",
public/locales/ko-kr.json+0 -1
@@ -1467,7 +1467,6 @@
1467 "menu within": "내의 메뉴",1467 "menu within": "내의 메뉴",
1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470 "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
1471 "Classifier API": "분류를 위한 API",1470 "Classifier API": "분류를 위한 API",
1472 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",1471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
1473 "Local": "로컬",1472 "Local": "로컬",
public/locales/zh-cn.json+0 -1
@@ -1349,7 +1349,6 @@
1349 "Character Expressions": "角色表情",1349 "Character Expressions": "角色表情",
1350 "Translate text to English before classification": "分类之前将文本翻译成英文",1350 "Translate text to English before classification": "分类之前将文本翻译成英文",
1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352 "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
1353 "Classifier API": "分类器 API",1352 "Classifier API": "分类器 API",
1354 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",1353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
1355 "Main API": "主要 API",1354 "Main API": "主要 API",
public/locales/zh-tw.json+0 -1
@@ -1653,7 +1653,6 @@
1653 "HuggingFace Token": "HuggingFace 符元",1653 "HuggingFace Token": "HuggingFace 符元",
1654 "Image Captioning": "圖片註解",1654 "Image Captioning": "圖片註解",
1655 "Generate Caption": "產生圖片註解",1655 "Generate Caption": "產生圖片註解",
1656 "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
1657 "Injection Position": "插入位置",1656 "Injection Position": "插入位置",
1658 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",1657 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
1659 "Injection Template": "插入範本",1658 "Injection Template": "插入範本",
public/scripts/extensions/expressions/index.js+118 -549
@@ -38,11 +38,9 @@ export { MODULE_NAME };
38const MODULE_NAME = 'expressions';38const MODULE_NAME = 'expressions';
39const UPDATE_INTERVAL = 2000;39const UPDATE_INTERVAL = 2000;
40const STREAMING_UPDATE_INTERVAL = 10000;40const STREAMING_UPDATE_INTERVAL = 10000;
41const TALKINGCHECK_UPDATE_INTERVAL = 500;
42const DEFAULT_FALLBACK_EXPRESSION = 'joy';41const DEFAULT_FALLBACK_EXPRESSION = 'joy';
43const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';42const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
44const DEFAULT_EXPRESSIONS = [43const DEFAULT_EXPRESSIONS = [
45 'talkinghead',
46 'admiration',44 'admiration',
47 'amusement',45 'amusement',
48 'anger',46 'anger',
@@ -86,8 +84,6 @@ const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No
86let expressionsList = null;84let expressionsList = null;
87let lastCharacter = undefined;85let lastCharacter = undefined;
88let lastMessage = null;86let lastMessage = null;
89let lastTalkingState = false;
90let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
91/** @type {{[characterKey: string]: Expression[]}} */87/** @type {{[characterKey: string]: Expression[]}} */
92let spriteCache = {};88let spriteCache = {};
93let inApiCall = false;89let inApiCall = false;
@@ -96,10 +92,6 @@ let lastServerResponseTime = 0;
96/** @type {{[characterName: string]: string}} */92/** @type {{[characterName: string]: string}} */
97export let lastExpression = {};93export let lastExpression = {};
9894
99function isTalkingHeadEnabled() {
100 return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras;
101}
102
103/**95/**
104 * Returns the fallback expression if explicitly chosen, otherwise the default one96 * Returns the fallback expression if explicitly chosen, otherwise the default one
105 * @returns {string} expression name97 * @returns {string} expression name
@@ -108,18 +100,6 @@ function getFallbackExpression() {
108 return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;100 return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
109}101}
110102
111/**
112 * Toggles Talkinghead mode on/off.
113 *
114 * Implements the `/th` slash command, which is meant to be bound to a Quick Reply button
115 * as a quick way to switch Talkinghead on or off (e.g. to conserve GPU resources when AFK
116 * for a long time).
117 */
118function toggleTalkingHeadCommand(_) {
119 setTalkingHeadState(!extension_settings.expressions.talkinghead);
120 return String(extension_settings.expressions.talkinghead);
121}
122
123function isVisualNovelMode() {103function isVisualNovelMode() {
124 return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);104 return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
125}105}
@@ -451,199 +431,9 @@ function onExpressionsShowDefaultInput() {
451 }431 }
452}432}
453433
454/**
455 * Stops animating Talkinghead.
456 */
457async function unloadTalkingHead() {
458 if (!modules.includes('talkinghead')) {
459 console.debug('talkinghead module is disabled');
460 return;
461 }
462 console.debug('expressions: Stopping Talkinghead');
463
464 try {
465 const url = new URL(getApiUrl());
466 url.pathname = '/api/talkinghead/unload';
467 const loadResponse = await doExtrasFetch(url);
468 if (!loadResponse.ok) {
469 throw new Error(loadResponse.statusText);
470 }
471 //console.log(`Response: ${loadResponseText}`);
472 } catch (error) {
473 //console.error(`Error unloading - ${error}`);
474 }
475}
476
477/**
478 * Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
479 */
480async function loadTalkingHead() {
481 if (!modules.includes('talkinghead')) {
482 console.debug('talkinghead module is disabled');
483 return;
484 }
485 console.debug('expressions: Starting Talkinghead');
486
487 const spriteFolderName = getSpriteFolderName();
488
489 const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
490 const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
491 const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
492
493 try {
494 const spriteResponse = await fetch(talkingheadPath);
495
496 if (!spriteResponse.ok) {
497 throw new Error(spriteResponse.statusText);
498 }
499
500 const spriteBlob = await spriteResponse.blob();
501 const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
502 const formData = new FormData();
503 formData.append('file', spriteFile);
504
505 const url = new URL(getApiUrl());
506 url.pathname = '/api/talkinghead/load';
507
508 const loadResponse = await doExtrasFetch(url, {
509 method: 'POST',
510 body: formData,
511 });
512
513 if (!loadResponse.ok) {
514 throw new Error(loadResponse.statusText);
515 }
516
517 const loadResponseText = await loadResponse.text();
518 console.log(`Load talkinghead response: ${loadResponseText}`);
519
520 // Optional: per-character emotion templates
521 let emotionsSettings;
522 try {
523 const emotionsResponse = await fetch(emotionsSettingsPath);
524 if (emotionsResponse.ok) {
525 emotionsSettings = await emotionsResponse.json();
526 console.log(`Loaded ${emotionsSettingsPath}`);
527 } else {
528 throw new Error();
529 }
530 }
531 catch (error) {
532 emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
533 console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
534 }
535 try {
536 const url = new URL(getApiUrl());
537 url.pathname = '/api/talkinghead/load_emotion_templates';
538 const apiResult = await doExtrasFetch(url, {
539 method: 'POST',
540 headers: {
541 'Content-Type': 'application/json',
542 'Bypass-Tunnel-Reminder': 'bypass',
543 },
544 body: JSON.stringify(emotionsSettings),
545 });
546
547 if (!apiResult.ok) {
548 throw new Error(apiResult.statusText);
549 }
550 }
551 catch (error) {
552 // it's ok if not supported
553 console.log('Failed to send _emotions.json (backend too old?), ignoring');
554 }
555
556 // Optional: per-character animator and postprocessor config
557 let animatorSettings;
558 try {
559 const animatorResponse = await fetch(animatorSettingsPath);
560 if (animatorResponse.ok) {
561 animatorSettings = await animatorResponse.json();
562 console.log(`Loaded ${animatorSettingsPath}`);
563 } else {
564 throw new Error();
565 }
566 }
567 catch (error) {
568 animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
569 console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
570 }
571 try {
572 const url = new URL(getApiUrl());
573 url.pathname = '/api/talkinghead/load_animator_settings';
574 const apiResult = await doExtrasFetch(url, {
575 method: 'POST',
576 headers: {
577 'Content-Type': 'application/json',
578 'Bypass-Tunnel-Reminder': 'bypass',
579 },
580 body: JSON.stringify(animatorSettings),
581 });
582
583 if (!apiResult.ok) {
584 throw new Error(apiResult.statusText);
585 }
586 }
587 catch (error) {
588 // it's ok if not supported
589 console.log('Failed to send _animator.json (backend too old?), ignoring');
590 }
591 } catch (error) {
592 console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
593 }
594}
595
596function handleImageChange() {
597 const imgElement = document.querySelector('img#expression-image.expression');
598
599 if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
600 console.log('Cannot find addExpressionImage()');
601 return;
602 }
603
604 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
605 const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
606 $('#expression-holder').css({ display: '' });
607 if (imgElement.src !== talkingheadResultFeedSrc) {
608 const expressionImageElement = document.querySelector('.expression_list_image');
609
610 if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
611 doExtrasFetch(expressionImageElement.src, {
612 method: 'HEAD',
613 })
614 .then(response => {
615 if (response.ok) {
616 imgElement.src = talkingheadResultFeedSrc;
617 }
618 })
619 .catch(error => {
620 console.error(error);
621 });
622 }
623 }
624 } else {
625 imgElement.src = ''; // remove in case char doesn't have expressions
626
627 // When switching Talkinghead off, force-set the character to the last known expression, if any.
628 // This preserves the same expression Talkinghead had at the moment it was switched off.
629 const charName = getContext().name2;
630 const last = lastExpression[charName];
631 const targetExpression = last ? last : getFallbackExpression();
632 setExpression(charName, targetExpression, true);
633 }
634}
635
636async function moduleWorker() {434async function moduleWorker() {
637 const context = getContext();435 const context = getContext();
638436
639 // Hide and disable Talkinghead while not in extras
640 $('#image_type_block').toggle(extension_settings.expressions.api == EXPRESSION_API.extras);
641
642 if (extension_settings.expressions.api != EXPRESSION_API.extras && extension_settings.expressions.talkinghead) {
643 $('#image_type_toggle').prop('checked', false);
644 setTalkingHeadState(false);
645 }
646
647 // non-characters not supported437 // non-characters not supported
648 if (!context.groupId && context.characterId === undefined) {438 if (!context.groupId && context.characterId === undefined) {
649 removeExpression();439 removeExpression();
@@ -773,91 +563,6 @@ async function moduleWorker() {
773 }563 }
774}564}
775565
776/**
777 * Starts/stops Talkinghead talking animation.
778 *
779 * Talking starts only when all the following conditions are met:
780 * - The LLM is currently streaming its output.
781 * - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
782 * - The AI's current last message has changed from what we saw during the previous call.
783 *
784 * In all other cases, talking stops.
785 *
786 * A Talkinghead API call is made only when the talking state changes.
787 *
788 * Note that also the TTS system, if enabled, starts/stops the Talkinghead talking animation.
789 * See `talkingAnimation` in `SillyTavern/public/scripts/extensions/tts/index.js`.
790 */
791async function updateTalkingState() {
792 // Don't bother if Talkinghead is disabled or not loaded.
793 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
794 return;
795 }
796
797 const context = getContext();
798 const currentLastMessage = getLastCharacterMessage();
799
800 try {
801 // TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
802 // (that similarly checks the streaming processor state) does that for some reason.
803 // Talkinghead isn't currently designed to work with groups.
804 const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
805 const url = new URL(getApiUrl());
806 let newTalkingState;
807 if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
808 currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
809 url.pathname = '/api/talkinghead/start_talking';
810 newTalkingState = true;
811 } else {
812 url.pathname = '/api/talkinghead/stop_talking';
813 newTalkingState = false;
814 }
815 try {
816 // Call the Talkinghead API only if the talking state changed.
817 if (newTalkingState !== lastTalkingState) {
818 console.debug(`updateTalkingState: calling ${url.pathname}`);
819 await doExtrasFetch(url);
820 }
821 }
822 catch (error) {
823 // it's ok if not supported
824 }
825 finally {
826 lastTalkingState = newTalkingState;
827 }
828 }
829 catch (error) {
830 // console.log(error);
831 }
832 finally {
833 lastTalkingStateMessage = currentLastMessage.mes;
834 }
835}
836
837/**
838 * Checks whether the current character has a talkinghead image available.
839 * @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
840 */
841async function isTalkingHeadAvailable() {
842 let spriteFolderName = getSpriteFolderName();
843
844 try {
845 await validateImages(spriteFolderName);
846
847 let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
848 let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
849
850 if (talkingheadPath != null) {
851 return true;
852 } else {
853 await unloadTalkingHead();
854 return false;
855 }
856 } catch (err) {
857 return err;
858 }
859}
860
861function getSpriteFolderName(characterMessage = null, characterName = null) {566function getSpriteFolderName(characterMessage = null, characterName = null) {
862 const context = getContext();567 const context = getContext();
863 let spriteFolderName = characterName ?? context.name2;568 let spriteFolderName = characterName ?? context.name2;
@@ -872,33 +577,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
872 return spriteFolderName;577 return spriteFolderName;
873}578}
874579
875function setTalkingHeadState(newState) {
876 console.debug(`expressions: New talkinghead state: ${newState}`);
877 extension_settings.expressions.talkinghead = newState; // Store setting
878 saveSettingsDebounced();
879
880 if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
881 return;
882 }
883
884 isTalkingHeadAvailable().then(result => {
885 if (result) {
886 //console.log("talkinghead exists!");
887
888 if (extension_settings.expressions.talkinghead) {
889 loadTalkingHead();
890 } else {
891 unloadTalkingHead();
892 }
893 handleImageChange(); // Change image as needed
894
895
896 } else {
897 //console.log("talkinghead does not exist.");
898 }
899 });
900}
901
902function getFolderNameByMessage(message) {580function getFolderNameByMessage(message) {
903 const context = getContext();581 const context = getContext();
904 let avatarPath = '';582 let avatarPath = '';
@@ -976,34 +654,28 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
976}654}
977655
978async function setSpriteSlashCommand(_, spriteId) {656async function setSpriteSlashCommand(_, spriteId) {
657 spriteId = spriteId.trim().toLowerCase();
979 if (!spriteId) {658 if (!spriteId) {
980 console.log('No sprite id provided');659 console.log('No sprite id provided');
981 return '';660 return '';
982 }661 }
983662
984 spriteId = spriteId.trim().toLowerCase();663 const spriteFolderName = getSpriteFolderName();
985
986 // In Talkinghead mode, don't check for the existence of the sprite
987 // (emotion names are the same as for sprites, but it only needs "talkinghead.png").
988 const currentLastMessage = getLastCharacterMessage();
989 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
990 let label = spriteId;
991 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
992 await validateImages(spriteFolderName);
993664
994 // Fuzzy search for sprite665 await validateImages(spriteFolderName);
995 const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
996 const results = fuse.search(spriteId);
997 const spriteItem = results[0]?.item;
998666
999 if (!spriteItem) {667 // Fuzzy search for sprite
1000 console.log('No sprite found for search term ' + spriteId);668 const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
1001 return '';669 const results = fuse.search(spriteId);
1002 }670 const spriteItem = results[0]?.item;
1003671
1004 label = spriteItem.label;672 if (!spriteItem) {
673 console.log('No sprite found for search term ' + spriteId);
674 return '';
1005 }675 }
1006676
677 const label = spriteItem.label;
678
1007 const vnMode = isVisualNovelMode();679 const vnMode = isVisualNovelMode();
1008 await sendExpressionCall(spriteFolderName, label, true, vnMode);680 await sendExpressionCall(spriteFolderName, label, true, vnMode);
1009 return label;681 return label;
@@ -1190,7 +862,7 @@ function getJsonSchema(emotions) {
1190function onTextGenSettingsReady(args) {862function onTextGenSettingsReady(args) {
1191 // Only call if inside an API call863 // Only call if inside an API call
1192 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {864 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1193 const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');865 const emotions = DEFAULT_EXPRESSIONS;
1194 Object.assign(args, {866 Object.assign(args, {
1195 top_k: 1,867 top_k: 1,
1196 stop: [],868 stop: [],
@@ -1581,7 +1253,7 @@ export async function getExpressionsList() {
1581 }1253 }
15821254
1583 // If there was no specific list, or an error, just return the default expressions1255 // If there was no specific list, or an error, just return the default expressions
1584 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();1256 expressionsList = DEFAULT_EXPRESSIONS.slice();
1585 return expressionsList;1257 return expressionsList;
1586 }1258 }
15871259
@@ -1597,162 +1269,126 @@ export async function getExpressionsList() {
1597 * @returns {Promise<void>} A promise that resolves when the expression has been set.1269 * @returns {Promise<void>} A promise that resolves when the expression has been set.
1598 */1270 */
1599async function setExpression(character, expression, force = false) {1271async function setExpression(character, expression, force = false) {
1600 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {1272 console.debug('entered setExpressions');
1601 console.debug('entered setExpressions');1273 await validateImages(character);
1602 await validateImages(character);1274 const img = $('img.expression');
1603 const img = $('img.expression');1275 const prevExpressionSrc = img.attr('src');
1604 const prevExpressionSrc = img.attr('src');1276 const expressionClone = img.clone();
1605 const expressionClone = img.clone();1277
16061278 /** @type {Expression} */
1607 /** @type {Expression} */1279 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1608 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));1280 console.debug('checking for expression images to show..');
1609 console.debug('checking for expression images to show..');1281 if (sprite && sprite.files.length > 0) {
1610 if (sprite && sprite.files.length > 0) {1282 console.debug('setting expression from character images folder');
1611 console.debug('setting expression from character images folder');1283
16121284 let spriteFile = sprite.files[0];
1613 let spriteFile = sprite.files[0];1285
16141286 // Calculate next expression, if multiple are allowed
1615 // Calculate next expression, if multiple are allowed1287 if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1616 if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {1288 let possibleFiles = sprite.files;
1617 let possibleFiles = sprite.files;1289 if (extension_settings.expressions.rerollIfSame) {
1618 if (extension_settings.expressions.rerollIfSame) {1290 possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc);
1619 possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc);
1620 }
1621 spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1622 }1291 }
1292 spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1293 }
16231294
1624 if (force && isVisualNovelMode()) {1295 if (force && isVisualNovelMode()) {
1625 const context = getContext();1296 const context = getContext();
1626 const group = context.groups.find(x => x.id === context.groupId);1297 const group = context.groups.find(x => x.id === context.groupId);
16271298
1628 for (const member of group.members) {1299 for (const member of group.members) {
1629 const groupMember = context.characters.find(x => x.avatar === member);1300 const groupMember = context.characters.find(x => x.avatar === member);
16301301
1631 if (!groupMember) {1302 if (!groupMember) {
1632 continue;1303 continue;
1633 }1304 }
16341305
1635 if (groupMember.name == character) {1306 if (groupMember.name == character) {
1636 await setImage($(`.expression-holder[data-avatar="${member}"] img`), spriteFile.imageSrc);1307 await setImage($(`.expression-holder[data-avatar="${member}"] img`), spriteFile.imageSrc);
1637 return;1308 return;
1638 }
1639 }1309 }
1640 }1310 }
1641 //only swap expressions when necessary1311 }
1642 if (prevExpressionSrc !== spriteFile.imageSrc1312 //only swap expressions when necessary
1643 && !img.hasClass('expression-animating')) {1313 if (prevExpressionSrc !== spriteFile.imageSrc
1644 //clone expression1314 && !img.hasClass('expression-animating')) {
1645 expressionClone.addClass('expression-clone');1315 //clone expression
1646 //make invisible and remove id to prevent double ids1316 expressionClone.addClass('expression-clone');
1647 //must be made invisible to start because they share the same Z-index1317 //make invisible and remove id to prevent double ids
1648 expressionClone.attr('id', '').css({ opacity: 0 });1318 //must be made invisible to start because they share the same Z-index
1649 //add new sprite path to clone src1319 expressionClone.attr('id', '').css({ opacity: 0 });
1650 expressionClone.attr('src', spriteFile.imageSrc);1320 //add new sprite path to clone src
1651 //add invisible clone to html1321 expressionClone.attr('src', spriteFile.imageSrc);
1652 expressionClone.appendTo($('#expression-holder'));1322 //add invisible clone to html
16531323 expressionClone.appendTo($('#expression-holder'));
1654 const duration = 200;
1655
1656 //add animation flags to both images
1657 //to prevent multiple expression changes happening simultaneously
1658 img.addClass('expression-animating');
1659
1660 // Set the parent container's min width and height before running the transition
1661 const imgWidth = img.width();
1662 const imgHeight = img.height();
1663 const expressionHolder = img.parent();
1664 expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
1665 expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
1666
1667 //position absolute prevent the original from jumping around during transition
1668 img.css('position', 'absolute').width(imgWidth).height(imgHeight);
1669 expressionClone.addClass('expression-animating');
1670 //fade the clone in
1671 expressionClone.css({
1672 opacity: 0,
1673 }).animate({
1674 opacity: 1,
1675 }, duration)
1676 //when finshed fading in clone, fade out the original
1677 .promise().done(function () {
1678 img.animate({
1679 opacity: 0,
1680 }, duration);
1681 //remove old expression
1682 img.remove();
1683 //replace ID so it becomes the new 'original' expression for next change
1684 expressionClone.attr('id', 'expression-image');
1685 expressionClone.removeClass('expression-animating');
1686
1687 // Reset the expression holder min height and width
1688 expressionHolder.css('min-width', 100);
1689 expressionHolder.css('min-height', 100);
1690 });
16911324
1325 const duration = 200;
16921326
1693 expressionClone.removeClass('expression-clone');1327 //add animation flags to both images
1328 //to prevent multiple expression changes happening simultaneously
1329 img.addClass('expression-animating');
16941330
1695 expressionClone.removeClass('default');1331 // Set the parent container's min width and height before running the transition
1696 expressionClone.off('error');1332 const imgWidth = img.width();
1697 expressionClone.on('error', function () {1333 const imgHeight = img.height();
1698 console.debug('Expression image error', spriteFile.imageSrc);1334 const expressionHolder = img.parent();
1699 $(this).attr('src', '');1335 expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
1700 $(this).off('error');1336 expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
1701 if (force && extension_settings.expressions.showDefault) {
1702 setDefault();
1703 }
1704 });
1705 }
1706 }
1707 else {
1708 if (extension_settings.expressions.showDefault) {
1709 setDefault();
1710 }
1711 }
17121337
1713 function setDefault() {1338 //position absolute prevent the original from jumping around during transition
1714 console.debug('setting default');1339 img.css('position', 'absolute').width(imgWidth).height(imgHeight);
1715 const defImgUrl = `/img/default-expressions/${expression}.png`;1340 expressionClone.addClass('expression-animating');
1716 //console.log(defImgUrl);1341 //fade the clone in
1717 img.attr('src', defImgUrl);1342 expressionClone.css({
1718 img.addClass('default');1343 opacity: 0,
1719 }1344 }).animate({
1720 document.getElementById('expression-holder').style.display = '';1345 opacity: 1,
1346 }, duration)
1347 //when finshed fading in clone, fade out the original
1348 .promise().done(function () {
1349 img.animate({
1350 opacity: 0,
1351 }, duration);
1352 //remove old expression
1353 img.remove();
1354 //replace ID so it becomes the new 'original' expression for next change
1355 expressionClone.attr('id', 'expression-image');
1356 expressionClone.removeClass('expression-animating');
17211357
1722 } else {1358 // Reset the expression holder min height and width
1723 // Set the Talkinghead emotion to the specified expression1359 expressionHolder.css('min-width', 100);
1724 // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.1360 expressionHolder.css('min-height', 100);
1725 try {
1726 let result = await isTalkingHeadAvailable();
1727 if (result) {
1728 const url = new URL(getApiUrl());
1729 url.pathname = '/api/talkinghead/set_emotion';
1730 await doExtrasFetch(url, {
1731 method: 'POST',
1732 headers: {
1733 'Content-Type': 'application/json',
1734 },
1735 body: JSON.stringify({ emotion_name: expression }),
1736 });1361 });
1737 }
1738 }
1739 catch (error) {
1740 // `set_emotion` is not present in old versions, so let it 404.
1741 }
17421362
1743 try {1363
1744 // Find the <img> element with id="expression-image" and class="expression"1364 expressionClone.removeClass('expression-clone');
1745 const imgElement = document.querySelector('img#expression-image.expression');1365
1746 //console.log("searching");1366 expressionClone.removeClass('default');
1747 if (imgElement && imgElement instanceof HTMLImageElement) {1367 expressionClone.off('error');
1748 //console.log("setting value");1368 expressionClone.on('error', function () {
1749 imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';1369 console.debug('Expression image error', spriteFile.imageSrc);
1750 }1370 $(this).attr('src', '');
1371 $(this).off('error');
1372 if (force && extension_settings.expressions.showDefault) {
1373 setDefault();
1374 }
1375 });
1751 }1376 }
1752 catch (error) {1377 }
1753 //console.log("The fetch failed!");1378 else {
1379 if (extension_settings.expressions.showDefault) {
1380 setDefault();
1754 }1381 }
1755 }1382 }
1383
1384 function setDefault() {
1385 console.debug('setting default');
1386 const defImgUrl = `/img/default-expressions/${expression}.png`;
1387 //console.log(defImgUrl);
1388 img.attr('src', defImgUrl);
1389 img.addClass('default');
1390 }
1391 document.getElementById('expression-holder').style.display = '';
1756}1392}
17571393
1758function onClickExpressionImage() {1394function onClickExpressionImage() {
@@ -1976,11 +1612,6 @@ async function onClickExpressionUpload(event) {
19761612
1977 // Reset the input1613 // Reset the input
1978 e.target.form.reset();1614 e.target.form.reset();
1979
1980 // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
1981 if (expression === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1982 await loadTalkingHead();
1983 }
1984 };1615 };
19851616
1986 $('#expression_upload')1617 $('#expression_upload')
@@ -2091,11 +1722,6 @@ async function onClickExpressionUploadPackButton() {
20911722
2092 // Reset the input1723 // Reset the input
2093 e.target.form.reset();1724 e.target.form.reset();
2094
2095 // In Talkinghead mode, refresh the live char.
2096 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
2097 await loadTalkingHead();
2098 }
2099 };1725 };
21001726
2101 $('#expression_upload_pack')1727 $('#expression_upload_pack')
@@ -2254,18 +1880,12 @@ function migrateSettings() {
2254 $(window).on('resize', updateVisualNovelModeDebounced);1880 $(window).on('resize', updateVisualNovelModeDebounced);
2255 $('#open_chat_expressions').hide();1881 $('#open_chat_expressions').hide();
22561882
2257 $('#image_type_toggle').on('click', function () {
2258 if (this instanceof HTMLInputElement) {
2259 setTalkingHeadState(this.checked);
2260 }
2261 });
2262
2263 await renderAdditionalExpressionSettings();1883 await renderAdditionalExpressionSettings();
2264 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);1884 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
2265 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));1885 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
2266 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');1886 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
2267 $('#expression_llm_prompt').on('input', function () {1887 $('#expression_llm_prompt').on('input', function () {
2268 extension_settings.expressions.llmPrompt = $(this).val();1888 extension_settings.expressions.llmPrompt = String($(this).val());
2269 saveSettingsDebounced();1889 saveSettingsDebounced();
2270 });1890 });
2271 $('#expression_llm_prompt_restore').on('click', function () {1891 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2280,34 +1900,6 @@ function migrateSettings() {
2280 $('#expression_api').on('change', onExpressionApiChanged);1900 $('#expression_api').on('change', onExpressionApiChanged);
2281 }1901 }
22821902
2283 // Pause Talkinghead to save resources when the ST tab is not visible or the window is minimized.
2284 // We currently do this via loading/unloading. Could be improved by adding new pause/unpause endpoints to Extras.
2285 document.addEventListener('visibilitychange', function (event) {
2286 let pageIsVisible;
2287 if (document.hidden) {
2288 console.debug('expressions: SillyTavern is now hidden');
2289 pageIsVisible = false;
2290 } else {
2291 console.debug('expressions: SillyTavern is now visible');
2292 pageIsVisible = true;
2293 }
2294
2295 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
2296 isTalkingHeadAvailable().then(result => {
2297 if (result) {
2298 if (pageIsVisible) {
2299 loadTalkingHead();
2300 } else {
2301 unloadTalkingHead();
2302 }
2303 handleImageChange(); // Change image as needed
2304 } else {
2305 //console.log("talkinghead does not exist.");
2306 }
2307 });
2308 }
2309 });
2310
2311 addExpressionImage();1903 addExpressionImage();
2312 addVisualNovelMode();1904 addVisualNovelMode();
2313 migrateSettings();1905 migrateSettings();
@@ -2316,11 +1908,6 @@ function migrateSettings() {
2316 const updateFunction = wrapper.update.bind(wrapper);1908 const updateFunction = wrapper.update.bind(wrapper);
2317 setInterval(updateFunction, UPDATE_INTERVAL);1909 setInterval(updateFunction, UPDATE_INTERVAL);
2318 moduleWorker();1910 moduleWorker();
2319 // For setting the Talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
2320 const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
2321 const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
2322 setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
2323 updateTalkingState();
2324 dragElement($('#expression-holder'));1911 dragElement($('#expression-holder'));
2325 eventSource.on(event_types.CHAT_CHANGED, () => {1912 eventSource.on(event_types.CHAT_CHANGED, () => {
2326 // character changed1913 // character changed
@@ -2334,12 +1921,6 @@ function migrateSettings() {
2334 imgElement.src = '';1921 imgElement.src = '';
2335 }1922 }
23361923
2337 //set checkbox to global var
2338 $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
2339 if (extension_settings.expressions.talkinghead) {
2340 setTalkingHeadState(extension_settings.expressions.talkinghead);
2341 }
2342
2343 setExpressionOverrideHtml();1924 setExpressionOverrideHtml();
23441925
2345 if (isVisualNovelMode()) {1926 if (isVisualNovelMode()) {
@@ -2350,11 +1931,6 @@ function migrateSettings() {
2350 });1931 });
2351 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);1932 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
2352 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);1933 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
2353 eventSource.on(event_types.EXTRAS_CONNECTED, () => {
2354 if (extension_settings.expressions.talkinghead) {
2355 setTalkingHeadState(extension_settings.expressions.talkinghead);
2356 }
2357 });
23581934
2359 const localEnumProviders = {1935 const localEnumProviders = {
2360 expressions: () => getCachedExpressions().map(expression => {1936 expressions: () => getCachedExpressions().map(expression => {
@@ -2421,13 +1997,6 @@ function migrateSettings() {
2421 helpString: 'Returns the last set expression for the named character.',1997 helpString: 'Returns the last set expression for the named character.',
2422 }));1998 }));
2423 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1999 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2424 name: 'expression-talkinghead',
2425 callback: toggleTalkingHeadCommand,
2426 aliases: ['th', 'talkinghead'],
2427 helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2428 returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
2429 }));
2430 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2431 name: 'expression-classify',2000 name: 'expression-classify',
2432 aliases: ['classify-expressions', 'expressions'],2001 aliases: ['classify-expressions', 'expressions'],
2433 /** @type {(args: {return: string}) => Promise<string>} */2002 /** @type {(args: {return: string}) => Promise<string>} */
public/scripts/extensions/expressions/settings.html+0 -4
@@ -22,10 +22,6 @@
22 <input id="expressions_reroll_if_same" type="checkbox">22 <input id="expressions_reroll_if_same" type="checkbox">
23 <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>23 <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
24 </label>24 </label>
25 <label id="image_type_block" class="checkbox_label" for="image_type_toggle">
26 <input id="image_type_toggle" type="checkbox">
27 <span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span>
28 </label>
29 <div class="expression_api_block m-b-1 m-t-1">25 <div class="expression_api_block m-b-1 m-t-1">
30 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>26 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
31 <small data-i18n="Select the API for classifying expressions.">Select the API for classifying expressions.</small>27 <small data-i18n="Select the API for classifying expressions.">Select the API for classifying expressions.</small>
public/scripts/extensions/tts/index.js+0 -26
@@ -27,13 +27,11 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
29import { GoogleTranslateTtsProvider } from './google-translate.js';29import { GoogleTranslateTtsProvider } from './google-translate.js';
30export { talkingAnimation };
3130
32const UPDATE_INTERVAL = 1000;31const UPDATE_INTERVAL = 1000;
3332
34let voiceMapEntries = [];33let voiceMapEntries = [];
35let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}34let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
36let talkingHeadState = false;
37let lastChatId = null;35let lastChatId = null;
38let lastMessage = null;36let lastMessage = null;
39let lastMessageHash = null;37let lastMessageHash = null;
@@ -165,27 +163,6 @@ async function moduleWorker() {
165 updateUiAudioPlayState();163 updateUiAudioPlayState();
166}164}
167165
168function talkingAnimation(switchValue) {
169 if (!modules.includes('talkinghead')) {
170 console.debug('Talking Animation module not loaded');
171 return;
172 }
173
174 const apiUrl = getApiUrl();
175 const animationType = switchValue ? 'start' : 'stop';
176
177 if (switchValue !== talkingHeadState) {
178 try {
179 console.log(animationType + ' Talking Animation');
180 doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
181 talkingHeadState = switchValue;
182 } catch (error) {
183 // Handle the error here or simply ignore it to prevent logging
184 }
185 }
186 updateUiAudioPlayState();
187}
188
189function resetTtsPlayback() {166function resetTtsPlayback() {
190 // Stop system TTS utterance167 // Stop system TTS utterance
191 cancelTtsPlay();168 cancelTtsPlay();
@@ -347,7 +324,6 @@ function onAudioControlClicked() {
347 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful324 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
348 if (!audioElement.paused || isTtsProcessing()) {325 if (!audioElement.paused || isTtsProcessing()) {
349 resetTtsPlayback();326 resetTtsPlayback();
350 talkingAnimation(false);
351 } else {327 } else {
352 // Default play behavior if not processing or playing is to play the last message.328 // Default play behavior if not processing or playing is to play the last message.
353 ttsJobQueue.push(context.chat[context.chat.length - 1]);329 ttsJobQueue.push(context.chat[context.chat.length - 1]);
@@ -374,7 +350,6 @@ function addAudioControl() {
374function completeCurrentAudioJob() {350function completeCurrentAudioJob() {
375 audioQueueProcessorReady = true;351 audioQueueProcessorReady = true;
376 currentAudioJob = null;352 currentAudioJob = null;
377 talkingAnimation(false); //stop lip animation
378 // updateUiPlayState();353 // updateUiPlayState();
379}354}
380355
@@ -404,7 +379,6 @@ async function processAudioJobQueue() {
404 audioQueueProcessorReady = false;379 audioQueueProcessorReady = false;
405 currentAudioJob = audioJobQueue.shift();380 currentAudioJob = audioJobQueue.shift();
406 playAudioData(currentAudioJob);381 playAudioData(currentAudioJob);
407 talkingAnimation(true);
408 } catch (error) {382 } catch (error) {
409 toastr.error(error.toString());383 toastr.error(error.toString());
410 console.error(error);384 console.error(error);
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
1import { isMobile } from '../../RossAscends-mods.js';1import { isMobile } from '../../RossAscends-mods.js';
2import { getPreviewString } from './index.js';2import { getPreviewString } from './index.js';
3import { talkingAnimation } from './index.js';
4import { saveTtsProviderSettings } from './index.js';3import { saveTtsProviderSettings } from './index.js';
5export { SystemTtsProvider };4export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
70 //placing the speak invocation inside a callback fixes ordering and onend issues.69 //placing the speak invocation inside a callback fixes ordering and onend issues.
71 setTimeout(function () {70 setTimeout(function () {
72 speechSynthesis.speak(newUtt);71 speechSynthesis.speak(newUtt);
73 talkingAnimation(true);
74 }, 0);72 }, 0);
75};73};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240 //some code to execute when done238 //some code to execute when done
241 resolve(silence);239 resolve(silence);
242 console.log('System TTS done');240 console.log('System TTS done');
243 talkingAnimation(false);
244 });241 });
245 });242 });
246 }243 }