Diarisation des locuteurs et pause/reprise (intégration des travaux de main)

- diarizer.py (ECAPA-TDNN + clustering incrémental) repris tel quel de main
- Hooks dans le pipeline : colonne speaker, identification par segment dans
  un thread, locuteur dans l'interface (étiquettes colorées) et les exports
- Pause/reprise : bouton ⏸, le client coupe l'envoi, le serveur clôt le
  segment en cours
- Case « Locuteurs » pour désactiver la diarisation par réunion
- DIARIZATION=on par défaut dans les compose, PyTorch CPU dans l'image

https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
Claude committed 2026-06-12 17:50:51 +00:00
1 parent 21866ec334
commit db73e8db02
10 files changed
+352 -20

No files matched your search

+58 -5
View File
@@ -16,8 +16,18 @@ const state = {
sourceNode: null,
autoMicQueue: null,
switching: false,
paused: false,
};
// couleurs des étiquettes de locuteurs (classes .speaker-0 à .speaker-7)
const speakerColors = {};
function speakerColorIndex(speaker) {
if (!(speaker in speakerColors)) {
speakerColors[speaker] = Object.keys(speakerColors).length % 8;
}
return speakerColors[speaker];
}
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
// fetch avec redirection vers la page de connexion si la session a expiré
@@ -124,7 +134,11 @@ async function startRecording() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
state.ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value }));
state.ws.onopen = () => state.ws.send(JSON.stringify({
type: 'start',
title: $('title').value,
diarization: $('diarization-cb').checked,
}));
state.ws.onmessage = onServerMessage;
state.ws.onclose = (e) => {
if (e.code === 4401) { location.href = '/login'; return; }
@@ -142,6 +156,7 @@ async function startRecording() {
listMics();
state.recording = true;
state.paused = false;
state.startedAt = Date.now();
state.lastLoudAt = Date.now();
state.silentWarned = false;
@@ -149,12 +164,18 @@ async function startRecording() {
state.timerInterval = setInterval(updateTimer, 500);
$('record-btn').textContent = '■ Arrêter';
$('record-btn').classList.add('recording');
$('pause-btn').classList.remove('hidden', 'paused');
$('pause-btn').textContent = '⏸ Pause';
$('title').disabled = true;
setStatus('rec', 'Enregistrement…');
clearTranscript();
}
function queuePcm(samples) {
if (state.paused) {
state.lastLoudAt = Date.now(); // pas de chasse au micro pendant la pause
return;
}
updateVu(samples);
if (!state.recording || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
state.sendBuffer.push(samples);
@@ -200,10 +221,32 @@ function stopRecording(abrupt = false) {
$('record-btn').textContent = '● Démarrer';
$('record-btn').classList.remove('recording');
$('pause-btn').classList.add('hidden');
state.paused = false;
$('title').disabled = false;
$('vu-bar').style.width = '0%';
}
function togglePause() {
if (!state.recording || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
state.paused = !state.paused;
const btn = $('pause-btn');
if (state.paused) {
flushPcm();
state.ws.send(JSON.stringify({ type: 'pause' }));
btn.textContent = '▶ Reprendre';
btn.classList.add('paused');
setStatus('busy', 'En pause');
$('vu-bar').style.width = '0%';
} else {
state.ws.send(JSON.stringify({ type: 'resume' }));
state.lastLoudAt = Date.now();
btn.textContent = '⏸ Pause';
btn.classList.remove('paused');
setStatus('rec', 'Enregistrement…');
}
}
function onServerMessage(event) {
const msg = JSON.parse(event.data);
if (msg.type === 'ready') {
@@ -238,7 +281,7 @@ function updateTimer() {
String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
// micro muet depuis 5 s : on essaie automatiquement les autres micros
if (!state.recording) return;
if (!state.recording || state.paused) return;
const silent = Date.now() - state.lastLoudAt > 5000;
if (silent) {
state.silentWarned = true;
@@ -270,7 +313,12 @@ function clearTranscript() {
function appendSegment(seg) {
const div = document.createElement('div');
div.className = 'segment';
div.innerHTML = `<span class="ts">${fmtTs(seg.t0)}</span><span class="text"></span>`;
let speakerHtml = '';
if (seg.speaker) {
speakerHtml = `<span class="speaker speaker-${speakerColorIndex(seg.speaker)}"></span>`;
}
div.innerHTML = `<span class="ts">${fmtTs(seg.t0)}</span>${speakerHtml}<span class="text"></span>`;
if (seg.speaker) div.querySelector('.speaker').textContent = seg.speaker;
div.querySelector('.text').textContent = seg.text;
$('transcript').appendChild(div);
$('transcript').scrollTop = $('transcript').scrollHeight;
@@ -328,8 +376,12 @@ async function deleteCurrentMeeting() {
}
async function copyTranscript() {
const text = [...document.querySelectorAll('#transcript .segment .text')]
.map((el) => el.textContent).join('\n');
const text = [...document.querySelectorAll('#transcript .segment')]
.map((el) => {
const speaker = el.querySelector('.speaker');
const t = el.querySelector('.text').textContent;
return speaker ? `[${speaker.textContent}] ${t}` : t;
}).join('\n');
await navigator.clipboard.writeText(text);
$('copy-btn').textContent = '✓ Copié';
setTimeout(() => ($('copy-btn').textContent = '📋 Copier'), 1500);
@@ -338,6 +390,7 @@ async function copyTranscript() {
// --------------------------------------------------------------------- init
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
$('pause-btn').onclick = togglePause;
$('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; };
$('mic-select').onchange = async () => {
const id = $('mic-select').value;
+4
View File
@@ -27,8 +27,12 @@
<option value="">Micro par défaut</option>
</select>
<button id="record-btn" class="record">● Démarrer</button>
<button id="pause-btn" class="pause hidden">⏸ Pause</button>
<span id="timer">00:00</span>
<div id="vu" title="Niveau du micro"><div id="vu-bar"></div></div>
<label id="diarization-toggle" class="toggle" title="Identifier qui parle (Locuteur 1, 2...)">
<input type="checkbox" id="diarization-cb" checked> Locuteurs
</label>
</section>
<section id="transcript-panel">
+32
View File
@@ -109,6 +109,38 @@ button, .controls a {
}
button.record { background: var(--accent); font-weight: 600; min-width: 130px; }
button.record.recording { background: var(--rec); }
button.pause { background: var(--panel-2); font-weight: 600; }
button.pause.paused { background: #2a2410; color: #ffd166; }
button.pause.hidden { display: none; }
.toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
}
.toggle input { accent-color: var(--accent); }
.speaker {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
align-self: flex-start;
margin-top: 2px;
flex-shrink: 0;
}
.speaker-0 { background: #1d3a5f; color: #7cb8ff; }
.speaker-1 { background: #1f4a2c; color: #6fdc8c; }
.speaker-2 { background: #4a2a1f; color: #ffab70; }
.speaker-3 { background: #3d1f4a; color: #d8a6ff; }
.speaker-4 { background: #4a1f2e; color: #ff9bb3; }
.speaker-5 { background: #1f4a47; color: #6fd9d2; }
.speaker-6 { background: #4a431f; color: #e3d56f; }
.speaker-7 { background: #2e2e4a; color: #a3a8ff; }
#timer { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 48px; }
#vu {