DEV Community

transcribir audio
transcribir audio

Posted on

Cómo transcribir archivos de audio con JavaScript y Node.js sin exponer tu API key

Un formulario para convertir audio en texto parece fácil de construir:

  1. el usuario selecciona un archivo;
  2. JavaScript lo envía a una API;
  3. la API devuelve la transcripción;
  4. 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-...";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Instala las dependencias:

npm install express multer openai dotenv
Enter fullscreen mode Exit fullscreen mode

Instala una dependencia de desarrollo para reiniciar el servidor automáticamente:

npm install --save-dev nodemon
Enter fullscreen mode Exit fullscreen mode

Estructura del proyecto

Crea esta estructura:

audio-transcription-node/
├── public/
│   ├── index.html
│   ├── app.js
│   └── styles.css
├── uploads/
├── .env
├── .gitignore
├── package.json
└── server.js
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Crea .gitignore:

node_modules/
.env
uploads/*
!uploads/.gitkeep
Enter fullscreen mode Exit fullscreen mode

Añade un archivo vacío:

touch uploads/.gitkeep
Enter fullscreen mode Exit fullscreen mode

En Windows PowerShell:

New-Item uploads/.gitkeep -ItemType File
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

La respuesta inicial será similar a:

{
  "taskId": "1b78bf8f-27dc-49d1-8f2d-7df81d4eeef2",
  "status": "queued"
}
Enter fullscreen mode Exit fullscreen mode

Después, el navegador consultará el estado:

{
  "taskId": "1b78bf8f-27dc-49d1-8f2d-7df81d4eeef2",
  "status": "completed",
  "text": "Texto obtenido del archivo..."
}
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

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,
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    });
  },
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}`,
  );
});
Enter fullscreen mode Exit fullscreen mode

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}`,
  );
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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",
      );
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Ejecutar la aplicación

Inicia el servidor:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Abre:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

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,
  },
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

El almacenamiento en memoria tiene límites

El ejemplo utiliza:

const jobs = new Map();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

setImmediate tampoco es una cola real

Este código:

setImmediate(() => {
  processTranscription(taskId);
});
Enter fullscreen mode Exit fullscreen mode

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);
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Puedes combinar:

hash del archivo
+
idioma
+
modelo
+
opciones de transcripción
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.",
            ),
          );
        }
      });
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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.",
  );
}
Enter fullscreen mode Exit fullscreen mode

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": "..."
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Qué cambiar antes de producción

La demostración es funcional, pero antes de publicarla deberías sustituir:

Map en memoria
Enter fullscreen mode Exit fullscreen mode

por una base de datos compartida.

Sustituir:

setImmediate
Enter fullscreen mode Exit fullscreen mode

por una cola persistente.

Sustituir:

disco local
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)