@quran-lab/tajweedformer 0.0.0-stage → 2.0.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +148 -0
  3. package/README.md +121 -2
  4. package/dist/tajweedformer-opus.mjs +2 -0
  5. package/dist/tajweedformer-opus.wasm +0 -0
  6. package/dist/tajweedformer-scorer.mjs +2 -0
  7. package/dist/tajweedformer-scorer.wasm +0 -0
  8. package/dist/tajweedformer.mjs +2 -0
  9. package/dist/tajweedformer.wasm +0 -0
  10. package/docs/api.md +167 -0
  11. package/examples/browser.html +69 -0
  12. package/examples/node.mjs +37 -0
  13. package/package.json +81 -6
  14. package/web/consent/copy.js +43 -0
  15. package/web/consent/decks.js +382 -0
  16. package/web/consent/element.js +164 -0
  17. package/web/consent/index.d.ts +65 -0
  18. package/web/consent/index.js +154 -0
  19. package/web/consent/state.js +100 -0
  20. package/web/index.d.ts +4 -0
  21. package/web/index.js +10 -0
  22. package/web/platform/client.js +366 -0
  23. package/web/platform/contributor-key.js +112 -0
  24. package/web/platform/index.d.ts +114 -0
  25. package/web/platform/index.js +7 -0
  26. package/web/platform/storage.js +50 -0
  27. package/web/platform/telemetry.js +199 -0
  28. package/web/scorer.d.ts +4 -0
  29. package/web/scorer.js +10 -0
  30. package/web/sharing/encoder.js +155 -0
  31. package/web/sharing/index.d.ts +215 -0
  32. package/web/sharing/index.js +257 -0
  33. package/web/sharing/oggopus.js +294 -0
  34. package/web/sharing/outbox.js +606 -0
  35. package/web/sharing/resampler.js +122 -0
  36. package/web/sharing/uploader.js +171 -0
  37. package/web/tajweed/decode.js +213 -0
  38. package/web/tajweed/delivery.js +317 -0
  39. package/web/tajweed/engine.js +600 -0
  40. package/web/tajweed/heads.js +73 -0
  41. package/web/tajweed/index.d.ts +737 -0
  42. package/web/tajweed/index.js +12 -0
  43. package/web/tajweed/manifest.js +452 -0
  44. package/web/tajweed/node.d.ts +3 -0
  45. package/web/tajweed/node.js +59 -0
  46. package/web/tajweed/settings.js +483 -0
  47. package/web/tajweed/tajweedformer.js +369 -0
