Commit 0acc80ba authored by Kourser's avatar Kourser
Browse files

Il n'y avait pas un seul élément audio dans le panneau d'appel

La caméra marchait, l'écran marchait, le bouton de micro s'allumait, le compte
des présents était juste. Tout disait que l'appel se tenait, et personne
n'entendait personne.

Le panneau ne rattachait que les pistes de caméra et d'écran. Les voix
arrivaient bien — elles n'étaient branchées nulle part. La bibliothèque ne le
fait pas d'elle-même : sa fonction « startAudio » ne parcourt que les éléments
déjà rattachés, et la liste était vide.

Chaque voix distante a maintenant son élément qui la joue, détaché quand la
personne s'en va. Le son d'un écran partagé aussi — un onglet dont on montre
une vidéo, un tableau de bord qui sonne — il se serait perdu de la même façon.
Jamais la sienne, qui ferait un larsen. Et la liste n'est remplacée que si elle
a changé : « rafraichir » se déclenche sur huit événements, et rattacher à
chaque fois couperait le son un instant à chaque entrée.

Deux ajouts qui relèvent de la même panne. Un refus d'autoplay produit
exactement le même silence : il se dit, avec le geste qui le lève. Et le
panneau de réglages propose la sortie audio, là où le navigateur le permet —
« personne ne m'entend » et « je n'entends personne » se ressemblent beaucoup
vus du poste, et le second vient souvent d'une sortie restée sur un casque
débranché.

