@onjmin/koe 1.0.2 → 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.
@@ -2,133 +2,35 @@
2
2
  #!/usr/bin/env node
3
3
 
4
4
  // src/converter/cli.ts
5
- import { readdir, readFile, writeFile, mkdir } from "fs/promises";
6
- import { join, dirname, basename, resolve } from "path";
5
+ import { mkdir, readdir, readFile, writeFile } from "fs/promises";
6
+ import { basename, dirname, join, resolve, sep } from "path";
7
7
 
8
- // src/converter/parse-oto.ts
9
- function parseOto(content) {
10
- const entries = [];
11
- for (const raw of content.split(/\r?\n/)) {
12
- const line = raw.trim();
13
- if (!line || line.startsWith("#")) continue;
14
- const eq = line.indexOf("=");
15
- if (eq === -1) continue;
16
- const wav = line.slice(0, eq).trim();
17
- const parts = line.slice(eq + 1).split(",");
18
- if (parts.length < 6) continue;
19
- const [alias, offsetStr, consonantStr, cutoffStr, preStr, overlapStr] = parts;
20
- const aliasStr = alias.trim() || wav.replace(/\.[^.]+$/, "");
21
- const entry = {
22
- wav,
23
- alias: aliasStr,
24
- offset: parseFloat(offsetStr) || 0,
25
- consonant: parseFloat(consonantStr) || 0,
26
- cutoff: parseFloat(cutoffStr) || 0,
27
- pre: parseFloat(preStr) || 0,
28
- overlap: parseFloat(overlapStr) || 0
29
- };
30
- if (!entry.alias) continue;
31
- entries.push(entry);
32
- }
33
- return entries;
8
+ // src/koe.ts
9
+ var MAGIC = 1263486208;
10
+ function packKoe(manifest, pcmParts) {
11
+ const json = new TextEncoder().encode(JSON.stringify(manifest));
12
+ const header = new ArrayBuffer(8);
13
+ const view = new DataView(header);
14
+ view.setUint32(0, MAGIC, false);
15
+ view.setUint32(4, json.byteLength, true);
16
+ return new Blob([header, json, ...pcmParts]);
34
17
  }
35
18
 
36
- // src/converter/wav.ts
37
- function parseWav(buf) {
38
- const view = new DataView(buf);
39
- const riff = readFourCC(view, 0);
40
- if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
41
- let sampleRate = 0;
42
- let channels = 0;
43
- let bitsPerSample = 0;
44
- let audioFormat = 1;
45
- let dataOffset = 0;
46
- let dataLength = 0;
47
- let pos = 12;
48
- while (pos < view.byteLength - 8) {
49
- const id = readFourCC(view, pos);
50
- const size = view.getUint32(pos + 4, true);
51
- pos += 8;
52
- if (id === "fmt ") {
53
- audioFormat = view.getUint16(pos, true);
54
- channels = view.getUint16(pos + 2, true);
55
- sampleRate = view.getUint32(pos + 4, true);
56
- bitsPerSample = view.getUint16(pos + 14, true);
57
- } else if (id === "data") {
58
- dataOffset = pos;
59
- dataLength = size;
60
- break;
61
- }
62
- pos += size + (size & 1);
63
- }
64
- if (!dataOffset) throw new Error("WAV has no data chunk");
65
- if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
66
- const bytesPerSample = bitsPerSample >> 3;
67
- const totalSamples = Math.floor(dataLength / bytesPerSample);
68
- const samples = new Float32Array(totalSamples);
69
- for (let i = 0; i < totalSamples; i++) {
70
- const p = dataOffset + i * bytesPerSample;
71
- if (audioFormat === 3) {
72
- samples[i] = view.getFloat32(p, true);
73
- } else if (bitsPerSample === 8) {
74
- samples[i] = (view.getUint8(p) - 128) / 128;
75
- } else if (bitsPerSample === 16) {
76
- samples[i] = view.getInt16(p, true) / 32768;
77
- } else if (bitsPerSample === 24) {
78
- const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
79
- let hi = view.getUint8(p + 2);
80
- if (hi & 128) hi = hi | 4294967040;
81
- samples[i] = (hi << 16 | lo) / 8388608;
82
- }
83
- }
84
- return { sampleRate, channels, samples };
85
- }
86
- function toMono(wav) {
87
- if (wav.channels === 1) return wav;
88
- const len = wav.samples.length / wav.channels;
89
- const out = new Float32Array(len);
90
- for (let i = 0; i < len; i++) {
91
- let sum = 0;
92
- for (let c = 0; c < wav.channels; c++)
93
- sum += wav.samples[i * wav.channels + c];
94
- out[i] = sum / wav.channels;
95
- }
96
- return { sampleRate: wav.sampleRate, channels: 1, samples: out };
97
- }
98
- function resample(wav, targetRate) {
99
- if (wav.sampleRate === targetRate) return wav;
100
- const ratio = wav.sampleRate / targetRate;
101
- const outLen = Math.floor(wav.samples.length / ratio);
102
- const out = new Float32Array(outLen);
103
- const src = wav.samples;
104
- for (let i = 0; i < outLen; i++) {
105
- const x = i * ratio;
106
- const xi = Math.floor(x);
107
- const frac = x - xi;
108
- out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
109
- }
110
- return { sampleRate: targetRate, channels: 1, samples: out };
111
- }
112
- function toInt16(samples) {
113
- const out = new Int16Array(samples.length);
114
- for (let i = 0; i < samples.length; i++) {
115
- out[i] = Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767);
116
- }
117
- return out;
118
- }
119
- function normalizePcm(buf) {
120
- const wav = parseWav(buf);
121
- const mono = toMono(wav);
122
- const resampled = resample(mono, 48e3);
123
- return toInt16(resampled.samples);
19
+ // src/converter/frq.ts
20
+ function parseFrqAverageF0(buffer) {
21
+ if (buffer.byteLength < 20) return null;
22
+ const view = new DataView(buffer);
23
+ let header = "";
24
+ for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
25
+ if (header !== "FREQ0003") return null;
26
+ const avg = view.getFloat64(12, true);
27
+ return Number.isFinite(avg) && avg > 0 ? avg : null;
124
28
  }
125
- function readFourCC(view, pos) {
126
- return String.fromCharCode(
127
- view.getUint8(pos),
128
- view.getUint8(pos + 1),
129
- view.getUint8(pos + 2),
130
- view.getUint8(pos + 3)
131
- );
29
+ function frqFileName(wavName) {
30
+ const dot = wavName.lastIndexOf(".");
31
+ const base = dot >= 0 ? wavName.slice(0, dot) : wavName;
32
+ const ext = dot >= 0 ? wavName.slice(dot + 1) : "wav";
33
+ return `${base}_${ext}.frq`;
132
34
  }
133
35
 
134
36
  // src/converter/pitch.ts
@@ -258,32 +160,139 @@ function pack(inputs, referencePitch = 220) {
258
160
  return { manifest, bin };
259
161
  }
260
162
 
261
- // src/converter/frq.ts
262
- function parseFrqAverageF0(buffer) {
263
- if (buffer.byteLength < 20) return null;
264
- const view = new DataView(buffer);
265
- let header = "";
266
- for (let i = 0; i < 8; i++) header += String.fromCharCode(view.getUint8(i));
267
- if (header !== "FREQ0003") return null;
268
- const avg = view.getFloat64(12, true);
269
- return Number.isFinite(avg) && avg > 0 ? avg : null;
270
- }
271
- function frqFileName(wavName) {
272
- const dot = wavName.lastIndexOf(".");
273
- const base = dot >= 0 ? wavName.slice(0, dot) : wavName;
274
- const ext = dot >= 0 ? wavName.slice(dot + 1) : "wav";
275
- return `${base}_${ext}.frq`;
163
+ // src/converter/parse-oto.ts
164
+ function parseOto(content) {
165
+ const entries = [];
166
+ for (const raw of content.split(/\r?\n/)) {
167
+ const line = raw.trim();
168
+ if (!line || line.startsWith("#")) continue;
169
+ const eq = line.indexOf("=");
170
+ if (eq === -1) continue;
171
+ const wav = line.slice(0, eq).trim();
172
+ const parts = line.slice(eq + 1).split(",");
173
+ if (parts.length < 6) continue;
174
+ const [alias, offsetStr, consonantStr, cutoffStr, preStr, overlapStr] = parts;
175
+ const aliasStr = alias.trim() || wav.replace(/\.[^.]+$/, "");
176
+ const entry = {
177
+ wav,
178
+ alias: aliasStr,
179
+ offset: parseFloat(offsetStr) || 0,
180
+ consonant: parseFloat(consonantStr) || 0,
181
+ cutoff: parseFloat(cutoffStr) || 0,
182
+ pre: parseFloat(preStr) || 0,
183
+ overlap: parseFloat(overlapStr) || 0
184
+ };
185
+ if (!entry.alias) continue;
186
+ entries.push(entry);
187
+ }
188
+ return entries;
276
189
  }
277
190
 
278
- // src/koe.ts
279
- var MAGIC = 1263486208;
280
- function packKoe(manifest, pcmParts) {
281
- const json = new TextEncoder().encode(JSON.stringify(manifest));
282
- const header = new ArrayBuffer(8);
283
- const view = new DataView(header);
284
- view.setUint32(0, MAGIC, false);
285
- view.setUint32(4, json.byteLength, true);
286
- return new Blob([header, json, ...pcmParts]);
191
+ // src/converter/wav.ts
192
+ function parseWav(buf) {
193
+ const view = new DataView(buf);
194
+ const riff = readFourCC(view, 0);
195
+ if (riff !== "RIFF") throw new Error(`Not a RIFF file (got "${riff}")`);
196
+ let sampleRate = 0;
197
+ let channels = 0;
198
+ let bitsPerSample = 0;
199
+ let audioFormat = 1;
200
+ let dataOffset = 0;
201
+ let dataLength = 0;
202
+ let pos = 12;
203
+ while (pos < view.byteLength - 8) {
204
+ const id = readFourCC(view, pos);
205
+ const size = view.getUint32(pos + 4, true);
206
+ pos += 8;
207
+ if (id === "fmt ") {
208
+ audioFormat = view.getUint16(pos, true);
209
+ channels = view.getUint16(pos + 2, true);
210
+ sampleRate = view.getUint32(pos + 4, true);
211
+ bitsPerSample = view.getUint16(pos + 14, true);
212
+ if (audioFormat === 65534 && size >= 40) {
213
+ audioFormat = view.getUint16(pos + 24, true);
214
+ }
215
+ } else if (id === "data") {
216
+ dataOffset = pos;
217
+ dataLength = Math.min(size, view.byteLength - pos);
218
+ break;
219
+ }
220
+ pos += size + (size & 1);
221
+ }
222
+ if (!dataOffset) throw new Error("WAV has no data chunk");
223
+ if (!channels || !sampleRate) throw new Error("WAV fmt chunk missing");
224
+ const supported = audioFormat === 3 && bitsPerSample === 32 || audioFormat === 1 && (bitsPerSample === 8 || bitsPerSample === 16 || bitsPerSample === 24);
225
+ if (!supported) {
226
+ throw new Error(
227
+ `Unsupported WAV format ${audioFormat} / ${bitsPerSample}-bit (need PCM 8/16/24-bit or IEEE float 32-bit)`
228
+ );
229
+ }
230
+ const bytesPerSample = bitsPerSample >> 3;
231
+ const totalSamples = Math.floor(dataLength / bytesPerSample);
232
+ const samples = new Float32Array(totalSamples);
233
+ for (let i = 0; i < totalSamples; i++) {
234
+ const p = dataOffset + i * bytesPerSample;
235
+ if (audioFormat === 3) {
236
+ samples[i] = view.getFloat32(p, true);
237
+ } else if (bitsPerSample === 8) {
238
+ samples[i] = (view.getUint8(p) - 128) / 128;
239
+ } else if (bitsPerSample === 16) {
240
+ samples[i] = view.getInt16(p, true) / 32768;
241
+ } else if (bitsPerSample === 24) {
242
+ const lo = view.getUint8(p) | view.getUint8(p + 1) << 8;
243
+ let hi = view.getUint8(p + 2);
244
+ if (hi & 128) hi = hi | 4294967040;
245
+ samples[i] = (hi << 16 | lo) / 8388608;
246
+ }
247
+ }
248
+ return { sampleRate, channels, samples };
249
+ }
250
+ function toMono(wav) {
251
+ if (wav.channels === 1) return wav;
252
+ const len = wav.samples.length / wav.channels;
253
+ const out = new Float32Array(len);
254
+ for (let i = 0; i < len; i++) {
255
+ let sum = 0;
256
+ for (let c = 0; c < wav.channels; c++)
257
+ sum += wav.samples[i * wav.channels + c];
258
+ out[i] = sum / wav.channels;
259
+ }
260
+ return { sampleRate: wav.sampleRate, channels: 1, samples: out };
261
+ }
262
+ function resample(wav, targetRate) {
263
+ if (wav.sampleRate === targetRate) return wav;
264
+ const ratio = wav.sampleRate / targetRate;
265
+ const outLen = Math.floor(wav.samples.length / ratio);
266
+ const out = new Float32Array(outLen);
267
+ const src = wav.samples;
268
+ for (let i = 0; i < outLen; i++) {
269
+ const x = i * ratio;
270
+ const xi = Math.floor(x);
271
+ const frac = x - xi;
272
+ out[i] = (src[xi] ?? 0) + ((src[xi + 1] ?? 0) - (src[xi] ?? 0)) * frac;
273
+ }
274
+ return { sampleRate: targetRate, channels: 1, samples: out };
275
+ }
276
+ function toInt16(samples) {
277
+ const out = new Int16Array(samples.length);
278
+ for (let i = 0; i < samples.length; i++) {
279
+ out[i] = Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767);
280
+ }
281
+ return out;
282
+ }
283
+ function normalizePcm(buf) {
284
+ const wav = parseWav(buf);
285
+ const mono = toMono(wav);
286
+ const resampled = resample(mono, 48e3);
287
+ return toInt16(resampled.samples);
288
+ }
289
+ function readFourCC(view, pos) {
290
+ return String.fromCharCode(
291
+ view.getUint8(pos),
292
+ view.getUint8(pos + 1),
293
+ view.getUint8(pos + 2),
294
+ view.getUint8(pos + 3)
295
+ );
287
296
  }
288
297
 
289
298
  // src/converter/cli.ts
@@ -292,6 +301,20 @@ if (!voiceDir) {
292
301
  process.stderr.write("Usage: koe-convert <voice-dir> [output-dir]\n");
293
302
  process.exit(1);
294
303
  }
304
+ function resolveInside(dir, name) {
305
+ const root = resolve(dir);
306
+ const path = resolve(root, name);
307
+ if (path !== root && !path.startsWith(root + sep)) {
308
+ throw new Error(`path escapes voice bank directory: ${name}`);
309
+ }
310
+ return path;
311
+ }
312
+ function toArrayBuffer(buf) {
313
+ return buf.buffer.slice(
314
+ buf.byteOffset,
315
+ buf.byteOffset + buf.byteLength
316
+ );
317
+ }
295
318
  async function findOtoFiles(dir) {
296
319
  const found = [];
297
320
  const entries = await readdir(dir, { withFileTypes: true, recursive: true });
@@ -314,17 +337,22 @@ async function main() {
314
337
  for (const otoPath of otoFiles) {
315
338
  const otoDir = dirname(otoPath);
316
339
  const rawBytes = await readFile(otoPath);
317
- const content = new TextDecoder("shift_jis").decode(rawBytes);
340
+ const isUtf8Bom = rawBytes[0] === 239 && rawBytes[1] === 187 && rawBytes[2] === 191;
341
+ const content = new TextDecoder(isUtf8Bom ? "utf-8" : "shift_jis").decode(
342
+ rawBytes
343
+ );
318
344
  const entries = parseOto(content);
319
345
  for (const oto of entries) {
320
- const wavPath = join(otoDir, oto.wav);
321
346
  try {
347
+ const wavPath = resolveInside(otoDir, oto.wav);
322
348
  const wavBytes = await readFile(wavPath);
323
- const pcm = normalizePcm(wavBytes.buffer);
349
+ const pcm = normalizePcm(toArrayBuffer(wavBytes));
324
350
  let recordedPitch = pitchFromAliasSuffix(oto.alias) ?? 0;
325
351
  try {
326
- const frqBytes = await readFile(join(otoDir, frqFileName(oto.wav)));
327
- const avg = parseFrqAverageF0(frqBytes.buffer);
352
+ const frqBytes = await readFile(
353
+ resolveInside(otoDir, frqFileName(oto.wav))
354
+ );
355
+ const avg = parseFrqAverageF0(toArrayBuffer(frqBytes));
328
356
  if (avg) recordedPitch = avg;
329
357
  } catch {
330
358
  }
@@ -332,7 +360,7 @@ async function main() {
332
360
  } catch (err) {
333
361
  const msg = err instanceof Error ? err.message : String(err);
334
362
  process.stderr.write(
335
- `[skip] ${oto.alias} (${basename(wavPath)}): ${msg}
363
+ `[skip] ${oto.alias} (${basename(oto.wav)}): ${msg}
336
364
  `
337
365
  );
338
366
  skipped++;
@@ -344,7 +372,7 @@ async function main() {
344
372
  process.exit(1);
345
373
  }
346
374
  const { manifest, bin } = pack(inputs);
347
- const koeName = basename(resolve(voiceDir)) + ".koe";
375
+ const koeName = `${basename(resolve(voiceDir))}.koe`;
348
376
  const koe = packKoe(manifest, [bin]);
349
377
  const koeBytes = Buffer.from(await koe.arrayBuffer());
350
378
  await mkdir(outDir, { recursive: true });
@@ -1 +1 @@
1
- "use strict";(()=>{var m=new Int16Array(0),u=class extends AudioWorkletProcessor{manifest=null;phonemes=new Map;queue=[];current=null;next=null;constructor(e){super(e),this.port.onmessage=t=>this.onMessage(t.data)}onMessage(e){e.type==="init"&&e.manifest?this.manifest=e.manifest:e.type==="phoneme"&&e.name&&e.buffer?this.phonemes.set(e.name,new Int16Array(e.buffer)):e.type==="play"&&e.notes?this.queue.push(...e.notes):e.type==="stop"&&(this.queue=[],this.current=null,this.next=null)}dequeue(){let e=this.queue.shift();if(!e||!this.manifest)return null;if(e.phoneme==="")return{data:m,length:0,consonant:0,pre:0,overlap:0,readPos:0,stepRate:1,remaining:e.duration};let t=this.manifest.phonemes[e.phoneme],n=this.phonemes.get(e.phoneme);if(!t||!n)return null;let i=t.pitch||this.manifest.referencePitch;return{data:n,length:t.length,consonant:t.consonant,pre:t.pre,overlap:t.overlap,readPos:0,stepRate:e.pitch/i,remaining:e.duration}}readSample(e,t){let n=t|0,i=t-n;return((e[n]??0)+((e[n+1]??0)-(e[n]??0))*i)/32768}advance(e){if(!(e.length<=0)&&(e.readPos+=e.stepRate,e.readPos>=e.length)){let t=e.length-e.consonant;e.readPos=t>0?e.consonant+(e.readPos-e.consonant)%t:e.length-1}}process(e,t){let n=t[0]?.[0];if(!n)return!0;for(let i=0;i<n.length;i++){if(!this.current||this.current.remaining<=0){let s=this.next!==null;if(this.current=this.next??this.dequeue(),this.next=null,!this.current){n[i]=0;continue}s||(this.current.readPos=this.current.pre)}let r=this.current;!this.next&&this.queue.length>0&&(this.next=this.dequeue());let a=this.readSample(r.data,r.readPos);if(!this.next&&this.queue.length===0&&r.remaining<=480&&r.length>0&&(a*=r.remaining/480),this.next){let s=this.next,o=Math.max(s.pre/s.stepRate,480);if(r.remaining<=o){let h=Math.min(Math.max(s.overlap/s.stepRate,480),o),l=o-r.remaining,c=Math.min(1,l/h),p=this.readSample(s.data,s.readPos);a=a*(1-c)+p*c,this.advance(s)}}n[i]=a,this.advance(r),r.remaining--}return!0}};registerProcessor("koe-processor",u);})();
1
+ "use strict";(()=>{var f=new Int16Array(0),u=class extends AudioWorkletProcessor{manifest=null;phonemes=new Map;queue=[];current=null;next=null;constructor(e){super(e),this.port.onmessage=t=>this.onMessage(t.data)}onMessage(e){e.type==="init"&&e.manifest?this.manifest=e.manifest:e.type==="phoneme"&&e.name&&e.buffer?this.phonemes.set(e.name,new Int16Array(e.buffer,0,Math.floor(e.buffer.byteLength/2))):e.type==="play"&&e.notes?this.queue.push(...e.notes):e.type==="stop"&&(this.queue=[],this.current=null,this.next=null)}dequeue(){let e=this.queue.shift();if(!e||!this.manifest)return null;if(e.phoneme==="")return{data:f,length:0,consonant:0,pre:0,overlap:0,readPos:0,stepRate:1,remaining:e.duration};let t=Object.hasOwn(this.manifest.phonemes,e.phoneme)?this.manifest.phonemes[e.phoneme]:void 0,n=this.phonemes.get(e.phoneme);if(!t||!n)return null;let i=t.pitch||this.manifest.referencePitch;return{data:n,length:t.length,consonant:t.consonant,pre:t.pre,overlap:t.overlap,readPos:0,stepRate:e.pitch/i,remaining:e.duration}}readSample(e,t){let n=t|0,i=t-n;return((e[n]??0)+((e[n+1]??0)-(e[n]??0))*i)/32768}advance(e){if(!(e.length<=0)&&(e.readPos+=e.stepRate,e.readPos>=e.length)){let t=e.length-e.consonant;e.readPos=t>0?e.consonant+(e.readPos-e.consonant)%t:e.length-1}}process(e,t){let n=t[0]?.[0];if(!n)return!0;for(let i=0;i<n.length;i++){if(!this.current||this.current.remaining<=0){let s=this.next!==null;if(this.current=this.next??this.dequeue(),this.next=null,!this.current){n[i]=0;continue}s||(this.current.readPos=this.current.pre)}let r=this.current;!this.next&&this.queue.length>0&&(this.next=this.dequeue());let o=this.readSample(r.data,r.readPos);if(!this.next&&this.queue.length===0&&r.remaining<=480&&r.length>0&&(o*=r.remaining/480),this.next){let s=this.next,a=Math.max(s.pre/s.stepRate,480);if(r.remaining<=a){let c=Math.min(Math.max(s.overlap/s.stepRate,480),a),l=a-r.remaining,h=Math.min(1,l/c),p=this.readSample(s.data,s.readPos);o=o*(1-h)+p*h,this.advance(s)}}n[i]=o,this.advance(r),r.remaining--}return!0}};registerProcessor("koe-processor",u);})();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "onjmin",
3
3
  "license": "MIT",
4
4
  "name": "@onjmin/koe",
5
- "version": "1.0.2",
5
+ "version": "1.0.4",
6
6
  "description": "UTAU concatenative synthesis engine optimized for browser / mobile via WebAssembly + AudioWorklet",
7
7
  "type": "module",
8
8
  "main": "./dist/index.js",