@onjmin/koe 1.0.3 → 1.0.4
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 +115 -109
- package/dist/index.js +482 -393
- package/dist/index.js.map +1 -1
- package/dist/koe-convert.js +182 -154
- package/dist/koe-worklet.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,137 +1,480 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
view.
|
|
9
|
-
return
|
|
1
|
+
// src/converter/frq.ts
|
|
2
|
+
function parseFrqAverageF0(buffer) {
|
|
3
|
+
if (buffer.byteLength < 20) return null;
|
|
4
|
+
const view = new DataView(buffer);
|
|
5
|
+
let header = "";
|
|
6
|
+
for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
|
|
7
|
+
if (header !== "FREQ0003") return null;
|
|
8
|
+
const avg = view.getFloat64(12, true);
|
|
9
|
+
return Number.isFinite(avg) && avg > 0 ? avg : null;
|
|
10
10
|
}
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
return { jsonLength: view.getUint32(4, true) };
|
|
11
|
+
function frqFileName(wavName) {
|
|
12
|
+
const dot = wavName.lastIndexOf(".");
|
|
13
|
+
const base = dot >= 0 ? wavName.slice(0, dot) : wavName;
|
|
14
|
+
const ext = dot >= 0 ? wavName.slice(dot + 1) : "wav";
|
|
15
|
+
return `${base}_${ext}.frq`;
|
|
17
16
|
}
|
|
18
|
-
var pcmBase = (jsonLength) => 8 + jsonLength;
|
|
19
17
|
|
|
20
|
-
// src/
|
|
21
|
-
var
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return this.blob.slice(start, start + length).arrayBuffer();
|
|
31
|
-
}
|
|
18
|
+
// src/converter/pitch.ts
|
|
19
|
+
var SAMPLE_RATE = 48e3;
|
|
20
|
+
var NAME_SEMITONE = {
|
|
21
|
+
c: 0,
|
|
22
|
+
d: 2,
|
|
23
|
+
e: 4,
|
|
24
|
+
f: 5,
|
|
25
|
+
g: 7,
|
|
26
|
+
a: 9,
|
|
27
|
+
b: 11
|
|
32
28
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
function noteNameToHz(name) {
|
|
30
|
+
const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(name);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
let semi = NAME_SEMITONE[m[1].toLowerCase()];
|
|
33
|
+
if (m[2] === "#") semi++;
|
|
34
|
+
else if (m[2] === "b") semi--;
|
|
35
|
+
const midi = (parseInt(m[3], 10) + 1) * 12 + semi;
|
|
36
|
+
return 440 * 2 ** ((midi - 69) / 12);
|
|
37
|
+
}
|
|
38
|
+
function pitchFromAliasSuffix(alias) {
|
|
39
|
+
const m = /_([A-Ga-g][#b]?-?\d+)$/.exec(alias);
|
|
40
|
+
return m ? noteNameToHz(m[1]) : null;
|
|
41
|
+
}
|
|
42
|
+
function detectF0(pcm, start, end) {
|
|
43
|
+
const DECIM = 4;
|
|
44
|
+
const sr = SAMPLE_RATE / DECIM;
|
|
45
|
+
const minLag = Math.floor(sr / 700);
|
|
46
|
+
const maxLag = Math.floor(sr / 70);
|
|
47
|
+
const outLen = Math.floor((end - start) / DECIM);
|
|
48
|
+
if (outLen < maxLag + 2) return 0;
|
|
49
|
+
const win = Math.min(outLen, 1500);
|
|
50
|
+
const buf = new Float32Array(win);
|
|
51
|
+
let mean = 0;
|
|
52
|
+
for (let i = 0; i < win; i++) {
|
|
53
|
+
let s = 0;
|
|
54
|
+
const base = start + i * DECIM;
|
|
55
|
+
for (let j = 0; j < DECIM; j++) s += pcm[base + j];
|
|
56
|
+
buf[i] = s;
|
|
57
|
+
mean += s;
|
|
37
58
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
mean /= win;
|
|
60
|
+
const sq = new Float64Array(win + 1);
|
|
61
|
+
for (let i = 0; i < win; i++) {
|
|
62
|
+
buf[i] -= mean;
|
|
63
|
+
sq[i + 1] = sq[i] + buf[i] * buf[i];
|
|
64
|
+
}
|
|
65
|
+
if (sq[win] < 1) return 0;
|
|
66
|
+
const norm = (lag) => {
|
|
67
|
+
const n = win - lag;
|
|
68
|
+
let r = 0;
|
|
69
|
+
for (let i = 0; i < n; i++) r += buf[i] * buf[i + lag];
|
|
70
|
+
const e = sq[n] + (sq[lag + n] - sq[lag]);
|
|
71
|
+
return e > 0 ? 2 * r / e : 0;
|
|
72
|
+
};
|
|
73
|
+
let bestLag = -1;
|
|
74
|
+
let best = 0;
|
|
75
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
76
|
+
const v = norm(lag);
|
|
77
|
+
if (v > best) {
|
|
78
|
+
best = v;
|
|
79
|
+
bestLag = lag;
|
|
47
80
|
}
|
|
48
|
-
return res.arrayBuffer();
|
|
49
81
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return res.arrayBuffer();
|
|
82
|
+
if (bestLag < 1 || best < 0.4) return 0;
|
|
83
|
+
const y0 = norm(bestLag - 1);
|
|
84
|
+
const y1 = best;
|
|
85
|
+
const y2 = norm(bestLag + 1);
|
|
86
|
+
const denom = y0 - 2 * y1 + y2;
|
|
87
|
+
const shift = denom !== 0 ? 0.5 * (y0 - y2) / denom : 0;
|
|
88
|
+
return sr / (bestLag + shift);
|
|
58
89
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
|
|
91
|
+
// src/converter/pack.ts
|
|
92
|
+
var TARGET_RATE = 48e3;
|
|
93
|
+
function msToSamples(ms) {
|
|
94
|
+
return Math.round(ms / 1e3 * TARGET_RATE);
|
|
95
|
+
}
|
|
96
|
+
var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
97
|
+
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);
|
|
102
|
+
const length = slice.length;
|
|
103
|
+
const pre = clamp(msToSamples(oto.pre), 0, length);
|
|
104
|
+
const overlap = clamp(msToSamples(oto.overlap), 0, length);
|
|
105
|
+
const consonant = clamp(msToSamples(oto.consonant), 0, length);
|
|
106
|
+
const pitch = recordedPitch > 0 ? recordedPitch : detectF0(
|
|
107
|
+
slice,
|
|
108
|
+
Math.min(Math.max(pre, consonant), Math.max(0, length - 1)),
|
|
109
|
+
length
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
pcm: slice,
|
|
113
|
+
entry: { length, pre, overlap, consonant, pitch }
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function pack(inputs, referencePitch = 220) {
|
|
117
|
+
const phonemes = {};
|
|
118
|
+
const chunks = [];
|
|
119
|
+
let byteOffset = 0;
|
|
120
|
+
for (const { oto, pcm, recordedPitch } of inputs) {
|
|
121
|
+
const { pcm: slice, entry } = trimToOto(pcm, oto, recordedPitch);
|
|
122
|
+
if (slice.length === 0) continue;
|
|
123
|
+
phonemes[oto.alias] = { offset: byteOffset, ...entry };
|
|
124
|
+
byteOffset += slice.byteLength;
|
|
125
|
+
chunks.push(slice);
|
|
63
126
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (typeof koe === "string") {
|
|
72
|
-
const header2 = await rangeFetch(koe, 0, 8);
|
|
73
|
-
const { jsonLength: jsonLength2 } = parseKoeHeader(header2);
|
|
74
|
-
const json2 = await rangeFetch(koe, 8, jsonLength2);
|
|
75
|
-
const manifest2 = JSON.parse(new TextDecoder().decode(json2));
|
|
76
|
-
return new _VoiceBank(
|
|
77
|
-
manifest2,
|
|
78
|
-
new RangeVoiceSource(koe, pcmBase(jsonLength2))
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
const header = await koe.slice(0, 8).arrayBuffer();
|
|
82
|
-
const { jsonLength } = parseKoeHeader(header);
|
|
83
|
-
const json = await koe.slice(8, 8 + jsonLength).arrayBuffer();
|
|
84
|
-
const manifest = JSON.parse(new TextDecoder().decode(json));
|
|
85
|
-
return new _VoiceBank(
|
|
86
|
-
manifest,
|
|
87
|
-
new BlobVoiceSource(koe, pcmBase(jsonLength))
|
|
127
|
+
const bin = new ArrayBuffer(byteOffset);
|
|
128
|
+
const view = new Uint8Array(bin);
|
|
129
|
+
let pos = 0;
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
view.set(
|
|
132
|
+
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
|
|
133
|
+
pos
|
|
88
134
|
);
|
|
135
|
+
pos += chunk.byteLength;
|
|
89
136
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* worker / AudioWorklet.
|
|
98
|
-
*/
|
|
99
|
-
async readPcmBytes(phoneme) {
|
|
100
|
-
const entry = this.manifest.phonemes[phoneme];
|
|
101
|
-
if (!entry) return null;
|
|
102
|
-
return this.source.readBytes(entry.offset, entry.length * 2);
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
|
|
106
|
-
* Intended for external analysis / resynthesis such as the WORLD vocoder.
|
|
107
|
-
*/
|
|
108
|
-
async getPcm(phoneme) {
|
|
109
|
-
const buf = await this.readPcmBytes(phoneme);
|
|
110
|
-
if (!buf) return null;
|
|
111
|
-
const int16 = new Int16Array(buf);
|
|
112
|
-
const f64 = new Float64Array(int16.length);
|
|
113
|
-
for (let i = 0; i < int16.length; i++) f64[i] = int16[i] / 32768;
|
|
114
|
-
return f64;
|
|
115
|
-
}
|
|
116
|
-
};
|
|
137
|
+
const manifest = {
|
|
138
|
+
sampleRate: 48e3,
|
|
139
|
+
referencePitch,
|
|
140
|
+
phonemes
|
|
141
|
+
};
|
|
142
|
+
return { manifest, bin };
|
|
143
|
+
}
|
|
117
144
|
|
|
118
|
-
// src/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
// src/converter/parse-oto.ts
|
|
146
|
+
function parseOto(content) {
|
|
147
|
+
const entries = [];
|
|
148
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
149
|
+
const line = raw.trim();
|
|
150
|
+
if (!line || line.startsWith("#")) continue;
|
|
151
|
+
const eq = line.indexOf("=");
|
|
152
|
+
if (eq === -1) continue;
|
|
153
|
+
const wav = line.slice(0, eq).trim();
|
|
154
|
+
const parts = line.slice(eq + 1).split(",");
|
|
155
|
+
if (parts.length < 6) continue;
|
|
156
|
+
const [alias, offsetStr, consonantStr, cutoffStr, preStr, overlapStr] = parts;
|
|
157
|
+
const aliasStr = alias.trim() || wav.replace(/\.[^.]+$/, "");
|
|
158
|
+
const entry = {
|
|
159
|
+
wav,
|
|
160
|
+
alias: aliasStr,
|
|
161
|
+
offset: parseFloat(offsetStr) || 0,
|
|
162
|
+
consonant: parseFloat(consonantStr) || 0,
|
|
163
|
+
cutoff: parseFloat(cutoffStr) || 0,
|
|
164
|
+
pre: parseFloat(preStr) || 0,
|
|
165
|
+
overlap: parseFloat(overlapStr) || 0
|
|
166
|
+
};
|
|
167
|
+
if (!entry.alias) continue;
|
|
168
|
+
entries.push(entry);
|
|
132
169
|
}
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
return entries;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/converter/wav.ts
|
|
174
|
+
function parseWav(buf) {
|
|
175
|
+
const view = new DataView(buf);
|
|
176
|
+
const riff = readFourCC(view, 0);
|
|
177
|
+
if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
|
|
178
|
+
let sampleRate = 0;
|
|
179
|
+
let channels = 0;
|
|
180
|
+
let bitsPerSample = 0;
|
|
181
|
+
let audioFormat = 1;
|
|
182
|
+
let dataOffset = 0;
|
|
183
|
+
let dataLength = 0;
|
|
184
|
+
let pos = 12;
|
|
185
|
+
while (pos < view.byteLength - 8) {
|
|
186
|
+
const id = readFourCC(view, pos);
|
|
187
|
+
const size = view.getUint32(pos + 4, true);
|
|
188
|
+
pos += 8;
|
|
189
|
+
if (id === "fmt ") {
|
|
190
|
+
audioFormat = view.getUint16(pos, true);
|
|
191
|
+
channels = view.getUint16(pos + 2, true);
|
|
192
|
+
sampleRate = view.getUint32(pos + 4, true);
|
|
193
|
+
bitsPerSample = view.getUint16(pos + 14, true);
|
|
194
|
+
if (audioFormat === 65534 && size >= 40) {
|
|
195
|
+
audioFormat = view.getUint16(pos + 24, true);
|
|
196
|
+
}
|
|
197
|
+
} else if (id === "data") {
|
|
198
|
+
dataOffset = pos;
|
|
199
|
+
dataLength = Math.min(size, view.byteLength - pos);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
pos += size + (size & 1);
|
|
203
|
+
}
|
|
204
|
+
if (!dataOffset) throw new Error("WAV has no data chunk");
|
|
205
|
+
if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
|
|
206
|
+
const supported = audioFormat === 3 && bitsPerSample === 32 || audioFormat === 1 && (bitsPerSample === 8 || bitsPerSample === 16 || bitsPerSample === 24);
|
|
207
|
+
if (!supported) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Unsupported WAV format ${audioFormat} / ${bitsPerSample}-bit (need PCM 8/16/24-bit or IEEE float 32-bit)`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const bytesPerSample = bitsPerSample >> 3;
|
|
213
|
+
const totalSamples = Math.floor(dataLength / bytesPerSample);
|
|
214
|
+
const samples = new Float32Array(totalSamples);
|
|
215
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
216
|
+
const p = dataOffset + i * bytesPerSample;
|
|
217
|
+
if (audioFormat === 3) {
|
|
218
|
+
samples[i] = view.getFloat32(p, true);
|
|
219
|
+
} else if (bitsPerSample === 8) {
|
|
220
|
+
samples[i] = (view.getUint8(p) - 128) / 128;
|
|
221
|
+
} else if (bitsPerSample === 16) {
|
|
222
|
+
samples[i] = view.getInt16(p, true) / 32768;
|
|
223
|
+
} else if (bitsPerSample === 24) {
|
|
224
|
+
const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
|
|
225
|
+
let hi = view.getUint8(p + 2);
|
|
226
|
+
if (hi & 128) hi = hi | 4294967040;
|
|
227
|
+
samples[i] = (hi << 16 | lo) / 8388608;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { sampleRate, channels, samples };
|
|
231
|
+
}
|
|
232
|
+
function toMono(wav) {
|
|
233
|
+
if (wav.channels === 1) return wav;
|
|
234
|
+
const len = wav.samples.length / wav.channels;
|
|
235
|
+
const out = new Float32Array(len);
|
|
236
|
+
for (let i = 0; i < len; i++) {
|
|
237
|
+
let sum = 0;
|
|
238
|
+
for (let c = 0; c < wav.channels; c++)
|
|
239
|
+
sum += wav.samples[i * wav.channels + c];
|
|
240
|
+
out[i] = sum / wav.channels;
|
|
241
|
+
}
|
|
242
|
+
return { sampleRate: wav.sampleRate, channels: 1, samples: out };
|
|
243
|
+
}
|
|
244
|
+
function resample(wav, targetRate) {
|
|
245
|
+
if (wav.sampleRate === targetRate) return wav;
|
|
246
|
+
const ratio = wav.sampleRate / targetRate;
|
|
247
|
+
const outLen = Math.floor(wav.samples.length / ratio);
|
|
248
|
+
const out = new Float32Array(outLen);
|
|
249
|
+
const src = wav.samples;
|
|
250
|
+
for (let i = 0; i < outLen; i++) {
|
|
251
|
+
const x = i * ratio;
|
|
252
|
+
const xi = Math.floor(x);
|
|
253
|
+
const frac = x - xi;
|
|
254
|
+
out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
|
|
255
|
+
}
|
|
256
|
+
return { sampleRate: targetRate, channels: 1, samples: out };
|
|
257
|
+
}
|
|
258
|
+
function toInt16(samples) {
|
|
259
|
+
const out = new Int16Array(samples.length);
|
|
260
|
+
for (let i = 0; i < samples.length; i++) {
|
|
261
|
+
out[i] = Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
function normalizePcm(buf) {
|
|
266
|
+
const wav = parseWav(buf);
|
|
267
|
+
const mono = toMono(wav);
|
|
268
|
+
const resampled = resample(mono, 48e3);
|
|
269
|
+
return toInt16(resampled.samples);
|
|
270
|
+
}
|
|
271
|
+
function readFourCC(view, pos) {
|
|
272
|
+
return String.fromCharCode(
|
|
273
|
+
view.getUint8(pos),
|
|
274
|
+
view.getUint8(pos + 1),
|
|
275
|
+
view.getUint8(pos + 2),
|
|
276
|
+
view.getUint8(pos + 3)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/koe.ts
|
|
281
|
+
var MAGIC = 1263486208;
|
|
282
|
+
function packKoe(manifest, pcmParts) {
|
|
283
|
+
const json = new TextEncoder().encode(JSON.stringify(manifest));
|
|
284
|
+
const header = new ArrayBuffer(8);
|
|
285
|
+
const view = new DataView(header);
|
|
286
|
+
view.setUint32(0, MAGIC, false);
|
|
287
|
+
view.setUint32(4, json.byteLength, true);
|
|
288
|
+
return new Blob([header, json, ...pcmParts]);
|
|
289
|
+
}
|
|
290
|
+
function parseKoeHeader(headerBytes) {
|
|
291
|
+
const view = new DataView(headerBytes);
|
|
292
|
+
if (view.byteLength < 8 || view.getUint32(0, false) !== MAGIC) {
|
|
293
|
+
throw new Error("Not a .koe file (bad magic)");
|
|
294
|
+
}
|
|
295
|
+
return { jsonLength: view.getUint32(4, true) };
|
|
296
|
+
}
|
|
297
|
+
var pcmBase = (jsonLength) => 8 + jsonLength;
|
|
298
|
+
|
|
299
|
+
// src/engine/voice-bank.ts
|
|
300
|
+
var MAX_PHONEME_SAMPLES = 5242880;
|
|
301
|
+
var MAX_JSON_LENGTH = 50 * 1024 * 1024;
|
|
302
|
+
var BlobVoiceSource = class {
|
|
303
|
+
constructor(blob, base) {
|
|
304
|
+
this.blob = blob;
|
|
305
|
+
this.base = base;
|
|
306
|
+
}
|
|
307
|
+
blob;
|
|
308
|
+
base;
|
|
309
|
+
readBytes(offset, length) {
|
|
310
|
+
const start = this.base + offset;
|
|
311
|
+
return this.blob.slice(start, start + length).arrayBuffer();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
var RangeVoiceSource = class {
|
|
315
|
+
constructor(url, base) {
|
|
316
|
+
this.url = url;
|
|
317
|
+
this.base = base;
|
|
318
|
+
}
|
|
319
|
+
url;
|
|
320
|
+
base;
|
|
321
|
+
async readBytes(offset, length) {
|
|
322
|
+
const start = this.base + offset;
|
|
323
|
+
return rangeFetch(this.url, start, length);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
async function rangeFetch(url, start, length) {
|
|
327
|
+
const res = await fetch(url, {
|
|
328
|
+
headers: { Range: `bytes=${start}-${start + length - 1}` },
|
|
329
|
+
credentials: "omit"
|
|
330
|
+
// never leak cookies / auth to a MML-supplied URL
|
|
331
|
+
});
|
|
332
|
+
if (res.status !== 206) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`.koe fetch failed: expected 206 Partial Content, got ${res.status}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return readCapped(res, length);
|
|
338
|
+
}
|
|
339
|
+
async function readCapped(res, length) {
|
|
340
|
+
const reader = res.body?.getReader();
|
|
341
|
+
if (!reader) {
|
|
342
|
+
const buf = await res.arrayBuffer();
|
|
343
|
+
if (buf.byteLength > length) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`.koe fetch failed: response exceeds requested ${length} bytes`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return buf;
|
|
349
|
+
}
|
|
350
|
+
const out = new Uint8Array(length);
|
|
351
|
+
let received = 0;
|
|
352
|
+
for (; ; ) {
|
|
353
|
+
const { done, value } = await reader.read();
|
|
354
|
+
if (done) break;
|
|
355
|
+
if (received + value.byteLength > length) {
|
|
356
|
+
await reader.cancel();
|
|
357
|
+
throw new Error(
|
|
358
|
+
`.koe fetch failed: response exceeds requested ${length} bytes`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
out.set(value, received);
|
|
362
|
+
received += value.byteLength;
|
|
363
|
+
}
|
|
364
|
+
return received === length ? out.buffer : out.buffer.slice(0, received);
|
|
365
|
+
}
|
|
366
|
+
function validateJsonLength(jsonLength) {
|
|
367
|
+
if (!Number.isInteger(jsonLength) || jsonLength < 0 || jsonLength > MAX_JSON_LENGTH) {
|
|
368
|
+
throw new Error(`manifest JSON length out of bounds: ${jsonLength}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function parseManifest(json) {
|
|
372
|
+
const manifest = JSON.parse(new TextDecoder().decode(json));
|
|
373
|
+
if (!manifest || typeof manifest !== "object" || typeof manifest.phonemes !== "object" || manifest.phonemes === null) {
|
|
374
|
+
throw new Error("invalid manifest: missing phonemes table");
|
|
375
|
+
}
|
|
376
|
+
return manifest;
|
|
377
|
+
}
|
|
378
|
+
var VoiceBank = class _VoiceBank {
|
|
379
|
+
constructor(manifest, source) {
|
|
380
|
+
this.manifest = manifest;
|
|
381
|
+
this.source = source;
|
|
382
|
+
}
|
|
383
|
+
manifest;
|
|
384
|
+
source;
|
|
385
|
+
/**
|
|
386
|
+
* Parse a .koe archive header + manifest and bind a lazy PCM source.
|
|
387
|
+
* @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
|
|
388
|
+
*/
|
|
389
|
+
static async load(koe) {
|
|
390
|
+
try {
|
|
391
|
+
if (typeof koe === "string") {
|
|
392
|
+
if (/^blob:/i.test(koe)) {
|
|
393
|
+
const res = await fetch(koe);
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
throw new Error(`blob: URL fetch failed: ${res.status}`);
|
|
396
|
+
}
|
|
397
|
+
return await _VoiceBank.fromBlob(await res.blob());
|
|
398
|
+
}
|
|
399
|
+
if (!/^https?:/i.test(koe)) {
|
|
400
|
+
throw new Error(`unsupported URL protocol: ${koe}`);
|
|
401
|
+
}
|
|
402
|
+
const header = await rangeFetch(koe, 0, 8);
|
|
403
|
+
const { jsonLength } = parseKoeHeader(header);
|
|
404
|
+
validateJsonLength(jsonLength);
|
|
405
|
+
const json = await rangeFetch(koe, 8, jsonLength);
|
|
406
|
+
const manifest = parseManifest(json);
|
|
407
|
+
return new _VoiceBank(
|
|
408
|
+
manifest,
|
|
409
|
+
new RangeVoiceSource(koe, pcmBase(jsonLength))
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return await _VoiceBank.fromBlob(koe);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Failed to load .koe voice bank: ${error instanceof Error ? error.message : String(error)}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
static async fromBlob(koe) {
|
|
420
|
+
const header = await koe.slice(0, 8).arrayBuffer();
|
|
421
|
+
const { jsonLength } = parseKoeHeader(header);
|
|
422
|
+
validateJsonLength(jsonLength);
|
|
423
|
+
const json = await koe.slice(8, 8 + jsonLength).arrayBuffer();
|
|
424
|
+
const manifest = parseManifest(json);
|
|
425
|
+
return new _VoiceBank(
|
|
426
|
+
manifest,
|
|
427
|
+
new BlobVoiceSource(koe, pcmBase(jsonLength))
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
/** True if the bank contains a phoneme under this alias. */
|
|
431
|
+
has(phoneme) {
|
|
432
|
+
return Object.hasOwn(this.manifest.phonemes, phoneme);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Raw Int16 PCM bytes (48 kHz / mono) for a phoneme, or null if unknown.
|
|
436
|
+
* The returned ArrayBuffer is freshly allocated and safe to transfer to a
|
|
437
|
+
* worker / AudioWorklet.
|
|
438
|
+
*/
|
|
439
|
+
async readPcmBytes(phoneme) {
|
|
440
|
+
if (!Object.hasOwn(this.manifest.phonemes, phoneme)) return null;
|
|
441
|
+
const entry = this.manifest.phonemes[phoneme];
|
|
442
|
+
if (!Number.isInteger(entry.offset) || !Number.isInteger(entry.length) || entry.offset < 0 || entry.length < 0 || entry.length > MAX_PHONEME_SAMPLES) {
|
|
443
|
+
throw new Error(`manifest entry out of bounds for phoneme: ${phoneme}`);
|
|
444
|
+
}
|
|
445
|
+
return this.source.readBytes(entry.offset, entry.length * 2);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
|
|
449
|
+
* Intended for external analysis / resynthesis such as the WORLD vocoder.
|
|
450
|
+
*/
|
|
451
|
+
async getPcm(phoneme) {
|
|
452
|
+
const buf = await this.readPcmBytes(phoneme);
|
|
453
|
+
if (!buf) return null;
|
|
454
|
+
const int16 = new Int16Array(buf, 0, Math.floor(buf.byteLength / 2));
|
|
455
|
+
const f64 = new Float64Array(int16.length);
|
|
456
|
+
for (let i = 0; i < int16.length; i++) f64[i] = int16[i] / 32768;
|
|
457
|
+
return f64;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// src/engine/index.ts
|
|
462
|
+
var KoeEngine = class {
|
|
463
|
+
ctx;
|
|
464
|
+
workletUrl;
|
|
465
|
+
node = null;
|
|
466
|
+
bank = null;
|
|
467
|
+
delivered = /* @__PURE__ */ new Set();
|
|
468
|
+
pending = /* @__PURE__ */ new Map();
|
|
469
|
+
constructor(options = {}) {
|
|
470
|
+
this.ctx = new AudioContext({ sampleRate: 48e3 });
|
|
471
|
+
this.workletUrl = options.workletUrl ?? "./koe-worklet.js";
|
|
472
|
+
}
|
|
473
|
+
get audioContext() {
|
|
474
|
+
return this.ctx;
|
|
475
|
+
}
|
|
476
|
+
get manifest() {
|
|
477
|
+
return this.bank?.manifest ?? null;
|
|
135
478
|
}
|
|
136
479
|
/** The underlying voice bank (manifest + on-demand PCM), or null before load(). */
|
|
137
480
|
get voiceBank() {
|
|
@@ -143,6 +486,8 @@ var KoeEngine = class {
|
|
|
143
486
|
*/
|
|
144
487
|
async load(koe) {
|
|
145
488
|
await this.ctx.audioWorklet.addModule(this.workletUrl);
|
|
489
|
+
this.node?.disconnect();
|
|
490
|
+
this.node = null;
|
|
146
491
|
this.bank = await VoiceBank.load(koe);
|
|
147
492
|
this.delivered.clear();
|
|
148
493
|
this.pending.clear();
|
|
@@ -166,11 +511,12 @@ var KoeEngine = class {
|
|
|
166
511
|
if (existing) return existing;
|
|
167
512
|
if (!this.bank || !this.node) return Promise.resolve();
|
|
168
513
|
const load = this.bank.readPcmBytes(name).then((buf) => {
|
|
169
|
-
if (!buf) return;
|
|
514
|
+
if (!buf || !this.node) return;
|
|
170
515
|
this.node.port.postMessage({ type: "phoneme", name, buffer: buf }, [
|
|
171
516
|
buf
|
|
172
517
|
]);
|
|
173
518
|
this.delivered.add(name);
|
|
519
|
+
}).finally(() => {
|
|
174
520
|
this.pending.delete(name);
|
|
175
521
|
});
|
|
176
522
|
this.pending.set(name, load);
|
|
@@ -192,6 +538,18 @@ var KoeEngine = class {
|
|
|
192
538
|
async resume() {
|
|
193
539
|
if (this.ctx.state === "suspended") await this.ctx.resume();
|
|
194
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Tear down the worklet node and close the AudioContext, releasing the audio
|
|
543
|
+
* hardware. The engine cannot be reused afterwards — create a new one.
|
|
544
|
+
*/
|
|
545
|
+
async dispose() {
|
|
546
|
+
this.node?.disconnect();
|
|
547
|
+
this.node = null;
|
|
548
|
+
this.bank = null;
|
|
549
|
+
this.delivered.clear();
|
|
550
|
+
this.pending.clear();
|
|
551
|
+
if (this.ctx.state !== "closed") await this.ctx.close();
|
|
552
|
+
}
|
|
195
553
|
/**
|
|
196
554
|
* Read a phoneme's raw PCM and return it as a Float64Array normalised to
|
|
197
555
|
* [-1, 1]. Convenience that forwards to the underlying {@link VoiceBank}.
|
|
@@ -388,282 +746,13 @@ var Worldline = class _Worldline {
|
|
|
388
746
|
}
|
|
389
747
|
const outLen = WL._PhraseSynthSynth(ps, yPtrPtr, 0);
|
|
390
748
|
const yPtr = WL.getValue(yPtrPtr, "*");
|
|
391
|
-
const audio = outLen > 0 ? new Float32Array(WL.HEAPF32.buffer, yPtr, outLen).slice() : null;
|
|
749
|
+
const audio = outLen > 0 && yPtr ? new Float32Array(WL.HEAPF32.buffer, yPtr, outLen).slice() : null;
|
|
750
|
+
if (yPtr) WL._free(yPtr);
|
|
392
751
|
WL._free(yPtrPtr);
|
|
393
752
|
WL._PhraseSynthDelete(ps);
|
|
394
753
|
return audio;
|
|
395
754
|
}
|
|
396
755
|
};
|
|
397
|
-
|
|
398
|
-
// src/converter/parse-oto.ts
|
|
399
|
-
function parseOto(content) {
|
|
400
|
-
const entries = [];
|
|
401
|
-
for (const raw of content.split(/\r?\n/)) {
|
|
402
|
-
const line = raw.trim();
|
|
403
|
-
if (!line || line.startsWith("#")) continue;
|
|
404
|
-
const eq = line.indexOf("=");
|
|
405
|
-
if (eq === -1) continue;
|
|
406
|
-
const wav = line.slice(0, eq).trim();
|
|
407
|
-
const parts = line.slice(eq + 1).split(",");
|
|
408
|
-
if (parts.length < 6) continue;
|
|
409
|
-
const [alias, offsetStr, consonantStr, cutoffStr, preStr, overlapStr] = parts;
|
|
410
|
-
const aliasStr = alias.trim() || wav.replace(/\.[^.]+$/, "");
|
|
411
|
-
const entry = {
|
|
412
|
-
wav,
|
|
413
|
-
alias: aliasStr,
|
|
414
|
-
offset: parseFloat(offsetStr) || 0,
|
|
415
|
-
consonant: parseFloat(consonantStr) || 0,
|
|
416
|
-
cutoff: parseFloat(cutoffStr) || 0,
|
|
417
|
-
pre: parseFloat(preStr) || 0,
|
|
418
|
-
overlap: parseFloat(overlapStr) || 0
|
|
419
|
-
};
|
|
420
|
-
if (!entry.alias) continue;
|
|
421
|
-
entries.push(entry);
|
|
422
|
-
}
|
|
423
|
-
return entries;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// src/converter/wav.ts
|
|
427
|
-
function parseWav(buf) {
|
|
428
|
-
const view = new DataView(buf);
|
|
429
|
-
const riff = readFourCC(view, 0);
|
|
430
|
-
if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
|
|
431
|
-
let sampleRate = 0;
|
|
432
|
-
let channels = 0;
|
|
433
|
-
let bitsPerSample = 0;
|
|
434
|
-
let audioFormat = 1;
|
|
435
|
-
let dataOffset = 0;
|
|
436
|
-
let dataLength = 0;
|
|
437
|
-
let pos = 12;
|
|
438
|
-
while (pos < view.byteLength - 8) {
|
|
439
|
-
const id = readFourCC(view, pos);
|
|
440
|
-
const size = view.getUint32(pos + 4, true);
|
|
441
|
-
pos += 8;
|
|
442
|
-
if (id === "fmt ") {
|
|
443
|
-
audioFormat = view.getUint16(pos, true);
|
|
444
|
-
channels = view.getUint16(pos + 2, true);
|
|
445
|
-
sampleRate = view.getUint32(pos + 4, true);
|
|
446
|
-
bitsPerSample = view.getUint16(pos + 14, true);
|
|
447
|
-
} else if (id === "data") {
|
|
448
|
-
dataOffset = pos;
|
|
449
|
-
dataLength = size;
|
|
450
|
-
break;
|
|
451
|
-
}
|
|
452
|
-
pos += size + (size & 1);
|
|
453
|
-
}
|
|
454
|
-
if (!dataOffset) throw new Error("WAV has no data chunk");
|
|
455
|
-
if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
|
|
456
|
-
const bytesPerSample = bitsPerSample >> 3;
|
|
457
|
-
const totalSamples = Math.floor(dataLength / bytesPerSample);
|
|
458
|
-
const samples = new Float32Array(totalSamples);
|
|
459
|
-
for (let i = 0; i < totalSamples; i++) {
|
|
460
|
-
const p = dataOffset + i * bytesPerSample;
|
|
461
|
-
if (audioFormat === 3) {
|
|
462
|
-
samples[i] = view.getFloat32(p, true);
|
|
463
|
-
} else if (bitsPerSample === 8) {
|
|
464
|
-
samples[i] = (view.getUint8(p) - 128) / 128;
|
|
465
|
-
} else if (bitsPerSample === 16) {
|
|
466
|
-
samples[i] = view.getInt16(p, true) / 32768;
|
|
467
|
-
} else if (bitsPerSample === 24) {
|
|
468
|
-
const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
|
|
469
|
-
let hi = view.getUint8(p + 2);
|
|
470
|
-
if (hi & 128) hi = hi | 4294967040;
|
|
471
|
-
samples[i] = (hi << 16 | lo) / 8388608;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return { sampleRate, channels, samples };
|
|
475
|
-
}
|
|
476
|
-
function toMono(wav) {
|
|
477
|
-
if (wav.channels === 1) return wav;
|
|
478
|
-
const len = wav.samples.length / wav.channels;
|
|
479
|
-
const out = new Float32Array(len);
|
|
480
|
-
for (let i = 0; i < len; i++) {
|
|
481
|
-
let sum = 0;
|
|
482
|
-
for (let c = 0; c < wav.channels; c++)
|
|
483
|
-
sum += wav.samples[i * wav.channels + c];
|
|
484
|
-
out[i] = sum / wav.channels;
|
|
485
|
-
}
|
|
486
|
-
return { sampleRate: wav.sampleRate, channels: 1, samples: out };
|
|
487
|
-
}
|
|
488
|
-
function resample(wav, targetRate) {
|
|
489
|
-
if (wav.sampleRate === targetRate) return wav;
|
|
490
|
-
const ratio = wav.sampleRate / targetRate;
|
|
491
|
-
const outLen = Math.floor(wav.samples.length / ratio);
|
|
492
|
-
const out = new Float32Array(outLen);
|
|
493
|
-
const src = wav.samples;
|
|
494
|
-
for (let i = 0; i < outLen; i++) {
|
|
495
|
-
const x = i * ratio;
|
|
496
|
-
const xi = Math.floor(x);
|
|
497
|
-
const frac = x - xi;
|
|
498
|
-
out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
|
|
499
|
-
}
|
|
500
|
-
return { sampleRate: targetRate, channels: 1, samples: out };
|
|
501
|
-
}
|
|
502
|
-
function toInt16(samples) {
|
|
503
|
-
const out = new Int16Array(samples.length);
|
|
504
|
-
for (let i = 0; i < samples.length; i++) {
|
|
505
|
-
out[i] = Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767);
|
|
506
|
-
}
|
|
507
|
-
return out;
|
|
508
|
-
}
|
|
509
|
-
function normalizePcm(buf) {
|
|
510
|
-
const wav = parseWav(buf);
|
|
511
|
-
const mono = toMono(wav);
|
|
512
|
-
const resampled = resample(mono, 48e3);
|
|
513
|
-
return toInt16(resampled.samples);
|
|
514
|
-
}
|
|
515
|
-
function readFourCC(view, pos) {
|
|
516
|
-
return String.fromCharCode(
|
|
517
|
-
view.getUint8(pos),
|
|
518
|
-
view.getUint8(pos + 1),
|
|
519
|
-
view.getUint8(pos + 2),
|
|
520
|
-
view.getUint8(pos + 3)
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// src/converter/pitch.ts
|
|
525
|
-
var SAMPLE_RATE = 48e3;
|
|
526
|
-
var NAME_SEMITONE = {
|
|
527
|
-
c: 0,
|
|
528
|
-
d: 2,
|
|
529
|
-
e: 4,
|
|
530
|
-
f: 5,
|
|
531
|
-
g: 7,
|
|
532
|
-
a: 9,
|
|
533
|
-
b: 11
|
|
534
|
-
};
|
|
535
|
-
function noteNameToHz(name) {
|
|
536
|
-
const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(name);
|
|
537
|
-
if (!m) return null;
|
|
538
|
-
let semi = NAME_SEMITONE[m[1].toLowerCase()];
|
|
539
|
-
if (m[2] === "#") semi++;
|
|
540
|
-
else if (m[2] === "b") semi--;
|
|
541
|
-
const midi = (parseInt(m[3], 10) + 1) * 12 + semi;
|
|
542
|
-
return 440 * 2 ** ((midi - 69) / 12);
|
|
543
|
-
}
|
|
544
|
-
function pitchFromAliasSuffix(alias) {
|
|
545
|
-
const m = /_([A-Ga-g][#b]?-?\d+)$/.exec(alias);
|
|
546
|
-
return m ? noteNameToHz(m[1]) : null;
|
|
547
|
-
}
|
|
548
|
-
function detectF0(pcm, start, end) {
|
|
549
|
-
const DECIM = 4;
|
|
550
|
-
const sr = SAMPLE_RATE / DECIM;
|
|
551
|
-
const minLag = Math.floor(sr / 700);
|
|
552
|
-
const maxLag = Math.floor(sr / 70);
|
|
553
|
-
const outLen = Math.floor((end - start) / DECIM);
|
|
554
|
-
if (outLen < maxLag + 2) return 0;
|
|
555
|
-
const win = Math.min(outLen, 1500);
|
|
556
|
-
const buf = new Float32Array(win);
|
|
557
|
-
let mean = 0;
|
|
558
|
-
for (let i = 0; i < win; i++) {
|
|
559
|
-
let s = 0;
|
|
560
|
-
const base = start + i * DECIM;
|
|
561
|
-
for (let j = 0; j < DECIM; j++) s += pcm[base + j];
|
|
562
|
-
buf[i] = s;
|
|
563
|
-
mean += s;
|
|
564
|
-
}
|
|
565
|
-
mean /= win;
|
|
566
|
-
const sq = new Float64Array(win + 1);
|
|
567
|
-
for (let i = 0; i < win; i++) {
|
|
568
|
-
buf[i] -= mean;
|
|
569
|
-
sq[i + 1] = sq[i] + buf[i] * buf[i];
|
|
570
|
-
}
|
|
571
|
-
if (sq[win] < 1) return 0;
|
|
572
|
-
const norm = (lag) => {
|
|
573
|
-
const n = win - lag;
|
|
574
|
-
let r = 0;
|
|
575
|
-
for (let i = 0; i < n; i++) r += buf[i] * buf[i + lag];
|
|
576
|
-
const e = sq[n] + (sq[lag + n] - sq[lag]);
|
|
577
|
-
return e > 0 ? 2 * r / e : 0;
|
|
578
|
-
};
|
|
579
|
-
let bestLag = -1;
|
|
580
|
-
let best = 0;
|
|
581
|
-
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
582
|
-
const v = norm(lag);
|
|
583
|
-
if (v > best) {
|
|
584
|
-
best = v;
|
|
585
|
-
bestLag = lag;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
if (bestLag < 1 || best < 0.4) return 0;
|
|
589
|
-
const y0 = norm(bestLag - 1);
|
|
590
|
-
const y1 = best;
|
|
591
|
-
const y2 = norm(bestLag + 1);
|
|
592
|
-
const denom = y0 - 2 * y1 + y2;
|
|
593
|
-
const shift = denom !== 0 ? 0.5 * (y0 - y2) / denom : 0;
|
|
594
|
-
return sr / (bestLag + shift);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// src/converter/pack.ts
|
|
598
|
-
var TARGET_RATE = 48e3;
|
|
599
|
-
function msToSamples(ms) {
|
|
600
|
-
return Math.round(ms / 1e3 * TARGET_RATE);
|
|
601
|
-
}
|
|
602
|
-
var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
603
|
-
function trimToOto(pcm, oto, recordedPitch = 0) {
|
|
604
|
-
const full = pcm.length;
|
|
605
|
-
const start = clamp(msToSamples(oto.offset), 0, full);
|
|
606
|
-
const end = oto.cutoff < 0 ? clamp(start + msToSamples(-oto.cutoff), start, full) : clamp(full - msToSamples(oto.cutoff), start, full);
|
|
607
|
-
const slice = pcm.subarray(start, end);
|
|
608
|
-
const length = slice.length;
|
|
609
|
-
const pre = clamp(msToSamples(oto.pre), 0, length);
|
|
610
|
-
const overlap = clamp(msToSamples(oto.overlap), 0, length);
|
|
611
|
-
const consonant = clamp(msToSamples(oto.consonant), 0, length);
|
|
612
|
-
const pitch = recordedPitch > 0 ? recordedPitch : detectF0(
|
|
613
|
-
slice,
|
|
614
|
-
Math.min(Math.max(pre, consonant), Math.max(0, length - 1)),
|
|
615
|
-
length
|
|
616
|
-
);
|
|
617
|
-
return {
|
|
618
|
-
pcm: slice,
|
|
619
|
-
entry: { length, pre, overlap, consonant, pitch }
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
function pack(inputs, referencePitch = 220) {
|
|
623
|
-
const phonemes = {};
|
|
624
|
-
const chunks = [];
|
|
625
|
-
let byteOffset = 0;
|
|
626
|
-
for (const { oto, pcm, recordedPitch } of inputs) {
|
|
627
|
-
const { pcm: slice, entry } = trimToOto(pcm, oto, recordedPitch);
|
|
628
|
-
if (slice.length === 0) continue;
|
|
629
|
-
phonemes[oto.alias] = { offset: byteOffset, ...entry };
|
|
630
|
-
byteOffset += slice.byteLength;
|
|
631
|
-
chunks.push(slice);
|
|
632
|
-
}
|
|
633
|
-
const bin = new ArrayBuffer(byteOffset);
|
|
634
|
-
const view = new Uint8Array(bin);
|
|
635
|
-
let pos = 0;
|
|
636
|
-
for (const chunk of chunks) {
|
|
637
|
-
view.set(
|
|
638
|
-
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
|
|
639
|
-
pos
|
|
640
|
-
);
|
|
641
|
-
pos += chunk.byteLength;
|
|
642
|
-
}
|
|
643
|
-
const manifest = {
|
|
644
|
-
sampleRate: 48e3,
|
|
645
|
-
referencePitch,
|
|
646
|
-
phonemes
|
|
647
|
-
};
|
|
648
|
-
return { manifest, bin };
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// src/converter/frq.ts
|
|
652
|
-
function parseFrqAverageF0(buffer) {
|
|
653
|
-
if (buffer.byteLength < 20) return null;
|
|
654
|
-
const view = new DataView(buffer);
|
|
655
|
-
let header = "";
|
|
656
|
-
for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
|
|
657
|
-
if (header !== "FREQ0003") return null;
|
|
658
|
-
const avg = view.getFloat64(12, true);
|
|
659
|
-
return Number.isFinite(avg) && avg > 0 ? avg : null;
|
|
660
|
-
}
|
|
661
|
-
function frqFileName(wavName) {
|
|
662
|
-
const dot = wavName.lastIndexOf(".");
|
|
663
|
-
const base = dot >= 0 ? wavName.slice(0, dot) : wavName;
|
|
664
|
-
const ext = dot >= 0 ? wavName.slice(dot + 1) : "wav";
|
|
665
|
-
return `${base}_${ext}.frq`;
|
|
666
|
-
}
|
|
667
756
|
export {
|
|
668
757
|
KoeEngine,
|
|
669
758
|
MIN_WORLDLINE_SAMPLES,
|