speaker-detector 0.1.5__py3-none-any.whl → 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- speaker_detector/cli.py +12 -26
- speaker_detector/core.py +78 -65
- speaker_detector/model/classifier.ckpt +0 -0
- speaker_detector/model/embedding_model.ckpt +0 -0
- speaker_detector/model/hyperparams.yaml +58 -0
- speaker_detector/model/label_encoder.ckpt +7207 -0
- speaker_detector/model/mean_var_norm_emb.ckpt +0 -0
- speaker_detector/server copy.py +296 -0
- speaker_detector/server.py +82 -0
- speaker_detector/state.py +69 -0
- speaker_detector/web/static/favicon.ico +0 -0
- speaker_detector/web/static/index.html +29 -0
- speaker_detector/web/static/scripts/loader copy.js +10 -0
- speaker_detector/web/static/scripts/loader.js +14 -0
- speaker_detector/web/static/scripts/script copy.js +954 -0
- speaker_detector/web/static/scripts/script.js +22 -0
- speaker_detector/web/static/style.css +133 -0
- {speaker_detector-0.1.5.dist-info → speaker_detector-0.1.6.dist-info}/METADATA +28 -3
- speaker_detector-0.1.6.dist-info/RECORD +25 -0
- {speaker_detector-0.1.5.dist-info → speaker_detector-0.1.6.dist-info}/WHEEL +1 -1
- speaker_detector/analyze.py +0 -59
- speaker_detector/combine.py +0 -22
- speaker_detector/export_embeddings.py +0 -62
- speaker_detector/export_model.py +0 -40
- speaker_detector/generate_summary.py +0 -110
- speaker_detector-0.1.5.dist-info/RECORD +0 -15
- /speaker_detector/{ECAPA_TDNN.py → model/ECAPA_TDNN.py} +0 -0
- /speaker_detector/{__init__.py → web/static/__init__.py} +0 -0
- {speaker_detector-0.1.5.dist-info → speaker_detector-0.1.6.dist-info}/entry_points.txt +0 -0
- {speaker_detector-0.1.5.dist-info → speaker_detector-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,954 @@
|
|
1
|
+
|
2
|
+
console.log("✅ Full script.js loaded");
|
3
|
+
|
4
|
+
let knownSpeakers = [];
|
5
|
+
let meetingMediaRecorder = null;
|
6
|
+
let meetingBlob = null;
|
7
|
+
let meetingId = null;
|
8
|
+
|
9
|
+
window.addEventListener("DOMContentLoaded", () => {
|
10
|
+
setupAccordionUI();
|
11
|
+
setupActions();
|
12
|
+
fetchSpeakers();
|
13
|
+
fetchRecordings();
|
14
|
+
// fetchExports();
|
15
|
+
fetchMeetings();
|
16
|
+
});
|
17
|
+
|
18
|
+
function formatTime(sec) {
|
19
|
+
const m = Math.floor(sec / 60);
|
20
|
+
const s = String(Math.floor(sec % 60)).padStart(2, '0');
|
21
|
+
return `${m}:${s}`;
|
22
|
+
}
|
23
|
+
|
24
|
+
function setupAccordionUI() {
|
25
|
+
document.querySelectorAll('.accordion-step').forEach(step => {
|
26
|
+
step.addEventListener('click', () => {
|
27
|
+
document.querySelectorAll('.accordion-step').forEach(s => s.classList.remove('active'));
|
28
|
+
document.querySelectorAll('.accordion-content').forEach(c => c.classList.remove('active'));
|
29
|
+
step.classList.add('active');
|
30
|
+
document.getElementById(step.dataset.tab).classList.add('active');
|
31
|
+
});
|
32
|
+
});
|
33
|
+
}
|
34
|
+
|
35
|
+
function setupActions() {
|
36
|
+
document.getElementById('action-btn')?.addEventListener('click', runMicTest);
|
37
|
+
document.getElementById('enroll-speaker-btn')?.addEventListener('click', enrollSpeaker);
|
38
|
+
document.getElementById('identify-speaker-btn')?.addEventListener('click', identifySpeaker);
|
39
|
+
document.getElementById('start-meeting')?.addEventListener('click', startMeeting);
|
40
|
+
document.getElementById('stop-meeting')?.addEventListener('click', stopMeeting);
|
41
|
+
document.getElementById('record-bg-btn')?.addEventListener('click', recordBackgroundNoise);
|
42
|
+
|
43
|
+
// ✅ Show mic popup trigger (e.g. from test button or some icon)
|
44
|
+
const micPopup = document.querySelector(".mic-popup");
|
45
|
+
const micStartBtn = document.getElementById("mic-start-btn");
|
46
|
+
const micCancelBtn = document.getElementById("mic-cancel-btn");
|
47
|
+
|
48
|
+
// Just for demo: automatically show popup on start
|
49
|
+
// remove this later and call `micPopup.classList.remove("hidden")` manually as needed
|
50
|
+
|
51
|
+
micStartBtn?.addEventListener("click", () => {
|
52
|
+
alert("🎤 Mic start clicked. Add your recording logic here.");
|
53
|
+
micPopup?.classList.add("hidden");
|
54
|
+
});
|
55
|
+
|
56
|
+
micCancelBtn?.addEventListener("click", () => {
|
57
|
+
micPopup?.classList.add("hidden");
|
58
|
+
});
|
59
|
+
}
|
60
|
+
|
61
|
+
|
62
|
+
function recordBackgroundNoise() {
|
63
|
+
const statusEl = document.getElementById("record-bg-status");
|
64
|
+
statusEl.textContent = "🎙 Recording background noise...";
|
65
|
+
|
66
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
|
67
|
+
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
68
|
+
const chunks = [];
|
69
|
+
|
70
|
+
recorder.ondataavailable = e => chunks.push(e.data);
|
71
|
+
|
72
|
+
recorder.onstop = async () => {
|
73
|
+
const blob = new Blob(chunks, { type: "audio/webm" });
|
74
|
+
const form = new FormData();
|
75
|
+
form.append("audio", blob, `noise_${Date.now()}.webm`);
|
76
|
+
|
77
|
+
try {
|
78
|
+
const res = await fetch("/api/background_noise", { method: "POST", body: form });
|
79
|
+
const data = await res.json();
|
80
|
+
statusEl.textContent = data.success ? "✅ Background noise saved." : `❌ ${data.error}`;
|
81
|
+
} catch (err) {
|
82
|
+
console.error("❌ Upload failed:", err);
|
83
|
+
statusEl.textContent = "❌ Failed to save noise.";
|
84
|
+
}
|
85
|
+
|
86
|
+
stream.getTracks().forEach(t => t.stop());
|
87
|
+
};
|
88
|
+
|
89
|
+
recorder.start();
|
90
|
+
setTimeout(() => recorder.stop(), 3000);
|
91
|
+
});
|
92
|
+
}
|
93
|
+
|
94
|
+
|
95
|
+
function getSpeakerPrompt() {
|
96
|
+
return `
|
97
|
+
Please read the following aloud for speaker enrollment:
|
98
|
+
"The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet, offering a rich variety of sounds. Speak naturally, with your normal tone and pace."
|
99
|
+
`.trim();
|
100
|
+
}
|
101
|
+
|
102
|
+
|
103
|
+
function runMicTest() {
|
104
|
+
const resultEl = document.getElementById('identify-result');
|
105
|
+
resultEl.innerHTML = "Testing microphone...";
|
106
|
+
|
107
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
|
108
|
+
const canvas = document.querySelector('.accordion-content.active .visualizer');
|
109
|
+
if (canvas) setupVisualizer(stream, canvas);
|
110
|
+
|
111
|
+
const recorder = new MediaRecorder(stream);
|
112
|
+
const chunks = [];
|
113
|
+
|
114
|
+
recorder.ondataavailable = e => chunks.push(e.data);
|
115
|
+
|
116
|
+
recorder.onstop = () => {
|
117
|
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
118
|
+
const url = URL.createObjectURL(blob);
|
119
|
+
|
120
|
+
resultEl.innerHTML = `✅ Mic test successful.<br><audio controls src="${url}"></audio>`;
|
121
|
+
|
122
|
+
const micTestStatus = document.getElementById('mic-test-status');
|
123
|
+
if (micTestStatus) micTestStatus.textContent = "✅ Passed";
|
124
|
+
|
125
|
+
stream.getTracks().forEach(t => t.stop());
|
126
|
+
};
|
127
|
+
|
128
|
+
recorder.start();
|
129
|
+
setTimeout(() => recorder.stop(), 3000);
|
130
|
+
});
|
131
|
+
}
|
132
|
+
|
133
|
+
|
134
|
+
function setupVisualizer(stream, canvas) {
|
135
|
+
const audioCtx = new AudioContext();
|
136
|
+
const analyser = audioCtx.createAnalyser();
|
137
|
+
const source = audioCtx.createMediaStreamSource(stream);
|
138
|
+
source.connect(analyser);
|
139
|
+
|
140
|
+
const canvasCtx = canvas.getContext('2d');
|
141
|
+
analyser.fftSize = 2048;
|
142
|
+
const bufferLength = analyser.frequencyBinCount;
|
143
|
+
const dataArray = new Uint8Array(bufferLength);
|
144
|
+
|
145
|
+
function draw() {
|
146
|
+
requestAnimationFrame(draw);
|
147
|
+
analyser.getByteTimeDomainData(dataArray);
|
148
|
+
|
149
|
+
canvasCtx.fillStyle = '#111';
|
150
|
+
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
|
151
|
+
|
152
|
+
canvasCtx.lineWidth = 2;
|
153
|
+
canvasCtx.strokeStyle = 'lime';
|
154
|
+
canvasCtx.beginPath();
|
155
|
+
|
156
|
+
const sliceWidth = canvas.width / bufferLength;
|
157
|
+
let x = 0;
|
158
|
+
|
159
|
+
for (let i = 0; i < bufferLength; i++) {
|
160
|
+
const v = dataArray[i] / 128.0;
|
161
|
+
const y = (v * canvas.height) / 2;
|
162
|
+
if (i === 0) canvasCtx.moveTo(x, y);
|
163
|
+
else canvasCtx.lineTo(x, y);
|
164
|
+
x += sliceWidth;
|
165
|
+
}
|
166
|
+
|
167
|
+
canvasCtx.lineTo(canvas.width, canvas.height / 2);
|
168
|
+
canvasCtx.stroke();
|
169
|
+
}
|
170
|
+
|
171
|
+
draw();
|
172
|
+
}
|
173
|
+
|
174
|
+
function enrollSpeaker() {
|
175
|
+
const id = document.getElementById("speaker-id").value.trim();
|
176
|
+
if (!id) return alert("Please enter speaker ID");
|
177
|
+
|
178
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
|
179
|
+
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
180
|
+
const chunks = [];
|
181
|
+
|
182
|
+
recorder.ondataavailable = e => chunks.push(e.data);
|
183
|
+
|
184
|
+
recorder.onstop = async () => {
|
185
|
+
const blob = new Blob(chunks);
|
186
|
+
const url = URL.createObjectURL(blob);
|
187
|
+
|
188
|
+
// ✅ Show preview UI
|
189
|
+
const previewDiv = document.createElement("div");
|
190
|
+
previewDiv.innerHTML = `
|
191
|
+
<h4>🎧 Preview your enrollment for "${id}"</h4>
|
192
|
+
<audio controls src="${url}"></audio>
|
193
|
+
<button id="confirm-enroll-btn">✅ Confirm Enrollment</button>
|
194
|
+
<button id="discard-enroll-btn">❌ Discard</button>
|
195
|
+
`;
|
196
|
+
document.body.appendChild(previewDiv);
|
197
|
+
|
198
|
+
document.getElementById("confirm-enroll-btn").onclick = async () => {
|
199
|
+
const form = new FormData();
|
200
|
+
form.append("file", blob, `enroll_${Date.now()}.webm`);
|
201
|
+
|
202
|
+
const res = await fetch(`/api/enroll/${encodeURIComponent(id)}`, {
|
203
|
+
method: "POST",
|
204
|
+
body: form
|
205
|
+
});
|
206
|
+
|
207
|
+
const data = await res.json();
|
208
|
+
if (data.status === "enrolled") {
|
209
|
+
alert(`✅ Enrolled "${id}".`);
|
210
|
+
fetchSpeakers();
|
211
|
+
} else {
|
212
|
+
alert(`❌ Enroll failed: ${data.error}`);
|
213
|
+
}
|
214
|
+
|
215
|
+
previewDiv.remove();
|
216
|
+
};
|
217
|
+
|
218
|
+
document.getElementById("discard-enroll-btn").onclick = () => {
|
219
|
+
alert("🚫 Discarded recording.");
|
220
|
+
previewDiv.remove();
|
221
|
+
};
|
222
|
+
|
223
|
+
stream.getTracks().forEach(t => t.stop());
|
224
|
+
};
|
225
|
+
|
226
|
+
recorder.start();
|
227
|
+
alert("🎙️ Recording for 20 seconds. Please read the provided text aloud...");
|
228
|
+
setTimeout(() => recorder.stop(), 20000);
|
229
|
+
});
|
230
|
+
}
|
231
|
+
|
232
|
+
function renameSpeaker(oldName) {
|
233
|
+
const newName = prompt(`Rename "${oldName}" to:`, oldName);
|
234
|
+
if (!newName || newName === oldName) return;
|
235
|
+
|
236
|
+
fetch(`/api/speakers/rename`, {
|
237
|
+
method: "POST",
|
238
|
+
headers: { "Content-Type": "application/json" },
|
239
|
+
body: JSON.stringify({ oldName, newName })
|
240
|
+
})
|
241
|
+
.then(res => res.json())
|
242
|
+
.then(data => {
|
243
|
+
if (data.success) {
|
244
|
+
alert(`✅ Renamed to "${newName}".`);
|
245
|
+
fetchSpeakers();
|
246
|
+
} else {
|
247
|
+
alert(`❌ Rename failed: ${data.error}`);
|
248
|
+
}
|
249
|
+
});
|
250
|
+
}
|
251
|
+
|
252
|
+
function deleteSpeaker(speakerId) {
|
253
|
+
if (!confirm(`Are you sure you want to delete "${speakerId}"?`)) return;
|
254
|
+
|
255
|
+
fetch(`/api/speakers/${encodeURIComponent(speakerId)}`, { method: "DELETE" })
|
256
|
+
.then(res => res.json())
|
257
|
+
.then(data => {
|
258
|
+
if (data.deleted) {
|
259
|
+
alert(`🗑️ Deleted "${speakerId}"`);
|
260
|
+
fetchSpeakers();
|
261
|
+
} else {
|
262
|
+
alert(`❌ Delete failed: ${data.error}`);
|
263
|
+
}
|
264
|
+
});
|
265
|
+
}
|
266
|
+
|
267
|
+
function improveSpeaker(speakerId) {
|
268
|
+
showMicOverlay({
|
269
|
+
title: `🔁 Improve Speaker: "${speakerId}"`,
|
270
|
+
message: getSpeakerPrompt(),
|
271
|
+
countdownSeconds: 5,
|
272
|
+
onStop: (blob) => {
|
273
|
+
const url = URL.createObjectURL(blob);
|
274
|
+
const overlayContent = document.querySelector(".overlay-content");
|
275
|
+
|
276
|
+
overlayContent.innerHTML = `
|
277
|
+
<h3>✅ Sample Ready for "${speakerId}"</h3>
|
278
|
+
<audio controls src="${url}"></audio>
|
279
|
+
<p>Preview your voice sample. If you're happy, click Upload:</p>
|
280
|
+
<button id="confirm-improve-btn">✅ Upload</button>
|
281
|
+
<button id="discard-improve-btn">❌ Discard</button>
|
282
|
+
`;
|
283
|
+
|
284
|
+
document.getElementById("confirm-improve-btn").onclick = async () => {
|
285
|
+
const form = new FormData();
|
286
|
+
form.append("file", blob, `improve_${Date.now()}.webm`);
|
287
|
+
|
288
|
+
const res = await fetch(`/api/speakers/${encodeURIComponent(speakerId)}/improve`, {
|
289
|
+
method: "POST",
|
290
|
+
body: form,
|
291
|
+
});
|
292
|
+
|
293
|
+
const data = await res.json();
|
294
|
+
if (data.status === "improved") {
|
295
|
+
alert(`✅ Improved recording added to "${speakerId}".`);
|
296
|
+
} else {
|
297
|
+
alert(`❌ Improve failed: ${data.error}`);
|
298
|
+
}
|
299
|
+
|
300
|
+
closeMicOverlay();
|
301
|
+
};
|
302
|
+
|
303
|
+
document.getElementById("discard-improve-btn").onclick = () => {
|
304
|
+
alert("🚫 Discarded recording.");
|
305
|
+
closeMicOverlay();
|
306
|
+
};
|
307
|
+
},
|
308
|
+
onStreamReady: (stream, stopRecorder) => {
|
309
|
+
const countdownEl = document.querySelector("#mic-countdown");
|
310
|
+
let duration = 10;
|
311
|
+
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
312
|
+
const chunks = [];
|
313
|
+
|
314
|
+
recorder.ondataavailable = (e) => chunks.push(e.data);
|
315
|
+
recorder.onstop = () => stopRecorder(new Blob(chunks, { type: "audio/webm" }));
|
316
|
+
|
317
|
+
recorder.start();
|
318
|
+
|
319
|
+
const interval = setInterval(() => {
|
320
|
+
countdownEl.textContent = `🎙️ Recording... ${duration--}s left`;
|
321
|
+
if (duration < 0) {
|
322
|
+
clearInterval(interval);
|
323
|
+
recorder.stop();
|
324
|
+
}
|
325
|
+
}, 1000);
|
326
|
+
},
|
327
|
+
});
|
328
|
+
}
|
329
|
+
|
330
|
+
async function identifySpeaker() {
|
331
|
+
const resultEl = document.getElementById('identify-result-step-3') || document.getElementById('identify-result');
|
332
|
+
const canvas = document.querySelector('#step-3 .visualizer');
|
333
|
+
const promptText = getSpeakerPrompt();
|
334
|
+
|
335
|
+
// Show prompt and prepare UI
|
336
|
+
resultEl.innerHTML = `
|
337
|
+
<p>${promptText}</p>
|
338
|
+
<p>🎙️ Preparing to record for speaker identification...</p>
|
339
|
+
`;
|
340
|
+
|
341
|
+
try {
|
342
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
343
|
+
if (canvas) setupVisualizer(stream, canvas);
|
344
|
+
|
345
|
+
// Countdown
|
346
|
+
const countdownEl = document.createElement('div');
|
347
|
+
countdownEl.textContent = "Recording will start in 3...";
|
348
|
+
resultEl.appendChild(countdownEl);
|
349
|
+
|
350
|
+
await delayCountdown(countdownEl, 3);
|
351
|
+
|
352
|
+
// Recording for 5s (you can adjust this)
|
353
|
+
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
354
|
+
const chunks = [];
|
355
|
+
|
356
|
+
recorder.ondataavailable = e => chunks.push(e.data);
|
357
|
+
|
358
|
+
recorder.onstop = async () => {
|
359
|
+
const blob = new Blob(chunks);
|
360
|
+
const url = URL.createObjectURL(blob);
|
361
|
+
|
362
|
+
// Send to backend
|
363
|
+
resultEl.innerHTML = `<p>⏳ Sending to backend...</p>`;
|
364
|
+
const form = new FormData();
|
365
|
+
form.append("file", blob, "identify.webm");
|
366
|
+
|
367
|
+
try {
|
368
|
+
const res = await fetch("/api/identify", { method: "POST", body: form });
|
369
|
+
const { speaker, score, error } = await res.json();
|
370
|
+
|
371
|
+
resultEl.innerHTML = error
|
372
|
+
? `❌ ${error}`
|
373
|
+
: `🗣️ Speaker: <strong class="segment-speaker">${speaker}</strong> (score: ${score})<br><audio controls src="${url}"></audio>`;
|
374
|
+
|
375
|
+
// Correction button
|
376
|
+
const feedbackBtn = document.createElement("button");
|
377
|
+
feedbackBtn.textContent = "✏️ Correct Speaker";
|
378
|
+
feedbackBtn.style.marginLeft = "10px";
|
379
|
+
feedbackBtn.onclick = () => {
|
380
|
+
showCorrectionUI(blob, resultEl);
|
381
|
+
};
|
382
|
+
resultEl.appendChild(feedbackBtn);
|
383
|
+
|
384
|
+
} catch (err) {
|
385
|
+
console.error(err);
|
386
|
+
resultEl.innerHTML = `❌ Failed to identify speaker.`;
|
387
|
+
}
|
388
|
+
|
389
|
+
stream.getTracks().forEach(t => t.stop());
|
390
|
+
};
|
391
|
+
|
392
|
+
recorder.start();
|
393
|
+
countdownEl.textContent = "🎙️ Recording... Speak now.";
|
394
|
+
setTimeout(() => recorder.stop(), 5000);
|
395
|
+
|
396
|
+
} catch (err) {
|
397
|
+
console.error(err);
|
398
|
+
resultEl.innerHTML = "❌ Failed to access microphone.";
|
399
|
+
}
|
400
|
+
}
|
401
|
+
|
402
|
+
// Utility to delay with countdown
|
403
|
+
function delayCountdown(el, seconds) {
|
404
|
+
return new Promise(resolve => {
|
405
|
+
let count = seconds;
|
406
|
+
const interval = setInterval(() => {
|
407
|
+
el.textContent = `Recording will start in ${count}...`;
|
408
|
+
count--;
|
409
|
+
if (count < 0) {
|
410
|
+
clearInterval(interval);
|
411
|
+
resolve();
|
412
|
+
}
|
413
|
+
}, 1000);
|
414
|
+
});
|
415
|
+
}
|
416
|
+
|
417
|
+
// Shared correction UI
|
418
|
+
function showCorrectionUI(blob, container) {
|
419
|
+
const wrapper = document.createElement("div");
|
420
|
+
wrapper.style.marginTop = "0.5rem";
|
421
|
+
|
422
|
+
const label = document.createElement("label");
|
423
|
+
label.textContent = "Correct speaker: ";
|
424
|
+
|
425
|
+
const input = document.createElement("input");
|
426
|
+
input.placeholder = "e.g. Lara or new...";
|
427
|
+
input.style.width = "200px";
|
428
|
+
|
429
|
+
const confirmBtn = document.createElement("button");
|
430
|
+
confirmBtn.textContent = "✅ Confirm";
|
431
|
+
confirmBtn.style.marginLeft = "0.5rem";
|
432
|
+
|
433
|
+
confirmBtn.onclick = async () => {
|
434
|
+
const correctedName = input.value.trim();
|
435
|
+
if (!correctedName) return alert("Please enter a name.");
|
436
|
+
|
437
|
+
const uploadForm = new FormData();
|
438
|
+
uploadForm.append("file", blob, `identify_${Date.now()}.webm`);
|
439
|
+
|
440
|
+
const res = await fetch(`/api/enroll/${encodeURIComponent(correctedName)}`, {
|
441
|
+
method: "POST",
|
442
|
+
body: uploadForm,
|
443
|
+
});
|
444
|
+
|
445
|
+
const data = await res.json();
|
446
|
+
if (data.status === "enrolled") {
|
447
|
+
alert(`✅ Reclassified and enrolled as "${correctedName}".`);
|
448
|
+
fetchSpeakers();
|
449
|
+
} else {
|
450
|
+
alert("❌ Correction failed.");
|
451
|
+
}
|
452
|
+
};
|
453
|
+
|
454
|
+
wrapper.appendChild(label);
|
455
|
+
wrapper.appendChild(input);
|
456
|
+
wrapper.appendChild(confirmBtn);
|
457
|
+
container.appendChild(wrapper);
|
458
|
+
}
|
459
|
+
|
460
|
+
// function exportSpeakersJSON() {
|
461
|
+
// fetch("/api/export-speakers-json", { method: "POST" }).then(res => {
|
462
|
+
// document.getElementById("export-json-status").textContent = res.ok ? "✅ Combined & Exported" : "❌ Failed";
|
463
|
+
// fetchExports();
|
464
|
+
// });
|
465
|
+
// }
|
466
|
+
|
467
|
+
function fetchSpeakers() {
|
468
|
+
fetch("/api/speakers")
|
469
|
+
.then(res => res.json())
|
470
|
+
.then(async (data) => {
|
471
|
+
const list = document.getElementById("speakers-list");
|
472
|
+
if (!list) return;
|
473
|
+
|
474
|
+
if (!Array.isArray(data) || data.length === 0) {
|
475
|
+
list.innerHTML = "<li><em>No speakers enrolled.</em></li>";
|
476
|
+
return;
|
477
|
+
}
|
478
|
+
|
479
|
+
// Get list of speakers needing rebuild
|
480
|
+
let needsRebuild = [];
|
481
|
+
try {
|
482
|
+
const res = await fetch("/api/speakers/needs-rebuild");
|
483
|
+
const result = await res.json();
|
484
|
+
needsRebuild = result.toRebuild || [];
|
485
|
+
} catch {
|
486
|
+
console.warn("⚠️ Could not fetch rebuild info");
|
487
|
+
}
|
488
|
+
|
489
|
+
list.innerHTML = "";
|
490
|
+
|
491
|
+
data.forEach(({ name, recordings }) => {
|
492
|
+
const li = document.createElement("li");
|
493
|
+
const needsUpdate = needsRebuild.includes(name);
|
494
|
+
|
495
|
+
li.innerHTML = `
|
496
|
+
<strong>${name}</strong> (${recordings} recording${recordings !== 1 ? "s" : ""})
|
497
|
+
${needsUpdate ? '<span style="color: #0ff;"> 🔁 Needs rebuild</span>' : ""}
|
498
|
+
<button onclick="renameSpeaker('${name}')">✏️ Rename</button>
|
499
|
+
<button onclick="deleteSpeaker('${name}')">🗑️ Delete</button>
|
500
|
+
<button onclick="improveSpeaker('${name}')">🔁 Improve</button>
|
501
|
+
${
|
502
|
+
needsUpdate
|
503
|
+
? `<button onclick="rebuildSpeaker('${name}')" style="margin-left: 6px;">⚙️ Rebuild</button>`
|
504
|
+
: ""
|
505
|
+
}
|
506
|
+
`;
|
507
|
+
|
508
|
+
list.appendChild(li);
|
509
|
+
});
|
510
|
+
})
|
511
|
+
.catch(err => {
|
512
|
+
console.error("❌ Failed to fetch speakers:", err);
|
513
|
+
const list = document.getElementById("speakers-list");
|
514
|
+
if (list) {
|
515
|
+
list.innerHTML = "<li><em>Error loading speakers.</em></li>";
|
516
|
+
}
|
517
|
+
});
|
518
|
+
}
|
519
|
+
|
520
|
+
function showMicOverlay({ title, message, countdownSeconds = 3, onStop, onStreamReady }) {
|
521
|
+
// Create overlay
|
522
|
+
const overlay = document.createElement("div");
|
523
|
+
overlay.className = "mic-overlay";
|
524
|
+
overlay.innerHTML = `
|
525
|
+
<div class="overlay-content">
|
526
|
+
<h2>${title}</h2>
|
527
|
+
<p>${message}</p>
|
528
|
+
<p id="mic-countdown">⏳ Starting in ${countdownSeconds}...</p>
|
529
|
+
<button id="cancel-overlay">❌ Cancel</button>
|
530
|
+
</div>
|
531
|
+
`;
|
532
|
+
document.body.appendChild(overlay);
|
533
|
+
|
534
|
+
document.getElementById("cancel-overlay").onclick = () => {
|
535
|
+
overlay.remove();
|
536
|
+
};
|
537
|
+
|
538
|
+
// Request microphone access and prepare recorder
|
539
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
540
|
+
const countdownEl = document.getElementById("mic-countdown");
|
541
|
+
let count = countdownSeconds;
|
542
|
+
|
543
|
+
const interval = setInterval(() => {
|
544
|
+
countdownEl.textContent = `⏳ Starting in ${count--}...`;
|
545
|
+
if (count < 0) {
|
546
|
+
clearInterval(interval);
|
547
|
+
countdownEl.textContent = "🎙️ Recording...";
|
548
|
+
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
549
|
+
const chunks = [];
|
550
|
+
|
551
|
+
recorder.ondataavailable = (e) => chunks.push(e.data);
|
552
|
+
|
553
|
+
recorder.onstop = () => {
|
554
|
+
const blob = new Blob(chunks, { type: "audio/webm" });
|
555
|
+
onStop?.(blob);
|
556
|
+
};
|
557
|
+
|
558
|
+
onStreamReady?.(stream, () => recorder.stop());
|
559
|
+
|
560
|
+
recorder.start();
|
561
|
+
|
562
|
+
// Default 20s stop if no manual trigger
|
563
|
+
setTimeout(() => {
|
564
|
+
if (recorder.state === "recording") recorder.stop();
|
565
|
+
}, 20000);
|
566
|
+
}
|
567
|
+
}, 1000);
|
568
|
+
});
|
569
|
+
}
|
570
|
+
|
571
|
+
function closeMicOverlay() {
|
572
|
+
document.querySelector(".mic-overlay")?.remove();
|
573
|
+
}
|
574
|
+
|
575
|
+
function rebuildSpeaker(name) {
|
576
|
+
const list = document.getElementById("speakers-list");
|
577
|
+
const button = [...list.querySelectorAll("button")]
|
578
|
+
.find(btn => btn.textContent.includes("Rebuild") && btn.onclick?.toString().includes(name));
|
579
|
+
const tag = [...list.querySelectorAll("span")]
|
580
|
+
.find(span => span.textContent.includes("Needs rebuild") && span.parentElement?.textContent.includes(name));
|
581
|
+
|
582
|
+
if (!confirm(`Rebuild embedding for "${name}"?`)) return;
|
583
|
+
|
584
|
+
if (button) {
|
585
|
+
button.disabled = true;
|
586
|
+
button.innerHTML = `⚙️ Rebuilding <span class="spinner"></span>`;
|
587
|
+
}
|
588
|
+
if (tag) {
|
589
|
+
tag.textContent = "🔁 Rebuilding...";
|
590
|
+
tag.style.color = "gray";
|
591
|
+
tag.style.opacity = "0.7";
|
592
|
+
}
|
593
|
+
|
594
|
+
fetch(`/api/rebuild/${encodeURIComponent(name)}`, { method: "POST" })
|
595
|
+
.then(res => res.json())
|
596
|
+
.then(data => {
|
597
|
+
if (data.status === "rebuilt") {
|
598
|
+
alert(`✅ Rebuilt embedding for "${data.name}".`);
|
599
|
+
} else {
|
600
|
+
alert(`❌ Failed to rebuild: ${data.error || JSON.stringify(data)}`);
|
601
|
+
}
|
602
|
+
})
|
603
|
+
.catch(err => {
|
604
|
+
alert(`❌ Error: ${err.message}`);
|
605
|
+
})
|
606
|
+
.finally(() => {
|
607
|
+
if (button) {
|
608
|
+
button.disabled = false;
|
609
|
+
button.textContent = "⚙️ Rebuild";
|
610
|
+
}
|
611
|
+
fetchSpeakers(); // Refresh status
|
612
|
+
});
|
613
|
+
}
|
614
|
+
|
615
|
+
function improveSpeaker(speakerId) {
|
616
|
+
showMicOverlay({
|
617
|
+
title: `🔁 Improve Speaker: "${speakerId}"`,
|
618
|
+
message: getSpeakerPrompt(),
|
619
|
+
countdownSeconds: 20,
|
620
|
+
onStop: (blob) => {
|
621
|
+
const url = URL.createObjectURL(blob);
|
622
|
+
|
623
|
+
const previewDiv = document.createElement("div");
|
624
|
+
previewDiv.classList.add("overlay-content");
|
625
|
+
previewDiv.innerHTML = `
|
626
|
+
<h4>🎧 Preview your improved recording for "${speakerId}"</h4>
|
627
|
+
<audio controls src="${url}"></audio>
|
628
|
+
<button id="confirm-improve-btn">✅ Confirm Upload</button>
|
629
|
+
<button id="discard-improve-btn">❌ Discard</button>
|
630
|
+
`;
|
631
|
+
document.body.appendChild(previewDiv);
|
632
|
+
|
633
|
+
document.getElementById("confirm-improve-btn").onclick = async () => {
|
634
|
+
const form = new FormData();
|
635
|
+
form.append("file", blob, `improve_${Date.now()}.webm`);
|
636
|
+
|
637
|
+
const res = await fetch(`/api/speakers/${encodeURIComponent(speakerId)}/improve`, {
|
638
|
+
method: "POST",
|
639
|
+
body: form,
|
640
|
+
});
|
641
|
+
|
642
|
+
const data = await res.json();
|
643
|
+
if (data.status === "improved") {
|
644
|
+
alert(`✅ Improved recording added to "${speakerId}".`);
|
645
|
+
} else {
|
646
|
+
alert(`❌ Improve failed: ${data.error}`);
|
647
|
+
}
|
648
|
+
|
649
|
+
previewDiv.remove();
|
650
|
+
closeMicOverlay();
|
651
|
+
};
|
652
|
+
|
653
|
+
document.getElementById("discard-improve-btn").onclick = () => {
|
654
|
+
alert("🚫 Discarded recording.");
|
655
|
+
previewDiv.remove();
|
656
|
+
closeMicOverlay();
|
657
|
+
};
|
658
|
+
},
|
659
|
+
});
|
660
|
+
}
|
661
|
+
|
662
|
+
function renameSpeaker(id) {
|
663
|
+
const newName = prompt(`Rename speaker "${id}" to:`);
|
664
|
+
if (!newName || newName === id) return;
|
665
|
+
|
666
|
+
fetch(`/api/speakers/rename`, {
|
667
|
+
method: "POST",
|
668
|
+
headers: { "Content-Type": "application/json" },
|
669
|
+
body: JSON.stringify({ oldName: id, newName }),
|
670
|
+
})
|
671
|
+
.then(res => res.json())
|
672
|
+
.then(data => {
|
673
|
+
if (data.status === "renamed") {
|
674
|
+
alert(`✅ Renamed to ${data.to}`);
|
675
|
+
fetchSpeakers();
|
676
|
+
} else {
|
677
|
+
alert(`❌ Failed: ${data.error}`);
|
678
|
+
}
|
679
|
+
});
|
680
|
+
}
|
681
|
+
|
682
|
+
function deleteSpeaker(id) {
|
683
|
+
if (!confirm(`Delete speaker "${id}"?`)) return;
|
684
|
+
|
685
|
+
fetch(`/api/speakers/${encodeURIComponent(id)}`, { method: "DELETE" })
|
686
|
+
.then(res => res.json())
|
687
|
+
.then(data => {
|
688
|
+
if (data.deleted) {
|
689
|
+
alert(`✅ Deleted "${id}"`);
|
690
|
+
fetchSpeakers();
|
691
|
+
} else {
|
692
|
+
alert(`❌ Failed`);
|
693
|
+
}
|
694
|
+
});
|
695
|
+
}
|
696
|
+
|
697
|
+
function downloadExport(filename) {
|
698
|
+
const link = document.createElement('a');
|
699
|
+
link.href = `/exports/${filename}`;
|
700
|
+
link.download = filename;
|
701
|
+
link.click();
|
702
|
+
}
|
703
|
+
|
704
|
+
function deleteExport(filename) {
|
705
|
+
if (!confirm(`Delete ${filename}?`)) return;
|
706
|
+
|
707
|
+
fetch(`/api/delete-export/${encodeURIComponent(filename)}`, { method: 'DELETE' })
|
708
|
+
.then(res => res.json())
|
709
|
+
.then(data => {
|
710
|
+
if (data.deleted) {
|
711
|
+
alert(`✅ Deleted ${filename}`);
|
712
|
+
fetchExports();
|
713
|
+
} else {
|
714
|
+
alert(`❌ Failed: ${data.error}`);
|
715
|
+
}
|
716
|
+
});
|
717
|
+
}
|
718
|
+
|
719
|
+
function startMeeting() {
|
720
|
+
const startBtn = document.getElementById("start-meeting");
|
721
|
+
const stopBtn = document.getElementById("stop-meeting");
|
722
|
+
const statusEl = document.getElementById("meeting-status");
|
723
|
+
const speakerEl = document.getElementById("speaker-label");
|
724
|
+
const timelineEl = document.getElementById("timeline");
|
725
|
+
|
726
|
+
meetingId = new Date().toISOString().replace(/[:.]/g, "-");
|
727
|
+
meetingBlob = null;
|
728
|
+
|
729
|
+
startBtn.disabled = true;
|
730
|
+
stopBtn.disabled = false;
|
731
|
+
statusEl.textContent = "Status: Preparing recording...";
|
732
|
+
statusEl.style.color = "red";
|
733
|
+
speakerEl.textContent = "Current speaker: —";
|
734
|
+
timelineEl.innerHTML = "<em>🎧 Listening...</em>";
|
735
|
+
|
736
|
+
showMicOverlay({
|
737
|
+
title: "🎙️ Meeting Mode",
|
738
|
+
message: "Capturing meeting audio... Meeting mode will continue until you stop it manually.",
|
739
|
+
countdownSeconds: 3,
|
740
|
+
onStop: (blob) => {
|
741
|
+
// This function will be called when stopMeeting is invoked
|
742
|
+
},
|
743
|
+
onStreamReady: (stream, stopOverlayRecording) => {
|
744
|
+
meetingMediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
745
|
+
|
746
|
+
meetingMediaRecorder.ondataavailable = (e) => {
|
747
|
+
if (e.data && e.data.size > 0) meetingBlob = e.data;
|
748
|
+
};
|
749
|
+
|
750
|
+
meetingMediaRecorder.onstop = async () => {
|
751
|
+
stopBtn.disabled = true;
|
752
|
+
startBtn.disabled = false;
|
753
|
+
statusEl.textContent = "Status: Recording stopped.";
|
754
|
+
statusEl.style.color = "";
|
755
|
+
|
756
|
+
if (meetingBlob) {
|
757
|
+
statusEl.textContent = "⏳ Uploading and processing...";
|
758
|
+
const formData = new FormData();
|
759
|
+
formData.append("file", meetingBlob, `${meetingId}.webm`);
|
760
|
+
formData.append("meeting_id", meetingId);
|
761
|
+
|
762
|
+
try {
|
763
|
+
await fetch("/api/save-chunk", { method: "POST", body: formData });
|
764
|
+
fetchMeetings();
|
765
|
+
statusEl.textContent = "✅ Meeting saved.";
|
766
|
+
} catch (err) {
|
767
|
+
console.error("❌ Failed to save meeting:", err);
|
768
|
+
statusEl.textContent = "❌ Failed to save meeting.";
|
769
|
+
} finally {
|
770
|
+
closeMicOverlay();
|
771
|
+
}
|
772
|
+
}
|
773
|
+
};
|
774
|
+
|
775
|
+
meetingMediaRecorder.start();
|
776
|
+
statusEl.textContent = "🔴 Recording meeting...";
|
777
|
+
},
|
778
|
+
});
|
779
|
+
}
|
780
|
+
|
781
|
+
function stopMeeting() {
|
782
|
+
if (meetingMediaRecorder?.state === "recording") {
|
783
|
+
meetingMediaRecorder.stop();
|
784
|
+
}
|
785
|
+
}
|
786
|
+
|
787
|
+
async function generateSummaryFor(meetingId) {
|
788
|
+
const timelineEl = document.getElementById("timeline");
|
789
|
+
timelineEl.innerHTML = "<strong>📄 Loading summary...</strong>";
|
790
|
+
|
791
|
+
try {
|
792
|
+
const res = await fetch(`/api/generate-summary/${meetingId}`);
|
793
|
+
const data = await res.json();
|
794
|
+
|
795
|
+
timelineEl.innerHTML = "<strong>📄 Meeting Summary:</strong>";
|
796
|
+
|
797
|
+
if (data.transcript) {
|
798
|
+
const pre = document.createElement("pre");
|
799
|
+
pre.textContent = data.transcript;
|
800
|
+
pre.style.marginBottom = "1rem";
|
801
|
+
timelineEl.appendChild(pre);
|
802
|
+
}
|
803
|
+
|
804
|
+
if (Array.isArray(data.segments)) {
|
805
|
+
data.segments.forEach((seg) => {
|
806
|
+
const div = document.createElement("div");
|
807
|
+
div.className = "segment-block";
|
808
|
+
div.innerHTML = `
|
809
|
+
<div class="segment-meta">
|
810
|
+
<span class="segment-time">[${formatTime(seg.start)}–${formatTime(seg.end)}]</span>
|
811
|
+
<span class="segment-speaker">${seg.speaker}</span>
|
812
|
+
<span class="segment-score">(${(seg.score ?? 0).toFixed(2)})</span>
|
813
|
+
</div>
|
814
|
+
<blockquote class="segment-text">${seg.text}</blockquote>
|
815
|
+
`;
|
816
|
+
|
817
|
+
const feedbackBtn = document.createElement("button");
|
818
|
+
feedbackBtn.textContent = "✏️ Correct Speaker";
|
819
|
+
feedbackBtn.style.marginLeft = "10px";
|
820
|
+
|
821
|
+
feedbackBtn.onclick = () => {
|
822
|
+
const wrapper = document.createElement("div");
|
823
|
+
wrapper.style.marginTop = "0.5rem";
|
824
|
+
|
825
|
+
const label = document.createElement("label");
|
826
|
+
label.textContent = "Correct speaker: ";
|
827
|
+
label.style.marginRight = "0.5rem";
|
828
|
+
|
829
|
+
const input = document.createElement("input");
|
830
|
+
input.setAttribute("list", "speaker-options");
|
831
|
+
input.placeholder = "e.g. Lara or new...";
|
832
|
+
input.style.width = "200px";
|
833
|
+
|
834
|
+
const dataList = document.createElement("datalist");
|
835
|
+
dataList.id = "speaker-options";
|
836
|
+
knownSpeakers.forEach((name) => {
|
837
|
+
const opt = document.createElement("option");
|
838
|
+
opt.value = name;
|
839
|
+
dataList.appendChild(opt);
|
840
|
+
});
|
841
|
+
|
842
|
+
const confirmBtn = document.createElement("button");
|
843
|
+
confirmBtn.textContent = "✅ Confirm";
|
844
|
+
confirmBtn.style.marginLeft = "0.5rem";
|
845
|
+
|
846
|
+
confirmBtn.onclick = async () => {
|
847
|
+
const correctedName = input.value.trim();
|
848
|
+
if (!correctedName) return alert("Please enter a name.");
|
849
|
+
|
850
|
+
feedbackBtn.disabled = true;
|
851
|
+
confirmBtn.disabled = true;
|
852
|
+
confirmBtn.textContent = "⏳ Correcting...";
|
853
|
+
|
854
|
+
try {
|
855
|
+
const payload = {
|
856
|
+
old_speaker: seg.speaker,
|
857
|
+
correct_speaker: correctedName,
|
858
|
+
filename: seg.filename || "" // Ensure filename is returned by backend
|
859
|
+
};
|
860
|
+
|
861
|
+
const res = await fetch("/api/correct-segment", {
|
862
|
+
method: "POST",
|
863
|
+
headers: { "Content-Type": "application/json" },
|
864
|
+
body: JSON.stringify(payload),
|
865
|
+
});
|
866
|
+
|
867
|
+
if (res.ok) {
|
868
|
+
div.querySelector(".segment-speaker").textContent = correctedName;
|
869
|
+
alert(`✅ Reclassified to ${correctedName}`);
|
870
|
+
fetchSpeakers();
|
871
|
+
} else {
|
872
|
+
alert("❌ Correction failed.");
|
873
|
+
}
|
874
|
+
} catch (err) {
|
875
|
+
alert(`❌ Error: ${err}`);
|
876
|
+
} finally {
|
877
|
+
wrapper.remove();
|
878
|
+
}
|
879
|
+
};
|
880
|
+
|
881
|
+
wrapper.appendChild(label);
|
882
|
+
wrapper.appendChild(input);
|
883
|
+
wrapper.appendChild(dataList);
|
884
|
+
wrapper.appendChild(confirmBtn);
|
885
|
+
div.appendChild(wrapper);
|
886
|
+
};
|
887
|
+
|
888
|
+
div.appendChild(feedbackBtn);
|
889
|
+
timelineEl.appendChild(div);
|
890
|
+
});
|
891
|
+
} else {
|
892
|
+
timelineEl.innerHTML += "<p><em>No segments found.</em></p>";
|
893
|
+
}
|
894
|
+
} catch (err) {
|
895
|
+
console.error("❌ Failed to generate summary:", err);
|
896
|
+
timelineEl.innerHTML = "<strong>❌ Failed to load summary.</strong>";
|
897
|
+
}
|
898
|
+
}
|
899
|
+
|
900
|
+
function deleteMeeting(meetingId) {
|
901
|
+
if (!confirm(`Delete meeting: ${meetingId}?`)) return;
|
902
|
+
|
903
|
+
fetch(`/api/delete-meeting/${meetingId}`, { method: "DELETE" })
|
904
|
+
.then((res) => res.json())
|
905
|
+
.then((data) => {
|
906
|
+
if (data.deleted) {
|
907
|
+
alert(`✅ Deleted ${meetingId}`);
|
908
|
+
fetchMeetings();
|
909
|
+
} else {
|
910
|
+
alert(`❌ Failed: ${data.error}`);
|
911
|
+
}
|
912
|
+
});
|
913
|
+
}
|
914
|
+
|
915
|
+
function fetchMeetings() {
|
916
|
+
fetch("/api/meetings")
|
917
|
+
.then(res => res.json())
|
918
|
+
.then(meetings => {
|
919
|
+
const list = document.getElementById("meeting-list");
|
920
|
+
if (!list) return; // <== ✅ prevent error
|
921
|
+
if (meetings.length === 0) {
|
922
|
+
list.innerHTML = "<em>No meetings found.</em>";
|
923
|
+
} else {
|
924
|
+
list.innerHTML = meetings.map(m => `<li>${m}</li>`).join("");
|
925
|
+
}
|
926
|
+
});
|
927
|
+
}
|
928
|
+
|
929
|
+
function fetchExports() {
|
930
|
+
fetch("/api/exports")
|
931
|
+
.then(res => res.json())
|
932
|
+
.then(exports => {
|
933
|
+
const list = document.getElementById("export-list");
|
934
|
+
if (!list) return; // <== ✅ prevent error
|
935
|
+
if (exports.length === 0) {
|
936
|
+
list.innerHTML = "<em>No exports found.</em>";
|
937
|
+
} else {
|
938
|
+
list.innerHTML = exports.map(e => `<li>${e}</li>`).join("");
|
939
|
+
}
|
940
|
+
});
|
941
|
+
}
|
942
|
+
|
943
|
+
function fetchRecordings() {
|
944
|
+
fetch("/api/recordings")
|
945
|
+
.then(res => res.json())
|
946
|
+
.then(data => {
|
947
|
+
const list = document.getElementById("recording-list");
|
948
|
+
if (!list) return; // <== ✅ prevent error
|
949
|
+
const html = Object.entries(data).map(([name, files]) => {
|
950
|
+
return `<li><strong>${name}</strong>: ${files.length} files</li>`;
|
951
|
+
}).join("");
|
952
|
+
list.innerHTML = html || "<em>No recordings found.</em>";
|
953
|
+
});
|
954
|
+
}
|