@onjmin/koe 1.0.8 → 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");
@@ -3147,119 +3177,846 @@ function encodeOto(entries) {
3147
3177
  return encodeShiftJis(formatOto(entries));
3148
3178
  }
3149
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
+
3150
3684
  // src/utautts/UtauTTSAdapter.ts
3151
- var UtauTTSAdapter = class {
3152
- worldline;
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 {
3153
3762
  constructor(worldline) {
3154
3763
  this.worldline = worldline;
3155
3764
  }
3765
+ worldline;
3766
+ static modelId = null;
3767
+ bankAliases = /* @__PURE__ */ new WeakMap();
3768
+ currentBank = null;
3769
+ pcmCache = /* @__PURE__ */ new Map();
3156
3770
  /**
3157
- * Wasmモジュールを初期化します。
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.
3158
3773
  */
3159
- static async initializeWasm(wasmUrl = "utautts.wasm") {
3774
+ static async initializeWasm(wasmUrl = "utautts.wasm", options = {}) {
3775
+ if (typeof utautts_plan === "function") return;
3160
3776
  if (typeof Go === "undefined") {
3161
- throw new Error("wasm_exec.js must be loaded before calling initializeWasm.");
3777
+ throw new Error(
3778
+ "wasm_exec.js must be loaded before calling initializeWasm."
3779
+ );
3162
3780
  }
3163
3781
  const go = new Go();
3164
- const response = await fetch(wasmUrl);
3165
- const buffer = await response.arrayBuffer();
3166
- const result = await WebAssembly.instantiate(buffer, go.importObject);
3167
- go.run(result.instance);
3168
- await new Promise((resolve) => setTimeout(resolve, 50));
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
+ }
3169
3794
  if (typeof utautts_plan !== "function") {
3170
3795
  throw new Error("utautts_plan failed to initialize in global scope.");
3171
3796
  }
3172
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
+ }
3173
3820
  /**
3174
- * koe VoiceBank から、UtauTTS Wasm に渡すための仮想 oto.ini 辞書を構築します。
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.
3175
3824
  */
3176
- buildOtoEntriesFromKoe(bank) {
3825
+ setBank(bank) {
3826
+ _UtauTTSAdapter.assertReady();
3827
+ if (this.currentBank === bank && this.bankAliases.has(bank)) return;
3177
3828
  const entries = {};
3178
- const manifest = bank.manifest;
3179
- for (const [alias, phoneme] of Object.entries(manifest.phonemes)) {
3180
- entries[alias] = [{
3181
- Filename: `v_${alias}.wav`,
3182
- // Wasm内での識別用ダミーファイル名
3183
- Alias: alias,
3184
- Offset: 0,
3185
- // koe は事前にトリミング済みのため常に0
3186
- Fixed: phoneme.consonant / 48,
3187
- Preutterance: phoneme.pre / 48,
3188
- Overlap: phoneme.overlap / 48,
3189
- SourceGroup: "koe-bank"
3190
- }];
3191
- }
3192
- return 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();
3193
3856
  }
3194
3857
  /**
3195
- * 指定したテキストを合成するための Plan (発話計画) と音声を生成します。
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.
3196
3861
  */
3197
- async synthesizeText(bank, text, tone = "C4") {
3198
- const otoEntries = this.buildOtoEntriesFromKoe(bank);
3862
+ plan(bank, text, features, options = {}) {
3863
+ this.setBank(bank);
3199
3864
  const request = {
3200
3865
  text,
3201
- oto_entries: otoEntries,
3202
- tone,
3203
- duration_ms: 0
3204
- // UtauTTS側に自動計算させる
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
3205
3879
  };
3206
3880
  const response = utautts_plan(JSON.stringify(request));
3207
- if (!response.success) {
3208
- throw new Error(`UtauTTS Error: ${response.error}`);
3881
+ if (!response.success || !response.plan) {
3882
+ throw new Error(`UtauTTS error: ${response.error ?? "no plan"}`);
3209
3883
  }
3210
3884
  const plan = JSON.parse(response.plan);
3211
- console.log("Generated TTS Plan:", plan);
3212
- const phraseUnits = [];
3213
- let currentPosMs = 0;
3214
- for (const unit of plan.units) {
3215
- const pcm = await bank.getPcm(unit.alias);
3216
- if (!pcm) {
3217
- console.warn(`PCM not found for alias: ${unit.alias}`);
3218
- continue;
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
+ });
3219
3949
  }
3220
- phraseUnits.push({
3221
- pcm,
3222
- posMs: currentPosMs,
3223
- skipMs: unit.offset_ms,
3224
- lengthMs: unit.duration_ms,
3225
- fadeInMs: unit.overlap_ms,
3226
- fadeOutMs: unit.overlap_ms,
3227
- // 次ノートとのクロスフェードとして設定
3228
- consonantMs: unit.consonant_ms
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
3229
3959
  });
3230
- currentPosMs += unit.duration_ms;
3231
- }
3232
- const baseHz = 261.63;
3233
- let pitchInput = baseHz;
3234
- if (plan.pitch_cents && plan.pitch_frame_ms) {
3235
- const frameMs = plan.pitch_frame_ms;
3236
- const centsArr = plan.pitch_cents;
3237
- pitchInput = (tMs, totalMs) => {
3238
- const frameIndex = Math.floor(tMs / frameMs);
3239
- if (frameIndex < 0 || frameIndex >= centsArr.length) return baseHz;
3240
- const centsOffset = centsArr[frameIndex];
3241
- return baseHz * Math.pow(2, centsOffset / 1200);
3242
- };
3243
- }
3244
- const outPcm = this.worldline.renderPhrase({
3245
- units: phraseUnits,
3246
- pitch: pitchInput
3247
- });
3248
- if (outPcm) {
3249
- console.log(`Successfully generated audio: ${outPcm.length} samples.`);
3250
- } else {
3251
- console.error("Audio generation failed.");
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));
3252
3991
  }
3253
- return outPcm;
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;
3254
4009
  }
3255
4010
  };
