@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,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
+
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
+ }
@@ -0,0 +1,294 @@
1
+ // Ogg Opus for shared sessions (RFC 7845), the same wire format as the Swift SDK's OggOpusWriter
2
+ // (tajweedformer-sdk-swift Sharing/OggOpus.swift) and every other SDK 2.0.0 (PLATFORM_CLIENT.md
3
+ // section 10): mono, coded at 48 kHz, OpusHead with the true input rate, OpusTags, pages of about a
4
+ // second of packets (50 at 20 ms, fewer when 255 lacing values would not hold them), cut into
5
+ // segments at page boundaries (10, 20, 40, then 60 s), the last page of a finished session with the
6
+ // end-of-stream flag and the encoder's final granule position. Plain JS, no dependencies.
7
+
8
+ /** The sharing bitrate (bit/s): the reference's 192 kbit/s (about 86 MB an hour) until Quran Lab decides;
9
+ * the one constant of this SDK. Apps may pass another (createSharingSession({ bitrate })). */
10
+ export const SHARING_BITRATE_DEFAULT = 192000;
11
+
12
+ /** The Opus settings sessions are shared with (Swift OpusSettings.shared, the opus-bench choice). */
13
+ export const OPUS_SETTINGS = Object.freeze({
14
+ application: 2049, // OPUS_APPLICATION_AUDIO
15
+ bitrate: SHARING_BITRATE_DEFAULT,
16
+ complexity: 10,
17
+ vbr: true,
18
+ constrainedVbr: false,
19
+ bandwidth: 1105, // OPUS_BANDWIDTH_FULLBAND
20
+ signal: -1000, // OPUS_AUTO
21
+ frameMs: 20,
22
+ });
23
+
24
+ /** The rate Opus codes at; the sharing path always encodes at it. */
25
+ export const CODED_RATE = 48000;
26
+
27
+ /** Samples per packet at 48 kHz, and packets on a full page (about one second). */
28
+ export const packetFrames = (s = OPUS_SETTINGS) => Math.trunc(48 * s.frameMs);
29
+ export const pagePackets = (s = OPUS_SETTINGS) => Math.max(1, Math.round(1000 / s.frameMs));
30
+
31
+ /** Seconds per segment: the first ones short, then a minute each; the last entry repeats. */
32
+ export const SEGMENT_PLAN = Object.freeze([10, 20, 40, 60]);
33
+ export const segmentSeconds = (n, plan = SEGMENT_PLAN) => plan[Math.min(n, plan.length) - 1];
34
+
35
+ /** "seg-00001" for segment 1 (the R2 key and the local name, without ".opus"). */
36
+ export const segmentName = (n) => `seg-${String(n).padStart(5, '0')}`;
37
+
38
+ const CRC_TABLE = (() => {
39
+ const t = new Uint32Array(256);
40
+ for (let i = 0; i < 256; i++) {
41
+ let r = i << 24;
42
+ for (let k = 0; k < 8; k++) r = r & 0x80000000 ? (r << 1) ^ 0x04c11db7 : r << 1;
43
+ t[i] = r >>> 0;
44
+ }
45
+ return t;
46
+ })();
47
+
48
+ /** The Ogg CRC-32: polynomial 0x04c11db7, no reflection, initial value 0. */
49
+ export function oggCrc(bytes) {
50
+ let c = 0;
51
+ for (let i = 0; i < bytes.length; i++) c = ((c << 8) ^ CRC_TABLE[((c >>> 24) & 0xff) ^ bytes[i]]) >>> 0;
52
+ return c >>> 0;
53
+ }
54
+
55
+ const utf8 = (s) => new TextEncoder().encode(s);
56
+ function concat(parts) {
57
+ const n = parts.reduce((k, p) => k + p.length, 0);
58
+ const out = new Uint8Array(n);
59
+ let at = 0;
60
+ for (const p of parts) { out.set(p, at); at += p.length; }
61
+ return out;
62
+ }
63
+
64
+ /** RFC 7845 identification header; inputRate is the capture rate (informational). */
65
+ export function opusHead(preSkip, inputRate) {
66
+ const d = new Uint8Array(19);
67
+ d.set(utf8('OpusHead'), 0);
68
+ d[8] = 1; d[9] = 1;
69
+ const v = new DataView(d.buffer);
70
+ v.setUint16(10, preSkip, true);
71
+ v.setUint32(12, inputRate, true);
72
+ v.setUint16(16, 0, true);
73
+ d[18] = 0;
74
+ return d;
75
+ }
76
+
77
+ /** RFC 7845 comment header with the vendor string and no comments. */
78
+ export function opusTags(vendor) {
79
+ const vb = utf8(vendor);
80
+ const d = new Uint8Array(8 + 4 + vb.length + 4);
81
+ d.set(utf8('OpusTags'), 0);
82
+ const v = new DataView(d.buffer);
83
+ v.setUint32(8, vb.length, true);
84
+ d.set(vb, 12);
85
+ v.setUint32(12 + vb.length, 0, true);
86
+ return d;
87
+ }
88
+
89
+ /** One Ogg page holding whole packets. */
90
+ export function oggPage(packets, { granule, serial, sequence, flags }) {
91
+ const lacing = [];
92
+ for (const p of packets) {
93
+ let n = p.length;
94
+ while (n >= 255) { lacing.push(255); n -= 255; }
95
+ lacing.push(n);
96
+ }
97
+ if (lacing.length > 255) throw new RangeError('an Ogg page holds at most 255 segments');
98
+ const head = new Uint8Array(27 + lacing.length);
99
+ head.set(utf8('OggS'), 0);
100
+ head[4] = 0;
101
+ head[5] = flags;
102
+ const v = new DataView(head.buffer);
103
+ v.setBigUint64(6, BigInt.asUintN(64, BigInt(granule)), true);
104
+ v.setUint32(14, serial >>> 0, true);
105
+ v.setUint32(18, sequence >>> 0, true);
106
+ v.setUint32(22, 0, true);
107
+ head[26] = lacing.length;
108
+ head.set(lacing, 27);
109
+ const page = concat([head, ...packets]);
110
+ new DataView(page.buffer).setUint32(22, oggCrc(page), true);
111
+ return page;
112
+ }
113
+
114
+ /**
115
+ * Every page of an Ogg stream, each checked (capture pattern, CRC); throws on a bad page.
116
+ * @returns {{flags: number, granule: number, serial: number, sequence: number, packets: Uint8Array[]}[]}
117
+ */
118
+ export function readPages(bytes) {
119
+ const out = [];
120
+ let at = 0;
121
+ while (at < bytes.length) {
122
+ if (!(bytes[at] === 0x4f && bytes[at + 1] === 0x67 && bytes[at + 2] === 0x67 && bytes[at + 3] === 0x53)) throw new Error(`no OggS at ${at}`);
123
+ const n = bytes[at + 26];
124
+ const lacing = bytes.subarray(at + 27, at + 27 + n);
125
+ const len = 27 + n + lacing.reduce((k, x) => k + x, 0);
126
+ if (at + len > bytes.length) throw new Error(`page at ${at} is cut`);
127
+ const page = bytes.slice(at, at + len);
128
+ const v = new DataView(page.buffer);
129
+ const crc = v.getUint32(22, true);
130
+ v.setUint32(22, 0, true);
131
+ if (oggCrc(page) !== crc) throw new Error(`page CRC at ${at}`);
132
+ const packets = [];
133
+ let parts = [], o = at + 27 + n;
134
+ for (const l of lacing) {
135
+ parts.push(bytes.subarray(o, o + l));
136
+ o += l;
137
+ if (l < 255) { packets.push(concat(parts)); parts = []; }
138
+ }
139
+ out.push({ flags: bytes[at + 5], granule: Number(v.getBigInt64(6, true)), serial: v.getUint32(14, true), sequence: v.getUint32(18, true), packets });
140
+ at += len;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** The length of the whole pages at the start of `bytes` (each with a valid CRC) and the last one's
146
+ * granule position (Swift ShareFiles.wholePages): what survives a torn write. */
147
+ export function wholePages(bytes) {
148
+ let at = 0, granule = 0;
149
+ while (at + 27 <= bytes.length && bytes[at] === 0x4f && bytes[at + 1] === 0x67 && bytes[at + 2] === 0x67 && bytes[at + 3] === 0x53) {
150
+ const n = bytes[at + 26];
151
+ if (at + 27 + n > bytes.length) break;
152
+ let len = 0;
153
+ for (let i = 0; i < n; i++) len += bytes[at + 27 + i];
154
+ const end = at + 27 + n + len;
155
+ if (end > bytes.length) break;
156
+ const page = bytes.slice(at, end);
157
+ const v = new DataView(page.buffer);
158
+ const crc = v.getUint32(22, true);
159
+ v.setUint32(22, 0, true);
160
+ if (oggCrc(page) !== crc) break;
161
+ granule = Number(v.getBigInt64(6, true));
162
+ at = end;
163
+ }
164
+ return { keep: at, granule };
165
+ }
166
+
167
+ /**
168
+ * Writes one session's audio as Ogg Opus pages while the session runs (Swift OggOpusWriter).
169
+ * Synchronous: each finished page goes to `onPage(n, bytes)` (n = its segment), each closed segment
170
+ * to `onSegment({ n, granule, last })`; the caller persists them. `plan` empty: one segment.
171
+ *
172
+ * @param {object} o
173
+ * @param {{ preSkip: number, encode(x: Float32Array): Uint8Array[], flush(): Uint8Array[], finalGranulePosition: number }} o.encoder
174
+ * an encoder taking 48 kHz input (encoder.js)
175
+ * @param {{ process(x: Float32Array, sink: Function): void, flush(sink: Function): void } | null} o.resampler
176
+ * input rate to 48 kHz (resampler.js), null when the input is 48 kHz
177
+ * @param {number} o.inputRate the capture rate (8000 to 384000, whole Hz)
178
+ */
179
+ export class OggOpusWriter {
180
+ constructor({ encoder, resampler = null, inputRate, plan = SEGMENT_PLAN, settings = OPUS_SETTINGS, serial, vendor, onPage, onSegment = () => {} }) {
181
+ if (!(inputRate >= 8000 && inputRate <= 384000 && Number.isInteger(inputRate))) throw new RangeError(`unsupported input rate ${inputRate} Hz`);
182
+ if (inputRate !== CODED_RATE && !resampler) throw new TypeError('a resampler is needed for input that is not 48 kHz');
183
+ this.encoder = encoder;
184
+ this.resampler = inputRate === CODED_RATE ? null : resampler;
185
+ this.inputRate = inputRate;
186
+ this.plan = plan;
187
+ this.settings = settings;
188
+ this.serial = serial === undefined ? (Math.random() * 0x100000000) >>> 0 : serial >>> 0;
189
+ this.onPage = onPage;
190
+ this.onSegment = onSegment;
191
+ this.packetFrames = packetFrames(settings);
192
+ this.pagePackets = pagePackets(settings);
193
+ this.segment = 0;
194
+ this.open = false;
195
+ this.segmentPackets = 0;
196
+ this.sequence = 0;
197
+ this.inputFrames = 0;
198
+ this.nonFiniteSamples = 0;
199
+ this.ended = false;
200
+ this.packets = [];
201
+ this.paged = 0;
202
+ this.lastGranule = 0;
203
+ this._clean = new Float32Array(0);
204
+ this._sink = (x) => { for (const p of this.encoder.encode(x)) this.packets.push(p); };
205
+ this._page([opusHead(encoder.preSkip, inputRate)], 0, 0x02, false);
206
+ this._page([opusTags(vendor || 'QuranLab SDK')], 0, 0, false);
207
+ }
208
+
209
+ get preSkip() { return this.encoder.preSkip; }
210
+ /** Seconds of audio written so far (at the input rate). */
211
+ get duration() { return this.inputFrames / this.inputRate; }
212
+
213
+ /** Mono samples at the input rate; non-finite samples are written as silence. */
214
+ append(samples) {
215
+ if (!samples.length || this.ended) return;
216
+ this.inputFrames += samples.length;
217
+ if (this._clean.length < samples.length) this._clean = new Float32Array(samples.length);
218
+ const c = this._clean.subarray(0, samples.length);
219
+ let bad = 0;
220
+ for (let i = 0; i < samples.length; i++) {
221
+ const v = samples[i];
222
+ if (Number.isFinite(v)) c[i] = v; else { c[i] = 0; bad++; }
223
+ }
224
+ this.nonFiniteSamples += bad;
225
+ if (this.resampler) this.resampler.process(c, this._sink); else this._sink(c);
226
+ this._writePages(false);
227
+ }
228
+
229
+ /** Flushes the resampler and the encoder, writes the last pages, closes the last segment. Returns
230
+ * the duration in ms. */
231
+ finish() {
232
+ if (!this.ended) {
233
+ this.ended = true;
234
+ if (this.resampler) this.resampler.flush(this._sink);
235
+ for (const p of this.encoder.flush()) this.packets.push(p);
236
+ this._writePages(true);
237
+ this._close(true);
238
+ }
239
+ return Math.round(this.duration * 1000);
240
+ }
241
+
242
+ /** Stops without finishing (the caller removes what was written). */
243
+ abandon() { this.ended = true; this.open = false; }
244
+
245
+ _segmentLimit(n) {
246
+ return this.plan.length ? Math.round(segmentSeconds(n, this.plan) * 1000 / this.settings.frameMs) : Infinity;
247
+ }
248
+
249
+ _writePages(final) {
250
+ while (this.packets.length) {
251
+ const next = this.open ? this.segment : this.segment + 1;
252
+ const used = this.open ? this.segmentPackets : 0;
253
+ const room = this._segmentLimit(next) - used;
254
+ const want = Math.min(this.pagePackets, room);
255
+ let take = 0, lacing = 0;
256
+ while (take < Math.min(want, this.packets.length)) {
257
+ const l = Math.floor(this.packets[take].length / 255) + 1;
258
+ if (lacing + l > 255) break;
259
+ lacing += l;
260
+ take++;
261
+ }
262
+ const full = take === want || take < this.packets.length;
263
+ if (!(full || final)) return;
264
+ const last = final && take === this.packets.length;
265
+ const chunk = this.packets.splice(0, take);
266
+ let granule = (this.paged + take) * this.packetFrames;
267
+ if (last) granule = Math.max(Math.min(this.encoder.finalGranulePosition, granule), this.paged * this.packetFrames);
268
+ this._page(chunk, granule, last ? 0x04 : 0, true);
269
+ }
270
+ }
271
+
272
+ _close(last) {
273
+ if (!this.open) return;
274
+ this.open = false;
275
+ this.onSegment({ n: this.segment, granule: this.lastGranule, last });
276
+ }
277
+
278
+ _page(packets, granule, flags, audio) {
279
+ const bytes = oggPage(packets, { granule, serial: this.serial, sequence: this.sequence, flags });
280
+ if (!this.open) {
281
+ this.segment += 1;
282
+ this.segmentPackets = 0;
283
+ this.open = true;
284
+ }
285
+ this.onPage(this.segment, bytes);
286
+ this.sequence += 1;
287
+ this.lastGranule = granule;
288
+ if (audio) {
289
+ this.paged += packets.length;
290
+ this.segmentPackets += packets.length;
291
+ if (!(flags & 0x04) && this.segmentPackets >= this._segmentLimit(this.segment)) this._close(false);
292
+ }
293
+ }
294
+ }