Le contrôle sur « muted » n'est pas décoratif : un élément muet rattaché à une
piste vivante a exactement l'apparence d'un appel qui fonctionne. C'est la même
panne, en plus difficile à voir.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent ae0f9124
Loading
Loading
Loading
Loading
+34 −1
Changes for apps/api/src/verification/interface.ts: 34 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -793,7 +793,7 @@ async function verifierGardeEcriture(r: Rapport): Promise<void> {
 * lui donner la place sans agrandir le panneau entier.
 */
async function verifierMiseEnAvant(r: Rapport): Promise<void> {
  r.titre("Ce qui passe devant dans un appel");
  r.titre("Ce qu'on voit et ce qu'on entend dans un appel");

  const chemin: string = new URL("../../../web/src/coque/miseEnAvant.js",
    import.meta.url).href;
@@ -895,4 +895,37 @@ async function verifierMiseEnAvant(r: Rapport): Promise<void> {
  // courante en réunion.
  r.verifie("EF-803", "et le geste reste atteignable là où il n'y a pas de survol",
    /@media \(hover: none\) \{\s*\.visio \.tuile \.avancer \{ opacity: 1; \}/.test(styles));

  // ── Et ce qu'on entend (EF-803) ──────────────────────────────────────────
  //
  // Il n'y avait aucun élément audio dans ce panneau. Pas un. La bibliothèque
  // ne rattache pas les voix d'elle-même — sa fonction « startAudio » ne
  // parcourt que les éléments déjà rattachés, et la liste restait vide. La
  // caméra marchait, l'écran marchait, le bouton de micro s'allumait, le
  // compte des présents était juste : tout disait que l'appel se tenait, et
  // personne n'entendait personne. Une panne qu'aucun message n'annonce et
  // qu'on ne découvre qu'en réunion.
  const elements = sien.match(/<audio[\s\S]*?\/>/g) ?? [];
  r.verifie("EF-803", "les voix distantes sont rattachées à un élément qui les joue",
    elements.length > 0 && /Track\.Source\.Microphone/.test(sien)
      && /\.piste\.attach\(el\)/.test(sien),
    `${elements.length} élément(s) audio`);
  // Un élément muet rattaché à une piste vivante a exactement l'apparence d'un
  // appel qui fonctionne : c'est la même panne, en plus difficile à voir.
  r.verifie("EF-803", "et aucun de ces éléments n'est muet",
    elements.length > 0 && elements.every((e) => !/\bmuted\b/.test(e)),
    elements.join(" | ").replace(/\s+/g, " "));
  // Un onglet dont on montre une vidéo, un tableau de bord qui sonne : le son
  // d'un partage se perd sans cela, et le partage paraît muet sans raison.
  r.verifie("EF-803", "le son d'un écran partagé s'entend comme une voix",
    /Track\.Source\.ScreenShareAudio/.test(sien));
  // Sa propre voix renvoyée dans ses haut-parleurs fait un larsen : la boucle
  // qui collecte les voix ne doit parcourir que les autres.
  r.verifie("EF-803", "mais jamais la sienne, qui ferait un larsen",
    !/moi\.getTrackPublication\(Track\.Source\.Microphone/.test(sien));
  // Un refus d'autoplay produit exactement le même silence que la panne qu'on
  // vient de corriger. Le distinguer, c'est éviter de chercher une heure du
  // côté du réseau.
  r.verifie("EF-803", "un son que le navigateur refuse se dit, et se lève d'un clic",
    /RoomEvent\.AudioPlaybackStatusChanged/.test(sien) && /salle\.startAudio\(\)/.test(sien));
}
+117 −3
Changes for apps/web/src/coque/PanneauVisio.tsx: 117 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -42,6 +42,12 @@ interface Tuile {
  soi: boolean;
}

/** Une piste qu'on entend sans la voir : un micro, ou le son d'un écran partagé. */
interface Voix {
  cle: string;
  piste: Track;
}

type Taille = "reduit" | "flottant" | "agrandi";

function PanneauVisio({
@@ -54,6 +60,24 @@ function PanneauVisio({
  const [etat, definirEtat] = useState<"connexion" | "connecte" | "echec">("connexion");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [tuiles, definirTuiles] = useState<Tuile[]>([]);
  /**
   * Les voix des autres (EF-803).
   *
   * Il n'y en avait aucune, et c'est tout le défaut : rien dans ce panneau ne
   * rattachait une piste de micro à quoi que ce soit. La bibliothèque ne le
   * fait pas d'elle-même — sa fonction « startAudio » ne parcourt que les
   * éléments déjà rattachés, et la liste était vide. La caméra marchait,
   * l'écran marchait, le bouton de micro s'allumait, le compte des présents
   * était juste : tout disait que l'appel se tenait, et personne n'entendait
   * personne.
   *
   * Elles vivent hors des tuiles parce qu'elles ne s'affichent pas : une voix
   * n'a pas de vignette, et l'attacher à l'élément d'une vidéo la ferait taire
   * dès que la caméra s'éteint.
   */
  const [voix, definirVoix] = useState<Voix[]>([]);
  /** Vrai quand le navigateur refuse de jouer le son sans un geste de plus. */
  const [sonBloque, definirSonBloque] = useState(false);
  /**
   * La tuile mise en avant, s'il y en a une (EF-803).
   *
@@ -100,8 +124,10 @@ function PanneauVisio({
  const [ecran, definirEcran] = useState(false);
  const [reglages, definirReglages] = useState(false);
  const [appareils, definirAppareils] = useState<MediaDeviceInfo[]>([]);
  const [choisis, definirChoisis] = useState<{ audioinput?: string; videoinput?: string }>({});
  const [choisis, definirChoisis] =
    useState<{ audioinput?: string; videoinput?: string; audiooutput?: string }>({});
  const conteneurs = useRef(new Map<string, HTMLDivElement | null>());
  const sorties = useRef(new Map<string, HTMLAudioElement | null>());

  useEffect(() => {
    let vivant = true;
@@ -118,6 +144,7 @@ function PanneauVisio({
     * met en avant, quelle qu'en soit la provenance.
     */
    const rafraichir = () => {
      const entendues: Voix[] = [];
      const moi = salle.localParticipant;
      // Soi d'abord, toujours à la même place : on cherche son propre cadrage
      // au même endroit à chaque coup d'œil.
@@ -148,8 +175,25 @@ function PanneauVisio({
            piste: ecran, ecran: true, soi: false,
          });
        }

        // La voix, et le son d'un écran partagé — un onglet dont on montre une
        // vidéo, un tableau de bord qui sonne. Jamais la sienne : sa propre
        // voix renvoyée dans ses haut-parleurs fait un larsen, et c'est
        // pourquoi cette boucle-ci ne parcourt que les autres.
        for (const source of [Track.Source.Microphone, Track.Source.ScreenShareAudio]) {
          const piste = p.getTrackPublication(source)?.track;
          if (piste) entendues.push({ cle: `${p.identity}:${source}`, piste });
        }
      }
      definirTuiles(liste);
      // Ne remplacer la liste que si elle a changé : « rafraichir » se
      // déclenche sur huit événements, et rattacher une piste à chaque fois
      // couperait le son un instant à chaque entrée, chaque sortie, chaque
      // caméra allumée.
      definirVoix((avant) =>
        avant.length === entendues.length
          && avant.every((v, i) => v.cle === entendues[i]!.cle && v.piste === entendues[i]!.piste)
          ? avant : entendues);
    };

    salle.on(RoomEvent.ParticipantConnected, rafraichir)
@@ -162,6 +206,15 @@ function PanneauVisio({
      // rien et le retour n'apparaîtrait jamais, même la tuile en place.
      .on(RoomEvent.LocalTrackPublished, rafraichir)
      .on(RoomEvent.LocalTrackUnpublished, rafraichir)
      // Le navigateur peut refuser de jouer un son tant qu'il n'a pas vu de
      // geste de l'utilisateur. Rejoindre l'appel en est un, mais pas toujours
      // aux yeux de Safari ni d'un onglet restauré au démarrage. Un refus
      // silencieux serait indiscernable de la panne qu'on vient de corriger :
      // on le dit, et on offre le geste qui le lève.
      .on(RoomEvent.AudioPlaybackStatusChanged, () => {
        if (!vivant) return;
        definirSonBloque(!salle.canPlaybackAudio);
      })
      // Perdre un appel qu'on avait rejoint n'est pas la même chose que ne
      // jamais l'avoir joint. Le premier cas met en cause le chemin média —
      // UDP filtré, réseau qui bascule ; le second, l'accès au serveur. Les
@@ -276,6 +329,22 @@ function PanneauVisio({
    }
  }, [tuiles]);

  /**
   * Chaque voix dans son élément, et détachée quand elle s'en va.
   *
   * Sans le détachement, la piste garderait la référence d'un élément que React
   * a démonté : une voix partie continuerait de compter parmi celles que la
   * bibliothèque essaie de relancer.
   */
  useEffect(() => {
    const posees: [Track, HTMLAudioElement][] = [];
    for (const v of voix) {
      const el = sorties.current.get(v.cle);
      if (el) { v.piste.attach(el); posees.push([v.piste, el]); }
    }
    return () => { for (const [piste, el] of posees) piste.detach(el); };
  }, [voix]);

  // La liste des tuiles change à chaque entrée, chaque sortie, chaque partage :
  // on redemande alors ce qui doit être devant, choix manuel compris.
  useEffect(() => {
@@ -323,16 +392,56 @@ function PanneauVisio({
    }
  }

  async function basculer(genre: "audioinput" | "videoinput", deviceId: string) {
  async function basculer(genre: MediaDeviceKind, deviceId: string) {
    // Le choix de la sortie n'aboutit que si le navigateur sait poser un
    // « sinkId » ; Firefox ne le fait pas sans réglage. On rapporte le refus
    // plutôt que d'afficher un choix qui ne prend pas.
    try {
      await salle.switchActiveDevice(genre, deviceId);
      definirChoisis((c) => ({ ...c, [genre]: deviceId }));
    } catch {
      definirGeneMicro(t("Ce navigateur ne laisse pas choisir la sortie audio. "
        + "Changez le périphérique par défaut du système."));
    }
  }

  /**
   * Où l'on entend les autres.
   *
   * Le panneau ne proposait que le micro et la caméra. Or « personne ne
   * m'entend » et « je n'entends personne » se ressemblent beaucoup vus du
   * poste, et le second vient souvent d'une sortie restée sur un casque
   * débranché. Le choix n'apparaît que là où il aboutit.
   */
  const sortieChoisissable = typeof HTMLMediaElement !== "undefined"
    && "setSinkId" in HTMLMediaElement.prototype;

  const parGenre = (genre: MediaDeviceKind) =>
    appareils.filter((a) => a.kind === genre && a.deviceId);

  return (
    <div className={`visio ${taille}`}>
      {/* Les voix des autres.
          Hors de tout affichage : une voix n'a pas de vignette. Jamais
          « muted » — un élément muet rattaché à une piste vivante est
          exactement l'apparence d'un appel qui fonctionne, et la panne la plus
          longue à trouver. */}
      {voix.map((v) => (
        <audio key={v.cle} autoPlay
          ref={(el) => { sorties.current.set(v.cle, el); }} />
      ))}

      {/* Un refus du navigateur se dit et se lève d'un clic : sans cela, le
          silence est indiscernable d'une panne de réseau. */}
      {sonBloque && etat === "connecte" && (
        <div className="gene">
          {t("Le navigateur n’a pas encore autorisé le son de cet appel.")}{" "}
          <button className="petit" onClick={() => {
            void salle.startAudio().then(() => definirSonBloque(false)).catch(() => undefined);
          }}>{t("Activer le son")}</button>
        </div>
      )}

      {geneMicro && etat === "connecte" && (
        <div className="gene">{geneMicro}</div>
      )}
@@ -530,7 +639,12 @@ function PanneauVisio({

          {reglages && (
            <div className="appareils">
              {([["audioinput", "Micro"], ["videoinput", t("Caméra")]] as const).map(
              {([
                ["audioinput", "Micro"],
                ["videoinput", t("Caméra")],
                ...(sortieChoisissable
                  ? [["audiooutput", t("Sortie audio")] as const] : []),
              ] as const).map(
                ([genre, libelle]) => {
                  const liste = parGenre(genre);
                  return (
+6 −0
Changes for apps/web/src/langues/en.ts: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -599,6 +599,12 @@ export const EN: Record<string, string> = {
  "Occuper l’écran": "Full screen",
  "Revenir au panneau flottant": "Back to the floating panel",
  "Choisir le micro et la caméra": "Choose microphone and camera",
  "Activer le son": "Turn the sound on",
  "Ce navigateur ne laisse pas choisir la sortie audio. Changez le périphérique par défaut du système.":
    "This browser does not allow choosing the audio output. Change the system's default device.",
  "Le navigateur n’a pas encore autorisé le son de cet appel.":
    "The browser has not yet allowed the sound of this call.",
  "Sortie audio": "Audio output",
  "Choisir…": "Choose…",
  "Le changement prend effet tout de suite, sans quitter l’appel.":
    "The change takes effect immediately, without leaving the call.",