@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 おんJ民
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # koe
2
+ ブラウザ上でUTAU音源をラグ無しで再生する技術
@@ -0,0 +1,362 @@
1
+ /**
2
+ * One phoneme, already trimmed to its usable oto region.
3
+ * Sample 0 corresponds to the oto `offset` (left blank); everything before it
4
+ * in the source WAV has been removed during conversion. All positions below are
5
+ * therefore relative to the trimmed sample start.
6
+ */
7
+ interface PhonemeEntry {
8
+ /** Byte offset of this phoneme's PCM within voice.bin */
9
+ offset: number;
10
+ /** Trimmed sample count = oto region [offset, cutoff] (48kHz / 16bit / mono) */
11
+ length: number;
12
+ /** Preutterance in samples (note onset alignment point) */
13
+ pre: number;
14
+ /** Overlap / crossfade length in samples */
15
+ overlap: number;
16
+ /** Consonant (fixed, non-looped) region length in samples */
17
+ consonant: number;
18
+ /**
19
+ * Detected recorded fundamental frequency in Hz (0 if undetectable).
20
+ * Playback resamples by `targetPitch / pitch`, so any source format
21
+ * (multi-pitch, single-pitch with pitch-suffixed aliases, …) tunes correctly.
22
+ */
23
+ pitch: number;
24
+ }
25
+ interface Manifest {
26
+ sampleRate: 48000;
27
+ /** Reference pitch used when recording the voice bank (Hz) */
28
+ referencePitch: number;
29
+ phonemes: Record<string, PhonemeEntry>;
30
+ }
31
+ interface NoteEvent {
32
+ phoneme: string;
33
+ /** Desired output pitch in Hz */
34
+ pitch: number;
35
+ /** Output duration in samples at 48kHz */
36
+ duration: number;
37
+ }
38
+
39
+ /**
40
+ * Read-only access to a .koe voice bank: its manifest plus per-phoneme PCM,
41
+ * fetched on demand (Blob slice or HTTP Range). The full bank is never held in
42
+ * memory.
43
+ *
44
+ * Pure data — no AudioContext, no AudioWorklet, no DOM. Use this when you only
45
+ * need the source samples (e.g. to feed the {@link Worldline} renderer or any
46
+ * other vocoder). {@link KoeEngine} builds its concatenative playback on top of
47
+ * this same class.
48
+ *
49
+ * const bank = await VoiceBank.load(koeBlobOrUrl);
50
+ * const pcm = await bank.getPcm('a'); // Float64 [-1, 1]
51
+ */
52
+ declare class VoiceBank {
53
+ /** The voice bank manifest (sample rate, reference pitch, phoneme table). */
54
+ readonly manifest: Manifest;
55
+ private source;
56
+ private constructor();
57
+ /**
58
+ * Parse a .koe archive header + manifest and bind a lazy PCM source.
59
+ * @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
60
+ */
61
+ static load(koe: Blob | string): Promise<VoiceBank>;
62
+ /** True if the bank contains a phoneme under this alias. */
63
+ has(phoneme: string): boolean;
64
+ /**
65
+ * Raw Int16 PCM bytes (48 kHz / mono) for a phoneme, or null if unknown.
66
+ * The returned ArrayBuffer is freshly allocated and safe to transfer to a
67
+ * worker / AudioWorklet.
68
+ */
69
+ readPcmBytes(phoneme: string): Promise<ArrayBuffer | null>;
70
+ /**
71
+ * A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
72
+ * Intended for external analysis / resynthesis such as the WORLD vocoder.
73
+ */
74
+ getPcm(phoneme: string): Promise<Float64Array | null>;
75
+ }
76
+
77
+ interface KoeEngineOptions {
78
+ /** URL to koe-worklet.js. Defaults to './koe-worklet.js'. */
79
+ workletUrl?: string;
80
+ }
81
+ /**
82
+ * Main-thread API for the koe concatenative synthesis engine.
83
+ *
84
+ * Loads a single .koe archive. The full voice bank is never held in memory:
85
+ * phonemes are fetched on demand (via {@link VoiceBank}) and cached in the
86
+ * audio thread.
87
+ *
88
+ * const engine = new KoeEngine();
89
+ * await engine.load(koeBlobOrUrl);
90
+ * await engine.play([{ phoneme: 'a', pitch: 440, duration: 48000 }]);
91
+ *
92
+ * For analysis / resynthesis (e.g. {@link Worldline}) you usually only need the
93
+ * raw samples — use {@link VoiceBank} directly instead, which needs no
94
+ * AudioContext or worklet.
95
+ */
96
+ declare class KoeEngine {
97
+ private ctx;
98
+ private workletUrl;
99
+ private node;
100
+ private bank;
101
+ private delivered;
102
+ private pending;
103
+ constructor(options?: KoeEngineOptions);
104
+ get audioContext(): AudioContext;
105
+ get manifest(): Manifest | null;
106
+ /** The underlying voice bank (manifest + on-demand PCM), or null before load(). */
107
+ get voiceBank(): VoiceBank | null;
108
+ /**
109
+ * Register the worklet and bind a .koe voice bank.
110
+ * @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
111
+ */
112
+ load(koe: Blob | string): Promise<void>;
113
+ /** Fetch one phoneme's PCM and deliver it to the worklet (deduped, cached). */
114
+ private ensurePhoneme;
115
+ /** Stop current playback, preload the phonemes for `notes`, then queue them. */
116
+ play(notes: NoteEvent[]): Promise<void>;
117
+ /** Stop playback and clear the queue. */
118
+ stop(): void;
119
+ /** Resume the AudioContext if suspended (e.g. after autoplay block). */
120
+ resume(): Promise<void>;
121
+ /**
122
+ * Read a phoneme's raw PCM and return it as a Float64Array normalised to
123
+ * [-1, 1]. Convenience that forwards to the underlying {@link VoiceBank}.
124
+ * Intended for external analysis such as the WORLD vocoder.
125
+ */
126
+ getPcm(phoneme: string): Promise<Float64Array | null>;
127
+ }
128
+
129
+ /** Output sample rate of the worldline synthesizer. */
130
+ declare const WORLDLINE_SAMPLE_RATE = 48000;
131
+ /**
132
+ * WORLD needs roughly 85 ms of audio for stable F0 analysis. Phonemes with
133
+ * fewer samples than this are rejected (renderNote returns null).
134
+ */
135
+ declare const MIN_WORLDLINE_SAMPLES = 4096;
136
+ /**
137
+ * The subset of the Emscripten module surface that we call. worldline.js is an
138
+ * MODULARIZE=1 / EXPORT_NAME=WorldlineModule build of OpenUtau's worldline.
139
+ */
140
+ interface WorldlineWasm {
141
+ _PhraseSynthNew(): number;
142
+ _PhraseSynthDelete(ps: number): void;
143
+ _PhraseSynthAddRequest(ps: number, req: number, posMs: number, skipMs: number, lengthMs: number, fadeInMs: number, fadeOutMs: number, flag: number): void;
144
+ _PhraseSynthSetCurves(ps: number, f0: number, gender: number, tension: number, breathiness: number, voicing: number, length: number, frameMs: number): void;
145
+ _PhraseSynthSynth(ps: number, yPtrPtr: number, flag: number): number;
146
+ _malloc(size: number): number;
147
+ _free(ptr: number): void;
148
+ setValue(ptr: number, value: number, type: string): void;
149
+ getValue(ptr: number, type: string): number;
150
+ HEAPF32: Float32Array;
151
+ HEAPF64: Float64Array;
152
+ }
153
+ type WorldlineFactory = (opts?: {
154
+ locateFile?: (path: string) => string;
155
+ }) => Promise<WorldlineWasm>;
156
+ declare global {
157
+ var WorldlineModule: WorldlineFactory | undefined;
158
+ }
159
+ interface WorldlineLoadOptions {
160
+ /**
161
+ * URL of `worldline.js` (the Emscripten loader). The matching
162
+ * `worldline.wasm` must sit next to it — it is resolved relative to this URL.
163
+ *
164
+ * When consuming koe from npm + a CDN this is typically e.g.
165
+ * `https://cdn.jsdelivr.net/npm/@onjmin/koe/dist/world/worldline.js`, or your
166
+ * own hosted copy of `dist/world/`.
167
+ */
168
+ scriptUrl: string;
169
+ }
170
+ interface RenderNoteParams {
171
+ /**
172
+ * Source phoneme PCM normalised to [-1, 1] (e.g. from
173
+ * `VoiceBank.getPcm()` / `KoeEngine.getPcm()`).
174
+ */
175
+ pcm: Float64Array;
176
+ /** Target output pitch in Hz. */
177
+ pitch: number;
178
+ /** Sustain / vowel duration in ms (the lead-in below is rendered on top). */
179
+ durationMs: number;
180
+ /** Preutterance / lead-in in ms — convert from {@link PhonemeEntry.pre}. */
181
+ preMs: number;
182
+ /** Consonant length in ms — convert from {@link PhonemeEntry.consonant}. */
183
+ consonantMs: number;
184
+ /** Reference tempo in BPM for worldline's internal timing. Default 120. */
185
+ tempo?: number;
186
+ }
187
+ /** Convert a sample count at 48 kHz to milliseconds. */
188
+ declare const samplesToMs: (samples: number) => number;
189
+ /**
190
+ * Derive {@link RenderNoteParams} lead-in / consonant fields from a manifest
191
+ * entry, so callers don't repeat the sample→ms conversion.
192
+ *
193
+ * const params = { pcm, pitch, durationMs, ...leadInFromEntry(entry) };
194
+ */
195
+ declare function leadInFromEntry(entry: PhonemeEntry): {
196
+ preMs: number;
197
+ consonantMs: number;
198
+ };
199
+ /**
200
+ * High-quality WORLD-vocoder note renderer (OpenUtau's worldline via WASM).
201
+ *
202
+ * Pure synthesis: PCM in → PCM out. No AudioContext, no scheduling — the caller
203
+ * owns playback (schedule the returned buffer on its own timeline). Pair it with
204
+ * {@link VoiceBank} for the source samples:
205
+ *
206
+ * const bank = await VoiceBank.load(koeUrl);
207
+ * const wl = await Worldline.load({ scriptUrl: '.../world/worldline.js' });
208
+ *
209
+ * const entry = bank.manifest.phonemes[alias];
210
+ * const pcm = await bank.getPcm(alias);
211
+ * const audio = wl.renderNote({
212
+ * pcm, pitch: 440, durationMs: 500, ...leadInFromEntry(entry),
213
+ * });
214
+ * // audio: Float32 @ 48 kHz, layout [lead-in/consonant ≈ preMs][vowel ≈ durationMs]
215
+ */
216
+ declare class Worldline {
217
+ private wasm;
218
+ readonly sampleRate = 48000;
219
+ private constructor();
220
+ /** Load + instantiate the worldline WASM module (deduped per scriptUrl). */
221
+ static load(options: WorldlineLoadOptions): Promise<Worldline>;
222
+ /**
223
+ * Render one note to Float32 PCM at 48 kHz.
224
+ *
225
+ * The output buffer is laid out as [lead-in/consonant ≈ preMs][vowel ≈
226
+ * durationMs], rendered from sample offset 0 (no leading silence). The vowel
227
+ * onset (the "beat") sits at ≈ preMs into the buffer, so a sequencer should
228
+ * place the buffer at `beatTime − preMs` and may trim/crossfade the lead-in.
229
+ *
230
+ * No internal crossfade is applied — apply fades externally.
231
+ *
232
+ * @returns Float32 PCM, or null when `pcm` is shorter than
233
+ * {@link MIN_WORLDLINE_SAMPLES} (too short for stable F0 analysis).
234
+ */
235
+ renderNote(params: RenderNoteParams): Float32Array | null;
236
+ }
237
+
238
+ interface OtoEntry {
239
+ /** Source WAV filename */
240
+ wav: string;
241
+ /** Phoneme alias */
242
+ alias: string;
243
+ /** Left blank — offset from WAV start (ms) */
244
+ offset: number;
245
+ /** Consonant portion end from offset (ms) */
246
+ consonant: number;
247
+ /** Right blank — negative = from WAV end, positive = from offset (ms) */
248
+ cutoff: number;
249
+ /** Preutterance from offset (ms) */
250
+ pre: number;
251
+ /** Overlap / crossfade region (ms) */
252
+ overlap: number;
253
+ }
254
+ /**
255
+ * Parse oto.ini content (already decoded to UTF-8 string).
256
+ * Silently skips malformed lines.
257
+ */
258
+ declare function parseOto(content: string): OtoEntry[];
259
+
260
+ interface WavData {
261
+ sampleRate: number;
262
+ channels: number;
263
+ /** Normalized samples in [-1, 1], interleaved if multi-channel */
264
+ samples: Float32Array;
265
+ }
266
+ /** Parse a WAV file from an ArrayBuffer. Supports PCM 8/16/24-bit and IEEE float 32-bit. */
267
+ declare function parseWav(buf: ArrayBuffer): WavData;
268
+ /** Mix down to mono by averaging all channels. */
269
+ declare function toMono(wav: WavData): WavData;
270
+ /** Linear interpolation resample to targetRate. Expects mono input. */
271
+ declare function resample(wav: WavData, targetRate: number): WavData;
272
+ /** Convert Float32 [-1,1] samples to Int16 PCM. */
273
+ declare function toInt16(samples: Float32Array): Int16Array;
274
+ /** Normalize then convert a WAV to 48kHz/16bit/mono Int16 PCM. */
275
+ declare function normalizePcm(buf: ArrayBuffer): Int16Array;
276
+
277
+ interface PackInput {
278
+ oto: OtoEntry;
279
+ /** Full normalized PCM of the source WAV (48kHz / 16bit / mono) */
280
+ pcm: Int16Array;
281
+ /** Known recorded pitch in Hz (e.g. from the .frq file). 0/undefined → auto-detect. */
282
+ recordedPitch?: number;
283
+ }
284
+ interface PackOutput {
285
+ manifest: Manifest;
286
+ /** Raw PCM blob — Int16 / 48kHz / mono */
287
+ bin: ArrayBuffer;
288
+ }
289
+ interface TrimmedPhoneme {
290
+ /** PCM trimmed to the oto region [offset, cutoff] */
291
+ pcm: Int16Array;
292
+ /** Manifest params relative to the trimmed start (sample 0 = oto offset) */
293
+ entry: Omit<PhonemeEntry, 'offset'>;
294
+ }
295
+ /**
296
+ * Cut the full WAV PCM down to its usable oto region and recompute parameters
297
+ * relative to the trimmed start.
298
+ *
299
+ * UTAU oto.ini values are all in ms and measured from `offset` (the left blank),
300
+ * except `cutoff` (right blank):
301
+ * - cutoff >= 0 : measured from the END of the file
302
+ * - cutoff < 0 : region length from offset = |cutoff|
303
+ *
304
+ * After trimming, sample 0 == oto offset, so pre/overlap/consonant carry over
305
+ * unchanged (just converted to samples), and the slice length is the region end.
306
+ */
307
+ declare function trimToOto(pcm: Int16Array, oto: OtoEntry, recordedPitch?: number): TrimmedPhoneme;
308
+ /**
309
+ * Pack normalized PCM phonemes into voice.bin + manifest.json.
310
+ * Each phoneme is trimmed to its oto region first.
311
+ * Duplicate aliases are silently overwritten by the later entry.
312
+ */
313
+ declare function pack(inputs: PackInput[], referencePitch?: number): PackOutput;
314
+
315
+ /** Parse a note name like "E4", "G#4", "Db5" → frequency in Hz (null if invalid). */
316
+ declare function noteNameToHz(name: string): number | null;
317
+ /** Recorded pitch encoded in a multi-pitch alias suffix: "a い_E4" → 329.63 Hz. */
318
+ declare function pitchFromAliasSuffix(alias: string): number | null;
319
+ /**
320
+ * Estimate the fundamental frequency (Hz) of a voiced region by normalized
321
+ * autocorrelation. Returns 0 when no clear pitch is found (unvoiced consonant,
322
+ * silence, or a region too short to analyse).
323
+ *
324
+ * The signal is decimated to a lower analysis rate for speed; f0 below ~700 Hz
325
+ * is well within the resulting Nyquist limit. A parabolic interpolation around
326
+ * the best lag gives sub-sample (sub-semitone) accuracy.
327
+ */
328
+ declare function detectF0(pcm: Int16Array, start: number, end: number): number;
329
+
330
+ /**
331
+ * UTAU `.frq` frequency-analysis files (FREQ0003). The same format OpenUtau
332
+ * reads: an 8-byte header, hop size, then the average fundamental frequency of
333
+ * the recording — exactly the reference pitch we need for correct resampling.
334
+ *
335
+ * Layout:
336
+ * char[8] "FREQ0003"
337
+ * int32 hopSize
338
+ * float64 averageF0 ← the recorded pitch in Hz
339
+ * byte[16] (blank)
340
+ * int32 length
341
+ * { float64 f0, float64 amp } × length
342
+ */
343
+ declare function parseFrqAverageF0(buffer: ArrayBuffer): number | null;
344
+ /** Map a WAV filename to its sibling frq filename: "あ.wav" → "あ_wav.frq". */
345
+ declare function frqFileName(wavName: string): string;
346
+
347
+ /**
348
+ * Koe Archive Format (.koe)
349
+ * [4B] magic 'KOE\0' (big-endian)
350
+ * [4B] JSON length (little-endian)
351
+ * [N ] manifest JSON (UTF-8)
352
+ * [M ] raw PCM (Int16 / 48kHz / mono); phoneme offsets are relative to here
353
+ */
354
+ declare function packKoe(manifest: Manifest, pcmParts: BlobPart[]): Blob;
355
+ /** Read the 8-byte header → JSON length. Throws on bad magic. */
356
+ declare function parseKoeHeader(headerBytes: ArrayBuffer): {
357
+ jsonLength: number;
358
+ };
359
+ /** Byte offset where PCM data begins, given the JSON length. */
360
+ declare const pcmBase: (jsonLength: number) => number;
361
+
362
+ 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, detectF0, frqFileName, leadInFromEntry, normalizePcm, noteNameToHz, pack, packKoe, parseFrqAverageF0, parseKoeHeader, parseOto, parseWav, pcmBase, pitchFromAliasSuffix, resample, samplesToMs, toInt16, toMono, trimToOto };