@quran-lab/tajweedformer 2.0.0 → 2.0.1
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 +27 -0
- package/dist/tajweedformer-opus.mjs +1 -1
- package/dist/tajweedformer-opus.wasm +0 -0
- package/docs/api.md +2 -2
- package/package.json +1 -1
- package/web/consent/copy.js +17 -9
- package/web/consent/decks.js +144 -56
- package/web/consent/element.js +21 -15
- package/web/consent/index.d.ts +76 -13
- package/web/consent/index.js +103 -18
- package/web/consent/state.js +91 -29
- package/web/consent/theme.js +31 -0
- package/web/consent/wording.js +126 -0
- package/web/index.d.ts +1 -1
- package/web/platform/client.js +484 -366
- package/web/platform/index.d.ts +39 -5
- package/web/platform/index.js +3 -2
- package/web/platform/telemetry.js +128 -13
- package/web/platform/update-notice.js +60 -0
- package/web/sharing/bitrate.js +69 -0
- package/web/sharing/encoder.js +26 -5
- package/web/sharing/index.d.ts +53 -6
- package/web/sharing/index.js +287 -257
- package/web/sharing/oggopus.js +325 -294
- package/web/sharing/outbox.js +670 -606
- package/web/sharing/uploader.js +10 -3
- package/web/tajweed/index.d.ts +2 -2
- package/web/tajweed/manifest.js +1 -1
package/web/platform/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Types for @quran-lab/tajweedformer/platform: the Quran Lab platform client.
|
|
2
2
|
|
|
3
|
-
export declare const SDK_VERSION: '2.0.
|
|
3
|
+
export declare const SDK_VERSION: '2.0.1';
|
|
4
4
|
export declare const API_URL: string;
|
|
5
5
|
export declare const CDN_URL: string;
|
|
6
6
|
|
|
@@ -23,6 +23,8 @@ export interface SignedConfig {
|
|
|
23
23
|
tajweed?: unknown;
|
|
24
24
|
/** where model files are served (qlab-api signs it): {base}/{model}/{revision}/{folder}/ */
|
|
25
25
|
base?: string;
|
|
26
|
+
/** the newest SDK for this platform (2.0.1); other unknown fields are ignored too */
|
|
27
|
+
sdk_latest?: { version: string; severity: string; url: string };
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface QuranLabOptions {
|
|
@@ -39,7 +41,8 @@ export interface QuranLabOptions {
|
|
|
39
41
|
/** telemetry: on by default, anonymous; 'off' is the one developer opt-out (never a prompt) */
|
|
40
42
|
analytics?: 'on' | 'off';
|
|
41
43
|
issues?: boolean;
|
|
42
|
-
/**
|
|
44
|
+
/** @deprecated ignored since 2.0.1: Quran Lab decides per app whether the sharing sheet is offered (signed
|
|
45
|
+
* config, flags.contributions); the SDK still asks the learner before anything is recorded for upload */
|
|
43
46
|
contributions?: boolean;
|
|
44
47
|
audience?: 'general' | 'children';
|
|
45
48
|
state?: StateStore;
|
|
@@ -52,7 +55,22 @@ export interface QuranLabOptions {
|
|
|
52
55
|
cdnUrl?: string;
|
|
53
56
|
fetch?: typeof fetch;
|
|
54
57
|
clock?: () => number;
|
|
58
|
+
/** a development build: logs "Quran Lab SDK X is available" once per launch when the signed config's sdk_latest is
|
|
59
|
+
* newer (default: process.env.NODE_ENV !== 'production' where it resolves; a plain browser page stays silent) */
|
|
60
|
+
debug?: boolean;
|
|
61
|
+
/** telemetry deltas on the SDK's schedule (default true): every 15 minutes while visible, when hidden or left */
|
|
62
|
+
autoSend?: boolean;
|
|
63
|
+
/** where visibilitychange and pagehide come from (default the page's document and window) */
|
|
64
|
+
lifecycle?: { document?: EventTarget & { visibilityState?: string }; window?: EventTarget | null };
|
|
55
65
|
}
|
|
66
|
+
export declare const DELTA_INTERVAL_MS: number;
|
|
67
|
+
/** -1, 0 or 1 for dotted numeric versions; null for a pre-release or junk */
|
|
68
|
+
export declare function compareVersions(a: string, b: string): -1 | 0 | 1 | null;
|
|
69
|
+
/** the signed config's sdk_latest when well formed, else null */
|
|
70
|
+
export declare function sdkLatestOf(cfg: unknown): { version: string; severity: string; url: string } | null;
|
|
71
|
+
export declare function isDevelopment(debug?: boolean): boolean;
|
|
72
|
+
export declare function noticeSdkLatest(cfg: unknown, current: string, o?: { debug?: boolean; warn?: (m: string) => void }): boolean;
|
|
73
|
+
export declare function resetSdkLatestNoticeForTests(): void;
|
|
56
74
|
|
|
57
75
|
export declare class QuranLab {
|
|
58
76
|
constructor(o: QuranLabOptions);
|
|
@@ -67,13 +85,21 @@ export declare class QuranLab {
|
|
|
67
85
|
readonly telemetry: Telemetry;
|
|
68
86
|
token(): Promise<string>;
|
|
69
87
|
launch(): Promise<void>;
|
|
70
|
-
|
|
88
|
+
/** at most once per UTC day per install; null when this install already registered today */
|
|
89
|
+
register(): Promise<{ ok: boolean; app_id: string; store_status: string } | null>;
|
|
90
|
+
/** the app came back to the foreground: a launch counted, register on a new day */
|
|
91
|
+
foreground(): void;
|
|
92
|
+
/** the app goes to the background: the delta sealed and sent (keepalive) */
|
|
93
|
+
background(): Promise<number>;
|
|
94
|
+
/** a send point: seal the delta and send the reports due */
|
|
95
|
+
sendTelemetry(reason?: string, o?: { keepalive?: boolean }): Promise<number>;
|
|
96
|
+
stopSchedule(): void;
|
|
71
97
|
refreshConfig(): Promise<SignedConfig | null>;
|
|
72
98
|
/** refresh every ms (60 s) while the app is in use */
|
|
73
99
|
startRefresh(ms?: number): void;
|
|
74
100
|
stopRefresh(): void;
|
|
75
101
|
modelSource(): Promise<{ url: string; headers: Record<string, string> }>;
|
|
76
|
-
flush(): Promise<number>;
|
|
102
|
+
flush(o?: { keepalive?: boolean }): Promise<number>;
|
|
77
103
|
reportIssue(kind: string, code: string, frames?: string[]): boolean;
|
|
78
104
|
contributor(): string;
|
|
79
105
|
resetContributor(): void;
|
|
@@ -88,10 +114,18 @@ export declare function keyId(rawPublicKey: string | Uint8Array): Promise<string
|
|
|
88
114
|
|
|
89
115
|
export declare class Telemetry {
|
|
90
116
|
enabled: boolean;
|
|
91
|
-
/**
|
|
117
|
+
/** +n on a 2.0.1 counter: launches, ayahs, words, share_asked, share_yes, share_no */
|
|
118
|
+
count(field: string, n?: number): void;
|
|
119
|
+
/** seal what was counted since the last send point (one report per UTC day, a fresh report_id each) */
|
|
120
|
+
seal(): number;
|
|
121
|
+
/** the sealed reports not yet accepted, oldest first, with the exact bytes sent */
|
|
122
|
+
reports(): { report_id: string; day: string; body: string; attempts: number; next: number }[];
|
|
123
|
+
/** the sealed reports as payloads (v2 with the 2.0.1 fields) */
|
|
92
124
|
pending(): object[];
|
|
93
125
|
clear(): void;
|
|
94
126
|
}
|
|
127
|
+
export declare const COUNTERS: readonly string[];
|
|
128
|
+
export declare function newReportId(): string;
|
|
95
129
|
export declare function bucket(kind: string, value: number): string;
|
|
96
130
|
export declare function utcDay(t?: number): string;
|
|
97
131
|
export declare function deviceInfo(): { class: string; soc?: string; os_major: number; ram_tier?: string };
|
package/web/platform/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @quran-lab/tajweedformer/platform: the Quran Lab platform client:
|
|
2
2
|
// key, token, register, the signed config, telemetry v2 (on by default, anonymous), issues.
|
|
3
3
|
// import { QuranLab } from '@quran-lab/tajweedformer/platform';
|
|
4
|
-
export { QuranLab, PlatformError, verifySignedConfig, keyId, SDK_VERSION, API_URL, CDN_URL } from './client.js';
|
|
5
|
-
export { Telemetry, bucket, utcDay, deviceInfo } from './telemetry.js';
|
|
4
|
+
export { QuranLab, PlatformError, verifySignedConfig, keyId, SDK_VERSION, API_URL, CDN_URL, DELTA_INTERVAL_MS } from './client.js';
|
|
5
|
+
export { Telemetry, bucket, utcDay, deviceInfo, COUNTERS, newReportId } from './telemetry.js';
|
|
6
6
|
export { memoryState, localState, dirState } from './storage.js';
|
|
7
|
+
export { compareVersions, sdkLatestOf, noticeSdkLatest, isDevelopment, resetSdkLatestNoticeForTests } from './update-notice.js';
|
|
7
8
|
export { contributorKey, forgetContributorKey, recoveryCodeFromBytes, normaliseRecoveryCode, recoveryHash, newRecoveryCode, deletionMessage, b64url } from './contributor-key.js';
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// Telemetry v2 (PLATFORM_CLIENT.md section 6): on by default, anonymous by construction. Only sums
|
|
2
2
|
// and histograms per UTC day: no audio, no text or phonemes, no identifiers, no time finer than the
|
|
3
|
-
// day.
|
|
3
|
+
// day. Since 2.0.1 it goes as deltas during the day: what was counted since the last send point is
|
|
4
|
+
// sealed into a report with a fresh random report_id (kept in storage before it is sent, so a retry sends
|
|
5
|
+
// the same bytes and the server counts it once), one report per UTC day it covers, at most 7 days kept.
|
|
6
|
+
// The send points (every 15 minutes while the app is in use, when it goes to the background, at launch)
|
|
7
|
+
// are the platform client's (client.js).
|
|
8
|
+
// Tried and rejected: sending the whole day again with a day-scoped id (the server would have to diff
|
|
9
|
+
// days); one report spanning midnight (a delta belongs to one day, as the server's points do).
|
|
4
10
|
|
|
5
11
|
import { CLASSES, CHECKS } from '../tajweed/settings.js';
|
|
6
12
|
|
|
@@ -22,13 +28,32 @@ export const utcDay = (t = Date.now()) => new Date(t).toISOString().slice(0, 10)
|
|
|
22
28
|
|
|
23
29
|
const FALLBACKS = ['non_finite', 'deadline', 'ane_unavailable', 'npu_unavailable', 'model_load', 'thermal', 'memory', 'other'];
|
|
24
30
|
const CAP = 10_000_000;
|
|
31
|
+
const DAY_MS = 86_400_000;
|
|
32
|
+
/** The 2.0.1 counters at the top level of a report (each left out when zero). */
|
|
33
|
+
export const COUNTERS = Object.freeze(['launches', 'ayahs', 'words', 'share_asked', 'share_yes', 'share_no']);
|
|
34
|
+
/** At most this many rule keys per report (the server's limit). */
|
|
35
|
+
export const MAX_RULE_KEYS = 32;
|
|
36
|
+
const RULE_KEY = /^[a-z][a-z0-9_]{0,23}$/;
|
|
37
|
+
/** A fresh report id: 16 random bytes, 32 lower-case hex. */
|
|
38
|
+
export function newReportId() {
|
|
39
|
+
const b = new Uint8Array(16);
|
|
40
|
+
globalThis.crypto.getRandomValues(b);
|
|
41
|
+
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
42
|
+
}
|
|
43
|
+
// a verdict or a check that ran: its status is not off and it is not disabled (absent settings: ran)
|
|
44
|
+
const ran = (x) => x === undefined || x === null || (x.status !== 'off' && x.enabled !== false);
|
|
25
45
|
|
|
26
46
|
/**
|
|
27
47
|
* The day's counters, persisted in `state` under "telemetry.days" ({ day: payload-in-progress }).
|
|
28
|
-
* `device` is { class, soc?, os_major, ram_tier? }, `product` '
|
|
48
|
+
* `device` is { class, soc?, os_major, ram_tier? }, `product` the platform's (productOf), `platform` the token app's.
|
|
29
49
|
*/
|
|
50
|
+
// The telemetry product of a platform, as the server checks it (PRODUCT_OF): a desktop app running this SDK in Node
|
|
51
|
+
// reports as the native product of its platform, a page as web.
|
|
52
|
+
export const PRODUCT_OF = Object.freeze({ web: 'web', windows: 'native', linux: 'native', macos: 'apple', ios: 'apple', android: 'android' });
|
|
53
|
+
export const productOf = (platform) => PRODUCT_OF[platform] || 'web';
|
|
54
|
+
|
|
30
55
|
export class Telemetry {
|
|
31
|
-
constructor({ state,
|
|
56
|
+
constructor({ state, platform = 'web', product = productOf(platform), sdkVersion = '2.0.1', appVersion = '0', device, clock = Date.now, enabled = true }) {
|
|
32
57
|
Object.assign(this, { state, product, platform, sdkVersion, appVersion, device, clock });
|
|
33
58
|
this.enabled = enabled;
|
|
34
59
|
this.coldStartDone = false;
|
|
@@ -120,9 +145,33 @@ export class Telemetry {
|
|
|
120
145
|
});
|
|
121
146
|
}
|
|
122
147
|
|
|
123
|
-
/** A scored ayah: per class sites and flags, the session's flag %, madd counts, words, coverage.
|
|
148
|
+
/** A scored ayah: per class sites and flags, the session's flag %, madd counts, words, coverage; and the 2.0.1
|
|
149
|
+
* counters: ayahs, surah_ayahs, words, rule_checks / rule_flags (a verdict counts only when its check ran). */
|
|
124
150
|
scored(tf, r) {
|
|
125
151
|
this._edit((d) => {
|
|
152
|
+
d.ayahs = Math.min(CAP, (d.ayahs || 0) + 1);
|
|
153
|
+
if (Number.isInteger(r.surah) && r.surah >= 1 && r.surah <= 114) {
|
|
154
|
+
d.surah_ayahs = d.surah_ayahs || {};
|
|
155
|
+
d.surah_ayahs[String(r.surah)] = (d.surah_ayahs[String(r.surah)] || 0) + 1;
|
|
156
|
+
}
|
|
157
|
+
const eff = r.settings || {};
|
|
158
|
+
const rule = (key, checked, flagged) => {
|
|
159
|
+
if (!checked || !RULE_KEY.test(key)) return;
|
|
160
|
+
d.rule_checks = d.rule_checks || {};
|
|
161
|
+
d.rule_flags = d.rule_flags || {};
|
|
162
|
+
if (!(key in d.rule_checks) && Object.keys(d.rule_checks).length >= MAX_RULE_KEYS) return;
|
|
163
|
+
d.rule_checks[key] = Math.min(CAP, (d.rule_checks[key] || 0) + 1);
|
|
164
|
+
if (flagged) d.rule_flags[key] = Math.min(d.rule_checks[key], (d.rule_flags[key] || 0) + 1);
|
|
165
|
+
};
|
|
166
|
+
for (const s of r.sites || []) rule(s.class, ran(s) && ran(eff.sites && eff.sites[s.class]), s.flagged);
|
|
167
|
+
for (const m of r.madd || []) rule('madd', ran(m) && ran(eff.madd), m.flagged);
|
|
168
|
+
for (const q of r.qalqalah || []) rule('qalqalah', ran(q) && ran(eff.qalqalah), q.flagged);
|
|
169
|
+
for (const g of r.gemination || []) rule('gemination', ran(g) && ran(eff.gemination), g.flagged);
|
|
170
|
+
for (const w of r.words || []) {
|
|
171
|
+
if (w.score === null || w.score === undefined) continue;
|
|
172
|
+
d.words = Math.min(CAP, (d.words || 0) + 1);
|
|
173
|
+
rule('word', true, w.flagged === true);
|
|
174
|
+
}
|
|
126
175
|
const c = this._cohort(d, Telemetry.cohortKey(tf));
|
|
127
176
|
for (const s of r.sites) {
|
|
128
177
|
const k = (c.classes[s.class] = c.classes[s.class] || { sites: 0, flagged: 0 });
|
|
@@ -154,16 +203,74 @@ export class Telemetry {
|
|
|
154
203
|
});
|
|
155
204
|
}
|
|
156
205
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
206
|
+
/** +n on a 2.0.1 counter (COUNTERS: launches, share_asked, share_yes, share_no, ...). */
|
|
207
|
+
count(field, n = 1) {
|
|
208
|
+
if (!COUNTERS.includes(field)) throw new RangeError(`telemetry counter ${field}`);
|
|
209
|
+
this._edit((d) => { d[field] = Math.min(CAP, (d[field] || 0) + n); });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A send point: what was counted since the last one is sealed, one report per UTC day it covers, each with a
|
|
214
|
+
* fresh report_id and its exact body, stored before anything is sent. Reports older than 7 days are deleted.
|
|
215
|
+
* Returns the number of reports sealed.
|
|
216
|
+
*/
|
|
217
|
+
seal() {
|
|
218
|
+
const days = this._days();
|
|
219
|
+
const sealed = this._sealed();
|
|
220
|
+
let n = 0;
|
|
221
|
+
for (const day of Object.keys(days).sort()) {
|
|
222
|
+
const body = Telemetry.wire(days[day]);
|
|
223
|
+
delete days[day];
|
|
224
|
+
if (!Telemetry.hasContent(body)) continue;
|
|
225
|
+
const reportId = newReportId();
|
|
226
|
+
sealed.push({ report_id: reportId, day, body: JSON.stringify({ ...body, report_id: reportId }), attempts: 0, next: 0 });
|
|
227
|
+
n++;
|
|
228
|
+
}
|
|
229
|
+
this._putSealed(this._fresh(sealed));
|
|
230
|
+
this._save(days);
|
|
231
|
+
return n;
|
|
161
232
|
}
|
|
162
233
|
|
|
163
|
-
|
|
234
|
+
_sealed() { return this.state.get('telemetry.sealed') || []; }
|
|
235
|
+
_putSealed(s) { this.state.set('telemetry.sealed', s.length ? s : null); }
|
|
236
|
+
_fresh(s) {
|
|
237
|
+
const oldest = utcDay(this.clock() - 7 * DAY_MS);
|
|
238
|
+
return s.filter((r) => r.day >= oldest).sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Sealed reports not yet accepted, oldest first: { report_id, day, body (the exact JSON sent), attempts, next }. */
|
|
242
|
+
reports() { return this._fresh(this._sealed()); }
|
|
243
|
+
/** The sealed reports as payloads (what is sent), oldest first. */
|
|
244
|
+
pending() { return this.reports().map((r) => JSON.parse(r.body)); }
|
|
245
|
+
/** A report was accepted (202) or refused for good (400, 413): deleted. */
|
|
246
|
+
dropReport(id) { this._putSealed(this._sealed().filter((r) => r.report_id !== id)); }
|
|
247
|
+
/** A report to retry later with the same bytes: 1 min, then 10 min, then the next launch. */
|
|
248
|
+
deferReport(id) {
|
|
249
|
+
const s = this._sealed();
|
|
250
|
+
const r = s.find((x) => x.report_id === id);
|
|
251
|
+
if (!r) return;
|
|
252
|
+
r.attempts += 1;
|
|
253
|
+
r.next = r.attempts === 1 ? this.clock() + 60_000 : r.attempts === 2 ? this.clock() + 600_000 : -1; // -1: the next launch
|
|
254
|
+
this._putSealed(s);
|
|
255
|
+
}
|
|
256
|
+
/** At launch: reports waiting for the next launch are due again. */
|
|
257
|
+
launched() {
|
|
258
|
+
const s = this._sealed();
|
|
259
|
+
for (const r of s) if (r.next === -1) r.next = 0;
|
|
260
|
+
this._putSealed(s);
|
|
261
|
+
}
|
|
262
|
+
/** Reports due now. */
|
|
263
|
+
due() { const now = this.clock(); return this.reports().filter((r) => r.next !== -1 && r.next <= now); }
|
|
264
|
+
|
|
265
|
+
/** Drop a day's open counts (kept for 2.0.0 callers). */
|
|
164
266
|
drop(day) { const d = this._days(); delete d[day]; this._save(d); }
|
|
165
|
-
/** Drop everything (flags.analytics false). */
|
|
166
|
-
clear() { this.state.set('telemetry.days', null); }
|
|
267
|
+
/** Drop everything, open counts and sealed reports (flags.analytics false). */
|
|
268
|
+
clear() { this.state.set('telemetry.days', null); this.state.set('telemetry.sealed', null); }
|
|
269
|
+
|
|
270
|
+
/** Whether a report body carries anything (a delta with no count is not sent). */
|
|
271
|
+
static hasContent(b) {
|
|
272
|
+
return b.cohorts.length > 0 || !!b.coverage || !!b.downloads || COUNTERS.some((k) => b[k] > 0) || !!b.surah_ayahs || !!b.rule_checks;
|
|
273
|
+
}
|
|
167
274
|
|
|
168
275
|
/** The closed v2 body: empty optional parts removed, cohorts beyond 16 merged into the largest. */
|
|
169
276
|
static wire(p) {
|
|
@@ -175,8 +282,16 @@ export class Telemetry {
|
|
|
175
282
|
}
|
|
176
283
|
const out = { v: 2, day: p.day, product: p.product, platform: p.platform, sdk_version: p.sdk_version, app_version: p.app_version,
|
|
177
284
|
device: p.device, cohorts };
|
|
178
|
-
if (Object.keys(p.coverage).length) out.coverage = p.coverage;
|
|
179
|
-
if (p.downloads.length) out.downloads = p.downloads;
|
|
285
|
+
if (Object.keys(p.coverage || {}).length) out.coverage = p.coverage;
|
|
286
|
+
if ((p.downloads || []).length) out.downloads = p.downloads;
|
|
287
|
+
// the 2.0.1 fields, each left out when zero or empty
|
|
288
|
+
for (const k of COUNTERS) if (p[k] > 0) out[k] = p[k];
|
|
289
|
+
if (p.surah_ayahs && Object.keys(p.surah_ayahs).length) out.surah_ayahs = p.surah_ayahs;
|
|
290
|
+
if (p.rule_checks && Object.keys(p.rule_checks).length) {
|
|
291
|
+
out.rule_checks = p.rule_checks;
|
|
292
|
+
const flags = Object.fromEntries(Object.entries(p.rule_flags || {}).filter(([k, v]) => v > 0 && k in p.rule_checks));
|
|
293
|
+
if (Object.keys(flags).length) out.rule_flags = flags;
|
|
294
|
+
}
|
|
180
295
|
return out;
|
|
181
296
|
}
|
|
182
297
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// "A newer SDK is available", for the developer only. The signed /v1/config may carry `sdk_latest: {version,
|
|
2
|
+
// severity, url}` for the app's platform. When this SDK is older, one console.warn per page load or process, and
|
|
3
|
+
// only outside production:
|
|
4
|
+
// Quran Lab SDK <version> is available (<severity>), you have <this version>: <url>
|
|
5
|
+
// Production (and anything that cannot tell) stays silent. A missing or malformed field is ignored, never thrown.
|
|
6
|
+
// Tried and rejected: warning per configured client (two clients or two configs in one launch would repeat it);
|
|
7
|
+
// semver pre-release ordering (a pre-release or junk version simply counts as not newer).
|
|
8
|
+
|
|
9
|
+
const NUMERIC = /^\d+(\.\d+)*$/;
|
|
10
|
+
let warned = false;
|
|
11
|
+
|
|
12
|
+
/** -1, 0 or 1 for two dotted numeric versions; null when either is not one (a pre-release, junk). */
|
|
13
|
+
export function compareVersions(a, b) {
|
|
14
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !NUMERIC.test(a) || !NUMERIC.test(b)) return null;
|
|
15
|
+
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
16
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
17
|
+
const d = (x[i] || 0) - (y[i] || 0);
|
|
18
|
+
if (d) return d < 0 ? -1 : 1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The field when it is well formed ({version: dotted numbers, severity: a string, url: https}), else null. */
|
|
24
|
+
export function sdkLatestOf(cfg) {
|
|
25
|
+
const s = cfg && typeof cfg === 'object' ? cfg.sdk_latest : null;
|
|
26
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return null;
|
|
27
|
+
if (typeof s.version !== 'string' || !NUMERIC.test(s.version)) return null;
|
|
28
|
+
if (typeof s.severity !== 'string' || !s.severity.length) return null;
|
|
29
|
+
if (typeof s.url !== 'string') return null;
|
|
30
|
+
try { if (new URL(s.url).protocol !== 'https:') return null; } catch (_) { return null; }
|
|
31
|
+
return { version: s.version, severity: s.severity, url: s.url };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Outside production: an explicit debug flag wins; else NODE_ENV where it resolves (Node, or a bundler that
|
|
35
|
+
* substitutes process.env.NODE_ENV); else (a plain browser page) production. */
|
|
36
|
+
export function isDevelopment(debug) {
|
|
37
|
+
if (debug === true || debug === false) return debug;
|
|
38
|
+
try {
|
|
39
|
+
// eslint-disable-next-line no-undef
|
|
40
|
+
const env = process.env.NODE_ENV;
|
|
41
|
+
if (typeof env === 'string') return env !== 'production';
|
|
42
|
+
return typeof process !== 'undefined' && !!process.versions && !!process.versions.node;
|
|
43
|
+
} catch (_) { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Warns once per launch when cfg's sdk_latest is newer than `current`; returns whether it warned now. */
|
|
47
|
+
export function noticeSdkLatest(cfg, current, { debug, warn = (m) => console.warn(m) } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
if (warned) return false;
|
|
50
|
+
const l = sdkLatestOf(cfg);
|
|
51
|
+
if (!l || compareVersions(current, l.version) !== -1) return false;
|
|
52
|
+
if (!isDevelopment(debug)) return false;
|
|
53
|
+
warned = true;
|
|
54
|
+
warn(`Quran Lab SDK ${l.version} is available (${l.severity}), you have ${current}: ${l.url}`);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (_) { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A new launch (tests). */
|
|
60
|
+
export function resetSdkLatestNoticeForTests() { warned = false; }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The adaptive Opus bitrate of shared sessions (SDK 2.0.1, the same ladder in every SDK; the shared vectors'
|
|
2
|
+
// bitrate_ladder.json is replayed by test/sharing_bitrate.test.mjs). One ladder per sharing pipeline (per page or
|
|
3
|
+
// process), in memory, starting at the top rung:
|
|
4
|
+
// uploadFailed() a network failure of an upload request (no connection, timeout, HTTP 408 or 5xx on
|
|
5
|
+
// POST /v1/uploads, POST /v1/uploads/{id}/urls or a segment PUT): one rung down
|
|
6
|
+
// uploadOk(backlogS) a segment PUT answered 2xx, backlogS seconds of closed audio still waiting after it:
|
|
7
|
+
// three in a row with backlogS <= 30 step one rung up
|
|
8
|
+
// segmentClosed(backlogS) a capture closed a segment, backlogS seconds of closed audio waiting (this segment
|
|
9
|
+
// included, every session of the outbox): at 120 or more, one rung down
|
|
10
|
+
// The encoder takes the ladder's bitrate when a segment opens (oggopus.js OggOpusWriter `bitrate`), so a
|
|
11
|
+
// segment has one bitrate, kept in its marker.
|
|
12
|
+
// Tried and rejected: changing the bitrate mid-segment (a segment would have no single bitrate to record);
|
|
13
|
+
// keeping the rung across page loads (a stale bad network would cost quality on a good one); a floor below
|
|
14
|
+
// 32 kbit/s (the log-mel features the model reads drift 1.60 dB at 24k and 2.48 at 16k against 1.17 at 32k,
|
|
15
|
+
// for about 0.07 MB a minute saved per step).
|
|
16
|
+
|
|
17
|
+
/** The rungs, bit/s, top first. */
|
|
18
|
+
export const BITRATE_RUNGS = Object.freeze([192000, 96000, 64000, 32000]);
|
|
19
|
+
/** Successes in a row that step one rung up. */
|
|
20
|
+
export const UP_STREAK = 3;
|
|
21
|
+
/** The most closed audio (s) still waiting that allows a step up. */
|
|
22
|
+
export const UP_MAX_BACKLOG_S = 30;
|
|
23
|
+
/** Closed audio (s) waiting when a segment closes that steps one rung down. */
|
|
24
|
+
export const DOWN_BACKLOG_S = 120;
|
|
25
|
+
|
|
26
|
+
export class BitrateLadder {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.rung = 0;
|
|
29
|
+
this.streak = 0;
|
|
30
|
+
this.listeners = new Set();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bit rate the next segment is encoded at. */
|
|
34
|
+
get bitrate() { return BITRATE_RUNGS[this.rung]; }
|
|
35
|
+
|
|
36
|
+
/** Called with the new bitrate whenever it changes; returns the unsubscribe function. */
|
|
37
|
+
onChange(f) { this.listeners.add(f); return () => this.listeners.delete(f); }
|
|
38
|
+
|
|
39
|
+
_set(rung) {
|
|
40
|
+
if (rung === this.rung) return;
|
|
41
|
+
this.rung = rung;
|
|
42
|
+
for (const f of this.listeners) { try { f(this.bitrate); } catch (_) { /* a listener's own problem */ } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
uploadFailed() {
|
|
46
|
+
this.streak = 0;
|
|
47
|
+
this._set(Math.min(this.rung + 1, BITRATE_RUNGS.length - 1));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
uploadOk(backlogS) {
|
|
51
|
+
this.streak = Math.min(this.streak + 1, UP_STREAK);
|
|
52
|
+
if (this.streak >= UP_STREAK && backlogS <= UP_MAX_BACKLOG_S && this.rung > 0) {
|
|
53
|
+
this.streak = 0;
|
|
54
|
+
this._set(this.rung - 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
segmentClosed(backlogS) {
|
|
59
|
+
if (backlogS >= DOWN_BACKLOG_S) {
|
|
60
|
+
this.streak = 0;
|
|
61
|
+
this._set(Math.min(this.rung + 1, BITRATE_RUNGS.length - 1));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether an upload request's answer is a network failure for the ladder: no answer (status 0: no
|
|
67
|
+
* connection, timeout, transport error), HTTP 408 or 5xx. Refusals (4xx, 429 volume limits, 403 on an
|
|
68
|
+
* expired URL) are not. */
|
|
69
|
+
export const isNetworkFailure = (status) => status === 0 || status === 408 || (status >= 500 && status <= 599);
|
package/web/sharing/encoder.js
CHANGED
|
@@ -11,6 +11,7 @@ export const OPUS_REQUEST = Object.freeze({
|
|
|
11
11
|
GET_APPLICATION: 4001, GET_BITRATE: 4003, GET_VBR: 4007, GET_BANDWIDTH: 4009, GET_COMPLEXITY: 4011,
|
|
12
12
|
GET_INBAND_FEC: 4013, GET_DTX: 4017, GET_VBR_CONSTRAINT: 4021, GET_SIGNAL: 4025, GET_LOOKAHEAD: 4027,
|
|
13
13
|
GET_SAMPLE_RATE: 4029, GET_LSB_DEPTH: 4037,
|
|
14
|
+
SET_BITRATE: 4002,
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
let modulePromise = null;
|
|
@@ -61,6 +62,21 @@ export class OpusEncoder {
|
|
|
61
62
|
this.samplesIn = 0;
|
|
62
63
|
this.nonFiniteSamples = 0;
|
|
63
64
|
this.finished = false;
|
|
65
|
+
this.bitrate = settings.bitrate;
|
|
66
|
+
// packets coded so far; beforeFrame(index), when set, runs before each packet is coded (the Ogg writer
|
|
67
|
+
// opens a segment there and sets its bitrate, so a change lands exactly on the segment's first packet)
|
|
68
|
+
this.packetCount = 0;
|
|
69
|
+
this.beforeFrame = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The bit rate of the packets coded from now on (OPUS_SET_BITRATE; any packet may change it, the
|
|
73
|
+
* stream needs no new header). */
|
|
74
|
+
setBitrate(bps) {
|
|
75
|
+
if (!(Number.isInteger(bps) && bps >= 6000 && bps <= 510000)) throw new RangeError(`Opus bitrate 6000 to 510000 bit/s, not ${bps}`);
|
|
76
|
+
if (bps === this.bitrate) return;
|
|
77
|
+
const e = this.m._ow_enc_set(this.enc, OPUS_REQUEST.SET_BITRATE, bps);
|
|
78
|
+
if (e !== 0) throw new Error(`the Opus encoder refused bitrate ${bps} (${e})`);
|
|
79
|
+
this.bitrate = bps;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
/** The granule position of the last page: pre-skip plus the input in 48 kHz samples. */
|
|
@@ -69,10 +85,12 @@ export class OpusEncoder {
|
|
|
69
85
|
/** A GET request read back from libopus (OPUS_REQUEST.GET_*), for the tests. */
|
|
70
86
|
get(request) { const v = this.m._ow_enc_get(this.enc, request); return v === -2147483648 ? null : v; }
|
|
71
87
|
|
|
72
|
-
/** Encodes samples; returns the packets completed so far (possibly none)
|
|
73
|
-
|
|
88
|
+
/** Encodes samples; returns the packets completed so far (possibly none), or hands each to `onPacket`
|
|
89
|
+
* as soon as it is coded (then the result is empty). */
|
|
90
|
+
encode(pcm, onPacket = null) {
|
|
74
91
|
if (this.finished) throw new Error('the Opus encoder was flushed');
|
|
75
92
|
const out = [];
|
|
93
|
+
const emit = onPacket || ((p) => out.push(p));
|
|
76
94
|
let i = 0;
|
|
77
95
|
while (i < pcm.length) {
|
|
78
96
|
const n = Math.min(this.perFrame - this.filled, pcm.length - i);
|
|
@@ -85,7 +103,7 @@ export class OpusEncoder {
|
|
|
85
103
|
this.nonFiniteSamples += bad;
|
|
86
104
|
this.filled += n;
|
|
87
105
|
i += n;
|
|
88
|
-
if (this.filled === this.perFrame)
|
|
106
|
+
if (this.filled === this.perFrame) emit(this._frame());
|
|
89
107
|
}
|
|
90
108
|
this.samplesIn += Math.trunc(pcm.length / this.channels);
|
|
91
109
|
return out;
|
|
@@ -93,21 +111,24 @@ export class OpusEncoder {
|
|
|
93
111
|
|
|
94
112
|
/** Encodes what is buffered and the lookahead, padded with silence to whole packets. The encoder
|
|
95
113
|
* cannot be used afterwards. */
|
|
96
|
-
flush() {
|
|
114
|
+
flush(onPacket = null) {
|
|
97
115
|
if (this.finished) throw new Error('the Opus encoder was flushed');
|
|
98
116
|
this.finished = true;
|
|
99
117
|
const out = [];
|
|
118
|
+
const emit = onPacket || ((p) => out.push(p));
|
|
100
119
|
let owed = Math.trunc(this.filled / this.channels) + Math.trunc(this.preSkip * this.sampleRate / 48000);
|
|
101
120
|
while (owed > 0) {
|
|
102
121
|
this.m.HEAPF32.fill(0, (this.framePtr >> 2) + this.filled, (this.framePtr >> 2) + this.perFrame);
|
|
103
122
|
this.filled = this.perFrame;
|
|
104
|
-
|
|
123
|
+
emit(this._frame());
|
|
105
124
|
owed -= this.frameSize;
|
|
106
125
|
}
|
|
107
126
|
return out;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
_frame() {
|
|
130
|
+
if (this.beforeFrame) this.beforeFrame(this.packetCount);
|
|
131
|
+
this.packetCount += 1;
|
|
111
132
|
const n = this.m._ow_enc_encode(this.enc, this.framePtr, this.frameSize, this.outPtr, 4000);
|
|
112
133
|
this.filled = 0;
|
|
113
134
|
if (n <= 0) throw new Error(`the Opus encoder failed (${n})`);
|
package/web/sharing/index.d.ts
CHANGED
|
@@ -23,8 +23,14 @@ export function wholePages(bytes: Uint8Array): { keep: number; granule: number }
|
|
|
23
23
|
export interface OpusEncoding {
|
|
24
24
|
readonly preSkip: number;
|
|
25
25
|
readonly finalGranulePosition: number;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** packets coded so far; or each handed to onPacket as it is coded */
|
|
27
|
+
encode(pcm: Float32Array, onPacket?: (p: Uint8Array) => void): Uint8Array[];
|
|
28
|
+
flush(onPacket?: (p: Uint8Array) => void): Uint8Array[];
|
|
29
|
+
/** the bit rate of the packets coded from now on (encoders that support it) */
|
|
30
|
+
setBitrate?(bps: number): void;
|
|
31
|
+
readonly bitrate?: number;
|
|
32
|
+
/** called before each packet is coded, with its index (encoders that support it) */
|
|
33
|
+
beforeFrame?: ((index: number) => void) | null;
|
|
28
34
|
}
|
|
29
35
|
export interface Resampling {
|
|
30
36
|
process(x: Float32Array, sink: (y: Float32Array) => void): void;
|
|
@@ -35,8 +41,13 @@ export class OggOpusWriter {
|
|
|
35
41
|
encoder: OpusEncoding; resampler?: Resampling | null; inputRate: number; plan?: readonly number[];
|
|
36
42
|
settings?: OpusSettings; serial?: number; vendor?: string;
|
|
37
43
|
onPage: (segment: number, bytes: Uint8Array) => void;
|
|
38
|
-
onSegment?: (s: { n: number; granule: number; last: boolean }) => void;
|
|
44
|
+
onSegment?: (s: { n: number; granule: number; last: boolean; bitrate: number | null }) => void;
|
|
45
|
+
/** the bit rate of segment n, asked as it opens (its first packet); null keeps the current one */
|
|
46
|
+
bitrate?: ((n: number) => number | null) | null;
|
|
47
|
+
onSegmentOpen?: (s: { n: number; bitrate: number | null }) => void;
|
|
39
48
|
});
|
|
49
|
+
/** segment number -> the bit rate its packets were coded at */
|
|
50
|
+
readonly segmentBitrates: Map<number, number | null>;
|
|
40
51
|
readonly preSkip: number;
|
|
41
52
|
readonly duration: number;
|
|
42
53
|
readonly inputFrames: number;
|
|
@@ -56,8 +67,12 @@ export class OpusEncoder implements OpusEncoding {
|
|
|
56
67
|
readonly preSkip: number;
|
|
57
68
|
readonly finalGranulePosition: number;
|
|
58
69
|
get(request: number): number | null;
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
readonly bitrate: number;
|
|
71
|
+
readonly packetCount: number;
|
|
72
|
+
beforeFrame: ((index: number) => void) | null;
|
|
73
|
+
setBitrate(bps: number): void;
|
|
74
|
+
encode(pcm: Float32Array, onPacket?: (p: Uint8Array) => void): Uint8Array[];
|
|
75
|
+
flush(onPacket?: (p: Uint8Array) => void): Uint8Array[];
|
|
61
76
|
free(): void;
|
|
62
77
|
}
|
|
63
78
|
export function decodeOggOpus(m: OpusModule, pages: OggPageInfo[]): Float32Array;
|
|
@@ -75,7 +90,7 @@ export function b64url(bytes: Uint8Array): string;
|
|
|
75
90
|
export function deletionMessage(f: { app: string; contributor: string; nonce: string; timestamp: number }): Uint8Array;
|
|
76
91
|
export function sessionJson(o: {
|
|
77
92
|
modelRevision?: string | null; backend?: 'neuralEngine' | 'exact' | 'mock'; weightsSha256?: string | null; sdkVersion?: string;
|
|
78
|
-
consentVersion?: string | null; contributor: string; day: string; deviceClass: string; osMajor: number;
|
|
93
|
+
consentVersion?: string | null; consentTextSha256?: string | null; contributor: string; day: string; deviceClass: string; osMajor: number;
|
|
79
94
|
inputSampleRate?: number; inputRoute?: InputRoute; contributorKey?: string | null; recoveryHash?: string | null;
|
|
80
95
|
}): string;
|
|
81
96
|
export interface SegmentUrl { n: number; url: string; expiresAt: number }
|
|
@@ -123,6 +138,10 @@ export function dirStorage(root: string): Promise<SharingStorage>;
|
|
|
123
138
|
export function indexedDbStorage(dbName?: string, idb?: IDBFactory): Promise<SharingStorage>;
|
|
124
139
|
export function defaultStorage(): Promise<SharingStorage>;
|
|
125
140
|
export function recover(storage: SharingStorage, id: string): Promise<boolean>;
|
|
141
|
+
/** A closed segment's record (seg-NNNNN.json); bitrate is absent for segments recorded before 2.0.1. */
|
|
142
|
+
export interface SegmentMarker { n: number; bytes: number; sha256: string; granule: number; bitrate?: number }
|
|
143
|
+
export function markers(storage: SharingStorage, id: string): Promise<SegmentMarker[]>;
|
|
144
|
+
export function markerSeconds(markers: SegmentMarker[]): number[];
|
|
126
145
|
export function sha256Hex(bytes: Uint8Array): Promise<string>;
|
|
127
146
|
export const MAX_AGE_MS: number;
|
|
128
147
|
export const FILES: Readonly<{ session: string; capture: string; end: string; item: string }>;
|
|
@@ -130,6 +149,8 @@ export const activeCaptures: Set<string>;
|
|
|
130
149
|
|
|
131
150
|
export interface ContributionItem {
|
|
132
151
|
id: string; segments: number; bytes: number; durationMs: number; day: string; completed: boolean | null;
|
|
152
|
+
/** the bit rate each segment was coded at, in segment order (null for a segment recorded before 2.0.1) */
|
|
153
|
+
segmentBitrates: (number | null)[];
|
|
133
154
|
status: 'queued' | 'uploaded' | 'failed'; capped: boolean; failCode: string | null; failMessage: string | null;
|
|
134
155
|
attempts: number; lastError: string | null;
|
|
135
156
|
}
|
|
@@ -149,14 +170,36 @@ export class Outbox {
|
|
|
149
170
|
cancel(id: string): Promise<void>;
|
|
150
171
|
drain(o?: { ignoringBackoff?: boolean }): Promise<void>;
|
|
151
172
|
uploadAllowed(): { ok: boolean; why?: string; tier?: string };
|
|
173
|
+
/** seconds of audio in closed segments not yet uploaded, every session */
|
|
174
|
+
backlogSeconds(): number;
|
|
152
175
|
}
|
|
153
176
|
|
|
177
|
+
/** The adaptive Opus bitrate (SDK 2.0.1): 192, 96, 64, 32 kbit/s; one per pipeline, from the top. */
|
|
178
|
+
export const BITRATE_RUNGS: readonly number[];
|
|
179
|
+
export const UP_STREAK: 3;
|
|
180
|
+
export const UP_MAX_BACKLOG_S: 30;
|
|
181
|
+
export const DOWN_BACKLOG_S: 120;
|
|
182
|
+
export class BitrateLadder {
|
|
183
|
+
constructor();
|
|
184
|
+
/** the bit rate the next segment opens at */
|
|
185
|
+
readonly bitrate: number;
|
|
186
|
+
readonly rung: number;
|
|
187
|
+
readonly streak: number;
|
|
188
|
+
uploadFailed(): void;
|
|
189
|
+
uploadOk(backlogS: number): void;
|
|
190
|
+
segmentClosed(backlogS: number): void;
|
|
191
|
+
onChange(f: (bitrate: number) => void): () => void;
|
|
192
|
+
}
|
|
193
|
+
export function isNetworkFailure(status: number): boolean;
|
|
194
|
+
|
|
154
195
|
export interface SharingOptions {
|
|
155
196
|
api: string;
|
|
156
197
|
tokenProvider: TokenProvider;
|
|
157
198
|
flags?: SharingFlags | (() => SharingFlags | null) | null;
|
|
158
199
|
enabled?: () => boolean;
|
|
159
200
|
consentVersion?: string | null;
|
|
201
|
+
/** with an app's own wording: the hash of the text the learner agreed to (sent as consent_text_sha256) */
|
|
202
|
+
consentTextSha256?: string | null;
|
|
160
203
|
contributor?: string;
|
|
161
204
|
contributorKeyRaw?: Uint8Array;
|
|
162
205
|
recoveryHash?: string;
|
|
@@ -170,6 +213,8 @@ export interface SharingOptions {
|
|
|
170
213
|
onError?: (e: SharingError) => void;
|
|
171
214
|
opus?: () => Promise<OpusModule>;
|
|
172
215
|
clock?: () => number;
|
|
216
|
+
/** a fixed Opus bit/s in place of the adaptive ladder (tests and special cases) */
|
|
217
|
+
bitrate?: number;
|
|
173
218
|
}
|
|
174
219
|
export interface DecodeInfo { modelRevision?: string | null; backend?: 'neuralEngine' | 'exact' | 'mock'; weightsSha256?: string | null; sdkVersion?: string }
|
|
175
220
|
export interface SharedSession {
|
|
@@ -186,6 +231,8 @@ export interface Sharing {
|
|
|
186
231
|
storage: SharingStorage;
|
|
187
232
|
outbox: Outbox;
|
|
188
233
|
client: ContributionClient;
|
|
234
|
+
/** the adaptive bitrate of this pipeline */
|
|
235
|
+
ladder: BitrateLadder;
|
|
189
236
|
update(next: Partial<SharingOptions>): void;
|
|
190
237
|
contributorId(): Promise<string>;
|
|
191
238
|
resetContributor(): Promise<string>;
|