Écrire un formulaire de contact est un exercice connu. Y ajouter un message vocal—le visiteur parle, on écoute—a l’air d’être une petite fonctionnalité de plus. C’en est une grosse, et pas pour les raisons qu’on imagine : l’enregistrement lui-même tient en vingt lignes, mais tout ce qui l’entoure—relire, mesurer, envoyer, stocker, écouter plus tard—réserve une série de surprises bien documentées… quand on sait où chercher.
Ce billet suit le trajet complet d’une voix : du micro du visiteur jusqu’à l’oreille de celui qui traitera la demande. Il reste au niveau des principes ; rien ici n’est propre à un outil particulier.
1. Enregistrer : le navigateur sait tout faire
Deux API suffisent, présentes partout depuis des années : getUserMedia ouvre le micro, MediaRecorder en fait un fichier. Pas de greffon, pas de bibliothèque, pas de serveur dans la boucle.
const flux = await navigator.mediaDevices.getUserMedia({ audio: true });
const enregistreur = new MediaRecorder(flux, { mimeType: "audio/webm" });
const morceaux = [];
enregistreur.ondataavailable = e => { if (e.data.size) morceaux.push(e.data); };
enregistreur.onstop = () => {
const fichier = new Blob(morceaux, { type: "audio/webm" });
// ... c'est tout : voila le message, pret a etre relu ou envoye
};
enregistreur.start();
getUserMedia au chargement de la page déclenche la demande d’autorisation avant que le visiteur ait compris pourquoi—et la réponse la plus fréquente à une question qu’on ne comprend pas est « non ». On attend la case à cocher, puis le clic sur Enregistrer. Et quand le visiteur refuse, on le dit calmement : le formulaire reste utilisable sans voix.
Une machine à états, pas une collection de boutons
La première version du nôtre affichait tous les boutons tout le temps, chacun vérifiant de son côté s’il avait le droit d’agir. Résultat prévisible : un bouton Pause qui n’arrêtait pas toujours l’enregistrement, selon l’ordre des clics. La refonte tient en une phrase : l’interface n’a que quatre états—repos, enregistrement, pause, prêt—et chaque état décrit entièrement ce qui est visible et actif. On ne modifie plus un bouton : on change d’état, et l’affichage se déduit du nouvel état.
// Une seule table : qui est visible, et dans quel etat.
const PANNEAU = {
repos: ["enregistrer"],
enregistrement: ["pause"],
pause: ["poursuivre", "valider", "refaire", "supprimer"],
pret: ["refaire", "supprimer"],
};
function aller(nouvelEtat) {
etat = nouvelEtat;
for (const [nom, bouton] of Object.entries(boutons))
bouton.hidden = !PANNEAU[etat].includes(nom);
lecteur.hidden = (etat === "repos" || etat === "enregistrement");
}
Le gain n’est pas dans les lignes économisées : il est dans ce qu’on ne peut plus écrire. Aucun bouton ne décide de son propre sort, donc aucune suite de clics ne peut produire un panneau incohérent. Un état oublié se voit dans la table ; un bouton oublié dans un if, non.
2. La durée manquante : « 0:17 » d’un côté, « 0:00 » de l’autre
Symptôme déroutant : le compteur annonce fièrement un message de 17 secondes, et le lecteur de relecture affiche 0:00 / 0:00. Le son se joue pourtant très bien. Le curseur, lui, ne sert à rien.
Ce n’est pas un bug : c’est la conséquence directe du streaming. Un fichier WebM déclare sa durée dans son en-tête—c’est-à-dire au début. Or MediaRecorder écrit l’en-tête en premier, à un moment où personne ne sait encore combien de temps le visiteur va parler. Il y inscrit donc « inconnu », et ne revient jamais le corriger. Le lecteur le croit sur parole.
La correction consiste à réécrire ce champ dans le fichier obtenu, une fois la durée réellement connue : on repère l’élément Duration dans l’en-tête et on y pose la valeur mesurée. Le fichier produit reste un WebM parfaitement standard—il est simplement complet.
Concrètement, il faut descendre dans le conteneur. Un WebM est un arbre d’éléments Matroska, chacun annonçant son identifiant puis sa taille ; la durée vit dans Segment > Info > Duration, sous forme de flottant exprimé en unités de TimecodeScale (une milliseconde, par défaut).
// On a descendu l'arbre jusqu'a Info, et releve :
// positionDuree : ou se trouve l'element Duration, ou -1 s'il manque
// echelle : la valeur de TimecodeScale
const duree = dureeMesuree_ms * 1000000 / echelle;
if (positionDuree >= 0) {
// Cas facile : l'element existe, on ecrase le flottant sur place.
const sortie = octets.slice();
new DataView(sortie.buffer).setFloat64(positionDuree, duree, false);
return sortie;
}
// Sinon il faut l'INSERER, et reecrire la taille d'Info ... voir plus bas.
Le second cas est le délicat : insérer onze octets déplace tout ce qui suit, et surtout agrandit l’élément Info qui les contient—sa taille déclarée devient fausse. On pourrait croire qu’il faut alors réécrire aussi la taille du Segment parent, et de proche en proche tout le fichier. L’astuce tient à une propriété du format : EBML autorise les encodages non minimaux. Une taille qui tenait sur un octet peut être réécrite sur le même octet avec une valeur plus grande, tant qu’elle y rentre. On réécrit donc la taille d’Info sur le même nombre d’octets, et rien d’autre ne bouge—ni les positions, ni le Segment, dont les navigateurs déclarent la taille « inconnue ».
const sonde = new Audio(URL.createObjectURL(blobCorrige));
sonde.onloadedmetadata = () => {
const d = sonde.duration;
conclure(isFinite(d) && d > 0); // sinon : on garde le blob d'origine
};
setTimeout(() => conclure(false), 1500);
0:00, ou bien l’infini, ou bien une durée correcte… après avoir joué le fichier une première fois en entier. Si vous voyez l’un de ces trois comportements, cherchez l’en-tête, pas le lecteur.
3. La relecture, et la CSP qui l’interdit
Relire avant d’envoyer n’est pas un luxe : c’est ce qui transforme « laisser un message » en « laisser un bon message ». Techniquement, c’est trivial : on fabrique une adresse locale pour le fichier et on la donne au lecteur.
lecteur.src = URL.createObjectURL(fichier); // -> "blob:https://exemple.tld/4f8c..."
Sur notre page, le lecteur affichait les bonnes commandes… et ne démarrait pas. J’ai corrigé trois fois le code du lecteur—trois fois sur le mauvais objet. La réponse était dans la console du navigateur depuis le début :
Refused to load media from 'blob:https://exemple.tld/4f8c...'
because it violates the following Content Security Policy
directive: "media-src 'self'".
La Content Security Policy est une liste blanche : elle énumère les origines autorisées, par type de ressource. Le pseudo-protocole blob:—un fichier fabriqué dans la page, qui ne vient d’aucun serveur—n’est pas couvert par 'self' : il doit être cité explicitement, et uniquement là où il sert.
Mais l’ajouter n’est pas si direct, et c’est le piège suivant.
On ne complète pas une politique : on la remplace
Quand un serveur permet de redéfinir des en-têtes pour un sous-dossier, les politiques de sécurité se combinent de la manière la plus restrictive. Ajouter une seconde politique qui autorise blob: ne l’autorise pas : les deux s’appliquent, et leur intersection reste un refus. Il faut retirer la politique héritée, puis en poser une neuve—complète, toutes directives recopiées, pas seulement celle qu’on voulait changer :
<location path="common/contact">
<system.webServer><httpProtocol><customHeaders>
<!-- blob: sur media-src, c'est le lecteur de relecture -->
<remove name="Content-Security-Policy" />
<add name="Content-Security-Policy" value="
default-src 'self';
img-src 'self' data: https: http:;
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline';
object-src 'none';
media-src 'self' blob:;
frame-ancestors 'self'; base-uri 'self'; form-action 'self';" />
<remove name="Permissions-Policy" />
<add name="Permissions-Policy"
value="camera=(), microphone=(self), geolocation=()" />
</customHeaders></httpProtocol></system.webServer>
</location>
Les deux remove sont l’essentiel du bloc. Sans eux, on obtient une page qui semble configurée et se comporte comme avant—le genre de panne qui coûte une demi-journée parce qu’on cherche l’erreur dans ce qu’on a écrit, et non dans ce qu’on n’a pas effacé.
Deuxième verrou, sur un tout autre plan : la Permissions-Policy, qui dit quelles fonctions matérielles la page a le droit de demander. Une politique prudente, posée à la racine d’un site, interdit souvent tout :
Permissions-Policy: camera=(), microphone=(), geolocation=()
Avec cet en-tête, getUserMedia échoue sans même afficher de demande d’autorisation : le visiteur n’a rien refusé, le site a refusé pour lui. Il faut rouvrir le micro—microphone=(self)—sur la seule page concernée, pas sur le site entier.
Permissions-Policy sur ce point, là où un autre l’applique strictement. Une page qui « marche chez moi » peut être muette ailleurs—et c’est le navigateur le plus permissif qui donne la fausse bonne nouvelle. Tester sur au moins deux moteurs n’est pas du zèle : la CSP, elle, est appliquée partout.
blob: autorisé sur media-src : curseur utilisable, durée juste, lecture immédiate.4. L’envoi : une pièce jointe comme les autres
Tentation : inventer un canal dédié pour la voix. Mauvaise idée. Un message vocal est un fichier ; il voyage par le même chemin que les autres pièces jointes du formulaire, avec les mêmes contrôles de taille et de type, et occupe une ligne ordinaire dans la même table. Ce qui le distingue tient en deux informations : son type MIME commence par audio/, et la demande porte un drapeau « contient un vocal ». Un canal de moins à écrire, à tester et à sécuriser.
Content-Type: audio/webm—avec une espace après les deux-points. Stockée telle quelle, la valeur commence par cette espace, et le filtre mime LIKE 'audio/%' ne trouve plus rien. Sans erreur, sans trace : une colonne vide. Chez nous le défaut existait depuis l’origine, sur tous les types de pièces—le vocal l’a seulement rendu visible, parce qu’il est le seul qu’on cherche à reconnaître par son type. Élaguez à l’écriture, et normalisez l’existant.
Le correctif tient en un mot—c’est bien le problème :
' Avant : la valeur arrive telle que l'en-tete multipart l'ecrit,
' c'est-a-dire " audio/webm;codecs=opus" - avec l'espace.
mime = pieces.Item(i).ContentType & ""
' Apres :
mime = Trim(pieces.Item(i).ContentType & "")
Et pour les lignes déjà en base, une normalisation—dans une transaction conditionnelle, qui ne valide que si le compte corrigé est celui qu’on attendait et qu’il ne reste rien :
UPDATE contact_pj SET mime = LTRIM(RTRIM(mime))
WHERE mime <> LTRIM(RTRIM(mime));
-- Doit rendre 0 ensuite, sinon on annule.
SELECT COUNT(*) FROM contact_pj WHERE mime <> LTRIM(RTRIM(mime));
Côté courriel de notification, rien de particulier : le fichier est joint au message qui annonce la demande. C’est le chemin le plus court vers l’oreille humaine, et il fonctionnait chez nous bien avant le reste—ce qui a d’ailleurs rendu le problème suivant très visible : le message était dans le mail, mais pas dans l’outil de suivi.
5. Consulter plus tard : le coffre qui ne doit rien servir
Un formulaire de contact reçoit des documents de clients : devis, plans, photos, et maintenant des voix. Ces fichiers n’ont aucune raison d’être téléchargeables depuis le site public. On ferme donc le dossier de stockage—non pas en masquant le listage, mais en retirant au serveur la capacité même de servir ce qui s’y trouve.
404—rassurant—et chaque fichier répondait 200. Une protection de ce genre ne se vérifie pas en demandant le dossier : elle se vérifie en demandant un fichier, depuis l’extérieur.
Reste à permettre l’écoute à celui qui traite la demande. Deux voies :
- Ouvrir le dossier aux seules adresses du réseau local. Simple—et toute la séparation entre ces documents et Internet tient alors à une ligne de configuration, que personne ne revérifie après chaque modification.
- Faire servir le fichier par l’outil de suivi lui-même, qui le lit sur le disque et le restitue sous son propre contrôle de session. Le site public ne sert jamais ces octets : un fichier qu’un site ne sert pas ne peut pas fuir par ce site. C’est la voie retenue.
Une page de diffusion de ce genre tient en une page, et sa règle tient en une phrase : le chemin ne vient jamais de l’URL. L’appelant transmet une référence et un numéro de pièce ; le dossier et le nom du fichier sont relus en base. Aucun nom de fichier ne circule, donc aucune traversée de répertoire n’est possible—même mal écrite. Trois ceintures s’y ajoutent par principe : la référence est bornée en longueur et en alphabet ; les valeurs lues en base sont refusées si elles contiennent un séparateur ou .. ; le chemin final doit commencer par la racine du coffre.
Content-Type: audio/webm;codecs=opus <-- relu en base, pas devine
X-Content-Type-Options: nosniff <-- un .webm reste un .webm
Content-Disposition: inline; filename="message_vocal.webm"
Le cœur de la page tient en une vingtaine de lignes. L’ordre compte : on refuse avant de lire, et on ne lit le disque qu’une fois le chemin reconstruit depuis la base.
' 1. L'appelant ne donne qu'une reference et un rang - jamais un chemin.
reference = Trim(Request("idc") & "") : rang = Trim(Request("rang") & "")
If Len(reference) = 0 Or Len(reference) > 10 Then Refuser 400
If Not IsNumeric(rang) Then Refuser 400
' 2. Le dossier et le nom du fichier sont RELUS EN BASE.
Set lignes = Lire("SELECT dossier, nom_stocke, mime, nom_origine " & _
"FROM contact_pj WHERE id_contact = ? AND rang = ? " & _
"AND statut = 'O' AND supprime = 0", reference, rang)
If lignes.EOF Then Refuser 404
' 3. Ceintures : ces valeurs viennent de la base, on ne les croit pas
' sur parole - une insertion fautive ailleurs ne doit pas devenir
' une lecture arbitraire ici.
If InStr(dossier, "..") > 0 Or InStr(dossier, "\") > 0 Then Refuser 400
chemin = RACINE_DU_COFFRE & dossier & "\" & nomStocke
If LCase(Left(chemin, Len(RACINE_DU_COFFRE))) <> LCase(RACINE_DU_COFFRE) _
Then Refuser 400
' 4. Et seulement maintenant, les octets.
Set flux = Server.CreateObject("ADODB.Stream")
flux.Type = 1 : flux.Open : flux.LoadFromFile chemin
Response.ContentType = mime ' relu en base, pas devine
Response.AddHeader "X-Content-Type-Options", "nosniff"
guil = Chr(34) ' plus lisible que des guillemets doubles echappes
Response.AddHeader "Content-Disposition", _
"inline; filename=" & guil & nom & guil
Response.BinaryWrite flux.Read
Les trois ceintures du point 3 sont, en toute rigueur, inutiles : puisque le chemin ne vient pas de l’URL, il n’y a rien à neutraliser. Elles sont là parce qu’une protection qui repose sur un raisonnement « de toute façon, personne ne peut… » cesse d’être vraie le jour où quelqu’un ajoute un chemin dans la table depuis un autre bout du code. Le coût est de trois lignes.
inline plutôt que attachment : le lecteur audio du navigateur s’ouvre directement, sans téléchargement—et « enregistrer sous » reste disponible pour qui le souhaite.
400 ou 404 selon le cas, et le type annoncé est celui de la base. C’est en les faisant qu’on découvre, par exemple, qu’un en-tête de longueur oublié tronque silencieusement les gros fichiers.
6. Le piège d’architecture : le bug qui dormait
Une dernière anecdote, parce qu’elle vaut pour toutes les fonctions longtemps inactives. Le bloc d’affichage des pièces jointes commençait par un test : « cette table existe-t-elle ? ». Le test répondait non depuis un renommage, et le bloc sortait immédiatement—sans rien afficher, et sans se plaindre.
Le jour où nous avons corrigé ce test, le bloc s’est mis à fonctionner… et a vidé le reste de la page. Cause : il interrogeait la base sur la connexion partagée, alors que la page y tenait déjà un résultat ouvert. Un bloc voisin avait rencontré le même problème des mois plus tôt et portait la solution en commentaire—une connexion à lui. Elle n’avait jamais été appliquée ici, précisément parce que ce code ne s’exécutait jamais.
Bilan
L’enregistrement vocal dans un formulaire n’est pas un problème d’audio : c’est un problème de chaîne. Quatre leçons à emporter :
- L’interface est une machine à états, pas une collection de boutons qui se surveillent mutuellement.
- Quand la console nomme une directive de sécurité, lisez la directive—pas votre code. Trois corrections successives au mauvais endroit, c’est le prix d’un message qu’on avait sous les yeux.
- Le vocal est une pièce jointe ordinaire. Un canal unique vaut mieux qu’un canal spécial : un seul endroit à tester, un seul à sécuriser.
- Un dossier de documents clients ne doit rien servir au public, et cela se vérifie en demandant un fichier, pas un dossier.
Le premier extrait de ce billet—ouvrir le micro, récupérer un fichier—est le seul qui parle d’audio. Tous les autres parlent d’en-têtes, de politiques et de chemins. C’est là que le travail s’est trouvé, et c’est pourquoi un billet sur l’enregistrement vocal finit par si peu parler de son.