Commit b576ca88 authored by Kourser's avatar Kourser
Browse files

Aucune voix n'a jamais pu être enregistrée séparément

La transcription ne lit pas le composite : elle lit une piste par personne,
faute de quoi elle ne saurait pas qui a dit quoi. Ces pistes ne pouvaient pas
démarrer.

Mesuré contre LiveKit 1.13.5 : « startParticipantEgress » vers un fichier OGG
est refusé à la validation, avant même qu'on cherche la personne — « no
supported codec is compatible with all outputs ». Cet enregistreur capte
caméra ET micro, et l'OGG ne porte pas d'image. Le même appel en MP4 est
accepté, puis reste en démarrage et s'annule sur « Start signal not
received » : il attend une image d'un participant qui n'en publie pas.

Le défaut ne se voyait nulle part, et c'est ce qui le rendait durable. Ouvrir
une piste échoue « au mieux » : l'enregistrement continue et la piste est
inscrite en échec. La réunion s'enregistrait donc bien, chaque piste échouait,
et la transcription n'avait jamais rien à lire — sans qu'aucun écran ne dise
autre chose que « non transcrite ».

« startTrackEgress » enregistre une piste désignée, telle qu'elle arrive, sans
réencoder ni attendre d'image. Il faut lui donner l'identifiant de la piste :
on le demande au serveur, qui sait qui publie quoi. Mesuré de bout en bout —
une voix publiée dans une vraie salle, 323 006 octets pour 19,9 s, déposés puis
transcrits correctement.

Le double d'essai disait toujours oui aux pistes : c'est pour cela que rien
n'a jamais signalé le défaut. Il sait maintenant refuser, ce qui met enfin à
l'épreuve la promesse « au mieux » — la réunion tient, la piste porte son
motif.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 0acc80ba
Loading
Loading
Loading
Loading
+61 −6
Changes for apps/api/src/adaptateurs/media.ts: 61 added lines, 6 removed lines.
Original line number Diff line number Diff line
import { AccessToken, EgressClient, EncodedFileOutput, EncodedFileType, RoomServiceClient }
import { AccessToken, DirectFileOutput, EgressClient, EncodedFileOutput, EncodedFileType,
  RoomServiceClient, TrackSource }
  from "livekit-server-sdk";

