@longsightgroup/qti3-core 0.1.1 → 0.2.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.
package/src/tts.ts ADDED
@@ -0,0 +1,555 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiChoice,
4
+ QtiContentNode,
5
+ QtiDiagnostic,
6
+ QtiDocument,
7
+ QtiInteraction,
8
+ QtiSourceLocation,
9
+ } from "./types.js";
10
+
11
+ export type QtiDataSsmlBreakStrength =
12
+ | "medium"
13
+ | "none"
14
+ | "strong"
15
+ | "weak"
16
+ | "x-strong"
17
+ | "x-weak";
18
+
19
+ export interface QtiDataSsmlBreak {
20
+ strength?: QtiDataSsmlBreakStrength | undefined;
21
+ time?: string | undefined;
22
+ }
23
+
24
+ export interface QtiDataSsmlPhoneme {
25
+ ph: string;
26
+ alphabet?: "ipa" | "x-sampa" | undefined;
27
+ type?: "default" | "ruby" | undefined;
28
+ }
29
+
30
+ export interface QtiDataSsmlProsody {
31
+ rate?: string | undefined;
32
+ }
33
+
34
+ export interface QtiDataSsmlSayAs {
35
+ "interpret-as": "cardinal" | "characters" | "date" | "ordinal" | "telephone" | "time";
36
+ }
37
+
38
+ export interface QtiDataSsmlSub {
39
+ alias: string;
40
+ }
41
+
42
+ export interface QtiDataSsml {
43
+ break?: QtiDataSsmlBreak | undefined;
44
+ phoneme?: QtiDataSsmlPhoneme | undefined;
45
+ prosody?: QtiDataSsmlProsody | undefined;
46
+ "say-as"?: QtiDataSsmlSayAs | undefined;
47
+ sub?: QtiDataSsmlSub | undefined;
48
+ }
49
+
50
+ export type QtiDataSsmlParseResult =
51
+ | { ok: true; value: QtiDataSsml }
52
+ | { ok: false; errors: string[] };
53
+
54
+ export type QtiTextToSpeechSegmentKind =
55
+ | "text"
56
+ | "content"
57
+ | "interaction"
58
+ | "interactionPrompt"
59
+ | "choice"
60
+ | "printedVariable"
61
+ | "feedback";
62
+
63
+ export interface QtiTextToSpeechSegment {
64
+ index: number;
65
+ kind: QtiTextToSpeechSegmentKind;
66
+ text: string;
67
+ attributes: Record<string, string>;
68
+ qtiName?: string | undefined;
69
+ responseIdentifier?: string | undefined;
70
+ identifier?: string | undefined;
71
+ interactionIndex?: number | undefined;
72
+ choiceIdentifier?: string | undefined;
73
+ dataSsml?: string | undefined;
74
+ ssml?: QtiDataSsml | undefined;
75
+ ssmlErrors?: string[] | undefined;
76
+ suppressTts?: string[] | undefined;
77
+ source?: QtiSourceLocation | undefined;
78
+ }
79
+
80
+ export interface QtiTextToSpeechTraversal {
81
+ itemIdentifier: string;
82
+ language?: string | undefined;
83
+ segments: QtiTextToSpeechSegment[];
84
+ diagnostics: QtiDiagnostic[];
85
+ }
86
+
87
+ interface TraversalContext {
88
+ item: QtiAssessmentItem;
89
+ segments: QtiTextToSpeechSegment[];
90
+ diagnostics: QtiDiagnostic[];
91
+ }
92
+
93
+ interface SegmentInput {
94
+ kind: QtiTextToSpeechSegmentKind;
95
+ text: string;
96
+ attributes?: Record<string, string> | undefined;
97
+ qtiName?: string | undefined;
98
+ responseIdentifier?: string | undefined;
99
+ identifier?: string | undefined;
100
+ interactionIndex?: number | undefined;
101
+ choiceIdentifier?: string | undefined;
102
+ source?: QtiSourceLocation | undefined;
103
+ }
104
+
105
+ const dataSsmlFunctionNames = new Set(["break", "phoneme", "prosody", "say-as", "sub"]);
106
+ const breakStrengths = new Set(["medium", "none", "strong", "weak", "x-strong", "x-weak"]);
107
+ const phonemeAlphabets = new Set(["ipa", "x-sampa"]);
108
+ const phonemeTypes = new Set(["default", "ruby"]);
109
+ const sayAsInterpretations = new Set([
110
+ "cardinal",
111
+ "characters",
112
+ "date",
113
+ "ordinal",
114
+ "telephone",
115
+ "time",
116
+ ]);
117
+
118
+ export function parseQtiDataSsml(raw: string): QtiDataSsmlParseResult {
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : "Unable to parse JSON.";
124
+ return { ok: false, errors: [`data-ssml must be valid JSON: ${message}`] };
125
+ }
126
+
127
+ if (!isRecord(parsed)) {
128
+ return { ok: false, errors: ["data-ssml must be a JSON object."] };
129
+ }
130
+
131
+ const errors: string[] = [];
132
+ const value: QtiDataSsml = {};
133
+ const entries = Object.entries(parsed);
134
+ if (entries.length === 0) {
135
+ errors.push("data-ssml must include at least one Data-SSML function.");
136
+ }
137
+
138
+ for (const [name, functionValue] of entries) {
139
+ if (!dataSsmlFunctionNames.has(name)) {
140
+ errors.push(`Unsupported Data-SSML function "${name}".`);
141
+ continue;
142
+ }
143
+ if (!isRecord(functionValue)) {
144
+ errors.push(`Data-SSML function "${name}" must be a JSON object.`);
145
+ continue;
146
+ }
147
+
148
+ const before = errors.length;
149
+ if (name === "break") {
150
+ const breakValue = validateBreak(functionValue, errors);
151
+ if (errors.length === before) value["break"] = breakValue;
152
+ } else if (name === "phoneme") {
153
+ const phoneme = validatePhoneme(functionValue, errors);
154
+ if (phoneme && errors.length === before) value.phoneme = phoneme;
155
+ } else if (name === "prosody") {
156
+ const prosody = validateProsody(functionValue, errors);
157
+ if (errors.length === before) value.prosody = prosody;
158
+ } else if (name === "say-as") {
159
+ const sayAs = validateSayAs(functionValue, errors);
160
+ if (sayAs && errors.length === before) value["say-as"] = sayAs;
161
+ } else if (name === "sub") {
162
+ const sub = validateSub(functionValue, errors);
163
+ if (sub && errors.length === before) value.sub = sub;
164
+ }
165
+ }
166
+
167
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, value };
168
+ }
169
+
170
+ export function createTextToSpeechTraversal(
171
+ model: QtiDocument | QtiAssessmentItem,
172
+ ): QtiTextToSpeechTraversal {
173
+ const item = "item" in model ? model.item : model;
174
+ const context: TraversalContext = {
175
+ item,
176
+ segments: [],
177
+ diagnostics: [],
178
+ };
179
+ if (item.body.length > 0) {
180
+ traverseContentNodes(item.body, context);
181
+ } else {
182
+ item.interactions.forEach((interaction, index) =>
183
+ traverseInteraction(interaction, index, context),
184
+ );
185
+ }
186
+ return {
187
+ itemIdentifier: item.identifier,
188
+ language: item.language,
189
+ segments: context.segments,
190
+ diagnostics: context.diagnostics,
191
+ };
192
+ }
193
+
194
+ export function validateQtiDataSsmlMetadata(item: QtiAssessmentItem): QtiDiagnostic[] {
195
+ return createTextToSpeechTraversal(item).diagnostics;
196
+ }
197
+
198
+ function validateBreak(value: Record<string, unknown>, errors: string[]): QtiDataSsmlBreak {
199
+ validateAllowedProperties(value, "break", ["strength", "time"], errors);
200
+ const result: QtiDataSsmlBreak = {};
201
+ const strength = optionalEnum(value, "strength", "break.strength", breakStrengths, errors);
202
+ const time = optionalString(value, "time", "break.time", errors);
203
+ if (strength) result.strength = strength as QtiDataSsmlBreakStrength;
204
+ if (time !== undefined) result.time = time;
205
+ return result;
206
+ }
207
+
208
+ function validatePhoneme(
209
+ value: Record<string, unknown>,
210
+ errors: string[],
211
+ ): QtiDataSsmlPhoneme | undefined {
212
+ validateAllowedProperties(value, "phoneme", ["alphabet", "ph", "type"], errors);
213
+ const ph = requiredString(value, "ph", "phoneme.ph", errors);
214
+ const alphabet = optionalEnum(value, "alphabet", "phoneme.alphabet", phonemeAlphabets, errors);
215
+ const type = optionalEnum(value, "type", "phoneme.type", phonemeTypes, errors);
216
+ if (!ph) return undefined;
217
+ const result: QtiDataSsmlPhoneme = { ph };
218
+ if (alphabet) result.alphabet = alphabet as QtiDataSsmlPhoneme["alphabet"];
219
+ if (type) result.type = type as QtiDataSsmlPhoneme["type"];
220
+ return result;
221
+ }
222
+
223
+ function validateProsody(value: Record<string, unknown>, errors: string[]): QtiDataSsmlProsody {
224
+ validateAllowedProperties(value, "prosody", ["rate"], errors);
225
+ const result: QtiDataSsmlProsody = {};
226
+ const rate = optionalString(value, "rate", "prosody.rate", errors);
227
+ if (rate !== undefined) result.rate = rate;
228
+ return result;
229
+ }
230
+
231
+ function validateSayAs(
232
+ value: Record<string, unknown>,
233
+ errors: string[],
234
+ ): QtiDataSsmlSayAs | undefined {
235
+ validateAllowedProperties(value, "say-as", ["interpret-as"], errors);
236
+ const interpretation = requiredEnum(
237
+ value,
238
+ "interpret-as",
239
+ "say-as.interpret-as",
240
+ sayAsInterpretations,
241
+ errors,
242
+ );
243
+ if (!interpretation) return undefined;
244
+ return { "interpret-as": interpretation as QtiDataSsmlSayAs["interpret-as"] };
245
+ }
246
+
247
+ function validateSub(value: Record<string, unknown>, errors: string[]): QtiDataSsmlSub | undefined {
248
+ validateAllowedProperties(value, "sub", ["alias"], errors);
249
+ const alias = requiredString(value, "alias", "sub.alias", errors);
250
+ return alias ? { alias } : undefined;
251
+ }
252
+
253
+ function traverseContentNodes(nodes: QtiContentNode[], context: TraversalContext): void {
254
+ for (const node of nodes) {
255
+ traverseContentNode(node, context);
256
+ }
257
+ }
258
+
259
+ function traverseContentNode(node: QtiContentNode, context: TraversalContext): void {
260
+ if (node.kind === "text") {
261
+ addTextSegment(node.text, node.source, context);
262
+ return;
263
+ }
264
+
265
+ if (node.kind === "interaction") {
266
+ const interaction = context.item.interactions[node.interactionIndex];
267
+ if (interaction) traverseInteraction(interaction, node.interactionIndex, context);
268
+ return;
269
+ }
270
+
271
+ if (node.kind === "printedVariable") {
272
+ addSegment(
273
+ {
274
+ kind: "printedVariable",
275
+ text: "",
276
+ attributes: node.attributes,
277
+ qtiName: "qti-printed-variable",
278
+ identifier: node.identifier,
279
+ source: node.source,
280
+ },
281
+ context,
282
+ );
283
+ return;
284
+ }
285
+
286
+ if (node.kind === "feedback") {
287
+ if (hasTtsMetadata(node.attributes)) {
288
+ addSegment(
289
+ {
290
+ kind: "feedback",
291
+ text: normalizeSpeechText(contentNodeChildrenText(node.children)),
292
+ attributes: node.attributes,
293
+ qtiName: node.feedbackType === "block" ? "qti-feedback-block" : "qti-feedback-inline",
294
+ identifier: node.identifier,
295
+ source: node.source,
296
+ },
297
+ context,
298
+ );
299
+ return;
300
+ }
301
+ traverseContentNodes(node.children, context);
302
+ return;
303
+ }
304
+
305
+ if (hasTtsMetadata(node.attributes)) {
306
+ addSegment(
307
+ {
308
+ kind: "content",
309
+ text: normalizeSpeechText(contentNodeChildrenText(node.children)),
310
+ attributes: node.attributes,
311
+ qtiName: node.qtiName,
312
+ source: node.source,
313
+ },
314
+ context,
315
+ );
316
+ return;
317
+ }
318
+
319
+ traverseContentNodes(node.children, context);
320
+ }
321
+
322
+ function traverseInteraction(
323
+ interaction: QtiInteraction,
324
+ interactionIndex: number,
325
+ context: TraversalContext,
326
+ ): void {
327
+ if (hasTtsMetadata(interaction.attributes)) {
328
+ addSegment(
329
+ {
330
+ kind: "interaction",
331
+ text: normalizeSpeechText(interaction.prompt ?? interaction.text),
332
+ attributes: interaction.attributes,
333
+ qtiName: interaction.qtiName,
334
+ responseIdentifier: interaction.responseIdentifier,
335
+ interactionIndex,
336
+ source: interaction.source,
337
+ },
338
+ context,
339
+ );
340
+ }
341
+
342
+ const promptText = normalizeSpeechText(interaction.prompt ?? "");
343
+ const promptAttributes = interaction.promptAttributes ?? {};
344
+ if (promptText.length > 0 || hasTtsMetadata(promptAttributes)) {
345
+ addSegment(
346
+ {
347
+ kind: "interactionPrompt",
348
+ text: promptText,
349
+ attributes: promptAttributes,
350
+ qtiName: "qti-prompt",
351
+ responseIdentifier: interaction.responseIdentifier,
352
+ interactionIndex,
353
+ source: interaction.promptSource ?? interaction.source,
354
+ },
355
+ context,
356
+ );
357
+ }
358
+
359
+ for (const choice of interaction.choices) {
360
+ addChoiceSegment(choice, interaction, interactionIndex, context);
361
+ }
362
+
363
+ if (interaction.portableCustom) {
364
+ traverseContentNodes(interaction.portableCustom.interactionMarkup, context);
365
+ }
366
+ }
367
+
368
+ function addChoiceSegment(
369
+ choice: QtiChoice,
370
+ interaction: QtiInteraction,
371
+ interactionIndex: number,
372
+ context: TraversalContext,
373
+ ): void {
374
+ const text = normalizeSpeechText(choice.text);
375
+ if (text.length === 0 && !hasTtsMetadata(choice.attributes)) return;
376
+ addSegment(
377
+ {
378
+ kind: "choice",
379
+ text,
380
+ attributes: choice.attributes,
381
+ qtiName: choice.qtiName,
382
+ responseIdentifier: interaction.responseIdentifier,
383
+ identifier: choice.identifier,
384
+ interactionIndex,
385
+ choiceIdentifier: choice.identifier,
386
+ source: choice.source,
387
+ },
388
+ context,
389
+ );
390
+ }
391
+
392
+ function addTextSegment(
393
+ text: string,
394
+ source: QtiSourceLocation | undefined,
395
+ context: TraversalContext,
396
+ ): void {
397
+ const normalized = normalizeSpeechText(text);
398
+ if (normalized.length === 0) return;
399
+ addSegment({ kind: "text", text: normalized, attributes: {}, source }, context);
400
+ }
401
+
402
+ function addSegment(input: SegmentInput, context: TraversalContext): void {
403
+ const attributes = input.attributes ?? {};
404
+ const segment: QtiTextToSpeechSegment = {
405
+ index: context.segments.length,
406
+ kind: input.kind,
407
+ text: input.text,
408
+ attributes: { ...attributes },
409
+ };
410
+ if (input.qtiName) segment.qtiName = input.qtiName;
411
+ if (input.responseIdentifier) segment.responseIdentifier = input.responseIdentifier;
412
+ if (input.identifier) segment.identifier = input.identifier;
413
+ if (input.interactionIndex !== undefined) segment.interactionIndex = input.interactionIndex;
414
+ if (input.choiceIdentifier) segment.choiceIdentifier = input.choiceIdentifier;
415
+ if (input.source) segment.source = input.source;
416
+
417
+ const suppressTts = ttsSuppressionModes(attributes);
418
+ if (suppressTts.length > 0) segment.suppressTts = suppressTts;
419
+
420
+ const rawDataSsml = attributeValue(attributes, "data-ssml");
421
+ if (rawDataSsml !== undefined) {
422
+ segment.dataSsml = rawDataSsml;
423
+ const parsed = parseQtiDataSsml(rawDataSsml);
424
+ if (parsed.ok) {
425
+ segment.ssml = parsed.value;
426
+ } else {
427
+ segment.ssmlErrors = parsed.errors;
428
+ context.diagnostics.push({
429
+ code: "content.dataSsml.invalid",
430
+ severity: "warning",
431
+ message: `data-ssml must be valid Data-SSML JSON: ${parsed.errors.join("; ")}`,
432
+ path: input.source?.path,
433
+ source: input.source,
434
+ });
435
+ }
436
+ }
437
+
438
+ context.segments.push(segment);
439
+ }
440
+
441
+ function hasTtsMetadata(attributes: Record<string, string>): boolean {
442
+ return (
443
+ attributeValue(attributes, "data-ssml") !== undefined ||
444
+ attributeValue(attributes, "data-qti-suppress-tts") !== undefined
445
+ );
446
+ }
447
+
448
+ function ttsSuppressionModes(attributes: Record<string, string>): string[] {
449
+ const raw = attributeValue(attributes, "data-qti-suppress-tts");
450
+ if (!raw) return [];
451
+ return raw
452
+ .toLowerCase()
453
+ .split(/[\s,]+/)
454
+ .filter(Boolean);
455
+ }
456
+
457
+ function contentNodeChildrenText(nodes: QtiContentNode[]): string {
458
+ return nodes.map(contentNodeText).join(" ");
459
+ }
460
+
461
+ function contentNodeText(node: QtiContentNode): string {
462
+ if (node.kind === "text") return node.text;
463
+ if (node.kind === "interaction") return "";
464
+ if (node.kind === "printedVariable") return "";
465
+ if (node.kind === "feedback") return contentNodeChildrenText(node.children);
466
+ return contentNodeChildrenText(node.children);
467
+ }
468
+
469
+ function normalizeSpeechText(value: string): string {
470
+ return value.replace(/\s+/g, " ").trim();
471
+ }
472
+
473
+ function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
474
+ const normalizedName = name.toLowerCase();
475
+ const entry = Object.entries(attributes).find(
476
+ ([attributeName]) => attributeName.toLowerCase() === normalizedName,
477
+ );
478
+ return entry?.[1];
479
+ }
480
+
481
+ function validateAllowedProperties(
482
+ value: Record<string, unknown>,
483
+ path: string,
484
+ allowed: string[],
485
+ errors: string[],
486
+ ): void {
487
+ const allowedNames = new Set(allowed);
488
+ for (const name of Object.keys(value)) {
489
+ if (!allowedNames.has(name)) {
490
+ errors.push(`${path}.${name} is not a supported Data-SSML property.`);
491
+ }
492
+ }
493
+ }
494
+
495
+ function optionalString(
496
+ value: Record<string, unknown>,
497
+ name: string,
498
+ path: string,
499
+ errors: string[],
500
+ ): string | undefined {
501
+ if (!Object.hasOwn(value, name)) return undefined;
502
+ const property = value[name];
503
+ if (typeof property !== "string") {
504
+ errors.push(`${path} must be a string.`);
505
+ return undefined;
506
+ }
507
+ return property;
508
+ }
509
+
510
+ function requiredString(
511
+ value: Record<string, unknown>,
512
+ name: string,
513
+ path: string,
514
+ errors: string[],
515
+ ): string | undefined {
516
+ if (!Object.hasOwn(value, name)) {
517
+ errors.push(`${path} is required.`);
518
+ return undefined;
519
+ }
520
+ return optionalString(value, name, path, errors);
521
+ }
522
+
523
+ function optionalEnum(
524
+ value: Record<string, unknown>,
525
+ name: string,
526
+ path: string,
527
+ allowed: Set<string>,
528
+ errors: string[],
529
+ ): string | undefined {
530
+ const property = optionalString(value, name, path, errors);
531
+ if (property === undefined) return undefined;
532
+ if (!allowed.has(property)) {
533
+ errors.push(`${path} has unsupported value "${property}".`);
534
+ return undefined;
535
+ }
536
+ return property;
537
+ }
538
+
539
+ function requiredEnum(
540
+ value: Record<string, unknown>,
541
+ name: string,
542
+ path: string,
543
+ allowed: Set<string>,
544
+ errors: string[],
545
+ ): string | undefined {
546
+ if (!Object.hasOwn(value, name)) {
547
+ errors.push(`${path} is required.`);
548
+ return undefined;
549
+ }
550
+ return optionalEnum(value, name, path, allowed, errors);
551
+ }
552
+
553
+ function isRecord(value: unknown): value is Record<string, unknown> {
554
+ return typeof value === "object" && value !== null && !Array.isArray(value);
555
+ }