From 7fddfb34f4259ecfb85bed7c7edde5fa97bbabbb Mon Sep 17 00:00:00 2001 From: SteffoSpieler Date: Fri, 2 Sep 2022 22:02:45 +0200 Subject: [PATCH 1/2] feat: Add opentts service and sample --- README.md | 1 + package-lock.json | 63 ++++++++++++- samples/opentts/extension/index.ts | 48 ++++++++++ samples/opentts/graphics/index.html | 10 +++ samples/opentts/graphics/index.ts | 14 +++ samples/opentts/package.json | 27 ++++++ samples/opentts/tsconfig.json | 11 +++ services/nodecg-io-opentts/extension/index.ts | 32 +++++++ .../extension/openTtsClient.ts | 88 +++++++++++++++++++ services/nodecg-io-opentts/package.json | 50 +++++++++++ services/nodecg-io-opentts/schema.json | 16 ++++ services/nodecg-io-opentts/tsconfig.json | 8 ++ 12 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 samples/opentts/extension/index.ts create mode 100644 samples/opentts/graphics/index.html create mode 100644 samples/opentts/graphics/index.ts create mode 100644 samples/opentts/package.json create mode 100644 samples/opentts/tsconfig.json create mode 100644 services/nodecg-io-opentts/extension/index.ts create mode 100644 services/nodecg-io-opentts/extension/openTtsClient.ts create mode 100644 services/nodecg-io-opentts/package.json create mode 100644 services/nodecg-io-opentts/schema.json create mode 100644 services/nodecg-io-opentts/tsconfig.json diff --git a/README.md b/README.md index a961db94e..cb542a2b0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ nodecg-io is the successor of [ChatOverflow](https://github.com/codeoverflow-org - MQTT - Nanoleafs - OBS +- [OpenTTS](https://github.com/synesthesiam/opentts) - Philips Hue - RCON - Reddit diff --git a/package-lock.json b/package-lock.json index a71810612..0ea77af08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8998,6 +8998,10 @@ "resolved": "services/nodecg-io-obs", "link": true }, + "node_modules/nodecg-io-opentts": { + "resolved": "services/nodecg-io-opentts", + "link": true + }, "node_modules/nodecg-io-philipshue": { "resolved": "services/nodecg-io-philipshue", "link": true @@ -13372,6 +13376,27 @@ "integrity": "sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw==", "dev": true }, + "services/nodecg-io-opentts": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.7", + "nodecg-io-core": "^0.3.0" + }, + "devDependencies": { + "@types/node": "^18.7.13", + "@types/node-fetch": "^2.6.1", + "nodecg-io-tsconfig": "^1.0.0", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2" + } + }, + "services/nodecg-io-opentts/node_modules/@types/node": { + "version": "18.7.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.14.tgz", + "integrity": "sha512-6bbDaETVi8oyIARulOE9qF1/Qdi/23z6emrUh0fNJRUmjznqrixD4MpGDdgOFk5Xb0m2H6Xu42JGdvAxaJR/wA==", + "dev": true + }, "services/nodecg-io-philipshue": { "version": "0.3.0", "license": "MIT", @@ -13418,7 +13443,7 @@ "license": "MIT", "dependencies": { "nodecg-io-core": "^0.3.0", - "reddit-ts": "git+ssh://git@github.com/noeppi-noeppi/npm-reddit-ts.git#40a1ff7c115ab4bf860d13179ebf28c6e9e69286" + "reddit-ts": "https://github.com/noeppi-noeppi/npm-reddit-ts.git#build" }, "devDependencies": { "@types/node": "^18.7.13", @@ -13699,6 +13724,20 @@ } } }, + "services/nodecg-io-tts": { + "version": "0.3.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "nodecg-io-core": "^0.3.0" + }, + "devDependencies": { + "@types/node": "^18.7.13", + "nodecg-io-tsconfig": "^1.0.0", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2" + } + }, "services/nodecg-io-twitch-addons": { "version": "0.3.0", "license": "MIT", @@ -20778,6 +20817,26 @@ } } }, + "nodecg-io-opentts": { + "version": "file:services/nodecg-io-opentts", + "requires": { + "@types/node": "^18.7.13", + "@types/node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", + "nodecg-io-core": "^0.3.0", + "nodecg-io-tsconfig": "^1.0.0", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2" + }, + "dependencies": { + "@types/node": { + "version": "18.7.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.14.tgz", + "integrity": "sha512-6bbDaETVi8oyIARulOE9qF1/Qdi/23z6emrUh0fNJRUmjznqrixD4MpGDdgOFk5Xb0m2H6Xu42JGdvAxaJR/wA==", + "dev": true + } + } + }, "nodecg-io-philipshue": { "version": "file:services/nodecg-io-philipshue", "requires": { @@ -20824,7 +20883,7 @@ "nodecg-io-core": "^0.3.0", "nodecg-io-tsconfig": "^1.0.0", "nodecg-types": "^1.9.0", - "reddit-ts": "git+ssh://git@github.com/noeppi-noeppi/npm-reddit-ts.git#40a1ff7c115ab4bf860d13179ebf28c6e9e69286", + "reddit-ts": "https://github.com/noeppi-noeppi/npm-reddit-ts.git#build", "typescript": "^4.8.2" }, "dependencies": { diff --git a/samples/opentts/extension/index.ts b/samples/opentts/extension/index.ts new file mode 100644 index 000000000..2123bb31d --- /dev/null +++ b/samples/opentts/extension/index.ts @@ -0,0 +1,48 @@ +import { NodeCG } from "nodecg-types/types/server"; +import { OpenTTSClient } from "nodecg-io-opentts"; +import { requireService } from "nodecg-io-core"; + +/** + * Plays a "hello world" tts message inside the graphic of this sample bundle. + */ +async function playTTSInGraphic(client: OpenTTSClient, nodecg: NodeCG) { + const voices = await client.getVoices("en"); + + // Get random voice + const voiceName = Object.keys(voices)[Math.floor(Math.random() * Object.keys(voices).length)]; + if (voiceName === undefined) throw new Error("no voice available"); + + const helloWorldUrl = client.generateWavUrl("Hello World", voiceName); + await nodecg.sendMessage("setSrc", helloWorldUrl); +} + +module.exports = function (nodecg: NodeCG) { + nodecg.log.info("Sample bundle for the OpenTTS service started."); + + const opentts = requireService(nodecg, "opentts"); + + nodecg.listenFor("ready", () => { + const client = opentts?.getClient(); + if (client !== undefined){ + playTTSInGraphic(client, nodecg) + .catch(err => nodecg.log.error(`Error while trying to play tts message: ${err.messages}`)); + } + }); + + opentts?.onAvailable(async (client) => { + nodecg.log.info("OpenTTS service available."); + + const voices = await client.getVoices(); + const languages = await client.getLanguages(); + + nodecg.log.info( + `OpenTTS server supports ${Object.entries(voices).length} voices in ${languages.length} languages.`, + ); + + await playTTSInGraphic(client, nodecg); + }); + + opentts?.onUnavailable(() => { + nodecg.log.info("OpenTTS service unavailable."); + }); +}; diff --git a/samples/opentts/graphics/index.html b/samples/opentts/graphics/index.html new file mode 100644 index 000000000..741bc30ba --- /dev/null +++ b/samples/opentts/graphics/index.html @@ -0,0 +1,10 @@ + + + + OpenTTS Sample + + + + + + \ No newline at end of file diff --git a/samples/opentts/graphics/index.ts b/samples/opentts/graphics/index.ts new file mode 100644 index 000000000..3cd821d01 --- /dev/null +++ b/samples/opentts/graphics/index.ts @@ -0,0 +1,14 @@ +/// + +// Listens for event from opentts sample and plays the audio by the provided url. + +const audioElement = document.getElementById("opentts-audio") as HTMLAudioElement; + +// Play audio when the graphic is newly opened +nodecg.sendMessage("ready"); + +nodecg.listenFor("setSrc", (newSrc) => { + audioElement.src = newSrc; + audioElement.currentTime = 0; + audioElement.play(); +}); diff --git a/samples/opentts/package.json b/samples/opentts/package.json new file mode 100644 index 000000000..387173198 --- /dev/null +++ b/samples/opentts/package.json @@ -0,0 +1,27 @@ +{ + "name": "opentts", + "version": "0.3.0", + "private": true, + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-template": "^0.3.0" + }, + "graphics": [ + { + "file": "index.html", + "width": "1920", + "height": "1080" + } + ] + }, + "license": "MIT", + "dependencies": { + "@types/node": "^18.7.13", + "nodecg-types": "^1.9.0", + "nodecg-io-core": "^0.3.0", + "nodecg-io-opentts": "^0.3.0", + "typescript": "^4.8.2", + "nodecg-io-tsconfig": "^1.0.0" + } +} diff --git a/samples/opentts/tsconfig.json b/samples/opentts/tsconfig.json new file mode 100644 index 000000000..49f69e03d --- /dev/null +++ b/samples/opentts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "nodecg-io-tsconfig", + "references": [ + { + "path": "../../nodecg-io-core" + }, + { + "path": "../../services/nodecg-io-opentts" + } + ] +} diff --git a/services/nodecg-io-opentts/extension/index.ts b/services/nodecg-io-opentts/extension/index.ts new file mode 100644 index 000000000..f8e021447 --- /dev/null +++ b/services/nodecg-io-opentts/extension/index.ts @@ -0,0 +1,32 @@ +import { NodeCG } from "nodecg-types/types/server"; +import { Result, emptySuccess, success, ServiceBundle, Logger, error } from "nodecg-io-core"; +import { OpenTTSClient } from "./openTtsClient"; + +export interface OpenTTSConfig { + host: string; + useHttps?: boolean; +} + +export { OpenTTSClient, OpenTTSVoice } from "./openTtsClient"; + +module.exports = (nodecg: NodeCG) => { + new OpenTTSService(nodecg, "opentts", __dirname, "../schema.json").register(); +}; + +class OpenTTSService extends ServiceBundle { + async validateConfig(config: OpenTTSConfig): Promise> { + if (await OpenTTSClient.isOpenTTSAvailable(config)) return emptySuccess(); + else return error("Unable to reach OpenTTS server at the specified host address"); + } + + async createClient(config: OpenTTSConfig, logger: Logger): Promise> { + const client = new OpenTTSClient(config); + logger.info("Successfully created OpenTTS client."); + return success(client); + } + + stopClient(_: OpenTTSClient, logger: Logger): void { + // Client is stateless, no need to stop anything + } +} + diff --git a/services/nodecg-io-opentts/extension/openTtsClient.ts b/services/nodecg-io-opentts/extension/openTtsClient.ts new file mode 100644 index 000000000..d7637fc94 --- /dev/null +++ b/services/nodecg-io-opentts/extension/openTtsClient.ts @@ -0,0 +1,88 @@ +import { OpenTTSConfig } from "./index"; +import { ObjectMap } from "nodecg-io-core/extension/service"; +import fetch, { Response } from "node-fetch"; + +type OpenTTSName = "espeak" | "flite" | "festival" | "nanotts" | "marytts"; +type OpenTTSGender = "F" | "M"; +type OpenTTVOCoderQuality = "high" | "medium" | "low"; + +export interface OpenTTSVoice { + gender: OpenTTSGender; + id: string; + language: string; + locale: string; + multispeaker: boolean; + name: string; + speakers?: ObjectMap; + tag: ObjectMap; + tts_name: string; +} + +export class OpenTTSClient { + constructor(private config: OpenTTSConfig) {} + + private buildBaseURL(): string { + const protocol = this.config.useHttps ? "https" : "http"; + return `${protocol}://${this.config.host}`; + } + + private async executeRequest(path: string): Promise { + const response = await fetch(this.buildBaseURL() + path); + if (!response.ok) { + throw new Error("Failed to execute opentts request: " + (await response.text())); + } + + return response; + } + + async getLanguages(ttsName?: OpenTTSName): Promise> { + const urlVar = ttsName ? `?tts_name=${ttsName}` : ""; + const response = await this.executeRequest(`/api/languages${urlVar}`); + return await response.json(); + } + + async getVoices( + language?: string, + locale?: string, + gender?: OpenTTSGender, + ttsName?: OpenTTSName, + ): Promise> { + const params = new URLSearchParams(); + if (language) params.set("language", language); + if (locale) params.set("locale", locale); + if (gender) params.set("gender", gender); + if (ttsName) params.set("tts_name", ttsName); + + const response = await this.executeRequest(`/api/voices?${params}`); + return await response.json(); + } + + generateWavUrl( + text: string, + voice: string, + vocoder?: OpenTTVOCoderQuality, + denoiserStrength?: number, + cache?: boolean, + ): string { + const params = new URLSearchParams({ text, voice }); + if(vocoder) params.set("vocoder", vocoder); + if(denoiserStrength) params.set("denoiserStrength", denoiserStrength.toString()); + if(cache !== undefined) params.set("cache", cache.toString()); + + return `${this.buildBaseURL()}/api/tts?${params}`; + } + + async getWavData(url: string): Promise { + const response = await this.executeRequest(url); + return await response.arrayBuffer(); + } + + static async isOpenTTSAvailable(config: OpenTTSConfig): Promise { + try { + await new OpenTTSClient(config).getLanguages(); + return true; + } catch (_e) { + return false; + } + } +} diff --git a/services/nodecg-io-opentts/package.json b/services/nodecg-io-opentts/package.json new file mode 100644 index 000000000..8cf58f657 --- /dev/null +++ b/services/nodecg-io-opentts/package.json @@ -0,0 +1,50 @@ +{ + "name": "nodecg-io-opentts", + "version": "0.3.0", + "description": "Allows generating audio for text using an OpenTTS instance.", + "homepage": "https://nodecg.io/RELEASE/samples/opentts", + "author": { + "name": "CodeOverflow team", + "url": "https://github.com/codeoverflow-org" + }, + "contributors": [ + { + "name": "SteffoSpieler", + "url": "https://about.steffospieler.de" + } + ], + "repository": { + "type": "git", + "url": "https://github.com/codeoverflow-org/nodecg-io.git", + "directory": "services/nodecg-io-opentts" + }, + "files": [ + "**/*.js", + "**/*.js.map", + "**/*.d.ts", + "*.json" + ], + "main": "extension/index", + "keywords": [ + "nodecg-io", + "nodecg-bundle" + ], + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-core": "^0.3.0" + } + }, + "license": "MIT", + "devDependencies": { + "@types/node": "^18.7.13", + "@types/node-fetch": "^2.6.1", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2", + "nodecg-io-tsconfig": "^1.0.0" + }, + "dependencies": { + "nodecg-io-core": "^0.3.0", + "node-fetch": "^2.6.7" + } +} diff --git a/services/nodecg-io-opentts/schema.json b/services/nodecg-io-opentts/schema.json new file mode 100644 index 000000000..cdc1f4574 --- /dev/null +++ b/services/nodecg-io-opentts/schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Host (hostname/ip) of the OpenTTS instance." + }, + "useHttps": { + "type": "boolean", + "default": false, + "description": "Whether the server should be contacted using https instead of http." + } + }, + "required": ["host"] +} diff --git a/services/nodecg-io-opentts/tsconfig.json b/services/nodecg-io-opentts/tsconfig.json new file mode 100644 index 000000000..f114d2be2 --- /dev/null +++ b/services/nodecg-io-opentts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "nodecg-io-tsconfig", + "references": [ + { + "path": "../../nodecg-io-core" + } + ] +} From bd6b4dea29ee55911fc91aaf40e2a66945dd6465 Mon Sep 17 00:00:00 2001 From: SteffoSpieler Date: Fri, 2 Sep 2022 22:09:17 +0200 Subject: [PATCH 2/2] fix: Add sample to package-lock.json --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/package-lock.json b/package-lock.json index 0ea77af08..82b6da25f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9396,6 +9396,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opentts": { + "resolved": "samples/opentts", + "link": true + }, "node_modules/optionator": { "version": "0.9.1", "dev": true, @@ -12524,6 +12528,23 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.13.tgz", "integrity": "sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw==" }, + "samples/opentts": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "@types/node": "^18.7.13", + "nodecg-io-core": "^0.3.0", + "nodecg-io-opentts": "^0.3.0", + "nodecg-io-tsconfig": "^1.0.0", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2" + } + }, + "samples/opentts/node_modules/@types/node": { + "version": "18.7.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.14.tgz", + "integrity": "sha512-6bbDaETVi8oyIARulOE9qF1/Qdi/23z6emrUh0fNJRUmjznqrixD4MpGDdgOFk5Xb0m2H6Xu42JGdvAxaJR/wA==" + }, "samples/philipshue-lights": { "version": "0.3.0", "license": "MIT", @@ -21521,6 +21542,24 @@ "is-wsl": "^2.2.0" } }, + "opentts": { + "version": "file:samples/opentts", + "requires": { + "@types/node": "^18.7.13", + "nodecg-io-core": "^0.3.0", + "nodecg-io-opentts": "^0.3.0", + "nodecg-io-tsconfig": "^1.0.0", + "nodecg-types": "^1.9.0", + "typescript": "^4.8.2" + }, + "dependencies": { + "@types/node": { + "version": "18.7.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.14.tgz", + "integrity": "sha512-6bbDaETVi8oyIARulOE9qF1/Qdi/23z6emrUh0fNJRUmjznqrixD4MpGDdgOFk5Xb0m2H6Xu42JGdvAxaJR/wA==" + } + } + }, "optionator": { "version": "0.9.1", "dev": true,