/**
@@ -212,11 +213,51 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
      return { tacheId: info.egressId, cleObjet: d.cleObjet };
    },

    /**
     * La voix d'une personne, seule, pour la transcription.
     *
     * ╔════════════════════════════════════════════════════════════════════╗
     * ║  Pas « startParticipantEgress » : il ne peut pas rendre de l'audio ║
     * ║  seul, et refusait donc toutes les pistes.                         ║
     * ╚════════════════════════════════════════════════════════════════════╝
     *
     * L'appel était « startParticipantEgress » vers un fichier OGG. Mesuré
     * contre LiveKit 1.13.5 : la demande est refusée à la validation, avant
     * même de chercher la personne — « no supported codec is compatible with
     * all outputs ». Cet enregistreur-là capte caméra ET micro, et l'OGG ne
     * sait pas porter de vidéo. Le même appel en MP4 est accepté, puis reste
     * en démarrage et s'annule sur « Start signal not received » : il attend
     * une image d'un participant qui n'en publie pas.
     *
     * Le défaut ne se voyait nulle part. Ouvrir une piste échoue « au mieux » :
     * l'enregistrement continue et la piste est inscrite en échec avec son
     * motif. La réunion s'enregistrait donc bien, chaque piste échouait, et la
     * transcription n'avait jamais rien à lire — sans qu'aucun écran ne dise
     * autre chose que « non transcrite ».
     *
     * « startTrackEgress » enregistre une piste désignée, telle qu'elle arrive,
     * sans réencoder ni attendre d'image. Il faut lui donner l'identifiant de
     * la piste : on le demande au serveur, qui sait qui publie quoi. Mesuré :
     * 323 006 octets pour 19,9 s de parole, déposés et transcrits.
     */
    async demarrerPisteParticipant(d) {
      const gens = await salles.listParticipants(d.salle);
      const personne = gens.find((p) => p.identity === d.identite);
      if (!personne) {
        throw new Error(`Personne « ${d.identite} » absente de la salle média.`);
      }
      // TrackType.AUDIO vaut 0. On ne prend pas le partage d'écran sonore :
      // la transcription attribue chaque phrase à une voix, et le son d'un
      // document n'en est pas une.
      const voix = personne.tracks.find(
        (t) => t.type === 0 && t.source !== TrackSource.SCREEN_SHARE_AUDIO);
      if (!voix) {
        throw new Error(`« ${d.identite} » ne publie aucune voix à enregistrer.`);
      }

      // OGG plutôt que MP4 : on ne veut que la voix. La vidéo de chacun pèse
      // lourd, ne sert pas à la transcription, et le composite la porte déjà.
      const sortie = new EncodedFileOutput({
        fileType: EncodedFileType.OGG,
      const sortie = new DirectFileOutput({
        filepath: d.cleObjet,
        output: {
          case: "s3",
@@ -230,8 +271,7 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
          },
        },
      });
      const info = await egress.startParticipantEgress(d.salle, d.identite,
        { file: sortie });
      const info = await egress.startTrackEgress(d.salle, sortie, voix.sid);
      return { tacheId: info.egressId, cleObjet: d.cleObjet };
    },

@@ -246,10 +286,23 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
 * consentement, marque-pages, rétention — s'éprouve sans serveur média, parce
 * qu'aucune de ces règles n'appartient au fournisseur.
 */
export function mediaEssai(): Media & { finirEnregistrement(tacheId: string): void } {
export function mediaEssai(): Media & {
  finirEnregistrement(tacheId: string): void;
  refuserPistes(motif: string | null): void;
} {
  const salles = new Map<string, ParticipantMedia[]>();
  const taches = new Map<string, { salle: string; cleObjet: string; fini: boolean }>();
  let compteur = 0;
  /**
   * De quoi éprouver le refus d'une piste, qui arrive pour de vrai.
   *
   * Un serveur média refuse une piste pour des raisons qui lui appartiennent :
   * la personne a quitté entre-temps, elle ne publie aucune voix, l'enregistreur
   * n'a plus de place. Le produit promet de continuer quand même — et cette
   * promesse-là n'était éprouvée nulle part, parce que ce double disait
   * toujours oui.
   */
  let refus: string | null = null;

  return {
    nom: "essai",
@@ -274,6 +327,7 @@ export function mediaEssai(): Media & { finirEnregistrement(tacheId: string): vo
    },

    async demarrerPisteParticipant(d) {
      if (refus !== null) throw new Error(refus);
      compteur += 1;
      const tacheId = `essai-piste-${compteur}`;
      taches.set(tacheId, { salle: d.salle, cleObjet: d.cleObjet, fini: false });
@@ -287,6 +341,7 @@ export function mediaEssai(): Media & { finirEnregistrement(tacheId: string): vo
      const t = taches.get(tacheId);
      if (t) t.fini = true;
    },
    refuserPistes(motif) { refus = motif; },
  };
}

+40 −0
Changes for apps/api/src/verification/transcription.ts: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import * as comptes from "../auth/comptes.js";
import {
  definirTranscription, transcriptionInactive, transcriptionOpenAI,
} from "../adaptateurs/transcription.js";
import { definirMedia, mediaEssai } from "../adaptateurs/media.js";
import { stockage, stockageConfigure } from "../adaptateurs/stockage.js";
import type { Sql } from "../db/client.js";
import { ouvrirCrise } from "../domaine/crise.js";
@@ -117,6 +119,12 @@ export async function verifierTranscription(
    select id from salle where crise_id = ${crise.criseId} limit 1`;
  const salleId = salle!.id;

  // Son propre serveur média d'essai, plutôt que celui qu'un autre contrôle a
  // laissé en place : on va lui demander de refuser des pistes, et cela ne
  // regarde que ce scénario.
  const mediaFaux = mediaEssai();
  definirMedia(mediaFaux);

  await reunions.rejoindre(sql, crise.criseId, salleId, "Solène Le Bihan", true, ctx);
  await reunions.rejoindre(sql, crise.criseId, salleId, "Erwan Quéméner", false,
    { acteurId: second.compteId, origine: "humaine" });
@@ -151,6 +159,38 @@ export async function verifierTranscription(
  r.verifie("EF-806", "qui arrive en cours d'enregistrement reçoit aussi la sienne",
    combien?.n === "3", `${combien?.n} pistes`);

  // ── Une piste refusée ne fait pas perdre la réunion (EF-806) ─────────────
  //
  // Le produit promet de continuer « au mieux » : si le serveur média refuse
  // une piste, l'enregistrement tient et la transcription se fera sans cette
  // voix, en le disant. Cette promesse n'était éprouvée nulle part — le double
  // disait toujours oui — et le refus, lui, arrivait pour de bon : mesuré
  // contre LiveKit 1.13.5, l'enregistreur de participant refuse un OGG à la
  // validation même, parce qu'il capte caméra ET micro et qu'un OGG ne porte
  // pas d'image. Toutes les pistes échouaient donc, en silence, et il ne
  // restait rien à transcrire.
  mediaFaux.refuserPistes("Le serveur média a refusé cette piste.");
  const tardif = await comptes.inscrire(sql, {
    email: adresse("tardif"), nom: "Yann Le Goff",
    motDePasse: "ressac-de-nuit-71", urlPublique: URL_TEST,
  });
  await sql`insert into membre (organisation_id, compte_id, role)
            values (${hote.organisationId}, ${tardif.compteId}, 'membre')`;
  const entree = await reunions.rejoindre(sql, crise.criseId, salleId, "Yann Le Goff", false,
    { acteurId: tardif.compteId, origine: "humaine" });
  mediaFaux.refuserPistes(null);
  r.verifie("EF-806", "une piste refusée n'empêche pas d'entrer en réunion",
    entree.enregistrementEnCours?.id === enr.enregistrementId);
  const [refusee] = await sql<{ statut: string; motif_echec: string | null }[]>`
    select statut, motif_echec from enregistrement_piste
     where crise_id = ${crise.criseId} and enregistrement_id = ${enr.enregistrementId}
       and compte_id = ${tardif.compteId}`;
  r.verifie("EF-806", "et le refus s'inscrit comme un échec, pas comme une piste vide",
    refusee?.statut === "echec", refusee?.statut ?? "aucune ligne");
  r.verifie("EF-806", "avec le motif que le serveur a donné, pour savoir quoi réparer",
    (refusee?.motif_echec ?? "").includes("refusé"),
    refusee?.motif_echec ?? "aucun motif");

  // ── Rendre l'enregistrement et ses pistes disponibles ────────────────────
  await reunions.arreterEnregistrement(sql, crise.criseId, enr.enregistrementId, ctx);
  const [avecCle] = await sql<{ cle_objet: string }[]>`