3256
4011
  export {
3257
4012
  KoeEngine,
3258
4013
  MIN_WORLDLINE_SAMPLES,
4014
+ NAIST_JDIC_FILES,
3259
4015
  UtauTTSAdapter,
3260
4016
  VoiceBank,
3261
4017
  WORLDLINE_SAMPLE_RATE,
3262
4018
  Worldline,
4019
+ alignHtsProsody,
3263
4020
  analyze,
3264
4021
  analyzeWav,
3265
4022
  detectF0,
@@ -3269,15 +4026,22 @@ export {
3269
4026
  estimateSequence,
3270
4027
  estimateSolo,
3271
4028
  estimateVowelJoin,
4029
+ fetchAsset,
4030
+ fetchAssetBytes,
4031
+ fetchAssetText,
3272
4032
  formatOto,
3273
4033
  frqAverageF0InRange,
3274
4034
  frqFileName,
3275
4035
  generateOto,
3276
4036
  generateOtoForFile,
4037
+ initJpreprocessDictionary,
4038
+ isQuestion,
3277
4039
  leadInFromEntry,
4040
+ loadNaistJdic,
3278
4041
  locateMora,
3279
4042
  normalizePcm,
3280
4043
  noteNameToHz,
4044
+ analyze2 as openjtalkAnalyze,
3281
4045
  otoRegion,
3282
4046
  pack,
3283
4047
  packKoe,
@@ -3289,8 +4053,11 @@ export {
3289
4053
  pcmBase,
3290
4054
  pitchFromAliasSuffix,
3291
4055
  readWavPcm48k,
4056
+ readingFromFeatures,
3292
4057
  resample,
3293
4058
  samplesToMs,
4059
+ shapeProsody,
4060
+ sparse_features,
3294
4061
  splitKana,
3295
4062
  suffixFromFolderName,
3296
4063
  summarise,