Aller au contenu

Enregistrer une voix en MP3

Enregistrer le micro dans le navigateur, envoyer l’enregistrement à ton serveur Node.js, le convertir en MP3 avec ffmpeg et le rejouer depuis une adresse servie par ce serveur. Code seulement.

Le trajet du son

Le navigateur sait enregistrer le micro, mais jamais directement en MP3 : MediaRecorder produit du WebM (Opus) sur Chrome et Firefox, et du MP4 (AAC) sur Safari. On fait donc le trajet en quatre étapes :

  1. la page enregistre le micro avec MediaRecorder ;
  2. elle envoie le fichier à ton serveur Node.js avec fetch ;
  3. le serveur l’enregistre dans un dossier voix/ et le convertit en MP3 avec ffmpeg ;
  4. il répond avec l’adresse du MP3, que la page (ou ta scène 3D) peut rejouer.

Cette doc n’a pas de démo en direct : le serveur doit tourner sur ton ordinateur. Tout le code est ci-dessous.

Installer

Dans ton projet Vite (voir la doc « Première scène »), installe ffmpeg-static : il télécharge un ffmpeg prêt à l’emploi pour ton système, rien d’autre à installer.

Terminal
npm install ffmpeg-static
package.json
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js"
  }
}

Faire passer les requêtes par Vite

La page est servie par Vite (port 5173), le serveur des voix écoute sur le port 3000. Pour que la page puisse lui parler sans erreur de sécurité (CORS), on demande à Vite de relayer les adresses qui commencent par /api et /voix :

vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      "/api": "http://localhost:3000",
      "/voix": "http://localhost:3000",
    },
  },
});

Pour la page, tout se passe comme si le serveur des voix était à la même adresse qu’elle.

La page

index.html
<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>Enregistrer une voix</title>
  </head>
  <body>
    <button id="record">Enregistrer</button>
    <button id="stop" disabled>Arrêter</button>
    <audio controls></audio>
    <script type="module" src="/main.js"></script>
  </body>
</html>
main.js
const recordButton = document.querySelector("#record");
const stopButton = document.querySelector("#stop");
const player = document.querySelector("audio");

let recorder = null;
let chunks = [];

async function sendVoice(blob, type) {
  const response = await fetch("/api/voix", {
    method: "POST",
    headers: { "Content-Type": type },
    body: blob,
  });

  if (!response.ok) {
    throw new Error("Le serveur a refusé l’enregistrement");
  }

  const { url } = await response.json();
  return url;
}

recordButton.addEventListener("click", async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    recorder = new MediaRecorder(stream);
    chunks = [];

    recorder.addEventListener("dataavailable", (event) => {
      chunks.push(event.data);
    });

    recorder.addEventListener("stop", async () => {
      stream.getTracks().forEach((track) => track.stop());
      const blob = new Blob(chunks, { type: recorder.mimeType });

      try {
        const url = await sendVoice(blob, recorder.mimeType);
        console.log("MP3 prêt :", url);
        player.src = url;
      } catch (error) {
        console.error(error);
      }
    });

    recorder.start();
    recordButton.disabled = true;
    stopButton.disabled = false;
  } catch (error) {
    console.error("Micro refusé ou indisponible :", error);
  }
});

stopButton.addEventListener("click", () => {
  recorder.stop();
  recordButton.disabled = false;
  stopButton.disabled = true;
});
  • getUserMedia({ audio: true }) demande l’accès au micro : le navigateur affiche une autorisation la première fois.
  • MediaRecorder découpe l’enregistrement en morceaux (dataavailable) qu’on range dans chunks. À l’arrêt, on les assemble en un seul fichier, un Blob.
  • recorder.mimeType dit quel format le navigateur a choisi : on l’envoie au serveur dans l’en-tête Content-Type.
  • stream.getTracks().forEach((track) => track.stop()) coupe le micro (et son voyant) une fois l’enregistrement fini.

Le navigateur n’autorise le micro que sur une page sécurisée : localhost sur l’ordi marche, mais pas une adresse http://192.168… sur un téléphone. Pour tester sur téléphone, il faut passer Vite en HTTPS, par exemple avec le plugin @vitejs/plugin-basic-ssl.

Le serveur

À la racine du projet, à côté de index.html :

server.js
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { promisify } from "node:util";
import ffmpegPath from "ffmpeg-static";

const PORT = 3000;
const VOICES_FOLDER = "voix";
const MAX_BYTES = 10 * 1024 * 1024;
const VOICE_FILE = /^\/voix\/([a-f0-9-]+\.mp3)$/;

