diff --git a/node_api/api/index.js b/node_api/api/index.js index 479637d..5c7b359 100644 --- a/node_api/api/index.js +++ b/node_api/api/index.js @@ -1,48 +1,135 @@ -require('dotenv').config() +require("dotenv").config() + +const path = require("node:path") +const fs = require("node:fs") +const axios = require("axios") +const { aws4Interceptor } = require("aws4-axios") const express = require("express") -const path = require("path") const cors = require("cors") -const mlib = require("../../node_lib") + +const getPhrases = require("../../node_lib") let app = null const { LISTENING_PORT } = process.env const PORT = LISTENING_PORT || 3000 -async function main() { - app = express() +const cachePath = path.join(process.cwd(), ".cache") +const audiosPath = path.join(cachePath, "audio") - app.use(cors()) - app.use(express.json()) +const PollyBaseURL = "https://polly.us-east-1.amazonaws.com" - app.get("/api", async (req, res) => { - let { random } = req.query - - // try to parse random, can be a number or a boolean - if (random) { - if (random === "true") { - random = true - } else if (Number(random)) { - random = Number(random) - } - } - - const phrases = await mlib({ random }) - - res.json(phrases) - }) - - app.use(express.static(path.join(__dirname, "..", "web", "dist",))) - - // serve static react build - app.get("*", (req, res) => { - res.sendFile(path.join(__dirname, "..", "web", "dist", "index.html")) - }) - - app.listen(PORT) - - console.log(`Listening on port ${PORT}`) +const PollyDefaultConfig = { + Engine: "generative", + VoiceId: "Lucia", + OutputFormat: "mp3", + LanguageCode: "es-ES", } -main().catch(console.error) \ No newline at end of file +const interceptor = aws4Interceptor({ + options: { + region: "us-east-1", + service: "polly", + }, + credentials: { + accessKeyId: process.env.AWS_API_KEY, + secretAccessKey: process.env.AWS_API_SECRET, + }, +}) + +axios.interceptors.request.use(interceptor) + +async function synthesizePollyVoice(phrase, phraseId) { + if (!fs.existsSync(audiosPath)) { + fs.mkdirSync(audiosPath, { recursive: true }) + } + + // call to api and stream result to a file + const voiceResultPath = path.resolve(audiosPath, phraseId + ".mp3") + + if (fs.existsSync(voiceResultPath)) { + fs.unlinkSync(voiceResultPath) + } + + const voiceResultFile = fs.createWriteStream(voiceResultPath) + + const { data: stream } = await axios({ + url: `${PollyBaseURL}/v1/speech`, + method: "POST", + data: { + ...PollyDefaultConfig, + Text: phrase, + }, + responseType: "stream", + }) + + stream.pipe(voiceResultFile) + + return new Promise((resolve, reject) => { + stream.on("end", () => resolve(voiceResultPath)) + stream.on("error", (error) => reject(error)) + }) +} + +async function fetchTTSAudioURL(req, phrase, phraseId) { + const filePath = path.join(audiosPath, `${phraseId}.mp3`) + + if (!fs.existsSync(filePath)) { + await synthesizePollyVoice(phrase, phraseId) + } + + return `${req.protocol}://${req.get("host")}${req.path}/audio/${phraseId}.mp3` +} + +async function handleApiRequest(req, res) { + let { random } = req.query + + // try to parse random, can be a number or a boolean + if (random) { + if (random === "true") { + random = true + } else if (Number(random)) { + random = Number(random) + } + } + + const result = await getPhrases({ random }) + + if (random) { + const phraseId = result + .trim() + .toLowerCase() + .replace(/\s+/g, "_") + .replace(/[^\w\s]/gi, "") + + return res.json({ + id: phraseId, + phrase: result.trim(), + tts_file: await fetchTTSAudioURL(req, result, phraseId), + }) + } + + return res.json(result) +} + +async function main() { + app = express() + + app.use(cors()) + app.use(express.json()) + + app.get("/api", handleApiRequest) + app.use("/api/audio", express.static(audiosPath)) + + app.use(express.static(path.join(__dirname, "..", "web", "dist"))) + // app.get("*", (req, res) => { + // res.sendFile(path.join(__dirname, "..", "web", "dist", "index.html")) + // }) + + app.listen(PORT) + + console.log(`Listening on port ${PORT}`) +} + +main().catch(console.error) diff --git a/node_api/api/package.json b/node_api/api/package.json index fac1fcf..00829c0 100644 --- a/node_api/api/package.json +++ b/node_api/api/package.json @@ -4,6 +4,8 @@ "main": "index.js", "license": "MIT", "dependencies": { + "aws4-axios": "^3.3.15", + "axios": "^1.7.9", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.2" diff --git a/node_api/web/src/index.css b/node_api/web/src/index.css index e20a1c7..33455e8 100644 --- a/node_api/web/src/index.css +++ b/node_api/web/src/index.css @@ -1,86 +1,99 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme : light dark; - color : rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis : none; - text-rendering : optimizeLegibility; - -webkit-font-smoothing : antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - font-weight : 500; - color : #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } a:hover { - color: #535bf2; + color: #535bf2; } body { - display : flex; - flex-direction: column; + display: flex; + flex-direction: column; - margin : 0; - padding: 0; + margin: 0; + padding: 0; } h1 { - font-size : 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } button { - border-radius : 8px; - border : 1px solid transparent; - padding : 0.6em 1.2em; - font-size : 1em; - font-weight : 500; - font-family : inherit; - background-color: #1a1a1a; - cursor : pointer; - transition : border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #646cff; } button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } .app { - display : flex; - flex-direction: column; + display: flex; + flex-direction: column; - align-items : center; - justify-content: center; + align-items: center; + justify-content: center; - width : 100%; - height: 100vh; + width: 100%; + height: 100vh; +} + +.result { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + .playback_audio { + font-size: 24px; + cursor: pointer; + } } footer { - position: fixed; + position: fixed; - bottom: 10vh; - left : 0; + bottom: 10vh; + left: 0; - width : 100%; - height: fit-content; + width: 100%; + height: fit-content; - display: flex; + display: flex; - align-items : center; - justify-content: center; + align-items: center; + justify-content: center; - gap: 20px; -} \ No newline at end of file + gap: 20px; +} diff --git a/node_api/web/src/main.jsx b/node_api/web/src/main.jsx index 8431b53..14b03bc 100644 --- a/node_api/web/src/main.jsx +++ b/node_api/web/src/main.jsx @@ -5,48 +5,76 @@ import axios from "axios" import "./index.css" -const API_ENDPOINT = import.meta.env.PROD ? "/api" : `http://${window.location.hostname}:3000/api` +const API_ENDPOINT = import.meta.env.PROD + ? "/api" + : `http://${window.location.hostname}:3000/api` const App = () => { - const [loading, setLoading] = React.useState(true) - const [randomWord, setRandomWord] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [randomWord, setRandomWord] = React.useState(null) - async function loadRandom({ - random = true, - } = {}) { - setLoading(true) + async function loadRandom({ random = true } = {}) { + setLoading(true) - let { data } = await axios({ - url: API_ENDPOINT, - method: "GET", - params: { - random, - }, - }) + let { data } = await axios({ + url: API_ENDPOINT, + method: "GET", + params: { + random, + }, + }) - setLoading(false) + setLoading(false) - setRandomWord(data) - } + setRandomWord(data) + } - React.useEffect(() => { - loadRandom() - }, []) + async function playbackCurrentWord() { + if (!randomWord || !randomWord.tts_file) return - return
- { - loading ?

Loading...

:

{randomWord}

- } + const audio = new Audio() - -
+ audio.src = randomWord.tts_file + audio.volume = 0.5 + + audio.play() + } + + React.useEffect(() => { + loadRandom() + }, []) + + return ( +
+
+ {loading ?

Loading...

:

{randomWord.phrase}

} + +
+ + + + +
+
+ + +
+ ) } -ReactDOM.createRoot(document.getElementById("root")).render( - - - , -) +ReactDOM.createRoot(document.getElementById("root")).render()