@onjmin/koe 1.0.6 → 1.0.7
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/README.md +122 -5
- package/dist/index.d.ts +328 -1
- package/dist/index.js +1795 -4
- package/dist/index.js.map +1 -1
- package/dist/koe-convert.js +0 -1
- package/dist/koe-oto.js +1399 -0
- package/package.json +4 -2
package/dist/koe-oto.js
ADDED
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/oto/cli.ts
|
|
4
|
+
import { readdir, readFile, stat, writeFile } from "fs/promises";
|
|
5
|
+
import { basename, join, relative, resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// src/converter/wav.ts
|
|
8
|
+
function parseWav(buf) {
|
|
9
|
+
const view = new DataView(buf);
|
|
10
|
+
const riff = readFourCC(view, 0);
|
|
11
|
+
if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
|
|
12
|
+
let sampleRate = 0;
|
|
13
|
+
let channels = 0;
|
|
14
|
+
let bitsPerSample = 0;
|
|
15
|
+
let audioFormat = 1;
|
|
16
|
+
let dataOffset = 0;
|
|
17
|
+
let dataLength = 0;
|
|
18
|
+
let pos = 12;
|
|
19
|
+
while (pos < view.byteLength - 8) {
|
|
20
|
+
const id = readFourCC(view, pos);
|
|
21
|
+
const size = view.getUint32(pos + 4, true);
|
|
22
|
+
pos += 8;
|
|
23
|
+
if (id === "fmt ") {
|
|
24
|
+
audioFormat = view.getUint16(pos, true);
|
|
25
|
+
channels = view.getUint16(pos + 2, true);
|
|
26
|
+
sampleRate = view.getUint32(pos + 4, true);
|
|
27
|
+
bitsPerSample = view.getUint16(pos + 14, true);
|
|
28
|
+
if (audioFormat === 65534 && size >= 40) {
|
|
29
|
+
audioFormat = view.getUint16(pos + 24, true);
|
|
30
|
+
}
|
|
31
|
+
} else if (id === "data") {
|
|
32
|
+
dataOffset = pos;
|
|
33
|
+
dataLength = Math.min(size, view.byteLength - pos);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
pos += size + (size & 1);
|
|
37
|
+
}
|
|
38
|
+
if (!dataOffset) throw new Error("WAV has no data chunk");
|
|
39
|
+
if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
|
|
40
|
+
const supported = audioFormat === 3 && bitsPerSample === 32 || audioFormat === 1 && (bitsPerSample === 8 || bitsPerSample === 16 || bitsPerSample === 24);
|
|
41
|
+
if (!supported) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Unsupported WAV format ${audioFormat} / ${bitsPerSample}-bit (need PCM 8/16/24-bit or IEEE float 32-bit)`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const bytesPerSample = bitsPerSample >> 3;
|
|
47
|
+
const totalSamples = Math.floor(dataLength / bytesPerSample);
|
|
48
|
+
const samples = new Float32Array(totalSamples);
|
|
49
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
50
|
+
const p = dataOffset + i * bytesPerSample;
|
|
51
|
+
if (audioFormat === 3) {
|
|
52
|
+
samples[i] = view.getFloat32(p, true);
|
|
53
|
+
} else if (bitsPerSample === 8) {
|
|
54
|
+
samples[i] = (view.getUint8(p) - 128) / 128;
|
|
55
|
+
} else if (bitsPerSample === 16) {
|
|
56
|
+
samples[i] = view.getInt16(p, true) / 32768;
|
|
57
|
+
} else if (bitsPerSample === 24) {
|
|
58
|
+
const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
|
|
59
|
+
let hi = view.getUint8(p + 2);
|
|
60
|
+
if (hi & 128) hi = hi | 4294967040;
|
|
61
|
+
samples[i] = (hi << 16 | lo) / 8388608;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { sampleRate, channels, samples };
|
|
65
|
+
}
|
|
66
|
+
function toMono(wav) {
|
|
67
|
+
if (wav.channels === 1) return wav;
|
|
68
|
+
const len = wav.samples.length / wav.channels;
|
|
69
|
+
const out = new Float32Array(len);
|
|
70
|
+
for (let i = 0; i < len; i++) {
|
|
71
|
+
let sum = 0;
|
|
72
|
+
for (let c = 0; c < wav.channels; c++)
|
|
73
|
+
sum += wav.samples[i * wav.channels + c];
|
|
74
|
+
out[i] = sum / wav.channels;
|
|
75
|
+
}
|
|
76
|
+
return { sampleRate: wav.sampleRate, channels: 1, samples: out };
|
|
77
|
+
}
|
|
78
|
+
function resample(wav, targetRate) {
|
|
79
|
+
if (wav.sampleRate === targetRate) return wav;
|
|
80
|
+
const ratio = wav.sampleRate / targetRate;
|
|
81
|
+
const outLen = Math.floor(wav.samples.length / ratio);
|
|
82
|
+
const out = new Float32Array(outLen);
|
|
83
|
+
const src = wav.samples;
|
|
84
|
+
for (let i = 0; i < outLen; i++) {
|
|
85
|
+
const x = i * ratio;
|
|
86
|
+
const xi = Math.floor(x);
|
|
87
|
+
const frac = x - xi;
|
|
88
|
+
out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
|
|
89
|
+
}
|
|
90
|
+
return { sampleRate: targetRate, channels: 1, samples: out };
|
|
91
|
+
}
|
|
92
|
+
function readFourCC(view, pos) {
|
|
93
|
+
return String.fromCharCode(
|
|
94
|
+
view.getUint8(pos),
|
|
95
|
+
view.getUint8(pos + 1),
|
|
96
|
+
view.getUint8(pos + 2),
|
|
97
|
+
view.getUint8(pos + 3)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/oto/frames.ts
|
|
102
|
+
var RATE = 16e3;
|
|
103
|
+
var HOP_MS = 2;
|
|
104
|
+
var HOP = RATE * HOP_MS / 1e3;
|
|
105
|
+
var FFT_SIZE = 512;
|
|
106
|
+
var BINS = FFT_SIZE / 2;
|
|
107
|
+
var PITCH_DECIM = 2;
|
|
108
|
+
var PITCH_RATE = RATE / PITCH_DECIM;
|
|
109
|
+
var PITCH_WIN = 512;
|
|
110
|
+
var MIN_F0 = 70;
|
|
111
|
+
var MAX_F0 = 600;
|
|
112
|
+
var WINDOW_CENTRE_MS = FFT_SIZE / 2 / RATE * 1e3;
|
|
113
|
+
function msToFrame(ms) {
|
|
114
|
+
return Math.round(ms / HOP_MS);
|
|
115
|
+
}
|
|
116
|
+
function framesToMs(frames) {
|
|
117
|
+
return frames * HOP_MS;
|
|
118
|
+
}
|
|
119
|
+
function frameTimeMs(frame) {
|
|
120
|
+
return frame * HOP_MS + WINDOW_CENTRE_MS;
|
|
121
|
+
}
|
|
122
|
+
function fft(re, im) {
|
|
123
|
+
const n = re.length;
|
|
124
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
125
|
+
let bit = n >> 1;
|
|
126
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
127
|
+
j ^= bit;
|
|
128
|
+
if (i < j) {
|
|
129
|
+
[re[i], re[j]] = [re[j], re[i]];
|
|
130
|
+
[im[i], im[j]] = [im[j], im[i]];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
134
|
+
const ang = -2 * Math.PI / len;
|
|
135
|
+
const wRe = Math.cos(ang);
|
|
136
|
+
const wIm = Math.sin(ang);
|
|
137
|
+
for (let i = 0; i < n; i += len) {
|
|
138
|
+
let curRe = 1;
|
|
139
|
+
let curIm = 0;
|
|
140
|
+
for (let k = 0; k < len >> 1; k++) {
|
|
141
|
+
const aRe = re[i + k];
|
|
142
|
+
const aIm = im[i + k];
|
|
143
|
+
const bRe = re[i + k + (len >> 1)] * curRe - im[i + k + (len >> 1)] * curIm;
|
|
144
|
+
const bIm = re[i + k + (len >> 1)] * curIm + im[i + k + (len >> 1)] * curRe;
|
|
145
|
+
re[i + k] = aRe + bRe;
|
|
146
|
+
im[i + k] = aIm + bIm;
|
|
147
|
+
re[i + k + (len >> 1)] = aRe - bRe;
|
|
148
|
+
im[i + k + (len >> 1)] = aIm - bIm;
|
|
149
|
+
const nextRe = curRe * wRe - curIm * wIm;
|
|
150
|
+
curIm = curRe * wIm + curIm * wRe;
|
|
151
|
+
curRe = nextRe;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function quietestWindow(db) {
|
|
157
|
+
const averaged = smooth(db, Math.round(50 / HOP_MS));
|
|
158
|
+
let min = Infinity;
|
|
159
|
+
for (let i = 0; i < averaged.length; i++) {
|
|
160
|
+
if (averaged[i] < min) min = averaged[i];
|
|
161
|
+
}
|
|
162
|
+
return Number.isFinite(min) ? min : -120;
|
|
163
|
+
}
|
|
164
|
+
function percentile(values, p) {
|
|
165
|
+
const sorted = Float32Array.from(values).sort();
|
|
166
|
+
const i = Math.min(
|
|
167
|
+
sorted.length - 1,
|
|
168
|
+
Math.max(0, Math.round(p * (sorted.length - 1)))
|
|
169
|
+
);
|
|
170
|
+
return sorted[i];
|
|
171
|
+
}
|
|
172
|
+
function ncc(buf, at, win, lag) {
|
|
173
|
+
let r = 0;
|
|
174
|
+
let e0 = 0;
|
|
175
|
+
let e1 = 0;
|
|
176
|
+
for (let i = 0; i < win; i++) {
|
|
177
|
+
const a = buf[at + i];
|
|
178
|
+
const b = buf[at + i + lag];
|
|
179
|
+
r += a * b;
|
|
180
|
+
e0 += a * a;
|
|
181
|
+
e1 += b * b;
|
|
182
|
+
}
|
|
183
|
+
const denom = Math.sqrt(e0 * e1);
|
|
184
|
+
return denom > 1e-12 ? r / denom : 0;
|
|
185
|
+
}
|
|
186
|
+
function analyze(wav) {
|
|
187
|
+
const mono = resample(toMono(wav), RATE);
|
|
188
|
+
const x = mono.samples;
|
|
189
|
+
const n = Math.max(1, Math.floor((x.length - FFT_SIZE) / HOP) + 1);
|
|
190
|
+
const rmsDb = new Float32Array(n);
|
|
191
|
+
const highRatio = new Float32Array(n);
|
|
192
|
+
const highDb = new Float32Array(n);
|
|
193
|
+
const flux = new Float32Array(n);
|
|
194
|
+
const voiced = new Float32Array(n);
|
|
195
|
+
const window = new Float32Array(FFT_SIZE);
|
|
196
|
+
for (let i = 0; i < FFT_SIZE; i++) {
|
|
197
|
+
window[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / FFT_SIZE);
|
|
198
|
+
}
|
|
199
|
+
const re = new Float32Array(FFT_SIZE);
|
|
200
|
+
const im = new Float32Array(FFT_SIZE);
|
|
201
|
+
const mag = new Float32Array(BINS);
|
|
202
|
+
const prevMag = new Float32Array(BINS);
|
|
203
|
+
const highBin = Math.floor(4e3 / (RATE / 2) * BINS);
|
|
204
|
+
for (let t = 0; t < n; t++) {
|
|
205
|
+
const off = t * HOP;
|
|
206
|
+
let energy = 0;
|
|
207
|
+
for (let i = 0; i < FFT_SIZE; i++) {
|
|
208
|
+
const s = x[off + i] ?? 0;
|
|
209
|
+
energy += s * s;
|
|
210
|
+
re[i] = s * window[i];
|
|
211
|
+
im[i] = 0;
|
|
212
|
+
}
|
|
213
|
+
rmsDb[t] = 10 * Math.log10(energy / FFT_SIZE + 1e-12);
|
|
214
|
+
fft(re, im);
|
|
215
|
+
let lowSum = 0;
|
|
216
|
+
let highSum = 0;
|
|
217
|
+
let fluxSum = 0;
|
|
218
|
+
for (let b = 1; b < BINS; b++) {
|
|
219
|
+
const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]);
|
|
220
|
+
mag[b] = m;
|
|
221
|
+
if (b < highBin) lowSum += m;
|
|
222
|
+
else highSum += m;
|
|
223
|
+
const d = Math.log10(m + 1e-6) - Math.log10(prevMag[b] + 1e-6);
|
|
224
|
+
if (d > 0) fluxSum += d;
|
|
225
|
+
}
|
|
226
|
+
prevMag.set(mag);
|
|
227
|
+
highRatio[t] = highSum / (lowSum + highSum + 1e-12);
|
|
228
|
+
highDb[t] = 20 * Math.log10(highSum / BINS + 1e-9);
|
|
229
|
+
flux[t] = t === 0 ? 0 : fluxSum;
|
|
230
|
+
}
|
|
231
|
+
const smoothDb = smooth(rmsDb, Math.round(30 / HOP_MS));
|
|
232
|
+
const smoothHighDb = smooth(highDb, Math.round(20 / HOP_MS));
|
|
233
|
+
const highFloorDb = quietestWindow(smoothHighDb);
|
|
234
|
+
const floorDb = percentile(smoothDb, 0.1);
|
|
235
|
+
const quietDb = quietestWindow(smoothDb);
|
|
236
|
+
let peakDb = -120;
|
|
237
|
+
for (let t = 0; t < n; t++) if (smoothDb[t] > peakDb) peakDb = smoothDb[t];
|
|
238
|
+
const pitchBuf = new Float32Array(Math.floor(x.length / PITCH_DECIM));
|
|
239
|
+
for (let i = 0; i < pitchBuf.length; i++) {
|
|
240
|
+
pitchBuf[i] = (x[i * PITCH_DECIM] + x[i * PITCH_DECIM + 1]) * 0.5;
|
|
241
|
+
}
|
|
242
|
+
const minLag = Math.floor(PITCH_RATE / MAX_F0);
|
|
243
|
+
const maxLag = Math.floor(PITCH_RATE / MIN_F0);
|
|
244
|
+
const loud = [];
|
|
245
|
+
for (let t = 0; t < n; t++) {
|
|
246
|
+
if (smoothDb[t] > peakDb - 10) loud.push(t);
|
|
247
|
+
}
|
|
248
|
+
const lags = [];
|
|
249
|
+
const step = Math.max(1, Math.floor(loud.length / 24));
|
|
250
|
+
for (let k = 0; k < loud.length; k += step) {
|
|
251
|
+
const at = Math.floor(loud[k] * HOP / PITCH_DECIM);
|
|
252
|
+
if (at + PITCH_WIN + maxLag >= pitchBuf.length) continue;
|
|
253
|
+
let best = 0;
|
|
254
|
+
let bestLag = 0;
|
|
255
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
256
|
+
const v = ncc(pitchBuf, at, PITCH_WIN, lag);
|
|
257
|
+
if (v > best) {
|
|
258
|
+
best = v;
|
|
259
|
+
bestLag = lag;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (best > 0.5 && bestLag > 0) lags.push(bestLag);
|
|
263
|
+
}
|
|
264
|
+
lags.sort((a, b) => a - b);
|
|
265
|
+
const centreLag = lags.length ? lags[lags.length >> 1] : 0;
|
|
266
|
+
const f0 = centreLag ? PITCH_RATE / centreLag : 0;
|
|
267
|
+
if (centreLag) {
|
|
268
|
+
const lo = Math.max(minLag, Math.floor(centreLag / 1.7));
|
|
269
|
+
const hi = Math.min(maxLag, Math.ceil(centreLag * 1.7));
|
|
270
|
+
for (let t = 0; t < n; t++) {
|
|
271
|
+
if (smoothDb[t] < floorDb + 6) continue;
|
|
272
|
+
const at = Math.floor(t * HOP / PITCH_DECIM);
|
|
273
|
+
if (at + PITCH_WIN + hi >= pitchBuf.length) continue;
|
|
274
|
+
let best = 0;
|
|
275
|
+
for (let lag = lo; lag <= hi; lag++) {
|
|
276
|
+
const v = ncc(pitchBuf, at, PITCH_WIN, lag);
|
|
277
|
+
if (v > best) best = v;
|
|
278
|
+
}
|
|
279
|
+
voiced[t] = best;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const medianFlux = percentile(flux, 0.5) || 1;
|
|
283
|
+
for (let t = 0; t < n; t++) flux[t] /= medianFlux;
|
|
284
|
+
return {
|
|
285
|
+
n,
|
|
286
|
+
durationMs: x.length / RATE * 1e3,
|
|
287
|
+
rmsDb,
|
|
288
|
+
smoothDb,
|
|
289
|
+
voiced,
|
|
290
|
+
highRatio,
|
|
291
|
+
highDb: smoothHighDb,
|
|
292
|
+
highFloorDb,
|
|
293
|
+
flux: smooth(flux, 3),
|
|
294
|
+
f0,
|
|
295
|
+
floorDb,
|
|
296
|
+
quietDb,
|
|
297
|
+
peakDb
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function smooth(src, width) {
|
|
301
|
+
if (width <= 1) return Float32Array.from(src);
|
|
302
|
+
const prefix = new Float64Array(src.length + 1);
|
|
303
|
+
for (let i = 0; i < src.length; i++) prefix[i + 1] = prefix[i] + src[i];
|
|
304
|
+
const out = new Float32Array(src.length);
|
|
305
|
+
const half = width >> 1;
|
|
306
|
+
for (let i = 0; i < src.length; i++) {
|
|
307
|
+
const lo = Math.max(0, i - half);
|
|
308
|
+
const hi = Math.min(src.length, i + half + 1);
|
|
309
|
+
out[i] = (prefix[hi] - prefix[lo]) / (hi - lo);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/oto/estimate.ts
|
|
315
|
+
var ARTICULATION = {
|
|
316
|
+
vowel: {
|
|
317
|
+
maxConsonantMs: 40,
|
|
318
|
+
leadInMs: 3,
|
|
319
|
+
overlapRatio: 0,
|
|
320
|
+
fixedOverlapMs: 20,
|
|
321
|
+
voicedConsonant: true,
|
|
322
|
+
fricationOnset: false
|
|
323
|
+
},
|
|
324
|
+
nasalN: {
|
|
325
|
+
maxConsonantMs: 40,
|
|
326
|
+
leadInMs: 3,
|
|
327
|
+
overlapRatio: 0,
|
|
328
|
+
fixedOverlapMs: 20,
|
|
329
|
+
voicedConsonant: true,
|
|
330
|
+
fricationOnset: false
|
|
331
|
+
},
|
|
332
|
+
nasal: {
|
|
333
|
+
maxConsonantMs: 110,
|
|
334
|
+
leadInMs: 5,
|
|
335
|
+
overlapRatio: 0.6,
|
|
336
|
+
voicedConsonant: true,
|
|
337
|
+
fricationOnset: false
|
|
338
|
+
},
|
|
339
|
+
liquid: {
|
|
340
|
+
maxConsonantMs: 80,
|
|
341
|
+
leadInMs: 5,
|
|
342
|
+
overlapRatio: 0.6,
|
|
343
|
+
voicedConsonant: true,
|
|
344
|
+
fricationOnset: false
|
|
345
|
+
},
|
|
346
|
+
semivowel: {
|
|
347
|
+
maxConsonantMs: 75,
|
|
348
|
+
leadInMs: 5,
|
|
349
|
+
overlapRatio: 0.6,
|
|
350
|
+
voicedConsonant: true,
|
|
351
|
+
fricationOnset: false
|
|
352
|
+
},
|
|
353
|
+
fricativeVoiceless: {
|
|
354
|
+
maxConsonantMs: 140,
|
|
355
|
+
leadInMs: 5,
|
|
356
|
+
overlapRatio: 0.3,
|
|
357
|
+
voicedConsonant: false,
|
|
358
|
+
fricationOnset: true
|
|
359
|
+
},
|
|
360
|
+
fricativeVoiced: {
|
|
361
|
+
maxConsonantMs: 130,
|
|
362
|
+
leadInMs: 5,
|
|
363
|
+
overlapRatio: 0.4,
|
|
364
|
+
voicedConsonant: true,
|
|
365
|
+
fricationOnset: true
|
|
366
|
+
},
|
|
367
|
+
affricate: {
|
|
368
|
+
maxConsonantMs: 120,
|
|
369
|
+
leadInMs: 5,
|
|
370
|
+
overlapRatio: 0.3,
|
|
371
|
+
voicedConsonant: false,
|
|
372
|
+
fricationOnset: true
|
|
373
|
+
},
|
|
374
|
+
plosiveVoiceless: {
|
|
375
|
+
maxConsonantMs: 90,
|
|
376
|
+
leadInMs: 8,
|
|
377
|
+
overlapRatio: 0,
|
|
378
|
+
// か/た/ぱ行 need a gap, not a crossfade: the wiki asks for a negative
|
|
379
|
+
// overlap so the closure that precedes the burst survives synthesis.
|
|
380
|
+
fixedOverlapMs: -10,
|
|
381
|
+
voicedConsonant: false,
|
|
382
|
+
fricationOnset: false
|
|
383
|
+
},
|
|
384
|
+
plosiveVoiced: {
|
|
385
|
+
maxConsonantMs: 90,
|
|
386
|
+
leadInMs: 8,
|
|
387
|
+
overlapRatio: 0.55,
|
|
388
|
+
voicedConsonant: true,
|
|
389
|
+
fricationOnset: false
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var MIN_OVERLAP_MS = 12;
|
|
393
|
+
var MAX_OVERLAP_MS = 40;
|
|
394
|
+
var VOICED_THRESHOLD = 0.55;
|
|
395
|
+
var VOICED_RUN_MS = 24;
|
|
396
|
+
var MIN_VOICED_CONSONANT_MS = 22;
|
|
397
|
+
var SEQUENCE_ONSET_BIAS = 12;
|
|
398
|
+
var MAX_SEQUENCE_PRE_MS = 250;
|
|
399
|
+
var DECAY_DROP_DB = 4;
|
|
400
|
+
var RELEASE_GUARD_FRAMES = 10;
|
|
401
|
+
function clamp(v, lo, hi) {
|
|
402
|
+
return Math.min(hi, Math.max(lo, v));
|
|
403
|
+
}
|
|
404
|
+
var SOUND_OVER_FLOOR_DB = 8;
|
|
405
|
+
var SOUND_UNDER_PEAK_DB = 15;
|
|
406
|
+
var ATTACK_SLOPE_DB = 1;
|
|
407
|
+
var FRICATION_HIGH_RATIO = 0.45;
|
|
408
|
+
var FRICATION_UNDER_PEAK_DB = 30;
|
|
409
|
+
function soundThreshold(f) {
|
|
410
|
+
return Math.min(
|
|
411
|
+
Math.max(f.floorDb + SOUND_OVER_FLOOR_DB, f.peakDb - 34),
|
|
412
|
+
f.peakDb - SOUND_UNDER_PEAK_DB
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
function findSoundStart(f, from, to) {
|
|
416
|
+
const th = soundThreshold(f);
|
|
417
|
+
for (let t = Math.max(0, from); t < Math.min(f.n, to); t++) {
|
|
418
|
+
if (f.smoothDb[t] >= th) return t;
|
|
419
|
+
}
|
|
420
|
+
return Math.max(0, from);
|
|
421
|
+
}
|
|
422
|
+
function findSoundEnd(f, from, to) {
|
|
423
|
+
const th = soundThreshold(f);
|
|
424
|
+
for (let t = Math.min(f.n, to) - 1; t > from; t--) {
|
|
425
|
+
if (f.smoothDb[t] >= th) return t;
|
|
426
|
+
}
|
|
427
|
+
return Math.min(f.n - 1, to);
|
|
428
|
+
}
|
|
429
|
+
function findConsonantStart(f, from, to, frication) {
|
|
430
|
+
const lo = Math.max(0, from);
|
|
431
|
+
let start = findSoundStart(f, from, to);
|
|
432
|
+
if (frication) {
|
|
433
|
+
const level = f.peakDb - FRICATION_UNDER_PEAK_DB;
|
|
434
|
+
const absolute = f.highFloorDb + 8;
|
|
435
|
+
for (let t = lo; t < start; t++) {
|
|
436
|
+
const byRatio = f.highRatio[t] >= FRICATION_HIGH_RATIO && f.smoothDb[t] >= level;
|
|
437
|
+
if (byRatio || f.highDb[t] >= absolute) {
|
|
438
|
+
start = t;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const foot = f.quietDb + 6;
|
|
444
|
+
const k = Math.max(1, msToFrame(6));
|
|
445
|
+
while (start > lo) {
|
|
446
|
+
const back = Math.max(lo, start - k);
|
|
447
|
+
const stillFalling = f.smoothDb[start] - f.smoothDb[back] >= ATTACK_SLOPE_DB;
|
|
448
|
+
if (!stillFalling && f.smoothDb[start - 1] <= foot) break;
|
|
449
|
+
start--;
|
|
450
|
+
}
|
|
451
|
+
return start;
|
|
452
|
+
}
|
|
453
|
+
function findVoiceOnset(f, from, to) {
|
|
454
|
+
const need = Math.ceil(VOICED_RUN_MS / HOP_MS);
|
|
455
|
+
const hi = Math.min(f.n, to);
|
|
456
|
+
for (let t = Math.max(0, from); t < hi; t++) {
|
|
457
|
+
if (f.voiced[t] < VOICED_THRESHOLD) continue;
|
|
458
|
+
let run = 0;
|
|
459
|
+
while (t + run < hi && f.voiced[t + run] >= VOICED_THRESHOLD) run++;
|
|
460
|
+
if (run >= need) return t;
|
|
461
|
+
t += run;
|
|
462
|
+
}
|
|
463
|
+
return -1;
|
|
464
|
+
}
|
|
465
|
+
function findVoicedRelease(f, from, to) {
|
|
466
|
+
const lo = Math.max(1, from);
|
|
467
|
+
const hi = Math.min(f.n - 1, to);
|
|
468
|
+
if (hi <= lo) return lo;
|
|
469
|
+
const k = Math.max(1, msToFrame(10));
|
|
470
|
+
let best = lo;
|
|
471
|
+
let bestScore = -Infinity;
|
|
472
|
+
for (let t = lo; t < hi; t++) {
|
|
473
|
+
const rise = f.smoothDb[Math.min(f.n - 1, t + k)] - f.smoothDb[Math.max(0, t - k)];
|
|
474
|
+
const deSibilance = (f.highRatio[Math.max(0, t - k)] - f.highRatio[Math.min(f.n - 1, t + k)]) * 30;
|
|
475
|
+
const score = rise + deSibilance + f.flux[t];
|
|
476
|
+
if (score > bestScore) {
|
|
477
|
+
bestScore = score;
|
|
478
|
+
best = t;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return best;
|
|
482
|
+
}
|
|
483
|
+
function findVowelStable(f, vowelOnset, limit) {
|
|
484
|
+
const minMs = 45;
|
|
485
|
+
const maxMs = 110;
|
|
486
|
+
const from = vowelOnset + msToFrame(minMs);
|
|
487
|
+
const to = Math.min(limit, vowelOnset + msToFrame(maxMs));
|
|
488
|
+
if (to <= from) return Math.min(limit, vowelOnset + msToFrame(minMs));
|
|
489
|
+
const calm = smooth(f.flux, Math.max(1, msToFrame(12)));
|
|
490
|
+
const need = Math.ceil(20 / HOP_MS);
|
|
491
|
+
for (let t = from; t < to - need; t++) {
|
|
492
|
+
let steady = true;
|
|
493
|
+
for (let k = 0; k < need; k++) {
|
|
494
|
+
if (calm[t + k] > 1.05) {
|
|
495
|
+
steady = false;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (steady) return t;
|
|
500
|
+
}
|
|
501
|
+
return to;
|
|
502
|
+
}
|
|
503
|
+
function findDecayStart(f, from, soundEnd) {
|
|
504
|
+
let peak = from;
|
|
505
|
+
for (let t = from; t <= soundEnd; t++) {
|
|
506
|
+
if (f.smoothDb[t] > f.smoothDb[peak]) peak = t;
|
|
507
|
+
}
|
|
508
|
+
const th = f.smoothDb[peak] - DECAY_DROP_DB;
|
|
509
|
+
const need = Math.ceil(24 / HOP_MS);
|
|
510
|
+
for (let t = peak; t <= soundEnd - need; t++) {
|
|
511
|
+
let falling = true;
|
|
512
|
+
for (let k = 0; k < need; k++) {
|
|
513
|
+
if (f.smoothDb[t + k] >= th) {
|
|
514
|
+
falling = false;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (falling) return Math.max(from, t - RELEASE_GUARD_FRAMES);
|
|
519
|
+
}
|
|
520
|
+
return Math.max(from, soundEnd - RELEASE_GUARD_FRAMES);
|
|
521
|
+
}
|
|
522
|
+
function hasPrevoicing(f, consStart, vowelOnset) {
|
|
523
|
+
if (vowelOnset - consStart < msToFrame(25)) return false;
|
|
524
|
+
let voicedFrames = 0;
|
|
525
|
+
for (let t = consStart; t < vowelOnset; t++) {
|
|
526
|
+
if (f.voiced[t] >= VOICED_THRESHOLD) voicedFrames++;
|
|
527
|
+
}
|
|
528
|
+
return voicedFrames >= (vowelOnset - consStart) * 0.6;
|
|
529
|
+
}
|
|
530
|
+
function locateMora(f, cls, from, to) {
|
|
531
|
+
const art = ARTICULATION[cls];
|
|
532
|
+
const consStart = findConsonantStart(f, from, to, art.fricationOnset);
|
|
533
|
+
const searchEnd = Math.min(
|
|
534
|
+
to,
|
|
535
|
+
consStart + msToFrame(art.maxConsonantMs + 60)
|
|
536
|
+
);
|
|
537
|
+
let vowelOnset;
|
|
538
|
+
if (!art.voicedConsonant) {
|
|
539
|
+
const v = findVoiceOnset(f, consStart, searchEnd);
|
|
540
|
+
vowelOnset = v >= 0 ? v : consStart + msToFrame(30);
|
|
541
|
+
} else if (cls === "vowel" || cls === "nasalN") {
|
|
542
|
+
const v = findVoiceOnset(f, consStart, searchEnd);
|
|
543
|
+
vowelOnset = v >= 0 ? v : consStart;
|
|
544
|
+
} else {
|
|
545
|
+
const voiceStart = findVoiceOnset(f, consStart, searchEnd);
|
|
546
|
+
const base = voiceStart >= 0 ? voiceStart : consStart;
|
|
547
|
+
vowelOnset = findVoicedRelease(
|
|
548
|
+
f,
|
|
549
|
+
base + msToFrame(MIN_VOICED_CONSONANT_MS),
|
|
550
|
+
Math.min(to, base + msToFrame(art.maxConsonantMs))
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
vowelOnset = clamp(vowelOnset, consStart, Math.max(consStart, to - 1));
|
|
554
|
+
return {
|
|
555
|
+
consStart,
|
|
556
|
+
vowelOnset,
|
|
557
|
+
stable: findVowelStable(f, vowelOnset, to),
|
|
558
|
+
prevoiced: cls === "plosiveVoiced" && hasPrevoicing(f, consStart, vowelOnset)
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function round(ms) {
|
|
562
|
+
return Math.round(ms * 1e3) / 1e3;
|
|
563
|
+
}
|
|
564
|
+
function buildEntry(wav, alias, f, cls, pos, endFrame) {
|
|
565
|
+
const art = ARTICULATION[cls];
|
|
566
|
+
let offsetMs = frameTimeMs(pos.consStart) - art.leadInMs;
|
|
567
|
+
const vowelMs = frameTimeMs(pos.vowelOnset);
|
|
568
|
+
offsetMs = Math.max(offsetMs, vowelMs - art.maxConsonantMs);
|
|
569
|
+
offsetMs = clamp(offsetMs, 0, Math.max(0, vowelMs - 1));
|
|
570
|
+
const pre = Math.max(1, vowelMs - offsetMs);
|
|
571
|
+
let overlap;
|
|
572
|
+
if (art.fixedOverlapMs !== void 0) {
|
|
573
|
+
overlap = art.fixedOverlapMs;
|
|
574
|
+
} else {
|
|
575
|
+
overlap = clamp(
|
|
576
|
+
art.overlapRatio * pre,
|
|
577
|
+
Math.min(MIN_OVERLAP_MS, pre),
|
|
578
|
+
Math.min(MAX_OVERLAP_MS, pre)
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const consonant = Math.max(
|
|
582
|
+
pre + 10,
|
|
583
|
+
Math.abs(overlap) + 10,
|
|
584
|
+
frameTimeMs(pos.stable) - offsetMs
|
|
585
|
+
);
|
|
586
|
+
const endMs = Math.max(frameTimeMs(endFrame), offsetMs + consonant + 30);
|
|
587
|
+
const cutoff = -(Math.min(endMs, f.durationMs) - offsetMs);
|
|
588
|
+
return {
|
|
589
|
+
wav,
|
|
590
|
+
alias,
|
|
591
|
+
offset: round(offsetMs),
|
|
592
|
+
consonant: round(consonant),
|
|
593
|
+
cutoff: round(cutoff),
|
|
594
|
+
pre: round(pre),
|
|
595
|
+
overlap: round(overlap)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function estimateSolo(wav, f, syl, aliases) {
|
|
599
|
+
const soundStart = findSoundStart(f, 0, f.n);
|
|
600
|
+
const soundEnd = findSoundEnd(f, soundStart, f.n);
|
|
601
|
+
const pos = locateMora(f, syl.cls, 0, soundEnd);
|
|
602
|
+
const end = findDecayStart(f, pos.stable, soundEnd);
|
|
603
|
+
return aliases.map((alias) => buildEntry(wav, alias, f, syl.cls, pos, end));
|
|
604
|
+
}
|
|
605
|
+
function estimateVowelJoin(wav, f, syl, alias) {
|
|
606
|
+
const soundStart = findSoundStart(f, 0, f.n);
|
|
607
|
+
const soundEnd = findSoundEnd(f, soundStart, f.n);
|
|
608
|
+
const pos = locateMora(f, syl.cls, 0, soundEnd);
|
|
609
|
+
const end = findDecayStart(f, pos.stable, soundEnd);
|
|
610
|
+
const pre = 50;
|
|
611
|
+
const overlap = 100;
|
|
612
|
+
const consonant = 110;
|
|
613
|
+
const offsetMs = frameTimeMs(pos.stable);
|
|
614
|
+
const endMs = frameTimeMs(end);
|
|
615
|
+
if (offsetMs < 0 || endMs - offsetMs < consonant + 40) return null;
|
|
616
|
+
return {
|
|
617
|
+
wav,
|
|
618
|
+
alias,
|
|
619
|
+
offset: round(offsetMs),
|
|
620
|
+
consonant,
|
|
621
|
+
cutoff: round(-(endMs - offsetMs)),
|
|
622
|
+
pre,
|
|
623
|
+
overlap
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
var GRID_SNAP_WEIGHT = 0.6;
|
|
627
|
+
function fitLine(values) {
|
|
628
|
+
const n = values.length;
|
|
629
|
+
if (n < 2) return { intercept: values[0] ?? 0, slope: 0 };
|
|
630
|
+
let sx = 0;
|
|
631
|
+
let sy = 0;
|
|
632
|
+
let sxx = 0;
|
|
633
|
+
let sxy = 0;
|
|
634
|
+
for (let i = 0; i < n; i++) {
|
|
635
|
+
sx += i;
|
|
636
|
+
sy += values[i];
|
|
637
|
+
sxx += i * i;
|
|
638
|
+
sxy += i * values[i];
|
|
639
|
+
}
|
|
640
|
+
const denom = n * sxx - sx * sx;
|
|
641
|
+
const slope = denom !== 0 ? (n * sxy - sx * sy) / denom : 0;
|
|
642
|
+
return { intercept: (sy - slope * sx) / n, slope };
|
|
643
|
+
}
|
|
644
|
+
function onsetStrength(f) {
|
|
645
|
+
const k = Math.max(1, msToFrame(12));
|
|
646
|
+
const out = new Float32Array(f.n);
|
|
647
|
+
for (let t = 0; t < f.n; t++) {
|
|
648
|
+
const rise = f.smoothDb[Math.min(f.n - 1, t + k)] - f.smoothDb[Math.max(0, t - k)];
|
|
649
|
+
out[t] = f.flux[t] + Math.max(0, rise) / 3;
|
|
650
|
+
}
|
|
651
|
+
return smooth(out, Math.max(1, msToFrame(8)));
|
|
652
|
+
}
|
|
653
|
+
function detectGrid(f, count) {
|
|
654
|
+
if (count < 1) return null;
|
|
655
|
+
const strength = onsetStrength(f);
|
|
656
|
+
const uttStart = findSoundStart(f, 0, f.n);
|
|
657
|
+
const uttEnd = findSoundEnd(f, uttStart, f.n);
|
|
658
|
+
const span = uttEnd - uttStart;
|
|
659
|
+
if (span < msToFrame(80)) return null;
|
|
660
|
+
if (count === 1) {
|
|
661
|
+
return { start: uttStart, interval: span, onsets: [uttStart] };
|
|
662
|
+
}
|
|
663
|
+
const candidates = intervalCandidates(
|
|
664
|
+
strength,
|
|
665
|
+
uttStart,
|
|
666
|
+
uttEnd,
|
|
667
|
+
span / count
|
|
668
|
+
);
|
|
669
|
+
const best = { score: -Infinity, start: uttStart, interval: span / count };
|
|
670
|
+
const search = (requireFit) => {
|
|
671
|
+
for (const candidate of candidates) {
|
|
672
|
+
for (let iv = candidate * 0.85; iv <= candidate * 1.18; iv += candidate * 0.02) {
|
|
673
|
+
const near2 = windowArgMax(strength, Math.round(iv * 0.22));
|
|
674
|
+
const tol = iv * 0.22;
|
|
675
|
+
const hardEnd = uttEnd + iv * 0.4;
|
|
676
|
+
for (let phase = uttStart - iv * 0.25; phase <= uttStart + iv * 0.5; phase += 1) {
|
|
677
|
+
if (requireFit && phase + (count - 1) * iv > hardEnd) break;
|
|
678
|
+
let total = 0;
|
|
679
|
+
for (let i = 0; i < count; i++) {
|
|
680
|
+
const centre = phase + i * iv;
|
|
681
|
+
const at = near2[clamp(Math.round(centre), 0, f.n - 1)];
|
|
682
|
+
total += strength[at] - Math.abs(at - centre) / tol;
|
|
683
|
+
}
|
|
684
|
+
if (total > best.score) {
|
|
685
|
+
best.score = total;
|
|
686
|
+
best.start = phase;
|
|
687
|
+
best.interval = iv;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
search(true);
|
|
694
|
+
if (!Number.isFinite(best.score)) search(false);
|
|
695
|
+
if (!Number.isFinite(best.score)) return null;
|
|
696
|
+
const near = windowArgMax(strength, Math.round(best.interval * 0.25));
|
|
697
|
+
const snapped = [];
|
|
698
|
+
for (let i = 0; i < count; i++) {
|
|
699
|
+
snapped.push(
|
|
700
|
+
near[clamp(Math.round(best.start + i * best.interval), 0, f.n - 1)]
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const { intercept, slope } = fitLine(snapped);
|
|
704
|
+
const onsets = [];
|
|
705
|
+
for (let i = 0; i < count; i++) {
|
|
706
|
+
const fitted = intercept + slope * i;
|
|
707
|
+
const blended = Math.round(
|
|
708
|
+
fitted + (snapped[i] - fitted) * GRID_SNAP_WEIGHT
|
|
709
|
+
);
|
|
710
|
+
onsets.push(i > 0 ? Math.max(blended, onsets[i - 1] + 4) : blended);
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
start: onsets[0],
|
|
714
|
+
interval: slope > 0 ? slope : best.interval,
|
|
715
|
+
onsets
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function windowArgMax(src, half) {
|
|
719
|
+
const n = src.length;
|
|
720
|
+
const out = new Int32Array(n);
|
|
721
|
+
const deque = new Int32Array(n);
|
|
722
|
+
let head = 0;
|
|
723
|
+
let tail = 0;
|
|
724
|
+
let next = 0;
|
|
725
|
+
for (let i = 0; i < n; i++) {
|
|
726
|
+
const limit = Math.min(n - 1, i + half);
|
|
727
|
+
while (next <= limit) {
|
|
728
|
+
while (tail > head && src[deque[tail - 1]] <= src[next]) tail--;
|
|
729
|
+
deque[tail++] = next++;
|
|
730
|
+
}
|
|
731
|
+
while (deque[head] < i - half) head++;
|
|
732
|
+
out[i] = deque[head];
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
function intervalCandidates(strength, from, to, fromSpan) {
|
|
737
|
+
const lo = msToFrame(180);
|
|
738
|
+
const hi = Math.min(msToFrame(1300), Math.floor((to - from) / 2));
|
|
739
|
+
const out = [];
|
|
740
|
+
if (hi > lo) {
|
|
741
|
+
let mean = 0;
|
|
742
|
+
for (let t = from; t < to; t++) mean += strength[t];
|
|
743
|
+
mean /= Math.max(1, to - from);
|
|
744
|
+
const r = new Float64Array(hi + 1);
|
|
745
|
+
let peak = 0;
|
|
746
|
+
for (let lag = lo; lag <= hi; lag++) {
|
|
747
|
+
let sum = 0;
|
|
748
|
+
let n = 0;
|
|
749
|
+
for (let t = from; t + lag < to; t++, n++) {
|
|
750
|
+
sum += (strength[t] - mean) * (strength[t + lag] - mean);
|
|
751
|
+
}
|
|
752
|
+
r[lag] = n > 0 ? sum / n : 0;
|
|
753
|
+
if (r[lag] > peak) peak = r[lag];
|
|
754
|
+
}
|
|
755
|
+
if (peak > 0) {
|
|
756
|
+
for (let lag = lo + 1; lag < hi; lag++) {
|
|
757
|
+
if (r[lag] < peak * 0.7) continue;
|
|
758
|
+
if (r[lag] < r[lag - 1] || r[lag] < r[lag + 1]) continue;
|
|
759
|
+
out.push(lag);
|
|
760
|
+
if (out.length >= 2) break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
out.push(fromSpan);
|
|
765
|
+
const withSubmultiples = [];
|
|
766
|
+
const seen = /* @__PURE__ */ new Set();
|
|
767
|
+
const shortest = msToFrame(150);
|
|
768
|
+
for (const c of out) {
|
|
769
|
+
for (const divisor of [1, 2, 3]) {
|
|
770
|
+
const key = Math.round(c / divisor);
|
|
771
|
+
if (key < shortest || seen.has(key)) continue;
|
|
772
|
+
seen.add(key);
|
|
773
|
+
withSubmultiples.push(key);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (withSubmultiples.length === 0) withSubmultiples.push(shortest);
|
|
777
|
+
return withSubmultiples;
|
|
778
|
+
}
|
|
779
|
+
function sequenceVowelOnset(f, cls, onset, next) {
|
|
780
|
+
if (ARTICULATION[cls].voicedConsonant) return onset + SEQUENCE_ONSET_BIAS;
|
|
781
|
+
const lo = Math.max(0, onset - msToFrame(100));
|
|
782
|
+
const hi = Math.min(next, onset + msToFrame(150));
|
|
783
|
+
let quietest = onset;
|
|
784
|
+
for (let t = lo; t < hi; t++) {
|
|
785
|
+
if (f.voiced[t] < f.voiced[quietest]) quietest = t;
|
|
786
|
+
}
|
|
787
|
+
if (f.voiced[quietest] >= VOICED_THRESHOLD) return onset;
|
|
788
|
+
const resumed = findVoiceOnset(
|
|
789
|
+
f,
|
|
790
|
+
quietest,
|
|
791
|
+
Math.min(next, hi + msToFrame(80))
|
|
792
|
+
);
|
|
793
|
+
return resumed >= 0 ? resumed : onset;
|
|
794
|
+
}
|
|
795
|
+
function estimateSequence(wav, f, syllables, opts = {}) {
|
|
796
|
+
const grid = detectGrid(f, syllables.length);
|
|
797
|
+
if (!grid) return [];
|
|
798
|
+
const intervalMs = framesToMs(grid.interval);
|
|
799
|
+
const pre = clamp(intervalMs / 2, 60, MAX_SEQUENCE_PRE_MS);
|
|
800
|
+
const overlap = round(pre / 3);
|
|
801
|
+
const consonant = round(pre * 1.5);
|
|
802
|
+
const suffix = opts.suffix ?? "";
|
|
803
|
+
const prefix = opts.prefix ?? "";
|
|
804
|
+
const entries = [];
|
|
805
|
+
for (let i = 0; i < syllables.length; i++) {
|
|
806
|
+
const syl = syllables[i];
|
|
807
|
+
const onset = grid.onsets[i];
|
|
808
|
+
const next = i + 1 < syllables.length ? grid.onsets[i + 1] : findSoundEnd(f, onset, f.n);
|
|
809
|
+
let vowelFrame;
|
|
810
|
+
if (i === 0) {
|
|
811
|
+
vowelFrame = locateMora(f, syl.cls, 0, next).vowelOnset;
|
|
812
|
+
} else {
|
|
813
|
+
vowelFrame = sequenceVowelOnset(f, syl.cls, onset, next);
|
|
814
|
+
}
|
|
815
|
+
const noteMs = frameTimeMs(vowelFrame);
|
|
816
|
+
const offsetMs = clamp(noteMs - pre, 0, Math.max(0, noteMs - 1));
|
|
817
|
+
const actualPre = noteMs - offsetMs;
|
|
818
|
+
const endMs = Math.min(f.durationMs, noteMs + intervalMs * 2 / 3);
|
|
819
|
+
const alias = i === 0 ? `- ${prefix}${syl.kana}${suffix}` : `${syllables[i - 1].vowel} ${syl.kana}${suffix}`;
|
|
820
|
+
entries.push({
|
|
821
|
+
wav,
|
|
822
|
+
alias,
|
|
823
|
+
offset: round(offsetMs),
|
|
824
|
+
consonant: Math.max(round(consonant), round(actualPre + 20)),
|
|
825
|
+
cutoff: round(-Math.max(endMs - offsetMs, consonant + 40)),
|
|
826
|
+
pre: round(actualPre),
|
|
827
|
+
overlap: i === 0 ? 0 : Math.min(overlap, round(actualPre))
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (opts.trailingRest && syllables.length > 0) {
|
|
831
|
+
const last = syllables[syllables.length - 1];
|
|
832
|
+
const lastOnset = grid.onsets[syllables.length - 1];
|
|
833
|
+
const soundEnd = findSoundEnd(f, lastOnset, f.n);
|
|
834
|
+
const decay = findDecayStart(f, lastOnset, soundEnd);
|
|
835
|
+
const noteMs = frameTimeMs(decay);
|
|
836
|
+
const offsetMs = clamp(noteMs - pre, 0, Math.max(0, noteMs - 1));
|
|
837
|
+
const actualPre = noteMs - offsetMs;
|
|
838
|
+
entries.push({
|
|
839
|
+
wav,
|
|
840
|
+
alias: `${last.vowel} R${suffix}`,
|
|
841
|
+
offset: round(offsetMs),
|
|
842
|
+
consonant: round(consonant),
|
|
843
|
+
cutoff: round(
|
|
844
|
+
-(Math.min(f.durationMs, frameTimeMs(soundEnd)) - offsetMs)
|
|
845
|
+
),
|
|
846
|
+
pre: round(actualPre),
|
|
847
|
+
overlap: Math.min(overlap, round(actualPre))
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
return entries;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/oto/kana.ts
|
|
854
|
+
var CLASS_OF = {
|
|
855
|
+
"": "vowel",
|
|
856
|
+
k: "plosiveVoiceless",
|
|
857
|
+
ky: "plosiveVoiceless",
|
|
858
|
+
t: "plosiveVoiceless",
|
|
859
|
+
ty: "plosiveVoiceless",
|
|
860
|
+
p: "plosiveVoiceless",
|
|
861
|
+
py: "plosiveVoiceless",
|
|
862
|
+
g: "plosiveVoiced",
|
|
863
|
+
gy: "plosiveVoiced",
|
|
864
|
+
d: "plosiveVoiced",
|
|
865
|
+
dy: "plosiveVoiced",
|
|
866
|
+
b: "plosiveVoiced",
|
|
867
|
+
by: "plosiveVoiced",
|
|
868
|
+
s: "fricativeVoiceless",
|
|
869
|
+
sh: "fricativeVoiceless",
|
|
870
|
+
h: "fricativeVoiceless",
|
|
871
|
+
hy: "fricativeVoiceless",
|
|
872
|
+
f: "fricativeVoiceless",
|
|
873
|
+
z: "fricativeVoiced",
|
|
874
|
+
j: "fricativeVoiced",
|
|
875
|
+
v: "fricativeVoiced",
|
|
876
|
+
ts: "affricate",
|
|
877
|
+
ch: "affricate",
|
|
878
|
+
n: "nasal",
|
|
879
|
+
ny: "nasal",
|
|
880
|
+
m: "nasal",
|
|
881
|
+
my: "nasal",
|
|
882
|
+
r: "liquid",
|
|
883
|
+
ry: "liquid",
|
|
884
|
+
y: "semivowel",
|
|
885
|
+
w: "semivowel"
|
|
886
|
+
};
|
|
887
|
+
var KANA = {
|
|
888
|
+
\u3042: " a",
|
|
889
|
+
\u3044: " i",
|
|
890
|
+
\u3046: " u",
|
|
891
|
+
\u3048: " e",
|
|
892
|
+
\u304A: " o",
|
|
893
|
+
\u3093: " n",
|
|
894
|
+
\u304B: "k a",
|
|
895
|
+
\u304D: "k i",
|
|
896
|
+
\u304F: "k u",
|
|
897
|
+
\u3051: "k e",
|
|
898
|
+
\u3053: "k o",
|
|
899
|
+
\u304C: "g a",
|
|
900
|
+
\u304E: "g i",
|
|
901
|
+
\u3050: "g u",
|
|
902
|
+
\u3052: "g e",
|
|
903
|
+
\u3054: "g o",
|
|
904
|
+
\u3055: "s a",
|
|
905
|
+
\u3057: "sh i",
|
|
906
|
+
\u3059: "s u",
|
|
907
|
+
\u305B: "s e",
|
|
908
|
+
\u305D: "s o",
|
|
909
|
+
\u3056: "z a",
|
|
910
|
+
\u3058: "j i",
|
|
911
|
+
\u305A: "z u",
|
|
912
|
+
\u305C: "z e",
|
|
913
|
+
\u305E: "z o",
|
|
914
|
+
\u305F: "t a",
|
|
915
|
+
\u3061: "ch i",
|
|
916
|
+
\u3064: "ts u",
|
|
917
|
+
\u3066: "t e",
|
|
918
|
+
\u3068: "t o",
|
|
919
|
+
\u3060: "d a",
|
|
920
|
+
\u3062: "j i",
|
|
921
|
+
\u3065: "z u",
|
|
922
|
+
\u3067: "d e",
|
|
923
|
+
\u3069: "d o",
|
|
924
|
+
\u306A: "n a",
|
|
925
|
+
\u306B: "n i",
|
|
926
|
+
\u306C: "n u",
|
|
927
|
+
\u306D: "n e",
|
|
928
|
+
\u306E: "n o",
|
|
929
|
+
\u306F: "h a",
|
|
930
|
+
\u3072: "h i",
|
|
931
|
+
\u3075: "f u",
|
|
932
|
+
\u3078: "h e",
|
|
933
|
+
\u307B: "h o",
|
|
934
|
+
\u3070: "b a",
|
|
935
|
+
\u3073: "b i",
|
|
936
|
+
\u3076: "b u",
|
|
937
|
+
\u3079: "b e",
|
|
938
|
+
\u307C: "b o",
|
|
939
|
+
\u3071: "p a",
|
|
940
|
+
\u3074: "p i",
|
|
941
|
+
\u3077: "p u",
|
|
942
|
+
\u307A: "p e",
|
|
943
|
+
\u307D: "p o",
|
|
944
|
+
\u307E: "m a",
|
|
945
|
+
\u307F: "m i",
|
|
946
|
+
\u3080: "m u",
|
|
947
|
+
\u3081: "m e",
|
|
948
|
+
\u3082: "m o",
|
|
949
|
+
\u3084: "y a",
|
|
950
|
+
\u3086: "y u",
|
|
951
|
+
\u3088: "y o",
|
|
952
|
+
\u3089: "r a",
|
|
953
|
+
\u308A: "r i",
|
|
954
|
+
\u308B: "r u",
|
|
955
|
+
\u308C: "r e",
|
|
956
|
+
\u308D: "r o",
|
|
957
|
+
\u308F: "w a",
|
|
958
|
+
\u3090: "w i",
|
|
959
|
+
\u3091: "w e",
|
|
960
|
+
\u3092: "w o",
|
|
961
|
+
\u3094: "v u"
|
|
962
|
+
};
|
|
963
|
+
var SMALL = {
|
|
964
|
+
\u3041: "a",
|
|
965
|
+
\u3043: "i",
|
|
966
|
+
\u3045: "u",
|
|
967
|
+
\u3047: "e",
|
|
968
|
+
\u3049: "o",
|
|
969
|
+
\u3083: "a",
|
|
970
|
+
\u3085: "u",
|
|
971
|
+
\u3087: "o",
|
|
972
|
+
\u308E: "a"
|
|
973
|
+
};
|
|
974
|
+
var DIGRAPH = {
|
|
975
|
+
\u3057\u3083: "sh a",
|
|
976
|
+
\u3057\u3085: "sh u",
|
|
977
|
+
\u3057\u3087: "sh o",
|
|
978
|
+
\u3057\u3047: "sh e",
|
|
979
|
+
\u3058\u3083: "j a",
|
|
980
|
+
\u3058\u3085: "j u",
|
|
981
|
+
\u3058\u3087: "j o",
|
|
982
|
+
\u3058\u3047: "j e",
|
|
983
|
+
\u3061\u3083: "ch a",
|
|
984
|
+
\u3061\u3085: "ch u",
|
|
985
|
+
\u3061\u3087: "ch o",
|
|
986
|
+
\u3061\u3047: "ch e",
|
|
987
|
+
\u3062\u3083: "j a",
|
|
988
|
+
\u3062\u3085: "j u",
|
|
989
|
+
\u3062\u3087: "j o",
|
|
990
|
+
\u3064\u3041: "ts a",
|
|
991
|
+
\u3064\u3043: "ts i",
|
|
992
|
+
\u3064\u3047: "ts e",
|
|
993
|
+
\u3064\u3049: "ts o",
|
|
994
|
+
\u3066\u3043: "t i",
|
|
995
|
+
\u3066\u3083: "ty a",
|
|
996
|
+
\u3066\u3085: "ty u",
|
|
997
|
+
\u3066\u3087: "ty o",
|
|
998
|
+
\u3067\u3043: "d i",
|
|
999
|
+
\u3067\u3083: "dy a",
|
|
1000
|
+
\u3067\u3085: "dy u",
|
|
1001
|
+
\u3067\u3087: "dy o",
|
|
1002
|
+
\u3068\u3045: "t u",
|
|
1003
|
+
\u3069\u3045: "d u",
|
|
1004
|
+
\u3075\u3041: "f a",
|
|
1005
|
+
\u3075\u3043: "f i",
|
|
1006
|
+
\u3075\u3047: "f e",
|
|
1007
|
+
\u3075\u3049: "f o",
|
|
1008
|
+
\u3075\u3085: "f u",
|
|
1009
|
+
\u3094\u3041: "v a",
|
|
1010
|
+
\u3094\u3043: "v i",
|
|
1011
|
+
\u3094\u3047: "v e",
|
|
1012
|
+
\u3094\u3049: "v o",
|
|
1013
|
+
\u3094\u3085: "v u",
|
|
1014
|
+
\u304F\u3041: "k a",
|
|
1015
|
+
\u304F\u3043: "k i",
|
|
1016
|
+
\u304F\u3047: "k e",
|
|
1017
|
+
\u304F\u3049: "k o",
|
|
1018
|
+
\u3050\u3041: "g a",
|
|
1019
|
+
\u3050\u3043: "g i",
|
|
1020
|
+
\u3050\u3047: "g e",
|
|
1021
|
+
\u3050\u3049: "g o",
|
|
1022
|
+
\u3059\u3043: "s i",
|
|
1023
|
+
\u305A\u3043: "z i"
|
|
1024
|
+
};
|
|
1025
|
+
function toHiragana(s) {
|
|
1026
|
+
let out = "";
|
|
1027
|
+
for (const ch of s) {
|
|
1028
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
1029
|
+
out += c >= 12449 && c <= 12534 ? String.fromCodePoint(c - 96) : ch;
|
|
1030
|
+
}
|
|
1031
|
+
return out;
|
|
1032
|
+
}
|
|
1033
|
+
function make(kana, pair) {
|
|
1034
|
+
const sp = pair.indexOf(" ");
|
|
1035
|
+
const consonant = pair.slice(0, sp);
|
|
1036
|
+
const vowel = pair.slice(sp + 1);
|
|
1037
|
+
return { kana, consonant, vowel, cls: CLASS_OF[consonant] ?? "vowel" };
|
|
1038
|
+
}
|
|
1039
|
+
function readSyllable(src, hira, i) {
|
|
1040
|
+
const two = hira.slice(i, i + 2);
|
|
1041
|
+
if (two.length === 2) {
|
|
1042
|
+
const explicit = DIGRAPH[two];
|
|
1043
|
+
if (explicit) return { syl: make(src.slice(i, i + 2), explicit), len: 2 };
|
|
1044
|
+
const small = SMALL[two[1]];
|
|
1045
|
+
const base = KANA[two[0]];
|
|
1046
|
+
if (small && base) {
|
|
1047
|
+
const sp = base.indexOf(" ");
|
|
1048
|
+
const baseConsonant = base.slice(0, sp);
|
|
1049
|
+
const baseVowel = base.slice(sp + 1);
|
|
1050
|
+
if (baseVowel === "i" && CLASS_OF[`${baseConsonant}y`]) {
|
|
1051
|
+
const pair = `${baseConsonant}y ${small}`;
|
|
1052
|
+
return { syl: make(src.slice(i, i + 2), pair), len: 2 };
|
|
1053
|
+
}
|
|
1054
|
+
if (!baseConsonant) {
|
|
1055
|
+
const glide = baseVowel === "i" ? "y" : baseVowel === "u" ? "w" : "";
|
|
1056
|
+
const syl = make(src.slice(i, i + 2), `${glide} ${small}`);
|
|
1057
|
+
return { syl: { ...syl, cls: "semivowel" }, len: 2 };
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
syl: make(src.slice(i, i + 2), `${baseConsonant} ${small}`),
|
|
1061
|
+
len: 2
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const one = KANA[hira[i]];
|
|
1066
|
+
if (one) return { syl: make(src.slice(i, i + 1), one), len: 1 };
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
function splitKana(text) {
|
|
1070
|
+
const src = text.normalize("NFC");
|
|
1071
|
+
const hira = toHiragana(src);
|
|
1072
|
+
const out = [];
|
|
1073
|
+
for (let i = 0; i < hira.length; ) {
|
|
1074
|
+
const ch = hira[i];
|
|
1075
|
+
if (ch === "\u3063" || ch === "\u30FC") {
|
|
1076
|
+
i++;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const read = readSyllable(src, hira, i);
|
|
1080
|
+
if (!read) return null;
|
|
1081
|
+
out.push(read.syl);
|
|
1082
|
+
i += read.len;
|
|
1083
|
+
}
|
|
1084
|
+
return out.length > 0 ? out : null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/oto/generate.ts
|
|
1088
|
+
var REST = /[RrRr]$|息$|吸$/;
|
|
1089
|
+
function transcribe(filename) {
|
|
1090
|
+
let body = filename.replace(/\.[^.]+$/, "");
|
|
1091
|
+
body = body.replace(/^[_\-\s]+/, "");
|
|
1092
|
+
body = body.replace(/_[A-G][#b]?-?\d$/i, "");
|
|
1093
|
+
body = body.trim();
|
|
1094
|
+
if (!body) return null;
|
|
1095
|
+
let mark = "";
|
|
1096
|
+
const arrows = /[↑↓→]+$/.exec(body);
|
|
1097
|
+
if (arrows) {
|
|
1098
|
+
mark = arrows[0];
|
|
1099
|
+
body = body.slice(0, -arrows[0].length);
|
|
1100
|
+
}
|
|
1101
|
+
let trailingRest = false;
|
|
1102
|
+
if (REST.test(body)) {
|
|
1103
|
+
trailingRest = true;
|
|
1104
|
+
body = body.replace(REST, "");
|
|
1105
|
+
}
|
|
1106
|
+
const take = /[A-Za-z0-9'][A-Za-z0-9']?$/.exec(body);
|
|
1107
|
+
if (take && body.length > take[0].length) {
|
|
1108
|
+
mark = take[0] + mark;
|
|
1109
|
+
body = body.slice(0, -take[0].length);
|
|
1110
|
+
}
|
|
1111
|
+
let prefix = "";
|
|
1112
|
+
const head = /^[A-Za-z]{1,2}(?=[ぁ-ゟァ-ヿ])/.exec(body);
|
|
1113
|
+
if (head) {
|
|
1114
|
+
prefix = head[0];
|
|
1115
|
+
body = body.slice(head[0].length);
|
|
1116
|
+
}
|
|
1117
|
+
if (!body) return null;
|
|
1118
|
+
const syllables = splitKana(body);
|
|
1119
|
+
if (!syllables) return null;
|
|
1120
|
+
return { syllables, trailingRest, prefix, mark };
|
|
1121
|
+
}
|
|
1122
|
+
function suffixFromFolderName(folder) {
|
|
1123
|
+
if (/^[A-G][#b]?-?\d$/.test(folder)) return `_${folder}`;
|
|
1124
|
+
const tagged = /_([A-G][#b]?-?\d)(?![0-9A-Za-z])/.exec(folder);
|
|
1125
|
+
return tagged ? `_${tagged[1]}` : "";
|
|
1126
|
+
}
|
|
1127
|
+
function aliasesFor(name, headAliases) {
|
|
1128
|
+
return headAliases ? [name, `- ${name}`] : [name];
|
|
1129
|
+
}
|
|
1130
|
+
function generateOtoForFile(file, options = {}) {
|
|
1131
|
+
const suffix = options.suffix ?? "";
|
|
1132
|
+
const headAliases = options.headAliases ?? true;
|
|
1133
|
+
const vowelJoinAliases = options.vowelJoinAliases ?? true;
|
|
1134
|
+
const transcript = transcribe(file.name);
|
|
1135
|
+
if (!transcript) {
|
|
1136
|
+
return {
|
|
1137
|
+
entries: [],
|
|
1138
|
+
skipped: { wav: file.name, reason: "filename is not kana" },
|
|
1139
|
+
style: null
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
let frames;
|
|
1143
|
+
try {
|
|
1144
|
+
frames = analyze(parseWav(file.data));
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
return {
|
|
1147
|
+
entries: [],
|
|
1148
|
+
skipped: {
|
|
1149
|
+
wav: file.name,
|
|
1150
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
1151
|
+
},
|
|
1152
|
+
style: null
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
if (frames.n < 8) {
|
|
1156
|
+
return {
|
|
1157
|
+
entries: [],
|
|
1158
|
+
skipped: { wav: file.name, reason: "too short to analyse" },
|
|
1159
|
+
style: null
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
const { syllables, trailingRest, prefix, mark } = transcript;
|
|
1163
|
+
if (syllables.length === 1 && !trailingRest) {
|
|
1164
|
+
const syl = syllables[0];
|
|
1165
|
+
const name = `${prefix}${syl.kana}${mark}${suffix}`;
|
|
1166
|
+
const entries2 = estimateSolo(
|
|
1167
|
+
file.name,
|
|
1168
|
+
frames,
|
|
1169
|
+
syl,
|
|
1170
|
+
aliasesFor(name, headAliases)
|
|
1171
|
+
);
|
|
1172
|
+
if (vowelJoinAliases && (syl.cls === "vowel" || syl.cls === "nasalN")) {
|
|
1173
|
+
const join2 = estimateVowelJoin(file.name, frames, syl, `* ${name}`);
|
|
1174
|
+
if (join2) entries2.push(join2);
|
|
1175
|
+
}
|
|
1176
|
+
return { entries: entries2, skipped: null, style: "solo" };
|
|
1177
|
+
}
|
|
1178
|
+
const entries = estimateSequence(file.name, frames, syllables, {
|
|
1179
|
+
suffix: `${mark}${suffix}`,
|
|
1180
|
+
prefix,
|
|
1181
|
+
trailingRest
|
|
1182
|
+
});
|
|
1183
|
+
if (entries.length === 0) {
|
|
1184
|
+
return {
|
|
1185
|
+
entries: [],
|
|
1186
|
+
skipped: { wav: file.name, reason: "could not segment the phrase" },
|
|
1187
|
+
style: null
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
return { entries, skipped: null, style: "sequence" };
|
|
1191
|
+
}
|
|
1192
|
+
function generateOto(files, options = {}) {
|
|
1193
|
+
const entries = [];
|
|
1194
|
+
const skipped = [];
|
|
1195
|
+
let solo = 0;
|
|
1196
|
+
let sequence = 0;
|
|
1197
|
+
for (const file of files) {
|
|
1198
|
+
const result = generateOtoForFile(file, options);
|
|
1199
|
+
entries.push(...result.entries);
|
|
1200
|
+
if (result.skipped) skipped.push(result.skipped);
|
|
1201
|
+
if (result.style === "solo") solo++;
|
|
1202
|
+
if (result.style === "sequence") sequence++;
|
|
1203
|
+
}
|
|
1204
|
+
return { entries, skipped, style: summarise(solo, sequence) };
|
|
1205
|
+
}
|
|
1206
|
+
function summarise(solo, sequence) {
|
|
1207
|
+
if (solo && sequence) return "mixed";
|
|
1208
|
+
if (sequence) return "sequence";
|
|
1209
|
+
if (solo) return "solo";
|
|
1210
|
+
return "empty";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/oto/write.ts
|
|
1214
|
+
var reverseTable = null;
|
|
1215
|
+
function shiftJisTable() {
|
|
1216
|
+
if (reverseTable) return reverseTable;
|
|
1217
|
+
const table = /* @__PURE__ */ new Map();
|
|
1218
|
+
const decoder = new TextDecoder("shift_jis", { fatal: false });
|
|
1219
|
+
for (let b = 0; b < 128; b++) {
|
|
1220
|
+
table.set(decoder.decode(new Uint8Array([b])), b);
|
|
1221
|
+
}
|
|
1222
|
+
for (let b = 161; b <= 223; b++) {
|
|
1223
|
+
table.set(decoder.decode(new Uint8Array([b])), b);
|
|
1224
|
+
}
|
|
1225
|
+
const pair = new Uint8Array(2);
|
|
1226
|
+
for (let lead = 129; lead <= 252; lead++) {
|
|
1227
|
+
if (lead > 159 && lead < 224) continue;
|
|
1228
|
+
pair[0] = lead;
|
|
1229
|
+
for (let trail = 64; trail <= 252; trail++) {
|
|
1230
|
+
if (trail === 127) continue;
|
|
1231
|
+
pair[1] = trail;
|
|
1232
|
+
const ch = decoder.decode(pair);
|
|
1233
|
+
if (ch.length !== 1 || ch === "\uFFFD") continue;
|
|
1234
|
+
if (!table.has(ch)) table.set(ch, lead << 8 | trail);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
reverseTable = table;
|
|
1238
|
+
return table;
|
|
1239
|
+
}
|
|
1240
|
+
function encodeShiftJis(text) {
|
|
1241
|
+
const table = shiftJisTable();
|
|
1242
|
+
const out = [];
|
|
1243
|
+
for (const ch of text) {
|
|
1244
|
+
const code = table.get(ch);
|
|
1245
|
+
if (code === void 0) out.push(63);
|
|
1246
|
+
else if (code > 255) out.push(code >> 8, code & 255);
|
|
1247
|
+
else out.push(code);
|
|
1248
|
+
}
|
|
1249
|
+
return new Uint8Array(out);
|
|
1250
|
+
}
|
|
1251
|
+
function num(v) {
|
|
1252
|
+
const s = v.toFixed(3);
|
|
1253
|
+
return s.replace(/\.?0+$/, "") || "0";
|
|
1254
|
+
}
|
|
1255
|
+
function formatOto(entries) {
|
|
1256
|
+
return entries.map(
|
|
1257
|
+
(e) => `${e.wav}=${e.alias},${num(e.offset)},${num(e.consonant)},${num(e.cutoff)},${num(e.pre)},${num(e.overlap)}`
|
|
1258
|
+
).join("\r\n").concat("\r\n");
|
|
1259
|
+
}
|
|
1260
|
+
function encodeOto(entries) {
|
|
1261
|
+
return encodeShiftJis(formatOto(entries));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/oto/cli.ts
|
|
1265
|
+
function parseArgs(argv) {
|
|
1266
|
+
let root = "";
|
|
1267
|
+
let force = false;
|
|
1268
|
+
let dryRun = false;
|
|
1269
|
+
let quiet = false;
|
|
1270
|
+
let suffix;
|
|
1271
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1272
|
+
const a = argv[i];
|
|
1273
|
+
if (a === "--force" || a === "-f") force = true;
|
|
1274
|
+
else if (a === "--dry-run" || a === "-n") dryRun = true;
|
|
1275
|
+
else if (a === "--quiet" || a === "-q") quiet = true;
|
|
1276
|
+
else if (a === "--suffix") suffix = argv[++i] ?? "";
|
|
1277
|
+
else if (a.startsWith("--suffix=")) suffix = a.slice(9);
|
|
1278
|
+
else if (a.startsWith("-")) return null;
|
|
1279
|
+
else if (!root) root = a;
|
|
1280
|
+
else return null;
|
|
1281
|
+
}
|
|
1282
|
+
return root ? { root, force, dryRun, suffix, quiet } : null;
|
|
1283
|
+
}
|
|
1284
|
+
var USAGE = `Usage: koe-oto <voice-bank-dir> [options]
|
|
1285
|
+
|
|
1286
|
+
Estimates UTAU oto.ini parameters from the recordings themselves and writes one
|
|
1287
|
+
oto.ini into every folder that holds WAV files.
|
|
1288
|
+
|
|
1289
|
+
Options:
|
|
1290
|
+
-f, --force overwrite an existing oto.ini (a .bak copy is kept)
|
|
1291
|
+
-n, --dry-run report what would be written, write nothing
|
|
1292
|
+
--suffix <s> append <s> to every alias (default: the folder's note name)
|
|
1293
|
+
-q, --quiet only print the summary
|
|
1294
|
+
`;
|
|
1295
|
+
async function findWavFolders(root) {
|
|
1296
|
+
const folders = /* @__PURE__ */ new Map();
|
|
1297
|
+
const walk = async (dir) => {
|
|
1298
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1299
|
+
const wavs = entries.filter((e) => e.isFile() && /\.wav$/i.test(e.name)).map((e) => e.name).sort((a, b) => a.localeCompare(b, "ja"));
|
|
1300
|
+
if (wavs.length > 0) folders.set(dir, wavs);
|
|
1301
|
+
for (const e of entries) {
|
|
1302
|
+
if (e.isDirectory()) await walk(join(dir, e.name));
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
await walk(root);
|
|
1306
|
+
return folders;
|
|
1307
|
+
}
|
|
1308
|
+
function toArrayBuffer(buf) {
|
|
1309
|
+
return buf.buffer.slice(
|
|
1310
|
+
buf.byteOffset,
|
|
1311
|
+
buf.byteOffset + buf.byteLength
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
async function exists(path) {
|
|
1315
|
+
try {
|
|
1316
|
+
await stat(path);
|
|
1317
|
+
return true;
|
|
1318
|
+
} catch {
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async function main() {
|
|
1323
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1324
|
+
if (!args) {
|
|
1325
|
+
process.stderr.write(USAGE);
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
const root = resolve(args.root);
|
|
1329
|
+
const folders = await findWavFolders(root);
|
|
1330
|
+
if (folders.size === 0) {
|
|
1331
|
+
process.stderr.write(`No WAV files found under "${root}"
|
|
1332
|
+
`);
|
|
1333
|
+
process.exit(1);
|
|
1334
|
+
}
|
|
1335
|
+
const log = (s) => {
|
|
1336
|
+
if (!args.quiet) process.stdout.write(s);
|
|
1337
|
+
};
|
|
1338
|
+
let totalEntries = 0;
|
|
1339
|
+
let totalSkipped = 0;
|
|
1340
|
+
let written = 0;
|
|
1341
|
+
let blocked = 0;
|
|
1342
|
+
for (const [dir, names] of folders) {
|
|
1343
|
+
const label = relative(root, dir) || basename(root);
|
|
1344
|
+
const files = [];
|
|
1345
|
+
for (const name of names) {
|
|
1346
|
+
files.push({
|
|
1347
|
+
name,
|
|
1348
|
+
data: toArrayBuffer(await readFile(join(dir, name)))
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
const suffix = args.suffix ?? suffixFromFolderName(basename(dir));
|
|
1352
|
+
const result = generateOto(files, { suffix });
|
|
1353
|
+
totalEntries += result.entries.length;
|
|
1354
|
+
totalSkipped += result.skipped.length;
|
|
1355
|
+
if (result.entries.length === 0) {
|
|
1356
|
+
log(` -- ${label}: no usable recordings (${names.length} wav)
|
|
1357
|
+
`);
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const otoPath = join(dir, "oto.ini");
|
|
1361
|
+
const already = await exists(otoPath);
|
|
1362
|
+
const detail = `${result.entries.length} entries from ${names.length} wav [${result.style}]${result.skipped.length ? `, ${result.skipped.length} skipped` : ""}`;
|
|
1363
|
+
if (args.dryRun) {
|
|
1364
|
+
log(` .. ${label}: ${detail}${already ? " (would overwrite)" : ""}
|
|
1365
|
+
`);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (already && !args.force) {
|
|
1369
|
+
log(
|
|
1370
|
+
` !! ${label}: oto.ini exists \u2014 rerun with --force to replace it
|
|
1371
|
+
`
|
|
1372
|
+
);
|
|
1373
|
+
blocked++;
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (already) {
|
|
1377
|
+
await writeFile(`${otoPath}.bak`, await readFile(otoPath));
|
|
1378
|
+
}
|
|
1379
|
+
await writeFile(otoPath, encodeOto(result.entries));
|
|
1380
|
+
written++;
|
|
1381
|
+
log(` ok ${label}: ${detail}
|
|
1382
|
+
`);
|
|
1383
|
+
}
|
|
1384
|
+
process.stdout.write(
|
|
1385
|
+
`${args.dryRun ? "Would write" : "Wrote"} ${args.dryRun ? folders.size : written} oto.ini \u2014 ${totalEntries} entries, ${totalSkipped} files skipped
|
|
1386
|
+
`
|
|
1387
|
+
);
|
|
1388
|
+
if (blocked > 0) {
|
|
1389
|
+
process.stdout.write(
|
|
1390
|
+
`${blocked} folder(s) already had an oto.ini; rerun with --force to replace them.
|
|
1391
|
+
`
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
main().catch((err) => {
|
|
1396
|
+
process.stderr.write(`${err instanceof Error ? err.stack : err}
|
|
1397
|
+
`);
|
|
1398
|
+
process.exit(1);
|
|
1399
|
+
});
|