const run = promisify(execFile);

await mkdir(VOICES_FOLDER, { recursive: true });

function readBody(request) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let size = 0;

    request.on("data", (chunk) => {
      size += chunk.length;

      if (size > MAX_BYTES) {
        reject(new Error("Fichier trop lourd"));
        request.destroy();
        return;
      }

      chunks.push(chunk);
    });
    request.on("end", () => resolve(Buffer.concat(chunks)));
    request.on("error", reject);
  });
}

async function saveVoice(request) {
  const id = randomUUID();
  const type = request.headers["content-type"] ?? "";
  const input = join(VOICES_FOLDER, `${id}.${type.includes("mp4") ? "m4a" : "webm"}`);
  const output = join(VOICES_FOLDER, `${id}.mp3`);

  await writeFile(input, await readBody(request));

  try {
    await run(ffmpegPath, ["-y", "-i", input, "-codec:a", "libmp3lame", "-q:a", "4", output]);
  } finally {
    await unlink(input);
  }

  return `/voix/${id}.mp3`;
}

function sendVoiceFile(response, name) {
  const stream = createReadStream(join(VOICES_FOLDER, name));

  stream.on("open", () => {
    response.writeHead(200, { "Content-Type": "audio/mpeg" });
    stream.pipe(response);
  });
  stream.on("error", () => {
    response.writeHead(404);
    response.end();
  });
}

const server = createServer(async (request, response) => {
  if (request.method === "POST" && request.url === "/api/voix") {
    try {
      const url = await saveVoice(request);
      response.writeHead(201, { "Content-Type": "application/json" });
      response.end(JSON.stringify({ url }));
    } catch (error) {
      console.error(error);
      response.writeHead(500, { "Content-Type": "application/json" });
      response.end(JSON.stringify({ error: "Enregistrement impossible" }));
    }
    return;
  }

  const match = request.method === "GET" ? VOICE_FILE.exec(request.url) : null;

  if (match) {
    sendVoiceFile(response, match[1]);
    return;
  }

  response.writeHead(404);
  response.end();
});

server.listen(PORT, () => {
  console.log(`Serveur des voix sur http://localhost:${PORT}`);
});

Ce qu’il faut retenir :

  • readBody lit le fichier envoyé morceau par morceau et refuse tout ce qui dépasse 10 Mo.
  • randomUUID() donne un nom unique à chaque enregistrement : deux voix ne s’écrasent jamais.
  • ffmpeg lit le WebM ou le MP4 et écrit un MP3 (libmp3lame, qualité 4 sur une échelle de 0, la meilleure, à 9). Le fichier d’origine est supprimé ensuite, qu’il y ait eu une erreur ou non.
  • VOICE_FILE n’accepte que des noms du type /voix/xxxx.mp3 : impossible de demander un autre fichier de ton ordinateur avec cette adresse.
  • Les MP3 restent dans le dossier voix/ du projet : ils sont toujours là après un redémarrage du serveur.

Lancer

Deux terminaux ouverts en même temps, dans le dossier du projet :

Terminal
npm run server
Terminal
npm run dev

Ouvre http://localhost:5173, clique sur Enregistrer, parle, clique sur Arrêter : le lecteur se remplit avec l’adresse du MP3 et le fichier apparaît dans le dossier voix/.

Rejouer la voix dans la scène 3D

Three.js sait jouer un son, et même le placer dans l’espace. Une fois l’adresse du MP3 reçue :

main.js
const listener = new THREE.AudioListener();
camera.add(listener);

const voice = new THREE.Audio(listener);

function playVoice(url) {
  new THREE.AudioLoader().load(url, (buffer) => {
    voice.setBuffer(buffer);
    voice.play();
  });
}

Remplace THREE.Audio par THREE.PositionalAudio et ajoute la voix à un objet (cube.add(voice)) : le son vient alors de l’objet, plus fort quand la caméra s’en approche.

Les navigateurs bloquent le son tant que la personne n’a pas cliqué ou touché la page : lance toujours la lecture après un clic.

Pour aller plus loin

  • Liste toutes les voix enregistrées : ajoute au serveur une route GET /api/voix qui lit le dossier avec readdir.
  • Envoie l’adresse du MP3 aux autres écrans avec un WebSocket (doc « Relier plusieurs écrans ») pour que la voix se joue partout.
  • La page MediaRecorder sur MDN détaille les formats et les événements.

Mise à jour : ven. 30 oct. à 17:05