@onjmin/koe 1.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.
package/dist/index.js ADDED
@@ -0,0 +1,632 @@
1
+ // src/koe.ts
2
+ var MAGIC = 1263486208;
3
+ function packKoe(manifest, pcmParts) {
4
+ const json = new TextEncoder().encode(JSON.stringify(manifest));
5
+ const header = new ArrayBuffer(8);
6
+ const view = new DataView(header);
7
+ view.setUint32(0, MAGIC, false);
8
+ view.setUint32(4, json.byteLength, true);
9
+ return new Blob([header, json, ...pcmParts]);
10
+ }
11
+ function parseKoeHeader(headerBytes) {
12
+ const view = new DataView(headerBytes);
13
+ if (view.byteLength < 8 || view.getUint32(0, false) !== MAGIC) {
14
+ throw new Error("Not a .koe file (bad magic)");
15
+ }
16
+ return { jsonLength: view.getUint32(4, true) };
17
+ }
18
+ var pcmBase = (jsonLength) => 8 + jsonLength;
19
+
20
+ // src/engine/voice-bank.ts
21
+ var BlobVoiceSource = class {
22
+ constructor(blob, base) {
23
+ this.blob = blob;
24
+ this.base = base;
25
+ }
26
+ blob;
27
+ base;
28
+ readBytes(offset, length) {
29
+ const start = this.base + offset;
30
+ return this.blob.slice(start, start + length).arrayBuffer();
31
+ }
32
+ };
33
+ var RangeVoiceSource = class {
34
+ constructor(url, base) {
35
+ this.url = url;
36
+ this.base = base;
37
+ }
38
+ url;
39
+ base;
40
+ async readBytes(offset, length) {
41
+ const start = this.base + offset;
42
+ const res = await fetch(this.url, {
43
+ headers: { Range: `bytes=${start}-${start + length - 1}` }
44
+ });
45
+ if (!res.ok && res.status !== 206) {
46
+ throw new Error(`.koe range request failed: ${res.status}`);
47
+ }
48
+ return res.arrayBuffer();
49
+ }
50
+ };
51
+ async function rangeFetch(url, start, length) {
52
+ const res = await fetch(url, { headers: { Range: `bytes=${start}-${start + length - 1}` } });
53
+ if (!res.ok && res.status !== 206) throw new Error(`.koe fetch failed: ${res.status}`);
54
+ return res.arrayBuffer();
55
+ }
56
+ var VoiceBank = class _VoiceBank {
57
+ constructor(manifest, source) {
58
+ this.manifest = manifest;
59
+ this.source = source;
60
+ }
61
+ manifest;
62
+ source;
63
+ /**
64
+ * Parse a .koe archive header + manifest and bind a lazy PCM source.
65
+ * @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
66
+ */
67
+ static async load(koe) {
68
+ if (typeof koe === "string") {
69
+ const header2 = await rangeFetch(koe, 0, 8);
70
+ const { jsonLength: jsonLength2 } = parseKoeHeader(header2);
71
+ const json2 = await rangeFetch(koe, 8, jsonLength2);
72
+ const manifest2 = JSON.parse(new TextDecoder().decode(json2));
73
+ return new _VoiceBank(manifest2, new RangeVoiceSource(koe, pcmBase(jsonLength2)));
74
+ }
75
+ const header = await koe.slice(0, 8).arrayBuffer();
76
+ const { jsonLength } = parseKoeHeader(header);
77
+ const json = await koe.slice(8, 8 + jsonLength).arrayBuffer();
78
+ const manifest = JSON.parse(new TextDecoder().decode(json));
79
+ return new _VoiceBank(manifest, new BlobVoiceSource(koe, pcmBase(jsonLength)));
80
+ }
81
+ /** True if the bank contains a phoneme under this alias. */
82
+ has(phoneme) {
83
+ return this.manifest.phonemes[phoneme] !== void 0;
84
+ }
85
+ /**
86
+ * Raw Int16 PCM bytes (48 kHz / mono) for a phoneme, or null if unknown.
87
+ * The returned ArrayBuffer is freshly allocated and safe to transfer to a
88
+ * worker / AudioWorklet.
89
+ */
90
+ async readPcmBytes(phoneme) {
91
+ const entry = this.manifest.phonemes[phoneme];
92
+ if (!entry) return null;
93
+ return this.source.readBytes(entry.offset, entry.length * 2);
94
+ }
95
+ /**
96
+ * A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
97
+ * Intended for external analysis / resynthesis such as the WORLD vocoder.
98
+ */
99
+ async getPcm(phoneme) {
100
+ const buf = await this.readPcmBytes(phoneme);
101
+ if (!buf) return null;
102
+ const int16 = new Int16Array(buf);
103
+ const f64 = new Float64Array(int16.length);
104
+ for (let i = 0; i < int16.length; i++) f64[i] = int16[i] / 32768;
105
+ return f64;
106
+ }
107
+ };
108
+
109
+ // src/engine/index.ts
110
+ var KoeEngine = class {
111
+ ctx;
112
+ workletUrl;
113
+ node = null;
114
+ bank = null;
115
+ delivered = /* @__PURE__ */ new Set();
116
+ pending = /* @__PURE__ */ new Map();
117
+ constructor(options = {}) {
118
+ this.ctx = new AudioContext({ sampleRate: 48e3 });
119
+ this.workletUrl = options.workletUrl ?? "./koe-worklet.js";
120
+ }
121
+ get audioContext() {
122
+ return this.ctx;
123
+ }
124
+ get manifest() {
125
+ return this.bank?.manifest ?? null;
126
+ }
127
+ /** The underlying voice bank (manifest + on-demand PCM), or null before load(). */
128
+ get voiceBank() {
129
+ return this.bank;
130
+ }
131
+ /**
132
+ * Register the worklet and bind a .koe voice bank.
133
+ * @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
134
+ */
135
+ async load(koe) {
136
+ await this.ctx.audioWorklet.addModule(this.workletUrl);
137
+ this.bank = await VoiceBank.load(koe);
138
+ this.delivered.clear();
139
+ this.pending.clear();
140
+ this.node = new AudioWorkletNode(this.ctx, "koe-processor", {
141
+ numberOfInputs: 0,
142
+ numberOfOutputs: 1,
143
+ outputChannelCount: [1]
144
+ });
145
+ this.node.port.postMessage({ type: "init", manifest: this.bank.manifest });
146
+ this.node.connect(this.ctx.destination);
147
+ console.log(
148
+ "[koe] ready \u2014",
149
+ Object.keys(this.bank.manifest.phonemes).length,
150
+ "phonemes (on-demand)"
151
+ );
152
+ }
153
+ /** Fetch one phoneme's PCM and deliver it to the worklet (deduped, cached). */
154
+ ensurePhoneme(name) {
155
+ if (this.delivered.has(name)) return Promise.resolve();
156
+ const existing = this.pending.get(name);
157
+ if (existing) return existing;
158
+ if (!this.bank || !this.node) return Promise.resolve();
159
+ const load = this.bank.readPcmBytes(name).then((buf) => {
160
+ if (!buf) return;
161
+ this.node.port.postMessage({ type: "phoneme", name, buffer: buf }, [buf]);
162
+ this.delivered.add(name);
163
+ this.pending.delete(name);
164
+ });
165
+ this.pending.set(name, load);
166
+ return load;
167
+ }
168
+ /** Stop current playback, preload the phonemes for `notes`, then queue them. */
169
+ async play(notes) {
170
+ if (!this.node) throw new Error("KoeEngine: call load() before play()");
171
+ this.node.port.postMessage({ type: "stop" });
172
+ const names = [...new Set(notes.map((n) => n.phoneme))].filter(Boolean);
173
+ await Promise.all(names.map((n) => this.ensurePhoneme(n)));
174
+ this.node.port.postMessage({ type: "play", notes });
175
+ }
176
+ /** Stop playback and clear the queue. */
177
+ stop() {
178
+ this.node?.port.postMessage({ type: "stop" });
179
+ }
180
+ /** Resume the AudioContext if suspended (e.g. after autoplay block). */
181
+ async resume() {
182
+ if (this.ctx.state === "suspended") await this.ctx.resume();
183
+ }
184
+ /**
185
+ * Read a phoneme's raw PCM and return it as a Float64Array normalised to
186
+ * [-1, 1]. Convenience that forwards to the underlying {@link VoiceBank}.
187
+ * Intended for external analysis such as the WORLD vocoder.
188
+ */
189
+ async getPcm(phoneme) {
190
+ return this.bank?.getPcm(phoneme) ?? null;
191
+ }
192
+ };
193
+
194
+ // src/engine/worldline.ts
195
+ var WORLDLINE_SAMPLE_RATE = 48e3;
196
+ var MIN_WORLDLINE_SAMPLES = 4096;
197
+ var SYNTH_REQ_SIZE = 120;
198
+ var WL_FRAME_MS = 10;
199
+ var samplesToMs = (samples) => samples / WORLDLINE_SAMPLE_RATE * 1e3;
200
+ function leadInFromEntry(entry) {
201
+ return { preMs: samplesToMs(entry.pre || 0), consonantMs: samplesToMs(entry.consonant || 0) };
202
+ }
203
+ var moduleCache = /* @__PURE__ */ new Map();
204
+ function injectScript(src) {
205
+ return new Promise((resolve, reject) => {
206
+ const existing = document.querySelector(`script[data-koe-worldline="${src}"]`);
207
+ if (existing) {
208
+ resolve();
209
+ return;
210
+ }
211
+ const s = document.createElement("script");
212
+ s.src = src;
213
+ s.dataset.koeWorldline = src;
214
+ s.onload = () => resolve();
215
+ s.onerror = () => reject(new Error(`worldline: failed to load ${src}`));
216
+ document.head.appendChild(s);
217
+ });
218
+ }
219
+ function loadWasm(scriptUrl) {
220
+ const cached = moduleCache.get(scriptUrl);
221
+ if (cached) return cached;
222
+ if (typeof document === "undefined") {
223
+ return Promise.reject(
224
+ new Error("Worldline.load requires a DOM (browser) environment to load worldline.js")
225
+ );
226
+ }
227
+ const baseUrl = scriptUrl.slice(0, scriptUrl.lastIndexOf("/") + 1);
228
+ const promise = injectScript(scriptUrl).then(() => {
229
+ const factory = globalThis.WorldlineModule;
230
+ if (!factory) throw new Error("worldline: WorldlineModule global was not defined by the script");
231
+ return factory({ locateFile: (f) => baseUrl + f });
232
+ });
233
+ moduleCache.set(scriptUrl, promise);
234
+ return promise;
235
+ }
236
+ var Worldline = class _Worldline {
237
+ constructor(wasm) {
238
+ this.wasm = wasm;
239
+ }
240
+ wasm;
241
+ sampleRate = WORLDLINE_SAMPLE_RATE;
242
+ /** Load + instantiate the worldline WASM module (deduped per scriptUrl). */
243
+ static async load(options) {
244
+ return new _Worldline(await loadWasm(options.scriptUrl));
245
+ }
246
+ /**
247
+ * Render one note to Float32 PCM at 48 kHz.
248
+ *
249
+ * The output buffer is laid out as [lead-in/consonant ≈ preMs][vowel ≈
250
+ * durationMs], rendered from sample offset 0 (no leading silence). The vowel
251
+ * onset (the "beat") sits at ≈ preMs into the buffer, so a sequencer should
252
+ * place the buffer at `beatTime − preMs` and may trim/crossfade the lead-in.
253
+ *
254
+ * No internal crossfade is applied — apply fades externally.
255
+ *
256
+ * @returns Float32 PCM, or null when `pcm` is shorter than
257
+ * {@link MIN_WORLDLINE_SAMPLES} (too short for stable F0 analysis).
258
+ */
259
+ renderNote(params) {
260
+ const { pcm, pitch, durationMs, preMs, consonantMs, tempo = 120 } = params;
261
+ if (!pcm || pcm.length < MIN_WORLDLINE_SAMPLES) return null;
262
+ const WL = this.wasm;
263
+ const FS = WORLDLINE_SAMPLE_RATE;
264
+ const midiNote = Math.round(69 + 12 * Math.log2(pitch / 440));
265
+ const posMs = 0;
266
+ const reqLen = preMs + durationMs;
267
+ const cutMs = WL_FRAME_MS * 2;
268
+ const ps = WL._PhraseSynthNew();
269
+ if (!ps) return null;
270
+ const reqPtr = WL._malloc(SYNTH_REQ_SIZE);
271
+ if (!reqPtr) {
272
+ WL._PhraseSynthDelete(ps);
273
+ return null;
274
+ }
275
+ const samplePtr = WL._malloc(pcm.length * 8);
276
+ if (!samplePtr) {
277
+ WL._free(reqPtr);
278
+ WL._PhraseSynthDelete(ps);
279
+ return null;
280
+ }
281
+ WL.HEAPF64.set(pcm, samplePtr >> 3);
282
+ const sv = (off, val, type) => WL.setValue(reqPtr + off, val, type);
283
+ sv(0, FS, "i32");
284
+ sv(4, pcm.length, "i32");
285
+ sv(8, samplePtr, "*");
286
+ sv(12, 0, "i32");
287
+ sv(16, 0, "*");
288
+ sv(20, midiNote, "i32");
289
+ sv(24, 100, "double");
290
+ sv(32, 0, "double");
291
+ sv(40, reqLen, "double");
292
+ sv(48, consonantMs, "double");
293
+ sv(56, cutMs, "double");
294
+ sv(64, 100, "double");
295
+ sv(72, 0, "double");
296
+ sv(80, tempo, "double");
297
+ sv(88, 0, "i32");
298
+ sv(92, 0, "*");
299
+ sv(96, 0, "i32");
300
+ sv(100, 0, "i32");
301
+ sv(104, 100, "i32");
302
+ sv(108, 0, "i32");
303
+ sv(112, 0, "i32");
304
+ sv(116, 100, "i32");
305
+ WL._PhraseSynthAddRequest(ps, reqPtr, posMs, 0, reqLen, 0, 0, 0);
306
+ WL._free(samplePtr);
307
+ WL._free(reqPtr);
308
+ const totalMs = posMs + reqLen + WL_FRAME_MS * 2;
309
+ const nFrames = Math.ceil(totalMs / WL_FRAME_MS) + 4;
310
+ const f0Arr = new Float64Array(nFrames).fill(pitch);
311
+ const gArr = new Float64Array(nFrames).fill(0.5);
312
+ const tArr = new Float64Array(nFrames).fill(0.5);
313
+ const bArr = new Float64Array(nFrames).fill(0.5);
314
+ const vArr = new Float64Array(nFrames).fill(1);
315
+ const f0Ptr = WL._malloc(nFrames * 8);
316
+ const gPtr = WL._malloc(nFrames * 8);
317
+ const tPtr = WL._malloc(nFrames * 8);
318
+ const bPtr = WL._malloc(nFrames * 8);
319
+ const vPtr = WL._malloc(nFrames * 8);
320
+ if (!f0Ptr || !gPtr || !tPtr || !bPtr || !vPtr) {
321
+ if (f0Ptr) WL._free(f0Ptr);
322
+ if (gPtr) WL._free(gPtr);
323
+ if (tPtr) WL._free(tPtr);
324
+ if (bPtr) WL._free(bPtr);
325
+ if (vPtr) WL._free(vPtr);
326
+ WL._PhraseSynthDelete(ps);
327
+ return null;
328
+ }
329
+ WL.HEAPF64.set(f0Arr, f0Ptr >> 3);
330
+ WL.HEAPF64.set(gArr, gPtr >> 3);
331
+ WL.HEAPF64.set(tArr, tPtr >> 3);
332
+ WL.HEAPF64.set(bArr, bPtr >> 3);
333
+ WL.HEAPF64.set(vArr, vPtr >> 3);
334
+ WL._PhraseSynthSetCurves(ps, f0Ptr, gPtr, tPtr, bPtr, vPtr, nFrames, WL_FRAME_MS);
335
+ WL._free(f0Ptr);
336
+ WL._free(gPtr);
337
+ WL._free(tPtr);
338
+ WL._free(bPtr);
339
+ WL._free(vPtr);
340
+ const yPtrPtr = WL._malloc(4);
341
+ if (!yPtrPtr) {
342
+ WL._PhraseSynthDelete(ps);
343
+ return null;
344
+ }
345
+ const outLen = WL._PhraseSynthSynth(ps, yPtrPtr, 0);
346
+ const yPtr = WL.getValue(yPtrPtr, "*");
347
+ const audio = outLen > 0 ? new Float32Array(WL.HEAPF32.buffer, yPtr, outLen).slice() : null;
348
+ WL._free(yPtrPtr);
349
+ WL._PhraseSynthDelete(ps);
350
+ return audio;
351
+ }
352
+ };
353
+
354
+ // src/converter/parse-oto.ts
355
+ function parseOto(content) {
356
+ const entries = [];
357
+ for (const raw of content.split(/\r?\n/)) {
358
+ const line = raw.trim();
359
+ if (!line || line.startsWith("#")) continue;
360
+ const eq = line.indexOf("=");
361
+ if (eq === -1) continue;
362
+ const wav = line.slice(0, eq).trim();
363
+ const parts = line.slice(eq + 1).split(",");
364
+ if (parts.length < 6) continue;
365
+ const [alias, offsetStr, consonantStr, cutoffStr, preStr, overlapStr] = parts;
366
+ const aliasStr = alias.trim() || wav.replace(/\.[^.]+$/, "");
367
+ const entry = {
368
+ wav,
369
+ alias: aliasStr,
370
+ offset: parseFloat(offsetStr) || 0,
371
+ consonant: parseFloat(consonantStr) || 0,
372
+ cutoff: parseFloat(cutoffStr) || 0,
373
+ pre: parseFloat(preStr) || 0,
374
+ overlap: parseFloat(overlapStr) || 0
375
+ };
376
+ if (!entry.alias) continue;
377
+ entries.push(entry);
378
+ }
379
+ return entries;
380
+ }
381
+
382
+ // src/converter/wav.ts
383
+ function parseWav(buf) {
384
+ const view = new DataView(buf);
385
+ const riff = readFourCC(view, 0);
386
+ if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
387
+ let sampleRate = 0;
388
+ let channels = 0;
389
+ let bitsPerSample = 0;
390
+ let audioFormat = 1;
391
+ let dataOffset = 0;
392
+ let dataLength = 0;
393
+ let pos = 12;
394
+ while (pos < view.byteLength - 8) {
395
+ const id = readFourCC(view, pos);
396
+ const size = view.getUint32(pos + 4, true);
397
+ pos += 8;
398
+ if (id === "fmt ") {
399
+ audioFormat = view.getUint16(pos, true);
400
+ channels = view.getUint16(pos + 2, true);
401
+ sampleRate = view.getUint32(pos + 4, true);
402
+ bitsPerSample = view.getUint16(pos + 14, true);
403
+ } else if (id === "data") {
404
+ dataOffset = pos;
405
+ dataLength = size;
406
+ break;
407
+ }
408
+ pos += size + (size & 1);
409
+ }
410
+ if (!dataOffset) throw new Error("WAV has no data chunk");
411
+ if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
412
+ const bytesPerSample = bitsPerSample >> 3;
413
+ const totalSamples = Math.floor(dataLength / bytesPerSample);
414
+ const samples = new Float32Array(totalSamples);
415
+ for (let i = 0; i < totalSamples; i++) {
416
+ const p = dataOffset + i * bytesPerSample;
417
+ if (audioFormat === 3) {
418
+ samples[i] = view.getFloat32(p, true);
419
+ } else if (bitsPerSample === 8) {
420
+ samples[i] = (view.getUint8(p) - 128) / 128;
421
+ } else if (bitsPerSample === 16) {
422
+ samples[i] = view.getInt16(p, true) / 32768;
423
+ } else if (bitsPerSample === 24) {
424
+ const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
425
+ let hi = view.getUint8(p + 2);
426
+ if (hi & 128) hi = hi | 4294967040;
427
+ samples[i] = (hi << 16 | lo) / 8388608;
428
+ }
429
+ }
430
+ return { sampleRate, channels, samples };
431
+ }
432
+ function toMono(wav) {
433
+ if (wav.channels === 1) return wav;
434
+ const len = wav.samples.length / wav.channels;
435
+ const out = new Float32Array(len);
436
+ for (let i = 0; i < len; i++) {
437
+ let sum = 0;
438
+ for (let c = 0; c < wav.channels; c++) sum += wav.samples[i * wav.channels + c];
439
+ out[i] = sum / wav.channels;
440
+ }
441
+ return { sampleRate: wav.sampleRate, channels: 1, samples: out };
442
+ }
443
+ function resample(wav, targetRate) {
444
+ if (wav.sampleRate === targetRate) return wav;
445
+ const ratio = wav.sampleRate / targetRate;
446
+ const outLen = Math.floor(wav.samples.length / ratio);
447
+ const out = new Float32Array(outLen);
448
+ const src = wav.samples;
449
+ for (let i = 0; i < outLen; i++) {
450
+ const x = i * ratio;
451
+ const xi = Math.floor(x);
452
+ const frac = x - xi;
453
+ out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
454
+ }
455
+ return { sampleRate: targetRate, channels: 1, samples: out };
456
+ }
457
+ function toInt16(samples) {
458
+ const out = new Int16Array(samples.length);
459
+ for (let i = 0; i < samples.length; i++) {
460
+ out[i] = Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767);
461
+ }
462
+ return out;
463
+ }
464
+ function normalizePcm(buf) {
465
+ const wav = parseWav(buf);
466
+ const mono = toMono(wav);
467
+ const resampled = resample(mono, 48e3);
468
+ return toInt16(resampled.samples);
469
+ }
470
+ function readFourCC(view, pos) {
471
+ return String.fromCharCode(
472
+ view.getUint8(pos),
473
+ view.getUint8(pos + 1),
474
+ view.getUint8(pos + 2),
475
+ view.getUint8(pos + 3)
476
+ );
477
+ }
478
+
479
+ // src/converter/pitch.ts
480
+ var SAMPLE_RATE = 48e3;
481
+ var NAME_SEMITONE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
482
+ function noteNameToHz(name) {
483
+ const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(name);
484
+ if (!m) return null;
485
+ let semi = NAME_SEMITONE[m[1].toLowerCase()];
486
+ if (m[2] === "#") semi++;
487
+ else if (m[2] === "b") semi--;
488
+ const midi = (parseInt(m[3], 10) + 1) * 12 + semi;
489
+ return 440 * 2 ** ((midi - 69) / 12);
490
+ }
491
+ function pitchFromAliasSuffix(alias) {
492
+ const m = /_([A-Ga-g][#b]?-?\d+)$/.exec(alias);
493
+ return m ? noteNameToHz(m[1]) : null;
494
+ }
495
+ function detectF0(pcm, start, end) {
496
+ const DECIM = 4;
497
+ const sr = SAMPLE_RATE / DECIM;
498
+ const minLag = Math.floor(sr / 700);
499
+ const maxLag = Math.floor(sr / 70);
500
+ const outLen = Math.floor((end - start) / DECIM);
501
+ if (outLen < maxLag + 2) return 0;
502
+ const win = Math.min(outLen, 1500);
503
+ const buf = new Float32Array(win);
504
+ let mean = 0;
505
+ for (let i = 0; i < win; i++) {
506
+ let s = 0;
507
+ const base = start + i * DECIM;
508
+ for (let j = 0; j < DECIM; j++) s += pcm[base + j];
509
+ buf[i] = s;
510
+ mean += s;
511
+ }
512
+ mean /= win;
513
+ const sq = new Float64Array(win + 1);
514
+ for (let i = 0; i < win; i++) {
515
+ buf[i] -= mean;
516
+ sq[i + 1] = sq[i] + buf[i] * buf[i];
517
+ }
518
+ if (sq[win] < 1) return 0;
519
+ const norm = (lag) => {
520
+ const n = win - lag;
521
+ let r = 0;
522
+ for (let i = 0; i < n; i++) r += buf[i] * buf[i + lag];
523
+ const e = sq[n] + (sq[lag + n] - sq[lag]);
524
+ return e > 0 ? 2 * r / e : 0;
525
+ };
526
+ let bestLag = -1;
527
+ let best = 0;
528
+ for (let lag = minLag; lag <= maxLag; lag++) {
529
+ const v = norm(lag);
530
+ if (v > best) {
531
+ best = v;
532
+ bestLag = lag;
533
+ }
534
+ }
535
+ if (bestLag < 1 || best < 0.4) return 0;
536
+ const y0 = norm(bestLag - 1);
537
+ const y1 = best;
538
+ const y2 = norm(bestLag + 1);
539
+ const denom = y0 - 2 * y1 + y2;
540
+ const shift = denom !== 0 ? 0.5 * (y0 - y2) / denom : 0;
541
+ return sr / (bestLag + shift);
542
+ }
543
+
544
+ // src/converter/pack.ts
545
+ var TARGET_RATE = 48e3;
546
+ function msToSamples(ms) {
547
+ return Math.round(ms / 1e3 * TARGET_RATE);
548
+ }
549
+ var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
550
+ function trimToOto(pcm, oto, recordedPitch = 0) {
551
+ const full = pcm.length;
552
+ const start = clamp(msToSamples(oto.offset), 0, full);
553
+ const end = oto.cutoff < 0 ? clamp(start + msToSamples(-oto.cutoff), start, full) : clamp(full - msToSamples(oto.cutoff), start, full);
554
+ const slice = pcm.subarray(start, end);
555
+ const length = slice.length;
556
+ const pre = clamp(msToSamples(oto.pre), 0, length);
557
+ const overlap = clamp(msToSamples(oto.overlap), 0, length);
558
+ const consonant = clamp(msToSamples(oto.consonant), 0, length);
559
+ const pitch = recordedPitch > 0 ? recordedPitch : detectF0(slice, Math.min(Math.max(pre, consonant), Math.max(0, length - 1)), length);
560
+ return {
561
+ pcm: slice,
562
+ entry: { length, pre, overlap, consonant, pitch }
563
+ };
564
+ }
565
+ function pack(inputs, referencePitch = 220) {
566
+ const phonemes = {};
567
+ const chunks = [];
568
+ let byteOffset = 0;
569
+ for (const { oto, pcm, recordedPitch } of inputs) {
570
+ const { pcm: slice, entry } = trimToOto(pcm, oto, recordedPitch);
571
+ if (slice.length === 0) continue;
572
+ phonemes[oto.alias] = { offset: byteOffset, ...entry };
573
+ byteOffset += slice.byteLength;
574
+ chunks.push(slice);
575
+ }
576
+ const bin = new ArrayBuffer(byteOffset);
577
+ const view = new Uint8Array(bin);
578
+ let pos = 0;
579
+ for (const chunk of chunks) {
580
+ view.set(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), pos);
581
+ pos += chunk.byteLength;
582
+ }
583
+ const manifest = {
584
+ sampleRate: 48e3,
585
+ referencePitch,
586
+ phonemes
587
+ };
588
+ return { manifest, bin };
589
+ }
590
+
591
+ // src/converter/frq.ts
592
+ function parseFrqAverageF0(buffer) {
593
+ if (buffer.byteLength < 20) return null;
594
+ const view = new DataView(buffer);
595
+ let header = "";
596
+ for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
597
+ if (header !== "FREQ0003") return null;
598
+ const avg = view.getFloat64(12, true);
599
+ return Number.isFinite(avg) && avg > 0 ? avg : null;
600
+ }
601
+ function frqFileName(wavName) {
602
+ const dot = wavName.lastIndexOf(".");
603
+ const base = dot >= 0 ? wavName.slice(0, dot) : wavName;
604
+ const ext = dot >= 0 ? wavName.slice(dot + 1) : "wav";
605
+ return `${base}_${ext}.frq`;
606
+ }
607
+ export {
608
+ KoeEngine,
609
+ MIN_WORLDLINE_SAMPLES,
610
+ VoiceBank,
611
+ WORLDLINE_SAMPLE_RATE,
612
+ Worldline,
613
+ detectF0,
614
+ frqFileName,
615
+ leadInFromEntry,
616
+ normalizePcm,
617
+ noteNameToHz,
618
+ pack,
619
+ packKoe,
620
+ parseFrqAverageF0,
621
+ parseKoeHeader,
622
+ parseOto,
623
+ parseWav,
624
+ pcmBase,
625
+ pitchFromAliasSuffix,
626
+ resample,
627
+ samplesToMs,
628
+ toInt16,
629
+ toMono,
630
+ trimToOto
631
+ };
632
+ //# sourceMappingURL=index.js.map