@gotcos/glasses-server 6.98.1 → 6.99.12
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.
- package/CHANGELOG.md +149 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,2755 @@
|
|
|
1
|
+
/* COS Control Web: Speakers, the same pane as Control 0.5.366 (ActivityWindow.swift speakersList 3319-4325,
|
|
2
|
+
voiceDirectoryDetail 5327-5431, held naming sheets 7363-7660; Views.swift SpeakerReviewPane 4642-6021;
|
|
3
|
+
ControllerModel.swift 2487-2580, 9685-11500; Models.swift ReviewableMeeting, SpeakerReview, held naming).
|
|
4
|
+
Three views: Meetings to review (default), Samples to review, Voices. Every write is the two-step Control
|
|
5
|
+
uses: a dryRun or preview first, then confirm only from the card the person saw.
|
|
6
|
+
Routes: #/speakers, #/speakers/samples, #/speakers/voices, #/speakers/review/<sessionId>,
|
|
7
|
+
#/speakers/voice/<name>, #/speakers/voice/<name>/review/<sessionId>. */
|
|
8
|
+
(function () {
|
|
9
|
+
'use strict';
|
|
10
|
+
var W = window.COSWeb;
|
|
11
|
+
if (!W) return;
|
|
12
|
+
var $ = W.$, esc = W.esc;
|
|
13
|
+
var pane = $('pane-speakers');
|
|
14
|
+
var MEM_KEY = 'cos_web_speaker_memory', PREF_KEY = 'cos_web_speaker_prefs';
|
|
15
|
+
var ROW_PAGE = 60;
|
|
16
|
+
var VIEWS = [
|
|
17
|
+
{ id: 'meetings', title: 'Meetings to review', hero: 'Meetings to review' },
|
|
18
|
+
{ id: 'samples', title: 'Samples to review', hero: 'Samples to review' },
|
|
19
|
+
{ id: 'voices', title: 'Voices', hero: 'Voice directory' }
|
|
20
|
+
];
|
|
21
|
+
var MEETING_SORTS = [['reviewPriority', 'Needs review first'], ['newest', 'Newest first'], ['oldest', 'Oldest first']];
|
|
22
|
+
var VOICE_SORTS = [['attention', 'Needs attention'], ['recent', 'Recently heard'], ['meetings', 'Most meetings'],
|
|
23
|
+
['samples', 'Most samples'], ['confidence', 'Lowest confidence'], ['name', 'Name']];
|
|
24
|
+
var PHONE_NOTE = 'iPhone microphone recordings are anonymous. Names and voice training are unavailable.';
|
|
25
|
+
var READ_ONLY = 'This review is read-only or needs a newer server to verify the selected meeting. Reopen it after updating.';
|
|
26
|
+
var EARLIER = 'An earlier correction on this meeting never finished, so its files may be part-written. Nothing was changed. Restart the COS server, then try again.';
|
|
27
|
+
var FINALIZING = 'This meeting is still being saved. Try again in a minute.';
|
|
28
|
+
var HELD_UPDATE = function (what) { return 'Update the COS server to 6.46.0 or newer to ' + what + '.'; };
|
|
29
|
+
|
|
30
|
+
// ── small helpers ──────────────────────────────────────
|
|
31
|
+
function str(v) { return typeof v === 'string' ? v : ''; }
|
|
32
|
+
function int(v) {
|
|
33
|
+
if (typeof v === 'number' && isFinite(v)) return Math.trunc(v);
|
|
34
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v)) return Number(v);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function num(v) { return typeof v === 'number' && isFinite(v) ? v : null; }
|
|
38
|
+
function fmt(n) { return Number(n || 0).toLocaleString(); }
|
|
39
|
+
function s(n) { return n === 1 ? '' : 's'; }
|
|
40
|
+
function two(x) { return Number(x).toFixed(2); }
|
|
41
|
+
function pct(x) { return Math.round(x * 100) + '%'; }
|
|
42
|
+
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
43
|
+
function icon(n) { return '<svg class="icon" aria-hidden="true"><use href="#i-' + n + '"/></svg>'; }
|
|
44
|
+
function isVoiceLabel(l) { return /^Voice [1-9][0-9]?$/.test(l || ''); }
|
|
45
|
+
function isMac(sid) { return /^mac_/.test(sid || ''); }
|
|
46
|
+
function isPhoneCapture(sid) { return /^phone_/.test(sid || ''); }
|
|
47
|
+
function validHash(h) { return typeof h === 'string' && /^[0-9a-f]{64}$/i.test(h); }
|
|
48
|
+
// "13:02" reads as "1:02 PM"; anything that is not H:mm comes back untouched (ClockStyle.format).
|
|
49
|
+
function clock12(hhmm) {
|
|
50
|
+
var m = /^(\d{1,2}):(\d{2})$/.exec(hhmm || '');
|
|
51
|
+
if (!m) return hhmm || '';
|
|
52
|
+
var h = Number(m[1]), min = Number(m[2]);
|
|
53
|
+
if (h > 23 || min > 59) return hhmm;
|
|
54
|
+
return (h % 12 === 0 ? 12 : h % 12) + ':' + m[2] + ' ' + (h < 12 ? 'AM' : 'PM');
|
|
55
|
+
}
|
|
56
|
+
function mmss(ms) { var t = Math.round(ms / 1000); return Math.floor(t / 60) + 'm ' + (t % 60) + 's'; }
|
|
57
|
+
function secs(seconds) { var t = Math.round(seconds); return Math.floor(t / 60) + 'm ' + (t % 60) + 's'; }
|
|
58
|
+
function stamp(ms) { var t = Math.floor((ms || 0) / 1000); return Math.floor(t / 60) + ':' + pad2(t % 60); }
|
|
59
|
+
function formatDuration(ms) {
|
|
60
|
+
if (!(ms > 0)) return '0m';
|
|
61
|
+
var minutes = Math.max(1, Math.round(ms / 60000));
|
|
62
|
+
return minutes < 60 ? minutes + 'm' : Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
|
|
63
|
+
}
|
|
64
|
+
function spin() { return '<span class="sp-spin" aria-hidden="true"></span>'; }
|
|
65
|
+
function hoverless() { try { return window.matchMedia('(hover: none)').matches; } catch (_e) { return false; } }
|
|
66
|
+
// A server sentence written for an API caller ("pass { force: true }") is dropped whole (MacVoiceCopy.withoutForceJargon).
|
|
67
|
+
function withoutForce(text, fallback) {
|
|
68
|
+
if (!text) return fallback;
|
|
69
|
+
return String(text).toLowerCase().split(/[^a-z]+/).indexOf('force') >= 0 ? fallback : text;
|
|
70
|
+
}
|
|
71
|
+
function serverMessage(body, status) {
|
|
72
|
+
var b = body || {};
|
|
73
|
+
return str(b.message) || str(b.error) || ('Request failed (' + status + ')');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every write reads its status and body: a 400 confirmation gate, a 409 refusal and a 422 decline carry the
|
|
77
|
+
// explanation the card shows, so they are answers, not transport errors. Same headers as W.api.
|
|
78
|
+
// Reads give up after 20 s, or after `timeoutMs` (the helper's own limit for slow routes), with err.code 'timeout',
|
|
79
|
+
// the same as core api(): a sleeping Mac must not leave a pane on its spinner. Writes wait unless they ask.
|
|
80
|
+
var READ_TIMEOUT_MS = 20000;
|
|
81
|
+
function call(method, path, body, timeoutMs) {
|
|
82
|
+
var headers = { 'X-COS-Web': '1' };
|
|
83
|
+
var opts = { method: method, headers: headers, credentials: 'same-origin' };
|
|
84
|
+
if (body !== undefined) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
|
85
|
+
var limit = timeoutMs != null ? timeoutMs : (method === 'GET' ? READ_TIMEOUT_MS : 0);
|
|
86
|
+
var ctrl = limit && window.AbortController ? new AbortController() : null;
|
|
87
|
+
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
|
|
88
|
+
if (ctrl) opts.signal = ctrl.signal;
|
|
89
|
+
return fetch(path, opts).then(function (res) {
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
if (res.status === 401) {
|
|
92
|
+
signedOut();
|
|
93
|
+
var e = new Error('Signed out. Connect this browser again.'); e.status = 401; throw e;
|
|
94
|
+
}
|
|
95
|
+
if (res.status === 403) { var f = new Error('Unauthorized'); f.status = 403; throw f; }
|
|
96
|
+
return res.text().then(function (t) {
|
|
97
|
+
var b = null;
|
|
98
|
+
try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = null; }
|
|
99
|
+
return { status: res.status, body: b };
|
|
100
|
+
});
|
|
101
|
+
}, function (err) {
|
|
102
|
+
if (timer) clearTimeout(timer);
|
|
103
|
+
if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
|
|
104
|
+
var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
|
|
105
|
+
late.code = 'timeout'; throw late;
|
|
106
|
+
}
|
|
107
|
+
var off = new Error(OFFLINE); off.code = 'offline'; throw off;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
var OFFLINE = 'Could not reach your Mac. Check Tailscale on this phone and that the Mac is awake.';
|
|
111
|
+
// The server answered, but not with JSON (a proxy page, a crash): say that, never that it stopped.
|
|
112
|
+
function unreadable(r) { return new Error('The server answered ' + r.status + ' without a readable reply.'); }
|
|
113
|
+
// A 401 hands the page to the shell's sign-in and stops every timer here, so nothing polls a signed-out session.
|
|
114
|
+
function signedOut() {
|
|
115
|
+
S.signedOut = true;
|
|
116
|
+
if (S.peekTimer) { clearInterval(S.peekTimer); S.peekTimer = null; }
|
|
117
|
+
if (expiryTimer) { clearInterval(expiryTimer); expiryTimer = null; }
|
|
118
|
+
if (S.R && S.R.suggest.timer) { clearTimeout(S.R.suggest.timer); S.R.suggest.timer = null; S.R.suggest.running = false; }
|
|
119
|
+
stopPlayback();
|
|
120
|
+
if (W.showAuth) W.showAuth('Signed out. Connect this browser again.');
|
|
121
|
+
}
|
|
122
|
+
// A read that must answer 200 (speakerReviewBody): anything else throws the server's own sentence.
|
|
123
|
+
function read(path, timeoutMs) {
|
|
124
|
+
return call('GET', path, undefined, timeoutMs).then(function (r) {
|
|
125
|
+
if (r.status !== 200 || !r.body) { var e = new Error(serverMessage(r.body, r.status)); e.status = r.status; throw e; }
|
|
126
|
+
return r.body;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function sidPath(sid) { return '/api/meeting/' + encodeURIComponent(sid); }
|
|
130
|
+
|
|
131
|
+
// ── local memory (SpeakerListMemory) and preferences ───
|
|
132
|
+
function loadMem() {
|
|
133
|
+
var m = W.readStore(MEM_KEY);
|
|
134
|
+
return {
|
|
135
|
+
acknowledged: Array.isArray(m.acknowledged) ? m.acknowledged.filter(function (x) { return typeof x === 'string'; }) : [],
|
|
136
|
+
opened: Array.isArray(m.opened) ? m.opened.filter(function (x) { return typeof x === 'string'; }) : [],
|
|
137
|
+
visits: m.visits && typeof m.visits === 'object' ? m.visits : {}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
var M = loadMem();
|
|
141
|
+
function saveMem() {
|
|
142
|
+
if (M.acknowledged.length > 600) M.acknowledged = M.acknowledged.slice(-600);
|
|
143
|
+
if (M.opened.length > 600) M.opened = M.opened.slice(-600);
|
|
144
|
+
var keys = Object.keys(M.visits);
|
|
145
|
+
if (keys.length > 600) keys.slice(0, keys.length - 600).forEach(function (k) { delete M.visits[k]; });
|
|
146
|
+
W.writeStore(MEM_KEY, M);
|
|
147
|
+
}
|
|
148
|
+
function seedIfEmpty(ids) { if (!M.acknowledged.length && ids.length) { M.acknowledged = ids.slice(); saveMem(); } }
|
|
149
|
+
function isNew(sid) { return M.acknowledged.length > 0 && M.acknowledged.indexOf(sid) < 0; }
|
|
150
|
+
function markOpened(sid) {
|
|
151
|
+
if (M.acknowledged.indexOf(sid) < 0) M.acknowledged.push(sid);
|
|
152
|
+
if (M.opened.indexOf(sid) < 0) M.opened.push(sid);
|
|
153
|
+
saveMem();
|
|
154
|
+
}
|
|
155
|
+
function recordVisit(sid, voices, unattributed) {
|
|
156
|
+
markOpened(sid);
|
|
157
|
+
M.visits[sid] = { voices: voices, unattributedVoices: unattributed, humanTouched: true };
|
|
158
|
+
saveMem();
|
|
159
|
+
}
|
|
160
|
+
// Server voiceReview is the truth; the visit overlay shows REVIEWED before the next list refresh.
|
|
161
|
+
function voiceTag(m) {
|
|
162
|
+
var v = M.visits[m.sessionId];
|
|
163
|
+
var un = v && typeof v.unattributedVoices === 'number' ? v.unattributedVoices : m.unattributedVoices;
|
|
164
|
+
var finished = (v && v.humanTouched === true) || m.humanTouched === true;
|
|
165
|
+
if (typeof un === 'number' && un === 0 && finished) return { kind: 'reviewed' };
|
|
166
|
+
if (typeof un === 'number' && un > 0) return { kind: 'needs', n: un };
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function reviewRank(m) {
|
|
170
|
+
var t = voiceTag(m);
|
|
171
|
+
if (t && t.kind === 'needs') return 0;
|
|
172
|
+
if (!t) return isNew(m.sessionId) ? 1 : 2;
|
|
173
|
+
return 3;
|
|
174
|
+
}
|
|
175
|
+
function ranked(list) {
|
|
176
|
+
return list.slice().sort(function (a, b) {
|
|
177
|
+
var ra = reviewRank(a), rb = reviewRank(b);
|
|
178
|
+
if (ra !== rb) return ra - rb;
|
|
179
|
+
if (a.date !== b.date) return a.date > b.date ? -1 : 1;
|
|
180
|
+
return a.title.localeCompare(b.title, undefined, { numeric: true });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function chronological(list, newest) {
|
|
184
|
+
return list.slice().sort(function (a, b) {
|
|
185
|
+
if (a.date !== b.date) return (a.date > b.date ? -1 : 1) * (newest ? 1 : -1);
|
|
186
|
+
if (a.time !== b.time) {
|
|
187
|
+
if (!a.time) return 1;
|
|
188
|
+
if (!b.time) return -1;
|
|
189
|
+
return (a.time > b.time ? -1 : 1) * (newest ? 1 : -1);
|
|
190
|
+
}
|
|
191
|
+
return a.title.localeCompare(b.title, undefined, { numeric: true });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
var prefs = W.readStore(PREF_KEY);
|
|
195
|
+
var P = {
|
|
196
|
+
hideReviewed: prefs.hideReviewed === true,
|
|
197
|
+
sort: MEETING_SORTS.some(function (o) { return o[0] === prefs.sort; }) ? prefs.sort : 'reviewPriority'
|
|
198
|
+
};
|
|
199
|
+
function savePrefs() { W.writeStore(PREF_KEY, { hideReviewed: P.hideReviewed, sort: P.sort }); }
|
|
200
|
+
|
|
201
|
+
// ── state ──────────────────────────────────────────────
|
|
202
|
+
var S = {
|
|
203
|
+
view: 'meetings', // the Speaker view picker: window state, as in Control, never a route of its own
|
|
204
|
+
meetings: null, meetingsLoading: false, meetingsError: '', refreshNeeded: false, pendingNew: 0, peekTimer: null,
|
|
205
|
+
dir: null, dirLoading: false, dirError: '', dirLoadFailed: false, dirRoute: null, unresolvedMeetings: 0, unresolvedSegments: 0,
|
|
206
|
+
voiceSearch: '', voiceSort: 'attention',
|
|
207
|
+
profiles: null,
|
|
208
|
+
H: null, // Samples to review
|
|
209
|
+
R: null // the open review
|
|
210
|
+
};
|
|
211
|
+
function visibleMeetings() {
|
|
212
|
+
var list = S.meetings || [];
|
|
213
|
+
var ordered = P.sort === 'newest' ? chronological(list, true) : P.sort === 'oldest' ? chronological(list, false) : ranked(list);
|
|
214
|
+
return P.hideReviewed ? ordered.filter(function (m) { var t = voiceTag(m); return !(t && t.kind === 'reviewed'); }) : ordered;
|
|
215
|
+
}
|
|
216
|
+
// Next to name walks the review queue, never the display sort.
|
|
217
|
+
function nextUnnamed(after) {
|
|
218
|
+
var queue = ranked(S.meetings || []).filter(function (m) { var t = voiceTag(m); return !(t && t.kind === 'reviewed'); });
|
|
219
|
+
var i = -1;
|
|
220
|
+
queue.forEach(function (m, n) { if (m.sessionId === after) i = n; });
|
|
221
|
+
if (i < 0) return queue[0] || null;
|
|
222
|
+
if (i + 1 < queue.length) return queue[i + 1];
|
|
223
|
+
return queue.filter(function (m) { return m.sessionId !== after; })[0] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── one shared player (Control's single AVAudioPlayer) ─
|
|
227
|
+
var audio = new Audio();
|
|
228
|
+
audio.preload = 'none';
|
|
229
|
+
var A = { key: null, voice: null, missing: '', note: null };
|
|
230
|
+
function stopPlayback() { A.key = null; A.voice = null; try { audio.pause(); audio.removeAttribute('src'); audio.load(); } catch (_e) {} }
|
|
231
|
+
// A second tap on what is already playing means stop.
|
|
232
|
+
function play(key, voice, url, missing) {
|
|
233
|
+
if (A.key === key) { stopPlayback(); render(); return; }
|
|
234
|
+
stopPlayback();
|
|
235
|
+
A.note = null; A.key = key; A.voice = voice; A.missing = missing;
|
|
236
|
+
audio.src = url;
|
|
237
|
+
var p = audio.play();
|
|
238
|
+
if (p && p.catch) p.catch(function (err) {
|
|
239
|
+
if (A.key !== key) return;
|
|
240
|
+
if (err && err.name === 'AbortError') return;
|
|
241
|
+
// A browser that refuses to start audio is not expired audio; say what is true.
|
|
242
|
+
A.note = { voice: voice, text: err && err.name === 'NotAllowedError' ? 'This browser did not start the audio. Tap play again.' : missing };
|
|
243
|
+
A.key = null; render();
|
|
244
|
+
});
|
|
245
|
+
render();
|
|
246
|
+
}
|
|
247
|
+
audio.addEventListener('ended', function () { A.key = null; render(); });
|
|
248
|
+
audio.addEventListener('error', function () {
|
|
249
|
+
if (!A.key) return;
|
|
250
|
+
A.note = { voice: A.voice, text: A.missing }; A.key = null; render();
|
|
251
|
+
});
|
|
252
|
+
function noteFor(voice) { return A.note && A.note.voice === voice && !A.key ? A.note.text : ''; }
|
|
253
|
+
|
|
254
|
+
// ── Meetings to review ─────────────────────────────────
|
|
255
|
+
function parseMeeting(o) {
|
|
256
|
+
if (!o || typeof o.sessionId !== 'string' || !o.sessionId) return null;
|
|
257
|
+
var vr = o.voiceReview && typeof o.voiceReview === 'object' ? o.voiceReview : null;
|
|
258
|
+
return {
|
|
259
|
+
sessionId: o.sessionId, title: str(o.title) || 'Untitled meeting', date: str(o.date), time: str(o.time),
|
|
260
|
+
domain: str(o.domain), duration: str(o.duration), month: str(o.month), filename: str(o.filename), source: str(o.source),
|
|
261
|
+
topicCount: int(o.topicCount) || 0, decisionCount: int(o.decisionCount) || 0, actionCount: int(o.actionCount) || 0,
|
|
262
|
+
attendeeCount: int(o.attendeeCount) || 0, recordId: str(o.recordId),
|
|
263
|
+
voices: vr ? int(vr.voices) || 0 : null, unattributedVoices: vr ? int(vr.unattributedVoices) || 0 : null,
|
|
264
|
+
namedVoices: vr ? int(vr.namedVoices) || 0 : null, humanTouched: vr ? vr.humanTouched === true : null
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function dateLine(m) { return [m.date, m.time ? clock12(m.time) : '', m.duration].filter(Boolean).join(' · '); }
|
|
268
|
+
function countsSummary(m) {
|
|
269
|
+
var parts = [];
|
|
270
|
+
if (m.topicCount > 0) parts.push(m.topicCount + ' topic' + s(m.topicCount));
|
|
271
|
+
if (m.decisionCount > 0) parts.push(m.decisionCount + ' decision' + s(m.decisionCount));
|
|
272
|
+
if (m.actionCount > 0) parts.push(m.actionCount + ' action' + s(m.actionCount));
|
|
273
|
+
if (m.attendeeCount > 0) parts.push(m.attendeeCount + ' attendee' + s(m.attendeeCount));
|
|
274
|
+
return parts.length ? parts.join(' · ') : m.source;
|
|
275
|
+
}
|
|
276
|
+
function isG2Row(o) { return !str(o.derivedKind) && /g2|glasses/i.test(str(o.source)); }
|
|
277
|
+
function skippedSentence(skipped, rows) {
|
|
278
|
+
function label(r) { return r.date ? '"' + r.title + '" (' + r.date + ')' : '"' + r.title + '"'; }
|
|
279
|
+
var g2 = rows.filter(function (r) { return r.isG2; }), other = rows.filter(function (r) { return !r.isG2; });
|
|
280
|
+
var parts = [];
|
|
281
|
+
if (other.length) {
|
|
282
|
+
var sources = other.map(function (r) { return r.source; }).filter(Boolean).filter(function (v, i, a) { return a.indexOf(v) === i; }).sort().join(', ');
|
|
283
|
+
parts.push(other.length + ' came from ' + (sources || 'another source') + ' and have no G2 audio to review');
|
|
284
|
+
}
|
|
285
|
+
if (g2.length) {
|
|
286
|
+
parts.push(g2.length + ' G2 recording' + s(g2.length) + ' ' + (g2.length === 1 ? 'was' : 'were') + ' saved without a speaker sidecar, so identity review is not available: ' +
|
|
287
|
+
g2.map(label).join('; ') + '. Send the meeting’s filename and date to support; this is not a server version problem');
|
|
288
|
+
}
|
|
289
|
+
if (!parts.length) return skipped + ' recent meeting(s) cannot be reviewed here. This is not a server version problem.';
|
|
290
|
+
return skipped + ' recent meeting(s) cannot be reviewed here: ' + parts.join('; ') + '.';
|
|
291
|
+
}
|
|
292
|
+
function loadMeetings() {
|
|
293
|
+
if (S.meetingsLoading) return;
|
|
294
|
+
S.meetingsLoading = true;
|
|
295
|
+
render();
|
|
296
|
+
read('/api/meetings?limit=30').then(function (d) {
|
|
297
|
+
var raw = Array.isArray(d.meetings) ? d.meetings : [];
|
|
298
|
+
S.meetings = raw.map(parseMeeting).filter(Boolean);
|
|
299
|
+
seedIfEmpty(S.meetings.map(function (m) { return m.sessionId; }));
|
|
300
|
+
S.refreshNeeded = false; S.pendingNew = 0; S.meetingsError = '';
|
|
301
|
+
if (!S.meetings.length) {
|
|
302
|
+
var skippedRows = raw.filter(function (o) { return !parseMeeting(o); }).slice(0, 10).map(function (o) {
|
|
303
|
+
return { title: str(o.title) || 'Untitled meeting', date: str(o.date), source: str(o.source), isG2: isG2Row(o) };
|
|
304
|
+
});
|
|
305
|
+
var skipped = raw.length;
|
|
306
|
+
return read('/api/voice/directory?limit=100').then(function (dir) {
|
|
307
|
+
return Array.isArray(dir.profiles) ? dir.profiles.length : null;
|
|
308
|
+
}, function () { return null; }).then(function (enrolled) {
|
|
309
|
+
if (enrolled === 0) S.meetingsError = 'No voices are enrolled yet, so every speaker is recorded as Ext and there is nothing to review. Name a held voice under Samples to review, or say "enroll my voice" on the glasses to record a 30-second sample.';
|
|
310
|
+
else if (skipped > 0) S.meetingsError = skippedSentence(skipped, skippedRows);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}).catch(function (err) {
|
|
314
|
+
S.meetings = []; S.meetingsError = err.message || 'Meetings could not be loaded.';
|
|
315
|
+
}).then(function () { S.meetingsLoading = false; render(); });
|
|
316
|
+
}
|
|
317
|
+
// Quiet poll: never replaces or rebuilds the list while someone works it. It patches only the Refresh button
|
|
318
|
+
// (paintRefresh), the way Control changes only its refreshTitle and refreshProminent.
|
|
319
|
+
function peek() {
|
|
320
|
+
if (S.meetingsLoading || !S.meetings) return;
|
|
321
|
+
read('/api/meetings?limit=30').then(function (d) {
|
|
322
|
+
var loaded = {};
|
|
323
|
+
S.meetings.forEach(function (m) { loaded[m.sessionId] = true; });
|
|
324
|
+
var incoming = (Array.isArray(d.meetings) ? d.meetings : []).map(parseMeeting).filter(Boolean).filter(function (m) { return !loaded[m.sessionId]; });
|
|
325
|
+
S.pendingNew = incoming.length; S.refreshNeeded = incoming.length > 0;
|
|
326
|
+
paintRefresh();
|
|
327
|
+
}, function () {});
|
|
328
|
+
}
|
|
329
|
+
function syncPeek() {
|
|
330
|
+
var r = W.parseRoute();
|
|
331
|
+
var want = !S.signedOut && r.pane === 'speakers' && !r.args.length && S.view === 'meetings' && !document.hidden;
|
|
332
|
+
if (want && !S.peekTimer) S.peekTimer = setInterval(peek, 45000);
|
|
333
|
+
if (!want && S.peekTimer) { clearInterval(S.peekTimer); S.peekTimer = null; }
|
|
334
|
+
}
|
|
335
|
+
document.addEventListener('visibilitychange', syncPeek);
|
|
336
|
+
// Patch the hero's Refresh in place: rebuilding the pane under a scrolling finger moves the page on a phone.
|
|
337
|
+
function paintRefresh() {
|
|
338
|
+
var b = pane.querySelector('[data-sp="refresh"]');
|
|
339
|
+
if (!b || route().view !== 'meetings' || W.parseRoute().args.length) return;
|
|
340
|
+
b.classList.toggle('primary', !!S.refreshNeeded);
|
|
341
|
+
b.classList.toggle('quiet', !S.refreshNeeded);
|
|
342
|
+
b.innerHTML = icon('refresh') + esc(refreshTitle());
|
|
343
|
+
}
|
|
344
|
+
function refreshTitle() {
|
|
345
|
+
if (!S.refreshNeeded) return 'Refresh';
|
|
346
|
+
if (S.pendingNew === 1) return 'Refresh · 1 new recording';
|
|
347
|
+
if (S.pendingNew > 1) return 'Refresh · ' + S.pendingNew + ' new recordings';
|
|
348
|
+
return 'Refresh needed';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── voice directory ────────────────────────────────────
|
|
352
|
+
function parsePerson(o) {
|
|
353
|
+
if (!o || typeof o.name !== 'string' || !o.name) return null;
|
|
354
|
+
function counts(obj) { var out = {}; if (obj && typeof obj === 'object') Object.keys(obj).forEach(function (k) { out[k] = int(obj[k]) || 0; }); return out; }
|
|
355
|
+
return {
|
|
356
|
+
name: o.name, isOwner: o.isOwner === true, embeddings: int(o.embeddings) || 0, sources: counts(o.sources),
|
|
357
|
+
sourcesAligned: o.sourcesAligned !== false, assertedSegments: int(o.assertedSegments) || 0,
|
|
358
|
+
candidateSegments: int(o.candidateSegments) || 0, assertedSpeakingMs: int(o.assertedSpeakingMs) || 0,
|
|
359
|
+
meetingCount: int(o.meetingCount) || 0, reviewMeetingCount: int(o.reviewMeetingCount) || 0,
|
|
360
|
+
observedMatch: num(o.observedMatch), observedMatchSegments: int(o.observedMatchSegments) || 0,
|
|
361
|
+
reliabilityCounts: counts(o.reliabilityCounts), firstSeen: str(o.firstSeen) || null, lastSeen: str(o.lastSeen) || null,
|
|
362
|
+
appearances: (Array.isArray(o.appearances) ? o.appearances : []).map(function (a) {
|
|
363
|
+
if (!a || typeof a.sessionId !== 'string' || !a.sessionId) return null;
|
|
364
|
+
return { sessionId: a.sessionId, title: str(a.title) || 'Untitled meeting', date: str(a.date), segments: int(a.segments) || 0,
|
|
365
|
+
speakingMs: int(a.speakingMs) || 0, observedMatch: num(a.observedMatch), confirmedByHuman: a.confirmedByHuman === true, needsReview: a.needsReview === true };
|
|
366
|
+
}).filter(Boolean)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function needsAttention(p) { return p.reviewMeetingCount > 0 || !p.sourcesAligned; }
|
|
370
|
+
function loadDirectory(refresh) {
|
|
371
|
+
if (S.dirLoading) return Promise.resolve();
|
|
372
|
+
S.dirLoading = true;
|
|
373
|
+
render();
|
|
374
|
+
return call('GET', '/api/voice/directory?limit=100' + (refresh ? '&refresh=1' : ''), undefined, 45000).then(function (r) {
|
|
375
|
+
if (r.status === 404) {
|
|
376
|
+
return read('/api/voice/profiles').then(function (b) {
|
|
377
|
+
S.dir = (Array.isArray(b.profiles) ? b.profiles : []).map(parsePerson).filter(Boolean);
|
|
378
|
+
S.dirRoute = false; S.unresolvedMeetings = 0; S.unresolvedSegments = 0;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (r.status !== 200 || !r.body) throw new Error(str(r.body && r.body.error) || 'Request failed (' + r.status + ')');
|
|
382
|
+
S.dir = (Array.isArray(r.body.profiles) ? r.body.profiles : []).map(parsePerson).filter(Boolean);
|
|
383
|
+
S.dirRoute = true;
|
|
384
|
+
S.unresolvedMeetings = int(r.body.unresolvedMeetings) || 0;
|
|
385
|
+
S.unresolvedSegments = int(r.body.unresolvedSegments) || 0;
|
|
386
|
+
}).then(function () { S.dirError = ''; S.dirLoadFailed = false; }, function (err) {
|
|
387
|
+
// Last-good rows stay; a failed first load is not "nobody enrolled".
|
|
388
|
+
S.dirError = err.message || 'The helper did not answer.';
|
|
389
|
+
S.dirLoadFailed = !S.dir || !S.dir.length;
|
|
390
|
+
}).then(function () { S.dirLoading = false; render(); });
|
|
391
|
+
}
|
|
392
|
+
// A failed read leaves profiles unknown (null), not empty, so the next review asks again and can say why.
|
|
393
|
+
function loadProfiles() {
|
|
394
|
+
return read('/api/voice/profiles').then(function (b) {
|
|
395
|
+
S.profilesError = '';
|
|
396
|
+
S.profiles = (Array.isArray(b.profiles) ? b.profiles : []).map(function (p) {
|
|
397
|
+
return p && typeof p.name === 'string' ? { name: p.name, embeddings: int(p.embeddings) || 0, isOwner: p.isOwner === true } : null;
|
|
398
|
+
}).filter(Boolean);
|
|
399
|
+
}, function (err) { S.profiles = null; S.profilesError = err.message || 'Voice profiles could not be read.'; });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Samples to review: held voices ─────────────────────
|
|
403
|
+
function newHeld() {
|
|
404
|
+
return {
|
|
405
|
+
loading: false, extSessions: [], extError: '', extLoadFailed: false,
|
|
406
|
+
state: null, error: '', groups: [], loose: [], pending: 0, samples: 0, embedded: 0, unusable: 0, speakerModel: true,
|
|
407
|
+
namingAvailable: false, batches: [], historyError: '', busy: false, result: '', generation: 0,
|
|
408
|
+
naming: null, typed: '', confirmDiscard: null, cursor: {}, shown: ROW_PAGE,
|
|
409
|
+
preview: null, members: [], ownerAck: false, listened: false, revised: '',
|
|
410
|
+
sheet: null, receipt: null, confirmUndo: null
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function parseRef(o) {
|
|
414
|
+
if (!o || typeof o.sessionId !== 'string' || !o.sessionId) return null;
|
|
415
|
+
var c = int(o.chunkIndex);
|
|
416
|
+
if (c == null || c < 0) return null;
|
|
417
|
+
var sg = o.suggestion && typeof o.suggestion === 'object' && str(o.suggestion.name) ? {
|
|
418
|
+
name: o.suggestion.name, similarity: num(o.suggestion.similarity) || 0, tier: str(o.suggestion.tier) || 'likely',
|
|
419
|
+
agreeing: int(o.suggestion.agreeing) || 0, ownerCaution: o.suggestion.ownerCaution === true } : null;
|
|
420
|
+
return { sessionId: o.sessionId, chunkIndex: c, suggestion: sg, id: o.sessionId + '#' + c };
|
|
421
|
+
}
|
|
422
|
+
function parseGroup(o) {
|
|
423
|
+
if (!o || typeof o.id !== 'string' || !o.id) return null;
|
|
424
|
+
var members = (Array.isArray(o.members) ? o.members : []).map(parseRef).filter(Boolean);
|
|
425
|
+
if (!members.length) return null;
|
|
426
|
+
var seed = parseRef(o.seed) || members[0];
|
|
427
|
+
var sg = o.suggestion && typeof o.suggestion === 'object' ? o.suggestion : null;
|
|
428
|
+
return {
|
|
429
|
+
id: o.id, members: members, sessions: (Array.isArray(o.sessions) ? o.sessions : []).filter(function (x) { return typeof x === 'string'; }),
|
|
430
|
+
sampleCount: int(o.sampleCount) || members.length, seed: seed,
|
|
431
|
+
playOrder: [seed].concat(members.filter(function (m) { return m.id !== seed.id; })),
|
|
432
|
+
suggestionName: sg && str(sg.name) ? sg.name : null, suggestionSimilarity: sg ? num(sg.similarity) || 0 : 0,
|
|
433
|
+
suggestionTier: sg ? str(sg.tier) : '', suggestionAgreeing: sg ? int(sg.agreeing) || 0 : 0, suggestionOf: sg ? int(sg.of) || 0 : 0,
|
|
434
|
+
ownerCaution: !!(sg && sg.ownerCaution === true)
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function namingSupported(body) {
|
|
438
|
+
var c = body && body.namingCapabilities;
|
|
439
|
+
return !!(c && c.version === 1 && c.preview === true && c.apply === true && c.undo === true);
|
|
440
|
+
}
|
|
441
|
+
function heldUsable(H) { return H.state === 'ready' && !(H.embedded === 0 && H.samples > 0); }
|
|
442
|
+
function loadHeld() {
|
|
443
|
+
var H = S.H;
|
|
444
|
+
if (H.loading) return Promise.resolve();
|
|
445
|
+
H.loading = true; H.extError = ''; H.extLoadFailed = false;
|
|
446
|
+
render();
|
|
447
|
+
var ext = call('GET', '/api/voice/ext-audio', undefined, 30000).then(function (r) {
|
|
448
|
+
if (r.status === 404) { H.extSessions = []; H.extError = 'Update the COS server to add a voice from here.'; return; }
|
|
449
|
+
if (r.status !== 200 || !r.body) throw new Error('Request failed (' + r.status + ')');
|
|
450
|
+
H.extSessions = (Array.isArray(r.body.sessions) ? r.body.sessions : []).map(function (o) {
|
|
451
|
+
if (!o || typeof o.sessionId !== 'string' || !o.sessionId) return null;
|
|
452
|
+
return { sessionId: o.sessionId, chunks: int(o.chunks) || 0, expiresIn: str(o.expiresIn),
|
|
453
|
+
chunkIndices: (Array.isArray(o.chunkIndices) ? o.chunkIndices : []).map(int).filter(function (n) { return n != null && n >= 0; }).sort(function (a, b) { return a - b; }) };
|
|
454
|
+
}).filter(Boolean);
|
|
455
|
+
if (!H.extSessions.length) H.extError = 'No unrecognized audio is being held. Record a meeting first, then come back within 72 hours.';
|
|
456
|
+
}).catch(function (err) { H.extSessions = []; H.extError = err.message; H.extLoadFailed = true; });
|
|
457
|
+
return ext.then(loadHeldGroups).then(function () {
|
|
458
|
+
H.loading = false; render();
|
|
459
|
+
// The directory's names back the "Adds to" hint while naming a voice.
|
|
460
|
+
if (!S.dir && !S.dirLoading) loadDirectory(false);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function loadHeldGroups() {
|
|
464
|
+
var H = S.H;
|
|
465
|
+
H.error = '';
|
|
466
|
+
return call('GET', '/api/voice/held-groups', undefined, 30000).then(function (r) {
|
|
467
|
+
if (r.status === 404) {
|
|
468
|
+
H.state = 'route_absent'; H.groups = []; H.loose = []; H.pending = 0; H.namingAvailable = false; return null;
|
|
469
|
+
}
|
|
470
|
+
if (r.status !== 200 || !r.body) throw new Error('Request failed (' + r.status + ')');
|
|
471
|
+
var b = r.body;
|
|
472
|
+
H.state = 'ready';
|
|
473
|
+
H.groups = (Array.isArray(b.groups) ? b.groups : []).map(parseGroup).filter(Boolean);
|
|
474
|
+
H.loose = (Array.isArray(b.loose) ? b.loose : []).map(parseRef).filter(Boolean);
|
|
475
|
+
H.pending = int(b.pending) || 0; H.samples = int(b.samples) || 0; H.embedded = int(b.embedded) || 0;
|
|
476
|
+
H.unusable = int(b.unusable) || 0; H.speakerModel = b.speakerModel !== false;
|
|
477
|
+
H.namingAvailable = namingSupported(b);
|
|
478
|
+
if (H.namingAvailable) return loadBatches(true);
|
|
479
|
+
H.batches = []; H.preview = null; if (H.sheet === 'preview') H.sheet = null;
|
|
480
|
+
return null;
|
|
481
|
+
}).catch(function (err) {
|
|
482
|
+
H.state = 'error'; H.namingAvailable = false; H.groups = []; H.loose = []; H.pending = 0; H.error = err.message;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
// Naming is its own capability: probe it before EVERY write, so an older enroll route never applies a preview.
|
|
486
|
+
function requireNaming() {
|
|
487
|
+
return call('GET', '/api/voice/held-groups').then(function (r) {
|
|
488
|
+
if (r.status !== 200 || !namingSupported(r.body)) throw new Error(HELD_UPDATE('preview, apply and undo meeting labels'));
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
function loadBatches(probed) {
|
|
492
|
+
var H = S.H;
|
|
493
|
+
if (!H.namingAvailable) return Promise.resolve();
|
|
494
|
+
return (probed ? Promise.resolve() : requireNaming()).then(function () {
|
|
495
|
+
return call('GET', '/api/voice/held-groups/batches');
|
|
496
|
+
}).then(function (r) {
|
|
497
|
+
if (r.status !== 200 || !r.body) throw new Error('Interrupted naming history could not be read. Refresh to retry.');
|
|
498
|
+
H.batches = (Array.isArray(r.body.batches) ? r.body.batches : []).map(parseBatch).filter(Boolean);
|
|
499
|
+
H.historyError = '';
|
|
500
|
+
}).catch(function (err) { H.historyError = err.message; });
|
|
501
|
+
}
|
|
502
|
+
function parseNamingMeeting(o) {
|
|
503
|
+
if (!o || typeof o.sessionId !== 'string' || !o.sessionId) return null;
|
|
504
|
+
var named = (Array.isArray(o.namedChunks) ? o.namedChunks : []).map(int).filter(function (n) { return n != null; });
|
|
505
|
+
var wider = (Array.isArray(o.widerChunks) ? o.widerChunks : []).map(int).filter(function (n) { return n != null; });
|
|
506
|
+
var copies = Array.isArray(o.receipts) ? o.receipts : Array.isArray(o.copies) ? o.copies : [];
|
|
507
|
+
var status = str(o.status) || 'unknown';
|
|
508
|
+
return {
|
|
509
|
+
sessionId: o.sessionId, status: status, error: str(o.error) || null, named: named, wider: wider,
|
|
510
|
+
labelled: int(o.labelled) != null ? int(o.labelled) : named.length + wider.length,
|
|
511
|
+
roomRisk: o.roomRisk === true, labelsNewerThanGraph: o.labelsNewerThanGraph === true,
|
|
512
|
+
playback: (Array.isArray(o.playback) ? o.playback : []).map(function (p) {
|
|
513
|
+
if (!p || typeof p.sessionId !== 'string' || !p.sessionId) return null;
|
|
514
|
+
var c = int(p.chunkIndex), pos = int(p.position);
|
|
515
|
+
if (c == null || c < 0 || pos == null || pos < 0) return null;
|
|
516
|
+
return { sessionId: p.sessionId, chunkIndex: c, position: pos, id: p.sessionId + '#' + c + '#' + pos };
|
|
517
|
+
}).filter(Boolean),
|
|
518
|
+
widerScores: Array.isArray(o.widerScores) ? o.widerScores : [],
|
|
519
|
+
copySummaries: copies.map(function (c) {
|
|
520
|
+
if (!c || typeof c.copy !== 'string') return null;
|
|
521
|
+
return c.copy + ': ' + (str(c.status) || status) + (str(c.error) ? ', ' + c.error : '');
|
|
522
|
+
}).filter(Boolean)
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function parseOutcomes(arr) {
|
|
526
|
+
return (Array.isArray(arr) ? arr : []).map(function (o) {
|
|
527
|
+
var ref = parseRef(o);
|
|
528
|
+
if (!ref) return null;
|
|
529
|
+
var labelStatus = str(o.labelStatus) || 'unknown', status = str(o.status) || 'unknown';
|
|
530
|
+
return { sample: ref, status: status, enrollmentStatus: str(o.enrollmentStatus) || 'unknown', labelStatus: labelStatus,
|
|
531
|
+
audioStatus: str(o.audioStatus) || 'unknown', reason: str(o.reason) || null,
|
|
532
|
+
noTranscript: labelStatus === 'no_transcript_position' || status === 'no_transcript_position' };
|
|
533
|
+
}).filter(Boolean);
|
|
534
|
+
}
|
|
535
|
+
function parseReceipt(raw, status) {
|
|
536
|
+
var b = raw || {};
|
|
537
|
+
var exp = null;
|
|
538
|
+
if (typeof b.expiresAt === 'number') exp = new Date(b.expiresAt);
|
|
539
|
+
else if (typeof b.expiresAt === 'string') { var d = new Date(b.expiresAt); exp = isNaN(d.getTime()) ? null : d; }
|
|
540
|
+
var meetings = (Array.isArray(b.meetings) ? b.meetings : []).map(parseNamingMeeting).filter(Boolean);
|
|
541
|
+
var membersRaw = Array.isArray(b.members) ? b.members : [];
|
|
542
|
+
var R = {
|
|
543
|
+
raw: b, httpStatus: status, kind: str(b.kind) || 'unknown', speaker: str(b.speaker) || str(b.name) || 'Voice',
|
|
544
|
+
previewHash: str(b.previewHash) || null, expiresAt: exp, owner: b.owner === true, ownerWarning: str(b.ownerWarning) || null,
|
|
545
|
+
requiresListening: b.requiresListening === true, meetings: meetings, batchId: str(b.batchId) || null,
|
|
546
|
+
undoHandle: str(b.undoHandle) || null, members: membersRaw.map(parseRef).filter(Boolean),
|
|
547
|
+
enrolled: int(b.enrolled) || 0, deleted: int(b.deleted) || 0, profileEmbeddings: int(b.profileEmbeddings),
|
|
548
|
+
partial: b.partial === true, outcomes: parseOutcomes(membersRaw), error: str(b.error) || null, lockMode: str(b.lockMode)
|
|
549
|
+
};
|
|
550
|
+
R.eligible = membersRaw.filter(function (m) { return m && (m.status === 'ready' || m.status === 'no_transcript_position'); }).length;
|
|
551
|
+
R.noTranscript = R.outcomes.filter(function (o) { return o.noTranscript; }).length;
|
|
552
|
+
R.labelled = meetings.filter(function (m) { return m.status === 'ready' || m.status === 'applied'; }).reduce(function (a, m) { return a + m.labelled; }, 0);
|
|
553
|
+
R.restored = meetings.filter(function (m) { return m.status === 'reverted'; }).reduce(function (a, m) { return a + m.labelled; }, 0);
|
|
554
|
+
R.canApplyBase = R.kind === 'preview' && status === 200 && R.eligible > 0 && validHash(R.previewHash) && !!R.expiresAt && R.expiresAt > new Date();
|
|
555
|
+
return R;
|
|
556
|
+
}
|
|
557
|
+
function parseBatch(o) {
|
|
558
|
+
if (!o || typeof o.batchId !== 'string' || !o.batchId) return null;
|
|
559
|
+
var status = str(o.status) || 'unknown';
|
|
560
|
+
return { batchId: o.batchId, speaker: str(o.speaker) || 'Voice', status: status, undoHandle: str(o.undoHandle) || null,
|
|
561
|
+
meetings: (Array.isArray(o.meetings) ? o.meetings : []).map(parseNamingMeeting).filter(Boolean),
|
|
562
|
+
needsReview: status === 'interrupted' || status === 'partial' };
|
|
563
|
+
}
|
|
564
|
+
function membersPayload(members) {
|
|
565
|
+
var seen = {}, out = [];
|
|
566
|
+
members.forEach(function (m) {
|
|
567
|
+
if (!m.sessionId || /[\/\\]|\.\./.test(m.sessionId) || !(m.chunkIndex >= 0)) return;
|
|
568
|
+
var k = m.sessionId + '#' + m.chunkIndex;
|
|
569
|
+
if (!seen[k]) { seen[k] = true; out.push({ sessionId: m.sessionId, chunkIndex: m.chunkIndex }); }
|
|
570
|
+
});
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
// 400, 404 with a reason, 409, 422 and 503 are review states that carry a receipt; never erase their body.
|
|
574
|
+
function namingRequest(path, payload) {
|
|
575
|
+
return requireNaming().then(function () { return call('POST', path, payload); }).then(function (r) {
|
|
576
|
+
if (r.status === 404 && !(r.body && r.body.reason)) throw new Error(HELD_UPDATE('preview, apply and undo meeting labels'));
|
|
577
|
+
if (!r.body) throw new Error('Server returned no naming receipt');
|
|
578
|
+
if ([200, 400, 404, 409, 422, 503].indexOf(r.status) < 0) throw new Error(str(r.body.error) || 'Naming failed (' + r.status + ')');
|
|
579
|
+
var message = str(r.body.message) || str(r.body.error) || (r.status === 200 ? 'Naming receipt ready' : 'Naming refused (' + r.status + ')');
|
|
580
|
+
return { message: message, receipt: parseReceipt(r.body, r.status) };
|
|
581
|
+
}, function (err) {
|
|
582
|
+
if (err && err.code === 'offline') throw new Error(OFFLINE + ' Then refresh to review any interrupted naming before retrying.');
|
|
583
|
+
throw err;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function canApply(H) {
|
|
587
|
+
var p = H.preview;
|
|
588
|
+
if (!H.namingAvailable || !H.speakerModel || H.busy || !p || !p.canApplyBase || !(p.expiresAt > new Date())) return false;
|
|
589
|
+
return (!p.owner || H.ownerAck) && (!p.requiresListening || H.listened);
|
|
590
|
+
}
|
|
591
|
+
function nameHeld(members, name) {
|
|
592
|
+
var H = S.H;
|
|
593
|
+
if (members.some(function (m) { return isPhoneCapture(m.sessionId); })) { H.result = PHONE_NOTE; render(); return Promise.resolve(); }
|
|
594
|
+
var trimmed = String(name || '').trim();
|
|
595
|
+
if (!H.namingAvailable) { H.result = 'Update the COS server to 6.46.0 or newer to preview and apply meeting labels.'; render(); return Promise.resolve(); }
|
|
596
|
+
if (trimmed.length < 2) { H.result = 'A name needs at least two characters.'; render(); return Promise.resolve(); }
|
|
597
|
+
if (!members.length || H.busy) return Promise.resolve();
|
|
598
|
+
H.busy = true; H.result = ''; H.preview = null; H.members = members; H.ownerAck = false; H.listened = false;
|
|
599
|
+
render();
|
|
600
|
+
return namingRequest('/api/voice/held-groups/enroll', { name: trimmed, members: membersPayload(members) }).then(function (out) {
|
|
601
|
+
H.result = out.message;
|
|
602
|
+
if (out.receipt.httpStatus === 200 && out.receipt.kind === 'preview') {
|
|
603
|
+
H.preview = out.receipt; H.revised = out.receipt.speaker; H.sheet = 'preview'; H.naming = null; H.typed = '';
|
|
604
|
+
}
|
|
605
|
+
}, function (err) { H.result = err.message; }).then(function () { H.busy = false; render(); });
|
|
606
|
+
}
|
|
607
|
+
function applyHeld() {
|
|
608
|
+
var H = S.H, p = H.preview;
|
|
609
|
+
if (p && p.expiresAt && p.expiresAt <= new Date()) { H.result = 'The preview expired. Preview this naming again.'; cancelPreview(); render(); return; }
|
|
610
|
+
if (!canApply(H)) return;
|
|
611
|
+
H.busy = true; render();
|
|
612
|
+
var payload = { name: p.speaker, members: membersPayload(H.members), previewHash: p.previewHash, confirm: true };
|
|
613
|
+
if (H.ownerAck) payload.ownerAck = true;
|
|
614
|
+
if (H.listened) payload.listened = true;
|
|
615
|
+
namingRequest('/api/voice/held-groups/enroll', payload).then(function (out) {
|
|
616
|
+
var rc = out.receipt;
|
|
617
|
+
H.result = out.message;
|
|
618
|
+
if (rc.kind === 'applied') {
|
|
619
|
+
H.result = rc.enrolled + ' samples enrolled · ' + rc.labelled + ' segments labelled.' + (rc.partial ? ' Some meetings need review.' : '');
|
|
620
|
+
H.receipt = rc; H.preview = null; H.sheet = null; H.naming = null; H.generation++; H.cursor = {}; H.confirmDiscard = null;
|
|
621
|
+
stopPlayback();
|
|
622
|
+
H.busy = false;
|
|
623
|
+
return loadHeld().then(function () { S.dir = null; return loadDirectory(true); });
|
|
624
|
+
}
|
|
625
|
+
// A changed hash, sync lock or owner check never leaves a stale Apply armed.
|
|
626
|
+
H.preview = null; H.sheet = null; H.receipt = rc;
|
|
627
|
+
return loadBatches(false);
|
|
628
|
+
}, function (err) {
|
|
629
|
+
H.result = err.message; H.preview = null; H.sheet = null;
|
|
630
|
+
return loadBatches(false);
|
|
631
|
+
}).then(function () { H.busy = false; render(); });
|
|
632
|
+
}
|
|
633
|
+
function cancelPreview() {
|
|
634
|
+
var H = S.H;
|
|
635
|
+
if (H.busy) return;
|
|
636
|
+
H.preview = null; H.ownerAck = false; H.listened = false;
|
|
637
|
+
if (H.sheet === 'preview') H.sheet = null;
|
|
638
|
+
stopPlayback();
|
|
639
|
+
}
|
|
640
|
+
function resumeHeld(batch) {
|
|
641
|
+
var H = S.H;
|
|
642
|
+
if (!H.namingAvailable || H.busy) return;
|
|
643
|
+
H.busy = true; H.sheet = null; render();
|
|
644
|
+
namingRequest('/api/voice/held-groups/resume', { batchId: batch.batchId, confirm: true }).then(function (out) {
|
|
645
|
+
H.result = out.message;
|
|
646
|
+
var rc = out.receipt;
|
|
647
|
+
if (rc.httpStatus === 200 && rc.kind === 'preview') {
|
|
648
|
+
H.members = rc.members; H.ownerAck = false; H.listened = false; H.preview = rc; H.revised = rc.speaker; H.sheet = 'preview';
|
|
649
|
+
} else { H.receipt = rc; }
|
|
650
|
+
}, function (err) { H.result = err.message; }).then(function () { H.busy = false; render(); });
|
|
651
|
+
}
|
|
652
|
+
// Undo relies on the persisted batch receipt, never on still-held audio.
|
|
653
|
+
function undoHeld(handle) {
|
|
654
|
+
var H = S.H;
|
|
655
|
+
if (!H.namingAvailable || H.busy) return;
|
|
656
|
+
H.busy = true; H.confirmUndo = null; render();
|
|
657
|
+
namingRequest('/api/voice/held-groups/undo', { batchId: handle, confirm: true }).then(function (out) {
|
|
658
|
+
var rc = out.receipt;
|
|
659
|
+
H.result = out.message; H.receipt = rc;
|
|
660
|
+
if (rc.kind === 'undone') {
|
|
661
|
+
H.result = rc.restored + ' segments restored. Voice samples stay enrolled; deleted audio stays deleted.' + (rc.partial ? ' Some copies still need review.' : '');
|
|
662
|
+
}
|
|
663
|
+
H.preview = null; stopPlayback();
|
|
664
|
+
return loadBatches(false).then(function () { S.dir = null; return loadDirectory(true); });
|
|
665
|
+
}, function (err) { H.result = err.message; }).then(function () { H.busy = false; render(); });
|
|
666
|
+
}
|
|
667
|
+
// Discard throws the audio out for good, in slices of 200, and says how many went if a later slice fails.
|
|
668
|
+
function discardHeld(members) {
|
|
669
|
+
var H = S.H;
|
|
670
|
+
if (!members.length || H.busy) return;
|
|
671
|
+
H.busy = true; H.result = ''; render();
|
|
672
|
+
var removed = 0, slice = 0;
|
|
673
|
+
function next() {
|
|
674
|
+
if (slice >= members.length) return Promise.resolve();
|
|
675
|
+
var batch = members.slice(slice, slice + 200);
|
|
676
|
+
return call('POST', '/api/voice/held-groups/discard', { members: membersPayload(batch), confirm: true }).then(function (r) {
|
|
677
|
+
if (r.status === 404 && !(r.body && r.body.reason)) throw new Error(HELD_UPDATE('name held voices by group'));
|
|
678
|
+
if (!r.body) throw unreadable(r);
|
|
679
|
+
if (r.status !== 200) throw new Error(str(r.body.error) || str(r.body.message) || 'Request failed (' + r.status + ')');
|
|
680
|
+
removed += int(r.body.removed) || 0;
|
|
681
|
+
slice += batch.length;
|
|
682
|
+
return next();
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
next().then(function () {
|
|
686
|
+
H.result = 'Discarded ' + removed + ' held sample' + s(removed) + '.';
|
|
687
|
+
}, function (err) {
|
|
688
|
+
H.result = removed > 0 ? 'Discarded ' + removed + ' of ' + members.length + ' samples before an error: ' + err.message : err.message;
|
|
689
|
+
}).then(function () {
|
|
690
|
+
H.busy = false;
|
|
691
|
+
if (removed > 0) { H.generation++; H.cursor = {}; H.confirmDiscard = null; stopPlayback(); return loadHeld(); }
|
|
692
|
+
render();
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
// "Sep 12, 3:41 PM · chunk 9" for a meeting_<ms>_<rand> session, else the raw id.
|
|
696
|
+
function heldLabel(sessionId, chunk) {
|
|
697
|
+
var parts = String(sessionId).split('_');
|
|
698
|
+
var ms = Number(parts[1]);
|
|
699
|
+
if (parts.length >= 2 && parts[0] === 'meeting' && ms > 1e12) {
|
|
700
|
+
var d = new Date(ms);
|
|
701
|
+
var t = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
702
|
+
return chunk == null ? t : t + ' · chunk ' + chunk;
|
|
703
|
+
}
|
|
704
|
+
return chunk == null ? sessionId : sessionId + ' · chunk ' + chunk;
|
|
705
|
+
}
|
|
706
|
+
function extSampleUrl(ref) { return '/api/voice/ext-audio/' + encodeURIComponent(ref.sessionId) + '/sample?chunk=' + ref.chunkIndex; }
|
|
707
|
+
|
|
708
|
+
// ── the review model (SpeakerReview, ReviewVoice) ──────
|
|
709
|
+
var KINDS = ['anonymous-group', 'unresolved', 'preserved-anonymous'];
|
|
710
|
+
function parseVoice(o) {
|
|
711
|
+
if (!o || typeof o.label !== 'string') return null;
|
|
712
|
+
var kind = KINDS.indexOf(o.kind) >= 0 ? o.kind : null;
|
|
713
|
+
var placeholder = !!kind;
|
|
714
|
+
var v = {
|
|
715
|
+
label: o.label, segments: int(o.segments) || 0, speakingMs: int(o.speakingMs), meanSimilarity: num(o.meanSimilarity),
|
|
716
|
+
isOwner: o.isOwner === true,
|
|
717
|
+
reliability: placeholder ? 'unattributed' : (['confident', 'weak', 'unreliable', 'unattributed'].indexOf(o.reliability) >= 0 ? o.reliability : 'weak'),
|
|
718
|
+
nameAsserted: placeholder ? false : o.nameAsserted !== false,
|
|
719
|
+
blockers: (Array.isArray(o.assertionBlockers) ? o.assertionBlockers : []).filter(function (x) { return typeof x === 'string'; }),
|
|
720
|
+
confirmedByHuman: o.confirmedByHuman === true,
|
|
721
|
+
thrash: (Array.isArray(o.thrashesWith) ? o.thrashesWith : []).filter(function (t) { return t && typeof t.speaker === 'string'; })
|
|
722
|
+
.map(function (t) { return { speaker: t.speaker, meanRun: num(t.meanRun) || 0 }; }),
|
|
723
|
+
phrases: (Array.isArray(o.phrases) ? o.phrases : []).filter(function (p) { return p && typeof p.text === 'string' && p.text; })
|
|
724
|
+
.map(function (p) { return { text: p.text, atMs: int(p.atMs) || 0, chunkIndex: int(p.chunkIndex) }; }),
|
|
725
|
+
kind: null, identityId: null, wordCount: int(o.wordCount)
|
|
726
|
+
};
|
|
727
|
+
if (kind === 'anonymous-group' && /^Anonymous group [1-9][0-9]{0,5}$/.test(v.label) && str(o.identityId)) { v.kind = kind; v.identityId = o.identityId; }
|
|
728
|
+
else if (kind === 'unresolved' && v.label === 'Ext') v.kind = kind;
|
|
729
|
+
else if (kind === 'preserved-anonymous' && (/^Unidentified /.test(v.label) || isVoiceLabel(v.label))) v.kind = kind;
|
|
730
|
+
v.displayName = v.kind === 'anonymous-group' || v.kind === 'preserved-anonymous' ? v.label
|
|
731
|
+
: v.kind === 'unresolved' ? 'Unresolved speech'
|
|
732
|
+
: (v.nameAsserted || isVoiceLabel(v.label) ? v.label : 'Unidentified voice');
|
|
733
|
+
v.canRename = !v.isOwner && v.kind !== 'anonymous-group' && v.kind !== 'unresolved';
|
|
734
|
+
v.isNameAssignment = v.reliability === 'unattributed';
|
|
735
|
+
v.rank = v.isNameAssignment ? 0 : (!v.nameAsserted ? 1 : 2);
|
|
736
|
+
v.canConfirmCandidate = !v.isOwner && !v.nameAsserted && v.reliability !== 'unattributed' && !v.confirmedByHuman;
|
|
737
|
+
v.confirmsInOneClick = (v.meanSimilarity || 0) >= 0.65;
|
|
738
|
+
v.canDeattribute = v.reliability !== 'unattributed';
|
|
739
|
+
return v;
|
|
740
|
+
}
|
|
741
|
+
function unnamedCount(labels) {
|
|
742
|
+
var voices = labels.filter(isVoiceLabel).length;
|
|
743
|
+
return voices > 0 ? voices : labels.length;
|
|
744
|
+
}
|
|
745
|
+
function parseSummary(o, voices) {
|
|
746
|
+
if (!o || typeof o !== 'object' || o.version !== 1 || o.basis !== 'source-bound-hq-words') return null;
|
|
747
|
+
var keys = ['anonymousGroupCount', 'preservedAnonymousGroupCount', 'namedVoiceCount', 'withheldNameCount', 'unresolvedSpeakingMs', 'unresolvedWordCount'];
|
|
748
|
+
if (keys.some(function (k) { return int(o[k]) == null || int(o[k]) < 0; })) return null;
|
|
749
|
+
var g = int(o.anonymousGroupCount), named = int(o.namedVoiceCount), pre = int(o.preservedAnonymousGroupCount), wh = int(o.withheldNameCount);
|
|
750
|
+
var parts = [g + ' estimated ' + (g === 1 ? 'group' : 'groups')];
|
|
751
|
+
if (named > 0) parts.push(named + ' named ' + (named === 1 ? 'voice' : 'voices'));
|
|
752
|
+
if (pre > 0) parts.push(pre + ' reviewed unnamed ' + (pre === 1 ? 'group' : 'groups'));
|
|
753
|
+
if (wh > 0) parts.push(wh + ' withheld ' + (wh === 1 ? 'name' : 'names'));
|
|
754
|
+
var words = int(o.unresolvedWordCount);
|
|
755
|
+
return { header: parts.join(' · '), unresolvedNote: words > 0 ? 'Unresolved speech: ' + words + ' words · ' + Math.round(int(o.unresolvedSpeakingMs) / 1000) + 's' : null };
|
|
756
|
+
}
|
|
757
|
+
function parseHint(o) {
|
|
758
|
+
if (!o || o.source !== 'speaker-shadow' || int(o.foldUnderSeconds) !== 15 || !str(o.recordedAt)
|
|
759
|
+
|| !Array.isArray(o.groups) || !Array.isArray(o.singles) || !Array.isArray(o.short)) return null;
|
|
760
|
+
function sec(v) { return typeof v === 'number' && isFinite(v) && v >= 0 ? v : null; }
|
|
761
|
+
return {
|
|
762
|
+
groups: o.groups.map(function (g) {
|
|
763
|
+
var labels = g && Array.isArray(g.labels) ? g.labels.filter(function (x) { return typeof x === 'string'; }) : [];
|
|
764
|
+
return g && str(g.id) && labels.length ? { id: g.id, labels: labels, seconds: sec(g.seconds) } : null;
|
|
765
|
+
}).filter(Boolean),
|
|
766
|
+
singles: o.singles.map(function (g) { return g && typeof g.label === 'string' ? { label: g.label, seconds: sec(g.seconds) } : null; }).filter(Boolean),
|
|
767
|
+
short: o.short.map(function (g) {
|
|
768
|
+
var x = g ? sec(g.seconds) : null;
|
|
769
|
+
return g && typeof g.label === 'string' && x != null && x < 15 ? { label: g.label, seconds: x } : null;
|
|
770
|
+
}).filter(Boolean)
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function rowTitle(labels) { return labels.length === 1 ? labels[0] : labels[0] + ' and ' + (labels.length - 1) + ' more'; }
|
|
774
|
+
function parseReview(o) {
|
|
775
|
+
if (!o || typeof o.sessionId !== 'string') return null;
|
|
776
|
+
var sid = o.sessionId, mac = isMac(sid);
|
|
777
|
+
var voices = (Array.isArray(o.voices) ? o.voices : []).map(parseVoice).filter(Boolean);
|
|
778
|
+
var interval = ['effectiveSpeakerSummary', 'intervalGroupScopes', 'intervalHumanAnnotation', 'intervalHumanRecoveryRequired', 'intervalOriginalViewAvailable']
|
|
779
|
+
.some(function (k) { return o[k] !== undefined; })
|
|
780
|
+
|| (Array.isArray(o.voices) ? o.voices : []).some(function (v) { return v && KINDS.indexOf(v.kind) >= 0; })
|
|
781
|
+
|| !!(o.speakerCallView && o.speakerCallView.mode === 'source-bound-intervals');
|
|
782
|
+
var rv = {
|
|
783
|
+
sessionId: sid, title: str(o.title) || 'Untitled meeting', segments: int(o.segments) || 0, attributed: o.attributed === true,
|
|
784
|
+
assertedSegments: int(o.assertedSegments), attributedSpeakingMs: int(o.attributedSpeakingMs), unattributedSpeakingMs: int(o.unattributedSpeakingMs),
|
|
785
|
+
speakingTimeSource: str(o.speakingTimeSource), durationMs: int(o.durationMs) || 0, voices: voices, intervalReadOnly: interval,
|
|
786
|
+
summary: mac ? parseSummary(o.effectiveSpeakerSummary, voices) : null,
|
|
787
|
+
timeline: (Array.isArray(o.timeline) ? o.timeline : []).map(function (t, i) {
|
|
788
|
+
return t && typeof t.speaker === 'string' ? { speaker: t.speaker, startMs: int(t.startMs) || 0, endMs: int(t.endMs) || 0, segments: int(t.segments) || 0, index: i } : null;
|
|
789
|
+
}).filter(Boolean),
|
|
790
|
+
recordId: str(o.recordId), sourceRevision: str(o.sourceRevision) || null, identityVersion: int(o.reviewIdentityVersion),
|
|
791
|
+
mutable: !interval && o.mutable !== false, domain: str(o.domain), filename: str(o.filename),
|
|
792
|
+
groupScopeLabels: (Array.isArray(o.groupScopes) ? o.groupScopes : []).map(function (g) { return g && str(g.displayLabel); }).filter(Boolean),
|
|
793
|
+
isMac: mac, callViewApplied: !!(o.speakerCallView && o.speakerCallView.status === 'applied'),
|
|
794
|
+
hint: mac ? parseHint(o.voiceDisplay) : null
|
|
795
|
+
};
|
|
796
|
+
rv.byLabel = {};
|
|
797
|
+
voices.forEach(function (v) { rv.byLabel[v.label] = v; });
|
|
798
|
+
rv.unnamed = unnamedCount(voices.filter(function (v) { return v.isNameAssignment; }).map(function (v) { return v.label; }));
|
|
799
|
+
var a = rv.attributedSpeakingMs, u = rv.unattributedSpeakingMs;
|
|
800
|
+
rv.coverage = a != null && u != null && a + u > 0 ? a / (a + u) : null;
|
|
801
|
+
rv.namedTotal = voices.reduce(function (t, v) { return t + (v.nameAsserted ? (v.speakingMs || 0) : 0); }, 0);
|
|
802
|
+
rv.ordered = voices.map(function (v, i) { return { v: v, i: i }; }).sort(function (x, y) {
|
|
803
|
+
if (x.v.rank !== y.v.rank) return x.v.rank - y.v.rank;
|
|
804
|
+
if (x.v.segments !== y.v.segments) return y.v.segments - x.v.segments;
|
|
805
|
+
return x.i - y.i;
|
|
806
|
+
}).map(function (x) { return x.v; });
|
|
807
|
+
rv.canSuggest = mac && rv.mutable && !!rv.recordId && validHash(rv.sourceRevision);
|
|
808
|
+
rv.layout = buildLayout(rv);
|
|
809
|
+
return rv;
|
|
810
|
+
}
|
|
811
|
+
function shareOf(rv, v) {
|
|
812
|
+
if (!v.nameAsserted || v.speakingMs == null) return null;
|
|
813
|
+
if (rv.coverage == null || rv.coverage < 0.6 || !(rv.namedTotal > 0)) return null;
|
|
814
|
+
return v.speakingMs / rv.namedTotal;
|
|
815
|
+
}
|
|
816
|
+
function assertsName(rv, label) { var v = rv.byLabel[label]; return !!(v && v.nameAsserted); }
|
|
817
|
+
function displayNameOf(rv, label) { var v = rv.byLabel[label]; return v ? v.displayName : label; }
|
|
818
|
+
function canNameAlone(rv, v) {
|
|
819
|
+
return rv.mutable && rv.isMac && v.canRename && isVoiceLabel(v.label) && rv.groupScopeLabels.indexOf(v.label) < 0;
|
|
820
|
+
}
|
|
821
|
+
// VoiceDisplayLayout: merged rows first, then singles and the rows that sit where they did, then one fold under 15 seconds.
|
|
822
|
+
function buildLayout(rv) {
|
|
823
|
+
var hint = rv.hint;
|
|
824
|
+
if (!rv.isMac || rv.intervalReadOnly || rv.summary || rv.callViewApplied || !hint) return null;
|
|
825
|
+
var ordered = rv.ordered;
|
|
826
|
+
var labels = ordered.map(function (v) { return v.label; });
|
|
827
|
+
if (labels.some(function (l, i) { return labels.indexOf(l) !== i; })) return null;
|
|
828
|
+
var used = {}, seconds = {}, ids = {};
|
|
829
|
+
function take(label) {
|
|
830
|
+
if (!isVoiceLabel(label) || !rv.byLabel[label] || used[label]) return null;
|
|
831
|
+
used[label] = true; return rv.byLabel[label];
|
|
832
|
+
}
|
|
833
|
+
var merged = [], singles = [], short = [];
|
|
834
|
+
hint.groups.forEach(function (g) {
|
|
835
|
+
var members = g.labels.map(take).filter(Boolean);
|
|
836
|
+
if (members.length >= 2) {
|
|
837
|
+
var id = 'group:' + (ids[g.id] ? members[0].label : g.id);
|
|
838
|
+
ids[g.id] = true;
|
|
839
|
+
merged.push({ id: id, members: members, seconds: members.length === g.labels.length ? g.seconds : null });
|
|
840
|
+
} else if (members.length === 1) singles.push({ v: members[0], seconds: null });
|
|
841
|
+
});
|
|
842
|
+
hint.singles.forEach(function (x) { var v = take(x.label); if (v) { singles.push({ v: v, seconds: x.seconds }); if (x.seconds != null) seconds[v.label] = x.seconds; } });
|
|
843
|
+
hint.short.forEach(function (x) { var v = take(x.label); if (v) { short.push({ v: v, seconds: x.seconds }); seconds[v.label] = x.seconds; } });
|
|
844
|
+
function rank(a, b) { return (b == null ? -1 : b) - (a == null ? -1 : a); }
|
|
845
|
+
merged = merged.map(function (m, i) { return { m: m, i: i }; }).sort(function (a, b) {
|
|
846
|
+
if (a.m.seconds !== b.m.seconds) return rank(a.m.seconds, b.m.seconds);
|
|
847
|
+
if (a.m.members.length !== b.m.members.length) return b.m.members.length - a.m.members.length;
|
|
848
|
+
return a.i - b.i;
|
|
849
|
+
}).map(function (x) { return x.m; });
|
|
850
|
+
singles = singles.map(function (m, i) { return { m: m, i: i }; }).sort(function (a, b) {
|
|
851
|
+
return a.m.seconds !== b.m.seconds ? rank(a.m.seconds, b.m.seconds) : a.i - b.i;
|
|
852
|
+
}).map(function (x) { return x.m.v; });
|
|
853
|
+
short = short.map(function (m, i) { return { m: m, i: i }; }).sort(function (a, b) {
|
|
854
|
+
return a.m.seconds !== b.m.seconds ? b.m.seconds - a.m.seconds : a.i - b.i;
|
|
855
|
+
}).map(function (x) { return x.m.v; });
|
|
856
|
+
var rest = ordered.filter(function (v) { return !used[v.label]; });
|
|
857
|
+
var rows = merged.map(function (m) { return { merged: m }; }).concat(singles.concat(rest).map(function (v) { return { voice: v }; }));
|
|
858
|
+
return { rows: rows, short: short, seconds: seconds, visibleRowCount: rows.length + (short.length ? 1 : 0) };
|
|
859
|
+
}
|
|
860
|
+
function inventoryHeader(rv) {
|
|
861
|
+
if (rv.layout) {
|
|
862
|
+
var n = rv.layout.visibleRowCount;
|
|
863
|
+
return rv.segments + ' segments · ' + (rv.voices.length === 1 ? '1 voice label' : rv.voices.length + ' voice labels') + ' in ' + n + ' row' + s(n);
|
|
864
|
+
}
|
|
865
|
+
if (rv.summary) return rv.summary.header;
|
|
866
|
+
return rv.intervalReadOnly ? 'Speaker grouping unavailable' : rv.segments + ' segments · ' + rv.voices.length + ' voices';
|
|
867
|
+
}
|
|
868
|
+
function parseContent(o) {
|
|
869
|
+
if (!o || typeof o.sessionId !== 'string') return null;
|
|
870
|
+
var sumText = str(o.clipboardSummary), fullText = str(o.clipboardFull);
|
|
871
|
+
var sections = [['Summary', str(o.summary)], ['Topics', str(o.topics)], ['Decisions', str(o.decisions)], ['Action items', str(o.actions)]]
|
|
872
|
+
.concat((Array.isArray(o.extras) ? o.extras : []).filter(function (e) { return e && typeof e.heading === 'string' && typeof e.body === 'string'; })
|
|
873
|
+
.map(function (e) { return [e.heading, e.body]; }))
|
|
874
|
+
.filter(function (x) { return x[1].trim(); });
|
|
875
|
+
return {
|
|
876
|
+
scribeAvailable: o.scribeAvailable === true, sections: sections, clipboardSummary: sumText, clipboardFull: fullText,
|
|
877
|
+
summaryChars: int(o.summaryChars) != null ? int(o.summaryChars) : sumText.length,
|
|
878
|
+
fullChars: int(o.fullChars) != null ? int(o.fullChars) : fullText.length,
|
|
879
|
+
removed: (Array.isArray(o.removedNames) ? o.removedNames : []).filter(function (r) { return r && str(r.label); })
|
|
880
|
+
.map(function (r) { return { label: r.label, proseStale: r.proseStale === true }; }),
|
|
881
|
+
recordId: str(o.recordId), sourceRevision: str(o.sourceRevision)
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
// Markdown markers softened for reading; the clipboard keeps the real markup (MeetingContent.panelText).
|
|
885
|
+
function panelText(body) {
|
|
886
|
+
return String(body).split('\n').map(function (line) {
|
|
887
|
+
var l = line;
|
|
888
|
+
var h = /^#{1,6}\s+/.exec(l);
|
|
889
|
+
if (h) l = l.slice(h[0].length).toUpperCase();
|
|
890
|
+
l = l.split('- [ ] ').join('• ').split('- [x] ').join('✓ ');
|
|
891
|
+
if (l.split('**').length % 2 === 1) l = l.split('**').join('');
|
|
892
|
+
return l;
|
|
893
|
+
}).join('\n');
|
|
894
|
+
}
|
|
895
|
+
function panelPreview(raw) {
|
|
896
|
+
var full = panelText(raw);
|
|
897
|
+
if (full.length <= 900) return full;
|
|
898
|
+
var hard = full.slice(0, 900);
|
|
899
|
+
var cut = Math.max(hard.lastIndexOf(' '), hard.lastIndexOf('\n'));
|
|
900
|
+
var shown = (cut > 0 ? hard.slice(0, cut) : hard).trim();
|
|
901
|
+
return shown + '\n\n… ' + (full.length - shown.length) + ' more characters. Use the copy buttons above.';
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Mac voice assignment preview (MacVoiceAssignment) ──
|
|
905
|
+
var MAC_RENAME_NOTE = 'A typed name renames text only, in this meeting. To add this voice’s audio to a voice profile, pick the person from the list.';
|
|
906
|
+
var MAC_RENAME_NOT_VOICE = 'A name renames text only, in this meeting. Only an unnamed Voice N can add voice samples to a profile.';
|
|
907
|
+
var MAC_TYPED_NOTE = 'Renames text only, in this meeting. No voice sample is added to any profile.';
|
|
908
|
+
var MAC_UNSUPPORTED = 'This server cannot add voice samples yet; it needs COS server 6.93.0 or newer. The name will be applied to this meeting as text when you save.';
|
|
909
|
+
var MAC_UNATTRIBUTED = 'Voice numbers do not identify people. When voices can be distinguished, they appear as Voice 1, Voice 2 and so on; names appear only at high confidence. Name the ones you know; this changes this meeting only.';
|
|
910
|
+
var GATES = ['below_floor', 'mixed_voice', 'too_little_audio'];
|
|
911
|
+
function parseAssignment(o) {
|
|
912
|
+
if (!o || typeof o !== 'object' || !str(o.person)) return null;
|
|
913
|
+
var below = o.belowFloor === true;
|
|
914
|
+
var gates = (Array.isArray(o.gates) ? o.gates : []).filter(function (g) { return typeof g === 'string' && g; });
|
|
915
|
+
if (below && gates.indexOf('below_floor') < 0) gates.unshift('below_floor');
|
|
916
|
+
var a = {
|
|
917
|
+
person: o.person, samples: Math.max(0, int(o.enrolled) || 0), attempted: Math.max(0, int(o.attempted) || 0),
|
|
918
|
+
clusterSkipped: Math.max(0, int(o.clusterSkipped) || 0), groupSec: Math.max(0, num(o.groupSec) || 0),
|
|
919
|
+
similarity: num(o.similarity), belowFloor: below, floor: num(o.floor) != null ? num(o.floor) : 0.55, gates: gates,
|
|
920
|
+
skipped: str(o.skipped) || null
|
|
921
|
+
};
|
|
922
|
+
a.canAdd = !a.skipped && a.samples > 0;
|
|
923
|
+
a.needsForce = a.canAdd && a.gates.length > 0;
|
|
924
|
+
return a;
|
|
925
|
+
}
|
|
926
|
+
function assignReason(code) {
|
|
927
|
+
switch (code) {
|
|
928
|
+
case 'audio_not_retained': return 'the audio for this recording is no longer kept';
|
|
929
|
+
case 'no_usable_audio': return 'no clean stretch of this voice on its own was found';
|
|
930
|
+
case 'store_unavailable': return 'the voice model is not available';
|
|
931
|
+
case 'disabled': return 'voice samples are switched off on this server';
|
|
932
|
+
case 'no_index_mapping': return 'this recording has no audio index';
|
|
933
|
+
case 'owner_profile': return 'that is the wearer’s own profile, and a Mac recording has no wearer microphone';
|
|
934
|
+
case 'profile_full': return 'that profile is full of other samples and has none from a Mac recording to replace';
|
|
935
|
+
case 'no_stored_voice': return 'that profile has no stored voice to compare with';
|
|
936
|
+
case 'deadline': return 'reading the audio took longer than the time limit. Try again and it continues where it stopped';
|
|
937
|
+
default: return code ? 'the server could not use this audio (' + code + ')' : 'the server could not use this audio';
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function assignLines(a, voice) {
|
|
941
|
+
if (!a.canAdd) return ['No voice samples will be added to ' + a.person + ': ' + assignReason(a.skipped) + '. The name will be applied to this meeting as text when you save.'];
|
|
942
|
+
var out = ['Adds up to ' + a.samples + ' voice sample' + s(a.samples) + ' of ' + voice + ' to ' + a.person + '’s profile. Nothing is added until you save.'];
|
|
943
|
+
if (a.attempted > 0) {
|
|
944
|
+
out.push(a.clusterSkipped > 0 ? a.clusterSkipped + ' of ' + a.attempted + ' stretches of audio sounded like a different voice and were left out.'
|
|
945
|
+
: a.attempted + ' stretch' + (a.attempted === 1 ? '' : 'es') + ' of audio read, none left out as a different voice.');
|
|
946
|
+
}
|
|
947
|
+
if (a.similarity != null) {
|
|
948
|
+
out.push(a.belowFloor ? 'This voice sounds unlike ' + a.person + '’s stored profile (similarity ' + two(a.similarity) + ', usual match floor ' + two(a.floor) + ').'
|
|
949
|
+
: 'Similarity to ' + a.person + '’s stored profile: ' + two(a.similarity) + '.');
|
|
950
|
+
}
|
|
951
|
+
if (a.gates.indexOf('mixed_voice') >= 0) out.push('This ' + voice + ' holds more than one voice, so the main one may not be ' + a.person + '.');
|
|
952
|
+
if (a.gates.indexOf('too_little_audio') >= 0) out.push('Only ' + a.groupSec.toFixed(1) + ' seconds of clean audio from this voice.');
|
|
953
|
+
return out;
|
|
954
|
+
}
|
|
955
|
+
function skipNote(code) {
|
|
956
|
+
if (code === 'mac_capture') return ' · text only, no voice sample added';
|
|
957
|
+
if (code === 'store_unavailable') return ' · the name is applied, but the voice samples could not be saved';
|
|
958
|
+
return code ? ' · profile not updated (' + code + ')' : '';
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ── review: load ───────────────────────────────────────
|
|
962
|
+
var reviewGen = 0;
|
|
963
|
+
function newReview(sid, parent) {
|
|
964
|
+
return {
|
|
965
|
+
sid: sid, parent: parent || null, gen: ++reviewGen, loading: true, loadError: '', review: null, content: null, contentUnavailable: null,
|
|
966
|
+
chunks: {}, naming: null, typed: {}, scope: 'thisMeeting', pending: null, busy: false, error: '', anchor: null, copyNote: '',
|
|
967
|
+
forceArmed: false, expanded: {}, shortOpen: false, hovered: null,
|
|
968
|
+
policy: { mode: 'standard', saved: 'standard', revision: null, busy: true, error: '' },
|
|
969
|
+
suggest: { probed: false, routeAbsent: false, job: null, note: null, timer: null, running: false },
|
|
970
|
+
groupPlan: null, groupProgress: '', groupOutcome: '', voiceNamed: false
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function closeReview() {
|
|
974
|
+
var R = S.R;
|
|
975
|
+
if (!R) return;
|
|
976
|
+
if (R.review) recordVisit(R.review.sessionId, R.review.voices.length, R.review.unnamed);
|
|
977
|
+
if (R.suggest.timer) clearTimeout(R.suggest.timer);
|
|
978
|
+
stopPlayback(); A.note = null;
|
|
979
|
+
S.R = null;
|
|
980
|
+
}
|
|
981
|
+
function live(R) { return S.R === R; }
|
|
982
|
+
function openReview(sid, parent) {
|
|
983
|
+
if (S.R && S.R.sid === sid && S.R.parent === (parent || null)) return;
|
|
984
|
+
closeReview();
|
|
985
|
+
if (isPhoneCapture(sid)) { W.toast(PHONE_NOTE); }
|
|
986
|
+
markOpened(sid);
|
|
987
|
+
var R = S.R = newReview(sid, parent);
|
|
988
|
+
fetchReview(R, true);
|
|
989
|
+
}
|
|
990
|
+
function fetchReview(R, first) {
|
|
991
|
+
R.loading = true; R.copyNote = ''; R.contentUnavailable = null;
|
|
992
|
+
render();
|
|
993
|
+
return call('GET', sidPath(R.sid) + '/speakers', undefined, 25000).then(function (r) {
|
|
994
|
+
if (!live(R)) return;
|
|
995
|
+
if (r.status !== 200 || !r.body) throw new Error(serverMessage(r.body, r.status));
|
|
996
|
+
var rv = parseReview(r.body);
|
|
997
|
+
if (!rv) throw new Error('The server returned a review this build cannot read.');
|
|
998
|
+
if (rv.sessionId !== R.sid) throw new Error('This review does not match the selected meeting. Reopen the saved meeting.');
|
|
999
|
+
R.review = rv; R.loadError = '';
|
|
1000
|
+
W.setCrumb(rv.title);
|
|
1001
|
+
if (first) { loadPolicy(R); probeSuggestions(R); }
|
|
1002
|
+
return loadParts(R, ['profiles', 'audio', 'content']);
|
|
1003
|
+
}).catch(function (err) {
|
|
1004
|
+
if (!live(R)) return;
|
|
1005
|
+
if (R.review) R.error = err.message; else R.loadError = err.message;
|
|
1006
|
+
}).then(function () { if (live(R)) { R.loading = false; render(); } });
|
|
1007
|
+
}
|
|
1008
|
+
function retryReview() { var R = S.R; if (!R) return; R.error = ''; R.loadError = ''; fetchReview(R, !R.review); }
|
|
1009
|
+
// The reads after the review never blank its rows (Control drops playback or the write-up quietly on an older
|
|
1010
|
+
// server), but a timeout or failure here is named, with a Try again that re-runs only the reads that failed.
|
|
1011
|
+
function loadParts(R, parts) {
|
|
1012
|
+
var rv = R.review;
|
|
1013
|
+
function next(i) {
|
|
1014
|
+
if (i >= parts.length || !live(R)) return Promise.resolve();
|
|
1015
|
+
return loadPart(R, rv, parts[i]).then(function () { return next(i + 1); });
|
|
1016
|
+
}
|
|
1017
|
+
return next(0);
|
|
1018
|
+
}
|
|
1019
|
+
function loadPart(R, rv, part) {
|
|
1020
|
+
R.failed = R.failed || {};
|
|
1021
|
+
delete R.failed[part];
|
|
1022
|
+
if (part === 'profiles') {
|
|
1023
|
+
if (S.profiles) return Promise.resolve();
|
|
1024
|
+
return loadProfiles().then(function () { if (S.profiles == null) R.failed.profiles = S.profilesError; });
|
|
1025
|
+
}
|
|
1026
|
+
if (part === 'audio') {
|
|
1027
|
+
return call('GET', sidPath(R.sid) + '/audio', undefined, 15000).then(function (a) {
|
|
1028
|
+
if (!live(R)) return;
|
|
1029
|
+
var map = {};
|
|
1030
|
+
if (a.status === 200 && a.body && Array.isArray(a.body.chunks)) a.body.chunks.forEach(function (c) { var n = int(c); if (n != null) map[n] = true; });
|
|
1031
|
+
// 404 is an older server with no playback, said nowhere, as in Control. Anything else is a failure worth naming.
|
|
1032
|
+
else if (a.status !== 404) R.failed.audio = 'Could not read retained audio (' + a.status + ')';
|
|
1033
|
+
R.chunks = map;
|
|
1034
|
+
}, function (err) { if (live(R)) { R.chunks = {}; R.failed.audio = err.message; } });
|
|
1035
|
+
}
|
|
1036
|
+
return call('GET', sidPath(R.sid) + '/content', undefined, 30000).then(function (c) {
|
|
1037
|
+
if (!live(R)) return;
|
|
1038
|
+
if (c.status === 404) { R.content = null; R.contentUnavailable = 'route_absent'; return; }
|
|
1039
|
+
if (c.status !== 200 || !c.body) { R.content = null; R.contentUnavailable = 'error'; R.failed.content = serverMessage(c.body, c.status); return; }
|
|
1040
|
+
var parsed = parseContent(c.body);
|
|
1041
|
+
if (parsed && rv.sourceRevision && parsed.sourceRevision && parsed.sourceRevision !== rv.sourceRevision) {
|
|
1042
|
+
R.content = null; R.contentUnavailable = 'Meeting changed while opening. Reopen review.'; return;
|
|
1043
|
+
}
|
|
1044
|
+
R.content = parsed; R.contentUnavailable = parsed ? null : 'error';
|
|
1045
|
+
}, function (err) { if (live(R)) { R.content = null; R.contentUnavailable = 'error'; R.failed.content = err.message; } });
|
|
1046
|
+
}
|
|
1047
|
+
function retryParts() {
|
|
1048
|
+
var R = S.R;
|
|
1049
|
+
if (!R || !R.review || !R.failed || R.partsBusy) return;
|
|
1050
|
+
var parts = Object.keys(R.failed);
|
|
1051
|
+
if (!parts.length) return;
|
|
1052
|
+
R.partsBusy = true; render();
|
|
1053
|
+
loadParts(R, parts).then(function () { if (live(R)) { R.partsBusy = false; render(); } });
|
|
1054
|
+
}
|
|
1055
|
+
var PART_NAMES = { profiles: 'the voice profiles', audio: 'the playback list', content: 'the write-up' };
|
|
1056
|
+
function partsBanner(R) {
|
|
1057
|
+
var keys = R.failed ? Object.keys(R.failed) : [];
|
|
1058
|
+
if (!keys.length || R.loading) return '';
|
|
1059
|
+
var names = keys.map(function (k) { return PART_NAMES[k]; });
|
|
1060
|
+
var list = names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1];
|
|
1061
|
+
return '<div class="sp-banner sp-parts" role="status"><span>' + esc('Part of this review did not load: ' + list + '. ' + R.failed[keys[0]]) + '</span><span class="sp-grow"></span>' +
|
|
1062
|
+
(R.partsBusy ? spin() : '<button type="button" class="sp-text" data-sp="retry-parts">Try again</button>') + '</div>';
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Voice identity policy (MeetingSpeakerPolicyView): the server owns this per-capture setting.
|
|
1066
|
+
function loadPolicy(R) {
|
|
1067
|
+
var p = R.policy;
|
|
1068
|
+
p.busy = true; p.revision = null; p.error = '';
|
|
1069
|
+
call('GET', sidPath(R.sid) + '/speaker-policy').then(function (r) {
|
|
1070
|
+
if (!live(R)) return;
|
|
1071
|
+
var b = r.body || {};
|
|
1072
|
+
if (r.status !== 200 || ['standard', 'high-confidence', 'anonymous'].indexOf(b.mode) < 0 || typeof b.revision !== 'string') {
|
|
1073
|
+
throw new Error(r.status === 200 ? 'Speaker matching mode' : serverMessage(b, r.status));
|
|
1074
|
+
}
|
|
1075
|
+
p.mode = b.mode; p.saved = b.mode; p.revision = b.revision;
|
|
1076
|
+
if (b.revision === 'unreadable') p.error = 'Anonymous safe mode is active because these voice settings could not be read. ' + str(b.error) + ' Choose a mode and click Repair settings to save it deliberately.';
|
|
1077
|
+
}).catch(function (err) { if (live(R)) p.error = 'Voice settings unavailable: ' + err.message; })
|
|
1078
|
+
.then(function () { if (live(R)) { p.busy = false; render(); } });
|
|
1079
|
+
}
|
|
1080
|
+
function savePolicy(R) {
|
|
1081
|
+
var p = R.policy, rv = R.review;
|
|
1082
|
+
if (p.busy || !p.revision) return;
|
|
1083
|
+
var selected = p.mode;
|
|
1084
|
+
p.busy = true; p.error = ''; render();
|
|
1085
|
+
var body = { mode: selected, expectedPolicyRevision: p.revision };
|
|
1086
|
+
if (rv && rv.recordId) body.recordId = rv.recordId;
|
|
1087
|
+
if (rv && rv.sourceRevision) body.expectedRevision = rv.sourceRevision;
|
|
1088
|
+
call('POST', sidPath(R.sid) + '/speaker-policy', body).then(function (r) {
|
|
1089
|
+
if (!live(R)) return;
|
|
1090
|
+
var b = r.body || {};
|
|
1091
|
+
if (r.status !== 200 || b.mode !== selected || typeof b.revision !== 'string') throw new Error(serverMessage(b, r.status));
|
|
1092
|
+
p.saved = selected; p.revision = b.revision;
|
|
1093
|
+
}).catch(function (err) { if (live(R)) p.error = err.message; })
|
|
1094
|
+
.then(function () { if (live(R)) { p.busy = false; render(); } });
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Suggest names (0.5.357): saved Mac calls only. A 404 hides the button silently.
|
|
1098
|
+
var SUG = {
|
|
1099
|
+
only: 'Suggestions only. Nothing is named until you click Use this name.',
|
|
1100
|
+
none: 'No suggestions for this call. Nothing changed.', checking: 'Checking voices',
|
|
1101
|
+
stopped: 'This meeting or a voice profile changed, so the voice check stopped. Suggest names again to redo it.',
|
|
1102
|
+
stale: 'This meeting changed, so those suggestions were dropped. Suggest names again to refresh them.',
|
|
1103
|
+
didNotFinish: 'The voice check did not finish. Try again.', didNotRun: 'The voice check did not run. Try again later.',
|
|
1104
|
+
still: 'Still checking voices. Click Suggest names, or reopen this meeting, to see where it got to.'
|
|
1105
|
+
};
|
|
1106
|
+
function parseJob(o) {
|
|
1107
|
+
if (!o || ['idle', 'running', 'done', 'refused', 'failed'].indexOf(o.state) < 0) return null;
|
|
1108
|
+
var pr = o.progress || {}, done = int(pr.done), total = int(pr.total);
|
|
1109
|
+
var job = { state: o.state, reason: str(o.reason) || null, done: done != null && total != null && done >= 0 && total >= done ? done : null,
|
|
1110
|
+
total: done != null && total != null && done >= 0 && total >= done ? total : null,
|
|
1111
|
+
sourceRevision: validHash(o.sourceRevision) ? o.sourceRevision : null, suggestions: [] };
|
|
1112
|
+
if (o.state === 'done' && o.modelSpace === '192d' && o.rule && typeof o.rule === 'object') {
|
|
1113
|
+
job.suggestions = (Array.isArray(o.suggestions) ? o.suggestions : []).map(function (x) {
|
|
1114
|
+
if (!x || x.requiresConfirmation !== true || !Array.isArray(x.labels) || !x.labels.length) return null;
|
|
1115
|
+
var labels = x.labels.filter(function (l) { return typeof l === 'string'; });
|
|
1116
|
+
var name = str(x.name), score = num(x.score);
|
|
1117
|
+
if (labels.length !== x.labels.length || !labels.every(isVoiceLabel) || name !== name.trim() || name.length < 2 || name.length > 200
|
|
1118
|
+
|| isVoiceLabel(name) || score == null || score < 0 || score > 1) return null;
|
|
1119
|
+
return { labels: labels, name: name, score: score, id: name + '|' + labels.join('|') };
|
|
1120
|
+
}).filter(Boolean);
|
|
1121
|
+
}
|
|
1122
|
+
return job;
|
|
1123
|
+
}
|
|
1124
|
+
function applyJob(R, r) {
|
|
1125
|
+
var g = R.suggest, prev = g.job;
|
|
1126
|
+
g.probed = true; g.note = null;
|
|
1127
|
+
if (r.status === 404) { g.routeAbsent = true; g.job = null; return; }
|
|
1128
|
+
var job = r.body ? parseJob(r.body) : null;
|
|
1129
|
+
if (!job) { g.routeAbsent = false; g.job = null; g.note = SUG.didNotFinish; return; }
|
|
1130
|
+
g.routeAbsent = false;
|
|
1131
|
+
if (job.state === 'idle' && prev && prev.state === 'done' && prev.sourceRevision !== job.sourceRevision) return;
|
|
1132
|
+
if (job.state === 'idle' && prev && prev.state === 'running') g.note = SUG.stopped;
|
|
1133
|
+
g.job = job;
|
|
1134
|
+
}
|
|
1135
|
+
function pollSuggestions(R, first, attempt, failures) {
|
|
1136
|
+
var g = R.suggest;
|
|
1137
|
+
if (!live(R)) return;
|
|
1138
|
+
if (attempt >= 1200) {
|
|
1139
|
+
var was = suggestKey(R);
|
|
1140
|
+
g.note = SUG.still; g.job = null; g.running = false;
|
|
1141
|
+
if (suggestKey(R) !== was || !paintSuggest(R)) render();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
call(first ? 'POST' : 'GET', sidPath(R.sid) + '/voice-suggestions', first ? {} : undefined, first ? 15000 : undefined).then(function (r) {
|
|
1145
|
+
if (!live(R)) return;
|
|
1146
|
+
var before = suggestKey(R);
|
|
1147
|
+
applyJob(R, r);
|
|
1148
|
+
// Each 1.5 s tick changes only the progress line: patch the bar. The review is rebuilt once, when the answer
|
|
1149
|
+
// changes what the rows show (the bar appears, or suggestions arrive or drop).
|
|
1150
|
+
if (suggestKey(R) !== before || !paintSuggest(R)) render();
|
|
1151
|
+
if (g.job && g.job.state === 'running') g.timer = setTimeout(function () { pollSuggestions(R, false, attempt + 1, 0); }, 1500);
|
|
1152
|
+
else g.running = false;
|
|
1153
|
+
}, function (err) {
|
|
1154
|
+
if (!live(R)) return;
|
|
1155
|
+
if (err && err.status === 401) { g.running = false; return; }
|
|
1156
|
+
if (failures + 1 < 3) { g.timer = setTimeout(function () { pollSuggestions(R, false, attempt + 1, failures + 1); }, 1500); return; }
|
|
1157
|
+
var before = suggestKey(R);
|
|
1158
|
+
if (g.probed) { g.note = SUG.didNotFinish; g.job = null; }
|
|
1159
|
+
g.running = false;
|
|
1160
|
+
if (suggestKey(R) !== before || !paintSuggest(R)) render();
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
// What the rest of the review reads from the check: whether the bar shows, and which suggestions the rows hold.
|
|
1164
|
+
function suggestKey(R) {
|
|
1165
|
+
var j = R.suggest.job;
|
|
1166
|
+
return (showsSuggest(R) ? '1' : '0') + '|' + (j && j.state === 'done' ? (j.sourceRevision || '') + '|' + j.suggestions.map(function (x) { return x.id; }).join(',') : '');
|
|
1167
|
+
}
|
|
1168
|
+
function paintSuggest(R) {
|
|
1169
|
+
var el = $('sp-sbar'), html = suggestBar(R);
|
|
1170
|
+
if (!el || !html || W.parseRoute().pane !== 'speakers') return false;
|
|
1171
|
+
el.outerHTML = html;
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
function probeSuggestions(R) { if (R.review && R.review.canSuggest) { R.suggest.running = true; pollSuggestions(R, false, 0, 0); } }
|
|
1175
|
+
function startSuggestions() {
|
|
1176
|
+
var R = S.R;
|
|
1177
|
+
if (!R || R.busy || !R.review || !showsSuggest(R) || isRunning(R)) return;
|
|
1178
|
+
if (R.suggest.timer) clearTimeout(R.suggest.timer);
|
|
1179
|
+
R.suggest.note = null; R.suggest.job = null; R.suggest.running = true;
|
|
1180
|
+
render();
|
|
1181
|
+
pollSuggestions(R, true, 0, 0);
|
|
1182
|
+
}
|
|
1183
|
+
function showsSuggest(R) { return !!(R.review && R.review.canSuggest && R.suggest.probed && !R.suggest.routeAbsent); }
|
|
1184
|
+
function isRunning(R) { return !!(R.suggest.job && R.suggest.job.state === 'running'); }
|
|
1185
|
+
function suggestLine(R) {
|
|
1186
|
+
if (!showsSuggest(R)) return null;
|
|
1187
|
+
var g = R.suggest, job = g.job;
|
|
1188
|
+
if (g.note) return g.note;
|
|
1189
|
+
if (!job || job.state === 'idle') return SUG.only;
|
|
1190
|
+
if (job.state === 'running') return job.done != null ? (job.total > 0 ? SUG.checking + ', ' + job.done + ' of ' + job.total : SUG.checking) : SUG.checking;
|
|
1191
|
+
if (job.state === 'refused') return refusalText(job.reason);
|
|
1192
|
+
if (job.state === 'failed') return failureText(job.reason);
|
|
1193
|
+
if (!job.sourceRevision || job.sourceRevision !== R.review.sourceRevision) return SUG.stale;
|
|
1194
|
+
return job.suggestions.length ? job.suggestions.length + ' suggestion' + s(job.suggestions.length) + '. ' + SUG.only : SUG.none;
|
|
1195
|
+
}
|
|
1196
|
+
function refusalText(reason) {
|
|
1197
|
+
var map = {
|
|
1198
|
+
recording_active: 'A recording is active. Try again when it ends.', meeting_finalizing: FINALIZING,
|
|
1199
|
+
maintenance: 'COS is updating or restarting. Try again in a minute.', job_running_elsewhere: 'Another call’s voice check is running. Try again when it ends.',
|
|
1200
|
+
speaker_model_unavailable: 'The voice model is not available on this Mac right now.',
|
|
1201
|
+
audio_not_retained: 'This call’s audio is no longer kept, so its voices cannot be checked.',
|
|
1202
|
+
not_enough_voice_profiles: 'There are not enough saved voice profiles to compare with.',
|
|
1203
|
+
profile_store_unreadable: 'The saved voice profiles could not be read. Try again later.',
|
|
1204
|
+
no_index_mapping: 'This recording has no map to find its audio, so its voices cannot be checked.',
|
|
1205
|
+
derived_call_view: 'This call has a grouped view, so its voices cannot be checked here.',
|
|
1206
|
+
not_saved_mac_capture: 'Suggestions work on saved Mac calls only.', sidecar_unreadable: 'This call’s saved speaker data could not be read.'
|
|
1207
|
+
};
|
|
1208
|
+
if (!reason) return SUG.didNotRun;
|
|
1209
|
+
return map[reason] || (reason.indexOf(' ') >= 0 ? reason : SUG.didNotRun);
|
|
1210
|
+
}
|
|
1211
|
+
function failureText(reason) {
|
|
1212
|
+
var map = {
|
|
1213
|
+
recording_active: 'A recording started, so the voice check stopped. Try again when it ends.',
|
|
1214
|
+
maintenance: 'COS was updating or restarting, so the voice check stopped. Try again.',
|
|
1215
|
+
meeting_finalizing: 'This meeting is being saved, so the voice check stopped. Try again in a minute.',
|
|
1216
|
+
deadline: 'The voice check ran out of time. Try again.', profile_store_unreadable: 'The saved voice profiles could not be read. Try again later.'
|
|
1217
|
+
};
|
|
1218
|
+
return map[reason] || SUG.didNotFinish;
|
|
1219
|
+
}
|
|
1220
|
+
// Every suggestion a row holds, widest first; a Mac voice is never offered the wearer's own profile.
|
|
1221
|
+
function suggestionsFor(R, labels) {
|
|
1222
|
+
var rv = R.review, job = R.suggest.job;
|
|
1223
|
+
if (!rv || !job || job.state !== 'done' || !job.sourceRevision || job.sourceRevision !== rv.sourceRevision) return [];
|
|
1224
|
+
var owners = (S.profiles || []).filter(function (p) { return p.isOwner; }).map(function (p) { return p.name; });
|
|
1225
|
+
return job.suggestions.map(function (x, i) { return { x: x, i: i }; }).filter(function (o) {
|
|
1226
|
+
return o.x.labels.every(function (l) { return labels.indexOf(l) >= 0 && rv.byLabel[l] && canNameAlone(rv, rv.byLabel[l]); })
|
|
1227
|
+
&& owners.indexOf(o.x.name) < 0;
|
|
1228
|
+
}).sort(function (a, b) { return a.x.labels.length !== b.x.labels.length ? b.x.labels.length - a.x.labels.length : a.i - b.i; })
|
|
1229
|
+
.map(function (o) { return o.x; });
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// ── review: corrections (two steps, ControllerModel 10657-10969) ──
|
|
1233
|
+
// Classify a per-meeting correction answer the way the helper does (emitMeetingCorrection).
|
|
1234
|
+
function classify(r, confirmed) {
|
|
1235
|
+
var b = r.body;
|
|
1236
|
+
var unknownProfile = r.status === 404 && b && b.reason === 'unknown_profile';
|
|
1237
|
+
if (r.status === 404 && !unknownProfile) return { state: 'route_missing', httpStatus: 404, result: {} };
|
|
1238
|
+
if (!b) throw unreadable(r);
|
|
1239
|
+
var notApplicable = r.status === 400 && ['not_a_voice_label', 'not_mac_capture'].indexOf(str(b.reason)) >= 0;
|
|
1240
|
+
var gated = r.status === 400 && !notApplicable;
|
|
1241
|
+
var pending = r.status === 409;
|
|
1242
|
+
var declined = r.status === 422 || unknownProfile || notApplicable;
|
|
1243
|
+
if (r.status !== 200 && !gated && !pending && !declined) throw new Error(serverMessage(b, r.status));
|
|
1244
|
+
var state;
|
|
1245
|
+
if (pending) {
|
|
1246
|
+
state = { correction_pending: 'pending_correction', meeting_finalizing: 'meeting_finalizing', record_source_mismatch: 'record_source_mismatch',
|
|
1247
|
+
direct_library_read_only: 'direct_library_read_only' }[str(b.reason)] || 'declined';
|
|
1248
|
+
} else if (declined) state = 'declined';
|
|
1249
|
+
else if (confirmed && r.status === 200) state = 'applied';
|
|
1250
|
+
else state = 'preview';
|
|
1251
|
+
return { state: state, httpStatus: r.status, result: b, reason: str(b.reason) || null, message: str(b.message) || str(b.error) || 'Preview ready' };
|
|
1252
|
+
}
|
|
1253
|
+
// The global fold: 409 is the server refusing a below-floor merge, 400 its confirmation gate; both carry the report.
|
|
1254
|
+
function classifyMerge(r, confirmed) {
|
|
1255
|
+
var b = r.body;
|
|
1256
|
+
if (!b) throw unreadable(r);
|
|
1257
|
+
var refused = r.status === 409, gated = r.status === 400;
|
|
1258
|
+
if (r.status !== 200 && !refused && !gated) throw new Error(serverMessage(b, r.status));
|
|
1259
|
+
var report = (b.report && typeof b.report === 'object') ? b.report : (b.preview && typeof b.preview === 'object') ? b.preview : b;
|
|
1260
|
+
// The helper's own line is the fallback the card shows when the report carries no sentence.
|
|
1261
|
+
return { state: refused ? 'refused' : (gated ? 'preview' : (confirmed ? 'merged' : 'preview')), httpStatus: r.status, result: report,
|
|
1262
|
+
isMerge: true, message: refused ? 'Merge refused: voices too far apart' : (confirmed ? 'Profiles merged' : '') };
|
|
1263
|
+
}
|
|
1264
|
+
function buildCorrection(from, to, scope, cls, opts) {
|
|
1265
|
+
var o = opts || {};
|
|
1266
|
+
var state = cls.state, result = cls.result || {};
|
|
1267
|
+
var refused = ['refused', 'declined', 'meeting_finalizing', 'record_source_mismatch', 'direct_library_read_only'].indexOf(state) >= 0;
|
|
1268
|
+
var pendingEarlier = state === 'pending_correction';
|
|
1269
|
+
var training = result.training && typeof result.training === 'object' ? result.training : {};
|
|
1270
|
+
// The global fold is the one irreversible voice write: its preview always states that, never a neutral server line.
|
|
1271
|
+
var serverMsg = cls.isMerge && !refused ? '' : withoutForce(str(result.message) || str(result.error) || cls.message || '', '');
|
|
1272
|
+
var message;
|
|
1273
|
+
if (state === 'meeting_finalizing') message = FINALIZING;
|
|
1274
|
+
else if (state === 'record_source_mismatch') message = 'This meeting changed since you opened it. Reopen it, then try again.';
|
|
1275
|
+
else if (state === 'direct_library_read_only') message = 'This meeting comes from a read-only library, so its speakers cannot be corrected here.';
|
|
1276
|
+
else if (pendingEarlier) message = EARLIER;
|
|
1277
|
+
else if (refused) message = serverMsg || 'The server declined this, so nothing was applied.';
|
|
1278
|
+
else if (serverMsg) message = serverMsg;
|
|
1279
|
+
else if (to == null) message = 'Removes ' + from + ' from this meeting.';
|
|
1280
|
+
else message = scope === 'thisMeeting' ? 'Renames ' + from + ' to ' + to + ' in this meeting only.'
|
|
1281
|
+
: 'Folds ' + from + ' into ' + to + ' across every meeting. This cannot be undone here.';
|
|
1282
|
+
var surf = result.surfaces && typeof result.surfaces === 'object' ? result.surfaces : {};
|
|
1283
|
+
var sim = result.similarity && typeof result.similarity === 'object' ? num(result.similarity[from]) : null;
|
|
1284
|
+
var assignment = o.assign && !(refused || pendingEarlier) ? parseAssignment(result.enrolment) : null;
|
|
1285
|
+
return {
|
|
1286
|
+
from: from, to: to, scope: scope, message: message,
|
|
1287
|
+
surfaces: { sidecar: int(surf.sidecar) || 0, attendees: int(surf.attendees) || 0, transcript: int(surf.transcript) || 0 },
|
|
1288
|
+
similarity: sim, refused: refused || pendingEarlier, forceable: pendingEarlier, proseStale: result.proseStale === true,
|
|
1289
|
+
isNameAssignment: !!o.isNameAssignment, createsProfile: !!o.createsProfile,
|
|
1290
|
+
wouldRetract: int(training.wouldRetract) || 0, untraceable: int(training.untraceable) || 0,
|
|
1291
|
+
assignment: assignment, assignUnsupported: !!o.assign && !(refused || pendingEarlier) && !assignment
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function addsSamples(c) { return !!(c.assignment && c.assignment.canAdd); }
|
|
1295
|
+
function needsForce(c) { return !!(c.assignment && c.assignment.needsForce); }
|
|
1296
|
+
// Which assignment flags a confirm sends; null means a flagged plan that must not be sent by a plain Save.
|
|
1297
|
+
function assignFlags(c, renameOnly, addAnyway, armed) {
|
|
1298
|
+
if (!addsSamples(c) || renameOnly) return {};
|
|
1299
|
+
if (needsForce(c)) return addAnyway && armed ? { assignVoiceProfile: true, force: true } : null;
|
|
1300
|
+
return { assignVoiceProfile: true };
|
|
1301
|
+
}
|
|
1302
|
+
function guardWrite(R) {
|
|
1303
|
+
var rv = R && R.review;
|
|
1304
|
+
if (!rv || !rv.mutable) { if (R) R.error = READ_ONLY; render(); return false; }
|
|
1305
|
+
if (isPhoneCapture(rv.sessionId)) { R.error = PHONE_NOTE; render(); return false; }
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
function withRevision(rv, body) {
|
|
1309
|
+
if (rv.recordId) body.recordId = rv.recordId;
|
|
1310
|
+
if (rv.sourceRevision) body.expectedRevision = rv.sourceRevision;
|
|
1311
|
+
return body;
|
|
1312
|
+
}
|
|
1313
|
+
function previewRename(from, to, scope, isNameAssignment, assign) {
|
|
1314
|
+
var R = S.R;
|
|
1315
|
+
if (R) R.anchor = from;
|
|
1316
|
+
if (from === to || !guardWrite(R)) return;
|
|
1317
|
+
var rv = R.review;
|
|
1318
|
+
scope = rv.isMac ? 'thisMeeting' : scope;
|
|
1319
|
+
var profiles = S.profiles || [];
|
|
1320
|
+
var createsProfile = !rv.isMac && !profiles.some(function (p) { return p.name.toLowerCase() === to.toLowerCase(); });
|
|
1321
|
+
var assigned = assign && rv.isMac && isVoiceLabel(from) ? (profiles.filter(function (p) { return p.name === to && !p.isOwner; })[0] || {}).name : null;
|
|
1322
|
+
R.forceArmed = false; R.groupPlan = null; R.busy = true; R.anchor = from;
|
|
1323
|
+
render();
|
|
1324
|
+
var req = scope === 'thisMeeting'
|
|
1325
|
+
? call('POST', sidPath(R.sid) + '/relabel', withRevision(rv, assigned ? { from: from, to: to, assignVoiceProfile: true, dryRun: true } : { from: from, to: to, dryRun: true })).then(function (r) { return classify(r, false); })
|
|
1326
|
+
: call('POST', '/api/voice/merge-profiles', { into: to, from: from, dryRun: true }).then(function (r) { return classifyMerge(r, false); });
|
|
1327
|
+
req.then(function (cls) {
|
|
1328
|
+
if (!live(R)) return;
|
|
1329
|
+
R.pending = buildCorrection(from, to, scope, cls, { isNameAssignment: isNameAssignment, createsProfile: createsProfile, assign: !!assigned });
|
|
1330
|
+
R.naming = null; R.error = '';
|
|
1331
|
+
}, function (err) {
|
|
1332
|
+
if (!live(R)) return;
|
|
1333
|
+
R.error = 'Could not check that name: ' + err.message; R.pending = null;
|
|
1334
|
+
}).then(function () { if (live(R)) { R.busy = false; render(); } });
|
|
1335
|
+
}
|
|
1336
|
+
function previewDeattribution(from) {
|
|
1337
|
+
var R = S.R;
|
|
1338
|
+
if (R) R.anchor = from;
|
|
1339
|
+
if (!guardWrite(R)) return;
|
|
1340
|
+
var rv = R.review;
|
|
1341
|
+
R.busy = true; R.anchor = from; render();
|
|
1342
|
+
call('POST', sidPath(R.sid) + '/deattribute', withRevision(rv, { from: from, dryRun: true })).then(function (r) {
|
|
1343
|
+
if (!live(R)) return;
|
|
1344
|
+
R.pending = buildCorrection(from, null, 'thisMeeting', classify(r, false), {});
|
|
1345
|
+
R.naming = null; R.error = '';
|
|
1346
|
+
}).catch(function (err) {
|
|
1347
|
+
if (!live(R)) return;
|
|
1348
|
+
R.error = 'Could not check that: ' + err.message; R.pending = null;
|
|
1349
|
+
}).then(function () { if (live(R)) { R.busy = false; render(); } });
|
|
1350
|
+
}
|
|
1351
|
+
// Vouch for a label the display floor withheld. It rewrites nothing, so there is no preview step.
|
|
1352
|
+
function confirmVoice(label) {
|
|
1353
|
+
var R = S.R;
|
|
1354
|
+
if (R) R.anchor = label;
|
|
1355
|
+
if (!guardWrite(R)) return;
|
|
1356
|
+
var rv = R.review;
|
|
1357
|
+
R.busy = true; R.anchor = label; render();
|
|
1358
|
+
call('POST', sidPath(R.sid) + '/confirm', withRevision(rv, { label: label })).then(function (r) {
|
|
1359
|
+
if (r.status !== 200 || !r.body) throw new Error(serverMessage(r.body, r.status));
|
|
1360
|
+
if (!live(R)) return;
|
|
1361
|
+
R.error = ''; S.dir = null;
|
|
1362
|
+
return fetchReview(R, false);
|
|
1363
|
+
}).catch(function (err) { if (live(R)) R.error = 'Could not confirm that name: ' + err.message; })
|
|
1364
|
+
.then(function () { if (live(R)) { R.busy = false; render(); } });
|
|
1365
|
+
}
|
|
1366
|
+
function confirmCorrection(c, o) {
|
|
1367
|
+
var R = S.R, opt = o || {};
|
|
1368
|
+
if (c.refused && !opt.force) { R.pending = null; render(); return; }
|
|
1369
|
+
var flags = assignFlags(c, !!opt.renameOnly, !!opt.addAnyway, R.forceArmed);
|
|
1370
|
+
if (!flags) return;
|
|
1371
|
+
if (!guardWrite(R)) return;
|
|
1372
|
+
var rv = R.review, path, body, merge = false;
|
|
1373
|
+
if (c.to == null) { path = sidPath(R.sid) + '/deattribute'; body = withRevision(rv, { from: c.from, confirm: true }); }
|
|
1374
|
+
else if (c.scope === 'thisMeeting') {
|
|
1375
|
+
path = sidPath(R.sid) + '/relabel';
|
|
1376
|
+
body = withRevision(rv, { from: c.from, to: c.to, confirm: true });
|
|
1377
|
+
if (flags.assignVoiceProfile) body.assignVoiceProfile = true;
|
|
1378
|
+
if (flags.force) body.force = true;
|
|
1379
|
+
} else { merge = true; path = '/api/voice/merge-profiles'; body = { into: c.to, from: c.from, confirm: true }; }
|
|
1380
|
+
// "Apply anyway" passes force only for a stalled earlier correction; a merge refusal is never forced from here.
|
|
1381
|
+
if (opt.force && !merge) body.force = true;
|
|
1382
|
+
R.busy = true; render();
|
|
1383
|
+
call('POST', path, body).then(function (r) {
|
|
1384
|
+
if (!live(R)) return;
|
|
1385
|
+
var cls = merge ? classifyMerge(r, true) : classify(r, true);
|
|
1386
|
+
var applied = cls.state === 'applied' || (merge && cls.state === 'merged');
|
|
1387
|
+
if (!applied) {
|
|
1388
|
+
var result = cls.result || {};
|
|
1389
|
+
var detail = withoutForce(str(result.message) || str(result.error) || cls.message || '', '') || null;
|
|
1390
|
+
var fresh = GATES.indexOf(str(result.reason)) >= 0 ? parseAssignment(result.enrolment) : null;
|
|
1391
|
+
if (fresh) { c.assignment = fresh; c.assignUnsupported = false; R.forceArmed = false; R.error = detail || ''; return; }
|
|
1392
|
+
if (cls.state === 'route_missing') R.error = 'This needs COS server 6.21.18 or newer. Use Update Server.';
|
|
1393
|
+
else if (cls.state === 'pending_correction') R.error = EARLIER;
|
|
1394
|
+
else if (cls.state === 'meeting_finalizing') R.error = FINALIZING;
|
|
1395
|
+
else R.error = detail || 'The server did not apply that (' + cls.state + ').';
|
|
1396
|
+
return; // the card stays so the person can retry or cancel deliberately
|
|
1397
|
+
}
|
|
1398
|
+
var en = (cls.result && cls.result.enrolment) || {};
|
|
1399
|
+
var created = en.created === true, enrolled = int(en.enrolled) || 0;
|
|
1400
|
+
var notice;
|
|
1401
|
+
if (created && c.to) notice = c.to + ' added to voice profiles (' + enrolled + ' sample' + s(enrolled) + ')';
|
|
1402
|
+
else if (enrolled > 0 && c.to) notice = c.from + ' is now ' + c.to + ' in this meeting · ' + enrolled + ' samples added';
|
|
1403
|
+
else if (c.to == null) notice = 'Removed ' + c.from + ' from this meeting';
|
|
1404
|
+
else if (c.scope === 'thisMeeting') notice = c.from + ' is now ' + c.to + ' in this meeting' + skipNote(str(en.skipped));
|
|
1405
|
+
else notice = 'Saved. ' + c.from + ' is now ' + c.to + ' everywhere';
|
|
1406
|
+
W.toast(notice);
|
|
1407
|
+
R.pending = null; R.scope = 'thisMeeting'; R.error = ''; R.voiceNamed = true; S.dir = null;
|
|
1408
|
+
return loadProfiles().then(function () { if (live(R)) return fetchReview(R, false); });
|
|
1409
|
+
}).catch(function (err) {
|
|
1410
|
+
if (!live(R)) return;
|
|
1411
|
+
R.error = err.message; R.pending = null;
|
|
1412
|
+
}).then(function () { if (live(R)) { R.busy = false; render(); } });
|
|
1413
|
+
}
|
|
1414
|
+
function groupStepRefusal(cls) {
|
|
1415
|
+
var detail = withoutForce(str(cls.result && (cls.result.message || cls.result.error)) || cls.message || '', '');
|
|
1416
|
+
if (cls.state === 'route_missing') return 'This needs a newer COS server. Use Update Server.';
|
|
1417
|
+
if (cls.state === 'pending_correction') return EARLIER;
|
|
1418
|
+
if (cls.state === 'meeting_finalizing') return FINALIZING;
|
|
1419
|
+
return detail || 'The server did not apply that (' + cls.state + ').';
|
|
1420
|
+
}
|
|
1421
|
+
function labelList(labels) {
|
|
1422
|
+
var all = labels.every(isVoiceLabel);
|
|
1423
|
+
var shown = all ? labels.map(function (l) { return l.slice(6); }) : labels;
|
|
1424
|
+
if (!shown.length) return 'no labels';
|
|
1425
|
+
return (all ? 'Voice ' : '') + (shown.length === 1 ? shown[0] : shown.slice(0, -1).join(', ') + ' and ' + shown[shown.length - 1]);
|
|
1426
|
+
}
|
|
1427
|
+
function groupOutcomeLine(o) {
|
|
1428
|
+
var named = o.labels.slice(0, o.namedCount), notNamed = o.labels.slice(o.namedCount);
|
|
1429
|
+
var sentence;
|
|
1430
|
+
if (!o.reason) sentence = 'Named ' + labelList(named) + ' ' + o.to + ' in this meeting.';
|
|
1431
|
+
else {
|
|
1432
|
+
var why = o.stoppedAt ? 'Stopped at ' + o.stoppedAt + ': ' + o.reason : o.reason;
|
|
1433
|
+
sentence = !named.length ? 'Nothing was named. ' + why : 'Named ' + labelList(named) + ' ' + o.to + '. Not named: ' + labelList(notNamed) + '. ' + why;
|
|
1434
|
+
}
|
|
1435
|
+
var seen = {};
|
|
1436
|
+
return sentence + (o.skipped || []).filter(function (k) { if (seen[k]) return false; seen[k] = true; return true; }).map(skipNote).join('');
|
|
1437
|
+
}
|
|
1438
|
+
// Naming a merged row: one dryRun per label at one revision, nothing written until Save.
|
|
1439
|
+
// `anchor` is where the plan card, progress and any refusal draw: the merged row's id, or null for the top of the list.
|
|
1440
|
+
function previewGroupName(row, rawName, assign, anchor) {
|
|
1441
|
+
var R = S.R, rv = R && R.review;
|
|
1442
|
+
var name = String(rawName || '').trim();
|
|
1443
|
+
var at = anchor === undefined ? row.id : anchor;
|
|
1444
|
+
if (!rv || R.busy || name.length < 2 || !row.members.every(function (v) { return canNameAlone(rv, v); })) {
|
|
1445
|
+
if (R) { R.anchor = at; R.error = 'This voice group changed. Reopen the meeting before naming it.'; render(); }
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (isPhoneCapture(rv.sessionId)) { R.anchor = at; R.error = PHONE_NOTE; render(); return; }
|
|
1449
|
+
var assigned = assign && (S.profiles || []).some(function (p) { return p.name === name && !p.isOwner; });
|
|
1450
|
+
var labels = row.members.map(function (v) { return v.label; });
|
|
1451
|
+
R.busy = true; R.pending = null; R.groupPlan = null; R.groupOutcome = ''; R.anchor = at;
|
|
1452
|
+
var corrections = [], i = 0;
|
|
1453
|
+
function step() {
|
|
1454
|
+
if (!live(R)) return Promise.resolve();
|
|
1455
|
+
if (i >= row.members.length) return Promise.resolve(true);
|
|
1456
|
+
var v = row.members[i];
|
|
1457
|
+
R.groupProgress = 'Checking ' + v.label + ', ' + (i + 1) + ' of ' + labels.length; render();
|
|
1458
|
+
var body = withRevision(rv, { from: v.label, to: name, dryRun: true });
|
|
1459
|
+
if (assigned) body.assignVoiceProfile = true;
|
|
1460
|
+
return call('POST', sidPath(R.sid) + '/relabel', body).then(function (r) {
|
|
1461
|
+
if (!live(R)) return;
|
|
1462
|
+
var cls = classify(r, false);
|
|
1463
|
+
var c = buildCorrection(v.label, name, 'thisMeeting', cls, { isNameAssignment: v.isNameAssignment, assign: assigned });
|
|
1464
|
+
if (c.refused || cls.state !== 'preview') {
|
|
1465
|
+
R.groupOutcome = groupOutcomeLine({ to: name, labels: labels, namedCount: 0, stoppedAt: v.label, reason: c.refused ? c.message : groupStepRefusal(cls) });
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
corrections.push(c); i++;
|
|
1469
|
+
return step();
|
|
1470
|
+
}, function (err) {
|
|
1471
|
+
if (!live(R)) return;
|
|
1472
|
+
R.groupOutcome = groupOutcomeLine({ to: name, labels: labels, namedCount: 0, stoppedAt: v.label, reason: err.message });
|
|
1473
|
+
return false;
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
render();
|
|
1477
|
+
step().then(function (ok) {
|
|
1478
|
+
if (!live(R) || !ok) return;
|
|
1479
|
+
R.naming = null; R.error = '';
|
|
1480
|
+
R.groupPlan = { rowId: row.id, revision: rv.sourceRevision, to: name, corrections: corrections, labels: labels };
|
|
1481
|
+
}).then(function () { if (live(R)) { R.busy = false; R.groupProgress = ''; render(); } });
|
|
1482
|
+
}
|
|
1483
|
+
// Save a previewed group one label at a time; each write moves the revision, so the review is read between writes.
|
|
1484
|
+
function confirmGroupName(plan) {
|
|
1485
|
+
var R = S.R, rv = R && R.review;
|
|
1486
|
+
if (!rv || R.busy || !R.groupPlan || R.groupPlan.rowId !== plan.rowId || !rv.mutable || rv.sourceRevision !== plan.revision) {
|
|
1487
|
+
if (R) { R.groupPlan = null; R.error = 'This voice group changed. Reopen the meeting before saving a name.'; render(); }
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
R.busy = true; R.groupPlan = null; R.groupOutcome = '';
|
|
1491
|
+
var named = 0, samples = 0, stop = null, skips = [], review = rv, i = 0;
|
|
1492
|
+
function step() {
|
|
1493
|
+
if (i >= plan.corrections.length) return Promise.resolve();
|
|
1494
|
+
var c = plan.corrections[i];
|
|
1495
|
+
if (!review.byLabel[c.from] || !review.byLabel[c.from].canRename) { stop = 'It is no longer in this meeting.'; return Promise.resolve(); }
|
|
1496
|
+
R.groupProgress = 'Naming ' + c.from + ', ' + (i + 1) + ' of ' + plan.labels.length; render();
|
|
1497
|
+
var body = withRevision(review, { from: c.from, to: plan.to, confirm: true });
|
|
1498
|
+
var f = assignFlags(c, needsForce(c), false, false) || {};
|
|
1499
|
+
if (f.assignVoiceProfile) body.assignVoiceProfile = true;
|
|
1500
|
+
return call('POST', sidPath(R.sid) + '/relabel', body).then(function (r) {
|
|
1501
|
+
var cls = classify(r, true);
|
|
1502
|
+
if (cls.state !== 'applied') { stop = groupStepRefusal(cls); return; }
|
|
1503
|
+
named++;
|
|
1504
|
+
var en = (cls.result && cls.result.enrolment) || {};
|
|
1505
|
+
samples += int(en.enrolled) || 0;
|
|
1506
|
+
if (str(en.skipped)) skips.push(en.skipped);
|
|
1507
|
+
i++;
|
|
1508
|
+
if (i >= plan.corrections.length) return;
|
|
1509
|
+
if (!live(R)) { stop = 'Another meeting was opened, so the rest were left.'; return; }
|
|
1510
|
+
return call('GET', sidPath(R.sid) + '/speakers').then(function (g) {
|
|
1511
|
+
var next = g.status === 200 ? parseReview(g.body) : null;
|
|
1512
|
+
if (!next || next.sessionId !== R.sid || !next.mutable) { stop = 'The meeting could not be read again after that name.'; return; }
|
|
1513
|
+
review = next; R.review = next;
|
|
1514
|
+
return step();
|
|
1515
|
+
});
|
|
1516
|
+
}).catch(function (err) { stop = err.message; });
|
|
1517
|
+
}
|
|
1518
|
+
render();
|
|
1519
|
+
step().then(function () {
|
|
1520
|
+
var line = groupOutcomeLine({ to: plan.to, labels: plan.labels, namedCount: named, stoppedAt: stop ? plan.labels[named] : null, reason: stop, skipped: skips });
|
|
1521
|
+
if (!live(R)) { W.toast(line); return; }
|
|
1522
|
+
var after = named > 0 ? loadProfiles().then(function () { S.dir = null; R.voiceNamed = true; return fetchReview(R, false); }) : Promise.resolve();
|
|
1523
|
+
return after.then(function () {
|
|
1524
|
+
if (!stop) W.toast(line + (samples > 0 ? ' ' + samples + ' voice sample' + s(samples) + ' added.' : ''));
|
|
1525
|
+
else R.groupOutcome = line;
|
|
1526
|
+
});
|
|
1527
|
+
}).then(function () { if (live(R)) { R.busy = false; R.groupProgress = ''; render(); } });
|
|
1528
|
+
}
|
|
1529
|
+
function useSuggestion(sg, rowLabels) {
|
|
1530
|
+
var R = S.R, rv = R && R.review;
|
|
1531
|
+
if (!rv || !suggestionsFor(R, rowLabels).some(function (x) { return x.id === sg.id; })) return;
|
|
1532
|
+
var members = sg.labels.map(function (l) { return rv.byLabel[l]; }).filter(Boolean);
|
|
1533
|
+
if (members.length !== sg.labels.length || !members.length) return;
|
|
1534
|
+
if (members.length === 1) previewRename(members[0].label, sg.name, 'thisMeeting', members[0].isNameAssignment, true);
|
|
1535
|
+
else {
|
|
1536
|
+
// The card belongs under the merged row that holds this suggestion, never under an id nothing renders.
|
|
1537
|
+
var holder = null;
|
|
1538
|
+
((rv.layout && rv.layout.rows) || []).forEach(function (r) {
|
|
1539
|
+
if (r.merged && r.merged.members.map(function (v) { return v.label; }).join('|') === rowLabels.join('|')) holder = r.merged.id;
|
|
1540
|
+
});
|
|
1541
|
+
previewGroupName({ id: 'suggestion:' + sg.id, members: members, seconds: null }, sg.name, true, holder);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// ── render: shell ──────────────────────────────────────
|
|
1546
|
+
// #/speakers shows whichever view the picker holds, so Back from any of the three leaves the section (Control's Back
|
|
1547
|
+
// from a section root goes home) and the phone keeps its section switcher. #/speakers/samples and /voices still open
|
|
1548
|
+
// as deep links: load() selects the view and rewrites the hash to #/speakers.
|
|
1549
|
+
function route() {
|
|
1550
|
+
var a = W.parseRoute().args;
|
|
1551
|
+
if (a[0] === 'review' && a[1]) return { view: S.view, review: a[1] };
|
|
1552
|
+
if (a[0] === 'voice' && a[1] && a[2] === 'review' && a[3]) return { view: 'voices', voice: a[1], review: a[3] };
|
|
1553
|
+
if (a[0] === 'voice' && a[1]) return { view: 'voices', voice: a[1] };
|
|
1554
|
+
return { view: S.view };
|
|
1555
|
+
}
|
|
1556
|
+
function viewOf(id) { return VIEWS.filter(function (v) { return v.id === id; })[0] || VIEWS[0]; }
|
|
1557
|
+
function heroDetail(view) {
|
|
1558
|
+
if (view === 'samples') return samplesDetail();
|
|
1559
|
+
if (view === 'meetings') {
|
|
1560
|
+
var list = S.meetings || [];
|
|
1561
|
+
if (!list.length) return 'Choose a saved meeting to name its voices.';
|
|
1562
|
+
var parts = [list.length + ' recent saved meetings'];
|
|
1563
|
+
var unnamed = list.filter(function (m) { var t = voiceTag(m); return t && t.kind === 'needs'; }).length;
|
|
1564
|
+
var done = list.filter(function (m) { var t = voiceTag(m); return t && t.kind === 'reviewed'; }).length;
|
|
1565
|
+
var fresh = list.filter(function (m) { return isNew(m.sessionId); }).length;
|
|
1566
|
+
if (unnamed > 0) parts.push(unnamed + ' still need names');
|
|
1567
|
+
if (done > 0) parts.push(done + ' reviewed');
|
|
1568
|
+
if (fresh > 0) parts.push(fresh + ' new');
|
|
1569
|
+
return parts.join(' · ');
|
|
1570
|
+
}
|
|
1571
|
+
var dir = S.dir || [];
|
|
1572
|
+
if (!dir.length) return 'Enrolled identities and cross-meeting evidence.';
|
|
1573
|
+
if (S.dirRoute === false) return dir.length + ' enrolled profiles · history unavailable on this server';
|
|
1574
|
+
var review = dir.reduce(function (t, p) { return t + p.reviewMeetingCount; }, 0);
|
|
1575
|
+
var samples = dir.reduce(function (t, p) { return t + p.embeddings; }, 0);
|
|
1576
|
+
return fmt(dir.length) + ' enrolled · ' + fmt(samples) + ' sample' + s(samples) + ' · ' + fmt(review) + ' review occurrence' + s(review);
|
|
1577
|
+
}
|
|
1578
|
+
function hero(view) {
|
|
1579
|
+
var loading = view === 'meetings' ? S.meetingsLoading : view === 'samples' ? !!(S.H && S.H.loading) : S.dirLoading;
|
|
1580
|
+
var title = view === 'meetings' ? refreshTitle() : 'Refresh';
|
|
1581
|
+
var prominent = view === 'meetings' && S.refreshNeeded;
|
|
1582
|
+
return '<header class="cx-hero" data-tint="speakers"><span class="cx-glyph">' + icon('speakers') + '</span>' +
|
|
1583
|
+
'<div class="cx-hero-text"><h1 class="cx-title">' + esc(viewOf(view).hero) + '</h1><p class="cx-detail-line">' + esc(heroDetail(view)) + '</p></div>' +
|
|
1584
|
+
'<div class="cx-actions"><button type="button" class="cx-btn ' + (prominent ? 'primary' : 'quiet') + '" data-sp="refresh"' + (loading ? ' disabled' : '') + '>' +
|
|
1585
|
+
icon('refresh') + esc(title) + '</button></div></header>';
|
|
1586
|
+
}
|
|
1587
|
+
function select(id, label, options, value) {
|
|
1588
|
+
return '<label class="sp-select"><select class="field" id="' + id + '" aria-label="' + esc(label) + '">' +
|
|
1589
|
+
options.map(function (o) { return '<option value="' + esc(o[0]) + '"' + (o[0] === value ? ' selected' : '') + '>' + esc(o[1]) + '</option>'; }).join('') +
|
|
1590
|
+
'</select></label>';
|
|
1591
|
+
}
|
|
1592
|
+
function toolbar(view) {
|
|
1593
|
+
var sw = '<div class="cx-switch" role="group" aria-label="Speaker view"><span class="cx-switch-label">Speaker view</span>' +
|
|
1594
|
+
VIEWS.map(function (v) { return '<button type="button" data-sp="view" data-view="' + v.id + '" aria-pressed="' + (v.id === view) + '">' + esc(v.title) + '</button>'; }).join('') + '</div>';
|
|
1595
|
+
var extra = '';
|
|
1596
|
+
if (view === 'meetings') {
|
|
1597
|
+
extra = '<label class="cx-check sp-check" title="Keep finished meetings off the list while you work through names."><input type="checkbox" id="sp-hide"' + (P.hideReviewed ? ' checked' : '') + '>Hide reviewed</label>' +
|
|
1598
|
+
select('sp-msort', 'Sort meetings', MEETING_SORTS, P.sort);
|
|
1599
|
+
} else if (view === 'voices') {
|
|
1600
|
+
extra = '<div class="cx-search sp-search"><input class="field" type="search" id="sp-vq" placeholder="Search voices" value="' + esc(S.voiceSearch) + '" autocomplete="off" aria-label="Search enrolled voices"></div>' +
|
|
1601
|
+
select('sp-vsort', 'Sort voices', VOICE_SORTS, S.voiceSort);
|
|
1602
|
+
}
|
|
1603
|
+
return '<div class="cx-controls sp-controls">' + sw + '<div class="sp-tools">' + extra + '</div></div>';
|
|
1604
|
+
}
|
|
1605
|
+
function emptyState(text, extra) {
|
|
1606
|
+
return '<div class="sp-empty"><span class="cx-glyph">' + icon('speakers') + '</span><p>' + esc(text) + '</p>' + (extra || '') + '</div>';
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// ── render: Meetings to review ─────────────────────────
|
|
1610
|
+
function pills(m) {
|
|
1611
|
+
var out = '';
|
|
1612
|
+
if (isNew(m.sessionId)) out += '<span class="cx-pill warn">NEW</span>';
|
|
1613
|
+
var t = voiceTag(m);
|
|
1614
|
+
if (t && t.kind === 'reviewed') out += '<span class="cx-pill ok">REVIEWED</span>';
|
|
1615
|
+
else if (t && t.kind === 'needs') out += '<span class="cx-pill warn">' + (t.n === 1 ? '1 TO NAME' : t.n + ' TO NAME') + '</span>';
|
|
1616
|
+
return out;
|
|
1617
|
+
}
|
|
1618
|
+
function meetingRow(m) {
|
|
1619
|
+
var p = pills(m);
|
|
1620
|
+
return '<button type="button" class="cx-row sp-mrow" data-sp="open-review" data-sid="' + esc(m.sessionId) + '">' +
|
|
1621
|
+
'<span class="cx-row-glyph">' + icon('speakers') + '</span><span class="cx-row-body">' +
|
|
1622
|
+
'<span class="cx-row-title strong sp-one">' + esc(m.title) + '</span>' +
|
|
1623
|
+
'<span class="sp-mono">' + esc(dateLine(m)) + '</span>' +
|
|
1624
|
+
'<span class="sp-counts">' + esc(countsSummary(m)) + '</span>' +
|
|
1625
|
+
(p ? '<span class="sp-pills sp-pills-in">' + p + '</span>' : '') + '</span>' +
|
|
1626
|
+
'<span class="sp-pills sp-pills-side">' + p + '</span><span class="chev"></span></button>';
|
|
1627
|
+
}
|
|
1628
|
+
function meetingsBody() {
|
|
1629
|
+
if (S.meetingsLoading && !S.meetings) return '<p class="cx-note sp-loading">' + spin() + 'Loading meetings…</p>';
|
|
1630
|
+
if (!S.meetings) return '';
|
|
1631
|
+
if (!S.meetings.length) return emptyState(S.meetingsError || 'No reviewable meetings yet.');
|
|
1632
|
+
var rows = visibleMeetings();
|
|
1633
|
+
if (!rows.length) return emptyState('All recent meetings are reviewed. Turn off Hide reviewed to see them.');
|
|
1634
|
+
return '<div class="cx-list sp-list">' + rows.map(meetingRow).join('') + '</div>';
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// ── render: Samples to review ──────────────────────────
|
|
1638
|
+
function samplesDetail() {
|
|
1639
|
+
var H = S.H;
|
|
1640
|
+
if (!H || H.state == null) return 'Asking the server what it is holding.';
|
|
1641
|
+
if (H.state === 'error') return 'Held voices could not be grouped. The card says why.';
|
|
1642
|
+
if (H.extLoadFailed && !heldUsable(H)) return 'Held audio could not be read. Refresh to try again.';
|
|
1643
|
+
if (heldUsable(H)) {
|
|
1644
|
+
var v = H.groups.length, l = H.loose.length;
|
|
1645
|
+
if (!v && !l) return 'Unrecognized voices wait here for 72 hours.';
|
|
1646
|
+
return fmt(v) + ' held voice' + s(v) + ' · ' + fmt(l) + ' loose sample' + s(l) + ' · kept 72 hours';
|
|
1647
|
+
}
|
|
1648
|
+
var n = H.extSessions.length;
|
|
1649
|
+
return n ? fmt(n) + ' unrecognized session' + s(n) + ' · kept 72 hours' : 'Unrecognized voices wait here for 72 hours.';
|
|
1650
|
+
}
|
|
1651
|
+
function heldLead(H) {
|
|
1652
|
+
var v = H.groups.length, l = H.loose.length, p = H.pending;
|
|
1653
|
+
if (!v && !l) return p > 0 ? 'Reading ' + p + ' held sample' + s(p) + '. Refresh in a moment.' : 'No unrecognized audio is being held. Record a meeting, then come back within 72 hours.';
|
|
1654
|
+
var parts = [];
|
|
1655
|
+
if (v > 0) parts.push(v + ' voice' + s(v) + ' the server is holding, grouped by who they sound like across meetings.');
|
|
1656
|
+
if (l > 0) {
|
|
1657
|
+
var sug = H.loose.filter(function (x) { return x.suggestion; }).length;
|
|
1658
|
+
parts.push(l + ' loose sample' + s(l) + ' to review individually' + (sug > 0 ? ', ' + sug + ' with a suggestion' : '') + '.');
|
|
1659
|
+
}
|
|
1660
|
+
if (p > 0) parts.push(p + ' still being read.');
|
|
1661
|
+
if (H.unusable > 0) parts.push(H.unusable + ' could not be read and will expire with the window.');
|
|
1662
|
+
if (!H.speakerModel) parts.push('The speaker model is not loaded on this Mac (Run checks shows it): voices can be heard and discarded, not named yet.');
|
|
1663
|
+
return parts.join(' ');
|
|
1664
|
+
}
|
|
1665
|
+
function notice(text) { return '<div class="sp-notice"><span class="sp-i" aria-hidden="true">i</span><span>' + esc(text) + '</span></div>'; }
|
|
1666
|
+
function listenStrip(key, refs, voice) {
|
|
1667
|
+
if (!refs.length) return '';
|
|
1668
|
+
var H = S.H;
|
|
1669
|
+
var cur = Math.min(Math.max(H.cursor[key] || 0, 0), refs.length - 1);
|
|
1670
|
+
var ref = refs[cur], playKey = 'held:' + ref.sessionId + '#' + ref.chunkIndex;
|
|
1671
|
+
var playing = A.key === playKey;
|
|
1672
|
+
return '<div class="sp-listen" data-key="' + esc(key) + '">' +
|
|
1673
|
+
'<button type="button" class="sp-play' + (playing ? ' on' : '') + '" data-sp="held-play" data-key="' + esc(key) + '" data-voice="' + esc(voice) + '" aria-label="' +
|
|
1674
|
+
(playing ? 'Stop' : 'Listen to sample ' + (cur + 1) + ' of ' + refs.length) + '" title="' + (playing ? 'Stop' : 'Listen to this sample') + '">' +
|
|
1675
|
+
(playing ? '<span class="sp-stop-glyph"></span>' : '<span class="sp-play-glyph"></span>') + '</button>' +
|
|
1676
|
+
'<button type="button" class="sp-step" data-sp="held-prev" data-key="' + esc(key) + '" aria-label="Previous sample"' + (refs.length < 2 ? ' disabled' : '') + '>‹</button>' +
|
|
1677
|
+
'<span class="sp-pos">' + (cur + 1) + ' of ' + refs.length + '</span>' +
|
|
1678
|
+
'<button type="button" class="sp-step" data-sp="held-next" data-key="' + esc(key) + '" aria-label="Next sample"' + (refs.length < 2 ? ' disabled' : '') + '>›</button></div>';
|
|
1679
|
+
}
|
|
1680
|
+
// The "Adds to" hint matches the server's case-folded, compatibility-normalized lookup.
|
|
1681
|
+
function nameHint(typed) {
|
|
1682
|
+
var name = String(typed || '').trim();
|
|
1683
|
+
if (name.length < 2) return '';
|
|
1684
|
+
var fold = function (x) { try { return x.normalize('NFKC').toLowerCase(); } catch (_e) { return x.toLowerCase(); } };
|
|
1685
|
+
var dir = S.dir || [];
|
|
1686
|
+
var matches = dir.filter(function (p) { return fold(p.name) === fold(name); });
|
|
1687
|
+
var exact = matches.length === 1 ? matches[0] : null;
|
|
1688
|
+
var lines = [];
|
|
1689
|
+
if (matches.length > 1) lines.push(esc('Several stored voices share this spelling. Resolve the duplicate names before applying.'));
|
|
1690
|
+
else if (exact) {
|
|
1691
|
+
lines.push(esc('Adds to ' + exact.name + ', who has ' + exact.embeddings + ' sample' + s(exact.embeddings) + '.'));
|
|
1692
|
+
if (exact.embeddings >= 40) lines.push(esc('At the 40-sample limit, the server chooses which voice samples to keep.'));
|
|
1693
|
+
if (exact.isOwner) lines.push(esc('This is your owner voice. Preview requires owner verification.'));
|
|
1694
|
+
} else {
|
|
1695
|
+
lines.push(esc('Creates a new voice.'));
|
|
1696
|
+
var near = dir.filter(function (p) { return p.name.toLowerCase().indexOf(name.toLowerCase()) >= 0; }).slice(0, 3);
|
|
1697
|
+
if (near.length) lines.push('<span class="sp-chips">Did you mean ' + near.map(function (p) {
|
|
1698
|
+
return '<button type="button" class="cx-btn quiet sp-chip" data-sp="held-fill" data-name="' + esc(p.name) + '">' + esc(p.name) + '</button>';
|
|
1699
|
+
}).join('') + '</span>');
|
|
1700
|
+
}
|
|
1701
|
+
return lines.map(function (l) { return '<span>' + l + '</span>'; }).join('');
|
|
1702
|
+
}
|
|
1703
|
+
function heldActions(key, count, suggestion) {
|
|
1704
|
+
var H = S.H;
|
|
1705
|
+
var disabledName = H.busy || !H.speakerModel || !H.namingAvailable;
|
|
1706
|
+
if (H.naming === key) {
|
|
1707
|
+
return '<div class="sp-held-name"><div class="sp-inline">' +
|
|
1708
|
+
'<input class="field sp-field" id="sp-held-input" type="text" placeholder="Who is this?" value="' + esc(H.typed) + '" autocomplete="off" aria-label="Who is this?">' +
|
|
1709
|
+
'<button type="button" class="cx-btn primary" data-sp="held-preview"' + (disabledName || H.typed.trim().length < 2 ? ' disabled' : '') + '>Preview</button>' +
|
|
1710
|
+
'<button type="button" class="sp-text" data-sp="held-cancel">Cancel</button>' + (H.busy ? spin() : '') + '</div>' +
|
|
1711
|
+
'<div class="sp-hint" id="sp-held-hint">' + nameHint(H.typed) + '</div></div>';
|
|
1712
|
+
}
|
|
1713
|
+
if (H.confirmDiscard === key) {
|
|
1714
|
+
return '<div class="sp-inline"><span class="sp-muted">Discard ' + count + ' sample' + s(count) + '?</span>' +
|
|
1715
|
+
'<button type="button" class="cx-btn sp-danger" data-sp="held-discard-yes" data-key="' + esc(key) + '"' + (H.busy ? ' disabled' : '') + '>Discard</button>' +
|
|
1716
|
+
'<button type="button" class="sp-text" data-sp="held-discard-no">Keep</button></div>';
|
|
1717
|
+
}
|
|
1718
|
+
return '<div class="sp-inline sp-held-acts">' +
|
|
1719
|
+
(suggestion ? '<button type="button" class="cx-btn primary" data-sp="held-add" data-key="' + esc(key) + '"' + (disabledName ? ' disabled' : '') + '>' + esc('Add to ' + suggestion) + '</button>' : '') +
|
|
1720
|
+
'<button type="button" class="cx-btn quiet" data-sp="held-name" data-key="' + esc(key) + '"' + (disabledName ? ' disabled' : '') + '>' + (count === 1 ? 'Name this sample' : 'Name this voice') + '</button>' +
|
|
1721
|
+
'<button type="button" class="sp-text sp-danger-text" data-sp="held-discard" data-key="' + esc(key) + '"' + (H.busy ? ' disabled' : '') + '>Discard</button></div>';
|
|
1722
|
+
}
|
|
1723
|
+
function groupRow(g) {
|
|
1724
|
+
var meetings = g.sessions.length;
|
|
1725
|
+
var sug = '';
|
|
1726
|
+
if (g.suggestionName) {
|
|
1727
|
+
var p = Math.round(g.suggestionSimilarity * 100) + '%';
|
|
1728
|
+
sug = '<div class="sp-sug"><span class="sp-muted">' + (g.suggestionTier === 'high' ? 'Sounds like' : 'May be') + '</span> <b>' + esc(g.suggestionName) + '</b> ' +
|
|
1729
|
+
'<span class="sp-mono-s">' + esc(g.suggestionOf > 0 ? p + ' · ' + g.suggestionAgreeing + ' of ' + g.suggestionOf + ' samples agree' : p) + '</span></div>';
|
|
1730
|
+
}
|
|
1731
|
+
var note = noteFor('heldgroup:' + g.id);
|
|
1732
|
+
var naming = S.H.naming === g.id || S.H.confirmDiscard === g.id;
|
|
1733
|
+
return '<div class="sp-held-row">' + listenStrip(g.id, g.playOrder, 'heldgroup:' + g.id) +
|
|
1734
|
+
'<div class="sp-held-body"><div class="sp-held-head"><b>' + g.sampleCount + ' sample' + s(g.sampleCount) + '</b><span class="sp-caps">' + meetings + ' MEETING' + (meetings === 1 ? '' : 'S') + '</span></div>' +
|
|
1735
|
+
sug + (g.ownerCaution ? '<div class="sp-accent">Also close to the owner voice. Listen carefully.</div>' : '') +
|
|
1736
|
+
(note ? '<div class="sp-muted sp-small">' + esc(note) + '</div>' : '') + '</div>' +
|
|
1737
|
+
heldActions(g.id, g.sampleCount, !naming && g.suggestionName ? g.suggestionName : null) + '</div>';
|
|
1738
|
+
}
|
|
1739
|
+
function looseRow(x) {
|
|
1740
|
+
var key = 'loose:' + x.id, sg = x.suggestion;
|
|
1741
|
+
var note = noteFor('heldloose:' + x.id);
|
|
1742
|
+
var naming = S.H.naming === key || S.H.confirmDiscard === key;
|
|
1743
|
+
return '<div class="sp-held-row">' + listenStrip(key, [x], 'heldloose:' + x.id) +
|
|
1744
|
+
'<div class="sp-held-body"><div class="sp-held-head"><b>Loose sample</b></div>' +
|
|
1745
|
+
(sg ? '<div class="sp-accent sp-strong">' + esc('Sounds like ' + sg.name + ' · ' + Math.round(sg.similarity * 100) + '%') + '</div>' +
|
|
1746
|
+
'<div class="sp-mono-s">' + sg.agreeing + ' voice samples agree</div>' +
|
|
1747
|
+
(sg.ownerCaution ? '<div class="sp-accent">Also close to the owner voice. Listen carefully.</div>' : '')
|
|
1748
|
+
: '<div class="sp-muted">No existing voice suggestion</div>') +
|
|
1749
|
+
'<div class="sp-mono-s">' + esc(heldLabel(x.sessionId, x.chunkIndex)) + '</div>' +
|
|
1750
|
+
(note ? '<div class="sp-muted sp-small">' + esc(note) + '</div>' : '') + '</div>' +
|
|
1751
|
+
heldActions(key, 1, !naming && sg ? sg.name : null) + '</div>';
|
|
1752
|
+
}
|
|
1753
|
+
function statusBar(H) {
|
|
1754
|
+
var out = '';
|
|
1755
|
+
var interrupted = H.batches.filter(function (b) { return b.needsReview; }).length;
|
|
1756
|
+
if (interrupted > 0) out += '<button type="button" class="cx-btn quiet" data-sp="held-history">Review ' + interrupted + ' interrupted naming' + s(interrupted) + '</button>';
|
|
1757
|
+
else if (H.batches.length) out += '<button type="button" class="cx-btn quiet" data-sp="held-history">Recent naming (' + H.batches.length + ')</button>';
|
|
1758
|
+
var rc = H.receipt;
|
|
1759
|
+
if (rc) {
|
|
1760
|
+
out += '<button type="button" class="sp-text" data-sp="held-result">' + (rc.kind === 'undone' ? 'Undo result' : 'Naming result') + '</button>';
|
|
1761
|
+
if (rc.undoHandle && (rc.kind !== 'undone' || rc.partial)) {
|
|
1762
|
+
out += '<button type="button" class="cx-btn quiet" data-sp="held-undo-ask" data-handle="' + esc(rc.undoHandle) + '"' + (H.busy || !H.namingAvailable ? ' disabled' : '') + '>Undo labels</button>';
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
if (H.historyError) out += '<span class="sp-muted sp-small">' + esc(H.historyError) + '</span>';
|
|
1766
|
+
out += '<span class="sp-grow"></span>';
|
|
1767
|
+
if (H.loose.length > 1) {
|
|
1768
|
+
out += H.confirmDiscard === 'loose-all'
|
|
1769
|
+
? '<button type="button" class="cx-btn sp-danger" data-sp="held-discard-all-yes"' + (H.busy ? ' disabled' : '') + '>Discard all ' + H.loose.length + '?</button><button type="button" class="sp-text" data-sp="held-discard-no">Keep</button>'
|
|
1770
|
+
: '<button type="button" class="sp-text sp-danger-text" data-sp="held-discard-all"' + (H.busy ? ' disabled' : '') + '>Discard all loose</button>';
|
|
1771
|
+
}
|
|
1772
|
+
return '<div class="sp-status">' + out + '</div>';
|
|
1773
|
+
}
|
|
1774
|
+
function undoConfirm(H) {
|
|
1775
|
+
if (!H.confirmUndo) return '';
|
|
1776
|
+
return '<div class="sp-confirm" role="alertdialog" aria-label="Restore this naming’s previous labels?"><b>Restore this naming’s previous labels?</b>' +
|
|
1777
|
+
'<p class="sp-muted">Later naming is protected. Voice samples stay enrolled; deleted audio stays deleted.</p><div class="sp-inline">' +
|
|
1778
|
+
'<button type="button" class="cx-btn sp-danger" data-sp="held-undo-yes"' + (H.busy ? ' disabled' : '') + '>Undo labels</button>' +
|
|
1779
|
+
'<button type="button" class="sp-text" data-sp="held-undo-no">Keep labels</button></div></div>';
|
|
1780
|
+
}
|
|
1781
|
+
function samplesBody() {
|
|
1782
|
+
var H = S.H;
|
|
1783
|
+
var head = '<div class="sp-card-head"><span class="sp-add-glyph">' + icon('speakers') + '</span><h2 class="sp-card-title">Add a voice</h2><span class="sp-grow"></span>' +
|
|
1784
|
+
(H.loading && H.state != null ? spin() : '') + '</div>';
|
|
1785
|
+
var body = head + (H.result ? notice(H.result) : '');
|
|
1786
|
+
if (H.namingAvailable) body += statusBar(H);
|
|
1787
|
+
else if (H.state != null) body += '<p class="sp-muted sp-small">Update the COS server to 6.46.0 or newer to preview, apply and undo meeting labels.</p>';
|
|
1788
|
+
body += undoConfirm(H);
|
|
1789
|
+
if (H.sheet === 'preview' || (H.sheet === 'preview-wait')) return '<section class="sp-card">' + body + previewSheet(H) + '</section>';
|
|
1790
|
+
if (H.sheet === 'result') return '<section class="sp-card">' + body + resultSheet(H) + '</section>';
|
|
1791
|
+
if (H.sheet === 'history') return '<section class="sp-card">' + body + historySheet(H) + '</section>';
|
|
1792
|
+
if (H.state == null) body += '<p class="sp-muted sp-loading">' + spin() + 'Grouping held voices…</p>';
|
|
1793
|
+
else if (heldUsable(H)) {
|
|
1794
|
+
var rows = H.groups.length + H.loose.length;
|
|
1795
|
+
body += '<p class="sp-lead">' + esc(heldLead(H)) + '</p>';
|
|
1796
|
+
if (rows > 0) body += '<p class="sp-muted sp-small">' + esc(H.namingAvailable
|
|
1797
|
+
? 'Preview first. Apply adds voice samples, labels matching segments in their own meetings, and uses up the audio. Discarding throws the audio out.'
|
|
1798
|
+
: 'Listen to or discard held audio. Naming needs server 6.46.0 or newer.') + '</p>';
|
|
1799
|
+
var all = H.groups.map(groupRow).concat(H.loose.map(looseRow));
|
|
1800
|
+
body += '<div class="sp-held-list">' + all.slice(0, H.shown).join('') + '</div>';
|
|
1801
|
+
if (all.length > H.shown) body += '<button type="button" class="cx-btn quiet sp-more" data-sp="held-more">Show ' + Math.min(ROW_PAGE, all.length - H.shown) + ' more of ' + (all.length - H.shown) + '</button>';
|
|
1802
|
+
} else {
|
|
1803
|
+
if (H.state === 'error' && H.error) body += notice('Voices could not be grouped: ' + H.error);
|
|
1804
|
+
else if (H.state === 'ready' && H.embedded === 0 && H.samples > 0) {
|
|
1805
|
+
body += notice(H.speakerModel ? H.samples + ' held samples are still being read for grouping. Refresh in a moment.'
|
|
1806
|
+
: H.samples + ' held samples cannot be grouped yet: the speaker model is not loaded on this Mac (Run checks shows it). The sessions are listed below.');
|
|
1807
|
+
}
|
|
1808
|
+
if (!H.extSessions.length) body += '<p class="sp-muted">' + esc(H.extError || 'No unrecognized audio is being held. Record a meeting, then come back within 72 hours.') + '</p>';
|
|
1809
|
+
else {
|
|
1810
|
+
body += '<p class="sp-muted">' + esc(H.extSessions.length > 5 ? H.extSessions.length + ' unrecognized sessions the server is holding. Naming one creates a new voice from it.'
|
|
1811
|
+
: 'Unrecognized audio the server is holding. Naming a session creates a new voice from it.') + '</p>' +
|
|
1812
|
+
'<p class="sp-muted sp-small">A session can hold more than one unknown speaker, and naming it uses up the audio.</p><div class="sp-held-list">' +
|
|
1813
|
+
H.extSessions.map(function (x) {
|
|
1814
|
+
var refs = x.chunkIndices.map(function (c) { return { sessionId: x.sessionId, chunkIndex: c, id: x.sessionId + '#' + c }; });
|
|
1815
|
+
var note = noteFor('held:' + x.sessionId);
|
|
1816
|
+
return '<div class="sp-held-row">' + listenStrip('ext:' + x.sessionId, refs, 'held:' + x.sessionId) +
|
|
1817
|
+
'<div class="sp-held-body"><b>' + x.chunks + ' sample' + s(x.chunks) + '</b><span class="sp-caps">EXPIRES IN ' + esc(x.expiresIn.toUpperCase()) + '</span>' +
|
|
1818
|
+
(note ? '<div class="sp-muted sp-small">' + esc(note) + '</div>' : '') + '</div>' +
|
|
1819
|
+
'<button type="button" class="cx-btn quiet" disabled title="Wait for sample grouping, or update the server to 6.46.0, to preview a name safely.">Name this voice</button></div>';
|
|
1820
|
+
}).join('') + '</div>';
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return '<section class="sp-card">' + body + '</section>';
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// ── render: the naming sheets, inline (Control draws them as an overlay; the web keeps no fixed layers) ──
|
|
1827
|
+
function namingMeeting(m, showPlayback) {
|
|
1828
|
+
var out = '<div class="sp-nm"><div class="sp-nm-head"><b>' + esc(heldLabel(m.sessionId, null)) + '</b><span class="sp-caps">' + esc(m.status.toUpperCase()) + '</span></div>' +
|
|
1829
|
+
'<div class="sp-muted sp-small">' + m.named.length + ' named · ' + m.wider.length + ' wider matches · ' + m.labelled + ' transcript positions</div>';
|
|
1830
|
+
if (!m.wider.length && showPlayback && m.status === 'ready') out += '<div class="sp-muted sp-small">No wider match. The named segments still get a label.</div>';
|
|
1831
|
+
if (m.error) out += '<div class="sp-accent sp-strong">' + esc(m.error) + '</div>';
|
|
1832
|
+
if (m.roomRisk) out += '<div class="sp-accent sp-small">Room caution: all agreeing samples for a wider match came from this meeting. Listen carefully.</div>';
|
|
1833
|
+
if (m.labelsNewerThanGraph) out += '<div class="sp-caps sp-accent">LABELS NEWER THAN GRAPH</div>';
|
|
1834
|
+
m.copySummaries.forEach(function (c) { out += '<div class="sp-mono-s">' + esc(c) + '</div>'; });
|
|
1835
|
+
if (showPlayback) {
|
|
1836
|
+
if (!m.playback.length) out += '<div class="sp-muted sp-small">No mapped audio to play. Audio expired, vectors only, or no transcript position.</div>';
|
|
1837
|
+
else {
|
|
1838
|
+
var row = function (t) {
|
|
1839
|
+
var key = 'naming:' + t.id, playing = A.key === key;
|
|
1840
|
+
var score = null;
|
|
1841
|
+
m.widerScores.forEach(function (w) { if (w && int(w.chunkIndex) === t.chunkIndex && num(w.similarity) != null) score = num(w.similarity); });
|
|
1842
|
+
return '<div class="sp-inline"><button type="button" class="cx-btn quiet" data-sp="naming-play" data-sid="' + esc(t.sessionId) + '" data-chunk="' + t.chunkIndex + '" data-key="' + esc(key) + '">' +
|
|
1843
|
+
(playing ? 'Stop' : 'Listen · chunk ' + t.chunkIndex) + '</button><span class="sp-mono-s">' + (score != null ? 'Sounds like · ' + Math.round(score * 100) + '%' : 'Named sample') + '</span></div>';
|
|
1844
|
+
};
|
|
1845
|
+
out += m.playback.slice(0, 5).map(row).join('');
|
|
1846
|
+
if (m.playback.length > 5) out += '<details class="sp-fold"><summary>Listen to all ' + m.playback.length + ' segments</summary>' + m.playback.slice(5).map(row).join('') + '</details>';
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return out + '</div>';
|
|
1850
|
+
}
|
|
1851
|
+
function expiryLine(p) {
|
|
1852
|
+
if (!p.expiresAt) return 'Preview expiration unavailable. Preview again.';
|
|
1853
|
+
return p.expiresAt > new Date()
|
|
1854
|
+
? 'Preview expires at ' + p.expiresAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + '. If a meeting changes, preview again.'
|
|
1855
|
+
: 'This preview expired. Choose Preview name to refresh it.';
|
|
1856
|
+
}
|
|
1857
|
+
function previewSheet(H) {
|
|
1858
|
+
var p = H.preview;
|
|
1859
|
+
if (!p) {
|
|
1860
|
+
return '<div class="sp-sheet"><p>' + esc(H.busy ? 'Preparing a fresh preview…' : (H.result || 'Preview unavailable. Close and try again.')) + '</p>' +
|
|
1861
|
+
'<button type="button" class="sp-text" data-sp="sheet-cancel">Close</button></div>';
|
|
1862
|
+
}
|
|
1863
|
+
var out = '<div class="sp-sheet"><div class="sp-caps">PREVIEW · NO CHANGES YET</div><h3 class="sp-sheet-title">' + esc('Name this voice ' + p.speaker + '?') + '</h3>' +
|
|
1864
|
+
'<div class="sp-inline"><input class="field sp-field" id="sp-revised" type="text" placeholder="Who is this?" value="' + esc(H.revised) + '" autocomplete="off" aria-label="Who is this?">' +
|
|
1865
|
+
'<button type="button" class="cx-btn quiet" data-sp="sheet-repreview"' + (H.busy || H.revised.trim().length < 2 ? ' disabled' : '') + '>Preview name</button></div>' +
|
|
1866
|
+
'<p class="sp-strong">' + p.eligible + ' held sample' + s(p.eligible) + ' eligible · ' + p.labelled + ' transcript segment' + s(p.labelled) + ' in ' + p.meetings.length + ' meeting' + s(p.meetings.length) + '</p>' +
|
|
1867
|
+
'<p class="sp-muted sp-small">The server selects distinct voiceprints to keep. Apply reports how many were enrolled; naming several samples does not always add that many voiceprints.</p>';
|
|
1868
|
+
if (p.noTranscript > 0) out += '<p class="sp-muted sp-small">' + p.noTranscript + ' samples have no transcript position. They can help the voice profile without adding transcript labels.</p>';
|
|
1869
|
+
out += p.meetings.map(function (m) { return namingMeeting(m, true); }).join('');
|
|
1870
|
+
var note = noteFor('held-naming-preview');
|
|
1871
|
+
if (note) out += '<p class="sp-accent sp-small">' + esc(note) + '</p>';
|
|
1872
|
+
if (p.owner) {
|
|
1873
|
+
out += '<p class="sp-accent sp-strong">' + esc(p.ownerWarning || 'This is your owner voice. Confirm these samples are your own voice.') + '</p>' +
|
|
1874
|
+
'<label class="cx-check sp-toggle"><input type="checkbox" id="sp-owner-ack"' + (H.ownerAck ? ' checked' : '') + '>I confirm these samples are my own voice.</label>';
|
|
1875
|
+
}
|
|
1876
|
+
if (p.requiresListening) out += '<label class="cx-check sp-toggle"><input type="checkbox" id="sp-listened"' + (H.listened ? ' checked' : '') + '>I listened and these segments sound like the same person.</label>';
|
|
1877
|
+
out += '<p class="sp-muted sp-small" id="sp-expiry">' + esc(expiryLine(p)) + '</p><div class="sp-inline sp-sheet-foot">' +
|
|
1878
|
+
'<button type="button" class="sp-text" data-sp="sheet-cancel">Cancel</button><span class="sp-grow"></span>' + (H.busy ? spin() : '') +
|
|
1879
|
+
'<button type="button" class="cx-btn primary" data-sp="sheet-apply"' + (canApply(H) ? '' : ' disabled') + '>Apply</button></div></div>';
|
|
1880
|
+
return out;
|
|
1881
|
+
}
|
|
1882
|
+
function resultContent(rc) {
|
|
1883
|
+
var out = '<h3 class="sp-sheet-title">' + esc(rc.kind === 'undone' ? (rc.partial ? 'Some labels still need review' : 'Labels restored') : 'Naming ' + rc.speaker) + '</h3>';
|
|
1884
|
+
if (rc.error) out += '<p class="sp-accent sp-strong">' + esc(rc.error) + '</p>';
|
|
1885
|
+
if (rc.kind === 'undone') out += '<p class="sp-strong">' + rc.restored + ' segment' + s(rc.restored) + ' restored · ' + rc.labelled + ' remain' + (rc.labelled === 1 ? 's' : '') + ' labelled by this naming</p>';
|
|
1886
|
+
else if (rc.kind === 'applied') out += '<p class="sp-strong">' + rc.enrolled + ' sample' + s(rc.enrolled) + ' enrolled · ' + rc.labelled + ' segment' + s(rc.labelled) + ' labelled · ' + rc.deleted + ' audio sample' + s(rc.deleted) + ' deleted</p>';
|
|
1887
|
+
if (rc.profileEmbeddings != null) out += '<p class="sp-muted sp-small">' + rc.profileEmbeddings + ' voice samples retained in the profile</p>';
|
|
1888
|
+
if (rc.partial) out += '<p class="sp-accent sp-small">Some meetings or copies could not be completed. Review each outcome below.</p>';
|
|
1889
|
+
out += '<p class="sp-muted sp-small">Undo restores labels. Voice samples stay enrolled; deleted audio stays deleted. Search catches up at up to 10 meetings per sync; graph labels may lag.</p>';
|
|
1890
|
+
if (rc.lockMode === 'standalone') out += '<p class="sp-muted sp-small">Standalone meeting library: no COS sync lock was needed.</p>';
|
|
1891
|
+
out += rc.meetings.map(function (m) { return namingMeeting(m, false); }).join('');
|
|
1892
|
+
if (rc.outcomes.length) {
|
|
1893
|
+
out += '<details class="sp-fold"><summary>Sample outcomes (' + rc.outcomes.length + ')</summary>' + rc.outcomes.map(function (o) {
|
|
1894
|
+
return '<div class="sp-outcome"><div class="sp-mono-s">' + esc(heldLabel(o.sample.sessionId, o.sample.chunkIndex)) + '</div><div class="sp-muted sp-small">' +
|
|
1895
|
+
esc(o.noTranscript ? o.enrollmentStatus + ' · no transcript position · audio ' + o.audioStatus : 'Voice ' + o.enrollmentStatus + ' · labels ' + o.labelStatus + ' · audio ' + o.audioStatus) + '</div>' +
|
|
1896
|
+
(o.reason ? '<div class="sp-accent sp-small">' + esc(o.reason) + '</div>' : '') + '</div>';
|
|
1897
|
+
}).join('') + '</details>';
|
|
1898
|
+
}
|
|
1899
|
+
return out;
|
|
1900
|
+
}
|
|
1901
|
+
function resultSheet(H) {
|
|
1902
|
+
var rc = H.receipt;
|
|
1903
|
+
if (!rc) return '<div class="sp-sheet"><p>No naming receipt is selected.</p><button type="button" class="sp-text" data-sp="sheet-close">Close</button></div>';
|
|
1904
|
+
return '<div class="sp-sheet">' + resultContent(rc) + '<div class="sp-inline sp-sheet-foot"><button type="button" class="sp-text" data-sp="sheet-close">Close</button><span class="sp-grow"></span>' +
|
|
1905
|
+
(rc.undoHandle && (rc.kind !== 'undone' || rc.partial) ? '<button type="button" class="cx-btn quiet" data-sp="held-undo-ask" data-handle="' + esc(rc.undoHandle) + '"' + (H.busy || !H.namingAvailable ? ' disabled' : '') + '>Undo labels</button>' : '') +
|
|
1906
|
+
(H.busy ? spin() : '') + '</div></div>';
|
|
1907
|
+
}
|
|
1908
|
+
function historySheet(H) {
|
|
1909
|
+
var out = '<div class="sp-sheet"><h3 class="sp-sheet-title">Recent naming</h3><p class="sp-muted">Interrupted naming never resumes automatically. Resume opens a fresh preview; Revert restores this naming’s labels where later changes allow it.</p>';
|
|
1910
|
+
if (!H.namingAvailable) out += '<p>Update the COS server to 6.46.0 or newer to review and undo naming.</p>';
|
|
1911
|
+
if (H.result) out += '<p class="sp-accent">' + esc(H.result) + '</p>';
|
|
1912
|
+
out += H.batches.map(function (b) {
|
|
1913
|
+
return '<div class="sp-batch"><div class="sp-nm-head"><b>' + esc(b.speaker) + '</b><span class="sp-caps sp-accent">' + esc(b.needsReview ? 'INTERRUPTED NAMING' : b.status.toUpperCase()) + '</span></div>' +
|
|
1914
|
+
b.meetings.map(function (m) { return namingMeeting(m, false); }).join('') + '<div class="sp-inline">' +
|
|
1915
|
+
(b.needsReview ? '<button type="button" class="cx-btn primary" data-sp="held-resume" data-batch="' + esc(b.batchId) + '"' + (H.busy || !H.namingAvailable ? ' disabled' : '') + '>Resume</button>' : '') +
|
|
1916
|
+
(b.undoHandle && b.status !== 'reverted' ? '<button type="button" class="cx-btn quiet" data-sp="held-undo-ask" data-handle="' + esc(b.undoHandle) + '"' + (H.busy || !H.namingAvailable ? ' disabled' : '') + '>' + (b.needsReview ? 'Revert labels' : 'Undo labels') + '</button>' : '') +
|
|
1917
|
+
'</div></div>';
|
|
1918
|
+
}).join('');
|
|
1919
|
+
return out + '<p class="sp-muted sp-small">Voice samples stay enrolled. Audio is never restored. Per-copy results show any labels that could not be reverted.</p>' +
|
|
1920
|
+
'<div class="sp-inline sp-sheet-foot"><button type="button" class="sp-text" data-sp="sheet-close">Close</button>' + (H.busy ? spin() : '') + '</div></div>';
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ── render: Voices (the directory) ─────────────────────
|
|
1924
|
+
// CONFIDENCE is the share of SCORED segments the server rated confident; under 10 scored it is marked thin.
|
|
1925
|
+
function confShare(p) {
|
|
1926
|
+
if (!(p.observedMatchSegments > 0)) return null;
|
|
1927
|
+
return Math.min(1, (p.reliabilityCounts.confident || 0) / p.observedMatchSegments);
|
|
1928
|
+
}
|
|
1929
|
+
function confValue(p) { var x = confShare(p); return x == null ? '–' : pct(x); }
|
|
1930
|
+
function confDetail(p) {
|
|
1931
|
+
if (p.observedMatch == null || !(p.observedMatchSegments > 0)) return 'not matched yet';
|
|
1932
|
+
var avg = 'avg ' + two(p.observedMatch);
|
|
1933
|
+
return p.observedMatchSegments < 10 ? avg + ' · thin' : avg;
|
|
1934
|
+
}
|
|
1935
|
+
function confRank(p) { var x = confShare(p); return x == null ? { tier: 2, share: null } : { tier: p.observedMatchSegments < 10 ? 1 : 0, share: x }; }
|
|
1936
|
+
function sourceLine(p) {
|
|
1937
|
+
var rows = Object.keys(p.sources).filter(function (k) { return p.sources[k] > 0; }).sort(function (a, b) {
|
|
1938
|
+
return p.sources[a] === p.sources[b] ? (a < b ? -1 : 1) : p.sources[b] - p.sources[a];
|
|
1939
|
+
});
|
|
1940
|
+
var out = rows.slice(0, 2).map(function (k) { return k + ' ' + p.sources[k]; }).join(' · ');
|
|
1941
|
+
return out || 'No recorded source';
|
|
1942
|
+
}
|
|
1943
|
+
function visibleVoices() {
|
|
1944
|
+
var needle = S.voiceSearch.trim().toLowerCase();
|
|
1945
|
+
var list = (S.dir || []).filter(function (p) {
|
|
1946
|
+
return !needle || p.name.toLowerCase().indexOf(needle) >= 0 || Object.keys(p.sources).some(function (k) { return k.toLowerCase().indexOf(needle) >= 0; });
|
|
1947
|
+
});
|
|
1948
|
+
return list.sort(function (a, b) {
|
|
1949
|
+
var sort = S.voiceSort;
|
|
1950
|
+
if (sort === 'attention') {
|
|
1951
|
+
var na = needsAttention(a), nb = needsAttention(b);
|
|
1952
|
+
if (na !== nb) return na ? -1 : 1;
|
|
1953
|
+
if (a.reviewMeetingCount !== b.reviewMeetingCount) return b.reviewMeetingCount - a.reviewMeetingCount;
|
|
1954
|
+
if (a.meetingCount !== b.meetingCount) return b.meetingCount - a.meetingCount;
|
|
1955
|
+
} else if (sort === 'recent') {
|
|
1956
|
+
if ((a.lastSeen || '') !== (b.lastSeen || '')) return (a.lastSeen || '') > (b.lastSeen || '') ? -1 : 1;
|
|
1957
|
+
} else if (sort === 'meetings') {
|
|
1958
|
+
if (a.meetingCount !== b.meetingCount) return b.meetingCount - a.meetingCount;
|
|
1959
|
+
} else if (sort === 'samples') {
|
|
1960
|
+
if (a.embeddings !== b.embeddings) return b.embeddings - a.embeddings;
|
|
1961
|
+
} else if (sort === 'confidence') {
|
|
1962
|
+
var ra = confRank(a), rb = confRank(b);
|
|
1963
|
+
if (ra.tier !== rb.tier) return ra.tier - rb.tier;
|
|
1964
|
+
if (ra.share != null && rb.share != null && ra.share !== rb.share) return ra.share - rb.share;
|
|
1965
|
+
if (a.observedMatchSegments !== b.observedMatchSegments) return b.observedMatchSegments - a.observedMatchSegments;
|
|
1966
|
+
}
|
|
1967
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
function metric(value, sub, cls) {
|
|
1971
|
+
return '<span class="sp-metric ' + cls + '"><b>' + esc(value) + '</b>' + (sub ? '<small>' + esc(sub) + '</small>' : '') + '</span>';
|
|
1972
|
+
}
|
|
1973
|
+
function voiceRowHtml(p) {
|
|
1974
|
+
var hist = S.dirRoute !== false;
|
|
1975
|
+
var pillsHtml = (p.isOwner ? '<span class="cx-pill ok">OWNER</span>' : '') + (needsAttention(p) ? '<span class="cx-pill warn">REVIEW</span>' : '');
|
|
1976
|
+
var stack = [fmt(p.embeddings) + ' sample' + s(p.embeddings)];
|
|
1977
|
+
if (hist) {
|
|
1978
|
+
stack.push(confValue(p) + ' ' + confDetail(p));
|
|
1979
|
+
stack.push(fmt(p.meetingCount) + ' meeting' + s(p.meetingCount) + (p.reviewMeetingCount > 0 ? ' (' + fmt(p.reviewMeetingCount) + ' review)' : ''));
|
|
1980
|
+
stack.push(fmt(p.assertedSegments) + ' segments' + (p.candidateSegments > 0 ? ' +' + fmt(p.candidateSegments) + ' review' : ''));
|
|
1981
|
+
stack.push(p.lastSeen || 'Never');
|
|
1982
|
+
}
|
|
1983
|
+
return '<button type="button" class="sp-vrow" data-sp="open-voice" data-name="' + esc(p.name) + '" aria-label="' + esc(p.name + ', ' + p.embeddings + ' training samples') + '">' +
|
|
1984
|
+
'<span class="cx-row-glyph sp-vglyph">' + icon('speakers') + '</span>' +
|
|
1985
|
+
'<span class="sp-vname"><span class="sp-vtitle"><b>' + esc(p.name) + '</b>' + pillsHtml + '</span><span class="sp-vsrc">' + esc(sourceLine(p)) + '</span>' +
|
|
1986
|
+
'<span class="sp-vstack">' + esc(stack.join(' · ')) + '</span></span>' +
|
|
1987
|
+
metric(fmt(p.embeddings), null, 'c-samples') +
|
|
1988
|
+
metric(hist ? confValue(p) : '–', hist ? confDetail(p) : 'update server', 'c-conf') +
|
|
1989
|
+
metric(hist ? fmt(p.meetingCount) : '–', hist && p.reviewMeetingCount > 0 ? fmt(p.reviewMeetingCount) + ' review' : null, 'c-count') +
|
|
1990
|
+
metric(hist ? fmt(p.assertedSegments) : '–', hist && p.candidateSegments > 0 ? '+' + fmt(p.candidateSegments) + ' review' : null, 'c-count') +
|
|
1991
|
+
metric(hist ? (p.lastSeen || 'Never') : '–', null, 'c-seen') + '<span class="chev"></span></button>';
|
|
1992
|
+
}
|
|
1993
|
+
function dirNotice(text, stale) { return '<div class="sp-dnotice' + (stale ? ' stale' : '') + '">' + esc(text) + '</div>'; }
|
|
1994
|
+
function voicesBody() {
|
|
1995
|
+
var dir = S.dir;
|
|
1996
|
+
if (S.dirLoading && (!dir || !dir.length)) return '<p class="cx-note sp-loading">' + spin() + 'Building the voice directory…</p>';
|
|
1997
|
+
if (!dir || !dir.length) {
|
|
1998
|
+
if (S.dirLoadFailed) {
|
|
1999
|
+
return '<div class="sp-empty"><span class="cx-glyph">' + icon('speakers') + '</span><p class="sp-strong">The voice directory could not be loaded.</p><p>' +
|
|
2000
|
+
esc(S.dirError || 'The helper did not answer.') + '</p><button type="button" class="cx-btn quiet" data-sp="dir-retry">Retry</button></div>';
|
|
2001
|
+
}
|
|
2002
|
+
if (!dir) return '';
|
|
2003
|
+
return '<div class="sp-empty"><span class="cx-glyph">' + icon('speakers') + '</span><p class="sp-strong">No voice profiles are enrolled yet.</p><p>' +
|
|
2004
|
+
esc('Voices the glasses did not recognize are held for 72 hours under Samples to review. Listen to one and name it to start a profile, or say "enroll my voice" on the glasses.') +
|
|
2005
|
+
'</p><button type="button" class="cx-btn primary" data-sp="view" data-view="samples">Open Samples to review</button></div>';
|
|
2006
|
+
}
|
|
2007
|
+
var out = '';
|
|
2008
|
+
if (S.dirError) out += dirNotice(S.dirError, S.dirRoute !== false);
|
|
2009
|
+
if (S.unresolvedMeetings > 0) out += dirNotice(fmt(S.unresolvedSegments) + ' unidentified segments remain local to ' + fmt(S.unresolvedMeetings) + ' meeting' + s(S.unresolvedMeetings) + '. They are not treated as one person.', false);
|
|
2010
|
+
var rows = visibleVoices();
|
|
2011
|
+
out += '<div class="sp-dir"><div class="sp-dhead" aria-hidden="true"><span class="sp-vname">VOICE</span><span class="c-samples">SAMPLES</span>' +
|
|
2012
|
+
'<span class="c-conf">CONFIDENCE</span><span class="c-count">MEETINGS</span><span class="c-count">SEGMENTS</span><span class="c-seen">LAST SEEN</span><span class="chev-space"></span></div>' +
|
|
2013
|
+
rows.map(voiceRowHtml).join('');
|
|
2014
|
+
if (!rows.length) out += '<div class="sp-nomatch"><p>' + esc('No voices match “' + S.voiceSearch + '”.') + '</p><button type="button" class="cx-btn quiet" data-sp="clear-search">Clear search</button></div>';
|
|
2015
|
+
return out + '</div>';
|
|
2016
|
+
}
|
|
2017
|
+
function voiceDetail(name) {
|
|
2018
|
+
var p = (S.dir || []).filter(function (x) { return x.name === name; })[0];
|
|
2019
|
+
if (!p) {
|
|
2020
|
+
if (S.dirLoading || !S.dir) return '<div class="cx">' + W.skeletonRows(4) + '</div>';
|
|
2021
|
+
return '<div class="cx"><div class="cx-notice warn">' + esc('No enrolled voice is named “' + name + '”.') + '</div></div>';
|
|
2022
|
+
}
|
|
2023
|
+
W.setCrumb(p.name);
|
|
2024
|
+
var hist = S.dirRoute !== false;
|
|
2025
|
+
function card(title, value, detail) {
|
|
2026
|
+
return '<div class="sp-mcard"><span class="sp-caps">' + esc(title) + '</span><b>' + esc(value) + '</b><small>' + esc(detail) + '</small></div>';
|
|
2027
|
+
}
|
|
2028
|
+
function info(title, rows, warning) {
|
|
2029
|
+
return '<div class="sp-icard"><b>' + esc(title) + '</b>' + rows.map(function (r) { return '<span>' + esc(r) + '</span>'; }).join('') +
|
|
2030
|
+
(warning ? '<span class="sp-warnline">⚠︎ ' + esc(warning) + '</span>' : '') + '</div>';
|
|
2031
|
+
}
|
|
2032
|
+
var srcRows = Object.keys(p.sources).sort().map(function (k) { return k + ': ' + p.sources[k] + ' sample' + s(p.sources[k]); });
|
|
2033
|
+
var out = '<div class="cx sp-voice" data-tint="speakers"><div class="sp-vhead"><span class="cx-glyph">' + icon('speakers') + '</span><div>' +
|
|
2034
|
+
'<h2 class="sp-vh-title">' + esc(p.name) + (p.isOwner ? ' <span class="cx-pill ok">OWNER</span>' : '') + (needsAttention(p) ? ' <span class="cx-pill warn">NEEDS REVIEW</span>' : '') + '</h2>' +
|
|
2035
|
+
'<p class="sp-mono">Enrolled identity · ' + p.embeddings + ' training samples</p></div></div>' +
|
|
2036
|
+
'<div class="sp-mcards">' +
|
|
2037
|
+
card('CONFIDENCE', hist ? confValue(p) : '–', hist ? (p.observedMatch != null ? 'avg match ' + two(p.observedMatch) + ' · ' + fmt(p.observedMatchSegments) + ' scored' : 'Not matched yet') : 'Update server') +
|
|
2038
|
+
card('ATTRIBUTED', hist ? String(p.assertedSegments) : '–', hist ? 'segments' : 'History unavailable') +
|
|
2039
|
+
card('MEETINGS', hist ? String(p.meetingCount) : '–', hist ? (p.reviewMeetingCount > 0 ? p.reviewMeetingCount + ' need review' : 'asserted') : 'History unavailable') +
|
|
2040
|
+
card('LAST HEARD', hist ? (p.lastSeen || 'Never') : '–', hist ? (p.firstSeen ? 'since ' + p.firstSeen : 'No occurrence') : 'History unavailable') + '</div>' +
|
|
2041
|
+
'<div class="sp-icards">' +
|
|
2042
|
+
info('Training provenance', srcRows.length ? srcRows : ['No provenance recorded'], p.sourcesAligned ? null : 'Sample and provenance counts do not align.') +
|
|
2043
|
+
info('Observed evidence', hist ? [p.assertedSegments + ' attributed segments', formatDuration(p.assertedSpeakingMs) + ' credited speaking time', p.candidateSegments + ' candidate segments awaiting review']
|
|
2044
|
+
: ['Cross-meeting evidence requires the Voice Directory server route.'], hist && p.candidateSegments > 0 ? 'Candidates are not counted as confirmed identity.' : null) + '</div>' +
|
|
2045
|
+
'<div class="sp-caps sp-hist-label">MEETING HISTORY</div>';
|
|
2046
|
+
if (!p.appearances.length) {
|
|
2047
|
+
out += '<p class="sp-muted">' + esc(S.dirRoute === false ? 'Update the server to add meeting history.' : 'No attributed meeting appearances are available yet.') + '</p>';
|
|
2048
|
+
} else {
|
|
2049
|
+
out += '<div class="cx-list sp-list">' + p.appearances.map(function (a) {
|
|
2050
|
+
return '<button type="button" class="cx-row sp-arow" data-sp="open-appearance" data-sid="' + esc(a.sessionId) + '">' +
|
|
2051
|
+
'<span class="cx-row-glyph' + (a.needsReview ? ' sp-amber' : '') + '">' + icon('speakers') + '</span><span class="cx-row-body">' +
|
|
2052
|
+
'<span class="cx-row-title strong sp-one">' + esc(a.title) + '</span><span class="sp-mono">' + esc(a.date + ' · ' + a.segments + ' segments · ' + formatDuration(a.speakingMs)) + '</span></span>' +
|
|
2053
|
+
'<span class="sp-match"><b>' + esc(a.observedMatch == null ? '–' : pct(a.observedMatch)) + '</b><small>' + (a.confirmedByHuman ? 'human confirmed' : a.needsReview ? 'review' : 'observed match') + '</small></span>' +
|
|
2054
|
+
'<span class="chev"></span></button>';
|
|
2055
|
+
}).join('') + '</div>';
|
|
2056
|
+
}
|
|
2057
|
+
return out + '</div>';
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// ── render: the review (SpeakerReviewPane) ─────────────
|
|
2061
|
+
var PALETTE = ['var(--green)', 'var(--amber)', 'color-mix(in srgb, var(--ink) 55%, transparent)', 'color-mix(in srgb, var(--green) 55%, transparent)', 'color-mix(in srgb, var(--amber) 55%, transparent)'];
|
|
2062
|
+
function speakerOrder(rv) {
|
|
2063
|
+
var seen = {}, out = [];
|
|
2064
|
+
rv.timeline.forEach(function (t) { if (!seen[t.speaker]) { seen[t.speaker] = true; out.push(t.speaker); } });
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
2067
|
+
// An unnamed voice stays neutral: colouring it like a person would imply an identity nobody has.
|
|
2068
|
+
function tint(rv, speaker, order) {
|
|
2069
|
+
if (!assertsName(rv, speaker)) return 'var(--sp-line)';
|
|
2070
|
+
var i = order.indexOf(speaker);
|
|
2071
|
+
return i < 0 ? 'var(--sp-line)' : PALETTE[i % PALETTE.length];
|
|
2072
|
+
}
|
|
2073
|
+
// Fixed-width columns, each won by whoever holds most of that slice (TurnRibbon.columns).
|
|
2074
|
+
function columns(rv, count) {
|
|
2075
|
+
var spanMs = Math.max(rv.timeline.length ? rv.timeline[rv.timeline.length - 1].endMs : 0, rv.durationMs, 1);
|
|
2076
|
+
var slice = spanMs / count, out = [], cursor = 0;
|
|
2077
|
+
for (var i = 0; i < count; i++) {
|
|
2078
|
+
var from = Math.floor(i * slice), to = Math.floor((i + 1) * slice), best = '', bestMs = 0, j = cursor;
|
|
2079
|
+
while (j < rv.timeline.length && rv.timeline[j].startMs < to) {
|
|
2080
|
+
var t = rv.timeline[j], overlap = Math.min(t.endMs, to) - Math.max(t.startMs, from);
|
|
2081
|
+
if (overlap > bestMs) { bestMs = overlap; best = t.speaker; }
|
|
2082
|
+
if (t.endMs <= to) j++; else break;
|
|
2083
|
+
}
|
|
2084
|
+
cursor = Math.max(cursor, j - 1);
|
|
2085
|
+
if (!best && out.length) best = out[out.length - 1].speaker;
|
|
2086
|
+
out.push({ speaker: best, startMs: from });
|
|
2087
|
+
}
|
|
2088
|
+
return out;
|
|
2089
|
+
}
|
|
2090
|
+
function ribbon(R) {
|
|
2091
|
+
var rv = R.review, order = speakerOrder(rv);
|
|
2092
|
+
var width = Math.max(200, (pane.clientWidth || 360) - 40);
|
|
2093
|
+
var cols = R.cols = columns(rv, Math.max(1, Math.min(Math.floor(width / 2.5), 200)));
|
|
2094
|
+
var span = R.hovered != null ? rv.timeline[R.hovered] : null;
|
|
2095
|
+
var hint = hoverless() ? 'Tap the bar to see who is speaking' : 'Hover the bar to see who is speaking';
|
|
2096
|
+
return '<div class="sp-tl"><div class="sp-caps sp-tl-label">WHO SPOKE, IN ORDER</div>' +
|
|
2097
|
+
'<div class="sp-bar" id="sp-bar" role="img" aria-label="Who spoke, in order">' + cols.map(function (c, i) {
|
|
2098
|
+
var hl = span && R.hoveredCol === i;
|
|
2099
|
+
return '<span style="background:' + tint(rv, c.speaker, order) + '"' + (R.hoveredCol != null && !hl ? ' class="dim"' : '') + '></span>';
|
|
2100
|
+
}).join('') + '</div>' +
|
|
2101
|
+
'<div class="sp-tl-read" id="sp-tl-read">' + (span ? '<span class="sp-mono-s">' + stamp(span.startMs) + '</span> <b>' + esc(displayNameOf(rv, span.speaker)) + '</b> <span class="sp-mono-s">' +
|
|
2102
|
+
span.segments + ' seg · ' + Math.max(1, Math.floor((span.endMs - span.startMs) / 1000)) + 's</span>' : '<span class="sp-muted sp-small">' + hint + '</span>') + '</div>' +
|
|
2103
|
+
'<div class="sp-legend">' + order.map(function (sp) {
|
|
2104
|
+
return '<button type="button" class="sp-leg' + (assertsName(rv, sp) ? '' : ' dim') + '" data-sp="legend" data-speaker="' + esc(sp) + '"><i style="background:' + tint(rv, sp, order) + '"></i>' + esc(displayNameOf(rv, sp)) + '</button>';
|
|
2105
|
+
}).join('') + '</div></div>';
|
|
2106
|
+
}
|
|
2107
|
+
function ramp(v) {
|
|
2108
|
+
var unreliable = v.reliability === 'unreliable', val = v.meanSimilarity, bars = '';
|
|
2109
|
+
for (var i = 0; i < 10; i++) bars += '<i' + (i < (val || 0) * 10 && !unreliable ? ' class="lit"' : '') + '></i>';
|
|
2110
|
+
return '<span class="sp-ramp"><span class="sp-ramp-bars">' + bars + '</span><span class="sp-mono-s">' + (unreliable ? 'unreliable' : val != null ? two(val) : '–') + '</span></span>';
|
|
2111
|
+
}
|
|
2112
|
+
function matchesFor(R, v, typed) {
|
|
2113
|
+
var q = String(typed || '').trim().toLowerCase();
|
|
2114
|
+
if (!q) return [];
|
|
2115
|
+
var mac = R.review.isMac;
|
|
2116
|
+
return (S.profiles || []).filter(function (p) { return p.name.toLowerCase().indexOf(q) >= 0 && p.name !== v.label && !(mac && p.isOwner); }).slice(0, 4);
|
|
2117
|
+
}
|
|
2118
|
+
function typedNew(R, label, typed) {
|
|
2119
|
+
var name = String(typed || '').trim();
|
|
2120
|
+
if (name.length < 2 || name === label) return null;
|
|
2121
|
+
return (S.profiles || []).some(function (p) { return p.name.toLowerCase() === name.toLowerCase(); }) ? null : name;
|
|
2122
|
+
}
|
|
2123
|
+
function namingOptions(R, v) {
|
|
2124
|
+
var typed = R.typed[v.label] || '';
|
|
2125
|
+
var busy = R.busy ? ' disabled' : '';
|
|
2126
|
+
return matchesFor(R, v, typed).map(function (o) {
|
|
2127
|
+
return '<button type="button" class="sp-opt" data-sp="rename-pick" data-label="' + esc(v.label) + '" data-name="' + esc(o.name) + '"' + busy + '><span>' + esc(o.name) + '</span>' +
|
|
2128
|
+
(R.busy ? spin() : '<span class="sp-mono-s">' + o.embeddings + ' samples</span>') + '</button>';
|
|
2129
|
+
}).join('') + (function () {
|
|
2130
|
+
var n = typedNew(R, v.label, typed);
|
|
2131
|
+
return n ? '<button type="button" class="sp-opt" data-sp="rename-new" data-label="' + esc(v.label) + '" data-name="' + esc(n) + '"' + busy + '><span>' + esc('Use “' + n + '”') + '</span>' +
|
|
2132
|
+
'<span class="sp-mono-s">' + (R.review.isMac ? 'text only' : 'new name') + '</span></button>' : '';
|
|
2133
|
+
})();
|
|
2134
|
+
}
|
|
2135
|
+
function scopeSwitch(R) {
|
|
2136
|
+
return '<div class="cx-switch sp-scope" role="group" aria-label="Correction scope">' +
|
|
2137
|
+
[['thisMeeting', 'Just this meeting'], ['everywhere', 'Every meeting']].map(function (o) {
|
|
2138
|
+
return '<button type="button" data-sp="scope" data-scope="' + o[0] + '" aria-pressed="' + (R.scope === o[0]) + '">' + o[1] + '</button>';
|
|
2139
|
+
}).join('') + '</div><p class="sp-tert">' + esc(R.scope === 'thisMeeting'
|
|
2140
|
+
? 'Corrects this meeting and adds samples to the voice profile. Other meetings are left alone.'
|
|
2141
|
+
: 'Folds one profile into the other across every meeting. Cannot be undone here.') + '</p>';
|
|
2142
|
+
}
|
|
2143
|
+
function voiceRow(R, v, hintSeconds) {
|
|
2144
|
+
var rv = R.review, out = '<div class="sp-voice-row" data-row="' + esc(v.label) + '"><div class="sp-vr-head">' +
|
|
2145
|
+
'<b class="' + (v.nameAsserted ? '' : 'sp-dimname') + '">' + esc(v.displayName) + '</b>' + (v.isOwner ? '<span class="sp-you">you</span>' : '') + '<span class="sp-grow"></span>' +
|
|
2146
|
+
'<span class="sp-mono-s">' + esc(v.kind ? (v.wordCount != null ? v.wordCount + ' words' : 'word timings') : v.segments + ' seg') + '</span>';
|
|
2147
|
+
if ((v.nameAsserted || v.kind) && v.speakingMs > 0) {
|
|
2148
|
+
out += '<span class="sp-mono-s">· ' + mmss(v.speakingMs) + '</span>';
|
|
2149
|
+
var share = shareOf(rv, v);
|
|
2150
|
+
if (share != null) out += '<span class="sp-mono-s sp-ink">· ' + pct(share) + '</span>';
|
|
2151
|
+
}
|
|
2152
|
+
if (hintSeconds != null) out += '<span class="sp-mono-s">· ' + secs(hintSeconds) + '</span>';
|
|
2153
|
+
if (!v.kind) out += ramp(v);
|
|
2154
|
+
out += '</div>';
|
|
2155
|
+
if (!v.phrases.length) {
|
|
2156
|
+
out += '<p class="sp-tert">' + (v.kind ? 'Word timings are retained for this speech.' : 'No line from this voice was distinctive enough to quote.') + '</p>';
|
|
2157
|
+
} else {
|
|
2158
|
+
out += '<div class="sp-phrases' + (v.reliability === 'unreliable' ? ' loose' : '') + '">' + v.phrases.map(function (ph) {
|
|
2159
|
+
var can = ph.chunkIndex != null && R.chunks[ph.chunkIndex];
|
|
2160
|
+
var key = v.label + '#' + (ph.chunkIndex == null ? -1 : ph.chunkIndex);
|
|
2161
|
+
return '<div class="sp-phrase">' + (can ? '<button type="button" class="sp-pplay' + (A.key === key ? ' on' : '') + '" data-sp="phrase" data-label="' + esc(v.label) + '" data-chunk="' + ph.chunkIndex + '" title="Hear this line" aria-label="' + (A.key === key ? 'Stop' : 'Hear this line') + '">' +
|
|
2162
|
+
(A.key === key ? '<span class="sp-stop-glyph"></span>' : '<span class="sp-play-glyph"></span>') + '</button>' : '<span class="sp-pplay-space"></span>') +
|
|
2163
|
+
'<span class="sp-ptext">“' + esc(ph.text) + '”</span><span class="sp-mono-s sp-tert">' + stamp(ph.atMs) + '</span></div>';
|
|
2164
|
+
}).join('') + '</div>';
|
|
2165
|
+
}
|
|
2166
|
+
if (v.thrash.length) out += '<p class="sp-muted sp-small">' + esc('Swaps with ' + v.thrash[0].speaker + ' every ' + Math.round(v.thrash[0].meanRun) + ' segments. Real turn-taking holds far longer, so a name here would be a guess.') + '</p>';
|
|
2167
|
+
if (!v.nameAsserted && v.reliability !== 'unattributed') {
|
|
2168
|
+
out += '<div class="sp-closest"><span>' + esc('Closest match: ' + v.label + (v.meanSimilarity != null ? ' (' + two(v.meanSimilarity) + ')' : '')) + '</span>' +
|
|
2169
|
+
v.blockers.map(function (b) { return '<span class="sp-tert">· ' + esc(b) + '</span>'; }).join('') + '</div>';
|
|
2170
|
+
}
|
|
2171
|
+
var note = noteFor(v.label);
|
|
2172
|
+
if (note) out += '<p class="sp-tert">' + esc(note) + '</p>';
|
|
2173
|
+
suggestionsFor(R, [v.label]).forEach(function (sg) { out += suggestionLine(R, sg, 1, [v.label]); });
|
|
2174
|
+
out += voiceNaming(R, v);
|
|
2175
|
+
return out + '</div>' + actionZone(R, v.label);
|
|
2176
|
+
}
|
|
2177
|
+
function suggestionLine(R, sg, rowSize, rowLabels) {
|
|
2178
|
+
var text = rowSize > 1 ? 'Suggested: ' + sg.name + ' for ' + sg.labels.length + ' of ' + rowSize + ' voices' : 'Suggested: ' + sg.name;
|
|
2179
|
+
return '<div class="sp-suggest"><span class="sp-strong">' + esc(text) + '</span><span class="sp-mono-s">' + two(sg.score) + '</span>' +
|
|
2180
|
+
'<button type="button" class="cx-btn quiet" data-sp="use-suggestion" data-id="' + esc(sg.id) + '" data-rows="' + esc(rowLabels.join('|')) + '"' + (R.busy ? ' disabled' : '') + '>Use this name</button></div>';
|
|
2181
|
+
}
|
|
2182
|
+
function voiceNaming(R, v) {
|
|
2183
|
+
var rv = R.review;
|
|
2184
|
+
if (rv.groupScopeLabels.indexOf(v.label) >= 0) return '<p class="sp-muted sp-small">This voice has a reviewed group. Name it in COS Control on your Mac.</p>';
|
|
2185
|
+
if (!rv.mutable) {
|
|
2186
|
+
return '<p class="sp-muted sp-small sp-lock">' + esc(!rv.intervalReadOnly ? 'Read-only library · new COS meetings remain editable' : 'Read-only interval view · naming needs an exact reviewed range') + '</p>';
|
|
2187
|
+
}
|
|
2188
|
+
if (!v.canRename) return '';
|
|
2189
|
+
var busy = R.busy ? ' disabled' : '';
|
|
2190
|
+
if (R.naming === v.label) {
|
|
2191
|
+
return '<div class="sp-naming"><input class="field sp-field" type="text" id="sp-name-input" data-label="' + esc(v.label) + '" placeholder="Type a name" value="' + esc(R.typed[v.label] || '') + '" autocomplete="off" aria-label="Type a name">' +
|
|
2192
|
+
(rv.isMac ? '<p class="sp-tert">' + esc(isVoiceLabel(v.label) ? MAC_RENAME_NOTE : MAC_RENAME_NOT_VOICE) + '</p>' : scopeSwitch(R)) +
|
|
2193
|
+
'<div class="sp-opts" id="sp-opts">' + namingOptions(R, v) + '</div>' +
|
|
2194
|
+
'<button type="button" class="sp-text" data-sp="naming-cancel">Cancel</button></div>';
|
|
2195
|
+
}
|
|
2196
|
+
var out = v.isNameAssignment ? '<p class="sp-tert">This voice was never named. Name it, or leave it unidentified.</p>' : '';
|
|
2197
|
+
out += '<div class="sp-inline sp-vacts"><button type="button" class="cx-btn quiet" data-sp="naming-open" data-label="' + esc(v.label) + '">' +
|
|
2198
|
+
(v.isNameAssignment ? 'Name this voice' : (v.reliability === 'unreliable' ? 'This is someone else' : 'Actually someone else')) + '</button>';
|
|
2199
|
+
if (v.canConfirmCandidate) {
|
|
2200
|
+
if (v.confirmsInOneClick) out += '<button type="button" class="cx-btn quiet" data-sp="confirm-voice" data-label="' + esc(v.label) + '"' + busy + '>' + esc('Yes, this is ' + v.label) + '</button>';
|
|
2201
|
+
else {
|
|
2202
|
+
out += R.confirmMenu === v.label
|
|
2203
|
+
? '<button type="button" class="cx-btn quiet" data-sp="confirm-voice" data-label="' + esc(v.label) + '"' + busy + '>' + esc('Yes, I checked: this is ' + v.label) + '</button>'
|
|
2204
|
+
: '<button type="button" class="cx-btn quiet sp-clip" data-sp="confirm-menu" data-label="' + esc(v.label) + '"' + busy + '>' + esc('It is ' + v.label + '…') + '</button>';
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
if (v.canDeattribute) out += '<button type="button" class="cx-btn quiet" data-sp="deattribute" data-label="' + esc(v.label) + '"' + busy + '>Not in this meeting</button>';
|
|
2208
|
+
return out + '</div>';
|
|
2209
|
+
}
|
|
2210
|
+
function mergedRow(R, row) {
|
|
2211
|
+
var rv = R.review, open = !!R.expanded[row.id];
|
|
2212
|
+
var labels = row.members.map(function (v) { return v.label; });
|
|
2213
|
+
var out = '<div class="sp-voice-row sp-merged" data-row="' + esc(row.id) + '"><button type="button" class="sp-mhead" data-sp="toggle-row" data-id="' + esc(row.id) + '" aria-expanded="' + open + '" title="' +
|
|
2214
|
+
(open ? 'Hide the voices in this group' : 'Show the voices and words in this group') + '"><span class="sp-disc' + (open ? ' open' : '') + '"></span><b class="sp-dimname">' + esc(rowTitle(labels)) + '</b><span class="sp-grow"></span>' +
|
|
2215
|
+
'<span class="sp-mono-s">' + row.members.length + ' labels</span>' + (row.seconds != null ? '<span class="sp-mono-s">· ' + secs(row.seconds) + '</span>' : '') + '</button>' +
|
|
2216
|
+
'<p class="sp-tert">Machine grouping, not confirmed as one voice.</p>';
|
|
2217
|
+
suggestionsFor(R, labels).forEach(function (sg) { out += suggestionLine(R, sg, row.members.length, labels); });
|
|
2218
|
+
var together = row.members.every(function (v) { return canNameAlone(rv, v); });
|
|
2219
|
+
if (together) {
|
|
2220
|
+
if (R.naming === row.id) {
|
|
2221
|
+
var typed = R.typed[row.id] || '';
|
|
2222
|
+
out += '<div class="sp-naming"><input class="field sp-field" type="text" id="sp-name-input" data-label="' + esc(row.id) + '" placeholder="Type a name" value="' + esc(typed) + '" autocomplete="off" aria-label="Type a name">' +
|
|
2223
|
+
'<p class="sp-tert">' + esc(MAC_RENAME_NOTE + ' Applies to each of the ' + row.members.length + ' labels.') + '</p><div class="sp-opts" id="sp-opts">' + groupOptions(R, row) + '</div>' +
|
|
2224
|
+
'<button type="button" class="sp-text" data-sp="naming-cancel">Cancel</button></div>';
|
|
2225
|
+
} else out += '<button type="button" class="cx-btn quiet" data-sp="naming-open" data-label="' + esc(row.id) + '"' + (R.busy ? ' disabled' : '') + '>Name this group</button>';
|
|
2226
|
+
} else if (rv.mutable) out += '<p class="sp-tert">Name these from their own rows, because at least one has its own reviewed path.</p>';
|
|
2227
|
+
if (open) out += '<div class="sp-members">' + row.members.map(function (v) { return voiceRow(R, v, rv.layout.seconds[v.label]); }).join('') + '</div>';
|
|
2228
|
+
return out + '</div>' + actionZone(R, row.id);
|
|
2229
|
+
}
|
|
2230
|
+
function groupOptions(R, row) {
|
|
2231
|
+
var typed = String(R.typed[row.id] || '').trim(), q = typed.toLowerCase(), busy = R.busy ? ' disabled' : '';
|
|
2232
|
+
var out = q ? (S.profiles || []).filter(function (p) { return p.name.toLowerCase().indexOf(q) >= 0 && !p.isOwner; }).slice(0, 4).map(function (o) {
|
|
2233
|
+
return '<button type="button" class="sp-opt" data-sp="group-pick" data-id="' + esc(row.id) + '" data-name="' + esc(o.name) + '"' + busy + '><span>' + esc(o.name) + '</span>' +
|
|
2234
|
+
(R.busy ? spin() : '<span class="sp-mono-s">' + o.embeddings + ' samples</span>') + '</button>';
|
|
2235
|
+
}).join('') : '';
|
|
2236
|
+
if (typed.length >= 2 && !(S.profiles || []).some(function (p) { return p.name.toLowerCase() === q; })) {
|
|
2237
|
+
out += '<button type="button" class="sp-opt" data-sp="group-new" data-id="' + esc(row.id) + '" data-name="' + esc(typed) + '"' + busy + '><span>' + esc('Use “' + typed + '”') + '</span><span class="sp-mono-s">text only</span></button>';
|
|
2238
|
+
}
|
|
2239
|
+
return out;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// Control pins these at the bottom of its panel; on the web they sit under the row they are about, so a phone sees them.
|
|
2243
|
+
function actionZone(R, anchor, force) {
|
|
2244
|
+
if (!force && (R.anchor || null) !== anchor) return '';
|
|
2245
|
+
R.zoneDrawn = true;
|
|
2246
|
+
var out = '';
|
|
2247
|
+
if (R.error) {
|
|
2248
|
+
out += '<div class="sp-banner" role="alert"><span>' + esc(R.error) + '</span><span class="sp-grow"></span>' +
|
|
2249
|
+
'<button type="button" class="sp-text" data-sp="retry">Try again</button><button type="button" class="sp-text" data-sp="dismiss">Dismiss</button></div>';
|
|
2250
|
+
}
|
|
2251
|
+
if (R.groupProgress) out += '<div class="sp-progress">' + spin() + '<span>' + esc(R.groupProgress) + '</span></div>';
|
|
2252
|
+
if (R.groupPlan) out += groupCard(R, R.groupPlan);
|
|
2253
|
+
if (R.groupOutcome) out += '<div class="sp-banner"><span>' + esc(R.groupOutcome) + '</span><span class="sp-grow"></span><button type="button" class="sp-text" data-sp="dismiss-outcome">Dismiss</button></div>';
|
|
2254
|
+
if (R.pending) out += correctionCard(R, R.pending);
|
|
2255
|
+
return out ? '<div class="sp-zone" id="sp-zone">' + out + '</div>' : '';
|
|
2256
|
+
}
|
|
2257
|
+
function groupCard(R, plan) {
|
|
2258
|
+
var segs = plan.corrections.reduce(function (t, c) { return t + c.surfaces.sidecar; }, 0);
|
|
2259
|
+
var lines = plan.corrections.reduce(function (t, c) { return t + c.surfaces.transcript; }, 0);
|
|
2260
|
+
var requested = plan.corrections.some(function (c) { return c.assignment || c.assignUnsupported; });
|
|
2261
|
+
var adding = plan.corrections.filter(function (c) { return addsSamples(c) && !needsForce(c); }).length;
|
|
2262
|
+
var flagged = plan.corrections.filter(needsForce).length;
|
|
2263
|
+
var out = '<div class="sp-ccard"><b>' + esc('Name ' + plan.labels.length + ' voice labels “' + plan.to + '”?') + '</b>' +
|
|
2264
|
+
'<p class="sp-muted">Saves each label in this meeting, one at a time. The grouping is a machine estimate, so check its words first.</p>';
|
|
2265
|
+
if (segs > 0) out += '<p class="sp-mono-s sp-tert">' + segs + ' segment(s)' + (lines > 0 ? ' · ' + lines + ' transcript line(s)' : '') + '</p>';
|
|
2266
|
+
if (requested) {
|
|
2267
|
+
if (adding > 0) out += '<p class="sp-muted sp-small">' + esc(adding + ' of ' + plan.labels.length + ' labels add voice samples to ' + plan.to + '’s profile.') + '</p>';
|
|
2268
|
+
if (flagged > 0) out += '<p class="sp-muted sp-small">' + flagged + ' were flagged by the voice checks and stay text only. To add those anyway, name them on their own rows.</p>';
|
|
2269
|
+
if (plan.corrections.some(function (c) { return c.assignUnsupported; })) out += '<p class="sp-muted sp-small">' + esc(MAC_UNSUPPORTED) + '</p>';
|
|
2270
|
+
} else out += '<p class="sp-muted sp-small">' + esc(MAC_TYPED_NOTE) + '</p>';
|
|
2271
|
+
return out + '<div class="sp-inline"><button type="button" class="cx-btn primary" data-sp="group-save"' + (R.busy ? ' disabled' : '') + '>Save</button>' +
|
|
2272
|
+
'<button type="button" class="cx-btn quiet" data-sp="group-cancel"' + (R.busy ? ' disabled' : '') + '>Cancel</button></div></div>';
|
|
2273
|
+
}
|
|
2274
|
+
function correctionTitle(c) {
|
|
2275
|
+
if (c.refused) return 'Not applied';
|
|
2276
|
+
if (c.to == null) return 'Remove ' + c.from + ' from this meeting?';
|
|
2277
|
+
return c.scope === 'thisMeeting' ? 'Rename ' + c.from + ' to ' + c.to + ' in this meeting?' : 'Rename ' + c.from + ' to ' + c.to + ' everywhere?';
|
|
2278
|
+
}
|
|
2279
|
+
function correctionCard(R, c) {
|
|
2280
|
+
var rv = R.review, busy = R.busy ? ' disabled' : '';
|
|
2281
|
+
var out = '<div class="sp-ccard" role="dialog" aria-label="' + esc(correctionTitle(c)) + '"><b>' + esc(correctionTitle(c)) + '</b><p class="sp-muted">' + esc(c.message) + '</p>';
|
|
2282
|
+
function line(t, cls) { out += '<p class="' + (cls || 'sp-muted sp-small') + '">' + esc(t) + '</p>'; }
|
|
2283
|
+
if (!c.refused && c.surfaces.sidecar > 0) {
|
|
2284
|
+
line(c.surfaces.sidecar + ' segment(s)' + (c.surfaces.transcript > 0 ? ' · ' + c.surfaces.transcript + ' transcript line(s)' : '') + (c.surfaces.attendees > 0 ? ' · attendee list' : ''), 'sp-mono-s sp-tert');
|
|
2285
|
+
}
|
|
2286
|
+
if (c.isNameAssignment && !c.refused && c.surfaces.sidecar > 0) {
|
|
2287
|
+
line('This voice was never matched to anyone, so nothing has established it is one person. Saving labels all ' + c.surfaces.sidecar + ' of its segments ' + (c.to || '') + '.');
|
|
2288
|
+
}
|
|
2289
|
+
if (rv && rv.isMac && !c.refused && c.to != null) {
|
|
2290
|
+
var al = c.assignUnsupported ? [MAC_UNSUPPORTED] : (c.assignment ? assignLines(c.assignment, c.from) : []);
|
|
2291
|
+
if (al.length) al.forEach(function (t) { line(t); }); else line(MAC_TYPED_NOTE);
|
|
2292
|
+
}
|
|
2293
|
+
if (c.createsProfile && !c.refused) line('This name is not in voice profiles yet. Saving adds it so other segments in this meeting can use it.');
|
|
2294
|
+
if (c.wouldRetract > 0) line('Also removes ' + c.wouldRetract + ' training sample(s) this meeting gave that profile, so the mistake stops reinforcing itself.');
|
|
2295
|
+
if (c.untraceable > 0) line(c.untraceable + ' older sample(s) carry no meeting provenance and cannot be removed.', 'sp-tert sp-small');
|
|
2296
|
+
if (c.proseStale) line('The summary still names the old speaker. It refers to people by first name, so it is left alone rather than risk rewriting a sentence about someone else.', 'sp-tert sp-small');
|
|
2297
|
+
if (c.similarity != null) line('Voice similarity ' + two(c.similarity), 'sp-mono-s sp-tert');
|
|
2298
|
+
if (needsForce(c)) {
|
|
2299
|
+
var person = c.assignment.person;
|
|
2300
|
+
line(R.forceArmed
|
|
2301
|
+
? 'Add ' + c.assignment.samples + ' voice sample' + s(c.assignment.samples) + ' to ' + person + '’s profile even though the checks above flagged this voice? Only do this if you know it is ' + person + '.'
|
|
2302
|
+
: 'The server holds these samples back. ' + person + '’s profile also drives who is recognised in future meetings, so a voice that is not theirs would stay in it until you remove it.');
|
|
2303
|
+
}
|
|
2304
|
+
out += '<div class="sp-inline">';
|
|
2305
|
+
if (!c.refused) {
|
|
2306
|
+
if (needsForce(c)) {
|
|
2307
|
+
out += '<button type="button" class="cx-btn primary" data-sp="corr-rename-only"' + busy + '>Rename only</button>';
|
|
2308
|
+
out += R.forceArmed
|
|
2309
|
+
? '<button type="button" class="cx-btn quiet" data-sp="corr-add-anyway"' + busy + '>Yes, add the samples</button><button type="button" class="sp-text" data-sp="corr-disarm">Back</button>'
|
|
2310
|
+
: '<button type="button" class="cx-btn quiet" data-sp="corr-arm"' + busy + '>Add anyway</button>';
|
|
2311
|
+
} else {
|
|
2312
|
+
out += '<button type="button" class="cx-btn primary" data-sp="corr-save"' + busy + '>Save</button>';
|
|
2313
|
+
if (addsSamples(c)) out += '<button type="button" class="cx-btn quiet" data-sp="corr-rename-only"' + busy + '>Rename only</button>';
|
|
2314
|
+
}
|
|
2315
|
+
} else if (c.forceable) out += '<button type="button" class="cx-btn primary" data-sp="corr-force"' + busy + '>Apply anyway</button>';
|
|
2316
|
+
out += '<button type="button" class="cx-btn quiet" data-sp="corr-cancel">' + (c.refused ? 'OK' : 'Cancel') + '</button>';
|
|
2317
|
+
if (c.refused && !c.forceable) out += '<button type="button" class="sp-text" data-sp="corr-reopen">Reopen review</button>';
|
|
2318
|
+
if (R.busy) out += spin();
|
|
2319
|
+
return out + '</div></div>';
|
|
2320
|
+
}
|
|
2321
|
+
function suggestBar(R) {
|
|
2322
|
+
if (!showsSuggest(R)) return '';
|
|
2323
|
+
var line = suggestLine(R);
|
|
2324
|
+
return '<div class="sp-sbar" id="sp-sbar"><div class="sp-inline"><button type="button" class="cx-btn quiet" data-sp="suggest"' + (isRunning(R) || R.busy ? ' disabled' : '') + '>Suggest names</button>' +
|
|
2325
|
+
(isRunning(R) ? spin() : '') + '</div>' + (line ? '<p class="sp-muted sp-small">' + esc(line) + '</p>' : '') + '</div>';
|
|
2326
|
+
}
|
|
2327
|
+
function policyBlock(R) {
|
|
2328
|
+
var p = R.policy, rv = R.review;
|
|
2329
|
+
if (!rv || !rv.mutable) return '';
|
|
2330
|
+
var dis = p.busy || !p.revision;
|
|
2331
|
+
return '<div class="sp-policy"><div class="sp-inline"><span class="sp-strong">Voice identity</span>' +
|
|
2332
|
+
'<select class="field sp-policy-select" id="sp-policy" aria-label="Voice identity"' + (dis ? ' disabled' : '') + '>' +
|
|
2333
|
+
[['standard', 'Standard'], ['high-confidence', 'High-confidence only'], ['anonymous', 'Anonymous / video']].map(function (o) {
|
|
2334
|
+
return '<option value="' + o[0] + '"' + (o[0] === p.mode ? ' selected' : '') + '>' + o[1] + '</option>';
|
|
2335
|
+
}).join('') + '</select>' +
|
|
2336
|
+
'<button type="button" class="cx-btn quiet" data-sp="policy-apply"' + (dis || (p.mode === p.saved && p.revision !== 'unreadable') ? ' disabled' : '') + '>' + (p.revision === 'unreadable' ? 'Repair settings' : 'Apply') + '</button>' +
|
|
2337
|
+
(p.busy ? spin() : '') + '</div><p class="sp-muted sp-small">Applies to new audio in this recording. Strict modes keep transcription, skip automatic profile training, and never assume the wearer is speaking. Existing names need a speaker correction.</p>' +
|
|
2338
|
+
(p.error ? '<p class="sp-err">' + esc(p.error) + '</p>' : '') +
|
|
2339
|
+
(!p.revision && !p.busy ? '<button type="button" class="sp-text" data-sp="policy-retry">Retry voice settings</button>' : '') + '</div>';
|
|
2340
|
+
}
|
|
2341
|
+
function reviewHeader(R) {
|
|
2342
|
+
var rv = R.review, lines = [];
|
|
2343
|
+
if (rv) {
|
|
2344
|
+
lines.push('<p class="sp-mono">' + esc(inventoryHeader(rv)) + '</p>');
|
|
2345
|
+
if (rv.assertedSegments != null && rv.segments > 0 && rv.attributed) {
|
|
2346
|
+
lines.push('<p class="sp-mono' + (rv.assertedSegments * 2 < rv.segments ? ' sp-orange' : '') + '">' + rv.assertedSegments + ' of ' + rv.segments + ' segments named</p>');
|
|
2347
|
+
}
|
|
2348
|
+
if (R.copyNote) lines.push('<p class="sp-mono sp-green">' + esc(R.copyNote) + '</p>');
|
|
2349
|
+
if (rv.summary) { if (rv.summary.unresolvedNote) lines.push('<p class="sp-mono sp-amber-t">' + esc(rv.summary.unresolvedNote) + '</p>'); }
|
|
2350
|
+
else if (!rv.intervalReadOnly && rv.unnamed > 0) {
|
|
2351
|
+
lines.push('<p class="sp-mono sp-amber-t">' + esc(rv.layout ? rv.unnamed + ' voice label' + (rv.unnamed === 1 ? ' has' : 's have') + ' no name yet'
|
|
2352
|
+
: rv.unnamed === 1 ? '1 voice still needs a name' : rv.unnamed + ' voices still need names') + '</p>');
|
|
2353
|
+
}
|
|
2354
|
+
if (rv.coverage != null && rv.attributed) {
|
|
2355
|
+
lines.push('<p class="sp-mono' + (rv.coverage < 0.6 ? ' sp-orange' : '') + '">' + esc(rv.coverage < 0.6
|
|
2356
|
+
? 'only ' + Math.round(rv.coverage * 100) + '% of the voice identified, shares unreliable'
|
|
2357
|
+
: 'talk time from ' + (rv.speakingTimeSource === 'words' ? 'word timings' : 'chunk estimates')) + '</p>');
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
var acts = '';
|
|
2361
|
+
if (rv && rv.recordId) acts += '<button type="button" class="cx-btn quiet" data-sp="open-meeting" title="Open this meeting’s transcript, summary and files">' + icon('meetings') + 'Open meeting</button>';
|
|
2362
|
+
if (R.content && R.content.clipboardSummary) {
|
|
2363
|
+
acts += '<button type="button" class="cx-btn quiet" data-sp="copy-summary" title="Copy the meeting without the transcript">Summary</button>' +
|
|
2364
|
+
'<button type="button" class="cx-btn quiet" data-sp="copy-full" title="Copy the meeting including the full transcript">Full (' + Math.max(1, Math.floor(R.content.fullChars / 1024)) + ' KB)</button>';
|
|
2365
|
+
}
|
|
2366
|
+
var next = S.meetings ? nextUnnamed(R.sid) : null;
|
|
2367
|
+
acts += '<button type="button" class="cx-btn quiet" data-sp="next-unnamed"' + (next ? '' : ' disabled') + ' title="' +
|
|
2368
|
+
(next ? 'Open the next meeting that still needs names (⌘])' : 'No other meetings still need names') + '">Next to name' + icon('chev') + '</button>';
|
|
2369
|
+
return '<div class="cx-detail-head sp-rhead"><div class="sp-rtitle"><h2 class="cx-detail-title sp-display">' + esc(rv ? rv.title : 'Speaker review') + '</h2>' + lines.join('') + '</div>' +
|
|
2370
|
+
'<div class="cx-actions sp-racts">' + acts + '</div></div>';
|
|
2371
|
+
}
|
|
2372
|
+
function writeUp(R) {
|
|
2373
|
+
var c = R.content;
|
|
2374
|
+
if (c && c.sections.length) {
|
|
2375
|
+
var stale = c.removed.filter(function (r) { return r.proseStale; }).map(function (r) { return '"' + r.label + '"'; });
|
|
2376
|
+
return '<div class="sp-writeup">' + (stale.length ? '<p class="sp-mono sp-orange">' + esc('You removed ' + stale.join(', ') + ' from this meeting. The write-up below was written before that and still uses the name.') + '</p>' : '') +
|
|
2377
|
+
c.sections.map(function (sec) { return '<div class="sp-sec"><div class="sp-caps">' + esc(sec[0].toUpperCase()) + '</div><div class="sp-sec-body">' + esc(panelPreview(sec[1])) + '</div></div>'; }).join('') + '</div>';
|
|
2378
|
+
}
|
|
2379
|
+
if (R.contentUnavailable) {
|
|
2380
|
+
return '<p class="sp-muted sp-writeup-note">' + esc(R.contentUnavailable === 'route_absent' ? 'The meeting write-up needs glasses-server 6.21.28 or newer. Use Update Server in Settings.'
|
|
2381
|
+
: R.contentUnavailable === 'error' ? 'The meeting write-up could not be loaded.' : R.contentUnavailable) + '</p>';
|
|
2382
|
+
}
|
|
2383
|
+
if (c && !c.scribeAvailable) return '<p class="sp-muted sp-writeup-note">No write-up saved for this meeting yet.</p>';
|
|
2384
|
+
return '';
|
|
2385
|
+
}
|
|
2386
|
+
function reviewBody(R) {
|
|
2387
|
+
var rv = R.review;
|
|
2388
|
+
if (!rv) {
|
|
2389
|
+
if (R.loadError) return '<div class="sp-empty"><p>' + esc(R.loadError) + '</p><button type="button" class="cx-btn quiet" data-sp="retry">Try again</button></div>';
|
|
2390
|
+
return '<p class="cx-note sp-loading">' + spin() + 'Reading the transcript…</p>';
|
|
2391
|
+
}
|
|
2392
|
+
var out = '';
|
|
2393
|
+
if (rv.timeline.length) out += ribbon(R);
|
|
2394
|
+
else if (rv.attributed) out += '<p class="sp-muted sp-small sp-pad">The speaking timeline needs glasses-server 6.21.18 or newer. Use Update Server in Settings.</p>';
|
|
2395
|
+
if (!rv.attributed) {
|
|
2396
|
+
out += '<div class="sp-pad"><b>No one was identified in this recording</b><p class="sp-muted">' + esc(rv.isMac ? MAC_UNATTRIBUTED
|
|
2397
|
+
: 'The audio was recovered after the session ended, so no voice matching ran while it played. The lines below are all that is left to go on.') + '</p></div>';
|
|
2398
|
+
}
|
|
2399
|
+
out += suggestBar(R);
|
|
2400
|
+
R.zoneDrawn = false;
|
|
2401
|
+
var list = '';
|
|
2402
|
+
if (rv.layout) {
|
|
2403
|
+
list += rv.layout.rows.map(function (row) { return row.merged ? mergedRow(R, row.merged) : voiceRow(R, row.voice, rv.layout.seconds[row.voice.label]); }).join('');
|
|
2404
|
+
if (rv.layout.short.length) {
|
|
2405
|
+
// A card about a short voice opens the fold, or it would sit hidden inside it.
|
|
2406
|
+
var holds = R.anchor != null && rv.layout.short.some(function (v) { return v.label === R.anchor; }) && !!(R.error || R.pending || R.groupPlan || R.groupProgress || R.groupOutcome);
|
|
2407
|
+
if (holds) R.shortOpen = true;
|
|
2408
|
+
list += '<details class="sp-fold sp-short"' + (R.shortOpen ? ' open' : '') + ' data-fold="short"><summary>Short voices (' + rv.layout.short.length + ')</summary>' +
|
|
2409
|
+
rv.layout.short.map(function (v) { return voiceRow(R, v, rv.layout.seconds[v.label]); }).join('') + '</details>';
|
|
2410
|
+
}
|
|
2411
|
+
} else list += rv.ordered.map(function (v) { return voiceRow(R, v, null); }).join('');
|
|
2412
|
+
// A row that is not on screen (a collapsed group, a label a refetch renamed) still gets its card, above the list.
|
|
2413
|
+
out += R.zoneDrawn ? '' : actionZone(R, null, true);
|
|
2414
|
+
out += '<div class="sp-voices">' + list + '</div>';
|
|
2415
|
+
return out + writeUp(R);
|
|
2416
|
+
}
|
|
2417
|
+
function renderReview(R) {
|
|
2418
|
+
return '<div class="cx sp-review" data-tint="speakers">' + reviewHeader(R) + partsBanner(R) + policyBlock(R) +
|
|
2419
|
+
(R.loading && R.review ? '<p class="cx-note sp-loading">' + spin() + 'Reading the transcript…</p>' : '') + reviewBody(R) + '</div>';
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// ── render dispatch ────────────────────────────────────
|
|
2423
|
+
var expiryTimer = null;
|
|
2424
|
+
function render() {
|
|
2425
|
+
if (W.parseRoute().pane !== 'speakers') return;
|
|
2426
|
+
var rt = route();
|
|
2427
|
+
var active = document.activeElement;
|
|
2428
|
+
var fid = active && pane.contains(active) && active.id ? active.id : null, sel = null;
|
|
2429
|
+
if (fid && typeof active.selectionStart === 'number') { try { sel = [active.selectionStart, active.selectionEnd]; } catch (_e) { sel = null; } }
|
|
2430
|
+
// A rebuild in place keeps the page where the finger left it; a move to another list or detail restores where that
|
|
2431
|
+
// one was left (Back to a list lands on the row you came from), or starts a new detail at the top.
|
|
2432
|
+
var where = location.hash + '|' + rt.view, y = window.scrollY || 0;
|
|
2433
|
+
S.scrollMem = S.scrollMem || {};
|
|
2434
|
+
var moved = S.lastWhere != null && S.lastWhere !== where;
|
|
2435
|
+
if (moved) S.scrollMem[S.lastWhere] = y;
|
|
2436
|
+
S.lastWhere = where;
|
|
2437
|
+
var html;
|
|
2438
|
+
if (rt.review) html = S.R ? renderReview(S.R) : '';
|
|
2439
|
+
else if (rt.voice) html = voiceDetail(rt.voice);
|
|
2440
|
+
else {
|
|
2441
|
+
if (rt.view === 'samples' && !S.H) S.H = newHeld();
|
|
2442
|
+
html = '<div class="cx" data-tint="speakers">' + hero(rt.view) + toolbar(rt.view) +
|
|
2443
|
+
(rt.view === 'meetings' ? meetingsBody() : rt.view === 'samples' ? samplesBody() : voicesBody()) + '</div>';
|
|
2444
|
+
}
|
|
2445
|
+
pane.innerHTML = html;
|
|
2446
|
+
var target = moved ? (S.scrollMem[where] || 0) : y;
|
|
2447
|
+
if (Math.abs((window.scrollY || 0) - target) > 1) window.scrollTo(0, target);
|
|
2448
|
+
if (fid) {
|
|
2449
|
+
var el = $(fid);
|
|
2450
|
+
if (el) { try { el.focus({ preventScroll: true }); if (sel) el.setSelectionRange(sel[0], sel[1]); } catch (_e) {} }
|
|
2451
|
+
}
|
|
2452
|
+
var wantExpiry = !rt.review && !rt.voice && rt.view === 'samples' && S.H && S.H.sheet === 'preview' && S.H.preview;
|
|
2453
|
+
if (wantExpiry && !expiryTimer) expiryTimer = setInterval(tickExpiry, 1000);
|
|
2454
|
+
if (!wantExpiry && expiryTimer) { clearInterval(expiryTimer); expiryTimer = null; }
|
|
2455
|
+
}
|
|
2456
|
+
function tickExpiry() {
|
|
2457
|
+
var H = S.H, el = $('sp-expiry');
|
|
2458
|
+
if (!H || !H.preview || !el) return;
|
|
2459
|
+
el.textContent = expiryLine(H.preview);
|
|
2460
|
+
var btn = pane.querySelector('[data-sp="sheet-apply"]');
|
|
2461
|
+
if (btn) btn.disabled = !canApply(H);
|
|
2462
|
+
}
|
|
2463
|
+
// The ribbon readout updates in place: a full render per pointer move would be needless work on a phone.
|
|
2464
|
+
function paintRibbon() {
|
|
2465
|
+
var R = S.R, read = $('sp-tl-read'), bar = $('sp-bar');
|
|
2466
|
+
if (!R || !R.review || !read || !bar) return;
|
|
2467
|
+
var rv = R.review, span = R.hovered != null ? rv.timeline[R.hovered] : null;
|
|
2468
|
+
read.innerHTML = span ? '<span class="sp-mono-s">' + stamp(span.startMs) + '</span> <b>' + esc(displayNameOf(rv, span.speaker)) + '</b> <span class="sp-mono-s">' +
|
|
2469
|
+
span.segments + ' seg · ' + Math.max(1, Math.floor((span.endMs - span.startMs) / 1000)) + 's</span>'
|
|
2470
|
+
: '<span class="sp-muted sp-small">' + (hoverless() ? 'Tap the bar to see who is speaking' : 'Hover the bar to see who is speaking') + '</span>';
|
|
2471
|
+
Array.prototype.forEach.call(bar.children, function (c, i) { c.classList.toggle('dim', R.hoveredCol != null && i !== R.hoveredCol); });
|
|
2472
|
+
}
|
|
2473
|
+
function pointAt(e) {
|
|
2474
|
+
var R = S.R, bar = $('sp-bar');
|
|
2475
|
+
if (!R || !R.review || !bar || !R.cols) return;
|
|
2476
|
+
var rect = bar.getBoundingClientRect();
|
|
2477
|
+
var i = Math.max(0, Math.min(R.cols.length - 1, Math.floor((e.clientX - rect.left) / Math.max(1, rect.width) * R.cols.length)));
|
|
2478
|
+
var at = R.cols[i].startMs, tl = R.review.timeline, idx = null;
|
|
2479
|
+
for (var k = 0; k < tl.length; k++) { if (tl[k].startMs <= at) idx = k; else break; }
|
|
2480
|
+
R.hoveredCol = i; R.hovered = idx == null ? (tl.length ? 0 : null) : idx;
|
|
2481
|
+
paintRibbon();
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// ── events ─────────────────────────────────────────────
|
|
2485
|
+
function heldRefs(key) {
|
|
2486
|
+
var H = S.H;
|
|
2487
|
+
if (!H) return [];
|
|
2488
|
+
if (key.indexOf('loose:') === 0) return H.loose.filter(function (x) { return 'loose:' + x.id === key; });
|
|
2489
|
+
if (key.indexOf('ext:') === 0) {
|
|
2490
|
+
var x = H.extSessions.filter(function (e) { return 'ext:' + e.sessionId === key; })[0];
|
|
2491
|
+
return x ? x.chunkIndices.map(function (c) { return { sessionId: x.sessionId, chunkIndex: c }; }) : [];
|
|
2492
|
+
}
|
|
2493
|
+
var g = H.groups.filter(function (y) { return y.id === key; })[0];
|
|
2494
|
+
return g ? g.playOrder : [];
|
|
2495
|
+
}
|
|
2496
|
+
function heldMembers(key) {
|
|
2497
|
+
var H = S.H;
|
|
2498
|
+
if (key.indexOf('loose:') === 0) return heldRefs(key);
|
|
2499
|
+
var g = H.groups.filter(function (y) { return y.id === key; })[0];
|
|
2500
|
+
return g ? g.members : [];
|
|
2501
|
+
}
|
|
2502
|
+
function heldSuggestion(key) {
|
|
2503
|
+
var H = S.H;
|
|
2504
|
+
if (key.indexOf('loose:') === 0) { var l = heldRefs(key)[0]; return l && l.suggestion ? l.suggestion.name : null; }
|
|
2505
|
+
var g = H.groups.filter(function (y) { return y.id === key; })[0];
|
|
2506
|
+
return g ? g.suggestionName : null;
|
|
2507
|
+
}
|
|
2508
|
+
function findRow(R, id) {
|
|
2509
|
+
var rows = (R.review && R.review.layout) ? R.review.layout.rows : [];
|
|
2510
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].merged && rows[i].merged.id === id) return rows[i].merged;
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
function openMeeting(R) {
|
|
2514
|
+
var rv = R.review;
|
|
2515
|
+
var row = (S.meetings || []).filter(function (m) { return m.sessionId === R.sid; })[0];
|
|
2516
|
+
var month = (row && row.month) || ((/^(\d{4}-\d{2})/.exec(rv.filename) || [])[1]) || '';
|
|
2517
|
+
var domain = (row && row.domain) || rv.domain, file = (row && row.filename) || rv.filename;
|
|
2518
|
+
if (domain && month && file) W.go('meetings', [domain, month, file]);
|
|
2519
|
+
else W.toast('This meeting has no saved file to open.');
|
|
2520
|
+
}
|
|
2521
|
+
function scrollZone() {
|
|
2522
|
+
setTimeout(function () { var z = $('sp-zone'); if (z && z.scrollIntoView) z.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, 30);
|
|
2523
|
+
}
|
|
2524
|
+
pane.addEventListener('click', function (e) {
|
|
2525
|
+
var t = e.target.closest('[data-sp]');
|
|
2526
|
+
if (!t || !pane.contains(t)) return;
|
|
2527
|
+
var act = t.getAttribute('data-sp'), R = S.R, H = S.H, rt = route();
|
|
2528
|
+
switch (act) {
|
|
2529
|
+
case 'view': {
|
|
2530
|
+
var v = t.getAttribute('data-view');
|
|
2531
|
+
stopPlayback(); A.note = null;
|
|
2532
|
+
if (H) { H.naming = null; H.confirmDiscard = null; H.result = ''; }
|
|
2533
|
+
S.view = v;
|
|
2534
|
+
if (W.parseRoute().args.length) W.go('speakers'); else render();
|
|
2535
|
+
if (v === 'meetings') loadMeetings(); else if (v === 'samples') { if (!S.H) S.H = newHeld(); loadHeld(); } else loadDirectory(false);
|
|
2536
|
+
syncPeek();
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
case 'refresh':
|
|
2540
|
+
if (rt.view === 'meetings') loadMeetings(); else if (rt.view === 'samples') loadHeld(); else loadDirectory(true);
|
|
2541
|
+
return;
|
|
2542
|
+
case 'open-review': W.go('speakers', ['review', t.getAttribute('data-sid')]); return;
|
|
2543
|
+
case 'open-voice': W.go('speakers', ['voice', t.getAttribute('data-name')]); return;
|
|
2544
|
+
case 'open-appearance': W.go('speakers', ['voice', rt.voice, 'review', t.getAttribute('data-sid')]); return;
|
|
2545
|
+
case 'clear-search': S.voiceSearch = ''; render(); return;
|
|
2546
|
+
case 'dir-retry': loadDirectory(true); return;
|
|
2547
|
+
// Samples
|
|
2548
|
+
case 'held-play': case 'held-prev': case 'held-next': {
|
|
2549
|
+
var key = t.getAttribute('data-key'), refs = heldRefs(key);
|
|
2550
|
+
if (!refs.length) return;
|
|
2551
|
+
var cur = Math.min(Math.max(H.cursor[key] || 0, 0), refs.length - 1);
|
|
2552
|
+
if (act === 'held-play') { var ref = refs[cur]; play('held:' + ref.sessionId + '#' + ref.chunkIndex, t.getAttribute('data-voice'), extSampleUrl(ref), 'That sample is no longer held.'); return; }
|
|
2553
|
+
stopPlayback();
|
|
2554
|
+
H.cursor[key] = (cur + (act === 'held-next' ? 1 : -1) + refs.length) % refs.length;
|
|
2555
|
+
render(); return;
|
|
2556
|
+
}
|
|
2557
|
+
case 'held-add': { var k = t.getAttribute('data-key'); nameHeld(heldMembers(k), heldSuggestion(k)); return; }
|
|
2558
|
+
case 'held-name': H.naming = t.getAttribute('data-key'); H.typed = ''; H.confirmDiscard = null; render(); var inp = $('sp-held-input'); if (inp) inp.focus(); return;
|
|
2559
|
+
case 'held-cancel': H.naming = null; H.typed = ''; render(); return;
|
|
2560
|
+
case 'held-preview': nameHeld(heldMembers(H.naming), H.typed).then(function () { if (!H.naming) H.typed = ''; }); return;
|
|
2561
|
+
case 'held-fill': H.typed = t.getAttribute('data-name'); render(); return;
|
|
2562
|
+
case 'held-discard': H.confirmDiscard = t.getAttribute('data-key'); H.naming = null; render(); return;
|
|
2563
|
+
case 'held-discard-no': H.confirmDiscard = null; render(); return;
|
|
2564
|
+
case 'held-discard-yes': { var dk = t.getAttribute('data-key'); H.confirmDiscard = null; discardHeld(heldMembers(dk)); return; }
|
|
2565
|
+
case 'held-discard-all': H.confirmDiscard = 'loose-all'; render(); return;
|
|
2566
|
+
case 'held-discard-all-yes': H.confirmDiscard = null; discardHeld(H.loose.slice()); return;
|
|
2567
|
+
case 'held-more': H.shown += ROW_PAGE; render(); return;
|
|
2568
|
+
case 'held-history': H.sheet = 'history'; render(); return;
|
|
2569
|
+
case 'held-result': H.sheet = 'result'; render(); return;
|
|
2570
|
+
case 'held-undo-ask': H.confirmUndo = t.getAttribute('data-handle'); render(); window.scrollTo(0, 0); return;
|
|
2571
|
+
case 'held-undo-no': H.confirmUndo = null; render(); return;
|
|
2572
|
+
case 'held-undo-yes': undoHeld(H.confirmUndo); return;
|
|
2573
|
+
case 'held-resume': { var b = H.batches.filter(function (x) { return x.batchId === t.getAttribute('data-batch'); })[0]; if (b) resumeHeld(b); return; }
|
|
2574
|
+
case 'sheet-cancel': cancelPreview(); render(); return;
|
|
2575
|
+
case 'sheet-close': H.sheet = null; render(); return;
|
|
2576
|
+
case 'sheet-apply': applyHeld(); return;
|
|
2577
|
+
case 'sheet-repreview': nameHeld(H.preview ? H.preview.members : H.members, H.revised); return;
|
|
2578
|
+
case 'naming-play': {
|
|
2579
|
+
var sid = t.getAttribute('data-sid'), ch = Number(t.getAttribute('data-chunk'));
|
|
2580
|
+
play(t.getAttribute('data-key'), 'held-naming-preview', sidPath(sid) + '/audio/' + ch, 'Audio expired, vectors only.'); return;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (!R || !R.review) { if (act === 'retry') retryReview(); return; }
|
|
2584
|
+
switch (act) {
|
|
2585
|
+
case 'retry': retryReview(); return;
|
|
2586
|
+
case 'retry-parts': retryParts(); return;
|
|
2587
|
+
case 'dismiss': R.error = ''; render(); return;
|
|
2588
|
+
case 'dismiss-outcome': R.groupOutcome = ''; render(); return;
|
|
2589
|
+
case 'legend': {
|
|
2590
|
+
var sp = t.getAttribute('data-speaker'), tl = R.review.timeline;
|
|
2591
|
+
for (var i = 0; i < tl.length; i++) if (tl[i].speaker === sp) { R.hovered = i; break; }
|
|
2592
|
+
R.hoveredCol = null; paintRibbon(); return;
|
|
2593
|
+
}
|
|
2594
|
+
case 'phrase': {
|
|
2595
|
+
var label = t.getAttribute('data-label'), chunk = Number(t.getAttribute('data-chunk'));
|
|
2596
|
+
play(label + '#' + chunk, label, sidPath(R.sid) + '/audio/' + chunk, 'That segment’s audio is no longer held.'); return;
|
|
2597
|
+
}
|
|
2598
|
+
case 'naming-open': R.naming = t.getAttribute('data-label'); R.confirmMenu = null; render(); var ni = $('sp-name-input'); if (ni) ni.focus(); return;
|
|
2599
|
+
case 'naming-cancel': if (R.naming) R.typed[R.naming] = ''; R.naming = null; render(); return;
|
|
2600
|
+
case 'scope': R.scope = t.getAttribute('data-scope'); render(); return;
|
|
2601
|
+
case 'rename-pick': case 'rename-new': {
|
|
2602
|
+
var lb = t.getAttribute('data-label'), v = R.review.byLabel[lb], to = t.getAttribute('data-name');
|
|
2603
|
+
if (!v) return;
|
|
2604
|
+
previewRename(lb, to, R.scope, v.isNameAssignment, act === 'rename-pick' && R.review.isMac && isVoiceLabel(lb));
|
|
2605
|
+
scrollZone(); return;
|
|
2606
|
+
}
|
|
2607
|
+
case 'group-pick': case 'group-new': {
|
|
2608
|
+
var row = findRow(R, t.getAttribute('data-id'));
|
|
2609
|
+
if (row) { previewGroupName(row, t.getAttribute('data-name'), act === 'group-pick'); scrollZone(); }
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
case 'group-save': if (R.groupPlan) confirmGroupName(R.groupPlan); return;
|
|
2613
|
+
case 'group-cancel': R.groupPlan = null; render(); return;
|
|
2614
|
+
case 'toggle-row': { var id = t.getAttribute('data-id'); R.expanded[id] = !R.expanded[id]; render(); return; }
|
|
2615
|
+
case 'confirm-menu': R.confirmMenu = t.getAttribute('data-label'); render(); return;
|
|
2616
|
+
case 'confirm-voice': R.confirmMenu = null; confirmVoice(t.getAttribute('data-label')); return;
|
|
2617
|
+
case 'deattribute': previewDeattribution(t.getAttribute('data-label')); scrollZone(); return;
|
|
2618
|
+
case 'use-suggestion': {
|
|
2619
|
+
var rows = t.getAttribute('data-rows').split('|'), sg = suggestionsFor(R, rows).filter(function (x) { return x.id === t.getAttribute('data-id'); })[0];
|
|
2620
|
+
if (sg) { useSuggestion(sg, rows); scrollZone(); }
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
case 'suggest': startSuggestions(); return;
|
|
2624
|
+
case 'corr-save': confirmCorrection(R.pending, {}); return;
|
|
2625
|
+
case 'corr-rename-only': confirmCorrection(R.pending, { renameOnly: true }); return;
|
|
2626
|
+
case 'corr-arm': if (R.pending && needsForce(R.pending)) { R.forceArmed = true; render(); } return;
|
|
2627
|
+
case 'corr-disarm': R.forceArmed = false; render(); return;
|
|
2628
|
+
case 'corr-add-anyway': confirmCorrection(R.pending, { addAnyway: true }); return;
|
|
2629
|
+
case 'corr-force': confirmCorrection(R.pending, { force: true }); return;
|
|
2630
|
+
case 'corr-cancel': R.pending = null; R.forceArmed = false; render(); return;
|
|
2631
|
+
case 'corr-reopen': R.pending = null; retryReview(); return;
|
|
2632
|
+
case 'policy-apply': savePolicy(R); return;
|
|
2633
|
+
case 'policy-retry': loadPolicy(R); render(); return;
|
|
2634
|
+
case 'open-meeting': openMeeting(R); return;
|
|
2635
|
+
case 'copy-summary': case 'copy-full': {
|
|
2636
|
+
var full = act === 'copy-full', c = R.content;
|
|
2637
|
+
var text = full ? c.clipboardFull : c.clipboardSummary;
|
|
2638
|
+
if (!text) { R.copyNote = 'Nothing to copy'; render(); return; }
|
|
2639
|
+
W.copyText(text).then(function () {
|
|
2640
|
+
var kb = Math.max(1, Math.floor((full ? c.fullChars : c.summaryChars) / 1024));
|
|
2641
|
+
R.copyNote = full ? 'Copied full meeting (' + kb + ' KB)' : 'Copied summary (' + kb + ' KB)'; render();
|
|
2642
|
+
}, function (err) { W.toast(err.message); });
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
case 'next-unnamed': {
|
|
2646
|
+
var nx = S.meetings ? nextUnnamed(R.sid) : null;
|
|
2647
|
+
if (nx) W.go('speakers', ['review', nx.sessionId]);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
});
|
|
2652
|
+
pane.addEventListener('input', function (e) {
|
|
2653
|
+
var id = e.target.id, R = S.R, H = S.H;
|
|
2654
|
+
if (id === 'sp-name-input' && R) {
|
|
2655
|
+
var label = e.target.getAttribute('data-label');
|
|
2656
|
+
R.typed[label] = e.target.value;
|
|
2657
|
+
var box = $('sp-opts');
|
|
2658
|
+
if (box) { var row = findRow(R, label); box.innerHTML = row ? groupOptions(R, row) : (R.review.byLabel[label] ? namingOptions(R, R.review.byLabel[label]) : ''); }
|
|
2659
|
+
} else if (id === 'sp-held-input' && H) {
|
|
2660
|
+
H.typed = e.target.value;
|
|
2661
|
+
var hint = $('sp-held-hint'); if (hint) hint.innerHTML = nameHint(H.typed);
|
|
2662
|
+
var pb = pane.querySelector('[data-sp="held-preview"]');
|
|
2663
|
+
if (pb) pb.disabled = H.busy || !H.speakerModel || !H.namingAvailable || H.typed.trim().length < 2;
|
|
2664
|
+
} else if (id === 'sp-revised' && H) {
|
|
2665
|
+
H.revised = e.target.value;
|
|
2666
|
+
var rb = pane.querySelector('[data-sp="sheet-repreview"]'); if (rb) rb.disabled = H.busy || H.revised.trim().length < 2;
|
|
2667
|
+
} else if (id === 'sp-vq') {
|
|
2668
|
+
S.voiceSearch = e.target.value;
|
|
2669
|
+
clearTimeout(pane._vq); pane._vq = setTimeout(render, 120);
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
pane.addEventListener('change', function (e) {
|
|
2673
|
+
var id = e.target.id, H = S.H, R = S.R;
|
|
2674
|
+
if (id === 'sp-hide') { P.hideReviewed = e.target.checked; savePrefs(); render(); }
|
|
2675
|
+
else if (id === 'sp-msort') { P.sort = e.target.value; savePrefs(); render(); }
|
|
2676
|
+
else if (id === 'sp-vsort') { S.voiceSort = e.target.value; render(); }
|
|
2677
|
+
else if (id === 'sp-policy' && R) { R.policy.mode = e.target.value; render(); }
|
|
2678
|
+
else if (id === 'sp-owner-ack' && H) { H.ownerAck = e.target.checked; render(); }
|
|
2679
|
+
else if (id === 'sp-listened' && H) { H.listened = e.target.checked; render(); }
|
|
2680
|
+
});
|
|
2681
|
+
pane.addEventListener('keydown', function (e) {
|
|
2682
|
+
if (e.key !== 'Enter') return;
|
|
2683
|
+
var H = S.H;
|
|
2684
|
+
if (e.target.id === 'sp-held-input' && H && H.typed.trim().length >= 2 && !H.busy) { e.preventDefault(); nameHeld(heldMembers(H.naming), H.typed); }
|
|
2685
|
+
else if (e.target.id === 'sp-revised' && H && H.revised.trim().length >= 2 && !H.busy) { e.preventDefault(); nameHeld(H.preview ? H.preview.members : H.members, H.revised); }
|
|
2686
|
+
});
|
|
2687
|
+
pane.addEventListener('toggle', function (e) { if (S.R && e.target.getAttribute && e.target.getAttribute('data-fold') === 'short') S.R.shortOpen = e.target.open; }, true);
|
|
2688
|
+
pane.addEventListener('pointermove', function (e) { if (e.target.closest && e.target.closest('#sp-bar') && e.pointerType === 'mouse') pointAt(e); });
|
|
2689
|
+
pane.addEventListener('pointerdown', function (e) { if (e.target.closest && e.target.closest('#sp-bar')) pointAt(e); });
|
|
2690
|
+
pane.addEventListener('pointerout', function (e) {
|
|
2691
|
+
var R = S.R;
|
|
2692
|
+
if (!R || e.pointerType !== 'mouse' || !e.target.closest || !e.target.closest('#sp-bar')) return;
|
|
2693
|
+
if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('#sp-bar')) return;
|
|
2694
|
+
R.hovered = null; R.hoveredCol = null; paintRibbon();
|
|
2695
|
+
});
|
|
2696
|
+
document.addEventListener('keydown', function (e) {
|
|
2697
|
+
if (!(e.metaKey || e.ctrlKey) || e.key !== ']' || !S.R || W.parseRoute().pane !== 'speakers') return;
|
|
2698
|
+
var nx = S.meetings ? nextUnnamed(S.R.sid) : null;
|
|
2699
|
+
if (nx) { e.preventDefault(); W.go('speakers', ['review', nx.sessionId]); }
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
// A deep link rewritten to #/speakers was routed as a reader; put back the root chrome route() would have drawn.
|
|
2703
|
+
function rootChrome() {
|
|
2704
|
+
var back = $('topbar-back'), sw = $('section-btn');
|
|
2705
|
+
if (back) back.classList.add('hidden');
|
|
2706
|
+
if (sw) sw.classList.remove('hidden');
|
|
2707
|
+
pane.classList.remove('reading');
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// ── registration ───────────────────────────────────────
|
|
2711
|
+
W.registerPane('speakers', {
|
|
2712
|
+
load: function (args, ctx) {
|
|
2713
|
+
S.signedOut = false;
|
|
2714
|
+
var a0 = (args || [])[0];
|
|
2715
|
+
if (a0 === 'meetings' || a0 === 'samples' || a0 === 'voices') {
|
|
2716
|
+
S.view = a0;
|
|
2717
|
+
try { history.replaceState(history.state, '', location.pathname + location.search + '#/speakers'); } catch (_e) {}
|
|
2718
|
+
rootChrome();
|
|
2719
|
+
}
|
|
2720
|
+
var rt = route(), changed = !!(ctx && ctx.changed);
|
|
2721
|
+
// A voice detail belongs to the directory; a review opened from anywhere else returns to Meetings to review.
|
|
2722
|
+
if (rt.voice) S.view = 'voices';
|
|
2723
|
+
else if (rt.review) S.view = 'meetings';
|
|
2724
|
+
if (S.R && !(rt.review === S.R.sid && (rt.voice || null) === S.R.parent)) closeReview();
|
|
2725
|
+
if (rt.review) {
|
|
2726
|
+
openReview(rt.review, rt.voice || null);
|
|
2727
|
+
if (!S.meetings && !S.meetingsLoading) loadMeetings();
|
|
2728
|
+
if (S.R && S.R.review) W.setCrumb(S.R.review.title);
|
|
2729
|
+
render();
|
|
2730
|
+
} else if (rt.voice) {
|
|
2731
|
+
if (!S.dir && !S.dirLoading) loadDirectory(false);
|
|
2732
|
+
render();
|
|
2733
|
+
} else if (rt.view === 'samples') {
|
|
2734
|
+
if (!S.H) S.H = newHeld();
|
|
2735
|
+
if (changed || S.H.state == null) loadHeld(); else render();
|
|
2736
|
+
} else if (rt.view === 'voices') {
|
|
2737
|
+
if (changed || !S.dir) loadDirectory(false); else render();
|
|
2738
|
+
} else {
|
|
2739
|
+
if (changed || !S.meetings) loadMeetings(); else render();
|
|
2740
|
+
}
|
|
2741
|
+
syncPeek();
|
|
2742
|
+
},
|
|
2743
|
+
leave: function () {
|
|
2744
|
+
closeReview(); stopPlayback();
|
|
2745
|
+
if (S.peekTimer) { clearInterval(S.peekTimer); S.peekTimer = null; }
|
|
2746
|
+
if (expiryTimer) { clearInterval(expiryTimer); expiryTimer = null; }
|
|
2747
|
+
},
|
|
2748
|
+
// Back unwinds one step: a review opened from a voice returns to that voice, a voice to the directory.
|
|
2749
|
+
// A voice returns to the directory and a review to its list: both are #/speakers, which shows the selected view.
|
|
2750
|
+
up: function (args) {
|
|
2751
|
+
if (args[0] === 'voice' && args[2] === 'review') return ['voice', args[1]];
|
|
2752
|
+
return null;
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
})();
|