diff --git a/src/lib/extensions.js b/src/lib/extensions.js
index d1e8f0ab9..ddd238385 100644
--- a/src/lib/extensions.js
+++ b/src/lib/extensions.js
@@ -137,8 +137,8 @@ export default [
{
name: "Text To Speech: Redone",
description: "A better alternitive to the base text to speech extension. Powered by the TTStool API",
- code: "PuzzlingGGG/ttsr.js",
- banner: "PuzzlingGGG/TTSR.avif",
+ code: "PuzzlingGGG/ttsrV2.js",
+ banner: "PuzzlingGGG/tts.avif",
creator: "PuzzlingGGG",
isGitHub: true,
},
diff --git a/static/extensions/PuzzlingGGG/ttsrV2.js b/static/extensions/PuzzlingGGG/ttsrV2.js
new file mode 100644
index 000000000..39aff98dc
--- /dev/null
+++ b/static/extensions/PuzzlingGGG/ttsrV2.js
@@ -0,0 +1,436 @@
+// name: Text To Speech: Redone
+// credit: PuzzlingGGG
+// description: Create better text to speech
+
+(function(Scratch) {
+ "use strict";
+
+ let dev = false; // set to true to enable dev prints
+
+ function devpr(txt) {
+ if (dev) {console.log(txt);}
+ }
+
+ let cache = {};
+
+ class Speech {
+ constructor(speed, pitch, speech, voice, combinator) { // combinator is another instance of speech that'll be combined with this one to form conversation and more complex speech
+ if (!(combinator instanceof Speech) && combinator != undefined) {
+ throw new Error("combinator must be Speech or undefined");
+ }
+ this.speed = speed;
+ this.pitch = pitch;
+ this.text = speech;
+ this.voice = voice;
+ this.combinator = combinator;
+ }
+ toString() {
+ let comb = "";
+ if (this.combinator) {
+ comb = " with " + this.combinator.toString();
+ }
+ return `Speech(${this.speed}, ${this.pitch}, ${this.text}, ${this.voice} ${comb})`;
+ }
+ conv(txt) {
+ if (txt === "normal") return "medium";
+ return txt.replace("super ", "x-");
+ }
+ escapeXml(txt) {
+ return String(txt)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
+ makeObj(voicelangs) {
+ let spd = this.conv(this.speed);
+ let ptch = this.conv(this.pitch);
+ let vce = this.voice;
+ let txt = this.escapeXml(this.text);
+ let lang = voicelangs[vce];
+ let xml = `${txt}`;
+ return {voiceId: vce, ssml: xml}
+ }
+ makeReqArr(voicelangs) {
+ const arr = [];
+ let current = this;
+ while (current) {
+ arr.push(current.makeObj(voicelangs));
+ current = current.combinator;
+ }
+
+ devpr(JSON.stringify(arr));
+
+ return arr;
+ }
+ }
+
+ class ttsrV2 {
+ constructor() {
+ this.voices = ["..."];
+ this.voicelangs = {};
+ this.fetching = false; // cuz for some reason blockly calls it like 3 times aaaand we dont want that!
+ this.audioPlayer = new Audio();
+ }
+ getInfo() {
+ return {
+ id: "puzzlinggggttsrv2",
+ name: "Text to Speech: Redone",
+ color1: "#1be758",
+ color2: "#22c24a",
+ color3: "#3c813f",
+ blockText: "#000000",
+ blocks: [
+ /* old dev stuff
+ {
+ opcode: "menutest",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "menu test [MENU]",
+ arguments: {
+ MENU: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "speed",
+ defaultValue: "normal"
+ }
+ }
+ },
+ {
+ opcode: "speechtestclass",
+ blockType: Scratch.BlockType.REPORTER,
+ blockShape: Scratch.BlockShape.PLUS,
+ text: "speech test class [SPEED] [TEXT]",
+ arguments: {
+ SPEED: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "speed",
+ defaultValue: "normal"
+ },
+ TEXT: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "hello world"
+ }
+ }
+ },
+ {
+ opcode: "isspeechtest",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "is [SPEECH] speech class?",
+ arguments: {
+ SPEECH: {
+ type: Scratch.ArgumentType.STRING,
+ exemptFromNormalization: true
+ }
+ }
+ },
+ {
+ opcode: "voicemenutest",
+ blockType: Scratch.BlockType.REPORTER,
+ blockShape: Scratch.BlockShape.PLUS,
+ text: "get voices [VOICEMENU]",
+ arguments: {
+ VOICEMENU: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "voices",
+ defaultValue: "ababababa"
+ }
+ }
+ },
+ {
+ opcode:"requestarr",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "make request array from [SPEECH]",
+ arguments: {
+ SPEECH: {
+ type: Scratch.ArgumentType.STRING,
+ exemptFromNormalization: true
+ }
+ }
+ },
+ */
+ {
+ opcode: "credit",
+ blockType: Scratch.BlockType.BUTTON,
+ text: "credits"
+ },
+ {
+ opcode: "makespeechpart",
+ blockType: Scratch.BlockType.REPORTER,
+ blockShape: Scratch.BlockShape.PLUS,
+ text: "make speech part with speed [SPEED] pitch [PITCH] text [TEXT] using voice [VOICE] with [COMBINATION]",
+ arguments: {
+ SPEED: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "speed",
+ defaultValue: "normal"
+ },
+ PITCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "pitch",
+ defaultValue: "normal"
+ },
+ TEXT: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "Joe"
+ },
+ VOICE: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "voices",
+ defaultValue: "Amazon US English (Joey)"
+ },
+ COMBINATION: {
+ exemptFromNormalization: true
+ }
+ }
+ },
+ "---",
+ {
+ opcode: "speak",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "speak [SPEECH]",
+ arguments: {
+ SPEECH: {
+ exemptFromNormalization: true
+ }
+ }
+ },
+ {
+ opcode: "speakwait",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "speak [SPEECH] and wait",
+ arguments: {
+ SPEECH: {
+ exemptFromNormalization: true
+ }
+ }
+ },
+ {
+ blockType: Scratch.BlockType.LABEL,
+ text: "advanced blocks"
+ },
+ {
+ opcode: "datauri",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "data uri of [SPEECH]",
+ arguments: {
+ SPEECH: {
+ exemptFromNormalization: true
+ }
+ }
+ },
+ {
+ opcode: "cache",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "add [SPEECH] to cache",
+ arguments: {
+ SPEECH: {
+ exemptFromNormalization: true
+ }
+ }
+ },
+ {
+ opcode: "clearcache",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "clear cache"
+ }
+ ],
+ menus: {
+ speed: {
+ acceptReporters: true,
+ items: ["super slow", "slow", "normal", "fast", "super fast"]
+ },
+ pitch: {
+ acceptReporters: true,
+ items: ["super low", "low", "normal", "high", "super high"]
+ },
+ voices: {
+ acceptReporters: true,
+ items: "getVoices"
+ }
+ }
+ }
+ }
+ menutest(args) {
+ return args.MENU;
+ }
+ speechtestclass(args) {
+ return new Speech(args.SPEED, args.TEXT, "abababa");
+ }
+ isspeechtest(args) {
+ return (args.SPEECH instanceof Speech);
+ }
+ getVoices() {
+ if (this.voices[0] == "...") {
+ devpr("no voices loaded");
+ void this.fetchVoices(); // just found out about this! cool!
+ }
+ return this.voices;
+ }
+ async fetchVoices() { // i hate promises :(
+ if (this.fetching) {
+ devpr("already fetching voices");
+ return;
+ }
+ this.fetching = true;
+ devpr("fetching voices");
+ fetch("https://support.readaloud.app/read-aloud/list-voices/premium").catch((e) => {alert("!!HORRIBLE ERROR!! cannot fetch voices: " + e)}).then((response) => {
+ console.log("got response for voices " + response);
+ if (!response.ok) {
+ alert("!!HORRIBLE ERROR!! cannot fetch voices response was not ok");
+ }
+ response.json().then((data) => {
+ this.voices = [];
+ for (const voice of data) {
+ devpr("got voice " + voice.voiceName + " " + voice.lang + " " + voice.gender);
+ this.voices.push(voice.voiceName);
+ this.voicelangs[voice.voiceName] = voice.lang;
+ }
+ });
+ });
+ }
+ voicemenutest(args) {
+ return args.VOICEMENU;
+ }
+ makespeechpart(args) {
+ let combinator = undefined;
+
+ if (args.COMBINATION instanceof Speech) {
+ combinator = args.COMBINATION;
+ } else if (args.COMBINATION != null && args.COMBINATION !== "") {
+ throw new Error("final input must be a speech part or left empty");
+ }
+
+ return new Speech(args.SPEED, args.PITCH, args.TEXT, args.VOICE, combinator);
+ }
+ requestarr(args) {
+ return JSON.stringify(args.SPEECH.makeReqArr(this.voicelangs));
+ }
+ play(blob) {
+ return new Promise((resolve, reject) => {
+ devpr("audio blob type: " + blob.type);
+ devpr("audio blob size: " + blob.size);
+
+ const url = URL.createObjectURL(blob);
+
+ this.audioPlayer.pause();
+ this.audioPlayer.currentTime = 0;
+ this.audioPlayer.src = url;
+
+ this.audioPlayer.onended = () => {
+ URL.revokeObjectURL(url);
+ resolve();
+ };
+
+ this.audioPlayer.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(this.audioPlayer.error);
+ };
+
+ this.audioPlayer.play().catch((e) => {
+ URL.revokeObjectURL(url);
+ reject(e);
+ });
+ });
+ }
+ async speakwait(args) {
+ devpr(cache);
+
+ if (!(args.SPEECH instanceof Speech)) {
+ throw new Error("input must be a speech part");
+ }
+ const key = args.SPEECH.toString();
+
+ if (cache[key] != undefined) {
+ const blob = cache[key];
+ await this.play(blob);
+ return;
+ }
+ const arr = args.SPEECH.makeReqArr(this.voicelangs);
+ const makeparts = await fetch("https://support.readaloud.app/ttstool/createParts", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(arr)
+ });
+
+ if (!makeparts.ok) {
+ throw new Error("cannot create speech response was not ok");
+ }
+
+ const audioids = await makeparts.json();
+ devpr("got audio ids " + JSON.stringify(audioids));
+
+ const audio = await fetch(
+ "https://support.readaloud.app/ttstool/getParts?q=" + audioids.join(",")
+ );
+
+ if (!audio.ok) {
+ throw new Error("cannot get speech response was not ok");
+ }
+
+ const blob = await audio.blob();
+
+ cache[key] = blob;
+
+ await this.play(blob);
+ }
+ speak(args) {
+ void this.speakwait(args); // same thing but no waiting. taht code is a mess soo just reuse it??
+ }
+ makedatauri(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ resolve(reader.result);
+ };
+ reader.onerror = () => {
+ reject(reader.error);
+ };
+ reader.readAsDataURL(blob);
+ });
+ }
+ async datauri(args) {
+ if (!(args.SPEECH instanceof Speech)) {
+ throw new Error("input must be a speech part");
+ }
+ const key = args.SPEECH.toString();
+ if (cache[key] != undefined) {
+ const blob = cache[key];
+ return this.makedatauri(blob);
+ }
+ const arr = args.SPEECH.makeReqArr(this.voicelangs);
+ const makeparts = await fetch("https://support.readaloud.app/ttstool/createParts", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(arr)
+ });
+ if (!makeparts.ok) {
+ throw new Error("cannot create speech response was not ok");
+ }
+ const audioids = await makeparts.json();
+ devpr("got audio ids " + JSON.stringify(audioids));
+ const audio = await fetch(
+ "https://support.readaloud.app/ttstool/getParts?q=" + audioids.join(",")
+ );
+ if (!audio.ok) {
+ throw new Error("cannot get speech response was not ok");
+ }
+ const blob = await audio.blob();
+ cache[key] = blob;
+ return this.makedatauri(blob);
+ }
+ credit() {
+ alert("Text To Speech: Redone by PuzzlingGGG\nUses TTSTools's TTS API (https://ttstool.com) created by LSD Software (https://www.lsdsoftware.com/)");
+ }
+ async cache(args) {
+ if (!(args.SPEECH instanceof Speech)) {
+ throw new Error("input must be a speech part");
+ }
+ await this.datauri(args); // already has caching so use that and ignore the output
+ }
+ clearcache() {
+ cache = {};
+ }
+ }
+
+ Scratch.extensions.register(new ttsrV2());
+})(Scratch);
diff --git a/static/images/PuzzlingGGG/tts.avif b/static/images/PuzzlingGGG/tts.avif
new file mode 100644
index 000000000..978ee3fad
Binary files /dev/null and b/static/images/PuzzlingGGG/tts.avif differ
diff --git a/static/images/PuzzlingGGG/tts.svg b/static/images/PuzzlingGGG/tts.svg
new file mode 100644
index 000000000..52149be2e
--- /dev/null
+++ b/static/images/PuzzlingGGG/tts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file