@onjmin/koe 1.0.7 → 1.0.9

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 CHANGED
@@ -1759,10 +1759,18 @@ var Worldline = class _Worldline {
1759
1759
  * {@link MIN_WORLDLINE_SAMPLES} (too short for stable F0 analysis).
1760
1760
  */
1761
1761
  renderPhrase(params) {
1762
- const { units, pitch, gender = 0.5, tension = 0.5, breathiness = 0.5, voicing = 1, tempo = 120 } = params;
1762
+ const {
1763
+ units,
1764
+ pitch,
1765
+ gender = 0.5,
1766
+ tension = 0.5,
1767
+ breathiness = 0.5,
1768
+ voicing = 1,
1769
+ tempo = 120
1770
+ } = params;
1763
1771
  if (units.length === 0) return null;
1764
1772
  const WL = this.wasm;
1765
- const FS = WORLDLINE_SAMPLE_RATE;
1773
+ const FS2 = WORLDLINE_SAMPLE_RATE;
1766
1774
  let totalMs = 0;
1767
1775
  for (const u of units) {
1768
1776
  const endMs = u.posMs + u.lengthMs;
@@ -1781,19 +1789,19 @@ var Worldline = class _Worldline {
1781
1789
  pointersToFree.push(samplePtr);
1782
1790
  WL.HEAPF64.set(u.pcm, samplePtr >> 3);
1783
1791
  const sv = (off, val, type) => WL.setValue(reqPtr + off, val, type);
1784
- sv(0, FS, "i32");
1792
+ sv(0, FS2, "i32");
1785
1793
  sv(4, u.pcm.length, "i32");
1786
1794
  sv(8, samplePtr, "*");
1787
1795
  sv(12, 0, "i32");
1788
1796
  sv(16, 0, "*");
1789
- sv(20, 69, "i32");
1797
+ sv(20, u.tone ?? 69, "i32");
1790
1798
  sv(24, 100, "double");
1791
1799
  sv(32, 0, "double");
1792
- sv(40, u.lengthMs, "double");
1800
+ sv(40, u.requiredLengthMs ?? u.lengthMs, "double");
1793
1801
  sv(48, u.consonantMs, "double");
1794
1802
  const cutMs = u.cutMs ?? WL_FRAME_MS * 2;
1795
1803
  sv(56, cutMs, "double");
1796
- sv(64, 100, "double");
1804
+ sv(64, u.volume ?? 100, "double");
1797
1805
  sv(72, 0, "double");
1798
1806
  sv(80, tempo, "double");
1799
1807
  sv(88, 0, "i32");
@@ -1804,7 +1812,16 @@ var Worldline = class _Worldline {
1804
1812
  sv(108, 0, "i32");
1805
1813
  sv(112, 0, "i32");
1806
1814
  sv(116, 100, "i32");
1807
- WL._PhraseSynthAddRequest(ps, reqPtr, u.posMs, u.skipMs, u.lengthMs, u.fadeInMs, u.fadeOutMs, 0);
1815
+ WL._PhraseSynthAddRequest(
1816
+ ps,
1817
+ reqPtr,
1818
+ u.posMs,
1819
+ u.skipMs,
1820
+ u.lengthMs,
1821
+ u.fadeInMs,
1822
+ u.fadeOutMs,
1823
+ 0
1824
+ );
1808
1825
  }
1809
1826
  totalMs += WL_FRAME_MS * 2;
1810
1827
  const nFrames = Math.ceil(totalMs / WL_FRAME_MS) + 4;
@@ -1832,7 +1849,16 @@ var Worldline = class _Worldline {
1832
1849
  WL.HEAPF64.set(tArr, tPtr >> 3);
1833
1850
  WL.HEAPF64.set(bArr, bPtr >> 3);
1834
1851
  WL.HEAPF64.set(vArr, vPtr >> 3);
1835
- WL._PhraseSynthSetCurves(ps, f0Ptr, gPtr, tPtr, bPtr, vPtr, nFrames, WL_FRAME_MS);
1852
+ WL._PhraseSynthSetCurves(
1853
+ ps,
1854
+ f0Ptr,
1855
+ gPtr,
1856
+ tPtr,
1857
+ bPtr,
1858
+ vPtr,
1859
+ nFrames,
1860
+ WL_FRAME_MS
1861
+ );
1836
1862
  }
1837
1863
  if (f0Ptr) WL._free(f0Ptr);
1838
1864
  if (gPtr) WL._free(gPtr);
@@ -1871,8 +1897,12 @@ var Worldline = class _Worldline {
1871
1897
  } = params;
1872
1898
  if (!pcm || pcm.length < MIN_WORLDLINE_SAMPLES) return null;
1873
1899
  const WL = this.wasm;
1874
- const FS = WORLDLINE_SAMPLE_RATE;
1875
- const basePitch = sampleCurve(preMs + durationMs / 2, pitch, preMs + durationMs);
1900
+ const FS2 = WORLDLINE_SAMPLE_RATE;
1901
+ const basePitch = sampleCurve(
1902
+ preMs + durationMs / 2,
1903
+ pitch,
1904
+ preMs + durationMs
1905
+ );
1876
1906
  const midiNote = Math.round(69 + 12 * Math.log2(basePitch / 440));
1877
1907
  const posMs = 0;
1878
1908
  const reqLen = preMs + durationMs;
@@ -1892,7 +1922,7 @@ var Worldline = class _Worldline {
1892
1922
  }
1893
1923
  WL.HEAPF64.set(pcm, samplePtr >> 3);
1894
1924
  const sv = (off, val, type) => WL.setValue(reqPtr + off, val, type);
1895
- sv(0, FS, "i32");
1925
+ sv(0, FS2, "i32");
1896
1926
  sv(4, pcm.length, "i32");
1897
1927
  sv(8, samplePtr, "*");
1898
1928
  sv(12, 0, "i32");
@@ -3146,12 +3176,847 @@ function formatOto(entries) {
3146
3176
  function encodeOto(entries) {
3147
3177
  return encodeShiftJis(formatOto(entries));
3148
3178
  }
3179
+
3180
+ // src/utautts/assets.ts
3181
+ var DEFAULT_CACHE = "koe-tts-assets-v1";
3182
+ async function openCache(name) {
3183
+ if (name === null) return null;
3184
+ try {
3185
+ if (typeof caches === "undefined") return null;
3186
+ return await caches.open(name ?? DEFAULT_CACHE);
3187
+ } catch {
3188
+ return null;
3189
+ }
3190
+ }
3191
+ async function cachedCopyIsCurrent(url, cached, signal) {
3192
+ let head;
3193
+ try {
3194
+ head = await fetch(url, { method: "HEAD", signal, cache: "no-cache" });
3195
+ } catch {
3196
+ return true;
3197
+ }
3198
+ if (!head.ok) return true;
3199
+ for (const name of ["etag", "last-modified", "content-length"]) {
3200
+ const remote = head.headers.get(name);
3201
+ const local = cached.headers.get(name);
3202
+ if (remote && local) return remote === local;
3203
+ }
3204
+ return true;
3205
+ }
3206
+ async function fetchAsset(url, options = {}) {
3207
+ const { onProgress, signal, revalidate = true } = options;
3208
+ const cache = await openCache(options.cacheName);
3209
+ if (cache) {
3210
+ try {
3211
+ const hit = await cache.match(url);
3212
+ if (hit && (!revalidate || await cachedCopyIsCurrent(url, hit, signal))) {
3213
+ const total2 = Number(hit.headers.get("content-length")) || 0;
3214
+ onProgress?.({ url, loaded: total2, total: total2, fromCache: true });
3215
+ return hit;
3216
+ }
3217
+ } catch {
3218
+ }
3219
+ }
3220
+ const response = await fetch(url, { signal });
3221
+ if (!response.ok) {
3222
+ throw new Error(`fetch ${url}: HTTP ${response.status}`);
3223
+ }
3224
+ const total = Number(response.headers.get("content-length")) || 0;
3225
+ let body;
3226
+ if (response.body && onProgress) {
3227
+ const reader = response.body.getReader();
3228
+ const parts = [];
3229
+ let loaded = 0;
3230
+ onProgress({ url, loaded, total, fromCache: false });
3231
+ for (; ; ) {
3232
+ const { done, value } = await reader.read();
3233
+ if (done) break;
3234
+ parts.push(value);
3235
+ loaded += value.byteLength;
3236
+ onProgress({ url, loaded, total, fromCache: false });
3237
+ }
3238
+ body = new Uint8Array(new ArrayBuffer(loaded));
3239
+ let offset = 0;
3240
+ for (const part of parts) {
3241
+ body.set(part, offset);
3242
+ offset += part.byteLength;
3243
+ }
3244
+ } else {
3245
+ body = new Uint8Array(await response.arrayBuffer());
3246
+ onProgress?.({
3247
+ url,
3248
+ loaded: body.byteLength,
3249
+ total: total || body.byteLength,
3250
+ fromCache: false
3251
+ });
3252
+ }
3253
+ const headers = new Headers();
3254
+ for (const name of ["content-type", "etag", "last-modified"]) {
3255
+ const value = response.headers.get(name);
3256
+ if (value) headers.set(name, value);
3257
+ }
3258
+ headers.set("content-length", String(body.byteLength));
3259
+ const buffered = new Response(body, { status: 200, headers });
3260
+ if (cache) {
3261
+ try {
3262
+ await cache.put(url, buffered.clone());
3263
+ } catch {
3264
+ }
3265
+ }
3266
+ return buffered;
3267
+ }
3268
+ async function fetchAssetBytes(url, options) {
3269
+ return new Uint8Array(await (await fetchAsset(url, options)).arrayBuffer());
3270
+ }
3271
+ async function fetchAssetText(url, options) {
3272
+ return (await fetchAsset(url, options)).text();
3273
+ }
3274
+ var NAIST_JDIC_FILES = [
3275
+ "metadata.json",
3276
+ "char_def.bin",
3277
+ "matrix.mtx",
3278
+ "dict.da",
3279
+ "dict.vals",
3280
+ "unk.bin",
3281
+ "dict.wordsidx",
3282
+ "dict.words"
3283
+ ];
3284
+ async function inflate(bytes) {
3285
+ if (typeof DecompressionStream === "undefined") {
3286
+ throw new Error(
3287
+ "DecompressionStream is not available; serve the dictionary uncompressed"
3288
+ );
3289
+ }
3290
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
3291
+ return new Uint8Array(await new Response(stream).arrayBuffer());
3292
+ }
3293
+ async function loadNaistJdic(baseUrl, options = {}) {
3294
+ const { compressed = true, ...fetchOptions } = options;
3295
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
3296
+ const entries = await Promise.all(
3297
+ NAIST_JDIC_FILES.map(async (name) => {
3298
+ const raw = await fetchAssetBytes(
3299
+ `${base}${name}${compressed ? ".gz" : ""}`,
3300
+ fetchOptions
3301
+ );
3302
+ return [name, compressed ? await inflate(raw) : raw];
3303
+ })
3304
+ );
3305
+ return Object.fromEntries(entries);
3306
+ }
3307
+ function initJpreprocessDictionary(module, data) {
3308
+ module.init_dictionary(
3309
+ data["metadata.json"],
3310
+ data["char_def.bin"],
3311
+ data["matrix.mtx"],
3312
+ data["dict.da"],
3313
+ data["dict.vals"],
3314
+ data["unk.bin"],
3315
+ data["dict.wordsidx"],
3316
+ data["dict.words"]
3317
+ );
3318
+ }
3319
+
3320
+ // src/utautts/hts.ts
3321
+ var NUCLEUS = /* @__PURE__ */ new Set([
3322
+ "a",
3323
+ "i",
3324
+ "u",
3325
+ "e",
3326
+ "o",
3327
+ "A",
3328
+ "I",
3329
+ "U",
3330
+ "E",
3331
+ "O",
3332
+ "N",
3333
+ "cl"
3334
+ ]);
3335
+ function softLimit(value, knee, max2) {
3336
+ const magnitude = Math.abs(value);
3337
+ const limited = magnitude <= knee ? magnitude : knee + (magnitude - knee) * 0.5;
3338
+ return Math.sign(value) * Math.min(max2, limited);
3339
+ }
3340
+ var DEVOICED = /* @__PURE__ */ new Set(["A", "I", "U", "E", "O"]);
3341
+ function segmentPhonemes(phonemes) {
3342
+ const segments = [];
3343
+ let openStart = null;
3344
+ for (let i = 0; i < phonemes.length; i++) {
3345
+ const { phone, start_ms, duration_ms } = phonemes[i];
3346
+ if (phone === "sil" || phone === "pau") {
3347
+ if (openStart !== null) {
3348
+ segments.push({
3349
+ pause: false,
3350
+ startMs: openStart,
3351
+ durationMs: start_ms - openStart,
3352
+ devoiced: false
3353
+ });
3354
+ openStart = null;
3355
+ }
3356
+ if (i === 0) continue;
3357
+ segments.push({
3358
+ pause: true,
3359
+ startMs: start_ms,
3360
+ durationMs: duration_ms,
3361
+ devoiced: false
3362
+ });
3363
+ continue;
3364
+ }
3365
+ if (openStart === null) openStart = start_ms;
3366
+ if (NUCLEUS.has(phone)) {
3367
+ segments.push({
3368
+ pause: false,
3369
+ startMs: openStart,
3370
+ durationMs: start_ms + duration_ms - openStart,
3371
+ devoiced: DEVOICED.has(phone)
3372
+ });
3373
+ openStart = null;
3374
+ }
3375
+ }
3376
+ if (openStart !== null) {
3377
+ const last = phonemes[phonemes.length - 1];
3378
+ segments.push({
3379
+ pause: false,
3380
+ startMs: openStart,
3381
+ durationMs: last.start_ms + last.duration_ms - openStart,
3382
+ devoiced: false
3383
+ });
3384
+ }
3385
+ return segments;
3386
+ }
3387
+ function alignHtsProsody(frames, features, options = {}) {
3388
+ const {
3389
+ intonationStrength = 1,
3390
+ frameMs = 10,
3391
+ fallbackPauseMs = 150,
3392
+ kneeCents = 400,
3393
+ maxCents = 700
3394
+ } = options;
3395
+ const segments = segmentPhonemes(frames.phonemes);
3396
+ const durations = new Array(features.length).fill(0);
3397
+ const htsStart = new Array(features.length).fill(null);
3398
+ const devoiced = new Array(features.length).fill(false);
3399
+ let s = 0;
3400
+ for (let f2 = 0; f2 < features.length; f2++) {
3401
+ const feature = features[f2];
3402
+ while (s < segments.length && segments[s].pause && !feature.pause) s++;
3403
+ const segment = segments[s];
3404
+ if (feature.pause) {
3405
+ if (segment?.pause) {
3406
+ durations[f2] = segment.durationMs;
3407
+ htsStart[f2] = segment.startMs;
3408
+ s++;
3409
+ } else {
3410
+ durations[f2] = fallbackPauseMs;
3411
+ }
3412
+ continue;
3413
+ }
3414
+ if (!segment) return null;
3415
+ durations[f2] = segment.durationMs;
3416
+ htsStart[f2] = segment.startMs;
3417
+ devoiced[f2] = segment.devoiced;
3418
+ s++;
3419
+ }
3420
+ if (segments.slice(s).some((segment) => !segment.pause)) return null;
3421
+ const f0 = frames.f0_hz;
3422
+ const logF0 = new Float64Array(f0.length);
3423
+ const voiced = [];
3424
+ for (let i = 0; i < f0.length; i++) if (f0[i] > 0) voiced.push(i);
3425
+ if (voiced.length === 0) return null;
3426
+ const sorted = voiced.map((i) => f0[i]).sort((a, b) => a - b);
3427
+ const medianHz = sorted[Math.floor(sorted.length / 2)];
3428
+ for (let i = 0, v = 0; i < f0.length; i++) {
3429
+ if (f0[i] > 0) {
3430
+ logF0[i] = Math.log2(f0[i]);
3431
+ continue;
3432
+ }
3433
+ while (v < voiced.length && voiced[v] < i) v++;
3434
+ const right = voiced[v];
3435
+ const left = v > 0 ? voiced[v - 1] : void 0;
3436
+ if (left === void 0) logF0[i] = Math.log2(f0[right]);
3437
+ else if (right === void 0) logF0[i] = Math.log2(f0[left]);
3438
+ else {
3439
+ const t = (i - left) / (right - left);
3440
+ logF0[i] = Math.log2(f0[left]) * (1 - t) + Math.log2(f0[right]) * t;
3441
+ }
3442
+ }
3443
+ const logMedian = Math.log2(medianHz);
3444
+ const htsCentsAt = (tMs) => {
3445
+ const position = Math.max(0, tMs) / frames.frame_ms;
3446
+ const left = Math.min(logF0.length - 1, Math.floor(position));
3447
+ const right = Math.min(logF0.length - 1, left + 1);
3448
+ const t = position - Math.floor(position);
3449
+ return (logF0[left] * (1 - t) + logF0[right] * t - logMedian) * 1200;
3450
+ };
3451
+ const planStart = [];
3452
+ let cursor = 0;
3453
+ for (let f2 = 0; f2 < features.length; f2++) {
3454
+ planStart.push(cursor);
3455
+ cursor += durations[f2];
3456
+ }
3457
+ const durationMs = cursor;
3458
+ const count = Math.max(2, Math.ceil(durationMs / frameMs) + 2);
3459
+ const cents = new Array(count);
3460
+ let f = 0;
3461
+ for (let frame = 0; frame < count; frame++) {
3462
+ const tMs = frame * frameMs;
3463
+ while (f + 1 < features.length && tMs >= planStart[f + 1]) f++;
3464
+ const start = htsStart[f];
3465
+ let htsMs;
3466
+ if (start !== null) htsMs = start + (tMs - planStart[f]);
3467
+ else {
3468
+ const next = htsStart.slice(f + 1).find((value) => value !== null);
3469
+ htsMs = next ?? frames.phonemes.at(-1)?.start_ms ?? 0;
3470
+ }
3471
+ cents[frame] = softLimit(
3472
+ htsCentsAt(htsMs) * intonationStrength,
3473
+ kneeCents,
3474
+ maxCents
3475
+ );
3476
+ }
3477
+ return {
3478
+ moraDurationsMs: durations,
3479
+ pitchCurve: { frame_ms: frameMs, cents },
3480
+ medianHz,
3481
+ durationMs,
3482
+ devoiced
3483
+ };
3484
+ }
3485
+
3486
+ // src/utautts/openjtalk_features.ts
3487
+ var PUNCTUATION = /* @__PURE__ */ new Set(["\u3001", "\u3002", "\uFF1F", "\uFF01", ",", ".", "?", "!"]);
3488
+ var SMALL_KANA = /* @__PURE__ */ new Set(["\u3041", "\u3043", "\u3045", "\u3047", "\u3049", "\u3083", "\u3085", "\u3087", "\u308E", "\u3095", "\u3096"]);
3489
+ function toHiragana2(character) {
3490
+ const code = character.charCodeAt(0);
3491
+ if (12449 <= code && code <= 12534) {
3492
+ return String.fromCharCode(code - 96);
3493
+ }
3494
+ return character;
3495
+ }
3496
+ function splitMorae(reading) {
3497
+ const result = [];
3498
+ const normalized = reading.normalize("NFC").replace(/'/g, "").replace(/’/g, "");
3499
+ for (const character of normalized) {
3500
+ if (/\s/.test(character) || PUNCTUATION.has(character)) {
3501
+ if (result.length > 0 && !result[result.length - 1].pause) {
3502
+ result.push({ mora: "", pause: true });
3503
+ }
3504
+ continue;
3505
+ }
3506
+ const mora = toHiragana2(character);
3507
+ if (SMALL_KANA.has(mora) && result.length > 0 && !result[result.length - 1].pause) {
3508
+ result[result.length - 1].mora += mora;
3509
+ } else {
3510
+ result.push({ mora, pause: false });
3511
+ }
3512
+ }
3513
+ return result;
3514
+ }
3515
+ function isHigh(position, accent) {
3516
+ if (accent === 1) return position === 1;
3517
+ if (accent > 1) return 2 <= position && position <= accent;
3518
+ return position >= 2;
3519
+ }
3520
+ function analyze2(nodes) {
3521
+ const reading_parts = [];
3522
+ const result = [];
3523
+ let index = 0;
3524
+ while (index < nodes.length) {
3525
+ const node = nodes[index];
3526
+ const pronunciation = (node.pron || "").replace(/'/g, "").replace(/’/g, "");
3527
+ if (node.mora_size === 0 || PUNCTUATION.has(node.string)) {
3528
+ reading_parts.push(node.string || "\u3001");
3529
+ if (result.length > 0 && !result[result.length - 1].pause) {
3530
+ result.push({ mora: "", pause: true });
3531
+ }
3532
+ index++;
3533
+ continue;
3534
+ }
3535
+ const phrase_nodes = [];
3536
+ while (index < nodes.length) {
3537
+ const current = nodes[index];
3538
+ if (current.mora_size === 0 || PUNCTUATION.has(current.string)) {
3539
+ break;
3540
+ }
3541
+ if (phrase_nodes.length > 0 && current.chain_flag !== 1) {
3542
+ break;
3543
+ }
3544
+ const current_pronunciation = (current.pron || "").replace(/'/g, "").replace(/’/g, "");
3545
+ const morae = splitMorae(current_pronunciation).filter((item) => !item.pause);
3546
+ phrase_nodes.push({ current, morae });
3547
+ reading_parts.push(current_pronunciation);
3548
+ index++;
3549
+ }
3550
+ const phrase_length = phrase_nodes.reduce((sum, item) => sum + item.morae.length, 0);
3551
+ const accent = phrase_nodes[0].current.acc || 0;
3552
+ let phrase_position = 0;
3553
+ for (const { current, morae } of phrase_nodes) {
3554
+ for (let word_position = 1; word_position <= morae.length; word_position++) {
3555
+ phrase_position++;
3556
+ result.push({
3557
+ mora: morae[word_position - 1].mora,
3558
+ pause: false,
3559
+ accent_phrase_position: phrase_position,
3560
+ accent_phrase_length: phrase_length,
3561
+ accent_nucleus: accent,
3562
+ accent_high: isHigh(phrase_position, accent),
3563
+ accent_phrase_start: phrase_position === 1,
3564
+ accent_phrase_end: phrase_position === phrase_length,
3565
+ word_start: word_position === 1,
3566
+ word_end: word_position === morae.length,
3567
+ pos: current.pos || "*",
3568
+ pos_group1: current.pos_group1 || "*"
3569
+ });
3570
+ }
3571
+ }
3572
+ }
3573
+ return { reading: reading_parts.join(""), features: result };
3574
+ }
3575
+ function sparse_features(token) {
3576
+ if (token.pause || token.accent_phrase_position === void 0 || token.accent_phrase_length === void 0 || token.accent_nucleus === void 0) {
3577
+ return {};
3578
+ }
3579
+ const phrase_length = Math.max(1, token.accent_phrase_length);
3580
+ const phrase_position = token.accent_phrase_position;
3581
+ const nucleus = token.accent_nucleus;
3582
+ const result = {
3583
+ "accent_position": phrase_position / phrase_length,
3584
+ "accent_from_end": (phrase_length - phrase_position) / phrase_length,
3585
+ "accent_nucleus_position": nucleus / phrase_length,
3586
+ "accent_high": token.accent_high ? 1 : 0,
3587
+ "accent_phrase_start": token.accent_phrase_start ? 1 : 0,
3588
+ "accent_phrase_end": token.accent_phrase_end ? 1 : 0,
3589
+ "word_start": token.word_start ? 1 : 0,
3590
+ "word_end": token.word_end ? 1 : 0
3591
+ };
3592
+ result[`pos=${token.pos || "*"}`] = 1;
3593
+ result[`pos_group1=${token.pos_group1 || "*"}`] = 1;
3594
+ if (nucleus === 0) {
3595
+ result["accent_type=heiban"] = 1;
3596
+ } else if (phrase_position < nucleus) {
3597
+ result["accent_type=before"] = 1;
3598
+ } else if (phrase_position === nucleus) {
3599
+ result["accent_type=nucleus"] = 1;
3600
+ } else {
3601
+ result["accent_type=after"] = 1;
3602
+ }
3603
+ return result;
3604
+ }
3605
+
3606
+ // src/utautts/prosody_rules.ts
3607
+ var TRAILING = /[\s」』))〕】>>"'”’]+$/u;
3608
+ function isQuestion(text) {
3609
+ return /[??]$/u.test(text.replace(TRAILING, ""));
3610
+ }
3611
+ function smoothstep(t) {
3612
+ const x = Math.min(1, Math.max(0, t));
3613
+ return x * x * (3 - 2 * x);
3614
+ }
3615
+ function shapeProsody(prosody, features, options = {}) {
3616
+ const {
3617
+ question = false,
3618
+ questionRiseCents = 350,
3619
+ energyDbPerSemitone = 0.5,
3620
+ devoicedGain = 0.5,
3621
+ minGain = 0.35,
3622
+ maxGain = 1.8
3623
+ } = options;
3624
+ const { frame_ms: frameMs } = prosody.pitchCurve;
3625
+ const cents = prosody.pitchCurve.cents.slice();
3626
+ const count = Math.min(features.length, prosody.moraDurationsMs.length);
3627
+ const startFrame = [];
3628
+ const endFrame = [];
3629
+ let cursor = 0;
3630
+ for (let f = 0; f < count; f++) {
3631
+ startFrame.push(cursor / frameMs);
3632
+ cursor += prosody.moraDurationsMs[f];
3633
+ endFrame.push(cursor / frameMs);
3634
+ }
3635
+ const frameRange = (f) => [
3636
+ Math.max(0, Math.min(cents.length, Math.round(startFrame[f]))),
3637
+ Math.max(0, Math.min(cents.length, Math.round(endFrame[f])))
3638
+ ];
3639
+ if (question && questionRiseCents > 0) {
3640
+ let last = -1;
3641
+ for (let f = count - 1; f >= 0; f--) {
3642
+ if (!features[f].pause) {
3643
+ last = f;
3644
+ break;
3645
+ }
3646
+ }
3647
+ if (last >= 0) {
3648
+ const [from, to] = frameRange(last);
3649
+ if (to > from) {
3650
+ const existing = cents[to - 1] - cents[from];
3651
+ const shortfall = questionRiseCents - existing;
3652
+ if (shortfall > 0) {
3653
+ for (let frame = from; frame < cents.length; frame++) {
3654
+ const t = (frame - from) / (to - from);
3655
+ cents[frame] += shortfall * smoothstep((t - 0.25) / 0.75);
3656
+ }
3657
+ }
3658
+ }
3659
+ }
3660
+ }
3661
+ const moraGains = new Array(features.length).fill(1);
3662
+ for (let f = 0; f < count; f++) {
3663
+ if (features[f].pause) continue;
3664
+ let gainDb = 0;
3665
+ if (energyDbPerSemitone !== 0) {
3666
+ const [from, to] = frameRange(f);
3667
+ if (to > from) {
3668
+ let sum = 0;
3669
+ for (let frame = from; frame < to; frame++) sum += cents[frame];
3670
+ gainDb += sum / (to - from) / 100 * energyDbPerSemitone;
3671
+ }
3672
+ }
3673
+ let gain = 10 ** (gainDb / 20);
3674
+ if (prosody.devoiced[f]) gain *= devoicedGain;
3675
+ moraGains[f] = Math.min(maxGain, Math.max(minGain, gain));
3676
+ }
3677
+ return {
3678
+ ...prosody,
3679
+ pitchCurve: { frame_ms: frameMs, cents },
3680
+ moraGains
3681
+ };
3682
+ }
3683
+
3684
+ // src/utautts/UtauTTSAdapter.ts
3685
+ function readingFromFeatures(features) {
3686
+ return features.map((frame) => frame.pause ? "\u3001" : frame.mora).join("");
3687
+ }
3688
+ var FS = WORLDLINE_SAMPLE_RATE;
3689
+ var msToSamples2 = (ms) => Math.round(ms / 1e3 * FS);
3690
+ function f0At(timeline, tMs) {
3691
+ const curve = timeline.f0_curve;
3692
+ if (curve.length === 0) return timeline.reference_hz || 220;
3693
+ const position = Math.max(0, tMs) / timeline.frame_ms;
3694
+ const left = Math.floor(position);
3695
+ if (left >= curve.length - 1) return curve[curve.length - 1];
3696
+ const progress = position - left;
3697
+ return curve[left] * (1 - progress) + curve[left + 1] * progress;
3698
+ }
3699
+ function applyMoraGains(plan, gains) {
3700
+ const morae = plan.morae ?? [];
3701
+ const timings = plan.mora_timings ?? [];
3702
+ const byIndex = morae.length === gains.length;
3703
+ const moraAt = (unit) => {
3704
+ if (byIndex) return unit.position;
3705
+ let found = -1;
3706
+ for (let i = 0; i < timings.length && i < gains.length; i++) {
3707
+ if (timings[i].StartMS <= unit.note_start_ms + 1e-6) found = i;
3708
+ else break;
3709
+ }
3710
+ return found;
3711
+ };
3712
+ for (const unit of plan.timeline.units) {
3713
+ const index = moraAt(unit);
3714
+ const gain = gains[index];
3715
+ if (index < 0 || gain === void 0 || !Number.isFinite(gain)) continue;
3716
+ unit.volume *= gain;
3717
+ }
3718
+ }
3719
+ function planChunks(units, firstChunkUnits, chunkUnits) {
3720
+ const ranges = [];
3721
+ if (units.length === 0) return ranges;
3722
+ let start = 0;
3723
+ let coveredEndMs = units[0].position_ms + units[0].length_ms;
3724
+ let headSeam = false;
3725
+ for (let i = 1; i <= units.length; i++) {
3726
+ const limit = ranges.length === 0 ? firstChunkUnits : chunkUnits;
3727
+ const atEnd = i === units.length;
3728
+ const clean = !atEnd && units[i].position_ms >= coveredEndMs - 1e-6;
3729
+ const full = !atEnd && i - start >= limit;
3730
+ if (atEnd || clean || full) {
3731
+ const tailSeam = !atEnd && !clean;
3732
+ ranges.push({ start, end: i, headSeam, tailSeam });
3733
+ start = i;
3734
+ headSeam = tailSeam;
3735
+ if (!atEnd) coveredEndMs = units[i].position_ms + units[i].length_ms;
3736
+ continue;
3737
+ }
3738
+ coveredEndMs = Math.max(
3739
+ coveredEndMs,
3740
+ units[i].position_ms + units[i].length_ms
3741
+ );
3742
+ }
3743
+ return ranges;
3744
+ }
3745
+ function seamTimeMs(next, crossfadeMs) {
3746
+ const earliest = next.position_ms + next.fade_in_ms + crossfadeMs / 2;
3747
+ const latest = next.position_ms + next.length_ms - crossfadeMs / 2;
3748
+ return Math.min(
3749
+ earliest,
3750
+ Math.max(next.position_ms + crossfadeMs / 2, latest)
3751
+ );
3752
+ }
3753
+ function applyFade(pcm, fromSample, samples, fadeIn) {
3754
+ const n = Math.max(1, Math.min(samples, pcm.length - fromSample));
3755
+ for (let k = 0; k < n; k++) {
3756
+ const t = (k + 0.5) / n;
3757
+ const gain = fadeIn ? Math.sin(Math.PI / 2 * t) : Math.cos(Math.PI / 2 * t);
3758
+ pcm[fromSample + k] *= gain;
3759
+ }
3760
+ }
3761
+ var UtauTTSAdapter = class _UtauTTSAdapter {
3762
+ constructor(worldline) {
3763
+ this.worldline = worldline;
3764
+ }
3765
+ worldline;
3766
+ static modelId = null;
3767
+ bankAliases = /* @__PURE__ */ new WeakMap();
3768
+ currentBank = null;
3769
+ pcmCache = /* @__PURE__ */ new Map();
3770
+ /**
3771
+ * Load the UtauTTS Go wasm. `wasm_exec.js` must already be on the page.
3772
+ * Pass `fetch` (e.g. koe's `fetchAsset`) to stream from the Cache API.
3773
+ */
3774
+ static async initializeWasm(wasmUrl = "utautts.wasm", options = {}) {
3775
+ if (typeof utautts_plan === "function") return;
3776
+ if (typeof Go === "undefined") {
3777
+ throw new Error(
3778
+ "wasm_exec.js must be loaded before calling initializeWasm."
3779
+ );
3780
+ }
3781
+ const go = new Go();
3782
+ const responsePromise = (options.fetch ?? fetch)(wasmUrl);
3783
+ let instance;
3784
+ try {
3785
+ instance = (await WebAssembly.instantiateStreaming(responsePromise, go.importObject)).instance;
3786
+ } catch {
3787
+ const bytes = await (await (options.fetch ?? fetch)(wasmUrl)).arrayBuffer();
3788
+ instance = (await WebAssembly.instantiate(bytes, go.importObject)).instance;
3789
+ }
3790
+ void go.run(instance);
3791
+ for (let attempt = 0; attempt < 100 && typeof utautts_plan !== "function"; attempt++) {
3792
+ await new Promise((resolve) => setTimeout(resolve, 10));
3793
+ }
3794
+ if (typeof utautts_plan !== "function") {
3795
+ throw new Error("utautts_plan failed to initialize in global scope.");
3796
+ }
3797
+ }
3798
+ static get ready() {
3799
+ return typeof utautts_plan === "function";
3800
+ }
3801
+ /** Parse and cache a prosody model (e.g. `frame-intonation-v8.json`) inside the wasm. */
3802
+ static setModel(modelJSON) {
3803
+ _UtauTTSAdapter.assertReady();
3804
+ const result = utautts_set_model(modelJSON);
3805
+ if (!result.success)
3806
+ throw new Error(`UtauTTS model error: ${result.error}`);
3807
+ _UtauTTSAdapter.modelId = result.id ?? null;
3808
+ return _UtauTTSAdapter.modelId;
3809
+ }
3810
+ static get currentModelId() {
3811
+ return _UtauTTSAdapter.modelId;
3812
+ }
3813
+ static assertReady() {
3814
+ if (typeof utautts_plan !== "function") {
3815
+ throw new Error(
3816
+ "UtauTTS wasm is not initialized; call UtauTTSAdapter.initializeWasm() first."
3817
+ );
3818
+ }
3819
+ }
3820
+ /**
3821
+ * Register a koe voice bank with the planner: the manifest becomes a virtual
3822
+ * oto.ini (koe PCM is pre-trimmed, so offset is 0) plus each sample's
3823
+ * recorded pitch. Called automatically by {@link plan}; cheap when unchanged.
3824
+ */
3825
+ setBank(bank) {
3826
+ _UtauTTSAdapter.assertReady();
3827
+ if (this.currentBank === bank && this.bankAliases.has(bank)) return;
3828
+ const entries = {};
3829
+ const pitch = {};
3830
+ for (const [alias, phoneme] of Object.entries(bank.manifest.phonemes)) {
3831
+ entries[alias] = [
3832
+ {
3833
+ Filename: `koe:${alias}`,
3834
+ Alias: alias,
3835
+ Offset: 0,
3836
+ Fixed: phoneme.consonant / 48,
3837
+ Blank: 0,
3838
+ Preutterance: phoneme.pre / 48,
3839
+ Overlap: phoneme.overlap / 48,
3840
+ SourceGroup: "koe"
3841
+ }
3842
+ ];
3843
+ if (phoneme.pitch > 0) pitch[alias] = phoneme.pitch;
3844
+ }
3845
+ const result = utautts_set_bank(
3846
+ JSON.stringify({
3847
+ name: "koe",
3848
+ oto_entries: entries,
3849
+ source_pitch_hz: pitch
3850
+ })
3851
+ );
3852
+ if (!result.success) throw new Error(`UtauTTS bank error: ${result.error}`);
3853
+ this.bankAliases.set(bank, result.aliases ?? 0);
3854
+ this.currentBank = bank;
3855
+ this.pcmCache.clear();
3856
+ }
3857
+ /**
3858
+ * Plan an utterance: unit selection, timing, pitch contour and worldline
3859
+ * placement. `features` are the mora-level frames from `openjtalkAnalyze`
3860
+ * (pauses included); the kana reading is derived from them.
3861
+ */
3862
+ plan(bank, text, features, options = {}) {
3863
+ this.setBank(bank);
3864
+ const request = {
3865
+ text,
3866
+ reading: readingFromFeatures(features),
3867
+ frames: features.map((frame) => sparse_features(frame)),
3868
+ tone: options.tone ?? "C4",
3869
+ mora_duration_ms: options.moraDurationMs ?? 0,
3870
+ pause_duration_ms: options.pauseDurationMs ?? 0,
3871
+ release_ms: options.releaseMs ?? 20,
3872
+ leading_preutterance_ms: options.leadingPreutteranceMs ?? 0,
3873
+ apply_pitch: options.applyPitch ?? true,
3874
+ intonation_strength: options.intonationStrength ?? 1,
3875
+ speech_timing: options.speechTiming ?? false,
3876
+ word_boundary_envelope: options.wordBoundaryEnvelope ?? false,
3877
+ mora_durations_ms: options.prosody?.moraDurationsMs,
3878
+ pitch_curve: options.prosody?.pitchCurve
3879
+ };
3880
+ const response = utautts_plan(JSON.stringify(request));
3881
+ if (!response.success || !response.plan) {
3882
+ throw new Error(`UtauTTS error: ${response.error ?? "no plan"}`);
3883
+ }
3884
+ const plan = JSON.parse(response.plan);
3885
+ const gains = options.prosody?.moraGains;
3886
+ if (gains) applyMoraGains(plan, gains);
3887
+ return plan;
3888
+ }
3889
+ getPcm(bank, alias) {
3890
+ let cached = this.pcmCache.get(alias);
3891
+ if (!cached) {
3892
+ cached = bank.getPcm(alias);
3893
+ this.pcmCache.set(alias, cached);
3894
+ }
3895
+ return cached;
3896
+ }
3897
+ /**
3898
+ * Render a plan chunk by chunk. Each chunk is independent audio positioned
3899
+ * at `startMs`; schedule them as they arrive (see the demo) or sum them.
3900
+ *
3901
+ * Chunk breaks fall on pauses when possible. Inside a phrase a break renders
3902
+ * one neighbouring unit of context on each side so the unit crossfade stays
3903
+ * WORLD's spectral one, then the two renders are joined with a short
3904
+ * equal-power crossfade in the following vowel.
3905
+ */
3906
+ async *renderChunks(bank, plan, options = {}) {
3907
+ const {
3908
+ firstChunkUnits = 3,
3909
+ chunkUnits = 6,
3910
+ seamCrossfadeMs = 20,
3911
+ signal
3912
+ } = options;
3913
+ const timeline = plan.timeline;
3914
+ const units = timeline.units.filter((unit) => unit.length_ms > 0);
3915
+ const ranges = planChunks(
3916
+ units,
3917
+ Math.max(1, firstChunkUnits),
3918
+ Math.max(1, chunkUnits)
3919
+ );
3920
+ const crossfadeSamples = Math.max(2, msToSamples2(seamCrossfadeMs));
3921
+ for (let index = 0; index < ranges.length; index++) {
3922
+ if (signal?.aborted) return;
3923
+ const range = ranges[index];
3924
+ const renderStart = range.headSeam ? range.start - 1 : range.start;
3925
+ const renderEnd = range.tailSeam ? range.end + 1 : range.end;
3926
+ const rendered = units.slice(renderStart, renderEnd);
3927
+ const baseMs = Math.min(...rendered.map((unit) => unit.position_ms));
3928
+ const phraseUnits = [];
3929
+ for (const unit of rendered) {
3930
+ const pcm2 = await this.getPcm(bank, unit.alias);
3931
+ if (!pcm2 || pcm2.length < MIN_WORLDLINE_SAMPLES) {
3932
+ console.warn(
3933
+ `[utautts] no usable PCM for alias "${unit.alias}"; skipped`
3934
+ );
3935
+ continue;
3936
+ }
3937
+ phraseUnits.push({
3938
+ pcm: pcm2,
3939
+ posMs: unit.position_ms - baseMs,
3940
+ skipMs: unit.skip_ms,
3941
+ lengthMs: unit.length_ms,
3942
+ fadeInMs: unit.fade_in_ms,
3943
+ fadeOutMs: unit.fade_out_ms,
3944
+ consonantMs: unit.consonant_ms,
3945
+ requiredLengthMs: unit.required_length_ms,
3946
+ volume: unit.volume,
3947
+ tone: unit.tone
3948
+ });
3949
+ }
3950
+ if (signal?.aborted) return;
3951
+ if (phraseUnits.length === 0) continue;
3952
+ const audio = this.worldline.renderPhrase({
3953
+ units: phraseUnits,
3954
+ pitch: (tMs) => f0At(timeline, baseMs + tMs),
3955
+ gender: options.gender,
3956
+ tension: options.tension,
3957
+ breathiness: options.breathiness,
3958
+ voicing: options.voicing
3959
+ });
3960
+ if (!audio || audio.length === 0) continue;
3961
+ let fromSample = 0;
3962
+ let toSample = audio.length;
3963
+ let startMs = baseMs;
3964
+ if (range.headSeam) {
3965
+ const seamMs = seamTimeMs(units[range.start], seamCrossfadeMs);
3966
+ fromSample = Math.max(
3967
+ 0,
3968
+ msToSamples2(seamMs - baseMs) - crossfadeSamples / 2
3969
+ );
3970
+ startMs = baseMs + fromSample / FS * 1e3;
3971
+ }
3972
+ if (range.tailSeam) {
3973
+ const seamMs = seamTimeMs(units[range.end], seamCrossfadeMs);
3974
+ toSample = Math.min(
3975
+ audio.length,
3976
+ msToSamples2(seamMs - baseMs) + crossfadeSamples / 2
3977
+ );
3978
+ }
3979
+ if (toSample <= fromSample) continue;
3980
+ const pcm = audio.slice(fromSample, toSample);
3981
+ if (range.headSeam) applyFade(pcm, 0, crossfadeSamples, true);
3982
+ if (range.tailSeam)
3983
+ applyFade(
3984
+ pcm,
3985
+ Math.max(0, pcm.length - crossfadeSamples),
3986
+ crossfadeSamples,
3987
+ false
3988
+ );
3989
+ yield { pcm, startMs, index, units: units.slice(range.start, range.end) };
3990
+ await new Promise((resolve) => setTimeout(resolve, 0));
3991
+ }
3992
+ }
3993
+ /**
3994
+ * Plan + render an utterance into one buffer (Float32, 48 kHz) covering the
3995
+ * whole timeline. Use {@link plan} + {@link renderChunks} for streaming.
3996
+ */
3997
+ async synthesizeText(bank, text, features, options = {}) {
3998
+ const plan = this.plan(bank, text, features, options);
3999
+ const total = msToSamples2(plan.timeline.duration_ms) + msToSamples2(200);
4000
+ const out = new Float32Array(total);
4001
+ let any = false;
4002
+ for await (const chunk of this.renderChunks(bank, plan, options)) {
4003
+ any = true;
4004
+ const offset = msToSamples2(chunk.startMs);
4005
+ const n = Math.min(chunk.pcm.length, out.length - offset);
4006
+ for (let k = 0; k < n; k++) out[offset + k] += chunk.pcm[k];
4007
+ }
4008
+ return any ? out : null;
4009
+ }
4010
+ };
3149
4011
  export {
3150
4012
  KoeEngine,
3151
4013
  MIN_WORLDLINE_SAMPLES,
4014
+ NAIST_JDIC_FILES,
4015
+ UtauTTSAdapter,
3152
4016
  VoiceBank,
3153
4017
  WORLDLINE_SAMPLE_RATE,
3154
4018
  Worldline,
4019
+ alignHtsProsody,
3155
4020
  analyze,
3156
4021
  analyzeWav,
3157
4022
  detectF0,
@@ -3161,15 +4026,22 @@ export {
3161
4026
  estimateSequence,
3162
4027
  estimateSolo,
3163
4028
  estimateVowelJoin,
4029
+ fetchAsset,
4030
+ fetchAssetBytes,
4031
+ fetchAssetText,
3164
4032
  formatOto,
3165
4033
  frqAverageF0InRange,
3166
4034
  frqFileName,
3167
4035
  generateOto,
3168
4036
  generateOtoForFile,
4037
+ initJpreprocessDictionary,
4038
+ isQuestion,
3169
4039
  leadInFromEntry,
4040
+ loadNaistJdic,
3170
4041
  locateMora,
3171
4042
  normalizePcm,
3172
4043
  noteNameToHz,
4044
+ analyze2 as openjtalkAnalyze,
3173
4045
  otoRegion,
3174
4046
  pack,
3175
4047
  packKoe,
@@ -3181,8 +4053,11 @@ export {
3181
4053
  pcmBase,
3182
4054
  pitchFromAliasSuffix,
3183
4055
  readWavPcm48k,
4056
+ readingFromFeatures,
3184
4057
  resample,
3185
4058
  samplesToMs,
4059
+ shapeProsody,
4060
+ sparse_features,
3186
4061
  splitKana,
3187
4062
  suffixFromFolderName,
3188
4063
  summarise,