@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.
@@ -1,257 +1,287 @@
1
- // Recitation sharing for the web SDK (PLATFORM_CLIENT.md sections 9 and 10): with the
2
- // learner's consent, EVERY session the SDK listens to is shared whole, continuously while it listens,
3
- // as one Ogg Opus stream cut into segments that go straight to R2 on presigned URLs. The same wire
4
- // format, segment schedule and batch logic as the Swift reference client and every SDK 2.0.0.
5
- //
6
- // import { createSharing } from '@quran-lab/tajweedformer/sharing';
7
- // const sharing = await createSharing({ api: 'https://api.quranlab.ai', tokenProvider, flags: () => config.flags,
8
- // enabled: () => consent === 'granted', consentVersion: 'deck-1' });
9
- // sharing.outbox.resume(); // at load: sessions a closed page left go up now
10
- // const s = await sharing.startSession({ inputSampleRate: 48000, inputRoute: 'built_in', decode });
11
- // s.push(pcm); ... await s.finish(); // or s.abort() for a cancelled session
12
- //
13
- // The libopus module (dist/tajweedformer-opus, about 316 KB) is loaded on the first session only.
14
-
15
- import { OggOpusWriter, SEGMENT_PLAN, OPUS_SETTINGS, segmentName, CODED_RATE } from './oggopus.js';
16
- import { OpusEncoder, loadOpus, opusVersion } from './encoder.js';
17
- import { ShareResampler } from './resampler.js';
18
- import { ContributionClient, sessionJson, utcDay, b64url, SDK_VERSION } from './uploader.js';
19
- import { Outbox, FILES, defaultStorage, writeJson, mark, recover, activeCaptures } from './outbox.js';
20
-
21
- export * from './oggopus.js';
22
- export { OpusEncoder, loadOpus, opusVersion, decodeOggOpus, OPUS_REQUEST } from './encoder.js';
23
- export { ShareResampler } from './resampler.js';
24
- export { ContributionClient, SharingError, sessionJson, parseUrls, requestCounts, utcDay, b64url, deletionMessage, INPUT_ROUTES, SDK_VERSION } from './uploader.js';
25
- export { Outbox, MemoryStorage, dirStorage, indexedDbStorage, defaultStorage, recover, markers, activeCaptures, sha256Hex, MAX_AGE_MS, FILES } from './outbox.js';
26
-
27
- /** A failed or cancelled session is shared only from this long (seconds). */
28
- export const MINIMUM_PARTIAL_S = 2;
29
-
30
- const randomHex = (bytes = 16) => {
31
- const b = new Uint8Array(bytes);
32
- globalThis.crypto.getRandomValues(b);
33
- return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
34
- };
35
-
36
- /** The web defaults for the session JSON's device fields (PLATFORM_CLIENT 5): device class
37
- * `browser`, os_major the browser's major version / 10 (at least 1); in Node `node` and Node's. */
38
- export function webDevice() {
39
- const nav = globalThis.navigator;
40
- if (globalThis.process?.versions?.node && !globalThis.window) {
41
- return { deviceClass: 'node', osMajor: Math.max(1, Math.min(99, Math.trunc(Number(globalThis.process.versions.node.split('.')[0]) / 10))) };
42
- }
43
- const ua = (nav && nav.userAgent) || '';
44
- const m = /(?:Chrome|Firefox|Version)\/(\d+)/.exec(ua);
45
- return { deviceClass: 'browser', osMajor: Math.max(1, Math.min(99, Math.trunc((m ? Number(m[1]) : 10) / 10))) };
46
- }
47
-
48
- /**
49
- * The sharing pipeline of one app: its outbox, the contributor id, and sessions.
50
- * @param {object} o
51
- * @param {string} o.api the API origin (https://api.quranlab.ai)
52
- * @param {(() => Promise<{token: string, attested?: boolean}>) & {invalidate?: () => Promise<void>}} o.tokenProvider
53
- * @param {(() => object|null) | object | null} [o.flags] the signed config's flags (or a getter)
54
- * @param {() => boolean} [o.enabled] the app's `contributions` option AND the learner's consent
55
- * @param {string|null} [o.consentVersion] the copy deck version the learner agreed to
56
- * @param {string} [o.contributor] the contributor id (32 hex) the app's controller keeps; default: one
57
- * kept in this storage
58
- * @param {Uint8Array} [o.contributorKeyRaw] the device key's public half, the 65-byte uncompressed point
59
- * @param {string} [o.recoveryHash] SHA-256 hex of the normalised recovery code (PLATFORM_CLIENT 9.1)
60
- * @param {object} [o.headers] extra headers for the Worker calls (never sent to R2)
61
- * @param {object} [o.storage] MemoryStorage, dirStorage(), indexedDbStorage() (default per runtime)
62
- * @param {Function} [o.fetch]
63
- * @param {() => Promise<string|null>} [o.turnstile]
64
- * @param {string} [o.platform] 'web'
65
- * @param {{deviceClass: string, osMajor: number}} [o.device]
66
- * @param {(e: SharingError) => void} [o.onError] contributor_revoked / contributor_key_mismatch (rotate
67
- * the contributor, key and code), volume_limit, capped_limit
68
- * @param {() => Promise<object>} [o.opus] a libopus module factory (default: the bundled module)
69
- * @param {number} [o.bitrate] Opus bit/s, 6000 to 510000 (default SHARING_BITRATE_DEFAULT, 192000)
70
- */
71
- export async function createSharing(o) {
72
- const storage = o.storage || await defaultStorage();
73
- const client = new ContributionClient({ api: o.api, fetch: o.fetch, headers: o.headers || {} });
74
- const opts = { ...o };
75
- const enabled = () => (opts.enabled ? !!opts.enabled() : true);
76
- const flags = () => (typeof opts.flags === 'function' ? opts.flags() : opts.flags || null);
77
- const device = () => opts.device || webDevice();
78
- const contributor = async () => {
79
- if (opts.contributor) return opts.contributor;
80
- let c = await storage.getGlobal('contributor');
81
- if (typeof c !== 'string' || !/^[0-9a-f]{32}$/.test(c)) { c = randomHex(); await storage.setGlobal('contributor', c); }
82
- return c;
83
- };
84
- const tokenProvider = (...a) => opts.tokenProvider(...a);
85
- tokenProvider.invalidate = async () => { if (opts.tokenProvider.invalidate) await opts.tokenProvider.invalidate(); };
86
- const outbox = new Outbox({ storage, client, tokenProvider, flags, enabled,
87
- turnstile: async () => (opts.turnstile ? opts.turnstile() : null), platform: o.platform || 'web', contributor, clock: o.clock,
88
- onChange: (x) => opts.onChange && opts.onChange(x), onError: (e) => opts.onError && opts.onError(e), random: o.random });
89
- let opus = null;
90
- const opusModule = async () => { opus = opus || await loadOpus(o.opus); return opus; };
91
-
92
- return {
93
- storage,
94
- outbox,
95
- client,
96
- /** Replace options later (the controller's new contributor and key, a new config, consent). */
97
- update(next) { Object.assign(opts, next); },
98
- /** The contributor id of this install (32 hex). */
99
- contributorId: contributor,
100
- /** A new contributor id kept in this storage (when the app does not manage it). */
101
- async resetContributor() { await storage.setGlobal('contributor', null); return contributor(); },
102
- /** "Delete what I shared" with the device key (PLATFORM_CLIENT 9.1): `sign(bytes)` signs with it.
103
- * The unsent queue goes too; the controller then deletes the key, id and code. */
104
- async deleteShared({ app, sign }) {
105
- const r = await client.deleteShared({ app, contributor: await contributor(), sign });
106
- await outbox.stop();
107
- outbox.stopped = false;
108
- return r;
109
- },
110
- /** Real aggregate numbers for the gratitude line, or null when the call fails. */
111
- async summary() {
112
- try {
113
- const t = await opts.tokenProvider();
114
- const r = await client.summary({ contributor: await contributor(), token: t.token });
115
- return r.status === 200 ? r.body : null;
116
- } catch (_) { return null; }
117
- },
118
- /** Sharing went off: uploads stop at once, the unsent queue is deleted. */
119
- stop: () => outbox.stop(),
120
- /**
121
- * A shared session: nothing is written until the first push; when sharing is off (enabled() or
122
- * the config) the session records nothing.
123
- * @param {{inputSampleRate: number, inputRoute?: string, decode?: {modelRevision?: string|null,
124
- * backend?: string, weightsSha256?: string|null, sdkVersion?: string}, serial?: number}} s
125
- */
126
- async startSession(s) {
127
- const m = await opusModule();
128
- if (opts.contributorKeyRaw && opts.contributorKeyRaw.length !== 65) throw new TypeError('contributorKeyRaw: the 65-byte uncompressed P-256 point');
129
- const key = opts.contributorKeyRaw ? b64url(opts.contributorKeyRaw) : null;
130
- const bitrate = Number.isInteger(opts.bitrate) && opts.bitrate >= 6000 && opts.bitrate <= 510000 ? opts.bitrate : OPUS_SETTINGS.bitrate;
131
- return new ShareCapture({ storage, outbox, opus: m, enabled: () => enabled() && outbox.uploadAllowed().ok,
132
- settings: { ...OPUS_SETTINGS, bitrate },
133
- consentVersion: opts.consentVersion ?? null, contributor: await contributor(), contributorKey: key,
134
- recoveryHash: opts.recoveryHash || null, device: device(), day: utcDay(o.clock ? o.clock() : Date.now()), ...s });
135
- },
136
- };
137
- }
138
-
139
- let shared = null;
140
- /**
141
- * The page's sharing pipeline, made by the first createSharingSession and updated by later ones:
142
- * `outbox.resume()` at load (sessions a closed page left go up), `outbox.stop()` when the learner
143
- * turns sharing off (uploads stop at once, the queue is deleted).
144
- */
145
- export const outbox = {
146
- async resume() { if (shared) await (await shared).outbox.resume(); },
147
- async stop() { if (shared) await (await shared).outbox.stop(); },
148
- async pending() { return shared ? (await shared).outbox.pending() : []; },
149
- async items() { return shared ? (await shared).outbox.items() : []; },
150
- /** Make the page's pipeline now (at load, to resume what a closed page left) with createSharing's options. */
151
- async configure(o) { if (!shared) shared = createSharing(o); const s = await shared; s.update(o); return s; },
152
- /** The pipeline itself (createSharing's result), or null before the first session. */
153
- async sharing() { return shared ? shared : null; },
154
- };
155
-
156
- /**
157
- * One shared session, the controller's one-call form:
158
- * createSharingSession({ api, fetch, tokenProvider, headers, contributor, contributorKeyRaw, recoveryHash,
159
- * inputSampleRate, inputRoute, decodeInfo, consentVersion, device, flags, storage })
160
- * -> { push(Float32Array), finish(): Promise<ms>, abort(): Promise }
161
- * The page keeps one pipeline (one outbox); each call updates its options.
162
- */
163
- export async function createSharingSession(o) {
164
- const sharing = await outbox.configure(o);
165
- const s = await sharing.startSession({ inputSampleRate: o.inputSampleRate, inputRoute: o.inputRoute, decode: o.decodeInfo || {} });
166
- return Object.assign(s, { outbox: sharing.outbox, sharing });
167
- }
168
-
169
- /** Forget the page's pipeline (tests). */
170
- export function resetSharingForTests() { shared = null; }
171
-
172
- /** One session being shared: Ogg Opus segments written as the session runs, each closed segment
173
- * handed to the outbox at once (the Swift ShareCapture). push() is synchronous (it encodes at once);
174
- * storage writes are queued in order and awaited by finish() and abort(). */
175
- export class ShareCapture {
176
- constructor({ storage, outbox, opus, enabled, consentVersion, contributor, contributorKey = null, recoveryHash = null, device, day, inputSampleRate,
177
- inputRoute = 'unknown',
178
- decode = {}, serial, plan = SEGMENT_PLAN, settings = OPUS_SETTINGS }) {
179
- Object.assign(this, { storage, outbox, opus, enabled, plan, settings, serial });
180
- this.id = randomHex();
181
- this.inputRate = Math.round(inputSampleRate);
182
- this.session = sessionJson({ modelRevision: decode.modelRevision ?? null, backend: decode.backend || 'exact',
183
- weightsSha256: decode.weightsSha256 ?? null, sdkVersion: decode.sdkVersion || SDK_VERSION, consentVersion, contributor,
184
- day, deviceClass: device.deviceClass, osMajor: device.osMajor, inputSampleRate: this.inputRate, inputRoute,
185
- contributorKey, recoveryHash });
186
- this.writer = null;
187
- this.encoder = null;
188
- this.segments = 0;
189
- this.failed = false;
190
- this.io = Promise.resolve();
191
- this.error = null;
192
- activeCaptures.add(this.id);
193
- }
194
-
195
- _queue(fn) { this.io = this.io.then(fn).catch((e) => { this.error = this.error || e; this.failed = true; }); }
196
-
197
- /** Audio seconds captured so far. */
198
- get duration() { return this.writer ? this.writer.duration : 0; }
199
-
200
- /** Mono float samples at the session's input rate. */
201
- push(pcm) {
202
- if (this.failed) return;
203
- if (!this.enabled()) { this.abort(); return; }
204
- if (!this.writer) {
205
- this.encoder = new OpusEncoder(this.opus, { sampleRate: CODED_RATE, settings: this.settings });
206
- const id = this.id, s = this.storage;
207
- this.writer = new OggOpusWriter({
208
- encoder: this.encoder, resampler: this.inputRate === CODED_RATE ? null : new ShareResampler(this.inputRate),
209
- inputRate: this.inputRate, plan: this.plan, settings: this.settings, serial: this.serial,
210
- vendor: `QuranLab SDK ${SDK_VERSION} ${opusVersion(this.opus)}`,
211
- onPage: (n, bytes) => this._queue(() => s.append(id, segmentName(n) + '.opus', bytes)),
212
- onSegment: (seg) => { this.segments = seg.n; this._queue(async () => { await mark(s, id, seg.n, seg.granule); this.outbox.drain().catch(() => {}); }); },
213
- });
214
- const preSkip = this.writer.preSkip;
215
- // the session JSON and capture.json before the first page lands
216
- this.io = this.io.then(() => Promise.all([s.write(id, FILES.session, new TextEncoder().encode(this.session)),
217
- writeJson(s, id, FILES.capture, { preSkip })]));
218
- }
219
- this.writer.append(pcm instanceof Float32Array ? pcm : Float32Array.from(pcm));
220
- }
221
-
222
- /** The end of the session: the last segment and end.json; nothing kept when sharing went off, or a
223
- * cancelled session (completed false) shorter than 2 s with no segment gone. Returns the duration
224
- * in ms, or 0 when nothing is shared. */
225
- async finish({ completed = true } = {}) {
226
- const w = this.writer;
227
- if (!w || this.failed || !this.enabled() || w.duration <= 0 || !(completed || w.duration >= MINIMUM_PARTIAL_S || this.segments > 0)) {
228
- await this.abort();
229
- return 0;
230
- }
231
- let ms = 0;
232
- try { ms = w.finish(); } catch (e) { this.error = e; await this.abort(); return 0; }
233
- this._queue(() => writeJson(this.storage, this.id, FILES.end, { durationMs: ms, completed }));
234
- await this.io;
235
- this._release();
236
- if (this.failed) return 0;
237
- this.outbox.drain().catch(() => {});
238
- return ms;
239
- }
240
-
241
- /** A cancelled or failed session: segments already closed stay with the outbox while sharing is on. */
242
- async abort() {
243
- if (this.writer) this.writer.abandon();
244
- this.failed = true;
245
- await this.io.catch(() => {});
246
- if (this.segments === 0 || !this.enabled()) await this.storage.removeSession(this.id);
247
- else await recover(this.storage, this.id);
248
- this._release();
249
- if (this.encoder) { this.encoder.free(); this.encoder = null; }
250
- this.outbox.drain().catch(() => {});
251
- }
252
-
253
- _release() {
254
- activeCaptures.delete(this.id);
255
- if (this.encoder && this.writer && this.writer.ended) { this.encoder.free(); this.encoder = null; }
256
- }
257
- }
1
+ // Recitation sharing for the web SDK (PLATFORM_CLIENT.md sections 9 and 10): with the
2
+ // learner's consent, EVERY session the SDK listens to is shared whole, continuously while it listens,
3
+ // as one Ogg Opus stream cut into segments that go straight to R2 on presigned URLs. The same wire
4
+ // format, segment schedule and batch logic as the Swift reference client and every SDK 2.0.0.
5
+ //
6
+ // import { createSharing } from '@quran-lab/tajweedformer/sharing';
7
+ // const sharing = await createSharing({ api: 'https://api.quranlab.ai', tokenProvider, flags: () => config.flags,
8
+ // enabled: () => consent === 'granted', consentVersion: 'deck-1' });
9
+ // sharing.outbox.resume(); // at load: sessions a closed page left go up now
10
+ // const s = await sharing.startSession({ inputSampleRate: 48000, inputRoute: 'built_in', decode });
11
+ // s.push(pcm); ... await s.finish(); // or s.abort() for a cancelled session
12
+ //
13
+ // The libopus module (dist/tajweedformer-opus, about 316 KB) is loaded on the first session only.
14
+
15
+ import { OggOpusWriter, SEGMENT_PLAN, OPUS_SETTINGS, segmentName, CODED_RATE } from './oggopus.js';
16
+ import { OpusEncoder, loadOpus, opusVersion } from './encoder.js';
17
+ import { ShareResampler } from './resampler.js';
18
+ import { ContributionClient, sessionJson, utcDay, b64url, SDK_VERSION } from './uploader.js';
19
+ import { Outbox, FILES, defaultStorage, writeJson, mark, recover, activeCaptures } from './outbox.js';
20
+ import { BitrateLadder } from './bitrate.js';
21
+
22
+ export * from './oggopus.js';
23
+ export { OpusEncoder, loadOpus, opusVersion, decodeOggOpus, OPUS_REQUEST } from './encoder.js';
24
+ export { ShareResampler } from './resampler.js';
25
+ export { ContributionClient, SharingError, sessionJson, parseUrls, requestCounts, utcDay, b64url, deletionMessage, INPUT_ROUTES, SDK_VERSION } from './uploader.js';
26
+ export { Outbox, MemoryStorage, dirStorage, indexedDbStorage, defaultStorage, recover, markers, markerSeconds, activeCaptures, sha256Hex, MAX_AGE_MS, FILES } from './outbox.js';
27
+ export { BitrateLadder, BITRATE_RUNGS, UP_STREAK, UP_MAX_BACKLOG_S, DOWN_BACKLOG_S, isNetworkFailure } from './bitrate.js';
28
+
29
+ /** A failed or cancelled session is shared only from this long (seconds). */
30
+ export const MINIMUM_PARTIAL_S = 2;
31
+
32
+ const randomHex = (bytes = 16) => {
33
+ const b = new Uint8Array(bytes);
34
+ globalThis.crypto.getRandomValues(b);
35
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
36
+ };
37
+
38
+ /** The web defaults for the session JSON's device fields (PLATFORM_CLIENT 5): device class
39
+ * `browser`, os_major the browser's major version / 10 (at least 1); in Node `node` and Node's. */
40
+ export function webDevice() {
41
+ const nav = globalThis.navigator;
42
+ if (globalThis.process?.versions?.node && !globalThis.window) {
43
+ return { deviceClass: 'node', osMajor: Math.max(1, Math.min(99, Math.trunc(Number(globalThis.process.versions.node.split('.')[0]) / 10))) };
44
+ }
45
+ const ua = (nav && nav.userAgent) || '';
46
+ const m = /(?:Chrome|Firefox|Version)\/(\d+)/.exec(ua);
47
+ return { deviceClass: 'browser', osMajor: Math.max(1, Math.min(99, Math.trunc((m ? Number(m[1]) : 10) / 10))) };
48
+ }
49
+
50
+ /**
51
+ * The sharing pipeline of one app: its outbox, the contributor id, and sessions.
52
+ * @param {object} o
53
+ * @param {string} o.api the API origin (https://api.quranlab.ai)
54
+ * @param {(() => Promise<{token: string, attested?: boolean}>) & {invalidate?: () => Promise<void>}} o.tokenProvider
55
+ * @param {(() => object|null) | object | null} [o.flags] the signed config's flags (or a getter)
56
+ * @param {() => boolean} [o.enabled] the app's `contributions` option AND the learner's consent
57
+ * @param {string|null} [o.consentVersion] the copy deck version the learner agreed to (custom-... for an app's own wording)
58
+ * @param {string|null} [o.consentTextSha256] with an app's own wording: the hash of the text the learner agreed to
59
+ * @param {string} [o.contributor] the contributor id (32 hex) the app's controller keeps; default: one
60
+ * kept in this storage
61
+ * @param {Uint8Array} [o.contributorKeyRaw] the device key's public half, the 65-byte uncompressed point
62
+ * @param {string} [o.recoveryHash] SHA-256 hex of the normalised recovery code (PLATFORM_CLIENT 9.1)
63
+ * @param {object} [o.headers] extra headers for the Worker calls (never sent to R2)
64
+ * @param {object} [o.storage] MemoryStorage, dirStorage(), indexedDbStorage() (default per runtime)
65
+ * @param {Function} [o.fetch]
66
+ * @param {() => Promise<string|null>} [o.turnstile]
67
+ * @param {string} [o.platform] 'web'
68
+ * @param {{deviceClass: string, osMajor: number}} [o.device]
69
+ * @param {(e: SharingError) => void} [o.onError] contributor_revoked / contributor_key_mismatch (rotate
70
+ * the contributor, key and code), volume_limit, capped_limit
71
+ * @param {() => Promise<object>} [o.opus] a libopus module factory (default: the bundled module)
72
+ * @param {number} [o.bitrate] a fixed Opus bit/s, 6000 to 510000, in place of the adaptive ladder (192, 96, 64,
73
+ * 32 kbit/s by how uploads go; bitrate.js). For tests and special cases; leave it out in apps.
74
+ */
75
+ export async function createSharing(o) {
76
+ const storage = o.storage || await defaultStorage();
77
+ const client = new ContributionClient({ api: o.api, fetch: o.fetch, headers: o.headers || {} });
78
+ const opts = { ...o };
79
+ // one ladder per pipeline (page or process), in memory, from the top rung
80
+ const ladder = new BitrateLadder();
81
+ const enabled = () => (opts.enabled ? !!opts.enabled() : true);
82
+ const flags = () => (typeof opts.flags === 'function' ? opts.flags() : opts.flags || null);
83
+ const device = () => opts.device || webDevice();
84
+ const contributor = async () => {
85
+ if (opts.contributor) return opts.contributor;
86
+ let c = await storage.getGlobal('contributor');
87
+ if (typeof c !== 'string' || !/^[0-9a-f]{32}$/.test(c)) { c = randomHex(); await storage.setGlobal('contributor', c); }
88
+ return c;
89
+ };
90
+ const tokenProvider = (...a) => opts.tokenProvider(...a);
91
+ tokenProvider.invalidate = async () => { if (opts.tokenProvider.invalidate) await opts.tokenProvider.invalidate(); };
92
+ const outbox = new Outbox({ storage, client, tokenProvider, flags, enabled,
93
+ turnstile: async () => (opts.turnstile ? opts.turnstile() : null), platform: o.platform || 'web', contributor, clock: o.clock,
94
+ onChange: (x) => opts.onChange && opts.onChange(x), onError: (e) => opts.onError && opts.onError(e), random: o.random, ladder });
95
+ let opus = null;
96
+ const opusModule = async () => { opus = opus || await loadOpus(o.opus); return opus; };
97
+
98
+ return {
99
+ storage,
100
+ outbox,
101
+ client,
102
+ /** The adaptive bitrate: `ladder.bitrate` is the bit rate the next segment opens at. */
103
+ ladder,
104
+ /** Replace options later (the controller's new contributor and key, a new config, consent). */
105
+ update(next) { Object.assign(opts, next); },
106
+ /** The contributor id of this install (32 hex). */
107
+ contributorId: contributor,
108
+ /** A new contributor id kept in this storage (when the app does not manage it). */
109
+ async resetContributor() { await storage.setGlobal('contributor', null); return contributor(); },
110
+ /** "Delete what I shared" with the device key (PLATFORM_CLIENT 9.1): `sign(bytes)` signs with it.
111
+ * The unsent queue goes too; the controller then deletes the key, id and code. */
112
+ async deleteShared({ app, sign }) {
113
+ const r = await client.deleteShared({ app, contributor: await contributor(), sign });
114
+ await outbox.stop();
115
+ outbox.stopped = false;
116
+ return r;
117
+ },
118
+ /** Real aggregate numbers for the gratitude line, or null when the call fails. */
119
+ async summary() {
120
+ try {
121
+ const t = await opts.tokenProvider();
122
+ const r = await client.summary({ contributor: await contributor(), token: t.token });
123
+ return r.status === 200 ? r.body : null;
124
+ } catch (_) { return null; }
125
+ },
126
+ /** Sharing went off: uploads stop at once, the unsent queue is deleted. */
127
+ stop: () => outbox.stop(),
128
+ /**
129
+ * A shared session: nothing is written until the first push; when sharing is off (enabled() or
130
+ * the config) the session records nothing.
131
+ * @param {{inputSampleRate: number, inputRoute?: string, decode?: {modelRevision?: string|null,
132
+ * backend?: string, weightsSha256?: string|null, sdkVersion?: string}, serial?: number}} s
133
+ */
134
+ async startSession(s) {
135
+ const m = await opusModule();
136
+ if (opts.contributorKeyRaw && opts.contributorKeyRaw.length !== 65) throw new TypeError('contributorKeyRaw: the 65-byte uncompressed P-256 point');
137
+ const key = opts.contributorKeyRaw ? b64url(opts.contributorKeyRaw) : null;
138
+ const fixed = Number.isInteger(opts.bitrate) && opts.bitrate >= 6000 && opts.bitrate <= 510000 ? opts.bitrate : null;
139
+ return new ShareCapture({ storage, outbox, opus: m, enabled: () => enabled() && outbox.uploadAllowed().ok,
140
+ settings: { ...OPUS_SETTINGS, bitrate: fixed || ladder.bitrate }, ladder: fixed ? null : ladder,
141
+ consentVersion: opts.consentVersion ?? null, consentTextSha256: opts.consentTextSha256 ?? null, contributor: await contributor(), contributorKey: key,
142
+ recoveryHash: opts.recoveryHash || null, device: device(), day: utcDay(o.clock ? o.clock() : Date.now()), ...s });
143
+ },
144
+ };
145
+ }
146
+
147
+ let shared = null;
148
+ /**
149
+ * The page's sharing pipeline, made by the first createSharingSession and updated by later ones:
150
+ * `outbox.resume()` at load (sessions a closed page left go up), `outbox.stop()` when the learner
151
+ * turns sharing off (uploads stop at once, the queue is deleted).
152
+ */
153
+ export const outbox = {
154
+ async resume() { if (shared) await (await shared).outbox.resume(); },
155
+ async stop() { if (shared) await (await shared).outbox.stop(); },
156
+ async pending() { return shared ? (await shared).outbox.pending() : []; },
157
+ async items() { return shared ? (await shared).outbox.items() : []; },
158
+ /** Make the page's pipeline now (at load, to resume what a closed page left) with createSharing's options. */
159
+ async configure(o) { if (!shared) shared = createSharing(o); const s = await shared; s.update(o); return s; },
160
+ /** The pipeline itself (createSharing's result), or null before the first session. */
161
+ async sharing() { return shared ? shared : null; },
162
+ };
163
+
164
+ /**
165
+ * One shared session, the controller's one-call form:
166
+ * createSharingSession({ api, fetch, tokenProvider, headers, contributor, contributorKeyRaw, recoveryHash,
167
+ * inputSampleRate, inputRoute, decodeInfo, consentVersion, device, flags, storage })
168
+ * -> { push(Float32Array), finish(): Promise<ms>, abort(): Promise }
169
+ * The page keeps one pipeline (one outbox); each call updates its options.
170
+ */
171
+ export async function createSharingSession(o) {
172
+ const sharing = await outbox.configure(o);
173
+ const s = await sharing.startSession({ inputSampleRate: o.inputSampleRate, inputRoute: o.inputRoute, decode: o.decodeInfo || {} });
174
+ return Object.assign(s, { outbox: sharing.outbox, sharing });
175
+ }
176
+
177
+ /** Forget the page's pipeline (tests). */
178
+ export function resetSharingForTests() { shared = null; }
179
+
180
+ /** One session being shared: Ogg Opus segments written as the session runs, each closed segment
181
+ * handed to the outbox at once (the Swift ShareCapture). push() is synchronous (it encodes at once);
182
+ * storage writes are queued in order and awaited by finish() and abort(). With a `ladder`, each segment
183
+ * is coded at the ladder's bitrate when it opens (segment 1: when the capture starts), recorded in
184
+ * capture.json as it opens (for recovery) and in the segment's marker. */
185
+ export class ShareCapture {
186
+ constructor({ storage, outbox, opus, enabled, consentVersion, consentTextSha256 = null, contributor, contributorKey = null, recoveryHash = null, device, day, inputSampleRate,
187
+ inputRoute = 'unknown',
188
+ decode = {}, serial, plan = SEGMENT_PLAN, settings = OPUS_SETTINGS, ladder = null }) {
189
+ Object.assign(this, { storage, outbox, opus, enabled, plan, settings, serial, ladder });
190
+ // segment 1 opens at the rung in force when the capture starts
191
+ if (ladder) this.settings = { ...settings, bitrate: ladder.bitrate };
192
+ /** segment number -> bit/s, as each segment opened */
193
+ this.bitrates = {};
194
+ this._closedGranule = 0;
195
+ this.id = randomHex();
196
+ this.inputRate = Math.round(inputSampleRate);
197
+ this.session = sessionJson({ modelRevision: decode.modelRevision ?? null, backend: decode.backend || 'exact',
198
+ weightsSha256: decode.weightsSha256 ?? null, sdkVersion: decode.sdkVersion || SDK_VERSION, consentVersion, consentTextSha256, contributor,
199
+ day, deviceClass: device.deviceClass, osMajor: device.osMajor, inputSampleRate: this.inputRate, inputRoute,
200
+ contributorKey, recoveryHash });
201
+ this.writer = null;
202
+ this.encoder = null;
203
+ this.segments = 0;
204
+ this.failed = false;
205
+ this.io = Promise.resolve();
206
+ this.error = null;
207
+ activeCaptures.add(this.id);
208
+ }
209
+
210
+ _queue(fn) { this.io = this.io.then(fn).catch((e) => { this.error = this.error || e; this.failed = true; }); }
211
+
212
+ /** Audio seconds captured so far. */
213
+ get duration() { return this.writer ? this.writer.duration : 0; }
214
+
215
+ /** Mono float samples at the session's input rate. */
216
+ push(pcm) {
217
+ if (this.failed) return;
218
+ if (!this.enabled()) { this.abort(); return; }
219
+ if (!this.writer) {
220
+ this.encoder = new OpusEncoder(this.opus, { sampleRate: CODED_RATE, settings: this.settings });
221
+ const id = this.id, s = this.storage;
222
+ const ladder = this.ladder;
223
+ this.writer = new OggOpusWriter({
224
+ encoder: this.encoder, resampler: this.inputRate === CODED_RATE ? null : new ShareResampler(this.inputRate),
225
+ inputRate: this.inputRate, plan: this.plan, settings: this.settings, serial: this.serial,
226
+ vendor: `QuranLab SDK ${SDK_VERSION} ${opusVersion(this.opus)}`,
227
+ // segment 1 keeps the bitrate the encoder was made with; later ones take the ladder's as they open
228
+ bitrate: (n) => (ladder && n > 1 ? ladder.bitrate : null),
229
+ onSegmentOpen: ({ n, bitrate }) => {
230
+ this.bitrates[n] = bitrate;
231
+ const bitrates = { ...this.bitrates };
232
+ this._queue(() => writeJson(s, id, FILES.capture, { preSkip: this.writer.preSkip, bitrates }));
233
+ },
234
+ onPage: (n, bytes) => this._queue(() => s.append(id, segmentName(n) + '.opus', bytes)),
235
+ onSegment: (seg) => {
236
+ this.segments = seg.n;
237
+ // the backlog and the ladder at once, before the next segment opens
238
+ const seconds = Math.max(0, seg.granule - this._closedGranule) / 48000;
239
+ this._closedGranule = seg.granule;
240
+ if (typeof this.outbox.segmentClosed === 'function') this.outbox.segmentClosed(id, seg.n, seconds);
241
+ this._queue(async () => { await mark(s, id, seg.n, seg.granule, seg.bitrate); this.outbox.drain().catch(() => {}); });
242
+ },
243
+ });
244
+ const preSkip = this.writer.preSkip;
245
+ // the session JSON and capture.json before the first page lands
246
+ this.io = this.io.then(() => Promise.all([s.write(id, FILES.session, new TextEncoder().encode(this.session)),
247
+ writeJson(s, id, FILES.capture, { preSkip, bitrates: {} })]));
248
+ }
249
+ this.writer.append(pcm instanceof Float32Array ? pcm : Float32Array.from(pcm));
250
+ }
251
+
252
+ /** The end of the session: the last segment and end.json; nothing kept when sharing went off, or a
253
+ * cancelled session (completed false) shorter than 2 s with no segment gone. Returns the duration
254
+ * in ms, or 0 when nothing is shared. */
255
+ async finish({ completed = true } = {}) {
256
+ const w = this.writer;
257
+ if (!w || this.failed || !this.enabled() || w.duration <= 0 || !(completed || w.duration >= MINIMUM_PARTIAL_S || this.segments > 0)) {
258
+ await this.abort();
259
+ return 0;
260
+ }
261
+ let ms = 0;
262
+ try { ms = w.finish(); } catch (e) { this.error = e; await this.abort(); return 0; }
263
+ this._queue(() => writeJson(this.storage, this.id, FILES.end, { durationMs: ms, completed }));
264
+ await this.io;
265
+ this._release();
266
+ if (this.failed) return 0;
267
+ this.outbox.drain().catch(() => {});
268
+ return ms;
269
+ }
270
+
271
+ /** A cancelled or failed session: segments already closed stay with the outbox while sharing is on. */
272
+ async abort() {
273
+ if (this.writer) this.writer.abandon();
274
+ this.failed = true;
275
+ await this.io.catch(() => {});
276
+ if (this.segments === 0 || !this.enabled()) await this.storage.removeSession(this.id);
277
+ else await recover(this.storage, this.id);
278
+ this._release();
279
+ if (this.encoder) { this.encoder.free(); this.encoder = null; }
280
+ this.outbox.drain().catch(() => {});
281
+ }
282
+
283
+ _release() {
284
+ activeCaptures.delete(this.id);
285
+ if (this.encoder && this.writer && this.writer.ended) { this.encoder.free(); this.encoder = null; }
286
+ }
287
+ }