Commit d40c249e authored by Kourser's avatar Kourser
Browse files

Qui arrivait en cours de réunion parlait avant tout le monde

Une piste ne commence pas forcément avec la réunion : qui rejoint en cours
reçoit la sienne à ce moment-là, et son fichier part de zéro alors que la
réunion en est à sa quarantième minute. Le moteur date depuis le début de la
piste ; le recousage triait ces dates sans les rapporter à quoi que ce soit.

Les premières paroles d'un arrivant portaient donc l'horodatage zéro et
remontaient en tête du document, avant des choses dites quarante minutes plus
tôt. C'est souvent le renfort qu'on a appelé pour trancher — donc celui qu'on
relit en priorité six mois plus tard, et celui dont la place dans l'échange
compte le plus.

Le décalage était déjà en base. On l'applique : « cree_at » de la piste moins
« demarre_at » de l'enregistrement. Ces horodatages servent aussi à sauter au
bon endroit dans la vidéo, dont le composite démarre avec la réunion : cliquer
sur la phrase d'un arrivant menait jusqu'ici au début de l'enregistrement.

Le contrôle ne pouvait pas voir le défaut. Ses trois voix parlaient à 12, 4 et
20 secondes dans leur piste, si bien que l'arrivante tombait au bon endroit par
hasard. Elle parle maintenant à la première seconde de la sienne — ce que fait
quelqu'un qui entre et prend la parole — et les instants sont posés nets plutôt
que laissés à l'horloge de la machine de contrôle.

