@onjmin/koe 1.0.5 → 1.0.6

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.d.ts CHANGED
@@ -1,16 +1,44 @@
1
1
  /**
2
2
  * UTAU `.frq` frequency-analysis files (FREQ0003). The same format OpenUtau
3
- * reads: an 8-byte header, hop size, then the average fundamental frequency of
4
- * the recording exactly the reference pitch we need for correct resampling.
3
+ * reads: an 8-byte header, hop size, the average fundamental frequency of the
4
+ * whole recording, then a per-frame f0 / amplitude curve.
5
5
  *
6
6
  * Layout:
7
7
  * char[8] "FREQ0003"
8
- * int32 hopSize
9
- * float64 averageF0 the recorded pitch in Hz
8
+ * int32 hopSize ← in ORIGINAL WAV samples
9
+ * float64 averageF0 whole-file average in Hz
10
10
  * byte[16] (blank)
11
11
  * int32 length
12
12
  * { float64 f0, float64 amp } × length
13
13
  */
14
+ interface FrqData {
15
+ /** Analysis hop in samples of the ORIGINAL wav (not the 48 kHz conversion) */
16
+ hopSize: number;
17
+ /** Whole-file average f0 in Hz, as stored in the header */
18
+ averageF0: number;
19
+ /** Per-frame f0 in Hz — 0 where the analyser found no clear pitch */
20
+ f0: Float64Array;
21
+ /** Per-frame amplitude, parallel to {@link f0} */
22
+ amp: Float64Array;
23
+ }
24
+ /** Parse a `.frq` file, including the per-frame curve. Null if not FREQ0003. */
25
+ declare function parseFrq(buffer: ArrayBuffer): FrqData | null;
26
+ /**
27
+ * Average f0 over the voiced frames covering a time span of the recording.
28
+ *
29
+ * The header's whole-file average includes leading silence and unvoiced
30
+ * consonants, so it can sit well away from the pitch actually sounding in the
31
+ * region a note is built from. Playback resamples the whole phoneme by one
32
+ * scalar ratio, so an inaccurate value both detunes the note and makes two
33
+ * crossfading notes drift apart in phase across the overlap — a 1% error at
34
+ * 233 Hz drifts ~25° over a 30 ms overlap and ~84° over 100 ms.
35
+ *
36
+ * @param startMs / endMs span within the ORIGINAL recording, in milliseconds
37
+ * @param sourceRate sample rate of the original WAV the frq describes
38
+ * @returns Hz, or 0 when the span holds no voiced frames
39
+ */
40
+ declare function frqAverageF0InRange(frq: FrqData, startMs: number, endMs: number, sourceRate: number): number;
41
+ /** Whole-file average f0 in Hz from a `.frq` file, or null. */
14
42
  declare function parseFrqAverageF0(buffer: ArrayBuffer): number | null;
15
43
  /** Map a WAV filename to its sibling frq filename: "あ.wav" → "あ_wav.frq". */
16
44
  declare function frqFileName(wavName: string): string;
@@ -77,7 +105,7 @@ declare function parseOto(content: string): OtoEntry[];
77
105
 
