@onjmin/dtm 0.1.0 → 0.1.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/LICENSE +21 -21
- package/README.md +1 -1
- package/dist/index.d.mts +511 -3
- package/dist/index.d.ts +511 -3
- package/dist/index.js +2572 -141
- package/dist/index.mjs +2541 -140
- package/dist/voice-worker.js +382 -0
- package/package.json +6 -3
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// node_modules/.pnpm/@onjmin+koe@1.0.3/node_modules/@onjmin/koe/dist/index.js
|
|
4
|
+
var MAGIC = 1263486208;
|
|
5
|
+
function parseKoeHeader(headerBytes) {
|
|
6
|
+
const view = new DataView(headerBytes);
|
|
7
|
+
if (view.byteLength < 8 || view.getUint32(0, false) !== MAGIC) {
|
|
8
|
+
throw new Error("Not a .koe file (bad magic)");
|
|
9
|
+
}
|
|
10
|
+
return { jsonLength: view.getUint32(4, true) };
|
|
11
|
+
}
|
|
12
|
+
var pcmBase = (jsonLength) => 8 + jsonLength;
|
|
13
|
+
var BlobVoiceSource = class {
|
|
14
|
+
constructor(blob, base) {
|
|
15
|
+
this.blob = blob;
|
|
16
|
+
this.base = base;
|
|
17
|
+
}
|
|
18
|
+
blob;
|
|
19
|
+
base;
|
|
20
|
+
readBytes(offset, length) {
|
|
21
|
+
const start = this.base + offset;
|
|
22
|
+
return this.blob.slice(start, start + length).arrayBuffer();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var RangeVoiceSource = class {
|
|
26
|
+
constructor(url, base) {
|
|
27
|
+
this.url = url;
|
|
28
|
+
this.base = base;
|
|
29
|
+
}
|
|
30
|
+
url;
|
|
31
|
+
base;
|
|
32
|
+
async readBytes(offset, length) {
|
|
33
|
+
const start = this.base + offset;
|
|
34
|
+
const res = await fetch(this.url, {
|
|
35
|
+
headers: { Range: `bytes=${start}-${start + length - 1}` }
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok && res.status !== 206) {
|
|
38
|
+
throw new Error(`.koe range request failed: ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
return res.arrayBuffer();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
async function rangeFetch(url, start, length) {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
headers: { Range: `bytes=${start}-${start + length - 1}` }
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok && res.status !== 206)
|
|
48
|
+
throw new Error(`.koe fetch failed: ${res.status}`);
|
|
49
|
+
return res.arrayBuffer();
|
|
50
|
+
}
|
|
51
|
+
var VoiceBank = class _VoiceBank {
|
|
52
|
+
constructor(manifest, source) {
|
|
53
|
+
this.manifest = manifest;
|
|
54
|
+
this.source = source;
|
|
55
|
+
}
|
|
56
|
+
manifest;
|
|
57
|
+
source;
|
|
58
|
+
/**
|
|
59
|
+
* Parse a .koe archive header + manifest and bind a lazy PCM source.
|
|
60
|
+
* @param koe a Blob/File of the .koe archive, or a URL (served with Range support)
|
|
61
|
+
*/
|
|
62
|
+
static async load(koe) {
|
|
63
|
+
if (typeof koe === "string") {
|
|
64
|
+
const header2 = await rangeFetch(koe, 0, 8);
|
|
65
|
+
const { jsonLength: jsonLength2 } = parseKoeHeader(header2);
|
|
66
|
+
const json2 = await rangeFetch(koe, 8, jsonLength2);
|
|
67
|
+
const manifest2 = JSON.parse(new TextDecoder().decode(json2));
|
|
68
|
+
return new _VoiceBank(
|
|
69
|
+
manifest2,
|
|
70
|
+
new RangeVoiceSource(koe, pcmBase(jsonLength2))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const header = await koe.slice(0, 8).arrayBuffer();
|
|
74
|
+
const { jsonLength } = parseKoeHeader(header);
|
|
75
|
+
const json = await koe.slice(8, 8 + jsonLength).arrayBuffer();
|
|
76
|
+
const manifest = JSON.parse(new TextDecoder().decode(json));
|
|
77
|
+
return new _VoiceBank(
|
|
78
|
+
manifest,
|
|
79
|
+
new BlobVoiceSource(koe, pcmBase(jsonLength))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
/** True if the bank contains a phoneme under this alias. */
|
|
83
|
+
has(phoneme) {
|
|
84
|
+
return this.manifest.phonemes[phoneme] !== void 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Raw Int16 PCM bytes (48 kHz / mono) for a phoneme, or null if unknown.
|
|
88
|
+
* The returned ArrayBuffer is freshly allocated and safe to transfer to a
|
|
89
|
+
* worker / AudioWorklet.
|
|
90
|
+
*/
|
|
91
|
+
async readPcmBytes(phoneme) {
|
|
92
|
+
const entry = this.manifest.phonemes[phoneme];
|
|
93
|
+
if (!entry) return null;
|
|
94
|
+
return this.source.readBytes(entry.offset, entry.length * 2);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A phoneme's PCM as a Float64Array normalised to [-1, 1], or null if unknown.
|
|
98
|
+
* Intended for external analysis / resynthesis such as the WORLD vocoder.
|
|
99
|
+
*/
|
|
100
|
+
async getPcm(phoneme) {
|
|
101
|
+
const buf = await this.readPcmBytes(phoneme);
|
|
102
|
+
if (!buf) return null;
|
|
103
|
+
const int16 = new Int16Array(buf);
|
|
104
|
+
const f64 = new Float64Array(int16.length);
|
|
105
|
+
for (let i = 0; i < int16.length; i++) f64[i] = int16[i] / 32768;
|
|
106
|
+
return f64;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var WORLDLINE_SAMPLE_RATE = 48e3;
|
|
110
|
+
var MIN_WORLDLINE_SAMPLES = 4096;
|
|
111
|
+
var SYNTH_REQ_SIZE = 120;
|
|
112
|
+
var WL_FRAME_MS = 10;
|
|
113
|
+
var samplesToMs = (samples) => samples / WORLDLINE_SAMPLE_RATE * 1e3;
|
|
114
|
+
function leadInFromEntry(entry) {
|
|
115
|
+
return {
|
|
116
|
+
preMs: samplesToMs(entry.pre || 0),
|
|
117
|
+
consonantMs: samplesToMs(entry.consonant || 0)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
var moduleCache = /* @__PURE__ */ new Map();
|
|
121
|
+
function injectScript(src) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const existing = document.querySelector(
|
|
124
|
+
`script[data-koe-worldline="${src}"]`
|
|
125
|
+
);
|
|
126
|
+
if (existing) {
|
|
127
|
+
resolve();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const s = document.createElement("script");
|
|
131
|
+
s.src = src;
|
|
132
|
+
s.dataset.koeWorldline = src;
|
|
133
|
+
s.onload = () => resolve();
|
|
134
|
+
s.onerror = () => reject(new Error(`worldline: failed to load ${src}`));
|
|
135
|
+
document.head.appendChild(s);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function loadWasm(scriptUrl) {
|
|
139
|
+
const cached = moduleCache.get(scriptUrl);
|
|
140
|
+
if (cached) return cached;
|
|
141
|
+
const baseUrl = scriptUrl.slice(0, scriptUrl.lastIndexOf("/") + 1);
|
|
142
|
+
const instantiate = () => {
|
|
143
|
+
const factory = globalThis.WorldlineModule;
|
|
144
|
+
if (!factory)
|
|
145
|
+
throw new Error(
|
|
146
|
+
"worldline: WorldlineModule global was not defined by the script"
|
|
147
|
+
);
|
|
148
|
+
return factory({ locateFile: (f) => baseUrl + f });
|
|
149
|
+
};
|
|
150
|
+
let promise;
|
|
151
|
+
if (typeof document !== "undefined") {
|
|
152
|
+
promise = injectScript(scriptUrl).then(instantiate);
|
|
153
|
+
} else if (typeof globalThis.importScripts === "function") {
|
|
154
|
+
promise = Promise.resolve().then(() => {
|
|
155
|
+
globalThis.importScripts(scriptUrl);
|
|
156
|
+
return instantiate();
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
return Promise.reject(
|
|
160
|
+
new Error(
|
|
161
|
+
"Worldline.load requires a DOM or a classic Web Worker (importScripts) to load worldline.js"
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
moduleCache.set(scriptUrl, promise);
|
|
166
|
+
return promise;
|
|
167
|
+
}
|
|
168
|
+
var Worldline = class _Worldline {
|
|
169
|
+
constructor(wasm) {
|
|
170
|
+
this.wasm = wasm;
|
|
171
|
+
}
|
|
172
|
+
wasm;
|
|
173
|
+
sampleRate = WORLDLINE_SAMPLE_RATE;
|
|
174
|
+
/**
|
|
175
|
+
* Load + instantiate the worldline WASM module (deduped per scriptUrl).
|
|
176
|
+
*
|
|
177
|
+
* Works on the main thread (loads via `<script>`) and inside a classic Web
|
|
178
|
+
* Worker (loads via `importScripts`), so the heavy synthesis can run
|
|
179
|
+
* off-thread. The matching `worldline.wasm` is fetched next to scriptUrl.
|
|
180
|
+
*/
|
|
181
|
+
static async load(options) {
|
|
182
|
+
return new _Worldline(await loadWasm(options.scriptUrl));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Render one note to Float32 PCM at 48 kHz.
|
|
186
|
+
*
|
|
187
|
+
* The output buffer is laid out as [lead-in/consonant ≈ preMs][vowel ≈
|
|
188
|
+
* durationMs], rendered from sample offset 0 (no leading silence). The vowel
|
|
189
|
+
* onset (the "beat") sits at ≈ preMs into the buffer, so a sequencer should
|
|
190
|
+
* place the buffer at `beatTime − preMs` and may trim/crossfade the lead-in.
|
|
191
|
+
*
|
|
192
|
+
* No internal crossfade is applied — apply fades externally.
|
|
193
|
+
*
|
|
194
|
+
* @returns Float32 PCM, or null when `pcm` is shorter than
|
|
195
|
+
* {@link MIN_WORLDLINE_SAMPLES} (too short for stable F0 analysis).
|
|
196
|
+
*/
|
|
197
|
+
renderNote(params) {
|
|
198
|
+
const { pcm, pitch, durationMs, preMs, consonantMs, tempo = 120 } = params;
|
|
199
|
+
if (!pcm || pcm.length < MIN_WORLDLINE_SAMPLES) return null;
|
|
200
|
+
const WL = this.wasm;
|
|
201
|
+
const FS = WORLDLINE_SAMPLE_RATE;
|
|
202
|
+
const midiNote = Math.round(69 + 12 * Math.log2(pitch / 440));
|
|
203
|
+
const posMs = 0;
|
|
204
|
+
const reqLen = preMs + durationMs;
|
|
205
|
+
const cutMs = WL_FRAME_MS * 2;
|
|
206
|
+
const ps = WL._PhraseSynthNew();
|
|
207
|
+
if (!ps) return null;
|
|
208
|
+
const reqPtr = WL._malloc(SYNTH_REQ_SIZE);
|
|
209
|
+
if (!reqPtr) {
|
|
210
|
+
WL._PhraseSynthDelete(ps);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const samplePtr = WL._malloc(pcm.length * 8);
|
|
214
|
+
if (!samplePtr) {
|
|
215
|
+
WL._free(reqPtr);
|
|
216
|
+
WL._PhraseSynthDelete(ps);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
WL.HEAPF64.set(pcm, samplePtr >> 3);
|
|
220
|
+
const sv = (off, val, type) => WL.setValue(reqPtr + off, val, type);
|
|
221
|
+
sv(0, FS, "i32");
|
|
222
|
+
sv(4, pcm.length, "i32");
|
|
223
|
+
sv(8, samplePtr, "*");
|
|
224
|
+
sv(12, 0, "i32");
|
|
225
|
+
sv(16, 0, "*");
|
|
226
|
+
sv(20, midiNote, "i32");
|
|
227
|
+
sv(24, 100, "double");
|
|
228
|
+
sv(32, 0, "double");
|
|
229
|
+
sv(40, reqLen, "double");
|
|
230
|
+
sv(48, consonantMs, "double");
|
|
231
|
+
sv(56, cutMs, "double");
|
|
232
|
+
sv(64, 100, "double");
|
|
233
|
+
sv(72, 0, "double");
|
|
234
|
+
sv(80, tempo, "double");
|
|
235
|
+
sv(88, 0, "i32");
|
|
236
|
+
sv(92, 0, "*");
|
|
237
|
+
sv(96, 0, "i32");
|
|
238
|
+
sv(100, 0, "i32");
|
|
239
|
+
sv(104, 100, "i32");
|
|
240
|
+
sv(108, 0, "i32");
|
|
241
|
+
sv(112, 0, "i32");
|
|
242
|
+
sv(116, 100, "i32");
|
|
243
|
+
WL._PhraseSynthAddRequest(ps, reqPtr, posMs, 0, reqLen, 0, 0, 0);
|
|
244
|
+
WL._free(samplePtr);
|
|
245
|
+
WL._free(reqPtr);
|
|
246
|
+
const totalMs = posMs + reqLen + WL_FRAME_MS * 2;
|
|
247
|
+
const nFrames = Math.ceil(totalMs / WL_FRAME_MS) + 4;
|
|
248
|
+
const f0Arr = new Float64Array(nFrames).fill(pitch);
|
|
249
|
+
const gArr = new Float64Array(nFrames).fill(0.5);
|
|
250
|
+
const tArr = new Float64Array(nFrames).fill(0.5);
|
|
251
|
+
const bArr = new Float64Array(nFrames).fill(0.5);
|
|
252
|
+
const vArr = new Float64Array(nFrames).fill(1);
|
|
253
|
+
const f0Ptr = WL._malloc(nFrames * 8);
|
|
254
|
+
const gPtr = WL._malloc(nFrames * 8);
|
|
255
|
+
const tPtr = WL._malloc(nFrames * 8);
|
|
256
|
+
const bPtr = WL._malloc(nFrames * 8);
|
|
257
|
+
const vPtr = WL._malloc(nFrames * 8);
|
|
258
|
+
if (!f0Ptr || !gPtr || !tPtr || !bPtr || !vPtr) {
|
|
259
|
+
if (f0Ptr) WL._free(f0Ptr);
|
|
260
|
+
if (gPtr) WL._free(gPtr);
|
|
261
|
+
if (tPtr) WL._free(tPtr);
|
|
262
|
+
if (bPtr) WL._free(bPtr);
|
|
263
|
+
if (vPtr) WL._free(vPtr);
|
|
264
|
+
WL._PhraseSynthDelete(ps);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
WL.HEAPF64.set(f0Arr, f0Ptr >> 3);
|
|
268
|
+
WL.HEAPF64.set(gArr, gPtr >> 3);
|
|
269
|
+
WL.HEAPF64.set(tArr, tPtr >> 3);
|
|
270
|
+
WL.HEAPF64.set(bArr, bPtr >> 3);
|
|
271
|
+
WL.HEAPF64.set(vArr, vPtr >> 3);
|
|
272
|
+
WL._PhraseSynthSetCurves(
|
|
273
|
+
ps,
|
|
274
|
+
f0Ptr,
|
|
275
|
+
gPtr,
|
|
276
|
+
tPtr,
|
|
277
|
+
bPtr,
|
|
278
|
+
vPtr,
|
|
279
|
+
nFrames,
|
|
280
|
+
WL_FRAME_MS
|
|
281
|
+
);
|
|
282
|
+
WL._free(f0Ptr);
|
|
283
|
+
WL._free(gPtr);
|
|
284
|
+
WL._free(tPtr);
|
|
285
|
+
WL._free(bPtr);
|
|
286
|
+
WL._free(vPtr);
|
|
287
|
+
const yPtrPtr = WL._malloc(4);
|
|
288
|
+
if (!yPtrPtr) {
|
|
289
|
+
WL._PhraseSynthDelete(ps);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const outLen = WL._PhraseSynthSynth(ps, yPtrPtr, 0);
|
|
293
|
+
const yPtr = WL.getValue(yPtrPtr, "*");
|
|
294
|
+
const audio = outLen > 0 ? new Float32Array(WL.HEAPF32.buffer, yPtr, outLen).slice() : null;
|
|
295
|
+
WL._free(yPtrPtr);
|
|
296
|
+
WL._PhraseSynthDelete(ps);
|
|
297
|
+
return audio;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// src/voice-worker.ts
|
|
302
|
+
var KOE_SAMPLE_RATE = 48e3;
|
|
303
|
+
var midiToFreq = (m) => 440 * 2 ** ((m - 69) / 12);
|
|
304
|
+
var wself = globalThis;
|
|
305
|
+
var bank = null;
|
|
306
|
+
var worldline = null;
|
|
307
|
+
var pcmCache = /* @__PURE__ */ new Map();
|
|
308
|
+
var getPcm = (alias) => {
|
|
309
|
+
let p = pcmCache.get(alias);
|
|
310
|
+
if (!p) {
|
|
311
|
+
p = bank.getPcm(alias);
|
|
312
|
+
pcmCache.set(alias, p);
|
|
313
|
+
}
|
|
314
|
+
return p;
|
|
315
|
+
};
|
|
316
|
+
var renderAlias = async (alias, pitch, durationMs) => {
|
|
317
|
+
if (!bank) return null;
|
|
318
|
+
const pcm = await getPcm(alias);
|
|
319
|
+
if (!pcm || pcm.length === 0) return null;
|
|
320
|
+
const entry = bank.manifest.phonemes[alias];
|
|
321
|
+
const lead = leadInFromEntry(entry);
|
|
322
|
+
const targetHz = midiToFreq(pitch);
|
|
323
|
+
if (worldline) {
|
|
324
|
+
const audio = worldline.renderNote({
|
|
325
|
+
pcm,
|
|
326
|
+
pitch: targetHz,
|
|
327
|
+
durationMs,
|
|
328
|
+
...lead
|
|
329
|
+
});
|
|
330
|
+
if (audio) return { pcm: audio, preSec: lead.preMs / 1e3, rate: 1 };
|
|
331
|
+
}
|
|
332
|
+
const rate = entry.pitch > 0 ? targetHz / entry.pitch : 1;
|
|
333
|
+
return {
|
|
334
|
+
pcm: Float32Array.from(pcm),
|
|
335
|
+
preSec: entry.pre / KOE_SAMPLE_RATE / rate,
|
|
336
|
+
rate
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
wself.onmessage = async (ev) => {
|
|
340
|
+
const msg = ev.data;
|
|
341
|
+
if (msg.type === "init") {
|
|
342
|
+
try {
|
|
343
|
+
bank = await VoiceBank.load(msg.koe);
|
|
344
|
+
worldline = msg.lightweight ? null : await Worldline.load({ scriptUrl: msg.worldlineScriptUrl }).catch(
|
|
345
|
+
() => null
|
|
346
|
+
);
|
|
347
|
+
wself.postMessage({
|
|
348
|
+
type: "ready",
|
|
349
|
+
aliases: Object.keys(bank.manifest.phonemes)
|
|
350
|
+
});
|
|
351
|
+
} catch (err) {
|
|
352
|
+
wself.postMessage({
|
|
353
|
+
type: "error",
|
|
354
|
+
message: String(err?.message ?? err)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (msg.type === "render") {
|
|
360
|
+
const { id, alias, pitch, durationMs } = msg;
|
|
361
|
+
try {
|
|
362
|
+
const out = await renderAlias(alias, pitch, durationMs);
|
|
363
|
+
if (out) {
|
|
364
|
+
wself.postMessage(
|
|
365
|
+
{
|
|
366
|
+
type: "rendered",
|
|
367
|
+
id,
|
|
368
|
+
pcm: out.pcm,
|
|
369
|
+
preSec: out.preSec,
|
|
370
|
+
rate: out.rate
|
|
371
|
+
},
|
|
372
|
+
[out.pcm.buffer]
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
wself.postMessage({ type: "rendered", id, pcm: null });
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
wself.postMessage({ type: "rendered", id, pcm: null });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
})();
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"author": "onjmin",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"name": "@onjmin/dtm",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.4",
|
|
6
6
|
"description": "MMLを中間言語に用いた、モバイルファーストなDAW / ピアノロール打ち込みコンポーネント",
|
|
7
7
|
"homepage": "https://onjmin.github.io/dtm",
|
|
8
8
|
"repository": {
|
|
@@ -48,12 +48,15 @@
|
|
|
48
48
|
"rimraf": "6.0.1",
|
|
49
49
|
"tsup": "8.5.0",
|
|
50
50
|
"tsx": "4.20.6",
|
|
51
|
-
"typedoc": "0.28.
|
|
51
|
+
"typedoc": "0.28.19",
|
|
52
52
|
"typescript": "5.8.3"
|
|
53
53
|
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@onjmin/koe": "1.0.3"
|
|
56
|
+
},
|
|
54
57
|
"scripts": {
|
|
55
58
|
"dev": "rimraf dist && tsup && biome format src --write && tsx demo/server.ts",
|
|
56
|
-
"build": "rimraf dist && tsup && biome
|
|
59
|
+
"build": "rimraf dist && tsup && biome format src --write",
|
|
57
60
|
"patch": "pnpm version patch",
|
|
58
61
|
"publish-dangerous-dont-use-this-command": "pnpm publish --access public"
|
|
59
62
|
}
|