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);