@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,65 @@
1
+ // Types for @quran-lab/tajweedformer/consent: the recitation sharing experience (SHARING_EXPERIENCE.md).
2
+ import type { QuranLab, StateStore } from '../platform/index.js';
3
+
4
+ export type AskVariant = 'first' | 'reask' | 'version';
5
+ export interface Ask { variant: AskVariant; guardian: boolean }
6
+ export interface Answer { yes: boolean; guardianConfirmed?: boolean }
7
+
8
+ export declare const MAX_ASKS: 3;
9
+ export declare const REASK_GAP: 7;
10
+
11
+ export declare class SharingState {
12
+ constructor(state: StateStore, o?: { enabled?: () => boolean; consentVersion?: string; audience?: string; clock?: () => number });
13
+ /** uploads may run: allowed by the app and the signed config, and the learner said yes */
14
+ readonly sharing: boolean;
15
+ readonly guardianVariant: boolean;
16
+ /** a session ended; the ask due now, or null */
17
+ sessionCompleted(o?: { feedback?: boolean }): Ask | null;
18
+ askNow(): Ask;
19
+ answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
20
+ setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
21
+ takeThanks(): boolean;
22
+ onChange(f: (s: object) => void): () => void;
23
+ }
24
+
25
+ /** A copy deck (quranlab.sharing-copy/1) for one language. */
26
+ export interface Deck {
27
+ lang: string; dir: 'ltr' | 'rtl'; version: string; consentVersion: string; deleteDays: number;
28
+ hadithSource: { collection: string; number: number; url: string; translation_source: string | null; translated: boolean } | null;
29
+ /** a string by key ({days} filled); English when this deck lacks it */
30
+ t(key: string): string;
31
+ /** the plural impact line for real shared minutes */
32
+ minutes(n: number): string;
33
+ }
34
+ export declare const EN: Readonly<{ format: string; version: string; lang: string; dir: string; consent_version: string; strings: Record<string, unknown> }>;
35
+ export declare function copyFor(lang?: string): Deck;
36
+ export declare function registerDeck(lang: string, deck: { format: 'quranlab.sharing-copy/1'; [k: string]: unknown }): void;
37
+ export declare function languages(): string[];
38
+
39
+ export interface SharingController {
40
+ lang: string;
41
+ state: SharingState;
42
+ deck: Deck;
43
+ sessionCompleted(o?: { feedback?: boolean }): Promise<Answer | null>;
44
+ setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
45
+ /** "Delete what I shared": signed with this install's device key (no app credential can delete) */
46
+ deleteShared(): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
47
+ /** "Show my recovery code": for the learner only */
48
+ recoveryCode(): string | null;
49
+ rotateContributor(): Promise<void>;
50
+ impactLine(): Promise<string | null>;
51
+ }
52
+
53
+ /** Turn on the sharing experience for a configured client (contributions: true). */
54
+ export declare function enableSharing(ql: QuranLab, o?: { lang?: string; ask?: 'auto' | ((a: Ask) => Promise<Answer>); idle?: () => boolean }): SharingController;
55
+ export declare function deleteWithKey(ql: QuranLab, key: import('../platform/index.js').ContributorKey): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
56
+
57
+ /** The web components (import '@quran-lab/tajweedformer/consent/element' or let enableSharing load them). */
58
+ export declare class QuranLabConsent extends HTMLElement {
59
+ static ask(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; parent?: Element }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
60
+ static thank(o?: { lang?: string; parent?: Element; ms?: number }): HTMLElement;
61
+ show(o?: { lang?: string; variant?: AskVariant; guardian?: boolean }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
62
+ }
63
+ export declare class QuranLabSharingSettings extends HTMLElement {
64
+ controller: SharingController;
65
+ }
@@ -0,0 +1,154 @@
1
+ // Recitation sharing for the web product: the SDK's own consent sheet and settings (web components),
2
+ // the state machine (when to ask, re-ask limits, guardian variant), and the wiring that shares the
3
+ // WHOLE session while the SDK listens once the learner said yes (the 2.0.0 design; the upload itself is
4
+ // web/sharing/, loaded lazily so it costs nothing until sharing is on).
5
+ //
6
+ // import { QuranLab } from '@quran-lab/tajweedformer/platform';
7
+ // import { enableSharing } from '@quran-lab/tajweedformer/consent';
8
+ // const ql = await QuranLab.configure({ key, contributions: true });
9
+ // const sharing = enableSharing(ql, { lang: 'ar' }); // asks by itself after a session with feedback
10
+ // document.querySelector('quranlab-sharing-settings').controller = sharing;
11
+
12
+ import { SharingState } from './state.js';
13
+ import { copyFor, registerDeck, languages, EN } from './copy.js';
14
+ import { contributorKey, forgetContributorKey, deletionMessage, b64url, newRecoveryCode, recoveryHash } from '../platform/contributor-key.js';
15
+
16
+ export { SharingState, MAX_ASKS, REASK_GAP } from './state.js';
17
+ export { copyFor, registerDeck, languages, EN } from './copy.js';
18
+
19
+ /**
20
+ * Turn on the sharing experience for a configured platform client.
21
+ * @param {import('../platform/client.js').QuranLab} ql configured with contributions: true
22
+ * @param {object} [o]
23
+ * @param {string} [o.lang] the deck's language (default: the browser's)
24
+ * @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}>)} [o.ask='auto']
25
+ * auto: the SDK shows <quranlab-consent> when the app is idle after a session with feedback; a
26
+ * function: the app shows it itself (timing only; the lines are copyFor(lang))
27
+ * @param {(() => boolean)} [o.idle] whether the app is idle now (default: always)
28
+ */
29
+ export function enableSharing(ql, o = {}) {
30
+ const lang = o.lang || (globalThis.navigator && navigator.language) || 'en';
31
+ const deck = copyFor(lang);
32
+ const state = new SharingState(ql.state, { enabled: () => ql.sharingAllowed(), consentVersion: deck.consentVersion, audience: ql.audience });
33
+ const ctl = {
34
+ lang, state, deck,
35
+ /** The ask to show after a session (null when none is due); auto mode shows it. */
36
+ async sessionCompleted({ feedback = true } = {}) {
37
+ const ask = state.sessionCompleted({ feedback });
38
+ if (!ask) return null;
39
+ const show = typeof o.ask === 'function' ? o.ask : async (a) => {
40
+ const { QuranLabConsent } = await import('./element.js');
41
+ return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian });
42
+ };
43
+ const wait = () => new Promise((r) => { const tick = () => (!o.idle || o.idle() ? r() : setTimeout(tick, 1000)); tick(); });
44
+ await wait();
45
+ const a = await show(ask);
46
+ state.answer(!!a.yes, { guardianConfirmed: !!a.guardianConfirmed });
47
+ return a;
48
+ },
49
+ /** The settings switch: off stops uploads at once and deletes the unsent queue. */
50
+ setSharing(on, opts) {
51
+ state.setSharing(on, opts);
52
+ if (!on && ctl.outbox) ctl.outbox.stop();
53
+ },
54
+ /** "Delete what I shared": challenge-response signed with this install's contributor key (P2.11). */
55
+ async deleteShared() {
56
+ const contributor = ql.contributor();
57
+ const key = await contributorKey(contributor, { create: false });
58
+ if (!key) { ql.resetContributor(); return { deleted: 0, objects: 0 }; } // never shared from here
59
+ const r = await deleteWithKey(ql, key);
60
+ await forgetContributorKey(contributor);
61
+ ql.resetContributor();
62
+ ql.state.set('sharing.recovery', null);
63
+ if (ctl.outbox) ctl.outbox.stop();
64
+ return r;
65
+ },
66
+ /** "Show my recovery code": for the learner's eyes only (call it from a confirm step in your UI). */
67
+ recoveryCode() { return ql.state.get('sharing.recovery'); },
68
+ /** A rejected contributor (403 contributor_revoked, 409 contributor_key_mismatch): new id, key, code. */
69
+ async rotateContributor() {
70
+ await forgetContributorKey(ql.contributor());
71
+ ql.resetContributor();
72
+ ql.state.set('sharing.recovery', null);
73
+ },
74
+ /** A quiet impact line from the backend's real numbers, or null. */
75
+ async impactLine() {
76
+ const s = await ql.contributionSummary();
77
+ if (!s || !s.you || !s.you.minutes) return null;
78
+ return deck.minutes(Math.floor(s.you.minutes));
79
+ },
80
+ };
81
+ ql.sharing = ctl;
82
+ // every session the SDK listens to, shared in full while consent is on (P2.9)
83
+ ctl.streamStarted = (tf, s) => {
84
+ if (!state.sharing) return;
85
+ let session = null, starting = null;
86
+ const begin = () => starting || (starting = import('../sharing/index.js').then(async (mod) => {
87
+ const key = await contributorKey(ql.contributor());
88
+ let code = ql.state.get('sharing.recovery');
89
+ if (!code) { code = newRecoveryCode(); ql.state.set('sharing.recovery', code); }
90
+ session = await mod.createSharingSession({
91
+ api: ql.apiUrl, fetch: ql.fetch, tokenProvider: () => ql.token(), headers: ql.origin ? { origin: ql.origin } : {},
92
+ contributor: ql.contributor(), contributorKeyRaw: key.publicKeyRaw, recoveryHash: await recoveryHash(code), inputSampleRate: tf.sampleRate, inputRoute: 'unknown',
93
+ decodeInfo: { model_revision: ql.modelRevision || null, backend: 'exact', weights_sha256: null, sdk_version: '2.0.0' },
94
+ consentVersion: deck.consentVersion, device: ql.telemetry.device, flags: ql.flags,
95
+ ...(ql.sharingBitrate ? { bitrate: ql.sharingBitrate } : {}),
96
+ });
97
+ ctl.outbox = mod.outbox || ctl.outbox;
98
+ return session;
99
+ }).catch((e) => { ql.reportIssue('other', 'sharing_start:' + (e.code || 'error')); return null; }));
100
+ const push = s.pushAudio, finish = s.finish, free = s.free;
101
+ s.pushAudio = (pcm) => {
102
+ const k = push(pcm);
103
+ if (state.sharing) { const copy = pcm.slice(); begin().then((x) => x && x.push(copy)); }
104
+ return k;
105
+ };
106
+ s.finish = () => {
107
+ const k = finish();
108
+ if (starting) begin().then((x) => x && x.finish()).then(() => { if (state.takeThanks() && typeof o.ask !== 'function') import('./element.js').then((m) => m.QuranLabConsent.thank({ lang })); });
109
+ starting = null;
110
+ return k;
111
+ };
112
+ s.free = () => { if (starting) begin().then((x) => x && x.finish()); starting = null; return free(); };
113
+ };
114
+ // the moment to ask: after a scored session (the learner saw feedback)
115
+ const scored = ql.scored.bind(ql);
116
+ ql.scored = (tf, r) => { scored(tf, r); ctl.sessionCompleted({ feedback: true }).catch(() => {}); };
117
+ return ctl;
118
+ }
119
+
120
+ /**
121
+ * "Delete what I shared" (PLATFORM_CLIENT.md section 9.1): a challenge for this app and contributor, the
122
+ * exact message signed with the device key (ECDSA P-256, SHA-256, r||s), then the deletion request. No
123
+ * app key or token is sent to these routes (the server refuses them). 202 is the answer whatever the
124
+ * signature (no enumeration); the summary then confirms. Returns { accepted, sessionsLeft }.
125
+ */
126
+ export async function deleteWithKey(ql, key) {
127
+ const app = `${ql.platform}:${ql.bundleId}`;
128
+ const contributor = key.contributor;
129
+ const post = async (path, body) => {
130
+ const h = { 'content-type': 'application/json', ...(ql.origin ? { origin: ql.origin } : {}) };
131
+ const r = await ql.fetch(ql.apiUrl + path, { method: 'POST', headers: h, body: JSON.stringify(body) });
132
+ let j = {};
133
+ try { j = await r.json(); } catch (_) { /* none */ }
134
+ if (r.status >= 400) { const e = new Error(`${r.status} ${j.error || ''}`); e.status = r.status; e.code = j.error; throw e; }
135
+ return j;
136
+ };
137
+ let lastErr = null;
138
+ for (let attempt = 0; attempt < 2; attempt++) {
139
+ const { nonce } = await post('/v1/contributions/challenge', { app, contributor });
140
+ const timestamp = Math.floor(ql.clock() / 1000);
141
+ const sig = await key.sign(deletionMessage({ app, contributor, nonce, timestamp }));
142
+ try {
143
+ await post('/v1/contributions/delete', { app, contributor, nonce, timestamp, action: 'delete-all', signature: b64url(sig) });
144
+ const s = await ql.contributionSummary();
145
+ return { accepted: true, sessionsLeft: s && s.you ? s.you.sessions : null };
146
+ } catch (e) {
147
+ lastErr = e;
148
+ if (!(e.code === 'nonce_invalid' || e.code === 'timestamp_invalid')) throw e;
149
+ }
150
+ }
151
+ throw lastErr;
152
+ }
153
+
154
+ export { QuranLabConsent, QuranLabSharingSettings } from './element.js';
@@ -0,0 +1,100 @@
1
+ // The sharing state machine: when to ask,
2
+ // how often to re-ask, the guardian variant, settings, the thank-you. Plain JS, no DOM.
3
+
4
+ export const SHARING_STATE_KEY = 'sharing.v1';
5
+ const FRESH = { v: 1, consent: 'unknown', asks: 0, lastAskSession: 0, sessions: 0, thanked: false, decidedAt: null,
6
+ consentVersion: null, guardian: false };
7
+ /** Asks in all: the first and at most two re-asks. */
8
+ export const MAX_ASKS = 3;
9
+ /** Sessions between asks. */
10
+ export const REASK_GAP = 7;
11
+
12
+ export class SharingState {
13
+ /**
14
+ * @param {{ get(k): any, set(k, v): void }} state persistence (per origin in browsers)
15
+ * @param {{ enabled: () => boolean, consentVersion?: string, audience?: string, clock?: () => number }} o
16
+ */
17
+ constructor(state, o = {}) {
18
+ this.state = state;
19
+ this.enabledFn = o.enabled || (() => false);
20
+ this.consentVersion = o.consentVersion || null;
21
+ this.audience = o.audience || 'general';
22
+ this.clock = o.clock || Date.now;
23
+ this.listeners = new Set();
24
+ }
25
+ get s() { return { ...FRESH, ...(this.state.get(SHARING_STATE_KEY) || {}) }; }
26
+ _put(s) { this.state.set(SHARING_STATE_KEY, s); for (const f of this.listeners) f(s); }
27
+ onChange(f) { this.listeners.add(f); return () => this.listeners.delete(f); }
28
+
29
+ /** Uploads may run: enabled by the app and the signed config, and the learner said yes (for the
30
+ * current consent version). */
31
+ get sharing() {
32
+ const s = this.s;
33
+ return this.enabledFn() && s.consent === 'granted';
34
+ }
35
+ get guardianVariant() { return this.audience === 'children'; }
36
+
37
+ /**
38
+ * A session ended. Returns null, or the ask to show: { variant: 'first' | 'reask' | 'version',
39
+ * guardian: boolean }. The caller shows the sheet and calls answer().
40
+ * @param {{ feedback: boolean }} o feedback: the learner was shown the model's verdicts
41
+ */
42
+ sessionCompleted({ feedback = false } = {}) {
43
+ const s = this.s;
44
+ s.sessions += 1;
45
+ let ask = null;
46
+ if (this.enabledFn() && feedback) {
47
+ if (s.consent === 'unknown') ask = 'first';
48
+ else if (s.consent === 'declined' && s.asks < MAX_ASKS && s.sessions - s.lastAskSession >= REASK_GAP) ask = 'reask';
49
+ else if (s.consent === 'granted' && this.consentVersion && s.consentVersion && s.consentVersion !== this.consentVersion && !s.versionAsked) {
50
+ ask = 'version'; s.versionAsked = this.consentVersion;
51
+ }
52
+ }
53
+ if (ask) { s.asks += ask === 'version' ? 0 : 1; s.lastAskSession = s.sessions; }
54
+ this._put(s);
55
+ return ask ? { variant: ask, guardian: this.guardianVariant } : null;
56
+ }
57
+
58
+ /** The app asks now (its own timing); the disclosure lines stay the SDK's. */
59
+ askNow() { return { variant: this.s.consent === 'unknown' ? 'first' : 'reask', guardian: this.guardianVariant }; }
60
+
61
+ /**
62
+ * The learner answered the sheet. yes: share; no (or dismissed): not now (final after the last ask).
63
+ * guardianConfirmed must be true for a yes in the guardian variant.
64
+ */
65
+ answer(yes, { guardianConfirmed = false } = {}) {
66
+ const s = this.s;
67
+ if (yes) {
68
+ if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
69
+ Object.assign(s, { consent: 'granted', decidedAt: new Date(this.clock()).toISOString().slice(0, 10), consentVersion: this.consentVersion,
70
+ guardian: !!guardianConfirmed });
71
+ } else {
72
+ s.consent = s.asks >= MAX_ASKS ? 'final' : 'declined';
73
+ s.decidedAt = new Date(this.clock()).toISOString().slice(0, 10);
74
+ }
75
+ this._put(s);
76
+ return s.consent;
77
+ }
78
+
79
+ /** The settings switch. Off is final until the learner turns it on again here. */
80
+ setSharing(on, { guardianConfirmed = false } = {}) {
81
+ const s = this.s;
82
+ if (on) {
83
+ if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
84
+ Object.assign(s, { consent: 'granted', consentVersion: this.consentVersion, guardian: !!guardianConfirmed,
85
+ decidedAt: new Date(this.clock()).toISOString().slice(0, 10) });
86
+ } else {
87
+ s.consent = 'final';
88
+ }
89
+ this._put(s);
90
+ }
91
+
92
+ /** The thank-you is due (first shared session uploaded): returns true once. */
93
+ takeThanks() {
94
+ const s = this.s;
95
+ if (s.consent !== 'granted' || s.thanked) return false;
96
+ s.thanked = true;
97
+ this._put(s);
98
+ return true;
99
+ }
100
+ }
package/web/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Type definitions for @quran-lab/tajweedformer: the V4 TajweedFormer API (SDK 2.0.0).
2
+ export * from './tajweed/index.js';
3
+ import { TajweedFormer } from './tajweed/index.js';
4
+ export default TajweedFormer;
package/web/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @quran-lab/tajweedformer: TajweedFormer V4 for browsers and Node. The exact engine in WebAssembly
2
+ // (single thread, SIMD128; works everywhere with no special headers), the model delivery (shared
3
+ // cache, resumable, hashed), the decoded and tajweed layers, and the Quran Lab platform client.
4
+ // import { TajweedFormer } from '@quran-lab/tajweedformer';
5
+ import createTajweedFormer from '../dist/tajweedformer.mjs';
6
+ import { bindTajweed } from './tajweed/tajweedformer.js';
7
+ export * from './tajweed/index.js';
8
+
9
+ export const TajweedFormer = bindTajweed(createTajweedFormer);
10
+ export default TajweedFormer;