@patterkit/core 1.0.0 → 1.1.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/dist/index.cjs CHANGED
@@ -20,20 +20,34 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ GENERATED_STAMP: () => GENERATED_STAMP,
24
+ PLACEHOLDER_ACCENTS: () => PLACEHOLDER_ACCENTS,
25
+ TEXT_STAMP: () => TEXT_STAMP,
26
+ accentForLanguage: () => accentForLanguage,
23
27
  canonicalStringify: () => canonicalStringify,
28
+ defaultPlaceholderVoice: () => defaultPlaceholderVoice,
29
+ describePlaceholderVoice: () => describePlaceholderVoice,
24
30
  effectiveGameId: () => import_model5.effectiveGameId,
25
31
  gameIdify: () => import_model5.gameIdify,
26
32
  hash32: () => hash32,
27
33
  hash4: () => hash4,
28
34
  humanizeNodeRefs: () => humanizeNodeRefs,
35
+ infoListChunk: () => infoListChunk,
29
36
  isValidGameId: () => import_model5.isValidGameId,
37
+ isWav: () => isWav,
30
38
  neverRunNodes: () => neverRunNodes,
31
39
  newId: () => import_toolkit.newId,
32
- parseSource: () => import_toolkit3.parseSource,
40
+ parseSource: () => import_toolkit4.parseSource,
41
+ placeholderAccent: () => placeholderAccent,
33
42
  planProject: () => planProject,
34
43
  planScene: () => planScene,
44
+ readGeneratedStamp: () => readGeneratedStamp,
45
+ readInfoChunks: () => readInfoChunks,
46
+ readTextStamp: () => readTextStamp,
35
47
  slug: () => slug,
36
- validateProject: () => validateProject
48
+ textHash: () => textHash,
49
+ validateProject: () => validateProject,
50
+ withStamps: () => withStamps
37
51
  });
38
52
  module.exports = __toCommonJS(index_exports);
39
53
 
@@ -62,26 +76,242 @@ function humanizeNodeRefs(cond, label) {
62
76
  return cond.replace(VISIT_FN_RE, (_m, fn, _q, quoted, bare) => `${fn}(${label(quoted ?? bare ?? "")})`);
63
77
  }
64
78
 