On ne prétend pas à la milliseconde : « cree_at » est l'instant où l'on demande
la piste au serveur média, pas celui où il commence à capter, et il y a une
seconde ou deux entre les deux. L'erreur corrigée, elle, se comptait en
dizaines de minutes.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 29bcba0a
Loading
Loading
Loading
Loading
Loading
+36 −7
Changes for apps/api/src/domaine/reunions.ts: 36 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -659,13 +659,27 @@ export async function transcrireSiPret(
       and statut in ('en_cours', 'disponible')`;
  if (deja) return false;

  /**
   * Chaque piste, et l'instant où elle a commencé.
   *
   * Une piste ne commence pas forcément avec la réunion : qui arrive en cours
   * reçoit la sienne à ce moment-là, et son fichier part de zéro alors que la
   * réunion en est à sa quarantième minute. Sans « demarre_at », ses premières
   * paroles portaient un horodatage de zéro et remontaient en tête du
   * document, avant des choses dites quarante minutes plus tôt — et c'est
   * souvent le renfort qu'on a appelé pour trancher, donc celui qu'on relit.
   */
  const pistes = await sql<{
    id: string; compte_id: string | null; nom_affiche: string; cle_objet: string;
    cree_at: Date; demarre_at: Date;
  }[]>`
    select id, compte_id, nom_affiche, cle_objet from enregistrement_piste
     where crise_id = ${criseId} and enregistrement_id = ${enregistrementId}
       and statut = 'disponible'
     order by cree_at`;
    select p.id, p.compte_id, p.nom_affiche, p.cle_objet, p.cree_at, e.demarre_at
      from enregistrement_piste p
      join enregistrement e
        on e.crise_id = p.crise_id and e.id = p.enregistrement_id
     where p.crise_id = ${criseId} and p.enregistrement_id = ${enregistrementId}
       and p.statut = 'disponible'
     order by p.cree_at`;

  const moteur = transcription();
  const transcriptionId = randomUUID();
@@ -698,16 +712,31 @@ export async function transcrireSiPret(
      const rendu = await moteur.transcrire(
        Buffer.concat(morceaux), `${piste.id}.ogg`, undefined);
      langue ??= rendu.langue;
      // Le moteur date depuis le début de LA PISTE ; le document, lui, se lit
      // depuis le début de la réunion. On rapporte donc chaque segment à
      // l'ouverture de l'enregistrement.
      //
      // « cree_at » est l'instant où l'on a demandé la piste au serveur média,
      // pas celui où il a réellement commencé à capter : il y a entre les deux
      // le temps qu'il met à se lever, une seconde ou deux. On ne prétend donc
      // pas à la milliseconde. Mais l'erreur qu'on corrige, elle, se compte en
      // dizaines de minutes.
      const decalage = Math.max(0,
        piste.cree_at.getTime() - piste.demarre_at.getTime());
      for (const seg of rendu.segments) {
        segments.push({
          ...seg, compteId: piste.compte_id, nom: piste.nom_affiche,
          ...seg,
          debutMs: seg.debutMs + decalage,
          finMs: seg.finMs + decalage,
          compteId: piste.compte_id, nom: piste.nom_affiche,
        });
      }
    }

    // Les pistes sont transcrites l'une après l'autre ; l'échange, lui, s'est
    // tenu dans un seul ordre. On recoud par l'horodatage, qui est commun à
    // toutes puisqu'elles partent du même instant.
    // tenu dans un seul ordre. On recoud par l'horodatage, ramené ci-dessus au
    // départ de la réunion — les pistes, elles, ne partent pas toutes du même
    // instant, et c'est précisément ce qui égarait les arrivants.
    segments.sort((a, b) => a.debutMs - b.debutMs || a.nom.localeCompare(b.nom));

    const titre = `Transcription — ${salle?.libelle ?? "réunion"}`;
+32 −2
Changes for apps/api/src/verification/transcription.ts: 32 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -220,11 +220,32 @@ export async function verifierTranscription(
  //
  // Chaque piste rend un segment, à un instant différent, pour que le
  // recousage par horodatage ait quelque chose à recoudre.
  //
  // Le troisième parle à la première seconde DE SA PISTE — il est arrivé en
  // cours de réunion. Sans le décalage, il remonterait en tête du document,
  // avant des choses dites quarante minutes plus tôt. Le jeu est fait pour
  // que le contrôle sache faire la différence.
  const paroles = [
    { debut: 12, texte: "On isole le réseau industriel, tout de suite." },
    { debut: 4, texte: "La chaîne de conditionnement s'arrête alors." },
    { debut: 20, texte: "Elle est déjà arrêtée depuis quarante minutes." },
    { debut: 1, texte: "Elle est déjà arrêtée depuis quarante minutes." },
  ];

  // Des instants nets, plutôt que le temps qu'ont pris les écritures
  // précédentes : c'est le décalage qu'on éprouve, pas l'horloge de la machine
  // qui fait tourner le contrôle. Une seconde d'écart entre les deux premiers
  // pour que l'ordre des requêtes soit déterminé.
  const caler = (compteId: string | null, quand: string) => sql`
    update enregistrement_piste p
       set cree_at = e.demarre_at + ${quand}::interval
      from enregistrement e
     where e.crise_id = p.crise_id and e.id = p.enregistrement_id
       and p.crise_id = ${crise.criseId}
       and p.enregistrement_id = ${enr.enregistrementId}
       and p.compte_id is not distinct from ${compteId}`;
  await caler(hote.compteId, "0 seconds");
  await caler(second.compteId, "1 second");
  await caler(tiers.compteId, "40 minutes");
  const faux = await serveurFactice((n) => {
    const p = paroles[(n - 1) % paroles.length]!;
    return {
@@ -258,9 +279,18 @@ export async function verifierTranscription(
  r.verifie("EF-806", "chaque piste a rendu son passage",
    lue?.segments.length === 3, `${lue?.segments.length} segments`);
  // Le point qui justifie toute la mécanique des pistes séparées.
  // 4 + 1 = 5 s pour le deuxième, 12 + 0 pour le premier, et 1 s + 40 min pour
  // celui qui est arrivé en cours. Sans le décalage on lirait « 1000 » en
  // tête : sa première phrase avant tout ce qui s'est dit avant lui.
  r.verifie("EF-806", "les passages sont remis dans l'ordre où l'on a parlé",
    lue?.segments.map((s) => s.debut_ms).join(",") === "4000,12000,20000",
    lue?.segments.map((s) => s.debut_ms).join(",") === "5000,12000,2401000",
    lue?.segments.map((s) => s.debut_ms).join(","));
  // Le point que ce jeu-là éprouve, et rien d'autre : un horodatage rapporté au
  // début de la réunion, pas au début de la piste.
  r.verifie("EF-806", "qui est arrivé en cours n'est pas remonté au début de la réunion",
    lue?.segments[lue.segments.length - 1]?.locuteur_nom === "Maëlle Riou"
      && (lue?.segments[lue.segments.length - 1]?.debut_ms ?? 0) >= 40 * 60_000,
    `${lue?.segments.at(-1)?.locuteur_nom} à ${lue?.segments.at(-1)?.debut_ms} ms`);
  r.verifie("EF-806", "et chacun porte le nom de qui l'a dit",
    new Set(lue?.segments.map((s) => s.locuteur_nom)).size === 3,
    [...new Set(lue?.segments.map((s) => s.locuteur_nom))].join(" · "));