@@ -0,0 +1,199 @@
1
+ // Telemetry v2 (PLATFORM_CLIENT.md section 6): on by default, anonymous by construction. Only sums
2
+ // and histograms per UTC day: no audio, no text or phonemes, no identifiers, no time finer than the
3
+ // day. One payload per completed day, at most 7 days kept, sent at the next launch.
4
+
5
+ import { CLASSES, CHECKS } from '../tajweed/settings.js';
6
+
7
+ const B = {
8
+ latency_ms: [['le_5', 5], ['le_10', 10], ['le_20', 20], ['le_40', 40], ['le_80', 80], ['le_160', 160], ['le_320', 320], ['gt_320', Infinity]],
9
+ rtf: [['le_0_1', 0.1], ['le_0_25', 0.25], ['le_0_5', 0.5], ['le_1', 1], ['gt_1', Infinity]],
10
+ ms: [['le_250', 250], ['le_500', 500], ['le_1000', 1000], ['le_2000', 2000], ['le_4000', 4000], ['gt_4000', Infinity]],
11
+ pct: [['le_1', 1], ['le_2', 2], ['le_5', 5], ['le_10', 10], ['le_20', 20], ['gt_20', Infinity]],
12
+ margin: [['le_0_5', 0.5], ['le_1', 1], ['le_2', 2], ['le_4', 4], ['le_8', 8], ['gt_8', Infinity]],
13
+ confidence: [['le_0_5', 0.5], ['le_0_7', 0.7], ['le_0_8', 0.8], ['le_0_9', 0.9], ['le_0_95', 0.95], ['gt_0_95', Infinity]],
14
+ word: [['le_3', 3], ['le_4', 4], ['le_5', 5], ['le_6', 6], ['le_7', 7], ['gt_7', Infinity]],
15
+ };
16
+ /** The bucket a value falls in ("le_x" is <= x). */
17
+ export function bucket(kind, v) { for (const [k, hi] of B[kind]) if (v <= hi) return k; return B[kind][B[kind].length - 1][0]; }
18
+ const bump = (h, kind, v, n = 1) => { const k = bucket(kind, v); h[k] = (h[k] || 0) + n; };
19
+
20
+ /** UTC day "YYYY-MM-DD". */
21
+ export const utcDay = (t = Date.now()) => new Date(t).toISOString().slice(0, 10);
22
+
23
+ const FALLBACKS = ['non_finite', 'deadline', 'ane_unavailable', 'npu_unavailable', 'model_load', 'thermal', 'memory', 'other'];
24
+ const CAP = 10_000_000;
25
+
26
+ /**
27
+ * The day's counters, persisted in `state` under "telemetry.days" ({ day: payload-in-progress }).
28
+ * `device` is { class, soc?, os_major, ram_tier? }, `product` 'web', `platform` the token app's.
29
+ */
30
+ export class Telemetry {
31
+ constructor({ state, product = 'web', platform = 'web', sdkVersion = '2.0.0', appVersion = '0', device, clock = Date.now, enabled = true }) {
32
+ Object.assign(this, { state, product, platform, sdkVersion, appVersion, device, clock });
33
+ this.enabled = enabled;
34
+ this.coldStartDone = false;
35
+ }
36
+
37
+ _days() { return this.state.get('telemetry.days') || {}; }
38
+ _save(d) { this.state.set('telemetry.days', d); }
39
+ _today() {
40
+ const days = this._days();
41
+ const day = utcDay(this.clock());
42
+ if (!days[day]) {
43
+ days[day] = { v: 2, day, product: this.product, platform: this.platform, sdk_version: this.sdkVersion, app_version: this.appVersion,
44
+ device: this.device, cohorts: [], coverage: {}, downloads: [] };
45
+ }
46
+ return { days, d: days[day] };
47
+ }
48
+ _edit(fn) {
49
+ if (!this.enabled) return;
50
+ const { days, d } = this._today();
51
+ fn(d);
52
+ // keep at most 7 days
53
+ const keys = Object.keys(days).sort();
54
+ while (keys.length > 7) delete days[keys.shift()];
55
+ this._save(days);
56
+ }
57
+
58
+ /** The cohort of a model's settings: revision, context, backend, preset (custom when any raw site
59
+ * threshold is set), word level. */
60
+ static cohortKey(tf) {
61
+ const e = tf.settings.effective();
62
+ const app = tf.settings.app || {};
63
+ const ov = { ...((tf.settings.remote && tf.settings.remote.overrides) || {}), ...(app.overrides || {}) };
64
+ const custom = Object.values(ov).some((o) => o && Object.prototype.hasOwnProperty.call(o, 'threshold'));
65
+ const wl = tf.settings.wordLevel ? tf.settings.wordLevel() : null;
66
+ let word = 'off', wordBucket = '';
67
+ if (wl && wl.enabled) {
68
+ if (wl.preset) word = wl.preset; else { word = 'custom'; wordBucket = bucket('word', wl.threshold); }
69
+ }
70
+ return {
71
+ revision: String((tf.folder && tf.folder.manifest && (tf.folder.manifest.model_id + '+' + tf.folder.manifest.revision)) || 'unknown').slice(0, 64),
72
+ context: tf.context, backend: 'cpu', preset: custom ? 'custom' : e.preset, word_level: word, word_bucket: wordBucket,
73
+ site_overrides: Math.min(8, Object.keys(ov).filter((k) => CLASSES.includes(k)).length),
74
+ };
75
+ }
76
+
77
+ _cohort(d, key) {
78
+ let c = d.cohorts.find((x) => ['revision', 'context', 'backend', 'preset', 'word_level', 'word_bucket'].every((k) => x[k] === key[k]));
79
+ if (!c) {
80
+ c = { ...key, sessions: 0, minutes: 0, chunks: 0, aborted: 0, fallbacks: {}, self_test: { pass: 0, fail: 0 }, latency_ms: {}, rtf: {},
81
+ cold_start_ms: {}, model_load_ms: {}, thermal: { transitions: 0, throttled_sessions: 0 }, checks_on: {}, classes: {},
82
+ session_flag_pct: {}, margin: {}, confidence: {}, madd: { c2: 0, c4: 0, c6: 0, flagged: 0 }, words: { scored: 0, flagged: 0 } };
83
+ d.cohorts.push(c);
84
+ }
85
+ c.site_overrides = key.site_overrides;
86
+ return c;
87
+ }
88
+
89
+ modelLoaded(tf, ms) { this._edit((d) => bump(this._cohort(d, Telemetry.cohortKey(tf)).model_load_ms, 'ms', ms)); }
90
+
91
+ /** A take ended: audio seconds, chunk timings (ms each), whether it was the launch's first. */
92
+ session(tf, { seconds, chunkMs = [], aborted = false, firstMs = null }) {
93
+ const chunk = tf.info.chunkMs || 320;
94
+ this._edit((d) => {
95
+ const c = this._cohort(d, Telemetry.cohortKey(tf));
96
+ if (aborted) c.aborted++; else c.sessions++;
97
+ c.minutes = Math.round((c.minutes + seconds / 60) * 1000) / 1000;
98
+ c.chunks = Math.min(CAP, c.chunks + chunkMs.length);
99
+ for (const ms of chunkMs) { bump(c.latency_ms, 'latency_ms', ms); bump(c.rtf, 'rtf', ms / chunk); }
100
+ if (!this.coldStartDone && firstMs !== null) { bump(c.cold_start_ms, 'ms', firstMs); this.coldStartDone = true; }
101
+ const eff = tf.settings.effective();
102
+ const on = { letters: false, vowels: false, length: false, madd: eff.madd.enabled, gemination: eff.gemination.enabled,
103
+ qalqalah: eff.qalqalah.enabled, lazim: eff.lazim.enabled };
104
+ for (const [cls, s] of Object.entries(eff.sites)) {
105
+ const k = { vowel_sub: 'vowels', vowel_del: 'vowels', vowel_ins: 'vowels', short_to_long: 'length', long_to_short: 'length' }[cls] || 'letters';
106
+ if (s.enabled) on[k] = true;
107
+ }
108
+ for (const k of CHECKS) if (on[k]) c.checks_on[k] = (c.checks_on[k] || 0) + 1;
109
+ });
110
+ }
111
+
112
+ /** The decoded layer of a take: margin and confidence per emitted token. */
113
+ decoded(tf, decoded) {
114
+ this._edit((d) => {
115
+ const c = this._cohort(d, Telemetry.cohortKey(tf));
116
+ for (const t of decoded.tokens) {
117
+ bump(c.margin, 'margin', t.margin);
118
+ if (t.confidence !== null) bump(c.confidence, 'confidence', t.confidence);
119
+ }
120
+ });
121
+ }
122
+
123
+ /** A scored ayah: per class sites and flags, the session's flag %, madd counts, words, coverage. */
124
+ scored(tf, r) {
125
+ this._edit((d) => {
126
+ const c = this._cohort(d, Telemetry.cohortKey(tf));
127
+ for (const s of r.sites) {
128
+ const k = (c.classes[s.class] = c.classes[s.class] || { sites: 0, flagged: 0 });
129
+ k.sites++;
130
+ if (s.flagged) k.flagged++;
131
+ }
132
+ const n = r.summary.sites || 0;
133
+ bump(c.session_flag_pct, 'pct', n ? (100 * r.summary.flagged) / n : 0);
134
+ for (const m of r.madd) {
135
+ if (m.count === 2) c.madd.c2++; else if (m.count === 4) c.madd.c4++; else if (m.count === 6) c.madd.c6++;
136
+ if (m.flagged) c.madd.flagged++;
137
+ }
138
+ c.words.scored += r.words.filter((w) => w.score !== null).length;
139
+ c.words.flagged += r.words.filter((w) => w.flagged).length;
140
+ d.coverage[String(r.surah)] = (d.coverage[String(r.surah)] || 0) + 1;
141
+ });
142
+ }
143
+
144
+ fallback(tf, reason) {
145
+ const k = FALLBACKS.includes(reason) ? reason : 'other';
146
+ this._edit((d) => { const c = this._cohort(d, Telemetry.cohortKey(tf)); c.fallbacks[k] = (c.fallbacks[k] || 0) + 1; });
147
+ }
148
+
149
+ download({ revision, profile, bytes, success, resumed }) {
150
+ this._edit((d) => {
151
+ let x = d.downloads.find((y) => y.revision === revision && y.profile === profile);
152
+ if (!x) { x = { revision, profile, bytes: 0, success: 0, failed: 0, resumed: 0 }; d.downloads.push(x); }
153
+ x.bytes += bytes; if (success) x.success++; else x.failed++; if (resumed) x.resumed++;
154
+ });
155
+ }
156
+
157
+ /** Completed days (before today), oldest first, as wire payloads (16 cohorts at most). */
158
+ pending() {
159
+ const today = utcDay(this.clock());
160
+ return Object.entries(this._days()).filter(([day]) => day < today).sort().map(([, p]) => Telemetry.wire(p));
161
+ }
162
+
163
+ /** Drop a day (sent, or refused with 400). */
164
+ 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); }
167
+
168
+ /** The closed v2 body: empty optional parts removed, cohorts beyond 16 merged into the largest. */
169
+ static wire(p) {
170
+ let cohorts = p.cohorts.slice().sort((a, b) => b.sessions - a.sessions);
171
+ if (cohorts.length > 16) {
172
+ const keep = cohorts.slice(0, 16), rest = cohorts.slice(16);
173
+ for (const r of rest) { keep[0].sessions += r.sessions; keep[0].minutes += r.minutes; keep[0].chunks += r.chunks; keep[0].aborted += r.aborted; }
174
+ cohorts = keep;
175
+ }
176
+ const out = { v: 2, day: p.day, product: p.product, platform: p.platform, sdk_version: p.sdk_version, app_version: p.app_version,
177
+ device: p.device, cohorts };
178
+ if (Object.keys(p.coverage).length) out.coverage = p.coverage;
179
+ if (p.downloads.length) out.downloads = p.downloads;
180
+ return out;
181
+ }
182
+ }
183
+
184
+ /** The device fields the spec allows, from what a browser or Node can tell without an identifier. */
185
+ export function deviceInfo() {
186
+ const nav = globalThis.navigator;
187
+ const isNode = typeof process !== 'undefined' && process.versions && process.versions.node && !globalThis.window;
188
+ const ramGb = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : null;
189
+ const tier = (g) => (g === null ? undefined : g < 2 ? 'lt2' : g < 4 ? '2_4' : g < 8 ? '4_8' : g < 16 ? '8_16' : 'ge16');
190
+ if (isNode) {
191
+ const major = Number(process.versions.node.split('.')[0]) || 1;
192
+ return { class: 'node', soc: 'wasm', os_major: Math.max(1, Math.min(99, major)) };
193
+ }
194
+ const ua = (nav && nav.userAgent) || '';
195
+ const m = /(?:Chrome|Firefox|Version)\/(\d+)/.exec(ua);
196
+ const d = { class: 'browser', soc: 'wasm', os_major: Math.max(1, Math.min(99, Math.floor((m ? Number(m[1]) : 10) / 10))) };
197
+ if (ramGb !== null) d.ram_tier = tier(ramGb);
198
+ return d;
199
+ }
@@ -0,0 +1,4 @@
1
+ // Type definitions for @quran-lab/tajweedformer/scorer (the scorer without the streaming engine).
2
+ export * from './tajweed/index.js';
3
+ import { TajweedFormer } from './tajweed/index.js';
4
+ export default TajweedFormer;
package/web/scorer.js ADDED
@@ -0,0 +1,10 @@
1
+ // @quran-lab/tajweedformer/scorer: the TajweedFormer scorer without the streaming engine
2
+ // (dist/tajweedformer-scorer, scripts/build_scorer.sh). Scores heads from any source: the Neural
3
+ // Engine, an NPU, a server, saved heads. Models load for scoring only; streams throw.
4
+ // import { TajweedFormer } from '@quran-lab/tajweedformer/scorer';
5
+ import createTajweedScorer from '../dist/tajweedformer-scorer.mjs';
6
+ import { bindTajweed } from './tajweed/tajweedformer.js';
7
+ export * from './tajweed/index.js';
8
+
9
+ export const TajweedFormer = bindTajweed(createTajweedScorer, { scoreOnly: true });
10
+ export default TajweedFormer;
@@ -0,0 +1,155 @@
1
+ // Opus from libopus 1.6.1 compiled to WebAssembly (dist/tajweedformer-opus, scripts/build_opus.sh),
2
+ // set up as the Swift SDK's LibOpusEncoder (Sharing/OpusEncoder.swift): the same settings, frame
3
+ // size, pre-skip and flush. The module is loaded only when sharing is on (loadOpus), so it never
4
+ // touches the first paint.
5
+
6
+ import { OPUS_SETTINGS } from './oggopus.js';
7
+
8
+ const RATES = [8000, 12000, 16000, 24000, 48000];
9
+ // libopus request codes (opus_defines.h)
10
+ export const OPUS_REQUEST = Object.freeze({
11
+ GET_APPLICATION: 4001, GET_BITRATE: 4003, GET_VBR: 4007, GET_BANDWIDTH: 4009, GET_COMPLEXITY: 4011,
12
+ GET_INBAND_FEC: 4013, GET_DTX: 4017, GET_VBR_CONSTRAINT: 4021, GET_SIGNAL: 4025, GET_LOOKAHEAD: 4027,
13
+ GET_SAMPLE_RATE: 4029, GET_LSB_DEPTH: 4037,
14
+ });
15
+
16
+ let modulePromise = null;
17
+ /**
18
+ * The libopus module, instantiated once per page or process. `factory` replaces the bundled module
19
+ * (tests, or an app that hosts the file elsewhere).
20
+ */
21
+ export function loadOpus(factory) {
22
+ if (factory) return factory();
23
+ if (!modulePromise) {
24
+ modulePromise = import('../../dist/tajweedformer-opus.mjs').then((m) => m.default());
25
+ }
26
+ return modulePromise;
27
+ }
28
+
29
+ /** "libopus 1.6.1". */
30
+ export function opusVersion(m) { return m.UTF8ToString(m._ow_version()); }
31
+
32
+ /** An Opus encoder: float PCM in at `sampleRate`, packets out. Non-finite samples are coded as
33
+ * silence. One instance per session; free() when done. */
34
+ export class OpusEncoder {
35
+ constructor(m, { sampleRate = 48000, channels = 1, settings = OPUS_SETTINGS } = {}) {
36
+ if (!RATES.includes(sampleRate)) throw new RangeError(`Opus encodes 8, 12, 16, 24 or 48 kHz, not ${sampleRate} Hz`);
37
+ if (channels !== 1 && channels !== 2) throw new RangeError(`Opus: 1 or 2 channels, not ${channels}`);
38
+ const size = sampleRate * settings.frameMs / 1000;
39
+ if (![2.5, 5, 10, 20, 40, 60].includes(settings.frameMs) || size !== Math.round(size)) {
40
+ throw new RangeError(`Opus frames are 2.5, 5, 10, 20, 40 or 60 ms, not ${settings.frameMs}`);
41
+ }
42
+ this.m = m;
43
+ const errp = m._malloc(4);
44
+ const enc = m._ow_enc_create(sampleRate, channels, settings.application, settings.bitrate, settings.complexity,
45
+ settings.vbr ? 1 : 0, settings.constrainedVbr ? 1 : 0, settings.bandwidth, settings.signal, 24, errp);
46
+ const err = m.HEAP32[errp >> 2];
47
+ m._free(errp);
48
+ if (!enc) throw new Error(`the Opus encoder is not available (${err})`);
49
+ const look = m._ow_enc_get(enc, OPUS_REQUEST.GET_LOOKAHEAD);
50
+ this.enc = enc;
51
+ this.sampleRate = sampleRate;
52
+ this.channels = channels;
53
+ this.settings = settings;
54
+ this.frameSize = size;
55
+ this.perFrame = size * channels;
56
+ // OpusHead pre-skip is in 48 kHz samples whatever the input rate (312 at 48 kHz)
57
+ this.preSkip = Math.trunc(look * 48000 / sampleRate);
58
+ this.framePtr = m._malloc(this.perFrame * 4);
59
+ this.outPtr = m._malloc(4000);
60
+ this.filled = 0;
61
+ this.samplesIn = 0;
62
+ this.nonFiniteSamples = 0;
63
+ this.finished = false;
64
+ }
65
+
66
+ /** The granule position of the last page: pre-skip plus the input in 48 kHz samples. */
67
+ get finalGranulePosition() { return this.preSkip + Math.trunc(this.samplesIn * 48000 / this.sampleRate); }
68
+
69
+ /** A GET request read back from libopus (OPUS_REQUEST.GET_*), for the tests. */
70
+ get(request) { const v = this.m._ow_enc_get(this.enc, request); return v === -2147483648 ? null : v; }
71
+
72
+ /** Encodes samples; returns the packets completed so far (possibly none). */
73
+ encode(pcm) {
74
+ if (this.finished) throw new Error('the Opus encoder was flushed');
75
+ const out = [];
76
+ let i = 0;
77
+ while (i < pcm.length) {
78
+ const n = Math.min(this.perFrame - this.filled, pcm.length - i);
79
+ const f = this.m.HEAPF32.subarray((this.framePtr >> 2) + this.filled, (this.framePtr >> 2) + this.filled + n);
80
+ let bad = 0;
81
+ for (let k = 0; k < n; k++) {
82
+ const v = pcm[i + k];
83
+ if (Number.isFinite(v)) f[k] = v; else { f[k] = 0; bad++; }
84
+ }
85
+ this.nonFiniteSamples += bad;
86
+ this.filled += n;
87
+ i += n;
88
+ if (this.filled === this.perFrame) out.push(this._frame());
89
+ }
90
+ this.samplesIn += Math.trunc(pcm.length / this.channels);
91
+ return out;
92
+ }
93
+
94
+ /** Encodes what is buffered and the lookahead, padded with silence to whole packets. The encoder
95
+ * cannot be used afterwards. */
96
+ flush() {
97
+ if (this.finished) throw new Error('the Opus encoder was flushed');
98
+ this.finished = true;
99
+ const out = [];
100
+ let owed = Math.trunc(this.filled / this.channels) + Math.trunc(this.preSkip * this.sampleRate / 48000);
101
+ while (owed > 0) {
102
+ this.m.HEAPF32.fill(0, (this.framePtr >> 2) + this.filled, (this.framePtr >> 2) + this.perFrame);
103
+ this.filled = this.perFrame;
104
+ out.push(this._frame());
105
+ owed -= this.frameSize;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ _frame() {
111
+ const n = this.m._ow_enc_encode(this.enc, this.framePtr, this.frameSize, this.outPtr, 4000);
112
+ this.filled = 0;
113
+ if (n <= 0) throw new Error(`the Opus encoder failed (${n})`);
114
+ return this.m.HEAPU8.slice(this.outPtr, this.outPtr + n);
115
+ }
116
+
117
+ free() {
118
+ if (!this.enc) return;
119
+ this.m._ow_enc_destroy(this.enc);
120
+ this.m._free(this.framePtr);
121
+ this.m._free(this.outPtr);
122
+ this.enc = 0;
123
+ }
124
+ }
125
+
126
+ /** RFC 7845 decode of an Ogg Opus stream with the same libopus (tests): pre-skip dropped, the end
127
+ * trimmed to the last granule position. `pages` from oggopus.readPages. */
128
+ export function decodeOggOpus(m, pages) {
129
+ const head = pages[0].packets[0];
130
+ const preSkip = head[10] | (head[11] << 8);
131
+ const d = m._ow_dec_create(48000, 1);
132
+ const inp = m._malloc(8192), pcm = m._malloc(5760 * 4);
133
+ const parts = [];
134
+ let total = 0;
135
+ try {
136
+ for (const page of pages.slice(2)) {
137
+ for (const p of page.packets) {
138
+ m.HEAPU8.set(p, inp);
139
+ const n = m._ow_dec_decode(d, inp, p.length, pcm, 5760);
140
+ if (n <= 0) throw new Error(`decode failed (${n})`);
141
+ parts.push(m.HEAPF32.slice(pcm >> 2, (pcm >> 2) + n));
142
+ total += n;
143
+ }
144
+ }
145
+ } finally {
146
+ m._ow_dec_destroy(d);
147
+ m._free(inp);
148
+ m._free(pcm);
149
+ }
150
+ const all = new Float32Array(total);
151
+ let at = 0;
152
+ for (const p of parts) { all.set(p, at); at += p.length; }
153
+ const end = pages[pages.length - 1].granule;
154
+ return all.slice(preSkip, Math.min(end, all.length));
155
+ }
@@ -0,0 +1,215 @@
1
+ // Types for '@quran-lab/tajweedformer/sharing': whole-session recitation sharing (PLATFORM_CLIENT.md
2
+ // sections 9 and 10). See web/sharing/index.js.
3
+
4
+ export interface OpusSettings {
5
+ application: number; bitrate: number; complexity: number; vbr: boolean; constrainedVbr: boolean;
6
+ bandwidth: number; signal: number; frameMs: number;
7
+ }
8
+ export const OPUS_SETTINGS: Readonly<OpusSettings>;
9
+ export const CODED_RATE: 48000;
10
+ export const SEGMENT_PLAN: readonly number[];
11
+ export function segmentSeconds(n: number, plan?: readonly number[]): number;
12
+ export function segmentName(n: number): string;
13
+ export function packetFrames(s?: OpusSettings): number;
14
+ export function pagePackets(s?: OpusSettings): number;
15
+ export function oggCrc(bytes: Uint8Array): number;
16
+ export function opusHead(preSkip: number, inputRate: number): Uint8Array;
17
+ export function opusTags(vendor: string): Uint8Array;
18
+ export function oggPage(packets: Uint8Array[], o: { granule: number; serial: number; sequence: number; flags: number }): Uint8Array;
19
+ export interface OggPageInfo { flags: number; granule: number; serial: number; sequence: number; packets: Uint8Array[] }
20
+ export function readPages(bytes: Uint8Array): OggPageInfo[];
21
+ export function wholePages(bytes: Uint8Array): { keep: number; granule: number };
22
+
23
+ export interface OpusEncoding {
24
+ readonly preSkip: number;
25
+ readonly finalGranulePosition: number;
26
+ encode(pcm: Float32Array): Uint8Array[];
27
+ flush(): Uint8Array[];
28
+ }
29
+ export interface Resampling {
30
+ process(x: Float32Array, sink: (y: Float32Array) => void): void;
31
+ flush(sink: (y: Float32Array) => void): void;
32
+ }
33
+ export class OggOpusWriter {
34
+ constructor(o: {
35
+ encoder: OpusEncoding; resampler?: Resampling | null; inputRate: number; plan?: readonly number[];
36
+ settings?: OpusSettings; serial?: number; vendor?: string;
37
+ onPage: (segment: number, bytes: Uint8Array) => void;
38
+ onSegment?: (s: { n: number; granule: number; last: boolean }) => void;
39
+ });
40
+ readonly preSkip: number;
41
+ readonly duration: number;
42
+ readonly inputFrames: number;
43
+ readonly nonFiniteSamples: number;
44
+ readonly lastGranule: number;
45
+ append(samples: Float32Array): void;
46
+ finish(): number;
47
+ abandon(): void;
48
+ }
49
+
50
+ export interface OpusModule { [k: string]: any }
51
+ export function loadOpus(factory?: () => Promise<OpusModule>): Promise<OpusModule>;
52
+ export function opusVersion(m: OpusModule): string;
53
+ export const OPUS_REQUEST: Readonly<Record<string, number>>;
54
+ export class OpusEncoder implements OpusEncoding {
55
+ constructor(m: OpusModule, o?: { sampleRate?: number; channels?: number; settings?: OpusSettings });
56
+ readonly preSkip: number;
57
+ readonly finalGranulePosition: number;
58
+ get(request: number): number | null;
59
+ encode(pcm: Float32Array): Uint8Array[];
60
+ flush(): Uint8Array[];
61
+ free(): void;
62
+ }
63
+ export function decodeOggOpus(m: OpusModule, pages: OggPageInfo[]): Float32Array;
64
+ export class ShareResampler implements Resampling {
65
+ constructor(inputRate: number);
66
+ process(x: Float32Array, sink: (y: Float32Array) => void): void;
67
+ flush(sink: (y: Float32Array) => void): void;
68
+ }
69
+
70
+ export type InputRoute = 'built_in' | 'wired' | 'usb' | 'bluetooth' | 'car' | 'continuity' | 'other' | 'unknown';
71
+ export const INPUT_ROUTES: readonly InputRoute[];
72
+ export const SDK_VERSION: string;
73
+ export function utcDay(ms?: number): string;
74
+ export function b64url(bytes: Uint8Array): string;
75
+ export function deletionMessage(f: { app: string; contributor: string; nonce: string; timestamp: number }): Uint8Array;
76
+ export function sessionJson(o: {
77
+ modelRevision?: string | null; backend?: 'neuralEngine' | 'exact' | 'mock'; weightsSha256?: string | null; sdkVersion?: string;
78
+ consentVersion?: string | null; contributor: string; day: string; deviceClass: string; osMajor: number;
79
+ inputSampleRate?: number; inputRoute?: InputRoute; contributorKey?: string | null; recoveryHash?: string | null;
80
+ }): string;
81
+ export interface SegmentUrl { n: number; url: string; expiresAt: number }
82
+ export function parseUrls(body: unknown, id: string): SegmentUrl[] | null;
83
+ export function requestCounts(seconds: number, plan?: readonly number[]): { segments: number; workerCalls: number };
84
+
85
+ /** contributor_revoked / contributor_key_mismatch: make a new contributor, key and recovery code;
86
+ * volume_limit / capped_limit: retried after the next UTC midnight; segment_cap. */
87
+ export class SharingError extends Error {
88
+ readonly code: string;
89
+ readonly status: number;
90
+ }
91
+
92
+ export interface ApiReply { status: number; body: any; text: string; error?: string }
93
+ export class ContributionClient {
94
+ constructor(o: { api: string; fetch?: typeof fetch; headers?: Record<string, string> });
95
+ start(o: { id: string; session: string; token: string; turnstile?: string | null; signal?: AbortSignal }): Promise<ApiReply>;
96
+ urls(o: { id: string; from: number; contributor: string; token: string; signal?: AbortSignal }): Promise<ApiReply>;
97
+ put(o: { url: string; bytes: Uint8Array; signal?: AbortSignal }): Promise<ApiReply>;
98
+ deleteShared(o: { app: string; contributor: string; sign: (message: Uint8Array) => Promise<Uint8Array | ArrayBuffer> }): Promise<any>;
99
+ summary(o: { contributor: string; token: string }): Promise<ApiReply>;
100
+ }
101
+
102
+ export interface SharingStorage {
103
+ sessions(): Promise<string[]>;
104
+ list(id: string): Promise<string[]>;
105
+ read(id: string, name: string): Promise<Uint8Array | null>;
106
+ write(id: string, name: string, bytes: Uint8Array): Promise<void>;
107
+ append(id: string, name: string, bytes: Uint8Array): Promise<void>;
108
+ truncate(id: string, name: string, length: number): Promise<void>;
109
+ remove(id: string, name: string): Promise<void>;
110
+ removeSession(id: string): Promise<void>;
111
+ getGlobal(key: string): Promise<any>;
112
+ setGlobal(key: string, value: any): Promise<void>;
113
+ }
114
+ export class MemoryStorage implements SharingStorage {
115
+ constructor();
116
+ snapshot(): MemoryStorage;
117
+ sessions(): Promise<string[]>; list(id: string): Promise<string[]>; read(id: string, name: string): Promise<Uint8Array | null>;
118
+ write(id: string, name: string, bytes: Uint8Array): Promise<void>; append(id: string, name: string, bytes: Uint8Array): Promise<void>;
119
+ truncate(id: string, name: string, length: number): Promise<void>; remove(id: string, name: string): Promise<void>;
120
+ removeSession(id: string): Promise<void>; getGlobal(key: string): Promise<any>; setGlobal(key: string, value: any): Promise<void>;
121
+ }
122
+ export function dirStorage(root: string): Promise<SharingStorage>;
123
+ export function indexedDbStorage(dbName?: string, idb?: IDBFactory): Promise<SharingStorage>;
124
+ export function defaultStorage(): Promise<SharingStorage>;
125
+ export function recover(storage: SharingStorage, id: string): Promise<boolean>;
126
+ export function sha256Hex(bytes: Uint8Array): Promise<string>;
127
+ export const MAX_AGE_MS: number;
128
+ export const FILES: Readonly<{ session: string; capture: string; end: string; item: string }>;
129
+ export const activeCaptures: Set<string>;
130
+
131
+ export interface ContributionItem {
132
+ id: string; segments: number; bytes: number; durationMs: number; day: string; completed: boolean | null;
133
+ status: 'queued' | 'uploaded' | 'failed'; capped: boolean; failCode: string | null; failMessage: string | null;
134
+ attempts: number; lastError: string | null;
135
+ }
136
+ export interface Token { token: string; attested?: boolean }
137
+ export type TokenProvider = (() => Promise<Token>) & { invalidate?: () => Promise<void> };
138
+ export interface SharingFlags {
139
+ contributions?: boolean;
140
+ upload_trust?: Record<string, 'off' | 'capped' | 'attested'>;
141
+ capped_limits?: { turnstile_web?: boolean; [k: string]: unknown };
142
+ }
143
+ export class Outbox {
144
+ items(): Promise<ContributionItem[]>;
145
+ item(id: string): Promise<ContributionItem | null>;
146
+ pending(): Promise<ContributionItem[]>;
147
+ resume(): Promise<void>;
148
+ stop(): Promise<void>;
149
+ cancel(id: string): Promise<void>;
150
+ drain(o?: { ignoringBackoff?: boolean }): Promise<void>;
151
+ uploadAllowed(): { ok: boolean; why?: string; tier?: string };
152
+ }
153
+
154
+ export interface SharingOptions {
155
+ api: string;
156
+ tokenProvider: TokenProvider;
157
+ flags?: SharingFlags | (() => SharingFlags | null) | null;
158
+ enabled?: () => boolean;
159
+ consentVersion?: string | null;
160
+ contributor?: string;
161
+ contributorKeyRaw?: Uint8Array;
162
+ recoveryHash?: string;
163
+ headers?: Record<string, string>;
164
+ storage?: SharingStorage;
165
+ fetch?: typeof fetch;
166
+ turnstile?: () => Promise<string | null>;
167
+ platform?: string;
168
+ device?: { deviceClass: string; osMajor: number };
169
+ onChange?: (item: ContributionItem | { id: string; status: 'dropped'; failCode: string }) => void;
170
+ onError?: (e: SharingError) => void;
171
+ opus?: () => Promise<OpusModule>;
172
+ clock?: () => number;
173
+ }
174
+ export interface DecodeInfo { modelRevision?: string | null; backend?: 'neuralEngine' | 'exact' | 'mock'; weightsSha256?: string | null; sdkVersion?: string }
175
+ export interface SharedSession {
176
+ readonly id: string;
177
+ readonly duration: number;
178
+ /** Mono float samples at the session's input rate (synchronous: encodes at once). */
179
+ push(pcm: Float32Array): void;
180
+ /** The end of the session; resolves to the shared duration in ms (0 when nothing is shared). */
181
+ finish(o?: { completed?: boolean }): Promise<number>;
182
+ /** A cancelled or failed session. */
183
+ abort(): Promise<void>;
184
+ }
185
+ export interface Sharing {
186
+ storage: SharingStorage;
187
+ outbox: Outbox;
188
+ client: ContributionClient;
189
+ update(next: Partial<SharingOptions>): void;
190
+ contributorId(): Promise<string>;
191
+ resetContributor(): Promise<string>;
192
+ deleteShared(o: { app: string; sign: (message: Uint8Array) => Promise<Uint8Array | ArrayBuffer> }): Promise<any>;
193
+ summary(): Promise<any | null>;
194
+ stop(): Promise<void>;
195
+ startSession(o: { inputSampleRate: number; inputRoute?: InputRoute; decode?: DecodeInfo; serial?: number }): Promise<SharedSession>;
196
+ }
197
+ export function createSharing(o: SharingOptions): Promise<Sharing>;
198
+ export function createSharingSession(o: SharingOptions & { inputSampleRate: number; inputRoute?: InputRoute; decodeInfo?: DecodeInfo }):
199
+ Promise<SharedSession & { outbox: Outbox; sharing: Sharing }>;
200
+ /** The page's pipeline (made by the first createSharingSession or configure()). */
201
+ export const outbox: {
202
+ resume(): Promise<void>;
203
+ stop(): Promise<void>;
204
+ pending(): Promise<ContributionItem[]>;
205
+ items(): Promise<ContributionItem[]>;
206
+ configure(o: SharingOptions): Promise<Sharing>;
207
+ sharing(): Promise<Promise<Sharing> | null>;
208
+ };
209
+ export function resetSharingForTests(): void;
210
+ export function webDevice(): { deviceClass: string; osMajor: number };
211
+ export const MINIMUM_PARTIAL_S: number;
212
+ export class ShareCapture implements SharedSession {
213
+ readonly id: string; readonly duration: number;
214
+ push(pcm: Float32Array): void; finish(o?: { completed?: boolean }): Promise<number>; abort(): Promise<void>;
215
+ }