@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 +94 -11
- package/dist/index.js +93 -13
- package/dist/index.js.map +1 -1
- package/dist/koe-convert.js +71 -13
- package/dist/koe-worklet.js +1 -1
- package/package.json +1 -1
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,
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
241
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
9
|
-
|
|
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
|
|
99
|
-
const
|
|
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
|
|
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
|
-
/**
|
|
1049
|
-
|
|
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
|
-
|
|
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,
|