78
106
  interface PackInput {
79
107
  oto: OtoEntry;
80
- /** Full normalized PCM of the source WAV (48kHz / 16bit / mono) */
108
+ /** Full PCM of the source WAV (48kHz / 16bit / mono) */
81
109
  pcm: Int16Array;
82
110
  /** Known recorded pitch in Hz (e.g. from the .frq file). 0/undefined → auto-detect. */
83
111
  recordedPitch?: number;
@@ -93,6 +121,16 @@ interface TrimmedPhoneme {
93
121
  /** Manifest params relative to the trimmed start (sample 0 = oto offset) */
94
122
  entry: Omit<PhonemeEntry, "offset">;
95
123
  }
124
+ /**
125
+ * Resolve an oto entry's usable region within the full 48 kHz PCM.
126
+ *
127
+ * Shared with the converter CLI, which needs the same bounds to look up the
128
+ * region's local f0 in a `.frq` curve.
129
+ */
130
+ declare function otoRegion(pcmLength: number, oto: OtoEntry): {
131
+ start: number;
132
+ end: number;
133
+ };
96
134
  /**
97
135
  * Cut the full WAV PCM down to its usable oto region and recompute parameters
98
136
  * relative to the trimmed start.
@@ -107,8 +145,8 @@ interface TrimmedPhoneme {
107
145
  */
108
146
  declare function trimToOto(pcm: Int16Array, oto: OtoEntry, recordedPitch?: number): TrimmedPhoneme;
109
147
  /**
110
- * Pack normalized PCM phonemes into voice.bin + manifest.json.
111
- * Each phoneme is trimmed to its oto region first.
148
+ * Pack phonemes into voice.bin + manifest.json.
149
+ * Each phoneme is trimmed to its oto region and DC-centred first.
112
150
  * Duplicate aliases are silently overwritten by the later entry.
113
151
  */
114
152
  declare function pack(inputs: PackInput[], referencePitch?: number): PackOutput;
@@ -142,7 +180,27 @@ declare function toMono(wav: WavData): WavData;
142
180
  declare function resample(wav: WavData, targetRate: number): WavData;
143
181
  /** Convert Float32 [-1,1] samples to Int16 PCM. */
144
182
  declare function toInt16(samples: Float32Array): Int16Array;
145
- /** Normalize then convert a WAV to 48kHz/16bit/mono Int16 PCM. */
183
+ /**
184
+ * Decode a WAV to 48kHz/16bit/mono Int16 PCM, reporting the source sample rate.
185
+ *
186
+ * The rate is needed to index a sibling `.frq` file, whose analysis hop is
187
+ * counted in ORIGINAL samples — see {@link frqAverageF0InRange}.
188
+ *
189
+ * Note this does not touch amplitude: peak levels are carried through
190
+ * unchanged, so a bank's own relative loudness between phonemes is preserved
191
+ * (UTAU's engine instead normalises each region to −6 dBFS, which is why its
192
+ * イ/エ段 and 語尾 samples come out louder than recorded).
193
+ */
194
+ declare function readWavPcm48k(buf: ArrayBuffer): {
195
+ pcm: Int16Array;
196
+ sourceRate: number;
197
+ };
198
+ /**
199
+ * Convert a WAV to 48kHz/16bit/mono Int16 PCM.
200
+ *
201
+ * @deprecated Misnomer — this never normalised amplitude. Use
202
+ * {@link readWavPcm48k}, which also reports the source sample rate.
203
+ */
146
204
  declare function normalizePcm(buf: ArrayBuffer): Int16Array;
147
205
 
148
206
  /** Minimal file-like handle so zip entries can stand in for `File` objects. */
@@ -203,6 +261,21 @@ interface KoeEngineOptions {
203
261
  /** URL to koe-worklet.js. Defaults to './koe-worklet.js'. */
204
262
  workletUrl?: string;
205
263
  }
264
+ interface PlayOptions {
265
+ /**
266
+ * Play the first note's lead-in (its consonant / preutterance region) instead
267
+ * of skipping straight to the vowel.
268
+ *
269
+ * Every note but the first gets its lead-in from the crossfade with the note
270
+ * before it. The first note has no predecessor, so the lead-in has to come
271
+ * from somewhere: with `leadIn` the phrase starts one preutterance EARLIER
272
+ * relative to its beats, and {@link KoeEngine.play} returns that offset in
273
+ * samples so a sequencer can schedule around it. Left off (the default), the
274
+ * first note keeps its beat exactly but opens on its vowel, dropping the
275
+ * consonant.
276
+ */
277
+ leadIn?: boolean;
278
+ }
206
279
  /**
207
280
  * Main-thread API for the koe concatenative synthesis engine.
208
281
  *
@@ -237,8 +310,18 @@ declare class KoeEngine {
237
310
  load(koe: Blob | string): Promise<void>;
238
311
  /** Fetch one phoneme's PCM and deliver it to the worklet (deduped, cached). */
239
312
  private ensurePhoneme;
240
- /** Stop current playback, preload the phonemes for `notes`, then queue them. */
241
- play(notes: NoteEvent[]): Promise<void>;
313
+ /**
314
+ * Stop current playback, preload the phonemes for `notes`, then queue them.
315
+ *
316
+ * @returns the lead-in offset in samples — how far the first note's audio
317
+ * starts ahead of its beat. 0 unless {@link PlayOptions.leadIn}.
318
+ */
319
+ play(notes: NoteEvent[], options?: PlayOptions): Promise<number>;
320
+ /**
321
+ * Output samples the first note's lead-in occupies ahead of its beat —
322
+ * mirrors what the worklet does with `leadIn`, so callers can compensate.
323
+ */
324
+ private leadInSamples;
242
325
  /** Stop playback and clear the queue. */
243
326
  stop(): void;
244
327
  /** Resume the AudioContext if suspended (e.g. after autoplay block). */
@@ -420,4 +503,4 @@ declare function parseKoeHeader(headerBytes: ArrayBuffer): {
420
503
  /** Byte offset where PCM data begins, given the JSON length. */
421
504
  declare const pcmBase: (jsonLength: number) => number;
422
505
 
423
- export { KoeEngine, type KoeEngineOptions, MIN_WORLDLINE_SAMPLES, type Manifest, type NoteEvent, type OtoEntry, type PackInput, type PackOutput, type PhonemeEntry, type RenderNoteParams, type TrimmedPhoneme, VoiceBank, WORLDLINE_SAMPLE_RATE, type WavData, Worldline, type WorldlineLoadOptions, type ZipFile, detectF0, frqFileName, leadInFromEntry, normalizePcm, noteNameToHz, pack, packKoe, parseFrqAverageF0, parseKoeHeader, parseOto, parseWav, pcmBase, pitchFromAliasSuffix, resample, samplesToMs, toInt16, toMono, trimToOto, unzipToFileMap };
506
+ export { type FrqData, KoeEngine, type KoeEngineOptions, MIN_WORLDLINE_SAMPLES, type Manifest, type NoteEvent, type OtoEntry, type PackInput, type PackOutput, type PhonemeEntry, type PlayOptions, type RenderNoteParams, type TrimmedPhoneme, VoiceBank, WORLDLINE_SAMPLE_RATE, type WavData, Worldline, type WorldlineLoadOptions, type ZipFile, detectF0, frqAverageF0InRange, frqFileName, leadInFromEntry, normalizePcm, noteNameToHz, otoRegion, pack, packKoe, parseFrq, parseFrqAverageF0, parseKoeHeader, parseOto, parseWav, pcmBase, pitchFromAliasSuffix, readWavPcm48k, resample, samplesToMs, toInt16, toMono, trimToOto, unzipToFileMap };
package/dist/index.js CHANGED
@@ -1,12 +1,50 @@
1
1
  // src/converter/frq.ts
2
- function parseFrqAverageF0(buffer) {
3
- if (buffer.byteLength < 20) return null;
2
+ var HEADER_SIZE = 40;
3
+ function parseFrq(buffer) {
4
+ if (buffer.byteLength < HEADER_SIZE) return null;
4
5
  const view = new DataView(buffer);
5
6
  let header = "";
6
7
  for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
7
8
  if (header !== "FREQ0003") return null;
8
- const avg = view.getFloat64(12, true);
9
- return Number.isFinite(avg) && avg > 0 ? avg : null;
9
+ const hopSize = view.getInt32(8, true);
10
+ const averageF0 = view.getFloat64(12, true);
11
+ const declared = view.getInt32(36, true);
12
+ const available = Math.floor((buffer.byteLength - HEADER_SIZE) / 16);
13
+ const length = Math.max(0, Math.min(declared, available));
14
+ const f0 = new Float64Array(length);
15
+ const amp = new Float64Array(length);
16
+ for (let i = 0; i < length; i++) {
17
+ const p = HEADER_SIZE + i * 16;
18
+ f0[i] = view.getFloat64(p, true);
19
+ amp[i] = view.getFloat64(p + 8, true);
20
+ }
21
+ return {
22
+ hopSize: hopSize > 0 ? hopSize : 256,
23
+ averageF0: Number.isFinite(averageF0) && averageF0 > 0 ? averageF0 : 0,
24
+ f0,
25
+ amp
26
+ };
27
+ }
28
+ function frqAverageF0InRange(frq, startMs, endMs, sourceRate) {
29
+ if (!(sourceRate > 0) || frq.f0.length === 0) return 0;
30
+ const perFrameMs = frq.hopSize / sourceRate * 1e3;
31
+ if (!(perFrameMs > 0)) return 0;
32
+ const first = Math.max(0, Math.floor(startMs / perFrameMs));
33
+ const last = Math.min(frq.f0.length - 1, Math.ceil(endMs / perFrameMs));
34
+ let num = 0;
35
+ let den = 0;
36
+ for (let i = first; i <= last; i++) {
37
+ const hz = frq.f0[i];
38
+ if (!(hz > 0)) continue;
39
+ const w = frq.amp[i] > 0 ? frq.amp[i] : 1;
40
+ num += hz * w;
41
+ den += w;
42
+ }
43
+ return den > 0 ? num / den : 0;
44
+ }
45
+ function parseFrqAverageF0(buffer) {
46
+ const frq = parseFrq(buffer);
47
+ return frq && frq.averageF0 > 0 ? frq.averageF0 : null;
10
48
  }
11
49
  function frqFileName(wavName) {
12
50
  const dot = wavName.lastIndexOf(".");
@@ -94,11 +132,25 @@ function msToSamples(ms) {
94
132
  return Math.round(ms / 1e3 * TARGET_RATE);
95
133
  }
96
134
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
135
+ function otoRegion(pcmLength, oto) {
136
+ const start = clamp(msToSamples(oto.offset), 0, pcmLength);
137
+ const end = oto.cutoff < 0 ? clamp(start + msToSamples(-oto.cutoff), start, pcmLength) : clamp(pcmLength - msToSamples(oto.cutoff), start, pcmLength);
138
+ return { start, end };
139
+ }
140
+ function centerSlice(slice) {
141
+ const out = new Int16Array(slice.length);
142
+ if (slice.length === 0) return out;
143
+ let sum = 0;
144
+ for (let i = 0; i < slice.length; i++) sum += slice[i];
145
+ const dc = Math.round(sum / slice.length);
146
+ for (let i = 0; i < slice.length; i++) {
147
+ out[i] = clamp(slice[i] - dc, -32768, 32767);
148
+ }
149
+ return out;
150
+ }
97
151
  function trimToOto(pcm, oto, recordedPitch = 0) {
98
- const full = pcm.length;
99
- const start = clamp(msToSamples(oto.offset), 0, full);
100
- const end = oto.cutoff < 0 ? clamp(start + msToSamples(-oto.cutoff), start, full) : clamp(full - msToSamples(oto.cutoff), start, full);
101
- const slice = pcm.subarray(start, end);
152
+ const { start, end } = otoRegion(pcm.length, oto);
153
+ const slice = centerSlice(pcm.subarray(start, end));
102
154
  const length = slice.length;
103
155
  const pre = clamp(msToSamples(oto.pre), 0, length);
104
156
  const overlap = clamp(msToSamples(oto.overlap), 0, length);
@@ -262,11 +314,14 @@ function toInt16(samples) {
262
314
  }
263
315
  return out;
264
316
  }
265
- function normalizePcm(buf) {
317
+ function readWavPcm48k(buf) {
266
318
  const wav = parseWav(buf);
267
319
  const mono = toMono(wav);
268
320
  const resampled = resample(mono, 48e3);
269
- return toInt16(resampled.samples);
321
+ return { pcm: toInt16(resampled.samples), sourceRate: wav.sampleRate };
322
+ }
323
+ function normalizePcm(buf) {
324
+ return readWavPcm48k(buf).pcm;
270
325
  }
271
326
  function readFourCC(view, pos) {
272
327
  return String.fromCharCode(
@@ -1045,13 +1100,34 @@ var KoeEngine = class {
1045
1100
  this.pending.set(name, load);
1046
1101
  return load;
1047
1102
  }
1048
- /** Stop current playback, preload the phonemes for `notes`, then queue them. */
1049
- async play(notes) {
1103
+ /**
1104
+ * Stop current playback, preload the phonemes for `notes`, then queue them.
1105
+ *
1106
+ * @returns the lead-in offset in samples — how far the first note's audio
1107
+ * starts ahead of its beat. 0 unless {@link PlayOptions.leadIn}.
1108
+ */
1109
+ async play(notes, options = {}) {
1050
1110
  if (!this.node) throw new Error("KoeEngine: call load() before play()");
1051
1111
  this.node.port.postMessage({ type: "stop" });
1052
1112
  const names = [...new Set(notes.map((n) => n.phoneme))].filter(Boolean);
1053
1113
  await Promise.all(names.map((n) => this.ensurePhoneme(n)));
1054
- this.node.port.postMessage({ type: "play", notes });
1114
+ const leadIn = options.leadIn === true;
1115
+ this.node.port.postMessage({ type: "play", notes, leadIn });
1116
+ return leadIn ? this.leadInSamples(notes) : 0;
1117
+ }
1118
+ /**
1119
+ * Output samples the first note's lead-in occupies ahead of its beat —
1120
+ * mirrors what the worklet does with `leadIn`, so callers can compensate.
1121
+ */
1122
+ leadInSamples(notes) {
1123
+ const first = notes[0];
1124
+ const m = this.bank?.manifest;
1125
+ if (!first || !m || first.phoneme === "") return 0;
1126
+ const entry = Object.hasOwn(m.phonemes, first.phoneme) ? m.phonemes[first.phoneme] : void 0;
1127
+ if (!entry || entry.length <= 0) return 0;
1128
+ const recorded = entry.pitch || m.referencePitch || first.pitch || 1;
1129
+ const stepRate = first.pitch / recorded;
1130
+ return stepRate > 0 ? entry.pre / stepRate : 0;
1055
1131
  }
1056
1132
  /** Stop playback and clear the queue. */
1057
1133
  stop() {
@@ -1304,18 +1380,22 @@ export {
1304
1380
  WORLDLINE_SAMPLE_RATE,
1305
1381
  Worldline,
1306
1382
  detectF0,
1383
+ frqAverageF0InRange,
1307
1384
  frqFileName,
1308
1385
  leadInFromEntry,
1309
1386
  normalizePcm,
1310
1387
  noteNameToHz,
1388
+ otoRegion,
1311
1389
  pack,
1312
1390
  packKoe,
1391
+ parseFrq,
1313
1392
  parseFrqAverageF0,
1314
1393
  parseKoeHeader,
1315
1394
  parseOto,
1316
1395
  parseWav,
1317
1396
  pcmBase,
1318
1397
  pitchFromAliasSuffix,
1398
+ readWavPcm48k,
1319
1399
  resample,
1320
1400
  samplesToMs,
1321
1401
  toInt16,