diff --git a/d.ts/src/helpers/socket.d.ts b/d.ts/src/helpers/socket.d.ts index 9d41528fefb..e987e7de437 100644 --- a/d.ts/src/helpers/socket.d.ts +++ b/d.ts/src/helpers/socket.d.ts @@ -12,7 +12,7 @@ import type { HighlightInterface } from '@entity/highlight'; import type { HowLongToBeatGameInterface, HowLongToBeatGameItemInterface } from '@entity/howLongToBeatGame'; import type { KeywordGroupInterface, KeywordInterface } from '@entity/keyword'; import type { OBSWebsocketInterface } from '@entity/obswebsocket'; -import type { OverlayMapperMarathon, Overlay } from '@entity/overlay'; +import type { OverlayMapperMarathon, Overlay, TTSService } from '@entity/overlay'; import type { Permissions } from '@entity/permissions'; import type { QueueInterface } from '@entity/queue'; import type { QuotesInterface } from '@entity/quotes'; @@ -194,7 +194,7 @@ export type ClientToServerEventsWithNamespace = { 'message': (data: { id: string, show: boolean; message: string; username: string, timestamp: number, badges: any }) => void, }, '/overlays/texttospeech': GenericEvents & { - 'speak': (data: { text: string; highlight: boolean, service: 0 | 1, key: string }) => void, + 'speak': (data: { text: string; highlight: boolean, key: string }) => void, }, '/overlays/wordcloud': GenericEvents & { 'wordcloud:word': (words: string[]) => void, @@ -251,7 +251,6 @@ export type ClientToServerEventsWithNamespace = { '/registries/alerts': GenericEvents & { 'alerts::settings': (data: null | { areAlertsMuted: boolean; isSoundMuted: boolean; isTTSMuted: boolean; }, cb: (item: { areAlertsMuted: boolean; isSoundMuted: boolean; isTTSMuted: boolean; }) => void) => void, 'test': (emit: EmitData) => void, - 'speak': (opts: { text: string, key: string, voice: string; volume: number; rate: number; pitch: number }, cb: (error: Error | string | null | unknown, b64mp3: string) => void) => void, 'alert': (data: (EmitData & { id: string; isTTSMuted: boolean; @@ -459,8 +458,7 @@ export type ClientToServerEventsWithNamespace = { 'events::remove': (eventId: Required, cb: (error: Error | string | null | unknown) => void) => void, }, '/core/tts': GenericEvents & { - 'google::speak': (opts: { volume: number; pitch: number; rate: number; text: string; voice: string; }, cb: (error: Error | string | null | unknown, audioContent?: string | null) => void) => void, - 'speak': (opts: { text: string, key: string, voice: string; volume: number; rate: number; pitch: number; triggerTTSByHighlightedMessage?: boolean; }, cb: (error: Error | string | null | unknown, b64mp3: string) => void) => void, + 'speak': (opts: { service: TTSService, text: string, key: string, voice: string; volume: number; rate: number; pitch: number; triggerTTSByHighlightedMessage?: boolean; }, cb: (error: Error | string | null | unknown, b64mp3?: string) => void) => void, }, '/core/ui': GenericEvents & { 'configuration': (cb: (error: Error | string | null | unknown, data?: Configuration) => void) => void, diff --git a/src/database/entity/overlay.ts b/src/database/entity/overlay.ts index fdcc650d0c6..764c3116e27 100644 --- a/src/database/entity/overlay.ts +++ b/src/database/entity/overlay.ts @@ -331,6 +331,13 @@ export interface Media { typeId: 'media'; } +export enum TTSService { + NONE = '-1', + RESPONSIVEVOICE = '0', + GOOGLE = '1', + SPEECHSYNTHESIS = '2', +} + export interface Alerts { typeId: 'alerts'; alertDelayInMs: number; @@ -349,6 +356,29 @@ export interface Alerts { }>; globalFont2: ExpandRecursively; tts: { + selectedService: TTSService; + // we are using this to store tts settings for each service, so if we are changing service, then we can previously set settings + services: { + [TTSService.NONE]?: null, + [TTSService.SPEECHSYNTHESIS]?: { + voice: string; + pitch: number; + volume: number; + rate: number; + }, + [TTSService.GOOGLE]?: { + voice: string; + pitch: number; + volume: number; + rate: number; + }, + [TTSService.RESPONSIVEVOICE]?: { + voice: string; + pitch: number; + volume: number; + rate: number; + } + } voice: string; pitch: number; volume: number; @@ -533,10 +563,9 @@ export interface ClipsCarousel { export interface TTS { typeId: 'tts'; - voice: string, - volume: number, - rate: number, - pitch: number, + selectedService: TTSService; + // we are using this to store tts settings for each service, so if we are changing service, then we can previously set settings + services: Alerts['tts']['services']; triggerTTSByHighlightedMessage: boolean, } diff --git a/src/database/entity/randomizer.ts b/src/database/entity/randomizer.ts index d338e754f67..879dc2b529a 100644 --- a/src/database/entity/randomizer.ts +++ b/src/database/entity/randomizer.ts @@ -1,7 +1,7 @@ import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; import { z } from 'zod'; -import { AlertTTS } from './overlay.js'; +import { Alerts } from './overlay.js'; import { BotEntity } from '../BotEntity.js'; import { command } from '../validators/IsCommand.js'; @@ -89,5 +89,5 @@ export class Randomizer extends BotEntity { }; @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) - tts: AlertTTS['tts'] & { enabled: boolean }; + tts: Alerts['tts'] & { enabled: boolean }; } \ No newline at end of file diff --git a/src/database/migration/mysql/21.x/1678892044038-updateTTSSettings.ts b/src/database/migration/mysql/21.x/1678892044038-updateTTSSettings.ts new file mode 100644 index 00000000000..815bb463b33 --- /dev/null +++ b/src/database/migration/mysql/21.x/1678892044038-updateTTSSettings.ts @@ -0,0 +1,141 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { insertItemIntoTable } from '../../../insertItemIntoTable.js'; + +export class updateTTSSettings1678892044038 implements MigrationInterface { + name = 'updateTTSSettings1678892044038'; + + public async up(queryRunner: QueryRunner): Promise { + const items = await queryRunner.query(`SELECT * FROM \`settings\` WHERE \`namespace\` = '/core/tts' AND \`name\` = 'service'`); + if (items.length === 0) { + return; + } + const service = JSON.parse(items[0].value) as number; + + const randomizers = await queryRunner.query(`SELECT * FROM \`randomizer\``); + for (const randomizer of randomizers) { + randomizer.tts = JSON.parse(randomizer.tts); + console.log(`Updating randomizer with id ${randomizer.id} for new TTS settings.`); + randomizer.tts.selectedService = String(service); + randomizer.tts.services = { + [String(service)]: { + voice: randomizer.tts.voice, + volume: randomizer.tts.volume, + rate: randomizer.tts.rate, + pitch: randomizer.tts.pitch, + }, + }; + delete randomizer.tts.voice; + delete randomizer.tts.volume; + delete randomizer.tts.rate; + delete randomizer.tts.pitch; + randomizer.tts = JSON.stringify(randomizer.tts); + + // save back to db + await queryRunner.query(`DELETE FROM \`randomizer\` WHERE \`id\` = '${randomizer.id}'`); + await insertItemIntoTable('randomizer', randomizer, queryRunner); + } + + const overlays = await queryRunner.query(`SELECT * FROM \`overlay\``); + for (const overlay of overlays) { + overlay.items = JSON.parse(overlay.items); + for (const item of overlay.items) { + // update TTS overlay + if (item.opts.typeId === 'tts') { + console.log(`Updating TTS overlay with id ${item.id} for new TTS settings.`); + item.opts.selectedService = String(service); + item.opts.services = { + [String(service)]: { + voice: item.opts.voice, + volume: item.opts.volume, + rate: item.opts.rate, + pitch: item.opts.pitch, + }, + }; + delete item.opts.voice; + delete item.opts.volume; + delete item.opts.rate; + delete item.opts.pitch; + } + + // update Alert overlay + if (item.opts.typeId === 'alerts') { + console.log(`Updating Alert overlay with id ${item.id} for new TTS settings.`); + item.opts.tts.selectedService = String(service); + item.opts.tts.services = { + [String(service)]: { + voice: item.opts.tts.voice, + volume: item.opts.tts.volume, + rate: item.opts.tts.rate, + pitch: item.opts.tts.pitch, + }, + }; + delete item.opts.tts.voice; + delete item.opts.tts.volume; + delete item.opts.tts.rate; + delete item.opts.tts.pitch; + + for (const group of item.opts.items) { + for (const variant of group.variants) { + for (const component of variant.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay variant component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay variant component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + for (const component of group.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + } + } + overlay.items = JSON.stringify(overlay.items); + + // save back to db + await queryRunner.query(`DELETE FROM \`overlay\` WHERE \`id\` = '${overlay.id}'`); + await insertItemIntoTable('overlay', overlay, queryRunner); + } + return; + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/src/database/migration/postgres/21.x/1678892044038-updateTTSSettings.ts b/src/database/migration/postgres/21.x/1678892044038-updateTTSSettings.ts new file mode 100644 index 00000000000..9261cbdd300 --- /dev/null +++ b/src/database/migration/postgres/21.x/1678892044038-updateTTSSettings.ts @@ -0,0 +1,141 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { insertItemIntoTable } from '../../../insertItemIntoTable.js'; + +export class updateTTSSettings1678892044038 implements MigrationInterface { + name = 'updateTTSSettings1678892044038'; + + public async up(queryRunner: QueryRunner): Promise { + const items = await queryRunner.query(`SELECT * FROM "settings" WHERE "namespace" = '/core/tts' AND "name" = 'service'`); + if (items.length === 0) { + return; + } + const service = JSON.parse(items[0].value) as number; + + const randomizers = await queryRunner.query(`SELECT * FROM "randomizer"`); + for (const randomizer of randomizers) { + randomizer.tts = JSON.parse(randomizer.tts); + console.log(`Updating randomizer with id ${randomizer.id} for new TTS settings.`); + randomizer.tts.selectedService = String(service); + randomizer.tts.services = { + [String(service)]: { + voice: randomizer.tts.voice, + volume: randomizer.tts.volume, + rate: randomizer.tts.rate, + pitch: randomizer.tts.pitch, + }, + }; + delete randomizer.tts.voice; + delete randomizer.tts.volume; + delete randomizer.tts.rate; + delete randomizer.tts.pitch; + randomizer.tts = JSON.stringify(randomizer.tts); + + // save back to db + await queryRunner.query(`DELETE FROM "randomizer" WHERE "id" = '${randomizer.id}'`); + await insertItemIntoTable('randomizer', randomizer, queryRunner); + } + + const overlays = await queryRunner.query(`SELECT * FROM "overlay"`); + for (const overlay of overlays) { + overlay.items = JSON.parse(overlay.items); + for (const item of overlay.items) { + // update TTS overlay + if (item.opts.typeId === 'tts') { + console.log(`Updating TTS overlay with id ${item.id} for new TTS settings.`); + item.opts.selectedService = String(service); + item.opts.services = { + [String(service)]: { + voice: item.opts.voice, + volume: item.opts.volume, + rate: item.opts.rate, + pitch: item.opts.pitch, + }, + }; + delete item.opts.voice; + delete item.opts.volume; + delete item.opts.rate; + delete item.opts.pitch; + } + + // update Alert overlay + if (item.opts.typeId === 'alerts') { + console.log(`Updating Alert overlay with id ${item.id} for new TTS settings.`); + item.opts.tts.selectedService = String(service); + item.opts.tts.services = { + [String(service)]: { + voice: item.opts.tts.voice, + volume: item.opts.tts.volume, + rate: item.opts.tts.rate, + pitch: item.opts.tts.pitch, + }, + }; + delete item.opts.tts.voice; + delete item.opts.tts.volume; + delete item.opts.tts.rate; + delete item.opts.tts.pitch; + + for (const group of item.opts.items) { + for (const variant of group.variants) { + for (const component of variant.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay variant component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay variant component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + for (const component of group.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + } + } + overlay.items = JSON.stringify(overlay.items); + + // save back to db + await queryRunner.query(`DELETE FROM "overlay" WHERE "id" = '${overlay.id}'`); + await insertItemIntoTable('overlay', overlay, queryRunner); + } + return; + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/src/database/migration/sqlite/21.x/1678892044038-updateTTSSettings.ts b/src/database/migration/sqlite/21.x/1678892044038-updateTTSSettings.ts new file mode 100644 index 00000000000..9261cbdd300 --- /dev/null +++ b/src/database/migration/sqlite/21.x/1678892044038-updateTTSSettings.ts @@ -0,0 +1,141 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { insertItemIntoTable } from '../../../insertItemIntoTable.js'; + +export class updateTTSSettings1678892044038 implements MigrationInterface { + name = 'updateTTSSettings1678892044038'; + + public async up(queryRunner: QueryRunner): Promise { + const items = await queryRunner.query(`SELECT * FROM "settings" WHERE "namespace" = '/core/tts' AND "name" = 'service'`); + if (items.length === 0) { + return; + } + const service = JSON.parse(items[0].value) as number; + + const randomizers = await queryRunner.query(`SELECT * FROM "randomizer"`); + for (const randomizer of randomizers) { + randomizer.tts = JSON.parse(randomizer.tts); + console.log(`Updating randomizer with id ${randomizer.id} for new TTS settings.`); + randomizer.tts.selectedService = String(service); + randomizer.tts.services = { + [String(service)]: { + voice: randomizer.tts.voice, + volume: randomizer.tts.volume, + rate: randomizer.tts.rate, + pitch: randomizer.tts.pitch, + }, + }; + delete randomizer.tts.voice; + delete randomizer.tts.volume; + delete randomizer.tts.rate; + delete randomizer.tts.pitch; + randomizer.tts = JSON.stringify(randomizer.tts); + + // save back to db + await queryRunner.query(`DELETE FROM "randomizer" WHERE "id" = '${randomizer.id}'`); + await insertItemIntoTable('randomizer', randomizer, queryRunner); + } + + const overlays = await queryRunner.query(`SELECT * FROM "overlay"`); + for (const overlay of overlays) { + overlay.items = JSON.parse(overlay.items); + for (const item of overlay.items) { + // update TTS overlay + if (item.opts.typeId === 'tts') { + console.log(`Updating TTS overlay with id ${item.id} for new TTS settings.`); + item.opts.selectedService = String(service); + item.opts.services = { + [String(service)]: { + voice: item.opts.voice, + volume: item.opts.volume, + rate: item.opts.rate, + pitch: item.opts.pitch, + }, + }; + delete item.opts.voice; + delete item.opts.volume; + delete item.opts.rate; + delete item.opts.pitch; + } + + // update Alert overlay + if (item.opts.typeId === 'alerts') { + console.log(`Updating Alert overlay with id ${item.id} for new TTS settings.`); + item.opts.tts.selectedService = String(service); + item.opts.tts.services = { + [String(service)]: { + voice: item.opts.tts.voice, + volume: item.opts.tts.volume, + rate: item.opts.tts.rate, + pitch: item.opts.tts.pitch, + }, + }; + delete item.opts.tts.voice; + delete item.opts.tts.volume; + delete item.opts.tts.rate; + delete item.opts.tts.pitch; + + for (const group of item.opts.items) { + for (const variant of group.variants) { + for (const component of variant.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay variant component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay variant component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + for (const component of group.items) { + if (component.type === 'tts') { + if (component.tts == null) { + console.log(`\t== Skipping Alert overlay component with id ${component.id} as it is inheriting global value.`); + continue; + } + console.log(`\t== Updating Alert overlay component with id ${component.id} for new TTS settings.`); + component.tts.selectedService = String(service); + component.tts.services = { + [String(service)]: { + voice: component.tts.voice, + volume: component.tts.volume, + rate: component.tts.rate, + pitch: component.tts.pitch, + }, + }; + delete component.tts.voice; + delete component.tts.volume; + delete component.tts.rate; + delete component.tts.pitch; + } + } + } + } + } + overlay.items = JSON.stringify(overlay.items); + + // save back to db + await queryRunner.query(`DELETE FROM "overlay" WHERE "id" = '${overlay.id}'`); + await insertItemIntoTable('overlay', overlay, queryRunner); + } + return; + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/src/overlays/texttospeech.ts b/src/overlays/texttospeech.ts index 790de72b5de..3cb0a32503d 100644 --- a/src/overlays/texttospeech.ts +++ b/src/overlays/texttospeech.ts @@ -1,10 +1,7 @@ -import { randomUUID } from 'node:crypto'; - import Overlay from './_interface.js'; import { command, default_permission, } from '../decorators.js'; -import { warning } from '../helpers/log.js'; import { onStartup } from '~/decorators/on.js'; import { eventEmitter } from '~/helpers/events/index.js'; @@ -24,25 +21,12 @@ class TextToSpeech extends Overlay { @command('!tts') @default_permission(defaultPermissions.CASTERS) async textToSpeech(opts: CommandOptions): Promise { - const { default: tts, services } = await import ('../tts.js'); - if (tts.ready) { - let key: string = randomUUID(); - if (tts.service === services.RESPONSIVEVOICE) { - key = tts.responsiveVoiceKey; - } - if (tts.service === services.GOOGLE) { - tts.addSecureKey(key); - } - - this.emit('speak', { - text: opts.parameters, - highlight: opts.isHighlight, - service: tts.service, - key, - }); - } else { - warning('!tts command cannot be executed. TTS is not properly set in a bot.'); - } + const { generateAndAddSecureKey } = await import ('../tts.js'); + this.emit('speak', { + text: opts.parameters, + highlight: opts.isHighlight, + key: generateAndAddSecureKey(), + }); return []; } } diff --git a/src/registries/alerts.ts b/src/registries/alerts.ts index 25577aaf849..c372822fdaf 100644 --- a/src/registries/alerts.ts +++ b/src/registries/alerts.ts @@ -1,13 +1,10 @@ -import { randomUUID } from 'node:crypto'; - -import { - EmitData, -} from '@entity/overlay.js'; -import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { EmitData } from '@entity/overlay.js'; import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; import Registry from './_interface.js'; import { command, default_permission, example, persistent, settings } from '../decorators.js'; +import * as changelog from '../helpers/user/changelog.js'; +import twitch from '../services/twitch.js'; import { parserReply } from '~/commons.js'; import { User, UserInterface } from '~/database/entity/user.js'; @@ -15,19 +12,14 @@ import { AppDataSource } from '~/database.js'; import { Expects } from '~/expects.js'; import { prepare } from '~/helpers/commons/index.js'; import { eventEmitter } from '~/helpers/events/emitter.js'; -import { error, debug, info } from '~/helpers/log.js'; +import { debug, info, error } from '~/helpers/log.js'; import { app, ioServer } from '~/helpers/panel.js'; import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; -import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; -import * as changelog from '~/helpers/user/changelog.js'; +import { adminEndpoint } from '~/helpers/socket.js'; import { Types } from '~/plugins/ListenTo.js'; -import twitch from '~/services/twitch.js'; import { translate } from '~/translate.js'; import { variables } from '~/watchers.js'; -/* secureKeys are used to authenticate use of public overlay endpoint */ -const secureKeys = new Set(); - const fetchUserForAlert = (opts: EmitData, type: 'recipient' | 'name'): Promise> & { game?: string } | null> => { return new Promise> & { game?: string } | null>((resolve) => { if ((opts.event === 'rewardredeem' || opts.event === 'custom') && type === 'name') { @@ -112,28 +104,6 @@ class Alerts extends Registry { }); }); - publicEndpoint('/registries/alerts', 'speak', async (opts, cb) => { - if (secureKeys.has(opts.key)) { - secureKeys.delete(opts.key); - - const { default: tts, services } = await import ('../tts.js'); - if (!tts.ready) { - cb(new Error('TTS is not properly set and ready.')); - return; - } - - if (tts.service === services.GOOGLE) { - try { - const audioContent = await tts.googleSpeak(opts); - cb(null, audioContent); - } catch (e) { - cb(e); - } - } - } else { - cb(new Error('Invalid auth.')); - } - }); adminEndpoint('/registries/alerts', 'alerts::settings', async (data, cb) => { if (data) { this.areAlertsMuted = data.areAlertsMuted; @@ -153,49 +123,14 @@ class Alerts extends Registry { monthsName: getLocalizedName(data.amount, translate('core.months')), }, true); }); - - publicEndpoint('/registries/alerts', 'speak', async (opts, cb) => { - if (secureKeys.has(opts.key)) { - secureKeys.delete(opts.key); - - const { default: tts, services } = await import ('../tts.js'); - if (!tts.ready) { - cb(new Error('TTS is not properly set and ready.')); - return; - } - - if (tts.service === services.GOOGLE) { - try { - const audioContent = await tts.googleSpeak(opts); - cb(null, audioContent); - } catch (e) { - cb(e); - } - } - } else { - cb(new Error('Invalid auth.')); - } - }); } async trigger(opts: EmitData, isTest = false) { debug('alerts.trigger', JSON.stringify(opts, null, 2)); - const { default: tts, services } = await import ('../tts.js'); - if (!this.areAlertsMuted || isTest) { - let key: string = randomUUID(); - if (tts.service === services.RESPONSIVEVOICE) { - key = tts.responsiveVoiceKey; - } - if (tts.service === services.GOOGLE) { - // add secureKey - secureKeys.add(key); - setTimeout(() => { - secureKeys.delete(key); - }, 10 * MINUTE); - } - - secureKeys.add(key); + const { generateAndAddSecureKey } = await import ('../tts.js'); + const key = generateAndAddSecureKey(); + if (!this.areAlertsMuted || isTest) { const [ user, recipient ] = await Promise.all([ fetchUserForAlert(opts, 'name'), fetchUserForAlert(opts, 'recipient'), @@ -206,7 +141,7 @@ class Alerts extends Registry { const caster = await AppDataSource.getRepository(User).findOneBy({ userId: broadcasterId }) ?? null; const data = { - ...opts, isTTSMuted: !tts.ready || this.isTTSMuted, isSoundMuted: this.isSoundMuted, TTSService: tts.service, TTSKey: key, user, game: user?.game, caster, recipientUser: recipient, id: randomUUID(), + ...opts, isTTSMuted: this.isTTSMuted, isSoundMuted: this.isSoundMuted, TTSKey: key, user, game: user?.game, caster, recipientUser: recipient, id: key, }; info(`Triggering alert send: ${JSON.stringify(data)}`); diff --git a/src/registries/randomizer.ts b/src/registries/randomizer.ts index 051e81367dc..19a07d86ad4 100644 --- a/src/registries/randomizer.ts +++ b/src/registries/randomizer.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import { Randomizer as RandomizerEntity } from '@entity/randomizer.js'; import { LOW } from '@sogebot/ui-helpers/constants.js'; @@ -50,19 +48,9 @@ class Randomizer extends Registry { res.status(204).send(); }); app.post('/api/registries/randomizer/:id/spin', adminMiddleware, async (req, res) => { - const { default: tts, services } = await import ('../tts.js'); - let key: string = randomUUID(); - if (tts.ready) { - if (tts.service === services.RESPONSIVEVOICE) { - key = tts.responsiveVoiceKey; - } - if (tts.service === services.GOOGLE) { - tts.addSecureKey(key); - } - } + const { generateAndAddSecureKey } = await import ('../tts.js'); this.socket?.emit('spin', { - service: tts.service, - key, + key: generateAndAddSecureKey(), }); res.status(204).send(); }); diff --git a/src/tts.ts b/src/tts.ts index 64a5ce57ff2..664c49a2d56 100644 --- a/src/tts.ts +++ b/src/tts.ts @@ -1,11 +1,16 @@ +import { randomUUID } from 'crypto'; + import { MINUTE } from '@sogebot/ui-helpers/constants.js'; import { JWT } from 'google-auth-library'; import { google } from 'googleapis'; +import { TTSService } from './database/entity/overlay.js'; + import Core from '~/_interface.js'; import { GooglePrivateKeys } from '~/database/entity/google.js'; import { AppDataSource } from '~/database.js'; import { + onChange, onStartup, } from '~/decorators/on.js'; import { settings } from '~/decorators.js'; @@ -15,18 +20,22 @@ import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; /* secureKeys are used to authenticate use of public overlay endpoint */ const secureKeys = new Set(); -export enum services { - 'NONE' = -1, - 'RESPONSIVEVOICE', - 'GOOGLE' -} +export const addSecureKey = (key: string) => { + secureKeys.add(key); + setTimeout(() => { + secureKeys.delete(key); + }, 10 * MINUTE); +}; + +export const generateAndAddSecureKey = () => { + const key = randomUUID(); + addSecureKey(key); + return key; +}; let jwtClient: null | JWT = null; class TTS extends Core { - @settings() - service: services = services.NONE; - @settings() responsiveVoiceKey = ''; @@ -35,124 +44,150 @@ class TTS extends Core { @settings() googleVoices: string[] = []; - addSecureKey(key: string) { - secureKeys.add(key); - setTimeout(() => { - secureKeys.delete(key); - }, 10 * MINUTE); - } - sockets() { adminEndpoint('/core/tts', 'settings.refresh', async () => { - this.onStartup(); // reset settings - }); - - adminEndpoint('/core/tts', 'google::speak', async (opts, cb) => { - const audioContent = await this.googleSpeak(opts); - if (cb) { - cb(null, audioContent); - } + this.initializeTTSServices(); // reset settings }); publicEndpoint('/core/tts', 'speak', async (opts, cb) => { if (secureKeys.has(opts.key)) { secureKeys.delete(opts.key); - if (!this.ready) { - cb(new Error('TTS is not properly set and ready.')); - return; - } + if (opts.service === TTSService.GOOGLE) { + const audioContent = await this.googleSpeak(opts); - if (this.service === services.GOOGLE) { - try { - const audioContent = await this.googleSpeak(opts); + if (!audioContent) { + throw new Error('Something went wrong'); + } + if (cb) { cb(null, audioContent); - } catch (e) { - cb(e); } + } else { + throw new Error('Invalid service.'); } } else { cb(new Error('Invalid auth.')); } }); - } - @onStartup() - async onStartup() { - switch(this.service) { - case services.NONE: - warning('TTS: no selected service has been configured.'); - break; - case services.GOOGLE: - try { - if (this.googlePrivateKey.length === 0) { - throw new Error('Missing private key'); + adminEndpoint('/core/tts', 'speak', async (opts, cb) => { + try { + if (opts.service === TTSService.GOOGLE) { + const audioContent = await this.googleSpeak(opts); + + if (!audioContent) { + throw new Error('Something went wrong'); + } + if (cb) { + cb(null, audioContent); } + } else { + throw new Error('Invalid service.'); + } + } catch (e) { + cb(e); + } + }); + } - // get private key - const privateKey = await AppDataSource.getRepository(GooglePrivateKeys).findOneByOrFail({ id: this.googlePrivateKey }); + initializedGoogleTTSHash: string | null = null; + async initializeGoogleTTS() { + if (this.initializedGoogleTTSHash === this.googlePrivateKey && jwtClient) { + // already initialized + return; + } + + if (this.googlePrivateKey.length === 0) { + warning('TTS: Google Private Key is not properly set.'); + this.initializedGoogleTTSHash = this.googlePrivateKey; + return; + } + + try { + // get private key + const privateKey = await AppDataSource.getRepository(GooglePrivateKeys).findOneByOrFail({ id: this.googlePrivateKey }); - // configure a JWT auth client - jwtClient = new google.auth.JWT( - privateKey.clientEmail, - undefined, - privateKey.privateKey, - ['https://www.googleapis.com/auth/cloud-platform']); - } catch (err) { + // configure a JWT auth client + jwtClient = new google.auth.JWT( + privateKey.clientEmail, + undefined, + privateKey.privateKey, + ['https://www.googleapis.com/auth/cloud-platform']); + + info('TTS: Authentication to Google Service successful.'); + + // authenticate request + jwtClient?.authorize(async (err) => { + if (err) { error('TTS: Something went wrong with authentication to Google Service.'); error(err); jwtClient = null; - } - - // authenticate request - jwtClient?.authorize(async (err) => { - if (err) { - error('TTS: Something went wrong with authentication to Google Service.'); - error(err); - jwtClient = null; - return; - } else { - if (!jwtClient) { + return; + } else { + if (!jwtClient) { // this shouldn't occur but make TS happy - return; - } + return; + } - info('TTS: Authentication to Google Service successful.'); + info('TTS: Authentication to Google Service successful.'); + this.initializedGoogleTTSHash = this.googlePrivateKey; - const texttospeech = google.texttospeech({ - auth: jwtClient, - version: 'v1', - }); + const texttospeech = google.texttospeech({ + auth: jwtClient, + version: 'v1', + }); - // get voices list - const list = await texttospeech.voices.list(); - this.googleVoices = Array.from(new Set(list.data.voices?.map(o => String(o.name)).sort() ?? [])); - info(`TTS: Cached ${this.googleVoices.length} Google Service voices.`); - } - }); - break; - case services.RESPONSIVEVOICE: - if (this.responsiveVoiceKey.length > 0) { - info('TTS: ResponsiveVoice ready.'); - } else { - warning('TTS: ResponsiveVoice ApiKey is not properly set.'); + // get voices list + const list = await texttospeech.voices.list(); + this.googleVoices = Array.from(new Set(list.data.voices?.map(o => String(o.name)).sort() ?? [])); + info(`TTS: Cached ${this.googleVoices.length} Google Service voices.`); } - break; + }); + } catch (err) { + error('TTS: Something went wrong with authentication to Google Service.'); + error(err); + jwtClient = null; } } - get ready() { - if (this.service === services.NONE) { - return false; + initializedResponsiveVoiceTTSHash: string | null = null; + initializeResponsiveVoiceTTS() { + if (this.initializedResponsiveVoiceTTSHash === this.responsiveVoiceKey) { + // already initialized + return; } - - if (this.service === services.RESPONSIVEVOICE) { - return this.responsiveVoiceKey.length > 0; + if (this.responsiveVoiceKey.length === 0) { + warning('TTS: ResponsiveVoice ApiKey is not properly set.'); + this.initializedResponsiveVoiceTTSHash = this.responsiveVoiceKey; + return; + } + if (this.responsiveVoiceKey.length > 0) { + info('TTS: ResponsiveVoice ready.'); + this.initializedResponsiveVoiceTTSHash = this.responsiveVoiceKey; + } else { + warning('TTS: ResponsiveVoice ApiKey is not properly set.'); } + } - if (this.service === services.GOOGLE) { - return this.googlePrivateKey.length > 0; + isReady(service: TTSService) { + if (service === TTSService.NONE) { + return false; + } + if (service === TTSService.GOOGLE) { + return this.initializedGoogleTTSHash === this.googlePrivateKey && jwtClient !== null; + } + if (service === TTSService.RESPONSIVEVOICE) { + return this.initializedResponsiveVoiceTTSHash === this.responsiveVoiceKey; } + return false; + } + + @onStartup() + @onChange('googlePrivateKey') + @onChange('responsiveVoiceKey') + async initializeTTSServices() { + this.initializeGoogleTTS(); + this.initializeResponsiveVoiceTTS(); } async googleSpeak(opts: { @@ -170,14 +205,13 @@ class TTS extends Core { version: 'v1', }); - const volumeGainDb = -6 + (12 * opts.volume); const synthesize = await texttospeech.text.synthesize({ requestBody: { audioConfig: { audioEncoding: 'MP3', pitch: opts.pitch, speakingRate: opts.rate, - volumeGainDb: volumeGainDb, + volumeGainDb: 10, }, input: { text: opts.text,