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 :
- la page enregistre le micro avec
MediaRecorder; - elle envoie le fichier à ton serveur Node.js avec
fetch; - le serveur l’enregistre dans un dossier
voix/et le convertit en MP3 avec ffmpeg ; - 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.
npm install ffmpeg-static{
"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 :
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
<!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>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.MediaRecorderdécoupe l’enregistrement en morceaux (dataavailable) qu’on range danschunks. À l’arrêt, on les assemble en un seul fichier, unBlob.recorder.mimeTypedit quel format le navigateur a choisi : on l’envoie au serveur dans l’en-têteContent-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 :
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 :
readBodylit 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é4sur une échelle de 0, la meilleure, à 9). Le fichier d’origine est supprimé ensuite, qu’il y ait eu une erreur ou non. VOICE_FILEn’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 :
npm run servernpm run devOuvre 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 :
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/voixqui lit le dossier avecreaddir. - 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.