diff --git a/client/play/TossupClient.js b/client/play/TossupClient.js
index 91a2e74a2..f254bdb40 100644
--- a/client/play/TossupClient.js
+++ b/client/play/TossupClient.js
@@ -1,6 +1,7 @@
import addTossupGameCard from './tossups/add-tossup-game-card.js';
import QuestionClient from './QuestionClient.js';
import audio from './audio.js';
+import audioReader from './audio/AudioReaderController.js';
import { MODE_ENUM } from '../../shared/constants.js';
export const TossupClientMixin = (ClientClass) => class extends ClientClass {
@@ -12,13 +13,28 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
onmessage (message) {
const data = JSON.parse(message);
switch (data.type) {
- case 'buzz': return this.buzz(data);
- case 'end-current-tossup': return this.endCurrentTossup(data);
+ case 'buzz': {
+ audioReader.onBuzz();
+ return this.buzz(data);
+ }
+ case 'end-current-tossup': {
+ audioReader.onEndQuestion();
+ return this.endCurrentTossup(data);
+ }
case 'give-tossup-answer': return this.giveTossupAnswer(data);
- case 'pause': return this.pause(data);
- case 'reveal-tossup-answer': return this.revealTossupAnswer(data);
+ case 'pause': {
+ audioReader.onPause(data.paused, this.room, this.socket);
+ return this.pause(data);
+ }
+ case 'reveal-tossup-answer': {
+ if (audioReader.tryDeferReveal(() => this.revealTossupAnswer(data))) return;
+ return this.revealTossupAnswer(data);
+ }
case 'set-reading-speed': return this.setReadingSpeed(data);
- case 'start-next-tossup': return this.startNextTossup(data);
+ case 'start-next-tossup': {
+ audioReader.onStartNextTossup(data.tossup, this.room.settings.readingSpeed, this.room, this.socket);
+ return this.startNextTossup(data);
+ }
case 'toggle-powermark-only': return this.togglePowermarkOnly(data);
case 'toggle-rebuzz': return this.toggleRebuzz(data);
case 'toggle-stop-on-power': return this.toggleStopOnPower(data);
@@ -76,6 +92,11 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
document.getElementById('reading-speed-display').textContent = readingSpeed;
}
+ timerUpdate ({ timeRemaining }) {
+ if (audioReader.shouldSuppressTimer()) return;
+ super.timerUpdate({ timeRemaining });
+ }
+
startNextTossup ({ tossup, packetLength }) {
this.startNextQuestion({ question: tossup, packetLength });
document.getElementById('buzz').textContent = 'Buzz';
@@ -98,6 +119,7 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
}
updateQuestion ({ word }) {
+ if (audioReader.isReading()) return;
if (word === '(*)' || word === '[*]' || word === '(+)') { return; }
document.getElementById('question').innerHTML += word + ' ';
}
@@ -140,6 +162,11 @@ function attachEventListeners (room, socket) {
this.blur();
socket.sendToServer({ type: 'toggle-stop-on-power', stopOnPower: this.checked });
});
+
+ document.getElementById('toggle-audio-reader')?.addEventListener('click', function () {
+ this.blur();
+ audioReader.onToggleChanged(this.checked);
+ });
}
const TossupClient = TossupClientMixin(QuestionClient);
diff --git a/client/play/audio.js b/client/play/audio.js
index b4de66e87..67dc0acfe 100644
--- a/client/play/audio.js
+++ b/client/play/audio.js
@@ -1,7 +1,13 @@
+import { questionAudioPlayer } from './audio/QuestionAudioPlayer.js';
+
export default class audio {
static soundEffects = window.localStorage.getItem('sound-effects') === 'true';
static buzz = new window.Audio('https://qbreader.github.io/website/buzz.mp3');
static correct = new window.Audio('https://qbreader.github.io/website/correct.mp3');
static incorrect = new window.Audio('https://qbreader.github.io/website/incorrect.mp3');
static power = new window.Audio('https://qbreader.github.io/website/power.mp3');
+ static questionReader = questionAudioPlayer;
}
+
+export { questionAudioPlayer };
+
diff --git a/client/play/audio/AudioReaderController.js b/client/play/audio/AudioReaderController.js
new file mode 100644
index 000000000..68b8359fc
--- /dev/null
+++ b/client/play/audio/AudioReaderController.js
@@ -0,0 +1,217 @@
+/**
+ * AudioReaderController
+ *
+ * Encapsulates all state and logic for the audio question reader feature.
+ * TossupClient.js calls the thin public API below; no state lives in TossupClient.
+ */
+
+import audio from '../audio.js';
+
+class AudioReaderController {
+ constructor () {
+ /** Toggle state queued for the *next* question (avoids mid-question desync). */
+ this.pendingEnable = false;
+
+ /** True once the current question's audio has fully finished playing. */
+ this.audioFinished = false;
+
+ /** True while the audio reader is actively playing and driving word reveal. */
+ this.isReadingActive = false;
+
+ /**
+ * Callback that performs the full reveal (including any subclass UI updates).
+ * Stored when the server's reveal-tossup-answer arrives before audio/countdown ends.
+ * @type {(() => void) | null}
+ */
+ this._pendingRevealCallback = null;
+
+ /** setInterval handle for the client-side 5-second dead-time countdown. */
+ this._deadTimerInterval = null;
+
+ /** Remaining dead time in tenths of a second. */
+ this.deadTimeRemaining = 50;
+
+ /** True if the post-question countdown is currently paused. */
+ this.countdownPaused = false;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API — called by TossupClient hooks
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Call at the start of each new tossup.
+ * Resets state, applies the pending toggle, and starts audio playback.
+ */
+ onStartNextTossup (tossup, readingSpeed, room, socket) {
+ this._clearDeadTimer();
+ this.audioFinished = false;
+ this.isReadingActive = false;
+ this._pendingRevealCallback = null;
+ this.countdownPaused = false;
+
+ // Apply the pending enable state — toggle takes effect per question.
+ audio.questionReader.enabled = this.pendingEnable;
+
+ if (!audio.questionReader.enabled || !tossup?.question) return;
+
+ this.isReadingActive = true;
+
+ // Immediately pause the simulated room's timeout loop so it doesn't run ahead of the audio.
+ clearTimeout(room.timeoutID);
+ room.paused = true;
+
+ // Clear question display — audio drives word-by-word reveal.
+ document.getElementById('question').innerHTML = '';
+
+ audio.questionReader.onWordCallback = (_index, word) => {
+ room.wordIndex = _index + 1;
+ document.getElementById('question').innerHTML += word;
+ };
+
+ audio.questionReader.onEndedCallback = () => {
+ this.audioFinished = true;
+ room.wordIndex = room.questionSplit.length;
+
+ this.deadTimeRemaining = 50;
+ this._startCountdown(socket);
+ };
+
+ audio.questionReader.loadAndPlay(tossup.question, readingSpeed);
+ }
+
+ _startCountdown (socket) {
+ clearInterval(this._deadTimerInterval);
+ this._deadTimerInterval = setInterval(() => {
+ this.deadTimeRemaining--;
+ const seconds = Math.floor(this.deadTimeRemaining / 10);
+ const tenths = this.deadTimeRemaining % 10;
+ const timerFace = document.querySelector('.timer .face');
+ const timerFraction = document.querySelector('.timer .fraction');
+ if (timerFace) timerFace.textContent = seconds;
+ if (timerFraction) timerFraction.textContent = '.' + tenths;
+
+ if (this.deadTimeRemaining <= 0) {
+ this._clearDeadTimer();
+ socket.sendToServer({ type: 'reveal-tossup-answer' });
+ }
+ }, 100);
+ }
+
+ /**
+ * Call when any player buzzes in.
+ * Stops audio immediately and lets the server answer-timer take over.
+ */
+ onBuzz () {
+ this._clearDeadTimer();
+ this.audioFinished = true; // allow server timer-update messages through
+ this.isReadingActive = false; // fall back to server text updates if room continues (e.g. neg/rebuzz)
+ audio.questionReader.stop();
+ }
+
+ /**
+ * Call when the current tossup ends (skip, next, etc.).
+ * Fully resets audio state.
+ */
+ onEndQuestion () {
+ this._clearDeadTimer();
+ audio.questionReader.stop();
+ this.isReadingActive = false;
+ this._pendingRevealCallback = null;
+ this.audioFinished = false;
+ }
+
+ /**
+ * Call when the room is paused or unpaused.
+ * @param {boolean} paused
+ */
+ onPause (paused, room, socket) {
+ if (paused) {
+ audio.questionReader.pause();
+ clearTimeout(room.timeoutID);
+ room.paused = true;
+
+ if (this._deadTimerInterval !== null) {
+ clearInterval(this._deadTimerInterval);
+ this._deadTimerInterval = null;
+ this.countdownPaused = true;
+ }
+ } else {
+ audio.questionReader.resume();
+ clearTimeout(room.timeoutID);
+ room.paused = true;
+
+ if (this.countdownPaused) {
+ this.countdownPaused = false;
+ this._startCountdown(socket);
+ }
+ }
+ }
+
+ /**
+ * Returns true when server `timer-update` messages should be suppressed
+ * (i.e. audio is reading OR client countdown is still running).
+ */
+ shouldSuppressTimer () {
+ return audio.questionReader.enabled && (!this.audioFinished || this._deadTimerInterval !== null || this.countdownPaused);
+ }
+
+ /**
+ * If audio is still reading or countdown is running, stores the full reveal
+ * callback for deferred execution.
+ * @param {() => void} revealCallback Zero-arg function that performs the full reveal.
+ * @returns {boolean} true if the reveal was deferred (caller should return early)
+ */
+ tryDeferReveal (revealCallback) {
+ if (!audio.questionReader.enabled) return false;
+ // Defer while audio is still reading OR while the client countdown is running.
+ if (!this.audioFinished || this._deadTimerInterval !== null || this.countdownPaused) {
+ this._pendingRevealCallback = revealCallback;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true while audio is active and driving the question display.
+ * Used by updateQuestion to skip server word-tick updates.
+ */
+ isReading () {
+ return audio.questionReader.enabled && this.isReadingActive;
+ }
+
+ /**
+ * Call when the toggle switch changes.
+ * Turning OFF takes effect immediately; turning ON is deferred to next question.
+ * @param {boolean} checked
+ */
+ onToggleChanged (checked) {
+ this.pendingEnable = checked;
+ if (!checked) {
+ this._clearDeadTimer();
+ audio.questionReader.stop();
+ audio.questionReader.enabled = false;
+ this.audioFinished = false;
+ this.isReadingActive = false;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ _clearDeadTimer () {
+ clearInterval(this._deadTimerInterval);
+ this._deadTimerInterval = null;
+ this.countdownPaused = false;
+ }
+
+ _revealPending () {
+ if (!this._pendingRevealCallback) return;
+ const cb = this._pendingRevealCallback;
+ this._pendingRevealCallback = null;
+ cb();
+ }
+}
+
+export default new AudioReaderController();
diff --git a/client/play/audio/QuestionAudioPlayer.js b/client/play/audio/QuestionAudioPlayer.js
new file mode 100644
index 000000000..3da822b2e
--- /dev/null
+++ b/client/play/audio/QuestionAudioPlayer.js
@@ -0,0 +1,104 @@
+export class QuestionAudioPlayer {
+ constructor () {
+ this.audio = new Audio();
+ this.timestamps = [];
+ this.enabled = false;
+ this.voice = 'en-US-GuyNeural';
+ this.currentWordIndex = -1;
+ this.onWordCallback = null;
+ this.onEndedCallback = null;
+ this.animFrameId = null;
+
+ this.audio.addEventListener('ended', () => {
+ cancelAnimationFrame(this.animFrameId);
+ if (this.timestamps && this.currentWordIndex < this.timestamps.length - 1) {
+ for (let i = this.currentWordIndex + 1; i < this.timestamps.length; i++) {
+ if (typeof this.onWordCallback === 'function') {
+ this.onWordCallback(i, this.timestamps[i].part);
+ }
+ }
+ this.currentWordIndex = this.timestamps.length - 1;
+ }
+ if (typeof this.onEndedCallback === 'function') {
+ this.onEndedCallback();
+ }
+ });
+ }
+
+ async loadAndPlay (text, readingSpeed = 50) {
+ if (!this.enabled || !text) return;
+ this.stop();
+
+ try {
+ const response = await fetch('/api/audio/synthesize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, voice: this.voice, readingSpeed })
+ });
+ if (!response.ok) return;
+
+ const data = await response.json();
+ if (!data.audioBase64) return;
+
+ this.timestamps = data.timestamps || [];
+ this.currentWordIndex = -1;
+ this.audio.src = `data:${data.mimeType || 'audio/mp3'};base64,${data.audioBase64}`;
+ this.audio.playbackRate = 1.0;
+
+ await this.audio.play();
+ this._startAnimationLoop();
+ } catch (err) {
+ console.error('Failed to play question audio stream:', err);
+ }
+ }
+
+ _startAnimationLoop () {
+ cancelAnimationFrame(this.animFrameId);
+ const loop = () => {
+ if (!this.audio || this.audio.paused || this.audio.ended) return;
+ const currentTimeMs = this.audio.currentTime * 1000;
+
+ for (let i = this.timestamps.length - 1; i >= 0; i--) {
+ if (currentTimeMs >= this.timestamps[i].start) {
+ if (i !== this.currentWordIndex) {
+ this.currentWordIndex = i;
+ if (typeof this.onWordCallback === 'function') {
+ this.onWordCallback(i, this.timestamps[i].part);
+ }
+ }
+ break;
+ }
+ }
+ this.animFrameId = requestAnimationFrame(loop);
+ };
+ this.animFrameId = requestAnimationFrame(loop);
+ }
+
+ pause () {
+ if (this.audio) {
+ this.audio.pause();
+ }
+ cancelAnimationFrame(this.animFrameId);
+ }
+
+ resume () {
+ if (this.audio && this.audio.src) {
+ this.audio.play();
+ this._startAnimationLoop();
+ }
+ }
+
+ stop () {
+ if (this.audio) {
+ this.audio.pause();
+ this.audio.currentTime = 0;
+ this.audio.src = '';
+ }
+ cancelAnimationFrame(this.animFrameId);
+ this.timestamps = [];
+ this.currentWordIndex = -1;
+ }
+}
+
+export const questionAudioPlayer = new QuestionAudioPlayer();
+export default questionAudioPlayer;
diff --git a/client/play/tossups/index.html b/client/play/tossups/index.html
index e8f419ad1..e0ec37702 100644
--- a/client/play/tossups/index.html
+++ b/client/play/tossups/index.html
@@ -95,6 +95,10 @@
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index aa56c258d..04db128b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"jsonwebtoken": "^9.0.0",
"mongodb": "^6.17.0",
"morgan": "^1.10.0",
+ "node-edge-tts": "^1.2.10",
"nodemailer": "^9.0.1",
"qb-answer-checker": "^1.1.9",
"stripe": "^12.10.0",
@@ -1297,7 +1298,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -1306,7 +1306,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -1321,7 +1320,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -1332,8 +1330,7 @@
"node_modules/ansi-styles/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/anymatch": {
"version": "3.1.3",
@@ -2547,7 +2544,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3411,7 +3407,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4019,6 +4014,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -4793,6 +4797,98 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-edge-tts": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.10.tgz",
+ "integrity": "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "https-proxy-agent": "^7.0.1",
+ "ws": "^8.13.0",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "node-edge-tts": "bin.js"
+ }
+ },
+ "node_modules/node-edge-tts/node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/node-edge-tts/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/node-edge-tts/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/node-edge-tts/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/node-edge-tts/node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/node-edge-tts/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -5517,6 +5613,15 @@
"url": "https://github.com/sponsors/mysticatea"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6219,7 +6324,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -7065,7 +7169,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -7988,14 +8091,12 @@
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"requires": {
"color-convert": "^2.0.1"
},
@@ -8004,7 +8105,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"requires": {
"color-name": "~1.1.4"
}
@@ -8012,8 +8112,7 @@
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}
}
},
@@ -8864,8 +8963,7 @@
"escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
},
"escape-html": {
"version": "1.0.3",
@@ -9478,8 +9576,7 @@
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-east-asian-width": {
"version": "1.5.0",
@@ -9878,6 +9975,11 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
},
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
+ },
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -10397,6 +10499,72 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
+ "node-edge-tts": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.10.tgz",
+ "integrity": "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==",
+ "requires": {
+ "https-proxy-agent": "^7.0.1",
+ "ws": "^8.13.0",
+ "yargs": "^17.7.2"
+ },
+ "dependencies": {
+ "cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "requires": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "requires": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ }
+ },
+ "yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="
+ }
+ }
+ },
"node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -10896,6 +11064,11 @@
"integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
"dev": true
},
+ "require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
+ },
"require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -11355,7 +11528,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
@@ -11894,8 +12066,7 @@
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
- "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
},
"yallist": {
"version": "3.1.1",
diff --git a/package.json b/package.json
index f30786d10..ead28d764 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"jsonwebtoken": "^9.0.0",
"mongodb": "^6.17.0",
"morgan": "^1.10.0",
+ "node-edge-tts": "^1.2.10",
"nodemailer": "^9.0.1",
"qb-answer-checker": "^1.1.9",
"stripe": "^12.10.0",
diff --git a/routes/api/audio.js b/routes/api/audio.js
new file mode 100644
index 000000000..5684fdcdc
--- /dev/null
+++ b/routes/api/audio.js
@@ -0,0 +1,83 @@
+import { EdgeTTS } from 'node-edge-tts';
+import { Router } from 'express';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { v4 as uuidv4 } from 'uuid';
+
+const router = Router();
+
+const SUPPORTED_VOICES = {
+ 'en-US-GuyNeural': 'en-US-GuyNeural',
+ 'en-US-JennyNeural': 'en-US-JennyNeural',
+ 'en-GB-RyanNeural': 'en-GB-RyanNeural',
+ 'en-GB-SoniaNeural': 'en-GB-SoniaNeural'
+};
+
+router.post('/synthesize', async (req, res) => {
+ try {
+ const { text, voice = 'en-US-GuyNeural', readingSpeed = 50 } = req.body || {};
+
+ if (!text || typeof text !== 'string' || text.trim().length === 0) {
+ return res.status(400).json({ error: 'Text parameter is required' });
+ }
+
+ if (text.length > 3000) {
+ return res.status(400).json({ error: 'Text length exceeds maximum allowed limit of 3000 characters' });
+ }
+
+ const selectedVoice = SUPPORTED_VOICES[voice] || 'en-US-GuyNeural';
+
+ // Map readingSpeed (0-100) to edge-tts rate percentage (+30% at default 50)
+ const speedNum = isNaN(readingSpeed) ? 50 : Math.max(0, Math.min(100, Number(readingSpeed)));
+ const ratePct = Math.round((speedNum - 50) * 0.6 + 30);
+ const rate = `${ratePct >= 0 ? '+' : ''}${ratePct}%`;
+
+ // Temporary files for audio and subtitle output
+ const uniqueId = uuidv4();
+ const tempAudioPath = path.join(os.tmpdir(), `qbreader_tts_${uniqueId}.mp3`);
+ const tempJsonPath = `${tempAudioPath}.json`;
+
+ const tts = new EdgeTTS({
+ voice: selectedVoice,
+ saveSubtitles: true,
+ rate,
+ outputFormat: 'audio-24khz-48kbitrate-mono-mp3'
+ });
+
+ // Clean text by stripping ALL asterisks (*), powermark tokens, HTML tags, and HTML entities
+ const cleanText = text
+ .replace(/*|*|\(\*\)|\[\*\]|\(\+\)|\[\+\]|\(#\)|\*|\+/g, '')
+ .replace(/<[^>]*>?/gm, '')
+ .replace(/&[a-z0-9#]+;/gi, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ await tts.ttsPromise(cleanText, tempAudioPath);
+
+ let timestamps = [];
+ if (fs.existsSync(tempJsonPath)) {
+ const rawJson = fs.readFileSync(tempJsonPath, 'utf8');
+ timestamps = JSON.parse(rawJson);
+ fs.unlinkSync(tempJsonPath);
+ }
+
+ let audioBase64 = '';
+ if (fs.existsSync(tempAudioPath)) {
+ const audioBuffer = fs.readFileSync(tempAudioPath);
+ audioBase64 = audioBuffer.toString('base64');
+ fs.unlinkSync(tempAudioPath);
+ }
+
+ res.json({
+ audioBase64,
+ timestamps,
+ mimeType: 'audio/mp3'
+ });
+ } catch (error) {
+ console.error('TTS Synthesis Error:', error);
+ res.status(500).json({ error: 'Failed to synthesize speech audio' });
+ }
+});
+
+export default router;
diff --git a/routes/api/index.js b/routes/api/index.js
index 2918ac137..5142600be 100644
--- a/routes/api/index.js
+++ b/routes/api/index.js
@@ -1,4 +1,5 @@
import adminRouter from './admin/index.js';
+import audioRouter from './audio.js';
import bonusRouter from './bonus.js';
import checkAnswerRouter from './check-answer.js';
import dbExplorerRouter from './db-explorer/index.js';
@@ -46,6 +47,7 @@ router.use((req, _res, next) => {
});
router.use('/admin', adminRouter);
+router.use('/audio', audioRouter);
router.use('/bonus', bonusRouter);
router.use('/check-answer', checkAnswerRouter);
router.use('/db-explorer', dbExplorerRouter);