79
+ // src/placeholder-voices.ts
80
+ var import_toolkit3 = require("@wildwinter/toolkit");
81
+ var v = (name, gender) => ({ name, gender });
82
+ var PLACEHOLDER_ACCENTS = [
83
+ { code: "en-GB", label: "English (British)", voices: [
84
+ v("en-GB-Standard-A", "female"),
85
+ v("en-GB-Standard-B", "male"),
86
+ v("en-GB-Standard-C", "female"),
87
+ v("en-GB-Standard-D", "male"),
88
+ v("en-GB-Standard-F", "female")
89
+ ] },
90
+ { code: "en-US", label: "English (American)", voices: [
91
+ v("en-US-Standard-A", "male"),
92
+ v("en-US-Standard-B", "male"),
93
+ v("en-US-Standard-C", "female"),
94
+ v("en-US-Standard-D", "male"),
95
+ v("en-US-Standard-E", "female"),
96
+ v("en-US-Standard-F", "female"),
97
+ v("en-US-Standard-G", "female"),
98
+ v("en-US-Standard-H", "female"),
99
+ v("en-US-Standard-I", "male"),
100
+ v("en-US-Standard-J", "male")
101
+ ] },
102
+ { code: "en-AU", label: "English (Australian)", voices: [
103
+ v("en-AU-Standard-A", "female"),
104
+ v("en-AU-Standard-B", "male"),
105
+ v("en-AU-Standard-C", "female"),
106
+ v("en-AU-Standard-D", "male")
107
+ ] },
108
+ { code: "en-IN", label: "English (Indian)", voices: [
109
+ v("en-IN-Standard-A", "female"),
110
+ v("en-IN-Standard-B", "male"),
111
+ v("en-IN-Standard-C", "male"),
112
+ v("en-IN-Standard-D", "female")
113
+ ] },
114
+ { code: "de-DE", label: "German", voices: [
115
+ v("de-DE-Standard-A", "female"),
116
+ v("de-DE-Standard-B", "male"),
117
+ v("de-DE-Standard-C", "female"),
118
+ v("de-DE-Standard-D", "male"),
119
+ v("de-DE-Standard-E", "male"),
120
+ v("de-DE-Standard-F", "female")
121
+ ] },
122
+ { code: "fr-FR", label: "French", voices: [
123
+ v("fr-FR-Standard-A", "female"),
124
+ v("fr-FR-Standard-B", "male"),
125
+ v("fr-FR-Standard-C", "female"),
126
+ v("fr-FR-Standard-D", "male"),
127
+ v("fr-FR-Standard-E", "female")
128
+ ] },
129
+ { code: "es-ES", label: "Spanish (Spain)", voices: [
130
+ v("es-ES-Standard-A", "female"),
131
+ v("es-ES-Standard-B", "male"),
132
+ v("es-ES-Standard-C", "female"),
133
+ v("es-ES-Standard-D", "female")
134
+ ] },
135
+ { code: "it-IT", label: "Italian", voices: [
136
+ v("it-IT-Standard-A", "female"),
137
+ v("it-IT-Standard-B", "female"),
138
+ v("it-IT-Standard-C", "male"),
139
+ v("it-IT-Standard-D", "male")
140
+ ] },
141
+ { code: "sv-SE", label: "Swedish", voices: [
142
+ v("sv-SE-Standard-A", "female"),
143
+ v("sv-SE-Standard-B", "female"),
144
+ v("sv-SE-Standard-C", "female"),
145
+ v("sv-SE-Standard-D", "male"),
146
+ v("sv-SE-Standard-E", "male")
147
+ ] },
148
+ { code: "ja-JP", label: "Japanese", voices: [
149
+ v("ja-JP-Standard-A", "female"),
150
+ v("ja-JP-Standard-B", "female"),
151
+ v("ja-JP-Standard-C", "male"),
152
+ v("ja-JP-Standard-D", "male")
153
+ ] }
154
+ ];
155
+ function placeholderAccent(code) {
156
+ const want = code?.replace(/_/g, "-").toLowerCase();
157
+ return want === void 0 ? void 0 : PLACEHOLDER_ACCENTS.find((a) => a.code.toLowerCase() === want);
158
+ }
159
+ function accentForLanguage(locale) {
160
+ if (!locale) return void 0;
161
+ const exact = placeholderAccent(locale);
162
+ if (exact) return exact.code;
163
+ const lang = locale.split(/[-_]/)[0].toLowerCase();
164
+ const same = PLACEHOLDER_ACCENTS.filter((a) => a.code.split("-")[0].toLowerCase() === lang);
165
+ return same[0]?.code;
166
+ }
167
+ function defaultPlaceholderVoice(name, accent, gender) {
168
+ const voices = placeholderAccent(accent)?.voices;
169
+ if (!voices?.length) return void 0;
170
+ const h = (0, import_toolkit3.fnv32)(name);
171
+ const pick = voices[h % voices.length];
172
+ const want = gender?.trim().toLowerCase();
173
+ if (want !== "male" && want !== "female" || pick.gender === want) return pick.name;
174
+ const fitting = voices.filter((x) => x.gender === want);
175
+ return fitting.length ? fitting[h % fitting.length].name : pick.name;
176
+ }
177
+ function describePlaceholderVoice(voice) {
178
+ for (const a of PLACEHOLDER_ACCENTS) {
179
+ const sameGender = (g) => a.voices.filter((x) => x.gender === g);
180
+ const found = a.voices.find((x) => x.name === voice);
181
+ if (found) {
182
+ const n = sameGender(found.gender).indexOf(found) + 1;
183
+ return `${found.gender === "female" ? "Female" : "Male"}, ${a.label} ${n}`;
184
+ }
185
+ }
186
+ return voice;
187
+ }
188
+
189
+ // src/take-stamp.ts
190
+ var TEXT_STAMP = "PTRK";
191
+ var TEXT_STAMP_OLD = "DINK";
192
+ var GENERATED_STAMP = "PTPS";
193
+ var GENERATED_STAMP_OTHER = "PSVT";
194
+ function normalizeForHash(input) {
195
+ return input.replace(/<\/?(?:b|i|bi)>/g, "").replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
196
+ }
197
+ function textHash(input) {
198
+ const text = normalizeForHash(input);
199
+ if (!text) return "000000";
200
+ let hash = 2166136261;
201
+ for (let i = 0; i < text.length; i++) {
202
+ hash ^= text.charCodeAt(i);
203
+ hash = Math.imul(hash, 16777619);
204
+ }
205
+ const bytes = [hash & 255, hash >>> 8 & 255, hash >>> 16 & 255, hash >>> 24 & 255];
206
+ return btoa(String.fromCharCode(...bytes)).slice(0, 6);
207
+ }
208
+ var ascii = (bytes, at, n) => {
209
+ let s = "";
210
+ for (let i = 0; i < n && at + i < bytes.length; i++) s += String.fromCharCode(bytes[at + i]);
211
+ return s;
212
+ };
213
+ var u32 = (bytes, at) => (bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16 | bytes[at + 3] << 24) >>> 0;
214
+ function isWav(bytes) {
215
+ return bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE";
216
+ }
217
+ function readInfoChunks(bytes) {
218
+ const out = /* @__PURE__ */ new Map();
219
+ if (!isWav(bytes)) return out;
220
+ let pos = 12;
221
+ while (pos + 8 <= bytes.length) {
222
+ const id = ascii(bytes, pos, 4);
223
+ const size = u32(bytes, pos + 4);
224
+ const body = pos + 8;
225
+ if (id === "LIST" && ascii(bytes, body, 4) === "INFO") {
226
+ const end = Math.min(body + size, bytes.length);
227
+ let sub = body + 4;
228
+ while (sub + 8 <= end) {
229
+ const subId = ascii(bytes, sub, 4);
230
+ const subSize = u32(bytes, sub + 4);
231
+ out.set(subId, ascii(bytes, sub + 8, subSize).replace(/\0+$/, ""));
232
+ sub += 8 + subSize + subSize % 2;
233
+ }
234
+ }
235
+ pos = body + size + size % 2;
236
+ }
237
+ return out;
238
+ }
239
+ function readTextStamp(bytes) {
240
+ const info = readInfoChunks(bytes);
241
+ return info.get(TEXT_STAMP) ?? info.get(TEXT_STAMP_OLD) ?? null;
242
+ }
243
+ function readGeneratedStamp(bytes) {
244
+ const info = readInfoChunks(bytes);
245
+ return info.get(GENERATED_STAMP) ?? info.get(GENERATED_STAMP_OTHER) ?? null;
246
+ }
247
+ function infoListChunk(stamps) {
248
+ const parts = stamps.map(([id, value]) => {
249
+ const data = new TextEncoder().encode(value + "\0");
250
+ const padded = data.length + data.length % 2;
251
+ const sub = new Uint8Array(8 + padded);
252
+ for (let i = 0; i < 4; i++) sub[i] = id.charCodeAt(i);
253
+ new DataView(sub.buffer).setUint32(4, data.length, true);
254
+ sub.set(data, 8);
255
+ return sub;
256
+ });
257
+ const content = 4 + parts.reduce((n, p) => n + p.length, 0);
258
+ const out = new Uint8Array(8 + content);
259
+ const view = new DataView(out.buffer);
260
+ out.set([76, 73, 83, 84], 0);
261
+ view.setUint32(4, content, true);
262
+ out.set([73, 78, 70, 79], 8);
263
+ let at = 12;
264
+ for (const p of parts) {
265
+ out.set(p, at);
266
+ at += p.length;
267
+ }
268
+ return out;
269
+ }
270
+ function withStamps(wav, stamps) {
271
+ if (!isWav(wav)) throw new Error("not a WAV file");
272
+ const kept = [];
273
+ let pos = 12;
274
+ while (pos + 8 <= wav.length) {
275
+ const id = ascii(wav, pos, 4);
276
+ const size = u32(wav, pos + 4);
277
+ const next = Math.min(pos + 8 + size + size % 2, wav.length);
278
+ if (!(id === "LIST" && ascii(wav, pos + 8, 4) === "INFO")) kept.push(wav.subarray(pos, next));
279
+ pos = next;
280
+ }
281
+ const list = infoListChunk(stamps);
282
+ const total = 12 + kept.reduce((n, c) => n + c.length, 0) + list.length;
283
+ const out = new Uint8Array(total);
284
+ out.set(wav.subarray(0, 12), 0);
285
+ let at = 12;
286
+ for (const c of kept) {
287
+ out.set(c, at);
288
+ at += c.length;
289
+ }
290
+ out.set(list, at);
291
+ new DataView(out.buffer).setUint32(4, total - 8, true);
292
+ return out;
293
+ }
294
+
65
295
  // src/serialize.ts
