Un formulario para convertir audio en texto parece fácil de construir:
- el usuario selecciona un archivo;
- JavaScript lo envía a una API;
- la API devuelve la transcripción;
- el navegador muestra el resultado.
El error habitual aparece en el segundo paso: llamar al proveedor de transcripción directamente desde el navegador y colocar la API key dentro del código JavaScript.
const apiKey = "sk-...";
Aunque la clave no se muestre en la interfaz, cualquier visitante puede encontrarla desde:
- las herramientas de desarrollo;
- el código JavaScript descargado;
- las peticiones de red;
- un mapa de código fuente;
- una extensión del navegador.
Una clave incluida en el frontend debe considerarse pública.
La arquitectura correcta separa responsabilidades:
Navegador
↓
Servidor Node.js
↓
Proveedor de transcripción
El navegador selecciona y sube el archivo. El servidor valida el audio, conserva la API key y realiza la petición externa.
En este tutorial construiremos una aplicación que:
- acepta archivos desde el navegador;
- valida formato y tamaño;
- no expone la API key;
- devuelve un identificador de trabajo;
- procesa la transcripción fuera de la petición inicial;
- permite consultar el estado;
- muestra el resultado;
- elimina los archivos temporales;
- controla los errores más frecuentes.
El ejemplo utiliza Express, Multer y el SDK de OpenAI, pero la estructura puede adaptarse a otros proveedores.
Qué vamos a construir
El flujo será este:
1. El usuario selecciona un audio.
2. El navegador envía el archivo a POST /api/transcriptions.
3. Node.js valida y guarda temporalmente el archivo.
4. El servidor devuelve un task_id.
5. El navegador consulta GET /api/transcriptions/:task_id.
6. Un proceso interno envía el audio al proveedor.
7. El servidor guarda el resultado.
8. El navegador muestra la transcripción.
9. El archivo temporal se elimina.
No mantendremos abierta una petición HTTP durante toda la transcripción.
Esto evita que una petición lenta termine por:
- timeout;
- cierre del navegador;
- fallo del proxy;
- pérdida de conexión;
- reintento accidental del mismo archivo.
Requisitos
Necesitas:
- Node.js 20 o superior;
- npm;
- una API key;
- un archivo MP3, WAV, M4A, MP4, MPEG, MPGA o WEBM para probar.
Crea el proyecto:
mkdir audio-transcription-node
cd audio-transcription-node
npm init -y
Instala las dependencias:
npm install express multer openai dotenv
Instala una dependencia de desarrollo para reiniciar el servidor automáticamente:
npm install --save-dev nodemon
Estructura del proyecto
Crea esta estructura:
audio-transcription-node/
├── public/
│ ├── index.html
│ ├── app.js
│ └── styles.css
├── uploads/
├── .env
├── .gitignore
├── package.json
└── server.js
La carpeta uploads almacenará temporalmente los archivos recibidos.
No debe utilizarse como almacenamiento permanente.
Configurar package.json
Modifica package.json:
{
"name": "audio-transcription-node",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"dotenv": "^16.0.0",
"express": "^5.0.0",
"multer": "^2.0.0",
"openai": "^5.0.0"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}
Las versiones exactas pueden variar cuando instales los paquetes. No es necesario copiarlas manualmente si ya ejecutaste npm install.
Guardar la API key
Crea .env:
OPENAI_API_KEY=tu_clave_aqui
PORT=3000
MAX_AUDIO_SIZE_MB=25
Crea .gitignore:
node_modules/
.env
uploads/*
!uploads/.gitkeep
Añade un archivo vacío:
touch uploads/.gitkeep
En Windows PowerShell:
New-Item uploads/.gitkeep -ItemType File
Nunca subas .env a GitHub.
Una API key expuesta debe revocarse. Eliminarla del último commit no basta, porque puede seguir presente en el historial del repositorio.
Estados de una transcripción
Cada trabajo tendrá uno de estos estados:
queued
processing
completed
failed
La respuesta inicial será similar a:
{
"taskId": "1b78bf8f-27dc-49d1-8f2d-7df81d4eeef2",
"status": "queued"
}
Después, el navegador consultará el estado:
{
"taskId": "1b78bf8f-27dc-49d1-8f2d-7df81d4eeef2",
"status": "completed",
"text": "Texto obtenido del archivo..."
}
Crear el servidor
Crea server.js:
import "dotenv/config";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import multer from "multer";
import OpenAI from "openai";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number.parseInt(process.env.PORT ?? "3000", 10);
const MAX_AUDIO_SIZE_MB = Number.parseInt(
process.env.MAX_AUDIO_SIZE_MB ?? "25",
10,
);
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"Falta OPENAI_API_KEY en el archivo .env",
);
}
if (!Number.isFinite(PORT) || PORT <= 0) {
throw new Error("PORT no es válido.");
}
if (
!Number.isFinite(MAX_AUDIO_SIZE_MB)
|| MAX_AUDIO_SIZE_MB <= 0
) {
throw new Error("MAX_AUDIO_SIZE_MB no es válido.");
}
const app = express();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const uploadsDirectory = path.join(__dirname, "uploads");
const publicDirectory = path.join(__dirname, "public");
await fs.promises.mkdir(uploadsDirectory, {
recursive: true,
});
app.use(express.json());
app.use(express.static(publicDirectory));
const jobs = new Map();
const allowedMimeTypes = new Set([
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/x-m4a",
"audio/wav",
"audio/x-wav",
"audio/webm",
"video/mp4",
"video/webm",
]);
const allowedExtensions = new Set([
".mp3",
".mp4",
".mpeg",
".mpga",
".m4a",
".wav",
".webm",
]);
function sanitizeExtension(originalName) {
const extension = path
.extname(originalName)
.toLowerCase();
if (!allowedExtensions.has(extension)) {
return "";
}
return extension;
}
const storage = multer.diskStorage({
destination: (_request, _file, callback) => {
callback(null, uploadsDirectory);
},
filename: (_request, file, callback) => {
const extension = sanitizeExtension(
file.originalname,
);
const generatedName =
`${crypto.randomUUID()}${extension}`;
callback(null, generatedName);
},
});
const upload = multer({
storage,
limits: {
fileSize: MAX_AUDIO_SIZE_MB * 1024 * 1024,
files: 1,
},
fileFilter: (_request, file, callback) => {
const extension = sanitizeExtension(
file.originalname,
);
const validMime =
allowedMimeTypes.has(file.mimetype);
if (!extension || !validMime) {
callback(
new Error(
"Formato no permitido. "
+ "Utiliza MP3, M4A, WAV, MP4 o WEBM.",
),
);
return;
}
callback(null, true);
},
});
La extensión y el MIME se revisan, pero esto todavía no garantiza que el archivo sea realmente audio.
Un atacante puede cambiar el nombre de cualquier archivo a .mp3.
En producción conviene inspeccionar el contenido mediante FFprobe, una biblioteca de detección binaria o ambos.
Crear un trabajo
Añade este endpoint:
app.post(
"/api/transcriptions",
upload.single("audio"),
async (request, response, next) => {
try {
if (!request.file) {
response.status(400).json({
error: "Debes seleccionar un archivo.",
});
return;
}
const taskId = crypto.randomUUID();
const now = new Date().toISOString();
const job = {
taskId,
status: "queued",
originalName: request.file.originalname,
storedPath: request.file.path,
mimeType: request.file.mimetype,
size: request.file.size,
text: null,
error: null,
createdAt: now,
updatedAt: now,
};
jobs.set(taskId, job);
response.status(202).json({
taskId,
status: job.status,
});
setImmediate(() => {
processTranscription(taskId).catch(
(error) => {
console.error(
"Error no controlado en el trabajo:",
error,
);
},
);
});
} catch (error) {
next(error);
}
},
);
La respuesta utiliza el código HTTP 202 Accepted.
Significa que el servidor aceptó el trabajo, pero todavía no lo ha terminado.
Procesar la transcripción
Añade esta función:
async function processTranscription(taskId) {
const job = jobs.get(taskId);
if (!job || job.status !== "queued") {
return;
}
job.status = "processing";
job.updatedAt = new Date().toISOString();
try {
const transcription =
await openai.audio.transcriptions.create({
file: fs.createReadStream(job.storedPath),
model: "whisper-1",
language: "es",
response_format: "json",
});
job.status = "completed";
job.text = transcription.text;
job.error = null;
job.updatedAt = new Date().toISOString();
} catch (error) {
console.error(
`Falló la transcripción ${taskId}:`,
error,
);
job.status = "failed";
job.text = null;
job.error = normalizeProviderError(error);
job.updatedAt = new Date().toISOString();
} finally {
await removeTemporaryFile(job.storedPath);
job.storedPath = null;
}
}
function normalizeProviderError(error) {
if (
error
&& typeof error === "object"
&& "status" in error
) {
if (error.status === 401) {
return "La credencial del proveedor no es válida.";
}
if (error.status === 413) {
return "El archivo supera el límite del proveedor.";
}
if (error.status === 429) {
return (
"El proveedor ha limitado temporalmente "
+ "las peticiones."
);
}
}
return "No se pudo completar la transcripción.";
}
async function removeTemporaryFile(filePath) {
if (!filePath) {
return;
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error?.code !== "ENOENT") {
console.error(
`No se pudo borrar ${filePath}:`,
error,
);
}
}
}
La clave solo existe en el proceso de Node.js.
El navegador nunca la recibe.
Consultar el estado
Añade:
app.get(
"/api/transcriptions/:taskId",
(request, response) => {
const job = jobs.get(request.params.taskId);
if (!job) {
response.status(404).json({
error: "No se encontró el trabajo.",
});
return;
}
response.json({
taskId: job.taskId,
status: job.status,
originalName: job.originalName,
text: job.text,
error: job.error,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
});
},
);
No devolvemos:
- la ruta temporal;
- la API key;
- detalles internos del proveedor;
- stack traces;
- nombres de directorios del servidor.
Eliminar trabajos antiguos
El archivo temporal ya se elimina al terminar, pero el objeto permanece dentro de jobs.
Añade una limpieza periódica:
const JOB_TTL_MS = 60 * 60 * 1000;
setInterval(() => {
const expirationTime = Date.now() - JOB_TTL_MS;
for (const [taskId, job] of jobs.entries()) {
const updatedAt = Date.parse(job.updatedAt);
if (
Number.isFinite(updatedAt)
&& updatedAt < expirationTime
) {
jobs.delete(taskId);
}
}
}, 10 * 60 * 1000).unref();
Los resultados se eliminan de memoria una hora después de la última actualización.
Este almacenamiento es intencionadamente sencillo. Más adelante veremos por qué no sirve para múltiples servidores.
Controlar errores de Multer
Añade al final:
app.use((error, _request, response, _next) => {
console.error(error);
if (error instanceof multer.MulterError) {
if (error.code === "LIMIT_FILE_SIZE") {
response.status(413).json({
error:
`El archivo supera ${MAX_AUDIO_SIZE_MB} MB.`,
});
return;
}
response.status(400).json({
error: "No se pudo procesar el archivo.",
});
return;
}
response.status(400).json({
error:
error instanceof Error
? error.message
: "Error inesperado.",
});
});
app.listen(PORT, () => {
console.log(
`Servidor disponible en http://localhost:${PORT}`,
);
});
Código completo del servidor
server.js debería quedar así:
import "dotenv/config";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import multer from "multer";
import OpenAI from "openai";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number.parseInt(
process.env.PORT ?? "3000",
10,
);
const MAX_AUDIO_SIZE_MB = Number.parseInt(
process.env.MAX_AUDIO_SIZE_MB ?? "25",
10,
);
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"Falta OPENAI_API_KEY en el archivo .env",
);
}
if (!Number.isFinite(PORT) || PORT <= 0) {
throw new Error("PORT no es válido.");
}
if (
!Number.isFinite(MAX_AUDIO_SIZE_MB)
|| MAX_AUDIO_SIZE_MB <= 0
) {
throw new Error("MAX_AUDIO_SIZE_MB no es válido.");
}
const app = express();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const uploadsDirectory = path.join(
__dirname,
"uploads",
);
const publicDirectory = path.join(
__dirname,
"public",
);
await fs.promises.mkdir(
uploadsDirectory,
{ recursive: true },
);
app.use(express.json());
app.use(express.static(publicDirectory));
const jobs = new Map();
const allowedMimeTypes = new Set([
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/x-m4a",
"audio/wav",
"audio/x-wav",
"audio/webm",
"video/mp4",
"video/webm",
]);
const allowedExtensions = new Set([
".mp3",
".mp4",
".mpeg",
".mpga",
".m4a",
".wav",
".webm",
]);
function sanitizeExtension(originalName) {
const extension = path
.extname(originalName)
.toLowerCase();
return allowedExtensions.has(extension)
? extension
: "";
}
const storage = multer.diskStorage({
destination: (_request, _file, callback) => {
callback(null, uploadsDirectory);
},
filename: (_request, file, callback) => {
const extension = sanitizeExtension(
file.originalname,
);
callback(
null,
`${crypto.randomUUID()}${extension}`,
);
},
});
const upload = multer({
storage,
limits: {
fileSize:
MAX_AUDIO_SIZE_MB * 1024 * 1024,
files: 1,
},
fileFilter: (_request, file, callback) => {
const extension = sanitizeExtension(
file.originalname,
);
const validMime =
allowedMimeTypes.has(file.mimetype);
if (!extension || !validMime) {
callback(
new Error(
"Formato no permitido. "
+ "Utiliza MP3, M4A, WAV, MP4 o WEBM.",
),
);
return;
}
callback(null, true);
},
});
function normalizeProviderError(error) {
if (
error
&& typeof error === "object"
&& "status" in error
) {
if (error.status === 401) {
return "La credencial del proveedor no es válida.";
}
if (error.status === 413) {
return "El archivo supera el límite del proveedor.";
}
if (error.status === 429) {
return (
"El proveedor ha limitado temporalmente "
+ "las peticiones."
);
}
}
return "No se pudo completar la transcripción.";
}
async function removeTemporaryFile(filePath) {
if (!filePath) {
return;
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error?.code !== "ENOENT") {
console.error(
`No se pudo borrar ${filePath}:`,
error,
);
}
}
}
async function processTranscription(taskId) {
const job = jobs.get(taskId);
if (!job || job.status !== "queued") {
return;
}
job.status = "processing";
job.updatedAt = new Date().toISOString();
try {
const transcription =
await openai.audio.transcriptions.create({
file: fs.createReadStream(
job.storedPath,
),
model: "whisper-1",
language: "es",
response_format: "json",
});
job.status = "completed";
job.text = transcription.text;
job.error = null;
job.updatedAt = new Date().toISOString();
} catch (error) {
console.error(
`Falló la transcripción ${taskId}:`,
error,
);
job.status = "failed";
job.text = null;
job.error =
normalizeProviderError(error);
job.updatedAt = new Date().toISOString();
} finally {
await removeTemporaryFile(
job.storedPath,
);
job.storedPath = null;
}
}
app.post(
"/api/transcriptions",
upload.single("audio"),
async (request, response, next) => {
try {
if (!request.file) {
response.status(400).json({
error:
"Debes seleccionar un archivo.",
});
return;
}
const taskId = crypto.randomUUID();
const now = new Date().toISOString();
const job = {
taskId,
status: "queued",
originalName:
request.file.originalname,
storedPath: request.file.path,
mimeType: request.file.mimetype,
size: request.file.size,
text: null,
error: null,
createdAt: now,
updatedAt: now,
};
jobs.set(taskId, job);
response.status(202).json({
taskId,
status: job.status,
});
setImmediate(() => {
processTranscription(taskId).catch(
(error) => {
console.error(
"Error no controlado:",
error,
);
},
);
});
} catch (error) {
next(error);
}
},
);
app.get(
"/api/transcriptions/:taskId",
(request, response) => {
const job = jobs.get(
request.params.taskId,
);
if (!job) {
response.status(404).json({
error:
"No se encontró el trabajo.",
});
return;
}
response.json({
taskId: job.taskId,
status: job.status,
originalName: job.originalName,
text: job.text,
error: job.error,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
});
},
);
const JOB_TTL_MS = 60 * 60 * 1000;
setInterval(() => {
const expirationTime =
Date.now() - JOB_TTL_MS;
for (
const [taskId, job]
of jobs.entries()
) {
const updatedAt =
Date.parse(job.updatedAt);
if (
Number.isFinite(updatedAt)
&& updatedAt < expirationTime
) {
jobs.delete(taskId);
}
}
}, 10 * 60 * 1000).unref();
app.use(
(error, _request, response, _next) => {
console.error(error);
if (
error instanceof multer.MulterError
) {
if (
error.code === "LIMIT_FILE_SIZE"
) {
response.status(413).json({
error:
`El archivo supera `
+ `${MAX_AUDIO_SIZE_MB} MB.`,
});
return;
}
response.status(400).json({
error:
"No se pudo procesar el archivo.",
});
return;
}
response.status(400).json({
error:
error instanceof Error
? error.message
: "Error inesperado.",
});
},
);
app.listen(PORT, () => {
console.log(
`Servidor disponible en `
+ `http://localhost:${PORT}`,
);
});
Crear la interfaz HTML
Crea public/index.html:
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Transcribir audio con Node.js</title>
<link
rel="stylesheet"
href="/styles.css"
/>
</head>
<body>
<main class="container">
<section class="card">
<h1>Transcribir un archivo de audio</h1>
<p>
Selecciona un archivo MP3, M4A,
WAV, MP4 o WEBM.
</p>
<form id="transcription-form">
<label for="audio">
Archivo de audio
</label>
<input
id="audio"
name="audio"
type="file"
accept="
audio/mpeg,
audio/mp4,
audio/wav,
audio/webm,
video/mp4,
video/webm,
.mp3,
.m4a,
.wav,
.mp4,
.webm
"
required
/>
<button type="submit">
Transcribir
</button>
</form>
<div
id="status"
class="status"
aria-live="polite"
></div>
<section
id="result-section"
hidden
>
<h2>Resultado</h2>
<textarea
id="result"
rows="18"
readonly
></textarea>
<button
id="copy-button"
type="button"
>
Copiar texto
</button>
</section>
</section>
</main>
<script
type="module"
src="/app.js"
></script>
</body>
</html>
Crear el JavaScript del navegador
Crea public/app.js:
const form = document.querySelector(
"#transcription-form",
);
const fileInput = document.querySelector(
"#audio",
);
const statusElement = document.querySelector(
"#status",
);
const resultSection = document.querySelector(
"#result-section",
);
const resultElement = document.querySelector(
"#result",
);
const copyButton = document.querySelector(
"#copy-button",
);
const submitButton = form.querySelector(
'button[type="submit"]',
);
const MAX_CLIENT_SIZE_MB = 25;
let pollingController = null;
function setStatus(message, type = "info") {
statusElement.textContent = message;
statusElement.dataset.type = type;
}
function setLoading(isLoading) {
submitButton.disabled = isLoading;
fileInput.disabled = isLoading;
submitButton.textContent = isLoading
? "Procesando…"
: "Transcribir";
}
function validateFile(file) {
if (!file) {
throw new Error(
"Selecciona un archivo.",
);
}
const maximumBytes =
MAX_CLIENT_SIZE_MB * 1024 * 1024;
if (file.size > maximumBytes) {
throw new Error(
`El archivo supera `
+ `${MAX_CLIENT_SIZE_MB} MB.`,
);
}
const allowedExtensions = [
".mp3",
".m4a",
".wav",
".mp4",
".webm",
".mpeg",
".mpga",
];
const lowerName =
file.name.toLowerCase();
const validExtension =
allowedExtensions.some(
(extension) =>
lowerName.endsWith(extension),
);
if (!validExtension) {
throw new Error(
"Formato no permitido.",
);
}
}
async function createTranscription(file) {
const formData = new FormData();
formData.append("audio", file);
const response = await fetch(
"/api/transcriptions",
{
method: "POST",
body: formData,
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error
?? "No se pudo subir el archivo.",
);
}
return data;
}
function delay(
milliseconds,
signal,
) {
return new Promise(
(resolve, reject) => {
const timer = setTimeout(
resolve,
milliseconds,
);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(
new DOMException(
"Operación cancelada",
"AbortError",
),
);
},
{ once: true },
);
},
);
}
async function getJob(taskId, signal) {
const response = await fetch(
`/api/transcriptions/${taskId}`,
{ signal },
);
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error
?? "No se pudo consultar el trabajo.",
);
}
return data;
}
async function waitForCompletion(
taskId,
signal,
) {
const maximumAttempts = 180;
for (
let attempt = 0;
attempt < maximumAttempts;
attempt += 1
) {
const job = await getJob(
taskId,
signal,
);
if (job.status === "completed") {
return job;
}
if (job.status === "failed") {
throw new Error(
job.error
?? "La transcripción ha fallado.",
);
}
if (job.status === "queued") {
setStatus(
"El archivo está en cola.",
);
}
if (job.status === "processing") {
setStatus(
"Transcribiendo el audio…",
);
}
await delay(2000, signal);
}
throw new Error(
"La operación tardó demasiado.",
);
}
form.addEventListener(
"submit",
async (event) => {
event.preventDefault();
pollingController?.abort();
pollingController =
new AbortController();
resultSection.hidden = true;
resultElement.value = "";
try {
const file =
fileInput.files?.[0];
validateFile(file);
setLoading(true);
setStatus("Subiendo el archivo…");
const created =
await createTranscription(file);
setStatus(
"Archivo recibido. "
+ "Preparando la transcripción…",
);
const completed =
await waitForCompletion(
created.taskId,
pollingController.signal,
);
resultElement.value =
completed.text ?? "";
resultSection.hidden = false;
setStatus(
"Transcripción completada.",
"success",
);
} catch (error) {
if (error?.name === "AbortError") {
return;
}
console.error(error);
setStatus(
error instanceof Error
? error.message
: "Ha ocurrido un error.",
"error",
);
} finally {
setLoading(false);
}
},
);
copyButton.addEventListener(
"click",
async () => {
const text =
resultElement.value.trim();
if (!text) {
return;
}
try {
await navigator.clipboard.writeText(
text,
);
setStatus(
"Texto copiado.",
"success",
);
} catch {
resultElement.select();
document.execCommand("copy");
setStatus(
"Texto copiado.",
"success",
);
}
},
);
Añadir estilos mínimos
Crea public/styles.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #1f2937;
background: #f3f4f6;
}
button,
input,
textarea {
font: inherit;
}
.container {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0;
}
.card {
display: grid;
gap: 20px;
padding: 28px;
border: 1px solid #d1d5db;
border-radius: 16px;
background: #ffffff;
}
h1,
h2,
p {
margin: 0;
}
form {
display: grid;
gap: 14px;
}
input[type="file"] {
width: 100%;
padding: 12px;
border: 1px solid #9ca3af;
border-radius: 10px;
}
button {
width: fit-content;
padding: 10px 18px;
border: 0;
border-radius: 10px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.status {
min-height: 24px;
}
.status[data-type="error"] {
font-weight: 600;
}
.status[data-type="success"] {
font-weight: 600;
}
#result-section {
display: grid;
gap: 12px;
}
#result-section[hidden] {
display: none;
}
textarea {
width: 100%;
resize: vertical;
padding: 14px;
line-height: 1.6;
border: 1px solid #9ca3af;
border-radius: 10px;
}
Ejecutar la aplicación
Inicia el servidor:
npm run dev
Abre:
http://localhost:3000
Selecciona un archivo en español y pulsa Transcribir.
Por qué no llamamos a la API desde el navegador
Este código sería un error:
const response = await fetch(
"https://api.example.com/transcriptions",
{
method: "POST",
headers: {
Authorization:
"Bearer CLAVE_PRIVADA",
},
body: formData,
},
);
La clave aparece dentro de la petición.
Un usuario puede abrir la pestaña Network y copiarla.
No importa que:
- el código esté minificado;
- la variable tenga otro nombre;
- el archivo JavaScript esté ofuscado;
- la clave se construya concatenando cadenas;
- la aplicación utilice React, Vue o Next.js.
Si el navegador necesita la clave para realizar la llamada, el visitante puede recuperarla.
Las variables de entorno del frontend tampoco solucionan el problema cuando su contenido termina incluido en el bundle público.
La validación del navegador no protege el servidor
En app.js validamos extensión y tamaño.
Eso mejora la experiencia, pero no es una medida de seguridad suficiente.
Un usuario puede enviar directamente:
curl
o utilizar Postman sin cargar tu HTML.
Por eso repetimos la validación en Node.js.
La regla es:
Validación del navegador:
comodidad
Validación del servidor:
obligatoria
El almacenamiento en memoria tiene límites
El ejemplo utiliza:
const jobs = new Map();
Esto funciona para:
- desarrollo local;
- demostraciones;
- una sola instancia;
- trabajos temporales;
- poco tráfico.
No funciona bien si:
- reinicias el servidor;
- utilizas varios procesos;
- despliegas varias instancias;
- necesitas conservar el historial;
- procesas muchos archivos.
Si el proceso se reinicia, todos los estados desaparecen.
En producción, guarda los trabajos en PostgreSQL, Redis u otra base compartida.
Una tabla mínima puede contener:
id
status
original_name
storage_key
result_text
error_message
created_at
updated_at
setImmediate tampoco es una cola real
Este código:
setImmediate(() => {
processTranscription(taskId);
});
separa el trabajo de la petición inicial, pero no proporciona:
- reintentos;
- prioridad;
- persistencia;
- control de concurrencia;
- recuperación después de reiniciar;
- distribución entre servidores.
Para producción utiliza una cola como:
- BullMQ;
- RabbitMQ;
- SQS;
- una cola basada en PostgreSQL;
- un sistema de workers propio.
El servidor web debería recibir el archivo y crear el trabajo. El worker debería procesarlo.
Evitar transcripciones duplicadas
Un usuario puede pulsar dos veces o repetir una petición.
Para archivos grandes, conviene calcular un hash:
import crypto from "node:crypto";
import fs from "node:fs";
async function calculateSha256(filePath) {
return new Promise(
(resolve, reject) => {
const hash =
crypto.createHash("sha256");
const stream =
fs.createReadStream(filePath);
stream.on("data", (chunk) => {
hash.update(chunk);
});
stream.on("end", () => {
resolve(hash.digest("hex"));
});
stream.on("error", reject);
},
);
}
Puedes combinar:
hash del archivo
+
idioma
+
modelo
+
opciones de transcripción
Si ya existe un trabajo idéntico completado, puedes reutilizarlo según tus políticas de privacidad.
No reutilices resultados entre usuarios sin asegurarte de que los permisos lo permiten.
Comprobar el archivo con FFprobe
La extensión y el MIME declarado por el navegador pueden falsificarse.
Una validación más sólida utiliza FFprobe:
ffprobe \
-v error \
-show_entries format=duration,format_name \
-show_entries stream=codec_type,codec_name \
-of json \
archivo.mp3
Desde Node.js:
import { spawn } from "node:child_process";
function inspectMedia(filePath) {
return new Promise(
(resolve, reject) => {
const process = spawn(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration,format_name",
"-show_entries",
"stream=codec_type,codec_name",
"-of",
"json",
filePath,
],
);
let stdout = "";
let stderr = "";
process.stdout.on("data", (chunk) => {
stdout += chunk;
});
process.stderr.on("data", (chunk) => {
stderr += chunk;
});
process.on("close", (code) => {
if (code !== 0) {
reject(
new Error(
stderr
|| "FFprobe no pudo leer el archivo.",
),
);
return;
}
try {
const data = JSON.parse(stdout);
const hasAudio =
data.streams?.some(
(stream) =>
stream.codec_type === "audio",
);
if (!hasAudio) {
reject(
new Error(
"El archivo no contiene audio.",
),
);
return;
}
resolve(data);
} catch {
reject(
new Error(
"Respuesta inválida de FFprobe.",
),
);
}
});
},
);
}
Con esto puedes comprobar:
- que existe una pista de audio;
- la duración real;
- el códec;
- el formato contenedor.
Limitar la duración, no solo el tamaño
Dos archivos de 20 MB pueden tener duraciones muy diferentes.
Un audio altamente comprimido puede durar horas.
Después de FFprobe:
const duration =
Number.parseFloat(
inspection.format.duration,
);
const MAX_DURATION_SECONDS =
2 * 60 * 60;
if (
!Number.isFinite(duration)
|| duration <= 0
) {
throw new Error(
"No se pudo determinar la duración.",
);
}
if (duration > MAX_DURATION_SECONDS) {
throw new Error(
"La duración máxima es de dos horas.",
);
}
El límite debe relacionarse con:
- coste;
- tiempo;
- almacenamiento;
- plan del usuario;
- capacidad del worker.
No devolver errores internos al navegador
Evita respuestas como:
{
"error": "ENOENT /home/app/uploads/...",
"stack": "..."
}
Pueden revelar:
- rutas del servidor;
- bibliotecas;
- arquitectura interna;
- nombres de contenedores;
- detalles del proveedor.
Registra el error completo en el servidor y devuelve un mensaje limitado al usuario.
También puedes guardar un código interno:
{
"error": "No se pudo completar la transcripción.",
"code": "TRANSCRIPTION_PROVIDER_ERROR"
}
Añadir límites por usuario o IP
Sin límites, una persona puede enviar cientos de archivos y consumir tu saldo.
Como mínimo, limita:
- peticiones por minuto;
- trabajos simultáneos;
- duración diaria;
- tamaño por archivo;
- número de trabajos pendientes.
La limitación por IP puede servir para una demostración pública, pero no es suficiente para un producto de pago.
Usuarios detrás de una misma red pueden compartir IP y un atacante puede utilizar múltiples direcciones.
Para un producto real, registra el consumo por cuenta.
No guardar archivos privados indefinidamente
Define una política clara:
Archivo original:
eliminar después de procesar
Resultado:
conservar según la cuenta
Trabajo fallido:
eliminar el temporal igualmente
Logs:
no incluir contenido transcrito
El bloque finally del ejemplo garantiza que el temporal se intenta eliminar tanto si la transcripción funciona como si falla.
Aun así, también necesitas una tarea periódica que elimine archivos abandonados después de:
- reinicios;
- cierres forzados;
- errores inesperados;
- procesos terminados por el sistema.
Cuándo merece la pena construirlo
Construir tu propio flujo tiene sentido cuando:
- la transcripción forma parte de tu producto;
- necesitas integrar usuarios y pagos;
- quieres controlar formatos y resultados;
- necesitas guardar un historial;
- procesas archivos regularmente;
- debes conectar el texto con otros procesos.
No tiene sentido mantener toda esta infraestructura para convertir un archivo de vez en cuando.
En ese caso tendrías que mantener:
- Node.js;
- almacenamiento;
- validaciones;
- colas;
- workers;
- API externa;
- reintentos;
- límites;
- eliminación de archivos;
- control de costes;
- seguridad de las claves.
Para una entrevista, una reunión o una clase ocasional, es más directo utilizar una herramienta para transcribir audio a texto sin desplegar una aplicación propia.
La decisión práctica es:
Uso ocasional:
herramienta web
Función integrada en un producto:
backend propio
Volumen elevado:
backend, cola, almacenamiento y workers
Qué cambiar antes de producción
La demostración es funcional, pero antes de publicarla deberías sustituir:
Map en memoria
por una base de datos compartida.
Sustituir:
setImmediate
por una cola persistente.
Sustituir:
disco local
por almacenamiento de objetos cuando utilices varios servidores.
También deberías añadir:
- autenticación;
- límites por usuario;
- verificación real del archivo;
- límites de duración;
- reintentos controlados;
- estados persistentes;
- monitorización;
- métricas de coste;
- borrado de trabajos antiguos;
- política de privacidad;
- consentimiento para archivos con terceros.
Conclusión
Proteger una API key no consiste en esconderla mejor dentro de JavaScript.
Consiste en no enviarla nunca al navegador.
La arquitectura correcta es:
Navegador
→ backend propio
→ proveedor externo
El frontend debe encargarse de:
- seleccionar el archivo;
- validar lo básico;
- subirlo;
- consultar el progreso;
- mostrar el resultado.
El backend debe encargarse de:
- validar de nuevo;
- proteger la clave;
- controlar el tamaño;
- crear el trabajo;
- llamar al proveedor;
- manejar errores;
- eliminar temporales;
- limitar el consumo.
El ejemplo utiliza una cola simulada y almacenamiento en memoria para que el flujo sea fácil de entender. Eso es suficiente para una demostración, pero no debe confundirse con una arquitectura de producción.
La diferencia entre una prueba y un producto no está en conseguir la primera transcripción. Está en controlar qué ocurre cuando el archivo es inválido, la API falla, el servidor se reinicia, el usuario repite la petición o cien personas suben audio al mismo tiempo.
Top comments (0)