From 503c5ef1f4078f625478110cf700217cffc4cb7d Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Thu, 26 Feb 2026 22:31:00 -0500 Subject: [PATCH 1/2] Add live captions stream endpoints and test sender --- index.js | 2 + package.json | 1 + routes/liveCaptions.js | 129 ++++++++++++++++++++++++++++++ scripts/liveCaptionsTestSender.js | 82 +++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 routes/liveCaptions.js create mode 100644 scripts/liveCaptionsTestSender.js diff --git a/index.js b/index.js index 9dde57d..a9c4741 100644 --- a/index.js +++ b/index.js @@ -184,6 +184,7 @@ const songsRoute = require('./routes/songs.js'); const paymentsRoute = require('./routes/payments.js'); const bibleRoute = require('./routes/bible.js'); const chatRoute = require('./routes/chat.js'); +const liveCaptionsRoute = require('./routes/liveCaptions.js'); const sessionChecker = require('./middleware/sessionChecker'); // -- Private Routes app.use('/user', sessionChecker, profileRoute); @@ -192,6 +193,7 @@ app.use('/payments', paymentsRoute); app.use('/bible', sessionChecker, bibleRoute); app.use('/songs', sessionChecker, songsRoute); app.use('/chat', sessionChecker, chatRoute); +app.use('/live-captions', liveCaptionsRoute); // -- Public Routes const subsplashRoute = require('./routes/subsplash.js'); app.use('/subsplash', subsplashRoute); diff --git a/package.json b/package.json index 8085319..0487d8d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "npx mocha test/auth.test.js", "start": "node index.js", + "live-captions:test-sender": "node scripts/liveCaptionsTestSender.js", "docker": "docker compose up -d", "docker_restore": "docker-compose exec mongo mongorestore --db EMI_SOCIAL /dump/EMI_SOCIAL/", "docker_dump": "docker-compose exec mongo mongodump --uri ${MONGO_URL} --out /dump" diff --git a/routes/liveCaptions.js b/routes/liveCaptions.js new file mode 100644 index 0000000..03da2f9 --- /dev/null +++ b/routes/liveCaptions.js @@ -0,0 +1,129 @@ +var express = require('express'); +var router = express.Router(); + +const sessionChecker = require("../middleware/sessionChecker.js"); + +const MAX_BUFFER_SIZE = 300; +const DEFAULT_INITIAL_LIMIT = 40; +const MAX_INITIAL_LIMIT = 120; + +const liveCaptionState = { + startedAt: Date.now(), + latestSequence: 0, + captions: [], +}; + +const normalizeLang = (lang = "") => { + const value = String(lang || "").trim().toLowerCase(); + if (!value) return ""; + const base = value.split(",")[0].split("-")[0].trim(); + return base || value; +}; + +const normalizeTranslations = (translations) => { + if (!translations || typeof translations !== "object" || Array.isArray(translations)) return {}; + const normalized = {}; + for (const [langKey, translatedText] of Object.entries(translations)) { + const lang = normalizeLang(langKey); + const text = typeof translatedText === "string" ? translatedText.trim() : ""; + if (!lang || !text) continue; + normalized[lang] = text; + } + return normalized; +}; + +const getAvailableLanguages = () => { + const langs = new Set(); + for (const caption of liveCaptionState.captions) { + if (caption?.sourceLang) langs.add(caption.sourceLang); + const translationMap = caption?.translations || {}; + Object.keys(translationMap).forEach((lang) => langs.add(normalizeLang(lang))); + } + return Array.from(langs).filter(Boolean).sort(); +}; + +router.get("/stream", sessionChecker, async (req, res) => { + try { + const sinceSequence = Number.parseInt(req.query?.sinceSequence, 10); + const requestedLimit = Number.parseInt(req.query?.limit, 10); + const initialLimit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, MAX_INITIAL_LIMIT)) + : DEFAULT_INITIAL_LIMIT; + + let captions = []; + if (Number.isFinite(sinceSequence) && sinceSequence >= 0) { + captions = liveCaptionState.captions.filter((item) => item.sequence > sinceSequence); + } else { + captions = liveCaptionState.captions.slice(-initialLimit); + } + + return res.json({ + status: "ok", + latestSequence: liveCaptionState.latestSequence, + startedAt: new Date(liveCaptionState.startedAt).toISOString(), + availableLanguages: getAvailableLanguages(), + captions, + }); + } catch (error) { + console.error("Error getting live captions stream", error); + return res.status(500).json({ + status: "Internal server error", + latestSequence: liveCaptionState.latestSequence, + captions: [], + availableLanguages: [], + }); + } +}); + +router.post("/ingest", async (req, res) => { + try { + // TODO: Add basic auth/API key validation before production roll-out. + const original = typeof req.body?.original === "string" ? req.body.original.trim() : ""; + const sourceLang = normalizeLang(req.body?.sourceLang || "original"); + const translations = normalizeTranslations(req.body?.translations); + + if (!original) { + return res.status(400).json({ status: "Original text is required" }); + } + + const sequence = liveCaptionState.latestSequence + 1; + const caption = { + sequence, + createdAt: new Date().toISOString(), + sourceLang, + original, + translations, + }; + + liveCaptionState.latestSequence = sequence; + liveCaptionState.captions.push(caption); + if (liveCaptionState.captions.length > MAX_BUFFER_SIZE) { + liveCaptionState.captions.splice(0, liveCaptionState.captions.length - MAX_BUFFER_SIZE); + } + + return res.json({ + status: "ok", + caption, + latestSequence: liveCaptionState.latestSequence, + availableLanguages: getAvailableLanguages(), + }); + } catch (error) { + console.error("Error ingesting live captions", error); + return res.status(500).json({ status: "Internal server error" }); + } +}); + +router.post("/reset", async (_, res) => { + try { + // TODO: Add admin authorization before exposing this endpoint. + liveCaptionState.startedAt = Date.now(); + liveCaptionState.latestSequence = 0; + liveCaptionState.captions = []; + return res.json({ status: "ok" }); + } catch (error) { + console.error("Error resetting live captions state", error); + return res.status(500).json({ status: "Internal server error" }); + } +}); + +module.exports = router; diff --git a/scripts/liveCaptionsTestSender.js b/scripts/liveCaptionsTestSender.js new file mode 100644 index 0000000..c6178b6 --- /dev/null +++ b/scripts/liveCaptionsTestSender.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +require("dotenv").config(); +const axios = require("axios"); + +const baseUrl = (process.env.CAPTION_TEST_BASE_URL || process.env.BASE_URL || "http://localhost:3000").replace(/\/+$/, ""); +const ingestUrl = `${baseUrl}/live-captions/ingest`; +const intervalMs = 5000; + +const samples = [ + { + original: "Bienvenidos a nuestro servicio de adoracion.", + sourceLang: "es", + translations: { + en: "Welcome to our worship service.", + fr: "Bienvenue a notre service de louange.", + }, + }, + { + original: "Leamos juntos en el Salmo 23.", + sourceLang: "es", + translations: { + en: "Let us read together in Psalm 23.", + fr: "Lisons ensemble le Psaume 23.", + }, + }, + { + original: "Dios es fiel en todo tiempo.", + sourceLang: "es", + translations: { + en: "God is faithful at all times.", + fr: "Dieu est fidele en tout temps.", + }, + }, + { + original: "Tomemos un momento para orar.", + sourceLang: "es", + translations: { + en: "Let us take a moment to pray.", + fr: "Prenons un moment pour prier.", + }, + }, +]; + +let sampleIndex = 0; +let timer = null; + +const sendNextSample = async () => { + const payload = samples[sampleIndex]; + sampleIndex = (sampleIndex + 1) % samples.length; + + try { + const response = await axios.post(ingestUrl, payload, { + headers: { "Content-Type": "application/json" }, + timeout: 10000, + }); + const seq = response?.data?.caption?.sequence || response?.data?.latestSequence || "?"; + console.log(`[live-captions:test-sender] sent sequence=${seq} original="${payload.original}"`); + } catch (error) { + const status = error?.response?.status; + const body = error?.response?.data; + const message = error?.message || "request failed"; + console.error("[live-captions:test-sender] send failed", { status, body, message }); + } +}; + +const start = async () => { + console.log(`[live-captions:test-sender] posting to ${ingestUrl} every ${intervalMs / 1000}s`); + await sendNextSample(); + timer = setInterval(sendNextSample, intervalMs); +}; + +const shutdown = () => { + if (timer) clearInterval(timer); + console.log("[live-captions:test-sender] stopped"); + process.exit(0); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +start(); -- 2.49.1 From f0afa200b11491691c4118b48121e9ff180cbda1 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Thu, 26 Feb 2026 22:56:51 -0500 Subject: [PATCH 2/2] Align live captions transport to flat language payloads --- routes/liveCaptions.js | 43 ++++++++++++++++++++++++++----- scripts/liveCaptionsTestSender.js | 32 +++++++++-------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/routes/liveCaptions.js b/routes/liveCaptions.js index 03da2f9..04490d6 100644 --- a/routes/liveCaptions.js +++ b/routes/liveCaptions.js @@ -6,6 +6,7 @@ const sessionChecker = require("../middleware/sessionChecker.js"); const MAX_BUFFER_SIZE = 300; const DEFAULT_INITIAL_LIMIT = 40; const MAX_INITIAL_LIMIT = 120; +const CAPTION_META_KEYS = new Set(["sequence", "createdAt", "original"]); const liveCaptionState = { startedAt: Date.now(), @@ -32,12 +33,36 @@ const normalizeTranslations = (translations) => { return normalized; }; +const buildTranslationsFromFlatPayload = (payload) => { + const ignoredKeys = new Set(["original", "sourceLang", "translations"]); + const normalized = {}; + for (const [key, value] of Object.entries(payload || {})) { + if (ignoredKeys.has(key)) continue; + const lang = normalizeLang(key); + const text = typeof value === "string" ? value.trim() : ""; + if (!lang || !text) continue; + normalized[lang] = text; + } + return normalized; +}; + +const inferSourceLangFromTranslations = (original, translations) => { + const normalizedOriginal = String(original || "").trim(); + if (!normalizedOriginal) return "original"; + for (const [lang, text] of Object.entries(translations || {})) { + if (String(text || "").trim() === normalizedOriginal) return normalizeLang(lang); + } + return "original"; +}; + const getAvailableLanguages = () => { const langs = new Set(); for (const caption of liveCaptionState.captions) { - if (caption?.sourceLang) langs.add(caption.sourceLang); - const translationMap = caption?.translations || {}; - Object.keys(translationMap).forEach((lang) => langs.add(normalizeLang(lang))); + Object.keys(caption || {}).forEach((key) => { + if (CAPTION_META_KEYS.has(key)) return; + const lang = normalizeLang(key); + if (lang) langs.add(lang); + }); } return Array.from(langs).filter(Boolean).sort(); }; @@ -79,20 +104,24 @@ router.post("/ingest", async (req, res) => { try { // TODO: Add basic auth/API key validation before production roll-out. const original = typeof req.body?.original === "string" ? req.body.original.trim() : ""; - const sourceLang = normalizeLang(req.body?.sourceLang || "original"); - const translations = normalizeTranslations(req.body?.translations); + const mapFromNested = normalizeTranslations(req.body?.translations); + const mapFromFlat = buildTranslationsFromFlatPayload(req.body); + const translations = { ...mapFromNested, ...mapFromFlat }; + const sourceLang = normalizeLang(req.body?.sourceLang || inferSourceLangFromTranslations(original, translations)); if (!original) { return res.status(400).json({ status: "Original text is required" }); } + if (sourceLang && sourceLang !== "original" && !translations[sourceLang]) { + translations[sourceLang] = original; + } const sequence = liveCaptionState.latestSequence + 1; const caption = { sequence, createdAt: new Date().toISOString(), - sourceLang, original, - translations, + ...translations, }; liveCaptionState.latestSequence = sequence; diff --git a/scripts/liveCaptionsTestSender.js b/scripts/liveCaptionsTestSender.js index c6178b6..80c7633 100644 --- a/scripts/liveCaptionsTestSender.js +++ b/scripts/liveCaptionsTestSender.js @@ -10,35 +10,27 @@ const intervalMs = 5000; const samples = [ { original: "Bienvenidos a nuestro servicio de adoracion.", - sourceLang: "es", - translations: { - en: "Welcome to our worship service.", - fr: "Bienvenue a notre service de louange.", - }, + es: "Bienvenidos a nuestro servicio de adoracion.", + en: "Welcome to our worship service.", + fr: "Bienvenue a notre service de louange.", }, { original: "Leamos juntos en el Salmo 23.", - sourceLang: "es", - translations: { - en: "Let us read together in Psalm 23.", - fr: "Lisons ensemble le Psaume 23.", - }, + es: "Leamos juntos en el Salmo 23.", + en: "Let us read together in Psalm 23.", + fr: "Lisons ensemble le Psaume 23.", }, { original: "Dios es fiel en todo tiempo.", - sourceLang: "es", - translations: { - en: "God is faithful at all times.", - fr: "Dieu est fidele en tout temps.", - }, + es: "Dios es fiel en todo tiempo.", + en: "God is faithful at all times.", + fr: "Dieu est fidele en tout temps.", }, { original: "Tomemos un momento para orar.", - sourceLang: "es", - translations: { - en: "Let us take a moment to pray.", - fr: "Prenons un moment pour prier.", - }, + es: "Tomemos un momento para orar.", + en: "Let us take a moment to pray.", + fr: "Prenons un moment pour prier.", }, ]; -- 2.49.1