66
296
  var import_json5 = require("json5");
67
- var import_toolkit3 = require("@wildwinter/toolkit");
297
+ var import_toolkit4 = require("@wildwinter/toolkit");
68
298
  function canonicalStringify(value, opts) {
69
299
  return write(value, "", opts?.trailingComma ?? true) + "\n";
70
300
  }
71
- function write(v, indent, tc) {
72
- if (v === null || typeof v === "boolean" || typeof v === "number") return JSON.stringify(v);
73
- if (typeof v === "string") return JSON.stringify(v);
301
+ function write(v2, indent, tc) {
302
+ if (v2 === null || typeof v2 === "boolean" || typeof v2 === "number") return JSON.stringify(v2);
303
+ if (typeof v2 === "string") return JSON.stringify(v2);
74
304
  const tail = tc ? "," : "";
75
- if (Array.isArray(v)) {
76
- if (v.length === 0) return "[]";
305
+ if (Array.isArray(v2)) {
306
+ if (v2.length === 0) return "[]";
77
307
  const next = indent + " ";
78
- const items = v.map((x) => next + write(x, next, tc));
308
+ const items = v2.map((x) => next + write(x, next, tc));
79
309
  return `[
80
310
  ${items.join(",\n")}${tail}
81
311
  ${indent}]`;
82
312
  }
83
- if (typeof v === "object") {
84
- const obj = v;
313
+ if (typeof v2 === "object") {
314
+ const obj = v2;
85
315
  const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
86
316
  if (keys.length === 0) return "{}";
87
317
  const next = indent + " ";
@@ -260,7 +490,7 @@ function contentlessJumpCycles(scenes) {
260
490
  code: "jump-cycle",
261
491
  severity: "warning",
262
492
  id: via[0],
263
- message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v) => `'${v}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
493
+ message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v2) => `'${v2}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
264
494
  });
265
495
  }
266
496
  for (const b of path) settled.add(b);
@@ -936,18 +1166,32 @@ function planScene(target, scaffold) {
936
1166
  }
937
1167
  // Annotate the CommonJS export names for ESM import in node:
938
1168
  0 && (module.exports = {
1169
+ GENERATED_STAMP,
1170
+ PLACEHOLDER_ACCENTS,
1171
+ TEXT_STAMP,
1172
+ accentForLanguage,
939
1173
  canonicalStringify,
1174
+ defaultPlaceholderVoice,
1175
+ describePlaceholderVoice,
940
1176
  effectiveGameId,
941
1177
  gameIdify,
942
1178
  hash32,
943
1179
  hash4,
944
1180
  humanizeNodeRefs,
1181
+ infoListChunk,
945
1182
  isValidGameId,
1183
+ isWav,
946
1184
  neverRunNodes,
947
1185
  newId,
948
1186
  parseSource,
1187
+ placeholderAccent,
949
1188
  planProject,
950
1189
  planScene,
1190
+ readGeneratedStamp,
1191
+ readInfoChunks,
1192
+ readTextStamp,
951
1193
  slug,
952
- validateProject
1194
+ textHash,
1195
+ validateProject,
1196
+ withStamps
953
1197
  });
package/dist/index.d.cts CHANGED
@@ -16,6 +16,57 @@ declare function hash32(input: string): string;
16
16
  */
17
17
  declare function humanizeNodeRefs(cond: string, label: (id: string) => string): string;
18
18
 
19
+ type VoiceGender = "female" | "male";
20
+ interface PlaceholderVoice {
21
+ name: string;
22
+ gender: VoiceGender;
23
+ }
24
+ interface PlaceholderAccent {
25
+ code: string;
26
+ label: string;
27
+ voices: readonly PlaceholderVoice[];
28
+ }
29
+ /** The accents on offer, in the order the settings page lists them. */
30
+ declare const PLACEHOLDER_ACCENTS: readonly PlaceholderAccent[];
31
+ /** The accent for a code, or undefined when it isn't one on offer. */
32
+ declare function placeholderAccent(code: string | undefined): PlaceholderAccent | undefined;
33
+ /** The accent a project's main language implies: an exact match (`en-GB`), else the first accent on offer for
34
+ * that language, so a bare `en` is British English (Ian, 2026-11-10), changeable in settings. Undefined for a
35
+ * language with no accent on offer. */
36
+ declare function accentForLanguage(locale: string | undefined): string | undefined;
37
+ /** The voice a character gets with none chosen: a hash of their name picks from the accent's voices; when
38
+ * their gender is male or female (any case) and that pick is the other, the pick moves within that gender's
39
+ * voices. Characters whose pick already fits keep it, so setting a gender changes only the voices that were
40
+ * wrong. Undefined when the accent isn't one on offer. */
41
+ declare function defaultPlaceholderVoice(name: string, accent: string | undefined, gender?: string): string | undefined;
42
+ /** A voice's name in words, for the Cast editor: "Female, English (British) 2". Falls back to the raw name. */
43
+ declare function describePlaceholderVoice(voice: string): string;
44
+
45
+ /** The text-hash stamp's chunk id, and the older name it's still read under. */
46
+ declare const TEXT_STAMP = "PTRK";
47
+ /** Placeholder Speech's request-hash stamp, and Patterstage's name for the same thing. */
48
+ declare const GENERATED_STAMP = "PTPS";
49
+ /** FNV-1a (32-bit) of the NORMALISED line text -> 4 little-endian bytes -> base64 -> first 6 chars.
50
+ * Empty (after normalising) -> "000000". */
51
+ declare function textHash(input: string): string;
52
+ /** Whether the bytes start as a RIFF/WAVE file. */
53
+ declare function isWav(bytes: Uint8Array): boolean;
54
+ /** Every LIST/INFO sub-chunk in a WAV, by id, as text (trailing NULs dropped). Walks the RIFF chunks, so it
55
+ * works wherever the list sits. Empty for anything that isn't a WAV. Reading only needs the file's chunk
56
+ * headers and the list itself, so a caller may pass just the head of a large file when the list comes first. */
57
+ declare function readInfoChunks(bytes: Uint8Array): Map<string, string>;
58
+ /** A take's text-hash stamp (`PTRK`, else the older `DINK`), or null when it has none. */
59
+ declare function readTextStamp(bytes: Uint8Array): string | null;
60
+ /** A take's generated-speech stamp (`PTPS`, else Patterstage's `PSVT`), or null for a person's take. */
61
+ declare function readGeneratedStamp(bytes: Uint8Array): string | null;
62
+ /** A LIST/INFO chunk holding these sub-chunks, in order. Each value is written as ASCII with a NUL terminator
63
+ * and padded to an even length, as RIFF wants. */
64
+ declare function infoListChunk(stamps: Array<[string, string]>): Uint8Array;
65
+ /** The same WAV with its LIST/INFO chunks replaced by one holding `stamps` (any other INFO entries, such as an
66
+ * encoder's, are dropped), appended after the audio, with the RIFF size fixed. Throws on bytes that aren't a
67
+ * WAV. */
68
+ declare function withStamps(wav: Uint8Array, stamps: Array<[string, string]>): Uint8Array;
69
+
19
70
  /** Options for {@link canonicalStringify}. */
20
71
  interface StringifyOptions {
21
72
  /** Emit a trailing comma after the last item/entry (the canonical SOURCE form).
@@ -138,4 +189,4 @@ declare function planProject(opts: {
138
189
  /** Plan a stub scene for a card. Pure: nothing is read or written. */
139
190
  declare function planScene(target: ScenePlanTarget, scaffold: SceneScaffold): ScenePlan;
140
191
 
141
- export { type NeverRuns, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, neverRunNodes, planProject, planScene, slug, validateProject };
192
+ export { GENERATED_STAMP, type NeverRuns, PLACEHOLDER_ACCENTS, type PlaceholderAccent, type PlaceholderVoice, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, TEXT_STAMP, type ValidationIssue, type VoiceGender, accentForLanguage, canonicalStringify, defaultPlaceholderVoice, describePlaceholderVoice, hash32, hash4, humanizeNodeRefs, infoListChunk, isWav, neverRunNodes, placeholderAccent, planProject, planScene, readGeneratedStamp, readInfoChunks, readTextStamp, slug, textHash, validateProject, withStamps };
package/dist/index.d.ts CHANGED
@@ -16,6 +16,57 @@ declare function hash32(input: string): string;
16
16
  */
17
17
  declare function humanizeNodeRefs(cond: string, label: (id: string) => string): string;
18
18
 
19
+ type VoiceGender = "female" | "male";
20
+ interface PlaceholderVoice {
21
+ name: string;
22
+ gender: VoiceGender;
23
+ }
24
+ interface PlaceholderAccent {
25
+ code: string;
26
+ label: string;
27
+ voices: readonly PlaceholderVoice[];
28
+ }
29
+ /** The accents on offer, in the order the settings page lists them. */
30
+ declare const PLACEHOLDER_ACCENTS: readonly PlaceholderAccent[];
31
+ /** The accent for a code, or undefined when it isn't one on offer. */
32
+ declare function placeholderAccent(code: string | undefined): PlaceholderAccent | undefined;
33
+ /** The accent a project's main language implies: an exact match (`en-GB`), else the first accent on offer for
34
+ * that language, so a bare `en` is British English (Ian, 2026-11-10), changeable in settings. Undefined for a
35
+ * language with no accent on offer. */
36
+ declare function accentForLanguage(locale: string | undefined): string | undefined;
37
+ /** The voice a character gets with none chosen: a hash of their name picks from the accent's voices; when
38
+ * their gender is male or female (any case) and that pick is the other, the pick moves within that gender's
39
+ * voices. Characters whose pick already fits keep it, so setting a gender changes only the voices that were
40
+ * wrong. Undefined when the accent isn't one on offer. */
41
+ declare function defaultPlaceholderVoice(name: string, accent: string | undefined, gender?: string): string | undefined;
42
+ /** A voice's name in words, for the Cast editor: "Female, English (British) 2". Falls back to the raw name. */
43
+ declare function describePlaceholderVoice(voice: string): string;
44
+
45
+ /** The text-hash stamp's chunk id, and the older name it's still read under. */
46
+ declare const TEXT_STAMP = "PTRK";
47
+ /** Placeholder Speech's request-hash stamp, and Patterstage's name for the same thing. */
48
+ declare const GENERATED_STAMP = "PTPS";
49
+ /** FNV-1a (32-bit) of the NORMALISED line text -> 4 little-endian bytes -> base64 -> first 6 chars.
50
+ * Empty (after normalising) -> "000000". */
51
+ declare function textHash(input: string): string;
52
+ /** Whether the bytes start as a RIFF/WAVE file. */
53
+ declare function isWav(bytes: Uint8Array): boolean;
54
+ /** Every LIST/INFO sub-chunk in a WAV, by id, as text (trailing NULs dropped). Walks the RIFF chunks, so it
55
+ * works wherever the list sits. Empty for anything that isn't a WAV. Reading only needs the file's chunk
56
+ * headers and the list itself, so a caller may pass just the head of a large file when the list comes first. */
57
+ declare function readInfoChunks(bytes: Uint8Array): Map<string, string>;
58
+ /** A take's text-hash stamp (`PTRK`, else the older `DINK`), or null when it has none. */
59
+ declare function readTextStamp(bytes: Uint8Array): string | null;
60
+ /** A take's generated-speech stamp (`PTPS`, else Patterstage's `PSVT`), or null for a person's take. */
61
+ declare function readGeneratedStamp(bytes: Uint8Array): string | null;
62
+ /** A LIST/INFO chunk holding these sub-chunks, in order. Each value is written as ASCII with a NUL terminator
63
+ * and padded to an even length, as RIFF wants. */
64
+ declare function infoListChunk(stamps: Array<[string, string]>): Uint8Array;
65
+ /** The same WAV with its LIST/INFO chunks replaced by one holding `stamps` (any other INFO entries, such as an
66
+ * encoder's, are dropped), appended after the audio, with the RIFF size fixed. Throws on bytes that aren't a
67
+ * WAV. */
68
+ declare function withStamps(wav: Uint8Array, stamps: Array<[string, string]>): Uint8Array;
69
+
19
70
  /** Options for {@link canonicalStringify}. */
20
71
  interface StringifyOptions {
21
72
  /** Emit a trailing comma after the last item/entry (the canonical SOURCE form).
@@ -138,4 +189,4 @@ declare function planProject(opts: {
138
189
  /** Plan a stub scene for a card. Pure: nothing is read or written. */
139
190
  declare function planScene(target: ScenePlanTarget, scaffold: SceneScaffold): ScenePlan;
140
191
 
141
- export { type NeverRuns, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, neverRunNodes, planProject, planScene, slug, validateProject };
192
+ export { GENERATED_STAMP, type NeverRuns, PLACEHOLDER_ACCENTS, type PlaceholderAccent, type PlaceholderVoice, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, TEXT_STAMP, type ValidationIssue, type VoiceGender, accentForLanguage, canonicalStringify, defaultPlaceholderVoice, describePlaceholderVoice, hash32, hash4, humanizeNodeRefs, infoListChunk, isWav, neverRunNodes, placeholderAccent, planProject, planScene, readGeneratedStamp, readInfoChunks, readTextStamp, slug, textHash, validateProject, withStamps };
package/dist/index.js CHANGED
@@ -23,26 +23,242 @@ function humanizeNodeRefs(cond, label) {
23
23
  return cond.replace(VISIT_FN_RE, (_m, fn, _q, quoted, bare) => `${fn}(${label(quoted ?? bare ?? "")})`);
24
24
  }
25
25
 
26
+ // src/placeholder-voices.ts
27
+ import { fnv32 as fnv322 } from "@wildwinter/toolkit";
28
+ var v = (name, gender) => ({ name, gender });
29
+ var PLACEHOLDER_ACCENTS = [
30
+ { code: "en-GB", label: "English (British)", voices: [
31
+ v("en-GB-Standard-A", "female"),
32
+ v("en-GB-Standard-B", "male"),
33
+ v("en-GB-Standard-C", "female"),
34
+ v("en-GB-Standard-D", "male"),
35
+ v("en-GB-Standard-F", "female")
36
+ ] },
37
+ { code: "en-US", label: "English (American)", voices: [
38
+ v("en-US-Standard-A", "male"),
39
+ v("en-US-Standard-B", "male"),
40
+ v("en-US-Standard-C", "female"),
41
+ v("en-US-Standard-D", "male"),
42
+ v("en-US-Standard-E", "female"),
43
+ v("en-US-Standard-F", "female"),
44
+ v("en-US-Standard-G", "female"),
45
+ v("en-US-Standard-H", "female"),
46
+ v("en-US-Standard-I", "male"),
47
+ v("en-US-Standard-J", "male")
48
+ ] },
49
+ { code: "en-AU", label: "English (Australian)", voices: [
50
+ v("en-AU-Standard-A", "female"),
51
+ v("en-AU-Standard-B", "male"),
52
+ v("en-AU-Standard-C", "female"),
53
+ v("en-AU-Standard-D", "male")
54
+ ] },
55
+ { code: "en-IN", label: "English (Indian)", voices: [
56
+ v("en-IN-Standard-A", "female"),
57
+ v("en-IN-Standard-B", "male"),
58
+ v("en-IN-Standard-C", "male"),
59
+ v("en-IN-Standard-D", "female")
60
+ ] },
61
+ { code: "de-DE", label: "German", voices: [
62
+ v("de-DE-Standard-A", "female"),
63
+ v("de-DE-Standard-B", "male"),
64
+ v("de-DE-Standard-C", "female"),
65
+ v("de-DE-Standard-D", "male"),
66
+ v("de-DE-Standard-E", "male"),
67
+ v("de-DE-Standard-F", "female")
68
+ ] },
69
+ { code: "fr-FR", label: "French", voices: [
70
+ v("fr-FR-Standard-A", "female"),
71
+ v("fr-FR-Standard-B", "male"),
72
+ v("fr-FR-Standard-C", "female"),
73
+ v("fr-FR-Standard-D", "male"),
74
+ v("fr-FR-Standard-E", "female")
75
+ ] },
76
+ { code: "es-ES", label: "Spanish (Spain)", voices: [
77
+ v("es-ES-Standard-A", "female"),
78
+ v("es-ES-Standard-B", "male"),
79
+ v("es-ES-Standard-C", "female"),
80
+ v("es-ES-Standard-D", "female")
81
+ ] },
82
+ { code: "it-IT", label: "Italian", voices: [
83
+ v("it-IT-Standard-A", "female"),
84
+ v("it-IT-Standard-B", "female"),
85
+ v("it-IT-Standard-C", "male"),
86
+ v("it-IT-Standard-D", "male")
87
+ ] },
88
+ { code: "sv-SE", label: "Swedish", voices: [
89
+ v("sv-SE-Standard-A", "female"),
90
+ v("sv-SE-Standard-B", "female"),
91
+ v("sv-SE-Standard-C", "female"),
92
+ v("sv-SE-Standard-D", "male"),
93
+ v("sv-SE-Standard-E", "male")
94
+ ] },
95
+ { code: "ja-JP", label: "Japanese", voices: [
96
+ v("ja-JP-Standard-A", "female"),
97
+ v("ja-JP-Standard-B", "female"),
98
+ v("ja-JP-Standard-C", "male"),
99
+ v("ja-JP-Standard-D", "male")
100
+ ] }
101
+ ];
102
+ function placeholderAccent(code) {
103
+ const want = code?.replace(/_/g, "-").toLowerCase();
104
+ return want === void 0 ? void 0 : PLACEHOLDER_ACCENTS.find((a) => a.code.toLowerCase() === want);
105
+ }
106
+ function accentForLanguage(locale) {
107
+ if (!locale) return void 0;
108
+ const exact = placeholderAccent(locale);
109
+ if (exact) return exact.code;
110
+ const lang = locale.split(/[-_]/)[0].toLowerCase();
111
+ const same = PLACEHOLDER_ACCENTS.filter((a) => a.code.split("-")[0].toLowerCase() === lang);
112
+ return same[0]?.code;
113
+ }
114
+ function defaultPlaceholderVoice(name, accent, gender) {
115
+ const voices = placeholderAccent(accent)?.voices;
116
+ if (!voices?.length) return void 0;
117
+ const h = fnv322(name);
118
+ const pick = voices[h % voices.length];
119
+ const want = gender?.trim().toLowerCase();
120
+ if (want !== "male" && want !== "female" || pick.gender === want) return pick.name;
121
+ const fitting = voices.filter((x) => x.gender === want);
122
+ return fitting.length ? fitting[h % fitting.length].name : pick.name;
123
+ }
124
+ function describePlaceholderVoice(voice) {
125
+ for (const a of PLACEHOLDER_ACCENTS) {
126
+ const sameGender = (g) => a.voices.filter((x) => x.gender === g);
127
+ const found = a.voices.find((x) => x.name === voice);
128
+ if (found) {
129
+ const n = sameGender(found.gender).indexOf(found) + 1;
130
+ return `${found.gender === "female" ? "Female" : "Male"}, ${a.label} ${n}`;
131
+ }
132
+ }
133
+ return voice;
134
+ }
135
+
136
+ // src/take-stamp.ts
137
+ var TEXT_STAMP = "PTRK";
138
+ var TEXT_STAMP_OLD = "DINK";
139
+ var GENERATED_STAMP = "PTPS";
140
+ var GENERATED_STAMP_OTHER = "PSVT";
141
+ function normalizeForHash(input) {
142
+ return input.replace(/<\/?(?:b|i|bi)>/g, "").replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
143
+ }
144
+ function textHash(input) {
145
+ const text = normalizeForHash(input);
146
+ if (!text) return "000000";
147
+ let hash = 2166136261;
148
+ for (let i = 0; i < text.length; i++) {
149
+ hash ^= text.charCodeAt(i);
150
+ hash = Math.imul(hash, 16777619);
151
+ }
152
+ const bytes = [hash & 255, hash >>> 8 & 255, hash >>> 16 & 255, hash >>> 24 & 255];
153
+ return btoa(String.fromCharCode(...bytes)).slice(0, 6);
154
+ }
155
+ var ascii = (bytes, at, n) => {
156
+ let s = "";
157
+ for (let i = 0; i < n && at + i < bytes.length; i++) s += String.fromCharCode(bytes[at + i]);
158
+ return s;
159
+ };
160
+ var u32 = (bytes, at) => (bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16 | bytes[at + 3] << 24) >>> 0;
161
+ function isWav(bytes) {
162
+ return bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE";
163
+ }
164
+ function readInfoChunks(bytes) {
165
+ const out = /* @__PURE__ */ new Map();
166
+ if (!isWav(bytes)) return out;
167
+ let pos = 12;
168
+ while (pos + 8 <= bytes.length) {
169
+ const id = ascii(bytes, pos, 4);
170
+ const size = u32(bytes, pos + 4);
171
+ const body = pos + 8;
172
+ if (id === "LIST" && ascii(bytes, body, 4) === "INFO") {
173
+ const end = Math.min(body + size, bytes.length);
174
+ let sub = body + 4;
175
+ while (sub + 8 <= end) {
176
+ const subId = ascii(bytes, sub, 4);
177
+ const subSize = u32(bytes, sub + 4);
178
+ out.set(subId, ascii(bytes, sub + 8, subSize).replace(/\0+$/, ""));
179
+ sub += 8 + subSize + subSize % 2;
180
+ }
181
+ }
182
+ pos = body + size + size % 2;
183
+ }
184
+ return out;
185
+ }
186
+ function readTextStamp(bytes) {
187
+ const info = readInfoChunks(bytes);
188
+ return info.get(TEXT_STAMP) ?? info.get(TEXT_STAMP_OLD) ?? null;
189
+ }
190
+ function readGeneratedStamp(bytes) {
191
+ const info = readInfoChunks(bytes);
192
+ return info.get(GENERATED_STAMP) ?? info.get(GENERATED_STAMP_OTHER) ?? null;
193
+ }
194
+ function infoListChunk(stamps) {
195
+ const parts = stamps.map(([id, value]) => {
196
+ const data = new TextEncoder().encode(value + "\0");
197
+ const padded = data.length + data.length % 2;
198
+ const sub = new Uint8Array(8 + padded);
199
+ for (let i = 0; i < 4; i++) sub[i] = id.charCodeAt(i);
200
+ new DataView(sub.buffer).setUint32(4, data.length, true);
201
+ sub.set(data, 8);
202
+ return sub;
203
+ });
204
+ const content = 4 + parts.reduce((n, p) => n + p.length, 0);
205
+ const out = new Uint8Array(8 + content);
206
+ const view = new DataView(out.buffer);
207
+ out.set([76, 73, 83, 84], 0);
208
+ view.setUint32(4, content, true);
209
+ out.set([73, 78, 70, 79], 8);
210
+ let at = 12;
211
+ for (const p of parts) {
212
+ out.set(p, at);
213
+ at += p.length;
214
+ }
215
+ return out;
216
+ }
217
+ function withStamps(wav, stamps) {
218
+ if (!isWav(wav)) throw new Error("not a WAV file");
219
+ const kept = [];
220
+ let pos = 12;
221
+ while (pos + 8 <= wav.length) {
222
+ const id = ascii(wav, pos, 4);
223
+ const size = u32(wav, pos + 4);
224
+ const next = Math.min(pos + 8 + size + size % 2, wav.length);
225
+ if (!(id === "LIST" && ascii(wav, pos + 8, 4) === "INFO")) kept.push(wav.subarray(pos, next));
226
+ pos = next;
227
+ }
228
+ const list = infoListChunk(stamps);
229
+ const total = 12 + kept.reduce((n, c) => n + c.length, 0) + list.length;
230
+ const out = new Uint8Array(total);
231
+ out.set(wav.subarray(0, 12), 0);
232
+ let at = 12;
233
+ for (const c of kept) {
234
+ out.set(c, at);
235
+ at += c.length;
236
+ }
237
+ out.set(list, at);
238
+ new DataView(out.buffer).setUint32(4, total - 8, true);
239
+ return out;
240
+ }
241
+
26
242
  // src/serialize.ts
27
243
  import "json5";
28
244
  import { parseSource } from "@wildwinter/toolkit";
29
245
  function canonicalStringify(value, opts) {
30
246
  return write(value, "", opts?.trailingComma ?? true) + "\n";
31
247
  }
32
- function write(v, indent, tc) {
33
- if (v === null || typeof v === "boolean" || typeof v === "number") return JSON.stringify(v);
34
- if (typeof v === "string") return JSON.stringify(v);
248
+ function write(v2, indent, tc) {
249
+ if (v2 === null || typeof v2 === "boolean" || typeof v2 === "number") return JSON.stringify(v2);
250
+ if (typeof v2 === "string") return JSON.stringify(v2);
35
251
  const tail = tc ? "," : "";
36
- if (Array.isArray(v)) {
37
- if (v.length === 0) return "[]";
252
+ if (Array.isArray(v2)) {
253
+ if (v2.length === 0) return "[]";
38
254
  const next = indent + " ";
39
- const items = v.map((x) => next + write(x, next, tc));
255
+ const items = v2.map((x) => next + write(x, next, tc));
40
256
  return `[
41
257
  ${items.join(",\n")}${tail}
42
258
  ${indent}]`;
43
259
  }
44
- if (typeof v === "object") {
45
- const obj = v;
260
+ if (typeof v2 === "object") {
261
+ const obj = v2;
46
262
  const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
47
263
  if (keys.length === 0) return "{}";
48
264
  const next = indent + " ";
@@ -221,7 +437,7 @@ function contentlessJumpCycles(scenes) {
221
437
  code: "jump-cycle",
222
438
  severity: "warning",
223
439
  id: via[0],
224
- message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v) => `'${v}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
440
+ message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v2) => `'${v2}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
225
441
  });
226
442
  }
227
443
  for (const b of path) settled.add(b);
@@ -896,18 +1112,32 @@ function planScene(target, scaffold) {
896
1112
  };
897
1113
  }
898
1114
  export {
1115
+ GENERATED_STAMP,
1116
+ PLACEHOLDER_ACCENTS,
1117
+ TEXT_STAMP,
1118
+ accentForLanguage,
899
1119
  canonicalStringify,
1120
+ defaultPlaceholderVoice,
1121
+ describePlaceholderVoice,
900
1122
  effectiveGameId2 as effectiveGameId,
901
1123
  gameIdify,
902
1124
  hash32,
903
1125
  hash4,
904
1126
  humanizeNodeRefs,
1127
+ infoListChunk,
905
1128
  isValidGameId2 as isValidGameId,
1129
+ isWav,
906
1130
  neverRunNodes,
907
1131
  newId,
908
1132
  parseSource,
1133
+ placeholderAccent,
909
1134
  planProject,
910
1135
  planScene,
1136
+ readGeneratedStamp,
1137
+ readInfoChunks,
1138
+ readTextStamp,
911
1139
  slug,
912
- validateProject
1140
+ textHash,
1141
+ validateProject,
1142
+ withStamps
913
1143
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Patter core: stable id generation, readable-handle computation, canonical (de)serialisation, and the structural validator.",
5
5
  "keywords": [
6
6
  "patter",
@@ -46,9 +46,9 @@
46
46
  "build": "tsup src/index.ts --format esm,cjs --dts --clean"
47
47
  },
48
48
  "dependencies": {
49
- "@patterkit/model": "1.0.0",
50
- "@wildwinter/expr": "^1.0.0",
51
- "@wildwinter/toolkit": "^1.0.0",
49
+ "@patterkit/model": "1.1.0",
50
+ "@wildwinter/expr": "^1.0.1",
51
+ "@wildwinter/toolkit": "^1.2.0",
52
52
  "json5": "^2.2.3"
53
53
  }
54
54
  }