';
if (m.state === 'failed') {
h += '
' + esc(m.error || 'Processing failed.') +
' The transcript is kept — retry when ready.
' +
'
Retry processing
';
if (m.segments.length) h += renderTranscript(bid, m).replace(
'
', '
');
return h + '
';
}
if (!m.shareHidden && m.summaryMd) {
h += '
Share this summary ' +
'⧉ Copy link ' +
'✉ Email ' +
'Slack ' +
'✕ ' +
'
';
}
if (m.completeness && m.completeness !== 'complete')
h += '
Summary class: ' + esc(m.completeness) +
' — partial recording, verify against the transcript.
';
h += renderSummaryMd(m.summaryMd);
h += '
↻ Regenerate
';
return h + '
';
}
function renderFoot(bid, m) {
if (m.state === 'idle') {
return '';
}
if (m.bannerHidden) return '';
return '
✨ AI Meeting Notes — live transcription' +
' in your browser · audio is ephemeral, transcript is kept. ' +
'✕
';
}
function html(bid) {
var b = ensure(bid);
if (!b) return '';
var m = b.meeting;
var warn = '';
if ((m.state === 'recording') && m.remoteSilentWarn && !m.remoteSilentWarned)
warn = '
🔇 I can only hear you. If the call is in another' +
' tab, check its audio is shared (Tab + microphone mode).
';
else if ((m.state === 'recording') && m.micLowWarn)
warn = '
🎤 Microphone level is very low — move closer or check input.
';
return '
' +
renderHead(m) + renderRow(bid, m) + warn + renderBody(bid, m) + renderFoot(bid, m) + '
';
}
/* ── elapsed ─────────────────────────────────────────────────── */
function elapsed(m) {
if (m.state === 'recording') return (m.elapsedBase || 0) + (Date.now() - (m.startedAt || Date.now()));
if (m.state === 'paused' || m.state === 'processing' ||
m.state === 'done' || m.state === 'failed')
return m.elapsedBase || 0;
return 0;
}
/* ── moteur de capture (modes A/B, §6.2) ─────────────────────── */
function hasSTT() {
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
}
function hasDisplayMedia() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
}
function stopTracks(rt) {
['micStream', 'tabStream'].forEach(function (k) {
var s = rt[k];
if (s) try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
rt[k] = null;
});
if (rt.audioCtx) try { rt.audioCtx.close(); } catch (e) {}
rt.audioCtx = null; rt.analyserSelf = null; rt.analyserRemote = null;
if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {}
rt.waveRaf = null;
}
function stopRecog(rt) {
if (rt.recog) { rt.recogStopping = true; try { rt.recog.stop(); } catch (e) {} }
rt.recog = null;
}
function teardownCapture(bid) {
var rt = FDMeeting._rt[bid] || {};
stopRecog(rt); stopTracks(rt);
if (rt.timer) { try { clearInterval(rt.timer); } catch (e) {} rt.timer = null; }
if (rt.poller) { try { clearInterval(rt.poller); } catch (e) {} rt.poller = null; }
}
function rms(analyser, buf) {
if (!analyser) return 0;
analyser.getByteTimeDomainData(buf);
var sum = 0;
for (var i = 0; i < buf.length; i++) {
var v = (buf[i] - 128) / 128; sum += v * v;
}
return Math.sqrt(sum / buf.length);
}
function attachAnalyser(rt, stream) {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
if (!rt.audioCtx) { try { rt.audioCtx = new Ctx(); } catch (e) { return null; } }
try {
var src = rt.audioCtx.createMediaStreamSource(stream);
var an = rt.audioCtx.createAnalyser();
an.fftSize = 512;
src.connect(an);
return an;
} catch (e) { return null; }
}
function startWave(bid, rt) {
var bars = rt.waveBars || (rt.waveBars = new Array(28).fill(0.05));
var bufS = new Uint8Array(512), bufR = new Uint8Array(512);
var last = 0, remoteQuietSince = 0;
function frame(t) {
var b = findBlock(bid);
if (!b || b.meeting.state !== 'recording') return;
if (t - last > 100) {
last = t;
var lvS = rms(rt.analyserSelf, bufS);
var lvR = rt.analyserRemote ? rms(rt.analyserRemote, bufR) : 0;
var lv = Math.max(lvS, lvR);
bars.push(Math.min(1, lv * 6 + 0.04)); bars.shift();
if (rt.analyserRemote) {
if (lvR < 0.015 && lvS > 0.02) {
if (!remoteQuietSince) remoteQuietSince = Date.now();
else if (Date.now() - remoteQuietSince > 20000 && !b.meeting.remoteSilentWarn) {
b.meeting.remoteSilentWarn = true; save(); FDMeeting.paint(bid);
}
} else { remoteQuietSince = 0; }
}
if (lvS < 0.008) {
rt.lowSince = rt.lowSince || Date.now();
if (Date.now() - rt.lowSince > 20000 && !b.meeting.micLowWarn) {
b.meeting.micLowWarn = true; save(); FDMeeting.paint(bid);
}
} else { rt.lowSince = 0; }
drawWave(bid, bars);
}
rt.waveRaf = requestAnimationFrame(frame);
}
rt.waveRaf = requestAnimationFrame(frame);
}
function drawWave(bid, bars) {
var rt = FDMeeting._rt[bid];
if (!rt || !rt.root) return;
var cv = rt.root.querySelector('[data-fm-wave]');
if (!cv) return;
var ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, cv.width, cv.height);
var n = bars.length, w = cv.width / n;
ctx.fillStyle = '#2383E2';
for (var i = 0; i < n; i++) {
var h = Math.max(2, bars[i] * cv.height);
var x = i * w + w * 0.25;
ctx.beginPath();
if (bars[i] < 0.12) {
ctx.arc(x + w * 0.25, cv.height / 2, 1.4, 0, 7);
ctx.fill();
} else {
ctx.fillRect(x, (cv.height - h) / 2, w * 0.5, h);
}
}
}
function startTimer(bid) {
var rt = FDMeeting._rt[bid] || {};
if (rt.timer) try { clearInterval(rt.timer); } catch (e) {}
rt.timer = setInterval(function () {
var b = findBlock(bid);
if (!b || (b.meeting.state !== 'recording' && b.meeting.state !== 'paused')) {
try { clearInterval(rt.timer); } catch (e2) {}
return;
}
var el = rt.root && rt.root.querySelector('[data-fm-timer]');
if (el) el.textContent = fmtClock(elapsed(b.meeting));
}, 1000);
}
function pushSegment(bid, seg) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
m.segSeq = (m.segSeq || 0) + 1;
var item = {
seq: m.segSeq, channel: seg.channel || 'self',
source: seg.source || '', speaker: seg.speaker || '',
startMs: seg.startMs != null ? seg.startMs : elapsed(m),
endMs: seg.endMs != null ? seg.endMs : elapsed(m),
text: (seg.mid ? '--' : '') + (seg.text || ''), mid: !!seg.mid
};
m.segments.push(item);
save();
// tampon local + envoi serveur (reprise sur rechargement).
idbGet(bid).then(function (pending) {
pending.push({
channel: item.channel, source_label: item.source, speaker: item.speaker,
text: (seg.text || ''), start_ms: item.startMs, end_ms: item.endMs,
starts_mid: item.mid
});
idbPut(bid, pending);
flushSegments(bid);
});
// rafraîchir seulement la zone transcript (pas de repaint complet).
var rt = FDMeeting._rt[bid];
if (rt && rt.root && (m.tab === 'transcript' || !m.tab)) {
var body = rt.root.querySelector('[data-fm-transcript]');
if (body) {
var tmp = document.createElement('div');
tmp.innerHTML = renderTranscript(bid, m);
var fresh = tmp.querySelector('[data-fm-transcript]');
if (fresh) body.innerHTML = fresh.innerHTML;
}
}
}
var flushBusy = {};
function flushSegments(bid) {
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId || flushBusy[bid]) return;
flushBusy[bid] = true;
idbGet(bid).then(function (pending) {
if (!pending.length) { flushBusy[bid] = false; return; }
var batch = pending.slice(0, 50);
api('/api/v2/meetings/transcripts/' + b.meeting.transcriptId + '/segments', {
method: 'POST', body: { segments: batch }
}).then(function () {
idbPut(bid, pending.slice(batch.length));
flushBusy[bid] = false;
if (pending.length > batch.length) flushSegments(bid);
}).catch(function () { flushBusy[bid] = false; });
});
}
// Vide le tampon local vers le serveur avant toute analyse : le manuel
// collé après un échec (cas du 400 en boucle) part lui aussi — c'était
// le chaînon manquant. Résout true si le serveur a tout, false sinon.
function drainOutbox(bid, timeoutMs) {
var b = findBlock(bid);
if (b && (b.meeting.manual || '').trim() && b.meeting.transcriptId) {
var pasted = b.meeting.manual.trim();
b.meeting.manual = '';
save();
FDMeeting._pushSegment(bid, {
channel: 'self', source: 'Pasted transcript', speaker: '',
text: pasted, startMs: 0, endMs: elapsed(b.meeting), mid: false
});
} else {
flushSegments(bid);
}
var bb = findBlock(bid);
if (!bb || !bb.meeting.transcriptId) return Promise.resolve(true);
var waited = 0, step = 400, max = timeoutMs || 5000;
return new Promise(function (resolve) {
var iv = setInterval(function () {
idbGet(bid).then(function (pending) {
if (!pending.length || waited >= max) {
clearInterval(iv);
resolve(!pending.length);
}
waited += step;
});
}, step);
});
}
// Réponse d'analyse (/stop ou /retry) → état done, dans tous les cas.
function applyDone(bid, out) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'done';
bb.meeting.summaryMd = (out && out.summary) || '';
bb.meeting.completeness = (out && out.completeness) || '';
bb.meeting.genTitle = (out && out.generated_title) || '';
bb.meeting.error = '';
save(); FDMeeting.paint(bid);
idbPut(bid, []);
}
function applyFailed(bid, err, defaultMsg) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'failed';
bb.meeting.error = err ? friendlyMeetingError(err)
: (defaultMsg || 'Processing failed — the transcript is kept, Retry when ready.');
save(); FDMeeting.paint(bid);
}
function startRecognition(bid, rt, m) {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return false;
stopRecog(rt);
var rec;
try { rec = new SR(); } catch (e) { return false; }
rt.recog = rec; rt.recogStopping = false;
rec.lang = m.lang === 'en' ? 'en-US' : 'fr-FR';
rec.continuous = true; rec.interimResults = true; rec.maxAlternatives = 1;
var base = 0;
rec.onresult = function (ev) {
var b = findBlock(bid);
if (!b) return;
var finalText = '', interim = '';
for (var i = base; i < ev.results.length; i++) {
var r = ev.results[i];
var t = (r[0] && r[0].transcript) || '';
if (r.isFinal) finalText += t; else interim += t;
}
base = ev.results.length;
if (finalText.trim()) {
var firstEver = b.meeting.segments.length === 0;
FDMeeting._pushSegment(bid, {
channel: 'self',
source: m.mode === 'tab_plus_mic' ? 'My microphone' : "My audio",
speaker: '',
text: finalText.trim(),
// Heuristique §3A.7 : si le premier segment arrive alors que la
// session tourne depuis un moment, on a pris en pleine phrase.
mid: firstEver && elapsed(b.meeting) > 5000
});
b.meeting.interim = '';
} else {
b.meeting.interim = interim.trim();
var el = rt.root && rt.root.querySelector('.fm-interim .fm-txt');
if (el) el.textContent = (b.meeting.interim || '') + '…';
}
};
rec.onend = function () {
var b2 = findBlock(bid);
if (!rt.recogStopping && b2 && b2.meeting.state === 'recording') {
try { rec.start(); } catch (e) {}
}
};
rec.onerror = function (ev) {
if (ev && ev.error === 'not-allowed') {
var b3 = findBlock(bid);
if (b3) {
toast('Microphone refused — allow the mic or use the manual transcript.');
paintBody(bid);
}
}
};
try { rec.start(); return true; }
catch (e) { return false; }
}
function paintBody(bid) { FDMeeting.paint(bid); }
function elapsedOf(m) { return elapsed(m); }
/* ── objet public (surcharge) ────────────────────────────────── */
var FDMeeting = {
_rt: {},
mount: function (bid, root) {
var rt = this._rt[bid] || (this._rt[bid] = {});
rt.root = root;
if (!rt.wired) {
rt.wired = true;
var self = this;
root.addEventListener('click', function (ev) {
var t = ev.target && ev.target.closest
? ev.target.closest('[data-act]') : null;
if (t) self.act(bid, t.getAttribute('data-act'), t, ev);
});
root.addEventListener('input', function (ev) { self.onInput(bid, ev.target, ev); });
root.addEventListener('change', function (ev) { self.onInput(bid, ev.target, ev); });
}
var b = ensure(bid);
if (b) {
var m = b.meeting;
// Rechargement en pleine session : restaurer l'état exact (§6.5).
if (m.state === 'recording' || m.state === 'paused') {
if (m.startedAt && m.state === 'recording') {
m.elapsedBase = elapsed(m);
m.state = 'paused';
toast('Capture interrupted by reload — press Resume to continue.');
save();
}
} else if (m.state === 'processing') {
this.followProcessing(bid);
}
// Rejouer le tampon local vers le serveur.
if (m.transcriptId) flushSegments(bid);
if (m.state === 'recording') startTimer(bid);
}
this.paint(bid);
},
focus: function () {},
paint: function (bid) {
var rt = this._rt[bid], b = ensure(bid);
if (!b || !rt || !rt.root) return;
rt.root.innerHTML = html(bid);
if (b.meeting.state === 'idle') refreshMicPerm(bid);
if (b.meeting.state === 'recording') {
startTimer(bid);
if (!rt.waveRaf && (rt.analyserSelf || !hasSTT())) startWave(bid, rt);
}
},
onInput: function (bid, el) {
var b = ensure(bid);
if (!b || !el) return;
var m = b.meeting, k = el.getAttribute && el.getAttribute('data-fm');
if (k === 'title') {
var v = el.value || '';
if (m.genTitle && !m.titleEdited && v !== m.genTitle && v !== m.title) {
m.titleEdited = true; m.title = v;
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/generated-title',
{ method: 'PATCH', body: { title: v } }).catch(function () {});
} else if (!m.genTitle || m.titleEdited) { m.title = v; }
else if (v !== m.genTitle) { m.titleEdited = true; m.title = v; }
save(); return; // pas de repaint (garder le focus)
}
if (k === 'notes') {
m.notes = el.value;
save();
if (m.transcriptId) {
clearTimeout(this._notesT);
this._notesT = setTimeout(function () {
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes',
{ method: 'PATCH', body: { notes: m.notes } }).catch(function () {});
}, 1200);
}
return;
}
if (k === 'manual') { m.manual = el.value; save(); return; }
if (k === 'instruction') { m.instruction = el.value; save(); this.paint(bid); return; }
if (k === 'consentAudio') { m.consentAudio = !!el.checked; save(); return; }
if (el.getAttribute && el.getAttribute('data-fm') === 'importAudio' && el.files) {
this.importAudio(bid, el.files[0]);
return;
}
// input file import audio (attribut data-fm="importAudio")
if (el.type === 'file' && el.files) { this.importAudio(bid, el.files[0]); }
},
act: function (bid, act, el, ev) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (ev && (act === 'mode' || act === 'lang')) ev.stopPropagation();
switch (act) {
case 'tab':
m.tab = (el && el.getAttribute('data-tab')) || defaultTab(m.state);
save(); this.paint(bid); break;
case 'menu':
m.menuOpen = !m.menuOpen; save(); this.paint(bid); break;
case 'settings':
m.settingsOpen = !m.settingsOpen; save(); this.paint(bid); break;
case 'testmic': this.testMic(bid); break;
case 'mode':
m.mode = el.getAttribute('data-v'); m.menuOpen = false;
save(); this.paint(bid); break;
case 'lang':
m.lang = el.getAttribute('data-v'); m.menuOpen = false;
save(); this.paint(bid); break;
case 'suggest': this.suggest(bid); break;
case 'consentAudio': this.playConsent(); break;
case 'consentCopy':
copyText(CONSENT_TEXT);
toast('Consent message copied — paste it in the call chat.'); break;
case 'bannerhide':
m.bannerHidden = true; save(); this.paint(bid); break;
case 'sharehide':
m.shareHidden = true; save(); this.paint(bid); break;
case 'start': this.start(bid); break;
case 'pause': this.pause(bid); break;
case 'resume': this.resume(bid); break;
case 'stop': this.stop(bid); break;
case 'retry': this.retry(bid); break;
case 'regen': this.regen(bid); break;
case 'reset': this.reset(bid); break;
case 'cite': {
var seq = parseInt(el.getAttribute('data-seq'), 10);
m.tab = 'transcript'; save(); this.paint(bid);
setTimeout(function () {
var rt = FDMeeting._rt[bid];
var target = rt && rt.root &&
rt.root.querySelector('.fm-seg[data-seq="' + seq + '"]');
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('fm-flash');
setTimeout(function () { target.classList.remove('fm-flash'); }, 2200);
}
}, 60);
break;
}
case 'speaker': {
var sq = parseInt(el.getAttribute('data-seq'), 10);
var cur = '';
m.segments.forEach(function (s) { if (s.seq === sq) cur = s.speaker || ''; });
var name = window.prompt('Speaker name for segment ' + sq + ':', cur || 'You');
if (name === null) break;
m.segments.forEach(function (s) { if (s.seq === sq) s.speaker = name.trim(); });
save();
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId +
'/segments/' + sq + '/speaker',
{ method: 'PATCH', body: { speaker: name.trim() } }).catch(function () {});
this.paint(bid);
break;
}
case 'share': this.share(bid, el.getAttribute('data-v')); break;
}
},
suggest: function (bid) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (!m.notes.trim()) {
m.notes = 'Agenda\n• …\n\nContext\n• …\n';
save(); this.paint(bid);
toast('Agenda template added — the AI uses it in the summary.');
} else {
toast('Tip: write decisions and names in Notes — they take priority in the summary.');
}
},
playConsent: function () {
try {
var u = new SpeechSynthesisUtterance(CONSENT_TEXT);
u.lang = 'en-US';
window.speechSynthesis.speak(u);
} catch (e) { toast(CONSENT_TEXT); }
},
/* ── cycle de vie ─────────────────────────────────────────── */
ensureServerRow: function (bid, m) {
if (m.transcriptId) return Promise.resolve(m.transcriptId);
var pid = (window.E && window.E.pid) || 0;
if (!pid) return Promise.reject(new Error('Open the page first (no page id).'));
return api('/api/v2/meetings/transcripts', {
method: 'POST',
body: {
page_id: pid, language: m.lang, mode: m.mode,
instruction_id: m.instruction
}
}).then(function (row) {
m.transcriptId = row.id;
save();
return row.id;
});
},
start: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
m.menuOpen = false;
if (m.state === 'recording' || m.state === 'processing') return;
// Porte de consentement : le clic sur Start vaut attestation (§3A.2).
this.ensureServerRow(bid, m).then(function (tid) {
return api('/api/v2/meetings/transcripts/' + tid + '/consents', {
method: 'POST',
body: { method: 'start_attestation', participants: [] }
}).then(function () { return tid; });
}).then(function (tid) {
m.consent = { method: 'start_attestation', at: Date.now() };
return api('/api/v2/meetings/transcripts/' + tid + '/sessions', {
method: 'POST',
body: {
mode: m.mode, language: m.lang, instruction_id: m.instruction,
channels: m.mode === 'tab_plus_mic'
? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }]
: [{ kind: 'self', label: 'My audio' }]
}
});
}).then(function () {
m.state = 'recording';
m.tab = m.tab === 'summary' ? 'transcript' : (m.tab || 'transcript');
m.startedAt = Date.now();
m.remoteSilentWarn = false; m.micLowWarn = false; m.remoteSilentWarned = false;
m.steps = []; m.summaryMd = ''; m.error = '';
m.channels = m.mode === 'tab_plus_mic'
? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }]
: [{ kind: 'self', label: 'My audio' }];
if (m.consentAudio) self.playConsent();
save(); self.paint(bid);
self.acquireAudio(bid);
}).catch(function (err) {
m.error = friendlyMeetingError(err);
save(); self.paint(bid);
toast(m.error);
});
this.paint(bid);
},
acquireAudio: function (bid) {
var self = this, b = findBlock(bid);
if (!b) return;
var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {});
teardownCaptureKeepTimer(rt);
var wantTab = m.mode === 'tab_plus_mic';
function afterStreams() {
if (rt.micStream) rt.analyserSelf = attachAnalyser(rt, rt.micStream);
if (rt.tabStream) rt.analyserRemote = attachAnalyser(rt, rt.tabStream);
startWave(bid, rt);
var ok = startRecognition(bid, rt, m);
if (!ok && !rt.micStream && !rt.tabStream) {
toast('No audio capture available — use the manual transcript.');
}
self.paint(bid);
}
function micOnly() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
afterStreams(); return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
rt.micStream = s; afterStreams();
}).catch(function () { afterStreams(); });
}
if (wantTab) {
if (!hasDisplayMedia()) {
toast('Tab capture unsupported here — microphone mode used instead.');
m.mode = 'mic_only'; save(); micOnly(); return;
}
toast('Choose the call tab and tick “Share audio”.');
navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
.then(function (tab) {
rt.tabStream = tab;
// Couper la vidéo : seul l'audio nous intéresse.
try {
tab.getVideoTracks().forEach(function (t) { t.stop(); });
} catch (e) {}
if (!navigator.mediaDevices.getUserMedia) { afterStreams(); return; }
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
rt.micStream = s; afterStreams();
}).catch(function () { afterStreams(); });
})
.catch(function () {
toast('Tab share cancelled — microphone mode used instead.');
m.mode = 'mic_only'; save(); micOnly();
});
} else { micOnly(); }
},
pause: function (bid) {
var self = this, b = ensure(bid);
if (!b || b.meeting.state !== 'recording') return;
var m = b.meeting, rt = this._rt[bid] || {};
m.elapsedBase = elapsed(m);
var delta = 0;
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/pause',
{ method: 'POST', body: {} }).catch(function () {});
stopRecog(rt);
if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {}
rt.waveRaf = null;
m.state = 'paused';
save(); self.paint(bid);
},
resume: function (bid) {
var self = this, b = ensure(bid);
if (!b || b.meeting.state !== 'paused') return;
var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {});
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/resume',
{ method: 'POST' }).catch(function () {});
m.startedAt = Date.now();
m.state = 'recording';
save(); self.paint(bid);
// Reprendre la reconnaissance vocale (les flux coupés sont réacquis).
if (!rt.micStream && !rt.tabStream) self.acquireAudio(bid);
else {
startWave(bid, rt);
startRecognition(bid, rt, m);
startTimer(bid);
}
},
stop: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (m.state !== 'recording' && m.state !== 'paused') return;
var rt = this._rt[bid] || (this._rt[bid] = {});
// Garde-fou : arrêter à vide effacerait la prise — proposer d'abord
// de vérifier le micro ou de coller un transcript (§ point 5).
if (!hasCaptured(m) && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
m.elapsedBase = elapsed(m);
teardownCapture(bid);
// Verser le manuel éventuel dans les segments avant l'analyse
// (via pushSegment : tampon local + envoi serveur).
if (m.manual.trim()) {
var pasted = m.manual.trim();
m.manual = '';
pushSegment(bid, {
channel: 'self', source: 'Pasted transcript', speaker: '',
text: pasted, startMs: 0, endMs: m.elapsedBase, mid: false
});
}
self.flushManualNotes(bid);
m.state = 'processing';
m.tab = 'summary';
m.steps = THINKING_STEPS.map(function (s, i) {
return { key: s[0], label: s[1], state: i === 0 ? 'running' : 'pending' };
});
save(); self.paint(bid);
function finishWith(out) {
applyDone(bid, out);
var bb = findBlock(bid);
if (!bb) return;
var mm = bb.meeting;
// Étapes finales depuis le serveur (source de vérité).
if (mm.transcriptId)
api('/api/v2/meetings/transcripts/' + mm.transcriptId + '/processing-steps', {})
.then(function (st) {
var bbb = findBlock(bid);
if (bbb && st.steps && st.steps.length) {
bbb.meeting.steps = st.steps;
save(); self.paint(bid);
}
}).catch(function () {});
}
if (!m.transcriptId) {
// 100 % local (page non sauvegardée) : résumé via l'agent.
this.summarizeLocal(bid);
return;
}
// Drainer d'abord : appeler /stop avec un tampon non parti condamnait
// à un 400 alors même que les segments existaient en local.
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
// Session serveur conservée (toujours live) : Retry renverra.
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Retry. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/stop', {
method: 'POST', body: { duration_ms: m.elapsedBase, allow_empty: allowEmpty }
}).then(finishWith).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
followProcessing: function (bid) {
var self = this, rt = this._rt[bid] || (this._rt[bid] = {});
if (rt.poller) try { clearInterval(rt.poller); } catch (e) {}
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId) return;
var tid = b.meeting.transcriptId;
rt.poller = setInterval(function () {
var bb = findBlock(bid);
if (!bb || bb.meeting.state !== 'processing') {
try { clearInterval(rt.poller); } catch (e2) {}
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {})
.then(function (st) {
var bbb = findBlock(bid);
if (!bbb || bbb.meeting.state !== 'processing') return;
if (st.steps && st.steps.length) {
// Ne pas écraser par une liste vide ; animer running→done.
var cur = {};
bbb.meeting.steps.forEach(function (s) { cur[s.key] = s.state; });
var changed = false;
st.steps.forEach(function (s) {
if (cur[s.key] !== s.state) changed = true;
});
if (changed || !bbb.meeting.steps.length) {
bbb.meeting.steps = st.steps;
save(); self.paint(bid);
}
}
if (st.status === 'done') {
try { clearInterval(rt.poller); } catch (e3) {}
api('/api/v2/meetings/transcripts/' + tid, {}).then(function (row) {
var b4 = findBlock(bid);
if (!b4) return;
b4.meeting.state = 'done';
b4.meeting.summaryMd = row.summary || b4.meeting.summaryMd;
b4.meeting.completeness = row.completeness_class || '';
b4.meeting.genTitle = row.generated_title || '';
save(); self.paint(bid);
idbPut(bid, []);
}).catch(function () {});
} else if (st.status === 'failed') {
try { clearInterval(rt.poller); } catch (e4) {}
}
}).catch(function () {});
}, 1500);
},
// Retry intelligent : verse d'abord le local (le manuel collé après un
// échec part lui aussi — fin du 400 en boucle), puis termine par /stop
// si la session serveur est encore en vie, sinon /processing:retry.
retry: function (bid) {
var self = this, b = ensure(bid);
if (!b || !b.meeting.transcriptId) { this.summarizeLocal(bid); return; }
var m = b.meeting;
var rt = this._rt[bid] || (this._rt[bid] = {});
if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
var tid = m.transcriptId;
m.state = 'processing'; m.tab = 'summary'; m.error = '';
save(); self.paint(bid);
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Retry. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {})
.then(function (st) {
if (st.status === 'recording' || st.status === 'paused') {
var bb = findBlock(bid);
var dur = bb ? (bb.meeting.elapsedBase || 0) : 0;
return api('/api/v2/meetings/transcripts/' + tid + '/stop', {
method: 'POST', body: { duration_ms: dur, allow_empty: allowEmpty }
});
}
return api('/api/v2/meetings/transcripts/' + tid + '/processing:retry',
{ method: 'POST', body: { allow_empty: allowEmpty } });
}).then(function (out) {
applyDone(bid, out);
}).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
regen: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (!m.transcriptId) { this.summarizeLocal(bid); return; }
var rt = this._rt[bid] || (this._rt[bid] = {});
if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
var tid = m.transcriptId;
m.state = 'processing'; m.tab = 'summary';
m.steps = THINKING_STEPS.map(function (s) {
return { key: s[0], label: s[1], state: 'pending' };
});
save(); self.paint(bid);
// Le manuel collé entre-temps part d'abord, sinon le serveur
// résume un transcript vide et répond 400.
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Regenerate. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/summarize',
{ method: 'POST', body: { allow_empty: allowEmpty } }).then(function (out) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'done';
bb.meeting.summaryMd = out.summary || '';
bb.meeting.completeness = out.completeness || bb.meeting.completeness;
bb.meeting.genTitle = out.generated_title || bb.meeting.genTitle;
save(); self.paint(bid);
}).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
/* Pre-recording microphone check : 4 s live level, then guidance. */
testMic: function (bid) {
var self = this;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast('Microphone API unavailable in this browser — use the manual transcript.');
return;
}
toast('Microphone test — speak for 4 seconds…');
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
function done(msg) {
try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
toast(msg);
self.paint(bid);
}
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) { done('Microphone granted (no level analysis available). You can Start.'); return; }
var ctx, an;
try {
ctx = new Ctx();
an = ctx.createAnalyser(); an.fftSize = 512;
ctx.createMediaStreamSource(s).connect(an);
} catch (e) { done('Microphone granted. You can Start.'); return; }
var buf = new Uint8Array(512), peak = 0, n = 0;
var iv = setInterval(function () {
try { an.getByteTimeDomainData(buf); } catch (e2) {}
var sum = 0, i;
for (i = 0; i < buf.length; i++) {
var v = (buf[i] - 128) / 128; sum += v * v;
}
var lv = Math.sqrt(sum / buf.length);
if (lv > peak) peak = lv;
if (++n >= 40) {
clearInterval(iv);
try { ctx.close(); } catch (e3) {}
if (peak > 0.02) done('Microphone OK — good level detected. You can Start.');
else done('Microphone granted but silent — check the input device, move closer and speak up.');
}
}, 100);
}).catch(function () {
toast('Microphone refused — allow access in the browser address bar, or use the manual transcript.');
});
},
/* Résumé 100 % local (aucune ligne serveur) via l'agent IA. */ summarizeLocal: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
var content = transcriptText(m);
if (!content.trim()) {
m.state = 'failed'; m.error = 'No meeting content to summarize.';
save(); self.paint(bid); return;
}
if (!(window.fdAgent && window.fdAgent.generate)) {
m.state = 'failed';
m.error = 'AI agent unavailable — configure it in Settings → Agent & AI.';
save(); self.paint(bid); return;
}
window.fdAgent.generate(
'Write the structured summary of this meeting (AI Meeting Note): one line' +
' with the date of ' + m.date + ', then “Overview”, “Decisions” and' +
' “Action Items” sections with concrete bullets. If the content is too' +
' short or fragmentary, say “No meaningful content could be extracted”.' +
' No preamble.',
content
).then(function (text) {
var bb = findBlock(bid);
if (!bb) return;
bb.meeting.state = 'done';
bb.meeting.summaryMd = (text || '').trim();
bb.meeting.completeness = 'complete';
save(); self.paint(bid);
}).catch(function (err) {
var bb2 = findBlock(bid);
if (!bb2) return;
bb2.meeting.state = 'failed';
bb2.meeting.error = (err && err.message) || 'Summary failed.';
save(); self.paint(bid);
});
},
flushManualNotes: function (bid) {
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId) return;
var m = b.meeting;
if (m.notes.trim())
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes',
{ method: 'PATCH', body: { notes: m.notes } }).catch(function () {});
flushSegments(bid);
},
reset: function (bid) {
var b = ensure(bid);
if (!b) return;
teardownCapture(bid);
var keepTitle = b.meeting.title, keepDate = b.meeting.date;
b.meeting = defState();
b.meeting.title = keepTitle; b.meeting.date = keepDate;
save(); this.paint(bid);
},
share: function (bid, channel) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
function record(status) {
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/distributions', {
method: 'POST',
body: { channel: channel, recipients: '', status: status || 'created' }
}).catch(function () {});
}
var url = location.href.split('#')[0] + '#meeting-' + bid;
var title = displayTitle(m);
if (channel === 'copy_link') {
copyText(url); record('link_copied');
toast('Summary link copied.');
} else if (channel === 'email') {
record('created');
var body = (m.summaryMd || '').replace(/ ¹( \[seg \d+\])?/g, '').slice(0, 3000);
location.href = 'mailto:?subject=' +
encodeURIComponent('Meeting summary — ' + title) +
'&body=' + encodeURIComponent(body + '\n\n' + url);
} else if (channel === 'slack') {
var txt = '*' + title + '*\n' +
(m.summaryMd || '').replace(/ ¹( \[seg \d+\])?/g, '').slice(0, 2500) +
'\n' + url;
copyText(txt); record('created');
toast('Summary formatted for Slack — copied, paste it in your channel.');
}
},
importAudio: function (bid, file) {
var self = this;
if (!file) return;
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
var pid = (window.E && window.E.pid) || 0;
if (!pid) { toast('Save the page first, then import audio.'); return; }
toast('Uploading audio for transcription…');
var fd = new FormData();
fd.append('audio', file);
fd.append('page_id', String(pid));
fd.append('language', m.lang || 'fr');
fetch('/api/v2/meetings/transcribe', {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrf() }, body: fd
}).then(function (r) {
if (!r.ok) throw new Error('upload rejected (' + r.status + ')');
return r.json();
}).then(function (row) {
var bb = ensure(bid);
if (!bb) return;
if (row.transcript) {
bb.meeting.manual = row.transcript;
bb.meeting.state = bb.meeting.state === 'idle' ? 'idle' : bb.meeting.state;
save(); self.paint(bid);
toast('Audio transcribed — review the manual transcript, then Start → Stop to summarize.');
} else {
toast('Audio stored — no server transcription backend; paste the transcript manually.');
}
}).catch(function (err) {
toast((err && err.message) || 'Import failed.');
});
},
_pushSegment: pushSegment
};
function teardownCaptureKeepTimer(rt) {
stopRecog(rt); stopTracks(rt);
}
// Raw API failures (ex. "HTTP 400 {...}") → actionable guidance, because a
// lost take is the worst possible outcome of this block.
function friendlyMeetingError(err) {
var raw = (err && err.message) || '';
if (/transcript is empty/i.test(raw))
return 'Nothing was captured — check the microphone (Start ▾ → Test microphone), ' +
'or paste a transcript in Transcript → Manual transcript, then Retry. Your notes are kept.';
if (/consent/i.test(raw))
return 'Consent is required before analysis — Start again to record it, then Retry.';
if (/^HTTP 5/i.test(raw))
return 'The AI service is unavailable — your transcript is kept, please Retry in a moment.';
var short = raw.replace(/^HTTP \d+ /, '').replace(/\{.*\}$/, '').trim();
return short || 'Processing failed — the transcript is kept, Retry when ready.';
}
// True when the session holds nothing analyzable (local view).
function hasCaptured(m) {
return (m.segments && m.segments.length > 0) ||
((m.manual || '').trim().length > 0);
}
function confirmStopEmpty() {
return window.confirm(
'No speech has been captured yet.\n\n' +
'Before closing this take:\n' +
'• check the microphone is allowed (Start ▾ → Test microphone),\n' +
'• or paste a transcript in Transcript → Manual transcript.\n\n' +
'Stop anyway and file an empty diagnostic?');
}
function copyText(t) {
try {
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(t).catch(function () {});
else {
var ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
} catch (e) {}
}
function transcriptText(m) {
var out = [];
(m.segments || []).forEach(function (s) {
out.push('[' + fmtTs(s.startMs) + '] ' + (s.speaker || s.channel) + ' : ' + s.text);
});
if ((m.manual || '').trim()) out.push('(pasted transcript) ' + m.manual.trim());
if ((m.notes || '').trim()) out.push('(notes) ' + m.notes.trim());
return out.join('\n');
}
window.FDMeeting = FDMeeting;
})();