retrotalk 2.1.0
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 -0
- package/README.md +45 -0
- package/dist/retrotalk.js +2108 -0
- package/dist/retrotalk.min.js +8 -0
- package/package.json +34 -0
- package/src/aquestalk.js +247 -0
- package/src/global.js +10 -0
- package/src/index.js +36 -0
- package/src/kana.js +388 -0
- package/src/simple_audio.js +163 -0
- package/src/sing.js +126 -0
- package/src/talk.js +2094 -0
- package/src/talk_audio.js +275 -0
- package/src/version.js +10 -0
- package/src/voicevox.js +245 -0
- package/src/wav.js +59 -0
|
@@ -0,0 +1,2108 @@
|
|
|
1
|
+
/*! RetroTalk v2.1.0 | MIT License | https://github.com/harayoki/retrotalk */
|
|
2
|
+
(() => {
|
|
3
|
+
// src/simple_audio.js
|
|
4
|
+
var SimpleAudio = class {
|
|
5
|
+
/**
|
|
6
|
+
* @param {AudioContext} ctx 呼び出し側で作った AudioContext。
|
|
7
|
+
* これが無いと再生できません(`unlock()` を参照)
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {number} [opts.maxVoices=8] 同時に再生できる音の数
|
|
10
|
+
*/
|
|
11
|
+
constructor(ctx, opts = {}) {
|
|
12
|
+
this.ctx = ctx || null;
|
|
13
|
+
this.seVoices = [];
|
|
14
|
+
this.maxVoices = opts.maxVoices ?? 8;
|
|
15
|
+
this.bgmVoices = 0;
|
|
16
|
+
this._seSeq = 0;
|
|
17
|
+
this._sePausedAll = false;
|
|
18
|
+
this._keepSec = 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 再生できる状態にします。
|
|
22
|
+
*
|
|
23
|
+
* ユーザーがページを一度操作したあとに呼びます。ブラウザの制限によるものです。
|
|
24
|
+
* `talk()` と `playTalk()` は内部で呼ぶので、通常は呼び出す必要がありません。
|
|
25
|
+
*
|
|
26
|
+
* AudioContext はここでは作りません。呼び出し側で作って渡してください。
|
|
27
|
+
*
|
|
28
|
+
* ```js
|
|
29
|
+
* const voice = new TalkAudio(new AudioContext());
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ブラウザが同時に作れる AudioContext の数には上限があり、
|
|
33
|
+
* ライブラリがそれぞれ勝手に作ると足りなくなります。
|
|
34
|
+
*
|
|
35
|
+
* @throws {Error} AudioContext が渡されていないとき
|
|
36
|
+
*/
|
|
37
|
+
unlock() {
|
|
38
|
+
if (!this.ctx) {
|
|
39
|
+
throw new Error("SimpleAudio: AudioContext を渡してください — new TalkAudio(new AudioContext())");
|
|
40
|
+
}
|
|
41
|
+
if (this.ctx.state === "suspended") this.ctx.resume();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 出力の記録を開始します。継承先が実装します。
|
|
45
|
+
* `_out()` から呼ばれるため、ここでは空のまま用意してあります
|
|
46
|
+
*/
|
|
47
|
+
_startTap() {
|
|
48
|
+
}
|
|
49
|
+
/** 再生が終わった音を片づける */
|
|
50
|
+
_cleanupSE(now) {
|
|
51
|
+
this.seVoices = this.seVoices.filter((v) => {
|
|
52
|
+
if (now < v.endTime) return true;
|
|
53
|
+
if (v.left !== 0 && v.timer) return true;
|
|
54
|
+
if (!v.system && (v.paused || this._sePausedAll)) return true;
|
|
55
|
+
if (v.timer) {
|
|
56
|
+
clearTimeout(v.timer);
|
|
57
|
+
v.timer = 0;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
v.gain.disconnect();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** 再生中の音の数 */
|
|
67
|
+
_usedVoices() {
|
|
68
|
+
return this.bgmVoices + this.seVoices.reduce((n, v) => n + v.voices, 0);
|
|
69
|
+
}
|
|
70
|
+
/** 音を 1 つ止める */
|
|
71
|
+
_stopVoice(v) {
|
|
72
|
+
if (v.timer) {
|
|
73
|
+
clearTimeout(v.timer);
|
|
74
|
+
v.timer = 0;
|
|
75
|
+
}
|
|
76
|
+
v.left = 0;
|
|
77
|
+
for (const n of v.nodes) {
|
|
78
|
+
try {
|
|
79
|
+
n.stop(0);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
v.gain.disconnect();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
}
|
|
87
|
+
this.seVoices = this.seVoices.filter((x) => x !== v);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 音の出力先を返します。すべての音がここを通るため、
|
|
91
|
+
* ここ 1 か所で全体の消音と音量を扱えます
|
|
92
|
+
*/
|
|
93
|
+
_out() {
|
|
94
|
+
if (!this.ctx) return null;
|
|
95
|
+
if (!this._bus) {
|
|
96
|
+
this._master = this.ctx.createGain();
|
|
97
|
+
this._master.gain.value = this._muted ? 0 : this.volume;
|
|
98
|
+
this._master.connect(this.ctx.destination);
|
|
99
|
+
this._bus = this.ctx.createGain();
|
|
100
|
+
this._bus.gain.value = 1;
|
|
101
|
+
this._bus.connect(this._master);
|
|
102
|
+
if (this._keepSec > 0) this._startTap(this._keepSec);
|
|
103
|
+
}
|
|
104
|
+
return this._bus;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 消音します。再生は止めずに出力だけを閉じるので、
|
|
108
|
+
* 戻したときは続きから聞こえます。
|
|
109
|
+
*
|
|
110
|
+
* @param {boolean} [on] 省略すると切り替えます
|
|
111
|
+
* @returns {boolean} いま消音しているか
|
|
112
|
+
*/
|
|
113
|
+
mute(on) {
|
|
114
|
+
this._muted = on === void 0 ? !this._muted : !!on;
|
|
115
|
+
this._out();
|
|
116
|
+
const out = this._master;
|
|
117
|
+
if (out) {
|
|
118
|
+
const t = this.ctx.currentTime;
|
|
119
|
+
out.gain.cancelScheduledValues(t);
|
|
120
|
+
out.gain.setValueAtTime(out.gain.value, t);
|
|
121
|
+
out.gain.linearRampToValueAtTime(this._muted ? 0 : this.volume, t + 0.05);
|
|
122
|
+
}
|
|
123
|
+
return this._muted;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 音量です。0 〜 8 で、初期値は 1 です。
|
|
127
|
+
*
|
|
128
|
+
* 1 を超えると増幅します。上げすぎると歪むので、聞きながら決めてください。
|
|
129
|
+
* 消音は `mute()` の役目で、このプロパティは音量だけを扱います。
|
|
130
|
+
*/
|
|
131
|
+
get volume() {
|
|
132
|
+
return this._vol == null ? 1 : this._vol;
|
|
133
|
+
}
|
|
134
|
+
set volume(v) {
|
|
135
|
+
this._vol = Math.max(0, Math.min(8, Number(v) || 0));
|
|
136
|
+
if (this._master && !this._muted) {
|
|
137
|
+
const t = this.ctx.currentTime;
|
|
138
|
+
this._master.gain.cancelScheduledValues(t);
|
|
139
|
+
this._master.gain.setValueAtTime(this._master.gain.value, t);
|
|
140
|
+
this._master.gain.linearRampToValueAtTime(this._vol, t + 0.05);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** いま消音しているか */
|
|
144
|
+
get muted() {
|
|
145
|
+
return !!this._muted;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// src/kana.js
|
|
150
|
+
var KANA = {
|
|
151
|
+
ア: ["", "a"],
|
|
152
|
+
イ: ["", "i"],
|
|
153
|
+
ウ: ["", "u"],
|
|
154
|
+
エ: ["", "e"],
|
|
155
|
+
オ: ["", "o"],
|
|
156
|
+
カ: ["k", "a"],
|
|
157
|
+
キ: ["k", "i"],
|
|
158
|
+
ク: ["k", "u"],
|
|
159
|
+
ケ: ["k", "e"],
|
|
160
|
+
コ: ["k", "o"],
|
|
161
|
+
ガ: ["g", "a"],
|
|
162
|
+
ギ: ["g", "i"],
|
|
163
|
+
グ: ["g", "u"],
|
|
164
|
+
ゲ: ["g", "e"],
|
|
165
|
+
ゴ: ["g", "o"],
|
|
166
|
+
サ: ["s", "a"],
|
|
167
|
+
シ: ["sh", "i"],
|
|
168
|
+
ス: ["s", "u"],
|
|
169
|
+
セ: ["s", "e"],
|
|
170
|
+
ソ: ["s", "o"],
|
|
171
|
+
ザ: ["z", "a"],
|
|
172
|
+
ジ: ["j", "i"],
|
|
173
|
+
ズ: ["z", "u"],
|
|
174
|
+
ゼ: ["z", "e"],
|
|
175
|
+
ゾ: ["z", "o"],
|
|
176
|
+
タ: ["t", "a"],
|
|
177
|
+
チ: ["ch", "i"],
|
|
178
|
+
ツ: ["ts", "u"],
|
|
179
|
+
テ: ["t", "e"],
|
|
180
|
+
ト: ["t", "o"],
|
|
181
|
+
ダ: ["d", "a"],
|
|
182
|
+
ヂ: ["j", "i"],
|
|
183
|
+
ヅ: ["z", "u"],
|
|
184
|
+
デ: ["d", "e"],
|
|
185
|
+
ド: ["d", "o"],
|
|
186
|
+
ナ: ["n", "a"],
|
|
187
|
+
ニ: ["n", "i"],
|
|
188
|
+
ヌ: ["n", "u"],
|
|
189
|
+
ネ: ["n", "e"],
|
|
190
|
+
ノ: ["n", "o"],
|
|
191
|
+
ハ: ["h", "a"],
|
|
192
|
+
ヒ: ["h", "i"],
|
|
193
|
+
フ: ["f", "u"],
|
|
194
|
+
ヘ: ["h", "e"],
|
|
195
|
+
ホ: ["h", "o"],
|
|
196
|
+
バ: ["b", "a"],
|
|
197
|
+
ビ: ["b", "i"],
|
|
198
|
+
ブ: ["b", "u"],
|
|
199
|
+
ベ: ["b", "e"],
|
|
200
|
+
ボ: ["b", "o"],
|
|
201
|
+
パ: ["p", "a"],
|
|
202
|
+
ピ: ["p", "i"],
|
|
203
|
+
プ: ["p", "u"],
|
|
204
|
+
ペ: ["p", "e"],
|
|
205
|
+
ポ: ["p", "o"],
|
|
206
|
+
マ: ["m", "a"],
|
|
207
|
+
ミ: ["m", "i"],
|
|
208
|
+
ム: ["m", "u"],
|
|
209
|
+
メ: ["m", "e"],
|
|
210
|
+
モ: ["m", "o"],
|
|
211
|
+
ヤ: ["y", "a"],
|
|
212
|
+
ユ: ["y", "u"],
|
|
213
|
+
ヨ: ["y", "o"],
|
|
214
|
+
ラ: ["r", "a"],
|
|
215
|
+
リ: ["r", "i"],
|
|
216
|
+
ル: ["r", "u"],
|
|
217
|
+
レ: ["r", "e"],
|
|
218
|
+
ロ: ["r", "o"],
|
|
219
|
+
ワ: ["w", "a"],
|
|
220
|
+
ヲ: ["", "o"],
|
|
221
|
+
ン: ["", "n"],
|
|
222
|
+
// 小書きの「ヮ」。「クヮ」は本来 1 拍ですが、唇を丸めた k は用意していません。
|
|
223
|
+
// ワ と同じ 1 拍として扱い、`( )` でくくれば 1 拍として歌えます
|
|
224
|
+
ヮ: ["w", "a"],
|
|
225
|
+
// 古い仮名。いまは イ エ と同じに読みます。飛ばしても得は無く、
|
|
226
|
+
// 読めるようにしておけば古い綴りの詞をそのまま渡せます
|
|
227
|
+
ヰ: ["", "i"],
|
|
228
|
+
ヱ: ["", "e"],
|
|
229
|
+
ヴ: ["b", "u"]
|
|
230
|
+
};
|
|
231
|
+
var SMALL = { ャ: "a", ュ: "u", ョ: "o", ァ: "a", ィ: "i", ゥ: "u", ェ: "e", ォ: "o" };
|
|
232
|
+
var PALATAL_SMALL = "ャュョ";
|
|
233
|
+
function applySmall(out, ch) {
|
|
234
|
+
if (!out.length) return;
|
|
235
|
+
const last = out[out.length - 1];
|
|
236
|
+
if (PALATAL_SMALL.includes(ch) && last.v === "i") last.pal = true;
|
|
237
|
+
last.v = SMALL[ch];
|
|
238
|
+
}
|
|
239
|
+
var DIGITS = {
|
|
240
|
+
0: "ゼロ",
|
|
241
|
+
1: "イチ",
|
|
242
|
+
2: "ニ",
|
|
243
|
+
3: "サン",
|
|
244
|
+
4: "ヨン",
|
|
245
|
+
5: "ゴ",
|
|
246
|
+
6: "ロク",
|
|
247
|
+
7: "ナナ",
|
|
248
|
+
8: "ハチ",
|
|
249
|
+
9: "キュウ"
|
|
250
|
+
};
|
|
251
|
+
var LATIN = {
|
|
252
|
+
A: "エー",
|
|
253
|
+
B: "ビー",
|
|
254
|
+
C: "シー",
|
|
255
|
+
D: "ディー",
|
|
256
|
+
E: "イー",
|
|
257
|
+
F: "エフ",
|
|
258
|
+
G: "ジー",
|
|
259
|
+
H: "エイチ",
|
|
260
|
+
I: "アイ",
|
|
261
|
+
J: "ジェー",
|
|
262
|
+
K: "ケー",
|
|
263
|
+
L: "エル",
|
|
264
|
+
M: "エム",
|
|
265
|
+
N: "エヌ",
|
|
266
|
+
O: "オー",
|
|
267
|
+
P: "ピー",
|
|
268
|
+
Q: "キュー",
|
|
269
|
+
R: "アール",
|
|
270
|
+
S: "エス",
|
|
271
|
+
T: "ティー",
|
|
272
|
+
U: "ユー",
|
|
273
|
+
V: "ブイ",
|
|
274
|
+
W: "ダブリュー",
|
|
275
|
+
X: "エックス",
|
|
276
|
+
Y: "ワイ",
|
|
277
|
+
Z: "ゼット"
|
|
278
|
+
};
|
|
279
|
+
function toHalf(ch) {
|
|
280
|
+
return ch >= "0" && ch <= "9" || ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 65248) : ch;
|
|
281
|
+
}
|
|
282
|
+
function readingOf(ch) {
|
|
283
|
+
const half = toHalf(ch);
|
|
284
|
+
return DIGITS[half] || LATIN[half.toUpperCase()] || null;
|
|
285
|
+
}
|
|
286
|
+
function toKatakana(text) {
|
|
287
|
+
return text.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 96));
|
|
288
|
+
}
|
|
289
|
+
function checkTalk(text) {
|
|
290
|
+
const s = toKatakana(String(text));
|
|
291
|
+
const unknown = [];
|
|
292
|
+
let kana = 0;
|
|
293
|
+
for (let i = 0; i < s.length; i++) {
|
|
294
|
+
const ch = s[i];
|
|
295
|
+
if (KANA[ch] || SMALL[ch] || ch === "ー" || ch === "ッ") {
|
|
296
|
+
kana++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const said = readingOf(ch);
|
|
300
|
+
if (said) {
|
|
301
|
+
kana += [...said].filter((c) => !SMALL[c] && c !== "ー").length;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (" 、,。!!??…〜~".includes(ch)) continue;
|
|
305
|
+
if ("()()".includes(ch)) continue;
|
|
306
|
+
if (ch === "." && s.slice(i, i + 3) === "...") {
|
|
307
|
+
i += 2;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if ("><><".includes(ch)) continue;
|
|
311
|
+
if ("'‘’'/+;^^__".includes(ch)) continue;
|
|
312
|
+
if (ch === "\n" || ch === "\r" || ch === " ") continue;
|
|
313
|
+
if (ch === "@") {
|
|
314
|
+
const k = s[i + 1];
|
|
315
|
+
let j = i + 2;
|
|
316
|
+
while (j < s.length && s[j] >= "0" && s[j] <= "9") j++;
|
|
317
|
+
if (j > i + 2 && "psvPSV".includes(k)) {
|
|
318
|
+
i = j - 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const kind = /[\u4e00-\u9fff\u3005]/.test(ch) ? "kanji" : "other";
|
|
323
|
+
unknown.push({ ch, at: i, kind });
|
|
324
|
+
}
|
|
325
|
+
return { ok: unknown.length === 0, kana, unknown };
|
|
326
|
+
}
|
|
327
|
+
function parseTalk(text) {
|
|
328
|
+
const s = toKatakana(String(text));
|
|
329
|
+
const out = [];
|
|
330
|
+
let pendingStop = false;
|
|
331
|
+
let pendingVoiced = false;
|
|
332
|
+
let group = 0;
|
|
333
|
+
let lift = false;
|
|
334
|
+
for (let i = 0; i < s.length; i++) {
|
|
335
|
+
const ch = s[i];
|
|
336
|
+
if (ch === "ー") {
|
|
337
|
+
if (out.length) out[out.length - 1].hold += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (ch === "〜" || ch === "~") {
|
|
341
|
+
if (out.length) {
|
|
342
|
+
out[out.length - 1].hold += 1;
|
|
343
|
+
out[out.length - 1].wave = true;
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (ch === "ッ") {
|
|
348
|
+
pendingStop = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (ch === "^" || ch === "^") {
|
|
352
|
+
pendingVoiced = true;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (ch === "(" || ch === "(") {
|
|
356
|
+
group = 1;
|
|
357
|
+
lift = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (ch === ")" || ch === ")") {
|
|
361
|
+
group = 0;
|
|
362
|
+
lift = false;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (ch === "@") {
|
|
366
|
+
const k2 = s[i + 1];
|
|
367
|
+
let j = i + 2, n = "";
|
|
368
|
+
while (j < s.length && s[j] >= "0" && s[j] <= "9") n += s[j++];
|
|
369
|
+
i = j - 1;
|
|
370
|
+
const kind = { p: "pitch", s: "speed", v: "vol" }[String(k2).toLowerCase()];
|
|
371
|
+
if (n && kind) {
|
|
372
|
+
const val = kind === "speed" ? +n / 100 : kind === "vol" ? Math.max(0, Math.min(15, +n)) / 15 : +n;
|
|
373
|
+
out.push({ pause: 1e-4, set: { [kind]: val } });
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (ch === ">" || ch === ">") {
|
|
378
|
+
out.push({ pause: 1e-4, tune: 1 });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (ch === "<" || ch === "<") {
|
|
382
|
+
out.push({ pause: 1e-4, tune: -1 });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (SMALL[ch]) {
|
|
386
|
+
applySmall(out, ch);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === " " || ch === " " || ch === "_" || ch === "_") {
|
|
390
|
+
out.push({ pause: 0.12 });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === "、" || ch === ",") {
|
|
394
|
+
out.push({ pause: 0.12, brk: true, mark: "," });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if ("。!!??…".includes(ch) || ch === "." && s.slice(i, i + 3) === "...") {
|
|
398
|
+
const norm = (c) => c === "!" ? "!" : c === "?" ? "?" : c;
|
|
399
|
+
const a = norm(ch), b = norm(s[i + 1] || "");
|
|
400
|
+
let mark;
|
|
401
|
+
if (a === "!" && b === "?" || a === "?" && b === "!") {
|
|
402
|
+
mark = "!?";
|
|
403
|
+
i++;
|
|
404
|
+
} else if (a === "…") mark = "…";
|
|
405
|
+
else if (a === ".") {
|
|
406
|
+
mark = "…";
|
|
407
|
+
i += 2;
|
|
408
|
+
} else mark = a === "!" ? "!" : a === "?" ? "?" : ".";
|
|
409
|
+
if (mark === "…") while (s[i + 1] === "…") i++;
|
|
410
|
+
out.push({ pause: mark === "…" ? 0.42 : 0.24, brk: true, mark });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const said = readingOf(ch);
|
|
414
|
+
if (said) {
|
|
415
|
+
for (const c of said) {
|
|
416
|
+
if (c === "ー") {
|
|
417
|
+
if (out.length) out[out.length - 1].hold += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (c === "ッ") {
|
|
421
|
+
pendingStop = true;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (SMALL[c]) {
|
|
425
|
+
applySmall(out, c);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const kk = KANA[c];
|
|
429
|
+
out.push({
|
|
430
|
+
c: kk[0],
|
|
431
|
+
v: kk[1],
|
|
432
|
+
hold: 0,
|
|
433
|
+
stop: pendingStop,
|
|
434
|
+
...pendingVoiced ? { devoice: false } : null
|
|
435
|
+
});
|
|
436
|
+
pendingStop = false;
|
|
437
|
+
pendingVoiced = false;
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const k = KANA[ch];
|
|
442
|
+
if (!k) continue;
|
|
443
|
+
out.push({
|
|
444
|
+
c: k[0],
|
|
445
|
+
v: k[1],
|
|
446
|
+
hold: 0,
|
|
447
|
+
stop: pendingStop,
|
|
448
|
+
tie: group > 1,
|
|
449
|
+
lift,
|
|
450
|
+
...pendingVoiced ? { devoice: false } : null
|
|
451
|
+
});
|
|
452
|
+
if (group) group++;
|
|
453
|
+
pendingStop = false;
|
|
454
|
+
pendingVoiced = false;
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/aquestalk.js
|
|
460
|
+
var STEP = 4;
|
|
461
|
+
var AFTER = -1;
|
|
462
|
+
var BREAKS = "、,;";
|
|
463
|
+
var LONG_PAUSE = 0.25;
|
|
464
|
+
var ENDS = "。??!!";
|
|
465
|
+
var tidy = (s) => String(s).replace(/[\u2018\u2019\uff07]/g, "'").replace(/\r\n?/g, "\n");
|
|
466
|
+
var kanaOnly = (s, strict = true) => s.replace(
|
|
467
|
+
strict ? /['\/_\u301c\uff5e><\uff1e\uff1c]/g : /['\/]/g,
|
|
468
|
+
""
|
|
469
|
+
);
|
|
470
|
+
function moraCount(kana) {
|
|
471
|
+
return parseTalk(kanaOnly(kana)).filter((m) => !m.pause && !m.set && !m.tune).length;
|
|
472
|
+
}
|
|
473
|
+
function accentPhrase(moras, acc, tune) {
|
|
474
|
+
const out = [];
|
|
475
|
+
let n = 0;
|
|
476
|
+
const to = (want) => {
|
|
477
|
+
while (tune < want) {
|
|
478
|
+
out.push({ pause: 1e-4, tune: 1 });
|
|
479
|
+
tune++;
|
|
480
|
+
}
|
|
481
|
+
while (tune > want) {
|
|
482
|
+
out.push({ pause: 1e-4, tune: -1 });
|
|
483
|
+
tune--;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
for (const m of moras) {
|
|
487
|
+
if (m.pause || m.set || m.tune) {
|
|
488
|
+
out.push(m);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (n === 0) to(acc === 1 ? STEP : 0);
|
|
492
|
+
else if (n === 1) to(acc === 1 ? AFTER : STEP);
|
|
493
|
+
else if (acc > 1 && n === acc) to(AFTER);
|
|
494
|
+
out.push(m);
|
|
495
|
+
n++;
|
|
496
|
+
}
|
|
497
|
+
return { moras: out, tune };
|
|
498
|
+
}
|
|
499
|
+
function phrase(src, tune, strict = true) {
|
|
500
|
+
const moras = parseTalk(kanaOnly(src, strict));
|
|
501
|
+
const at = src.indexOf("'");
|
|
502
|
+
if (!strict && at < 0) {
|
|
503
|
+
const out = [];
|
|
504
|
+
while (tune > 0) {
|
|
505
|
+
out.push({ pause: 1e-4, tune: -1 });
|
|
506
|
+
tune--;
|
|
507
|
+
}
|
|
508
|
+
while (tune < 0) {
|
|
509
|
+
out.push({ pause: 1e-4, tune: 1 });
|
|
510
|
+
tune++;
|
|
511
|
+
}
|
|
512
|
+
out.push(...moras);
|
|
513
|
+
return { moras: out, tune: 0 };
|
|
514
|
+
}
|
|
515
|
+
const acc = at < 0 ? 0 : moraCount(src.slice(0, at));
|
|
516
|
+
const off = /* @__PURE__ */ new Set();
|
|
517
|
+
for (let i = 0; i < src.length; i++) {
|
|
518
|
+
if (src[i] === "_") off.add(moraCount(src.slice(0, i)));
|
|
519
|
+
}
|
|
520
|
+
let n = 0;
|
|
521
|
+
const marked = strict ? moras.map((m) => m.pause || m.set || m.tune ? m : { ...m, devoice: off.has(n++) }) : moras;
|
|
522
|
+
return accentPhrase(marked, acc, tune);
|
|
523
|
+
}
|
|
524
|
+
function parseAqua(text, opts = {}) {
|
|
525
|
+
const strict = opts.strict !== false;
|
|
526
|
+
const src = tidy(text);
|
|
527
|
+
const out = [];
|
|
528
|
+
let tune = 0;
|
|
529
|
+
let buf = "";
|
|
530
|
+
const flush = (sep) => {
|
|
531
|
+
if (buf.trim()) {
|
|
532
|
+
const got = phrase(buf, tune, strict);
|
|
533
|
+
out.push(...got.moras);
|
|
534
|
+
tune = got.tune;
|
|
535
|
+
}
|
|
536
|
+
buf = "";
|
|
537
|
+
if (sep) {
|
|
538
|
+
if (ENDS.includes(sep)) tune = 0;
|
|
539
|
+
if (sep === ";") out.push({ pause: LONG_PAUSE, brk: true, mark: "," });
|
|
540
|
+
else out.push(...parseTalk(strict && ENDS.includes(sep) ? "。" : sep));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
for (const ch of src) {
|
|
544
|
+
if (ch === "/") {
|
|
545
|
+
flush("");
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (ch === "+") continue;
|
|
549
|
+
if (ch === ";") {
|
|
550
|
+
flush(";");
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (BREAKS.includes(ch)) {
|
|
554
|
+
flush(ch);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (ENDS.includes(ch)) {
|
|
558
|
+
flush(ch === "?" ? "?" : ch === "!" ? "!" : ch);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (ch === " " || ch === " " || ch === "\n") {
|
|
562
|
+
flush(" ");
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
buf += ch;
|
|
566
|
+
}
|
|
567
|
+
flush("");
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
function checkAqua(text) {
|
|
571
|
+
let plain = "";
|
|
572
|
+
for (const ch of tidy(text)) {
|
|
573
|
+
if (ch === "\n") {
|
|
574
|
+
plain += " ";
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (ch === "/" || ch === "_" || ch === "'" || ch === "+") continue;
|
|
578
|
+
plain += BREAKS.includes(ch) ? "、" : ch;
|
|
579
|
+
}
|
|
580
|
+
return checkTalk(plain);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/voicevox.js
|
|
584
|
+
function queries(data) {
|
|
585
|
+
const talk = data && data.talk;
|
|
586
|
+
if (talk && talk.audioItems) {
|
|
587
|
+
const keys = talk.audioKeys || Object.keys(talk.audioItems);
|
|
588
|
+
return keys.map((k) => talk.audioItems[k]).filter((it) => it && it.query).map((it) => it.query);
|
|
589
|
+
}
|
|
590
|
+
if (data && (data.accentPhrases || data.accent_phrases)) return [data];
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
var moraSec = (mo) => (+mo.consonantLength || +mo.consonant_length || 0) + (+mo.vowelLength || +mo.vowel_length || 0);
|
|
594
|
+
var pick = (o, a, b, def) => {
|
|
595
|
+
const v = o[a] ?? o[b];
|
|
596
|
+
return v === void 0 || v === null ? def : v;
|
|
597
|
+
};
|
|
598
|
+
function flatten(list, meta) {
|
|
599
|
+
const out = [];
|
|
600
|
+
for (const q of list) {
|
|
601
|
+
const speedScale = meta ? +pick(q, "speedScale", "speed_scale", 1) || 1 : 1;
|
|
602
|
+
const pitchScale = meta ? +pick(q, "pitchScale", "pitch_scale", 0) || 0 : 0;
|
|
603
|
+
const intoScale = meta ? +pick(q, "intonationScale", "intonation_scale", 1) : 1;
|
|
604
|
+
const pauseScale = meta ? +pick(q, "pauseLengthScale", "pause_length_scale", 1) || 1 : 1;
|
|
605
|
+
const pre = meta ? +pick(q, "prePhonemeLength", "pre_phoneme_length", 0) || 0 : 0;
|
|
606
|
+
const post = meta ? +pick(q, "postPhonemeLength", "post_phoneme_length", 0) || 0 : 0;
|
|
607
|
+
const phrases = q.accentPhrases || q.accent_phrases || [];
|
|
608
|
+
const said = [];
|
|
609
|
+
for (const ph of phrases) {
|
|
610
|
+
for (const mo of ph.moras || []) if (+mo.pitch > 0) said.push(+mo.pitch);
|
|
611
|
+
}
|
|
612
|
+
const mid = said.length ? said.reduce((a, b) => a + b, 0) / said.length : 0;
|
|
613
|
+
const into = intoScale >= 0 ? intoScale : 1;
|
|
614
|
+
if (pre > 0) out.push({ gap: pre / speedScale });
|
|
615
|
+
for (const ph of phrases) {
|
|
616
|
+
const moras = ph.moras || [];
|
|
617
|
+
for (const mo of moras) {
|
|
618
|
+
const p = +mo.pitch || 0;
|
|
619
|
+
out.push({
|
|
620
|
+
kana: String(mo.text || ""),
|
|
621
|
+
// `pitchScale` はオクターブなので、対数へ直してから足す
|
|
622
|
+
pitch: p > 0 ? mid + (p - mid) * into + pitchScale * Math.LN2 : 0,
|
|
623
|
+
sec: moraSec(mo) / speedScale
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const pau = ph.pauseMora || ph.pause_mora;
|
|
627
|
+
if (pau) out.push({ gap: moraSec(pau) * pauseScale / speedScale });
|
|
628
|
+
}
|
|
629
|
+
if (post > 0) out.push({ gap: post / speedScale });
|
|
630
|
+
out.push({ end: true });
|
|
631
|
+
}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
function voicevoxText(src, opts = {}) {
|
|
635
|
+
let data = src;
|
|
636
|
+
if (typeof data === "string") {
|
|
637
|
+
try {
|
|
638
|
+
data = JSON.parse(data);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
data = null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const list = queries(data);
|
|
644
|
+
if (!list.length) {
|
|
645
|
+
console.warn("[RetroTalk] VOICEVOX の読みが見つかりません(.vvproj の talk か、/audio_query の返しを渡してください)");
|
|
646
|
+
return "";
|
|
647
|
+
}
|
|
648
|
+
const usePitch = opts.usePitch !== false;
|
|
649
|
+
const useLength = opts.useLength !== false;
|
|
650
|
+
const flat = flatten(list, opts.useMeta !== false);
|
|
651
|
+
const base0 = +opts.speed > 0 ? +opts.speed : 1;
|
|
652
|
+
const secs = flat.filter((f) => f.sec > 0).map((f) => f.sec);
|
|
653
|
+
const midSec = secs.length ? secs.reduce((a, b) => a + b, 0) / secs.length : 0;
|
|
654
|
+
const toHz = (p) => Math.max(50, Math.min(800, Math.round(Math.exp(p))));
|
|
655
|
+
const toSpeed = (sec) => Math.round(100 * Math.max(0.6, Math.min(
|
|
656
|
+
1.8,
|
|
657
|
+
base0 * Math.sqrt(midSec / sec)
|
|
658
|
+
)));
|
|
659
|
+
let out = "";
|
|
660
|
+
let speed = Math.round(base0 * 100);
|
|
661
|
+
let hz = 0;
|
|
662
|
+
for (const f of flat) {
|
|
663
|
+
if (f.end) {
|
|
664
|
+
out += "。";
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (f.gap !== void 0) {
|
|
668
|
+
if (!useLength) out += "、";
|
|
669
|
+
else if (f.gap >= 0.2) out += "。";
|
|
670
|
+
else if (f.gap >= 0.08) out += "、";
|
|
671
|
+
else out += " ";
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (usePitch && f.pitch > 0 && toHz(f.pitch) !== hz) {
|
|
675
|
+
hz = toHz(f.pitch);
|
|
676
|
+
out += "@p" + hz;
|
|
677
|
+
}
|
|
678
|
+
if (useLength && midSec > 0 && f.sec > 0) {
|
|
679
|
+
const want = toSpeed(f.sec);
|
|
680
|
+
if (want !== speed) {
|
|
681
|
+
speed = want;
|
|
682
|
+
out += "@s" + speed;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
out += f.kana;
|
|
686
|
+
}
|
|
687
|
+
return out.replace(/[、。 ]*。[、。 ]*/g, "。").replace(/[、 ]*、[、 ]*/g, "、").replace(/ +/g, " ").replace(/^[、。 ]+/, "").trim();
|
|
688
|
+
}
|
|
689
|
+
function voicevoxInfo(src) {
|
|
690
|
+
let data = src;
|
|
691
|
+
if (typeof data === "string") {
|
|
692
|
+
try {
|
|
693
|
+
data = JSON.parse(data);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
data = null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const list = queries(data);
|
|
699
|
+
let moras = 0;
|
|
700
|
+
for (const q of list) {
|
|
701
|
+
for (const ph of q.accentPhrases || q.accent_phrases || []) {
|
|
702
|
+
moras += (ph.moras || []).length;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
ok: list.length > 0 && moras > 0,
|
|
707
|
+
version: String(data && (data.appVersion || data.app_version) || ""),
|
|
708
|
+
lines: list.length,
|
|
709
|
+
moras
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function parseVoicevox(src, opts = {}) {
|
|
713
|
+
return parseTalk(voicevoxText(src, opts));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/sing.js
|
|
717
|
+
var SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
718
|
+
function parseSing(mml) {
|
|
719
|
+
const src = String(mml).toLowerCase();
|
|
720
|
+
const notes = [];
|
|
721
|
+
const warn = [];
|
|
722
|
+
let octave = 4, defLen = 4, tempo = 120, pos = 0, vol = 1, q = 1;
|
|
723
|
+
const add = (list, what) => {
|
|
724
|
+
if (!list.includes(what)) list.push(what);
|
|
725
|
+
};
|
|
726
|
+
const readNumber = () => {
|
|
727
|
+
let n = "";
|
|
728
|
+
while (pos < src.length && src[pos] >= "0" && src[pos] <= "9") n += src[pos++];
|
|
729
|
+
return n === "" ? null : parseInt(n, 10);
|
|
730
|
+
};
|
|
731
|
+
const readDuration = () => {
|
|
732
|
+
const len = readNumber() ?? defLen;
|
|
733
|
+
let d = 240 / tempo / Math.max(1, len);
|
|
734
|
+
let dot = d;
|
|
735
|
+
while (src[pos] === ".") {
|
|
736
|
+
pos++;
|
|
737
|
+
dot /= 2;
|
|
738
|
+
d += dot;
|
|
739
|
+
}
|
|
740
|
+
return d;
|
|
741
|
+
};
|
|
742
|
+
while (pos < src.length) {
|
|
743
|
+
const ch = src[pos++];
|
|
744
|
+
if (" \n \r|".includes(ch)) continue;
|
|
745
|
+
if (SEMI[ch] !== void 0) {
|
|
746
|
+
let semi = SEMI[ch];
|
|
747
|
+
while (src[pos] === "+" || src[pos] === "#") {
|
|
748
|
+
semi++;
|
|
749
|
+
pos++;
|
|
750
|
+
}
|
|
751
|
+
while (src[pos] === "-") {
|
|
752
|
+
semi--;
|
|
753
|
+
pos++;
|
|
754
|
+
}
|
|
755
|
+
const sec = readDuration();
|
|
756
|
+
const midi = (octave + 1) * 12 + semi;
|
|
757
|
+
notes.push({ hz: 440 * Math.pow(2, (midi - 69) / 12), sec, rest: false, vol, q });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (ch === "r") {
|
|
761
|
+
notes.push({ hz: 0, sec: readDuration(), rest: true, vol, q: 1 });
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (ch === "&") {
|
|
765
|
+
while (pos < src.length && " \n \r".includes(src[pos])) pos++;
|
|
766
|
+
if (SEMI[src[pos]] !== void 0 && notes.length) {
|
|
767
|
+
pos++;
|
|
768
|
+
while ("+#-".includes(src[pos])) pos++;
|
|
769
|
+
notes[notes.length - 1].sec += readDuration();
|
|
770
|
+
}
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (ch === "o") {
|
|
774
|
+
octave = Math.max(1, Math.min(8, readNumber() ?? octave));
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (ch === ">") {
|
|
778
|
+
octave = Math.min(8, octave + 1);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (ch === "<") {
|
|
782
|
+
octave = Math.max(1, octave - 1);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (ch === "l") {
|
|
786
|
+
defLen = readNumber() ?? defLen;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (ch === "t") {
|
|
790
|
+
tempo = Math.max(20, readNumber() ?? tempo);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (ch === "@") {
|
|
794
|
+
let kind = "";
|
|
795
|
+
if (src[pos] && src[pos] !== "{") kind = src[pos++];
|
|
796
|
+
if (src[pos] === "{") {
|
|
797
|
+
while (pos < src.length && src[pos] !== "}") pos++;
|
|
798
|
+
pos++;
|
|
799
|
+
} else readNumber();
|
|
800
|
+
add(warn, "@" + kind);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (ch === "'") {
|
|
804
|
+
while (pos < src.length && src[pos] !== "'") pos++;
|
|
805
|
+
pos++;
|
|
806
|
+
readDuration();
|
|
807
|
+
add(warn, "'…'");
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (ch === "v") {
|
|
811
|
+
vol = Math.max(0, Math.min(15, readNumber() ?? 15)) / 15;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (ch === "q") {
|
|
815
|
+
q = Math.max(0, Math.min(8, readNumber() ?? 8)) / 8;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if ("$p".includes(ch)) {
|
|
819
|
+
readNumber();
|
|
820
|
+
add(warn, ch);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if ("[]".includes(ch)) {
|
|
824
|
+
readNumber();
|
|
825
|
+
add(warn, ch);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
add(warn, ch);
|
|
829
|
+
}
|
|
830
|
+
return { notes, warn };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/talk.js
|
|
834
|
+
var TICK = 1006e-7;
|
|
835
|
+
var RATE = Math.round(1 / TICK);
|
|
836
|
+
var FRAME = 0.01;
|
|
837
|
+
var P_MIN = 1;
|
|
838
|
+
var P_MAX = 128;
|
|
839
|
+
var FREQ_A = 136.2;
|
|
840
|
+
var FREQ_K = 0.0552;
|
|
841
|
+
var idxToHz = (idx) => FREQ_A * Math.exp(FREQ_K * idx);
|
|
842
|
+
var hzToIdx = (hz) => Math.log(hz / FREQ_A) / FREQ_K;
|
|
843
|
+
var F_HOME = [62, 0, 57, 47, 32];
|
|
844
|
+
var VOICED_STAGES = [1, 4, 3];
|
|
845
|
+
var B_HOME = [9, 4, 9, 9, 11];
|
|
846
|
+
var B_STEPS = 16;
|
|
847
|
+
var bposToBw = (pos) => 40 * Math.pow(30, pos / (B_STEPS - 1));
|
|
848
|
+
var B_VOICED = [3, 4.5, 6.5];
|
|
849
|
+
var B_NASAL = [3, 8, 9];
|
|
850
|
+
var B_BAR = [3, 13, 14];
|
|
851
|
+
var B_NASALIZED = [3, 7, 9];
|
|
852
|
+
var B_NOISE = [8, 9, 10];
|
|
853
|
+
var B_BURST = [12, 13, 13];
|
|
854
|
+
var B_PARK_NOISE = 15;
|
|
855
|
+
var SLEW_F = 6;
|
|
856
|
+
var SLEW_B = 4;
|
|
857
|
+
var SLEW_P = 2;
|
|
858
|
+
var TALK_STYLES = ["flat", "natural", "adsr", "vibrato"];
|
|
859
|
+
var SING = {
|
|
860
|
+
attack: 6,
|
|
861
|
+
// 立ち上がりに使うフレーム数
|
|
862
|
+
attackDrop: 5,
|
|
863
|
+
// 立ち上がりの頭で下げておく段数
|
|
864
|
+
decay: 10,
|
|
865
|
+
// 立ち上がったあと落ち着くまでのフレーム数
|
|
866
|
+
sustain: 1,
|
|
867
|
+
// 落ち着いたあと下げたままにする段数
|
|
868
|
+
release: 6,
|
|
869
|
+
// 切り際に使うフレーム数
|
|
870
|
+
releaseDrop: 4,
|
|
871
|
+
// 切り際で下げる段数
|
|
872
|
+
// 震えはじめるまで(フレーム)。短い音符では震えないのがこれ。
|
|
873
|
+
// 四分音符(t120 で 50 フレーム)の尻で震えだすと、音符ごとに尾を引いて
|
|
874
|
+
// 「震え」ではなく「ふらつき」に聞こえたので、伸ばす音まで待たせる
|
|
875
|
+
vibWait: 30,
|
|
876
|
+
vibRamp: 16,
|
|
877
|
+
// 震えが本気になるまで(フレーム)
|
|
878
|
+
vibHz: 4.5,
|
|
879
|
+
// 震える速さ。速いほど段の行き来が目立つので、少し遅く
|
|
880
|
+
// 震える幅(セント。半音の 100 分の 1)。
|
|
881
|
+
// 狭くしすぎると震えない。高さは P(周期の刻み)でしか持てないので、
|
|
882
|
+
// 幅が P の 1 目盛りに届かないと、2 つの値を行き来するだけの
|
|
883
|
+
// トリルになってしまう。45 セントで 4 段ほど動く(200Hz あたり)。
|
|
884
|
+
// 30 セントだと段を 2 つ行き来するだけで、震えではなくトリルに聞こえた
|
|
885
|
+
vibCents: 45
|
|
886
|
+
};
|
|
887
|
+
var NATURAL_DROP = 3;
|
|
888
|
+
var NATURAL_BEND = 0.7;
|
|
889
|
+
var NATURAL_HEAD = 0.6;
|
|
890
|
+
function pushBang(out, body, frames, attack) {
|
|
891
|
+
const tail = Math.max(1, Math.round(frames * (1 - 1 / BANG_LONG)));
|
|
892
|
+
const from = frames - tail;
|
|
893
|
+
const hold = Math.round(from * BANG_HOLD);
|
|
894
|
+
for (let k = 0; k < frames; k++) {
|
|
895
|
+
const t = k <= hold ? 0 : Math.min(1, (k - hold) / Math.max(1, attack));
|
|
896
|
+
let amp = body.amp + BANG_ATTACK * (1 - Math.pow(t, 0.6));
|
|
897
|
+
if (k >= from) amp -= BANG_TAIL_DROP * ((k - from + 1) / tail);
|
|
898
|
+
out.push({ ...body, amp: Math.max(1, amp), frames: 1 });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
var BANG_ATTACK = 3;
|
|
902
|
+
var BANG_FRAMES = 5;
|
|
903
|
+
var BANG_HOLD = 0.5;
|
|
904
|
+
var BANG_LONG = 1.2;
|
|
905
|
+
var BANG_PRE_LONG = 1.4;
|
|
906
|
+
var BANG_TAIL_DROP = 6;
|
|
907
|
+
function pushDecay(out, body, frames) {
|
|
908
|
+
for (let k = 0; k < frames; k++) {
|
|
909
|
+
const t = k / Math.max(1, frames - 1);
|
|
910
|
+
const drop = NATURAL_DROP * Math.pow(t, NATURAL_BEND) + NATURAL_HEAD * Math.max(0, 1 - k / 2);
|
|
911
|
+
out.push({ ...body, amp: Math.max(1, body.amp - drop), frames: 1 });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
var WAVE = {
|
|
915
|
+
hz: 4,
|
|
916
|
+
// 揺れる速さ
|
|
917
|
+
// 揺れる幅(セント)。歌の震え(30)より広い。言い方を表すものなので、
|
|
918
|
+
// はっきり揺れないと「伸ばしただけ」に聞こえる。
|
|
919
|
+
// 高さは周期の刻みでしか持てないので、高い声ほど段が粗くなる
|
|
920
|
+
cents: 60,
|
|
921
|
+
ramp: 5,
|
|
922
|
+
// 揺れが本気になるまで(フレーム)
|
|
923
|
+
sag: 2
|
|
924
|
+
// 終わりまでに落とす段数
|
|
925
|
+
};
|
|
926
|
+
function pushWave(out, body, frames, f0, frameSec) {
|
|
927
|
+
for (let k = 0; k < frames; k++) {
|
|
928
|
+
const depth = Math.min(1, (k + 1) / WAVE.ramp);
|
|
929
|
+
const cents = WAVE.cents * depth * Math.sin(2 * Math.PI * WAVE.hz * k * frameSec);
|
|
930
|
+
const P = Math.max(P_MIN, Math.min(
|
|
931
|
+
P_MAX,
|
|
932
|
+
Math.round(1 / (f0 * Math.pow(2, cents / 1200) * TICK))
|
|
933
|
+
));
|
|
934
|
+
const drop = WAVE.sag * (k / Math.max(1, frames - 1));
|
|
935
|
+
out.push({ ...body, P, amp: Math.max(1, Math.round(body.amp - drop)), frames: 1 });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function tri(phase) {
|
|
939
|
+
const t = phase - Math.floor(phase);
|
|
940
|
+
return t < 0.5 ? 4 * t - 1 : 3 - 4 * t;
|
|
941
|
+
}
|
|
942
|
+
function pushSung(out, body, frames, f0, style) {
|
|
943
|
+
const { attackDrop, sustain, releaseDrop } = SING;
|
|
944
|
+
const attack = Math.max(1, Math.min(SING.attack, Math.floor(frames / 3)));
|
|
945
|
+
const release = Math.max(1, Math.min(SING.release, Math.floor(frames / 3)));
|
|
946
|
+
const decay = Math.max(1, Math.min(SING.decay, frames - attack - release));
|
|
947
|
+
for (let k = 0; k < frames; k++) {
|
|
948
|
+
let drop = 0;
|
|
949
|
+
if (k < attack) drop += attackDrop * (1 - (k + 1) / attack);
|
|
950
|
+
else if (k < attack + decay) drop += sustain * ((k - attack + 1) / decay);
|
|
951
|
+
else drop += sustain;
|
|
952
|
+
const left = frames - 1 - k;
|
|
953
|
+
if (left < release) drop += releaseDrop * (1 - left / release);
|
|
954
|
+
let P = body.P;
|
|
955
|
+
if (style === "vibrato" && k >= SING.vibWait) {
|
|
956
|
+
const depth = Math.min(1, (k - SING.vibWait) / SING.vibRamp);
|
|
957
|
+
const cents = SING.vibCents * depth * tri(SING.vibHz * (k - SING.vibWait) * (SING.frameSec || 0.01));
|
|
958
|
+
P = Math.max(P_MIN, Math.min(
|
|
959
|
+
P_MAX,
|
|
960
|
+
Math.round(1 / (f0 * Math.pow(2, cents / 1200) * TICK))
|
|
961
|
+
));
|
|
962
|
+
}
|
|
963
|
+
out.push({ ...body, P, amp: Math.max(1, Math.round(body.amp - drop)), frames: 1 });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
var NOISE_LEVEL = 0.2;
|
|
967
|
+
var SILENCE_DAMP = 0.94;
|
|
968
|
+
var F_SHIFT = 2;
|
|
969
|
+
var FV_LEVEL = 0.25;
|
|
970
|
+
var ASPIRATE = 9;
|
|
971
|
+
var ASPIRATE_FADE = 3;
|
|
972
|
+
var BAR_HZ1 = 200;
|
|
973
|
+
var BAR_AMP = 7;
|
|
974
|
+
var COART = 2;
|
|
975
|
+
var PAL = 2;
|
|
976
|
+
var PAL_NASAL_FRAMES = 6;
|
|
977
|
+
var ASSEMBLY = {
|
|
978
|
+
palNasalFrames: PAL_NASAL_FRAMES,
|
|
979
|
+
nasalAmp: null,
|
|
980
|
+
nasalTail: true,
|
|
981
|
+
nasalizedGlide: true,
|
|
982
|
+
glideAfterNasal: "ramp",
|
|
983
|
+
palF3: "vowel",
|
|
984
|
+
voicedBurstAmp: 12,
|
|
985
|
+
voicedBurstVoice: false,
|
|
986
|
+
voicedGap: "voice",
|
|
987
|
+
barAmp: BAR_AMP,
|
|
988
|
+
barBand: "bar"
|
|
989
|
+
};
|
|
990
|
+
var PALATAL_ALREADY = /* @__PURE__ */ new Set(["sh", "ch", "j", "y"]);
|
|
991
|
+
var palVoiced = (v) => [CONSONANTS2.y.hz[0], CONSONANTS2.y.hz[1], (VOWELS2[v] || VOWELS2.a)[2]];
|
|
992
|
+
var VOICELESS = /* @__PURE__ */ new Set(["k", "s", "t", "h", "p", "sh", "ch", "ts", "f"]);
|
|
993
|
+
var DEVOICED = 8;
|
|
994
|
+
var DEVOICED_FRAMES = 6;
|
|
995
|
+
var BANG_MARKS = /* @__PURE__ */ new Set(["!", "!?"]);
|
|
996
|
+
var LIFT_MARKS = /* @__PURE__ */ new Set(["!", "?", "!?"]);
|
|
997
|
+
function markDevoiced(moras) {
|
|
998
|
+
const out = moras.map(() => false);
|
|
999
|
+
let prev = -1;
|
|
1000
|
+
let phrase2 = [];
|
|
1001
|
+
const keepOne = () => {
|
|
1002
|
+
if (!phrase2.length || phrase2.some((k) => !out[k])) return;
|
|
1003
|
+
out[phrase2[phrase2.length - 1]] = false;
|
|
1004
|
+
};
|
|
1005
|
+
for (let i = 0; i < moras.length; i++) {
|
|
1006
|
+
const m = moras[i];
|
|
1007
|
+
if (m.set || m.tune) continue;
|
|
1008
|
+
if (m.pause) {
|
|
1009
|
+
keepOne();
|
|
1010
|
+
phrase2 = [];
|
|
1011
|
+
prev = -1;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const was = prev;
|
|
1015
|
+
prev = i;
|
|
1016
|
+
phrase2.push(i);
|
|
1017
|
+
if (m.devoice !== void 0) {
|
|
1018
|
+
out[i] = !!m.devoice;
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
if (m.v !== "i" && m.v !== "u") continue;
|
|
1022
|
+
if (m.hold) continue;
|
|
1023
|
+
if (!VOICELESS.has(m.c)) continue;
|
|
1024
|
+
let j = i + 1;
|
|
1025
|
+
while (j < moras.length && (moras[j].set || moras[j].tune)) j++;
|
|
1026
|
+
const next = moras[j];
|
|
1027
|
+
if (next && !next.pause && !VOICELESS.has(next.c)) continue;
|
|
1028
|
+
if (next && next.pause && LIFT_MARKS.has(next.mark)) continue;
|
|
1029
|
+
if (was >= 0 && out[was]) continue;
|
|
1030
|
+
out[i] = true;
|
|
1031
|
+
}
|
|
1032
|
+
keepOne();
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
var VOWELS2 = {
|
|
1036
|
+
a: [648, 1028, 2560],
|
|
1037
|
+
i: [280, 2230, 3e3],
|
|
1038
|
+
u: [320, 1300, 2200],
|
|
1039
|
+
e: [470, 1900, 2550],
|
|
1040
|
+
o: [520, 850, 2500],
|
|
1041
|
+
n: [280, 1200, 2400]
|
|
1042
|
+
// 撥音。鼻へ抜けるので弱く
|
|
1043
|
+
};
|
|
1044
|
+
var NASAL = {
|
|
1045
|
+
m: [280, 1e3, 2200],
|
|
1046
|
+
// 唇。マ行と同じところ
|
|
1047
|
+
n: [280, 1500, 2500],
|
|
1048
|
+
// 舌の先。ナ行と同じところ
|
|
1049
|
+
ng: [280, 1900, 2300]
|
|
1050
|
+
// 舌の奥。カ行と同じところ
|
|
1051
|
+
};
|
|
1052
|
+
function nasalFor(c) {
|
|
1053
|
+
if (c === "m" || c === "p" || c === "b") return NASAL.m;
|
|
1054
|
+
if (c === "k" || c === "g") return NASAL.ng;
|
|
1055
|
+
if (c === "t" || c === "d" || c === "n" || c === "r" || c === "s" || c === "z" || c === "ts" || c === "ch" || c === "j" || c === "sh") return NASAL.n;
|
|
1056
|
+
return VOWELS2.n;
|
|
1057
|
+
}
|
|
1058
|
+
var CONSONANTS2 = {
|
|
1059
|
+
// カ行とタ行は、第 2 フォルマントがほとんど同じ。分けているのは第 3 のほう —
|
|
1060
|
+
// 舌の奥で閉じる音(カ・ガ)は第 2 と第 3 が近寄り、
|
|
1061
|
+
// 舌の先で閉じる音(タ・ダ)は第 3 が高いところから母音へ落ちてくる。
|
|
1062
|
+
// その落ちる音が、息の漏れているあいだに聞こえる(`ASPIRATE`)。
|
|
1063
|
+
// ここを 2500 と 2800 にしていたころは、3 つとも同じに聞こえた(2026-09-03)
|
|
1064
|
+
k: {
|
|
1065
|
+
kind: "burst",
|
|
1066
|
+
closure: 6,
|
|
1067
|
+
frames: 1,
|
|
1068
|
+
gap: 3,
|
|
1069
|
+
hz: [300, 1900, 2300],
|
|
1070
|
+
hzBy: {
|
|
1071
|
+
i: [300, 2900, 3300],
|
|
1072
|
+
e: [300, 2500, 2900],
|
|
1073
|
+
u: [300, 1300, 2200],
|
|
1074
|
+
o: [300, 1200, 2300]
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
// 有声の破裂の一撃は、無声と同じ大きさ(12)で、ノイズだけ。
|
|
1078
|
+
// 一度 14 で声も乗せていた(閉鎖が無音だったころ、「開いた」と分かるのが
|
|
1079
|
+
// 一撃だけだったため)。いまは閉鎖に声があり(`BAR_AMP`)、開いたあとも
|
|
1080
|
+
// 声がすぐ戻る(`gap` を有声で鳴らす)ので、一撃で「開いた」を言う必要が無い。
|
|
1081
|
+
// 声を乗せた大きな一撃は、拗音でイ段の位置に置くと `ギュ` が `シュ` に聞こえた
|
|
1082
|
+
// (2026-09-03)。人の声でも、有声の破裂の一撃は無声より弱い
|
|
1083
|
+
g: {
|
|
1084
|
+
kind: "burst",
|
|
1085
|
+
closure: 4,
|
|
1086
|
+
frames: 1,
|
|
1087
|
+
gap: 1,
|
|
1088
|
+
hz: [300, 1900, 2250],
|
|
1089
|
+
voiced: true,
|
|
1090
|
+
hzBy: {
|
|
1091
|
+
i: [300, 2700, 3100],
|
|
1092
|
+
e: [300, 2400, 2800],
|
|
1093
|
+
u: [300, 1300, 2100],
|
|
1094
|
+
o: [300, 1200, 2200]
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
// タ の息は 1 フレームだけ。破裂の一撃も、そのあとの息も、
|
|
1098
|
+
// 高いところが鳴る(第 3 が 3400)。それを 30ms 続けると `サ` になる —
|
|
1099
|
+
// 実際、母音のあとの `タ` が `サ` に聞こえていた(2026-09-03)。
|
|
1100
|
+
// `サ` は 70ms 続くので、分けているのは長さ。20ms まで詰める
|
|
1101
|
+
t: { kind: "burst", closure: 6, frames: 1, gap: 1, hz: [300, 1750, 3400] },
|
|
1102
|
+
d: { kind: "burst", closure: 4, frames: 1, gap: 1, hz: [300, 1700, 3100], voiced: true },
|
|
1103
|
+
p: { kind: "burst", closure: 6, frames: 1, gap: 1, hz: [300, 900, 2300] },
|
|
1104
|
+
b: { kind: "burst", closure: 4, frames: 1, gap: 1, hz: [300, 900, 2200], voiced: true },
|
|
1105
|
+
// サ行・シャ行は大きい。こすれる音のうち、この 2 つは山が高いところに
|
|
1106
|
+
// あって母音と紛れないので、大きくしても母音へ溶けない(ハ行とは逆)。
|
|
1107
|
+
// 既定(9)では母音より 11dB 下で、弱いと言われた(2026-09-03)
|
|
1108
|
+
s: { kind: "noise", frames: 7, amp: 11, hz: [500, 3400, 4e3] },
|
|
1109
|
+
z: { kind: "noise", frames: 4, hz: [400, 3e3, 3600], voiced: true },
|
|
1110
|
+
sh: { kind: "noise", frames: 7, amp: 11, hz: [500, 2300, 3100] },
|
|
1111
|
+
j: { kind: "noise", frames: 4, hz: [400, 2300, 3e3], voiced: true },
|
|
1112
|
+
ch: { kind: "noise", closure: 4, frames: 5, gap: 1, hz: [500, 2400, 3400] },
|
|
1113
|
+
ts: { kind: "burst", closure: 5, frames: 3, gap: 1, hz: [500, 3300, 3900] },
|
|
1114
|
+
// ハ行は息だけの音なので、幅を広く取る(`wide`)。
|
|
1115
|
+
//
|
|
1116
|
+
// サ行と違って山が母音と同じあたりにあるので、狭い幅で鳴らすと
|
|
1117
|
+
// ささやいた母音になる。実際「アハ」が「アア」と聞こえていた(2026-09-03)。
|
|
1118
|
+
// 広げると帯ではなく息に聞こえる。
|
|
1119
|
+
//
|
|
1120
|
+
// 大きさを上げてはいけない。同じ形で母音に近い大きさにすると、
|
|
1121
|
+
// ますますひと続きの母音になる(11 まで上げて悪くなった)
|
|
1122
|
+
h: {
|
|
1123
|
+
kind: "noise",
|
|
1124
|
+
frames: 6,
|
|
1125
|
+
wide: true,
|
|
1126
|
+
hz: [600, 1500, 2400],
|
|
1127
|
+
hzBy: { i: [300, 2800, 3300] }
|
|
1128
|
+
},
|
|
1129
|
+
// ヒ は口の前のほうでこすれる
|
|
1130
|
+
f: { kind: "noise", frames: 6, wide: true, hz: [500, 1600, 2500] },
|
|
1131
|
+
// 鼻に抜ける音は小さい。口を閉じていて、音は鼻からしか出ない。
|
|
1132
|
+
// 有声の既定(11)のままだと母音とほとんど同じ大きさなので、
|
|
1133
|
+
// 言葉の切れ目にならない — 「ギュウニュウ」が 490ms ぶっ通しで鳴って、
|
|
1134
|
+
// 言葉というより効果音に聞こえた(2026-09-03)。母音より 5 段(12dB)下げる
|
|
1135
|
+
// 鼻音は、はっきり鳴って長い。閉じているのは口だけで、鼻からは出ている。
|
|
1136
|
+
// 有声の破裂音(閉鎖はほとんど無音)と分けているのはここなので、
|
|
1137
|
+
// 弱くも短くもしない — 弱くすると破裂音に寄り、実際「ニュ」が
|
|
1138
|
+
// 「ドゥ」に聞こえたことがある(2026-09-03)。
|
|
1139
|
+
//
|
|
1140
|
+
// 長さは触らない。「`ニュ` は `グ` より長い、短くできない」という
|
|
1141
|
+
// 自分の口での確かめを受けて 60ms・80ms を試したが、
|
|
1142
|
+
// どちらも「むしろ変」だった(2026-09-03)。
|
|
1143
|
+
// 人の発声がそうでも、この音では不自然になる。40ms のまま。
|
|
1144
|
+
//
|
|
1145
|
+
// 拗音(`ニャ` `ニュ` `ニョ`)は渡りの位置で鳴らす(`palVoiced`)。
|
|
1146
|
+
// ここに `hzBy: { i: [280, 2100, 2500] }` を置いて試したら、
|
|
1147
|
+
// 鼻音が母音と同じ大きさになった(-5dB → +0.5dB。第 2 が 2kHz を越えると
|
|
1148
|
+
// 置きっぱなしの 2 段と重なって明るくなる)。鼻音は暗く小さくなければならない
|
|
1149
|
+
n: { kind: "voiced", frames: 4, amp: 10, nasal: true, hz: [280, 1500, 2500] },
|
|
1150
|
+
m: { kind: "voiced", frames: 4, amp: 10, nasal: true, hz: [280, 1e3, 2300] },
|
|
1151
|
+
r: { kind: "voiced", frames: 2, hz: [400, 1400, 1800] },
|
|
1152
|
+
y: { kind: "voiced", frames: 3, hz: [300, 2100, 2900] },
|
|
1153
|
+
w: { kind: "voiced", frames: 3, hz: [320, 800, 2200] }
|
|
1154
|
+
};
|
|
1155
|
+
var TALK_DEFAULTS = {
|
|
1156
|
+
pitch: 280,
|
|
1157
|
+
// 声の高さ(Hz)
|
|
1158
|
+
pitchShift: 0,
|
|
1159
|
+
// 文の中の `@p` ごと上げ下げする量(半音)
|
|
1160
|
+
tuneStep: 1,
|
|
1161
|
+
// `>` `<` 1 つで動く量(半音)
|
|
1162
|
+
volume: 10,
|
|
1163
|
+
// `@v` を書かなかったときの音量(0〜15)
|
|
1164
|
+
volumeShift: 0,
|
|
1165
|
+
// 文の中の `@v` ごと上げ下げする量(段。`@v` と同じ 0〜15 の目盛り)
|
|
1166
|
+
speedShift: 1,
|
|
1167
|
+
// 文の中の `@s` ごと掛ける倍率
|
|
1168
|
+
speed: 1,
|
|
1169
|
+
// しゃべる速さ
|
|
1170
|
+
fall: 4,
|
|
1171
|
+
// 語尾へ下がる量(半音)
|
|
1172
|
+
rise: 1,
|
|
1173
|
+
// 出だしで上がる量(半音)
|
|
1174
|
+
formantShift: F_SHIFT,
|
|
1175
|
+
bandShift: 0,
|
|
1176
|
+
// 共鳴の鋭さ
|
|
1177
|
+
breath: 0,
|
|
1178
|
+
// 息の混じり
|
|
1179
|
+
jitter: 0,
|
|
1180
|
+
// 周期のゆらぎ
|
|
1181
|
+
ring: 0,
|
|
1182
|
+
// 掛け合わせる音の高さ(Hz)
|
|
1183
|
+
interp: false,
|
|
1184
|
+
// フレーム間を埋めるか
|
|
1185
|
+
frame: FRAME,
|
|
1186
|
+
// 1 フレームの長さ(秒)
|
|
1187
|
+
bits: 0,
|
|
1188
|
+
// 量子化のビット数(0 で無し)
|
|
1189
|
+
style: "flat",
|
|
1190
|
+
slewF: SLEW_F,
|
|
1191
|
+
slewB: SLEW_B,
|
|
1192
|
+
slewP: SLEW_P,
|
|
1193
|
+
lag: 0,
|
|
1194
|
+
lagPerMora: 0,
|
|
1195
|
+
seed: 1,
|
|
1196
|
+
gainNorm: true,
|
|
1197
|
+
level: 0.15
|
|
1198
|
+
};
|
|
1199
|
+
var D = TALK_DEFAULTS;
|
|
1200
|
+
function makeRandom(seed) {
|
|
1201
|
+
let s = seed | 0 || 2654435769;
|
|
1202
|
+
return () => {
|
|
1203
|
+
s ^= s << 13;
|
|
1204
|
+
s ^= s >>> 17;
|
|
1205
|
+
s ^= s << 5;
|
|
1206
|
+
return (s >>> 0) / 4294967296 * 2 - 1;
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
function toParams(hz, shift = 0) {
|
|
1210
|
+
const p = [shift, shift, shift, shift, shift];
|
|
1211
|
+
VOICED_STAGES.forEach((stage, k) => {
|
|
1212
|
+
p[stage] = hzToIdx(hz[k]) - F_HOME[stage] + shift;
|
|
1213
|
+
});
|
|
1214
|
+
return p;
|
|
1215
|
+
}
|
|
1216
|
+
function toBpos(noise, wide, voicedKind) {
|
|
1217
|
+
const bp = noise ? B_HOME.map(() => B_PARK_NOISE) : B_HOME.slice();
|
|
1218
|
+
const table = noise ? wide ? B_BURST : B_NOISE : voicedKind === "nasal" ? B_NASAL : voicedKind === "bar" ? B_BAR : voicedKind === "nasalized" ? B_NASALIZED : B_VOICED;
|
|
1219
|
+
VOICED_STAGES.forEach((stage, k) => {
|
|
1220
|
+
bp[stage] = table[k];
|
|
1221
|
+
});
|
|
1222
|
+
return bp;
|
|
1223
|
+
}
|
|
1224
|
+
function buildTargets(moras, opts) {
|
|
1225
|
+
const frame = opts.frame ?? D.frame;
|
|
1226
|
+
const A = { ...ASSEMBLY, ...opts.assembly || {} };
|
|
1227
|
+
const auto = opts.format !== "voicevox";
|
|
1228
|
+
const fall = auto ? opts.fall ?? D.fall : 0;
|
|
1229
|
+
const rise = auto ? opts.rise ?? D.rise : 0;
|
|
1230
|
+
const out = [];
|
|
1231
|
+
const pShift = Math.pow(2, (opts.pitchShift ?? D.pitchShift) / 12);
|
|
1232
|
+
let pitch = (opts.pitch ?? D.pitch) * pShift;
|
|
1233
|
+
const sMul = Math.max(0.05, opts.speedShift ?? D.speedShift);
|
|
1234
|
+
const tuneStep = opts.tuneStep ?? D.tuneStep;
|
|
1235
|
+
let speed = (opts.speed ?? D.speed) * sMul;
|
|
1236
|
+
const vAdd = (opts.volumeShift ?? D.volumeShift) / 15;
|
|
1237
|
+
let vol = Math.max(0, Math.min(15, opts.volume ?? D.volume)) / 15;
|
|
1238
|
+
const withShift = (v) => Math.max(0, Math.min(1, v + vAdd));
|
|
1239
|
+
const volOf = [];
|
|
1240
|
+
let postGap = 0;
|
|
1241
|
+
let tied = null, tiedShare = 0, tiedLeft = 0, tiedRest = 0;
|
|
1242
|
+
const takeNote = (m, mi) => {
|
|
1243
|
+
if (m.tie && tied) {
|
|
1244
|
+
tiedLeft--;
|
|
1245
|
+
const frames = tiedLeft > 0 ? tiedShare : tiedRest;
|
|
1246
|
+
if (tiedLeft > 0) tiedRest -= tiedShare;
|
|
1247
|
+
return { ...tied, frames, gap: tiedLeft > 0 ? 0 : tied.gap };
|
|
1248
|
+
}
|
|
1249
|
+
const raw = notes[noteAt++];
|
|
1250
|
+
if (!raw) return null;
|
|
1251
|
+
let n = 1;
|
|
1252
|
+
for (let k = mi + 1; k < moras.length; k++) {
|
|
1253
|
+
const q = moras[k];
|
|
1254
|
+
if (q.set || q.tune) continue;
|
|
1255
|
+
if (q.pause || !q.tie) break;
|
|
1256
|
+
n++;
|
|
1257
|
+
}
|
|
1258
|
+
if (n < 2) {
|
|
1259
|
+
tied = null;
|
|
1260
|
+
return raw;
|
|
1261
|
+
}
|
|
1262
|
+
tiedShare = Math.max(1, Math.floor(raw.frames / (n + 1)));
|
|
1263
|
+
tiedLeft = n - 1;
|
|
1264
|
+
tiedRest = raw.frames - tiedShare;
|
|
1265
|
+
tied = raw;
|
|
1266
|
+
return { ...raw, frames: tiedShare, gap: 0 };
|
|
1267
|
+
};
|
|
1268
|
+
const perTick = D.frame / frame;
|
|
1269
|
+
const nf = (n) => Math.max(1, Math.round(n * perTick / speed));
|
|
1270
|
+
const nfSec = (sec) => Math.max(1, Math.round(sec / frame / speed));
|
|
1271
|
+
const BANG_LIFT = 2;
|
|
1272
|
+
const SHAPE = {
|
|
1273
|
+
".": { lift: 0, up: rise, down: fall },
|
|
1274
|
+
// `!` は、最後の 1 字だけ上がる。そこまではふつうの文と同じに読む。
|
|
1275
|
+
//
|
|
1276
|
+
// 文まるごと持ち上げていたころは、出だしから裏返って聞こえた。
|
|
1277
|
+
// 上がり下がりまで止めたら、こんどは手前がずっと平らになった
|
|
1278
|
+
// (2026-09-04)。上げるのは尻(`TAIL_UP`)、手前は言い切る文と同じでよい。
|
|
1279
|
+
// まるごと張りたいときは `( )` でくくる
|
|
1280
|
+
"!": { lift: 0, up: rise, down: fall },
|
|
1281
|
+
"?": { lift: 0, up: rise, down: fall },
|
|
1282
|
+
// `!?` は文まるごとは上げない。上げると出だしから裏返って聞こえる —
|
|
1283
|
+
// 「ソウナノ!?」の `ソ` からうわずっていた(2026-09-04)。
|
|
1284
|
+
// 驚きは語尾で出す(下の `TAIL_UP`)
|
|
1285
|
+
"!?": { lift: 0, up: rise, down: fall },
|
|
1286
|
+
"…": { lift: 0, up: 0, down: fall * 1.3 }
|
|
1287
|
+
};
|
|
1288
|
+
const GROUP_SHAPE = {
|
|
1289
|
+
"!": () => 0,
|
|
1290
|
+
// 段々に上げない。くくった中はまるごと同じだけ上がる。
|
|
1291
|
+
// 上げていくと、字を追うごとに高くなって階段に聞こえた(2026-09-05)
|
|
1292
|
+
"?": () => Math.max(2, fall),
|
|
1293
|
+
"!?": () => Math.max(2, fall)
|
|
1294
|
+
};
|
|
1295
|
+
const TAIL_UP = {
|
|
1296
|
+
// `?` の 3 分の 1。張りと長さだけでは `。` と聞き分けられなかったが、
|
|
1297
|
+
// `?` に近いところまで上げると尋ねているように聞こえる
|
|
1298
|
+
"!": [Math.max(1, fall / 3)],
|
|
1299
|
+
"?": [Math.max(2, fall)],
|
|
1300
|
+
"!?": [Math.max(2, fall)]
|
|
1301
|
+
};
|
|
1302
|
+
const PUSH = { "!": 2, "!?": 3, "…": -2 };
|
|
1303
|
+
let tune = 0;
|
|
1304
|
+
const TUNE_MAX = 12;
|
|
1305
|
+
const f0at = (t, mark, tail, flat) => {
|
|
1306
|
+
const ups = TAIL_UP[mark];
|
|
1307
|
+
if (ups && tail && tail <= ups.length) {
|
|
1308
|
+
return pitch * Math.pow(2, (ups[ups.length - tail] + tune) / 12);
|
|
1309
|
+
}
|
|
1310
|
+
const sh = SHAPE[mark] || SHAPE["."];
|
|
1311
|
+
const base = sh.lift + (flat ? t < 0.15 ? 0 : -sh.down * ((t - 0.15) / 0.85) : t < 0.15 ? sh.up * (t / 0.15) : sh.up - (sh.up + sh.down) * ((t - 0.15) / 0.85));
|
|
1312
|
+
return pitch * Math.pow(2, (base + tune) / 12);
|
|
1313
|
+
};
|
|
1314
|
+
let notes = opts.notes || null;
|
|
1315
|
+
if (!notes && opts.sing) {
|
|
1316
|
+
const got = parseSing(opts.sing);
|
|
1317
|
+
if (got.warn.length) {
|
|
1318
|
+
console.warn("[RetroTalk] 歌の音符に読めないものがあります — " + got.warn.join(" ") + "(使えるのは cdefgab r + # - 数字 . l o > < t &)");
|
|
1319
|
+
}
|
|
1320
|
+
notes = got.notes.map((n) => {
|
|
1321
|
+
const all = Math.max(1, Math.round(n.sec / frame));
|
|
1322
|
+
const on = Math.max(1, Math.round(all * (n.q ?? 1)));
|
|
1323
|
+
return { hz: n.hz, rest: n.rest, vol: n.vol, frames: on, gap: all - on };
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
const shift = opts.formantShift ?? D.formantShift;
|
|
1327
|
+
let style = opts.style ?? D.style;
|
|
1328
|
+
if (!TALK_STYLES.includes(style)) {
|
|
1329
|
+
console.warn(`[RetroTalk] 音の形に ${style} はありません(${TALK_STYLES.join(" / ")})`);
|
|
1330
|
+
style = "flat";
|
|
1331
|
+
}
|
|
1332
|
+
SING.frameSec = frame;
|
|
1333
|
+
let noteAt = 0;
|
|
1334
|
+
const ts = [];
|
|
1335
|
+
const mk = [];
|
|
1336
|
+
const tl = [];
|
|
1337
|
+
{
|
|
1338
|
+
let head = 0;
|
|
1339
|
+
const spread = (end, mark) => {
|
|
1340
|
+
const n = Math.max(1, end - head - 1);
|
|
1341
|
+
for (let k = head; k < end; k++) {
|
|
1342
|
+
ts[k] = (k - head) / n;
|
|
1343
|
+
mk[k] = mark;
|
|
1344
|
+
}
|
|
1345
|
+
let seen = 0;
|
|
1346
|
+
for (let k = end - 1; k >= head; k--) {
|
|
1347
|
+
if (moras[k].pause || moras[k].set || moras[k].tune) continue;
|
|
1348
|
+
tl[k] = ++seen;
|
|
1349
|
+
if (seen >= 2) break;
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
for (let k = 0; k < moras.length; k++) {
|
|
1353
|
+
if (!moras[k].brk) continue;
|
|
1354
|
+
spread(k, moras[k].mark || ".");
|
|
1355
|
+
ts[k] = 0;
|
|
1356
|
+
mk[k] = moras[k].mark || ".";
|
|
1357
|
+
head = k + 1;
|
|
1358
|
+
}
|
|
1359
|
+
spread(moras.length, ".");
|
|
1360
|
+
}
|
|
1361
|
+
const liftOf = [];
|
|
1362
|
+
const preLong = [];
|
|
1363
|
+
for (let k = 0; k < moras.length; k++) {
|
|
1364
|
+
if (!moras[k].lift) continue;
|
|
1365
|
+
let end = k;
|
|
1366
|
+
while (end + 1 < moras.length && moras[end + 1].lift) end++;
|
|
1367
|
+
let shape = null;
|
|
1368
|
+
let gm = null;
|
|
1369
|
+
for (let j = end + 1; j < moras.length; j++) {
|
|
1370
|
+
const q = moras[j];
|
|
1371
|
+
if (q.set || q.tune) continue;
|
|
1372
|
+
if (q.pause) {
|
|
1373
|
+
gm = q.mark;
|
|
1374
|
+
shape = GROUP_SHAPE[q.mark] || null;
|
|
1375
|
+
}
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
if (BANG_MARKS.has(gm)) {
|
|
1379
|
+
for (let j = k - 1; j >= 0; j--) {
|
|
1380
|
+
const q = moras[j];
|
|
1381
|
+
if (q.set || q.tune) continue;
|
|
1382
|
+
if (q.pause) break;
|
|
1383
|
+
preLong[j] = true;
|
|
1384
|
+
break;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const at = [];
|
|
1388
|
+
for (let j = k; j <= end; j++) if (!moras[j].pause && !moras[j].set) at.push(j);
|
|
1389
|
+
for (const [n, j] of at.entries()) {
|
|
1390
|
+
const u = at.length > 1 ? n / (at.length - 1) : 0.5;
|
|
1391
|
+
liftOf[j] = { t: ts[k] ?? 0, up: shape ? shape(u) : 0, mark: gm };
|
|
1392
|
+
}
|
|
1393
|
+
k = end;
|
|
1394
|
+
}
|
|
1395
|
+
const devoiced = notes ? moras.map(() => false) : markDevoiced(moras);
|
|
1396
|
+
let i = 0;
|
|
1397
|
+
for (const m of moras) {
|
|
1398
|
+
const mi = i++;
|
|
1399
|
+
const t = ts[mi] ?? 0;
|
|
1400
|
+
if (m.set) {
|
|
1401
|
+
if (m.set.pitch > 0) pitch = m.set.pitch * pShift;
|
|
1402
|
+
if (m.set.speed > 0) speed = m.set.speed * sMul;
|
|
1403
|
+
if (m.set.vol !== void 0) vol = m.set.vol;
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
volOf[mi] = withShift(vol);
|
|
1407
|
+
if (m.tune) {
|
|
1408
|
+
tune = Math.max(-TUNE_MAX, Math.min(TUNE_MAX, tune + m.tune * tuneStep));
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (m.pause) {
|
|
1412
|
+
if (m.brk) tune = 0;
|
|
1413
|
+
out.push({ silent: true, frames: nfSec(m.pause), mora: mi, brk: !!m.brk });
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
const mark = mk[i - 1] || ".";
|
|
1417
|
+
const g = liftOf[mi];
|
|
1418
|
+
const bang = g ? mark === "!" && g.mark === "!" : BANG_MARKS.has(mark) && tl[i - 1] === 1;
|
|
1419
|
+
const longBody = bang || g && g.mark === "!?";
|
|
1420
|
+
const preBang = preLong[mi] || !g && BANG_MARKS.has(mark) && tl[i - 1] === 2;
|
|
1421
|
+
if (postGap > 0) {
|
|
1422
|
+
out.push({ silent: true, frames: postGap, mora: mi });
|
|
1423
|
+
postGap = 0;
|
|
1424
|
+
}
|
|
1425
|
+
if (m.stop) out.push({ silent: true, frames: nf(6), mora: mi });
|
|
1426
|
+
if (!m.tie) {
|
|
1427
|
+
while (notes && notes[noteAt] && notes[noteAt].rest) {
|
|
1428
|
+
out.push({ silent: true, frames: notes[noteAt++].frames, mora: mi });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const note = notes ? takeNote(m, mi) : null;
|
|
1432
|
+
if (note && note.gap > 0) postGap = note.gap;
|
|
1433
|
+
if (note && note.vol !== void 0) volOf[mi] = withShift(vol * note.vol);
|
|
1434
|
+
const f0 = note ? note.hz : g ? g.up ? pitch * Math.pow(2, (g.up + tune) / 12) : f0at(g.t, mark, 0, true) : f0at(t, mark, tl[i - 1]);
|
|
1435
|
+
const push = mark === "!" ? 0 : PUSH[mark] || 0;
|
|
1436
|
+
const P = Math.max(P_MIN, Math.min(P_MAX, Math.round(1 / (f0 * TICK))));
|
|
1437
|
+
const cons = CONSONANTS2[m.c];
|
|
1438
|
+
const next = (() => {
|
|
1439
|
+
for (let k = mi + 1; k < moras.length; k++) {
|
|
1440
|
+
const q = moras[k];
|
|
1441
|
+
if (q.set || q.tune) continue;
|
|
1442
|
+
if (q.pause) return null;
|
|
1443
|
+
return q;
|
|
1444
|
+
}
|
|
1445
|
+
return null;
|
|
1446
|
+
})();
|
|
1447
|
+
const vowel = m.v === "n" ? nasalFor(next && next.c) : VOWELS2[m.v] || VOWELS2.a;
|
|
1448
|
+
const nextLocus = (() => {
|
|
1449
|
+
if (!next) return null;
|
|
1450
|
+
const c = CONSONANTS2[next.c];
|
|
1451
|
+
if (!c || c.kind === "noise" || next.c === "ts") return null;
|
|
1452
|
+
if (next.pal && !PALATAL_ALREADY.has(next.c) && (c.kind === "voiced" || c.voiced)) {
|
|
1453
|
+
return A.palF3 === "y" ? CONSONANTS2.y.hz : palVoiced(next.v);
|
|
1454
|
+
}
|
|
1455
|
+
return c.hzBy && c.hzBy[next.pal ? "i" : next.v] || c.hz;
|
|
1456
|
+
})();
|
|
1457
|
+
const pal = !!m.pal && !!cons && !PALATAL_ALREADY.has(m.c);
|
|
1458
|
+
const voicedCons = !!cons && (cons.kind === "voiced" || !!cons.voiced);
|
|
1459
|
+
const vAmp = Math.min(15, (m.v === "n" ? 11 : 14) + push);
|
|
1460
|
+
const palHz = voicedCons && A.palF3 !== "y" ? palVoiced(m.v) : CONSONANTS2.y.hz;
|
|
1461
|
+
if (cons) {
|
|
1462
|
+
const hz = pal && voicedCons && !cons.nasal ? palHz : cons.hzBy && cons.hzBy[pal ? "i" : m.v] || cons.hz;
|
|
1463
|
+
const noise = cons.kind !== "voiced";
|
|
1464
|
+
const barHz = cons.voiced && cons.closure ? [BAR_HZ1, hz[1], hz[2]] : hz;
|
|
1465
|
+
if (cons.closure && cons.voiced) {
|
|
1466
|
+
out.push({
|
|
1467
|
+
hz: barHz,
|
|
1468
|
+
param: toParams(barHz, shift),
|
|
1469
|
+
bpos: toBpos(false, false, A.barBand),
|
|
1470
|
+
P,
|
|
1471
|
+
amp: A.barAmp,
|
|
1472
|
+
voiced: true,
|
|
1473
|
+
noise: false,
|
|
1474
|
+
frames: nf(cons.closure),
|
|
1475
|
+
mora: mi
|
|
1476
|
+
});
|
|
1477
|
+
} else if (cons.closure) {
|
|
1478
|
+
out.push({
|
|
1479
|
+
silent: true,
|
|
1480
|
+
frames: nf(cons.closure),
|
|
1481
|
+
mora: mi,
|
|
1482
|
+
glideTo: hz,
|
|
1483
|
+
glideNoise: noise
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
out.push({
|
|
1487
|
+
hz: barHz,
|
|
1488
|
+
param: toParams(barHz, shift),
|
|
1489
|
+
bpos: toBpos(noise, cons.kind === "burst" || cons.wide, cons.nasal && "nasal"),
|
|
1490
|
+
P,
|
|
1491
|
+
// 表で `amp` を持っているものはそちら(ハ行・サ行・鼻音。理由は表に書いた)
|
|
1492
|
+
amp: cons.nasal ? A.nasalAmp ?? cons.amp : cons.voiced && cons.kind === "burst" ? A.voicedBurstAmp : cons.amp ?? (cons.kind === "noise" ? 9 : cons.kind === "burst" ? 12 : 11),
|
|
1493
|
+
// 有声の破裂の一撃は、ノイズだけ。声は閉鎖(`BAR_AMP`)と、
|
|
1494
|
+
// 次のフレームからの戻りで聞こえる。一撃に声を乗せると、
|
|
1495
|
+
// 拗音で高いところに置いたとき `シュ` に寄る(表の `g` に書いた)
|
|
1496
|
+
voiced: cons.kind === "voiced" || !!cons.voiced && (cons.kind === "noise" || A.voicedBurstVoice),
|
|
1497
|
+
noise,
|
|
1498
|
+
// 有声摩擦音(実機の FV)だけ、声を薄くする。
|
|
1499
|
+
// ここを「ノイズを通す有声フレーム全部」に掛けていたので、
|
|
1500
|
+
// 有声の破裂音(ガ・ダ・バ)の一撃まで 4 分の 1 になっていた。
|
|
1501
|
+
// 「ギュウニュウ」の頭が聞こえなかったのはこれ(2026-09-03)。
|
|
1502
|
+
// ザ・ジャ は声が飛び出さないよう薄いままにする
|
|
1503
|
+
fv: cons.kind === "noise" && !!cons.voiced,
|
|
1504
|
+
// 拗音の鼻音だけ長い(`PAL_NASAL_FRAMES`)。最後の 1 フレームは下で別に置く
|
|
1505
|
+
frames: nf(pal && cons.nasal ? A.palNasalFrames - (A.nasalTail ? 1 : 0) : cons.frames),
|
|
1506
|
+
mora: mi
|
|
1507
|
+
});
|
|
1508
|
+
if (pal && cons.nasal && A.nasalTail) {
|
|
1509
|
+
const tail = [barHz[0], palHz[1], palHz[2]];
|
|
1510
|
+
out.push({
|
|
1511
|
+
hz: tail,
|
|
1512
|
+
param: toParams(tail, shift),
|
|
1513
|
+
bpos: toBpos(false, false, "nasal"),
|
|
1514
|
+
P,
|
|
1515
|
+
amp: A.nasalAmp ?? cons.amp ?? 11,
|
|
1516
|
+
voiced: true,
|
|
1517
|
+
noise: false,
|
|
1518
|
+
frames: 1,
|
|
1519
|
+
mora: mi
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
const after = pal ? palHz : vowel;
|
|
1523
|
+
if (cons.gap && cons.voiced && A.voicedGap === "silent") {
|
|
1524
|
+
out.push({ silent: true, frames: nf(cons.gap), glideTo: after, mora: mi });
|
|
1525
|
+
} else if (cons.gap && cons.voiced) {
|
|
1526
|
+
out.push({
|
|
1527
|
+
hz: after,
|
|
1528
|
+
param: toParams(after, shift),
|
|
1529
|
+
bpos: toBpos(false),
|
|
1530
|
+
P,
|
|
1531
|
+
amp: Math.max(1, vAmp - 2),
|
|
1532
|
+
voiced: true,
|
|
1533
|
+
noise: false,
|
|
1534
|
+
frames: nf(cons.gap),
|
|
1535
|
+
mora: mi
|
|
1536
|
+
});
|
|
1537
|
+
} else if (cons.gap) {
|
|
1538
|
+
for (let k = 0; k < nf(cons.gap); k++) {
|
|
1539
|
+
out.push({
|
|
1540
|
+
hz: after,
|
|
1541
|
+
param: toParams(after, shift),
|
|
1542
|
+
bpos: toBpos(true, true),
|
|
1543
|
+
P,
|
|
1544
|
+
amp: Math.max(1, ASPIRATE - ASPIRATE_FADE * k),
|
|
1545
|
+
voiced: false,
|
|
1546
|
+
noise: true,
|
|
1547
|
+
frames: 1,
|
|
1548
|
+
mora: mi
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
const dev = devoiced[mi];
|
|
1554
|
+
const body = dev ? {
|
|
1555
|
+
hz: vowel,
|
|
1556
|
+
param: toParams(vowel, shift),
|
|
1557
|
+
bpos: toBpos(true, true),
|
|
1558
|
+
P,
|
|
1559
|
+
amp: DEVOICED,
|
|
1560
|
+
voiced: false,
|
|
1561
|
+
noise: true,
|
|
1562
|
+
mora: mi
|
|
1563
|
+
} : {
|
|
1564
|
+
hz: vowel,
|
|
1565
|
+
param: toParams(vowel, shift),
|
|
1566
|
+
bpos: toBpos(false),
|
|
1567
|
+
P,
|
|
1568
|
+
amp: vAmp,
|
|
1569
|
+
voiced: true,
|
|
1570
|
+
noise: false,
|
|
1571
|
+
mora: mi
|
|
1572
|
+
};
|
|
1573
|
+
const palFrames = pal ? nf(PAL) : 0;
|
|
1574
|
+
if (palFrames) {
|
|
1575
|
+
const glide = {
|
|
1576
|
+
// 鼻音のあとは幅を広げる(`B_NASALIZED`)。母音の出だしが鼻にかかる
|
|
1577
|
+
hz: palHz,
|
|
1578
|
+
param: toParams(palHz, shift),
|
|
1579
|
+
bpos: toBpos(false, false, cons.nasal && A.nasalizedGlide && "nasalized"),
|
|
1580
|
+
P,
|
|
1581
|
+
// 母音より少し弱い。渡りであって、母音ではない
|
|
1582
|
+
amp: Math.max(1, vAmp - 2),
|
|
1583
|
+
voiced: true,
|
|
1584
|
+
noise: false,
|
|
1585
|
+
frames: palFrames,
|
|
1586
|
+
mora: mi
|
|
1587
|
+
};
|
|
1588
|
+
if (cons.nasal && A.glideAfterNasal === "ramp" && palFrames > 1) {
|
|
1589
|
+
out.push({ ...glide, frames: 1 });
|
|
1590
|
+
out.push({ ...glide, amp: vAmp, frames: palFrames - 1 });
|
|
1591
|
+
} else if (cons.nasal && A.glideAfterNasal === "full") {
|
|
1592
|
+
out.push({ ...glide, amp: vAmp });
|
|
1593
|
+
} else out.push(glide);
|
|
1594
|
+
}
|
|
1595
|
+
const vFrames = note ? Math.max(1, note.frames - palFrames - (cons ? (cons.closure || 0) + cons.frames + (cons.gap || 0) : 0)) : Math.max(1, Math.round(nf((dev ? DEVOICED_FRAMES : 11) * (1 + m.hold * 0.9)) * (longBody ? BANG_LONG : preBang ? BANG_PRE_LONG : 1)) - palFrames);
|
|
1596
|
+
const coart = note || m.wave || dev || !nextLocus ? 0 : Math.min(COART, Math.max(0, vFrames - 3));
|
|
1597
|
+
const bend = () => out.push({
|
|
1598
|
+
...body,
|
|
1599
|
+
param: toParams([vowel[0], nextLocus[1], nextLocus[2]], shift),
|
|
1600
|
+
amp: Math.max(1, body.amp - 1),
|
|
1601
|
+
frames: coart
|
|
1602
|
+
});
|
|
1603
|
+
if (m.wave && !note) pushWave(out, body, vFrames, f0, frame);
|
|
1604
|
+
else if (bang && !note) {
|
|
1605
|
+
pushBang(out, body, vFrames - coart, nf(BANG_FRAMES));
|
|
1606
|
+
if (coart) bend();
|
|
1607
|
+
} else if (style === "natural") {
|
|
1608
|
+
pushDecay(out, body, vFrames - coart);
|
|
1609
|
+
if (coart) bend();
|
|
1610
|
+
} else if (style !== "flat") {
|
|
1611
|
+
pushSung(out, body, vFrames - coart, f0, style);
|
|
1612
|
+
if (coart) bend();
|
|
1613
|
+
} else if (coart) {
|
|
1614
|
+
out.push({ ...body, frames: vFrames - coart });
|
|
1615
|
+
bend();
|
|
1616
|
+
} else out.push({ ...body, frames: vFrames });
|
|
1617
|
+
}
|
|
1618
|
+
if (notes) {
|
|
1619
|
+
const sung = notes.filter((n) => !n.rest).length;
|
|
1620
|
+
const said = moras.filter((m) => !m.pause && !m.set && !m.tie).length;
|
|
1621
|
+
if (sung !== said) {
|
|
1622
|
+
console.warn(`[RetroTalk] 歌: 拍 ${said} 個に対して音符 ${sung} 個です` + (sung < said ? "(足りないぶんは、ふつうに読みます)" : "(余ったぶんは鳴りません)"));
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
for (const t of out) t.vol = volOf[t.mora] ?? 1;
|
|
1626
|
+
return out;
|
|
1627
|
+
}
|
|
1628
|
+
function readText(text, opts) {
|
|
1629
|
+
if (opts.format === "aquestalk") return parseAqua(text);
|
|
1630
|
+
if (opts.format === "voicevox") return parseVoicevox(text, opts);
|
|
1631
|
+
if (typeof text === "string" && ACCENT_MARK.test(text)) {
|
|
1632
|
+
return parseAqua(text, { strict: false });
|
|
1633
|
+
}
|
|
1634
|
+
return parseTalk(text);
|
|
1635
|
+
}
|
|
1636
|
+
var ACCENT_MARK = /['\u2018\u2019\uff07/+]/;
|
|
1637
|
+
function runFrames(targets, opts) {
|
|
1638
|
+
const slewF = opts.slewF ?? D.slewF;
|
|
1639
|
+
const slewB = opts.slewB ?? D.slewB;
|
|
1640
|
+
const slewP = opts.slewP ?? D.slewP;
|
|
1641
|
+
const shift = opts.formantShift ?? D.formantShift;
|
|
1642
|
+
const bShift = opts.bandShift ?? D.bandShift;
|
|
1643
|
+
const frames = [];
|
|
1644
|
+
const step = (cur, want, lim) => cur + Math.max(-lim, Math.min(lim, want - cur));
|
|
1645
|
+
let param = [0, 0, 0, 0, 0];
|
|
1646
|
+
let bpos = B_HOME.slice();
|
|
1647
|
+
let P = (targets.find((t) => !t.silent && t.P) || {}).P || 30;
|
|
1648
|
+
for (let ti = 0; ti < targets.length; ti++) {
|
|
1649
|
+
const t = targets[ti];
|
|
1650
|
+
if (t.brk) {
|
|
1651
|
+
for (let k = ti + 1; k < targets.length; k++) {
|
|
1652
|
+
const nx = targets[k];
|
|
1653
|
+
if (nx.silent || !nx.P) continue;
|
|
1654
|
+
P = nx.P;
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const want = t.silent ? t.glideTo ? toParams(t.glideTo, shift) : param : t.param;
|
|
1659
|
+
const wantB = (t.silent ? t.glideTo ? toBpos(!!t.glideNoise) : bpos : t.bpos).map((v) => v + bShift);
|
|
1660
|
+
const wantP = t.silent ? P : t.P;
|
|
1661
|
+
for (let n = 0; n < t.frames; n++) {
|
|
1662
|
+
param = param.map((v, k) => step(v, want[k], slewF));
|
|
1663
|
+
bpos = bpos.map((v, k) => Math.max(0, Math.min(B_STEPS - 1, step(v, wantB[k], slewB))));
|
|
1664
|
+
P = Math.max(P_MIN, Math.min(P_MAX, step(P, wantP, slewP)));
|
|
1665
|
+
frames.push({
|
|
1666
|
+
param: param.slice(),
|
|
1667
|
+
bpos: bpos.slice(),
|
|
1668
|
+
P,
|
|
1669
|
+
// 絵に描くための Hz。番地のままでは読めない。並びは第 1〜3 フォルマント
|
|
1670
|
+
formant: VOICED_STAGES.map((s) => idxToHz(param[s] + F_HOME[s])),
|
|
1671
|
+
formantBw: VOICED_STAGES.map((s) => bposToBw(bpos[s])),
|
|
1672
|
+
norm: chainNorm(param, bpos),
|
|
1673
|
+
amp: t.silent ? 0 : t.amp,
|
|
1674
|
+
voiced: !t.silent && t.voiced,
|
|
1675
|
+
noise: !t.silent && t.noise,
|
|
1676
|
+
fv: !t.silent && !!t.fv,
|
|
1677
|
+
mora: t.mora,
|
|
1678
|
+
vol: t.vol ?? 1
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return frames;
|
|
1683
|
+
}
|
|
1684
|
+
function talkFrames(text, opts = {}) {
|
|
1685
|
+
return runFrames(buildTargets(readText(text, opts), opts), opts);
|
|
1686
|
+
}
|
|
1687
|
+
function softClip(x) {
|
|
1688
|
+
const KNEE = 0.9;
|
|
1689
|
+
const a = Math.abs(x);
|
|
1690
|
+
if (a <= KNEE) return x;
|
|
1691
|
+
return Math.sign(x) * (KNEE + (1 - KNEE) * Math.tanh((a - KNEE) / (1 - KNEE)));
|
|
1692
|
+
}
|
|
1693
|
+
function gatedRms(data, n) {
|
|
1694
|
+
let all = 0;
|
|
1695
|
+
for (let k = 0; k < n; k++) all += data[k] * data[k];
|
|
1696
|
+
const gate = Math.sqrt(all / n) * 0.5;
|
|
1697
|
+
let sum = 0, cnt = 0;
|
|
1698
|
+
for (let k = 0; k < n; k++) {
|
|
1699
|
+
if (Math.abs(data[k]) > gate) {
|
|
1700
|
+
sum += data[k] * data[k];
|
|
1701
|
+
cnt++;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return cnt ? Math.sqrt(sum / cnt) : 0;
|
|
1705
|
+
}
|
|
1706
|
+
var ampToGain = (a) => a <= 0 ? 0 : Math.pow(10, (a - 15) * 0.125);
|
|
1707
|
+
function stageGain(hz, bw, f) {
|
|
1708
|
+
const r = Math.exp(-Math.PI * bw / RATE);
|
|
1709
|
+
const th = 2 * Math.PI * hz / RATE;
|
|
1710
|
+
const b = 2 * r * Math.cos(th), c = -r * r, a = 1 - b - c;
|
|
1711
|
+
const w = 2 * Math.PI * f / RATE;
|
|
1712
|
+
const re = 1 - b * Math.cos(w) - c * Math.cos(2 * w);
|
|
1713
|
+
const im = b * Math.sin(w) + c * Math.sin(2 * w);
|
|
1714
|
+
return a / Math.sqrt(re * re + im * im);
|
|
1715
|
+
}
|
|
1716
|
+
function chainNorm(param, bpos) {
|
|
1717
|
+
const hz = param.map((v, s) => Math.min(idxToHz(v + F_HOME[s]), RATE * 0.45));
|
|
1718
|
+
const bw = bpos.map(bposToBw);
|
|
1719
|
+
let mx = 0;
|
|
1720
|
+
for (const f of hz) {
|
|
1721
|
+
let g = 1;
|
|
1722
|
+
for (let s = 0; s < 5; s++) g *= stageGain(hz[s], bw[s], f);
|
|
1723
|
+
if (g > mx) mx = g;
|
|
1724
|
+
}
|
|
1725
|
+
return mx > 0 ? mx : 1;
|
|
1726
|
+
}
|
|
1727
|
+
function renderTalk(text, opts = {}) {
|
|
1728
|
+
const interp = opts.interp ?? D.interp;
|
|
1729
|
+
const bits = opts.bits ?? D.bits;
|
|
1730
|
+
const breath = opts.breath ?? D.breath;
|
|
1731
|
+
const jitter = opts.jitter ?? D.jitter;
|
|
1732
|
+
const ring = Math.max(0, opts.ring ?? D.ring);
|
|
1733
|
+
const rnd = makeRandom(opts.seed ?? D.seed);
|
|
1734
|
+
const moras = readText(text, opts);
|
|
1735
|
+
const targets = buildTargets(moras, opts);
|
|
1736
|
+
const frames = runFrames(targets, opts);
|
|
1737
|
+
const lead = Math.max(0, (opts.lag ?? D.lag) + (opts.lagPerMora ?? D.lagPerMora) * moras.length);
|
|
1738
|
+
const perFrame = Math.round((opts.frame ?? D.frame) * RATE);
|
|
1739
|
+
const leadN = Math.round(lead * RATE);
|
|
1740
|
+
const n = leadN + frames.length * perFrame + Math.round(0.02 * RATE);
|
|
1741
|
+
const data = new Float32Array(n);
|
|
1742
|
+
const volAt = new Float32Array(n).fill(1);
|
|
1743
|
+
const y1 = new Float64Array(5), y2 = new Float64Array(5);
|
|
1744
|
+
let phase = 0;
|
|
1745
|
+
let at = leadN;
|
|
1746
|
+
for (let fi = 0; fi < frames.length; fi++) {
|
|
1747
|
+
const f = frames[fi];
|
|
1748
|
+
const prev = fi > 0 ? frames[fi - 1] : f;
|
|
1749
|
+
for (let k = 0; k < perFrame; k++) {
|
|
1750
|
+
const u = interp || f.amp <= 0 || f.voiced !== prev.voiced || f.noise !== prev.noise ? k / perFrame : 1;
|
|
1751
|
+
const lerp = (a, b) => a + (b - a) * u;
|
|
1752
|
+
let src = 0;
|
|
1753
|
+
if (f.noise) src = rnd() * NOISE_LEVEL;
|
|
1754
|
+
if (f.voiced) {
|
|
1755
|
+
let P = Math.max(1, Math.round(lerp(prev.P, f.P)));
|
|
1756
|
+
if (breath > 0) src += rnd() * NOISE_LEVEL * breath;
|
|
1757
|
+
if (jitter > 0) P = Math.max(1, Math.round(P * (1 + rnd() * jitter)));
|
|
1758
|
+
if (--phase <= 0) {
|
|
1759
|
+
src += f.fv ? FV_LEVEL : 1;
|
|
1760
|
+
phase = P;
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
phase = 0;
|
|
1764
|
+
}
|
|
1765
|
+
src *= ampToGain(lerp(prev.amp, f.amp)) / lerp(prev.norm, f.norm);
|
|
1766
|
+
if (f.amp <= 0) {
|
|
1767
|
+
for (let s2 = 0; s2 < 5; s2++) {
|
|
1768
|
+
y1[s2] *= SILENCE_DAMP;
|
|
1769
|
+
y2[s2] *= SILENCE_DAMP;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
let x = src;
|
|
1773
|
+
for (let s = 0; s < 5; s++) {
|
|
1774
|
+
const hz = Math.min(
|
|
1775
|
+
idxToHz(lerp(prev.param[s], f.param[s]) + F_HOME[s]),
|
|
1776
|
+
RATE * 0.45
|
|
1777
|
+
);
|
|
1778
|
+
const bw = bposToBw(lerp(prev.bpos[s], f.bpos[s]));
|
|
1779
|
+
const r = Math.exp(-Math.PI * bw / RATE);
|
|
1780
|
+
const b = 2 * r * Math.cos(2 * Math.PI * hz / RATE);
|
|
1781
|
+
const c = -r * r;
|
|
1782
|
+
const a = 1 - b - c;
|
|
1783
|
+
const y = a * x + b * y1[s] + c * y2[s];
|
|
1784
|
+
y2[s] = y1[s];
|
|
1785
|
+
y1[s] = y;
|
|
1786
|
+
x = y;
|
|
1787
|
+
}
|
|
1788
|
+
if (ring > 0) x *= Math.sin(2 * Math.PI * ring * (at / RATE));
|
|
1789
|
+
if (at < n) {
|
|
1790
|
+
volAt[at] = f.vol ?? 1;
|
|
1791
|
+
data[at++] = x;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (opts.gainNorm ?? D.gainNorm) {
|
|
1796
|
+
const up = Math.max(0, (opts.formantShift ?? D.formantShift) - D.formantShift);
|
|
1797
|
+
const AIM = (opts.level ?? D.level) * (1 + Math.min(0.6, up * 0.1));
|
|
1798
|
+
const rms = gatedRms(data, n);
|
|
1799
|
+
if (rms > 0) {
|
|
1800
|
+
for (let k = 0; k < n; k++) data[k] = softClip(data[k] * (AIM / rms));
|
|
1801
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
1802
|
+
const now = gatedRms(data, n);
|
|
1803
|
+
if (!(now > 0)) break;
|
|
1804
|
+
const fix = Math.min(1.5, AIM / now);
|
|
1805
|
+
if (fix <= 1.01) break;
|
|
1806
|
+
for (let k = 0; k < n; k++) data[k] = softClip(data[k] * fix);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
for (let k = 0; k < n; k++) data[k] *= volAt[k];
|
|
1811
|
+
if (bits > 0) {
|
|
1812
|
+
const levels = Math.pow(2, bits) / 2;
|
|
1813
|
+
for (let k = 0; k < n; k++) data[k] = Math.round(data[k] * levels) / levels;
|
|
1814
|
+
}
|
|
1815
|
+
return { rate: RATE, data, frames: frames.length };
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/talk_audio.js
|
|
1819
|
+
var AUTO_TALK = "\0talk:";
|
|
1820
|
+
var TalkAudio = class extends SimpleAudio {
|
|
1821
|
+
/**
|
|
1822
|
+
* @param {AudioContext} ctx 呼び出し側で作った AudioContext
|
|
1823
|
+
* @param {object} [opts]
|
|
1824
|
+
* @param {number} [opts.maxVoices=8] 同時に再生できる音の数
|
|
1825
|
+
* @param {number} [opts.maxTalk=1] 同時に再生できるセリフの数
|
|
1826
|
+
*/
|
|
1827
|
+
constructor(ctx, opts = {}) {
|
|
1828
|
+
super(ctx, opts);
|
|
1829
|
+
this.talkDefs = /* @__PURE__ */ new Map();
|
|
1830
|
+
this.maxTalk = opts.maxTalk ?? 1;
|
|
1831
|
+
this._talkSeq = 0;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* テキストに名前を付けて登録します。再生はしません。
|
|
1835
|
+
*
|
|
1836
|
+
* 同じテキストを何度も再生するときに使います。作った波形は名前ごとに
|
|
1837
|
+
* 保持するので、2 回目以降は作り直しません。
|
|
1838
|
+
*
|
|
1839
|
+
* `@` で始まる指示をテキストに混ぜられます。指示はそこから後ろに効きます。
|
|
1840
|
+
*
|
|
1841
|
+
* `@p<Hz>` 声の高さ。例 `@p200`
|
|
1842
|
+
* `@s<%>` 話す速さ。例 `@s150`(1.5 倍)
|
|
1843
|
+
* `@v<0-15>` 音量。例 `@v8`
|
|
1844
|
+
*
|
|
1845
|
+
* ```js
|
|
1846
|
+
* voice.defineTalk('two', 'ワタシデス。@p180 イエ、ワタシデス。');
|
|
1847
|
+
* ```
|
|
1848
|
+
*
|
|
1849
|
+
* 抑揚は句読点(`、` `。`)で元に戻ります。文全体で少しずつ下がるため、
|
|
1850
|
+
* 切れ目が無いと長い文の終わりで音が沈みます。空白では戻りません。
|
|
1851
|
+
*
|
|
1852
|
+
* @param {string} name 登録名
|
|
1853
|
+
* @param {string} text カタカナ。空白と句読点は間になります
|
|
1854
|
+
* @param {object} [opts]
|
|
1855
|
+
* 音声のパラメータ。一覧は `renderTalk()` の説明にあります。
|
|
1856
|
+
* `unknown` は読めない文字が混ざったときの扱いで、
|
|
1857
|
+
* `warn`(初期値)/ `error` / `ignore` から選びます
|
|
1858
|
+
* @returns {void}
|
|
1859
|
+
*/
|
|
1860
|
+
defineTalk(name, text, opts = {}) {
|
|
1861
|
+
const how = opts.unknown ?? "warn";
|
|
1862
|
+
if (how !== "ignore") {
|
|
1863
|
+
const got = opts.format === "voicevox" ? { ok: true, unknown: [] } : opts.format === "aquestalk" ? checkAqua(text) : checkTalk(text);
|
|
1864
|
+
if (!got.ok) {
|
|
1865
|
+
const what = got.unknown.slice(0, 8).map((u) => u.ch).join(" ");
|
|
1866
|
+
const more = got.unknown.length > 8 ? ` ほか ${got.unknown.length - 8} 字` : "";
|
|
1867
|
+
const label = name.startsWith(AUTO_TALK) ? text : name;
|
|
1868
|
+
const say = `[RetroTalk] talk "${label}": 読めない字があります — ${what}${more}(漢字と数字は読みにしてから渡します)`;
|
|
1869
|
+
if (how === "error") throw new Error(say);
|
|
1870
|
+
console.warn(say);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
this.talkDefs.set(name, { text, opts, buffer: null });
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* テキストをその場で再生します。
|
|
1877
|
+
*
|
|
1878
|
+
* `defineTalk()` と `playTalk()` をまとめて行う短い書き方です。
|
|
1879
|
+
*
|
|
1880
|
+
* ```js
|
|
1881
|
+
* voice.talk('コンニチワ');
|
|
1882
|
+
* ```
|
|
1883
|
+
*
|
|
1884
|
+
* 登録は残らないので、作った波形も保持しません。同じテキストを
|
|
1885
|
+
* 繰り返し再生するなら `defineTalk()` で名前を付けてください。
|
|
1886
|
+
*
|
|
1887
|
+
* @param {string} text カタカナ。`defineTalk()` と同じ
|
|
1888
|
+
* @param {object} [opts] 音声のパラメータ。`defineTalk()` と同じ
|
|
1889
|
+
* @param {number} [priority=0] `playTalk()` と同じ
|
|
1890
|
+
* @returns {number|undefined} 再生 ID。再生できなかったときは undefined
|
|
1891
|
+
*/
|
|
1892
|
+
talk(text, opts = {}, priority = 0) {
|
|
1893
|
+
const name = AUTO_TALK + ++this._talkSeq;
|
|
1894
|
+
this.defineTalk(name, text, opts);
|
|
1895
|
+
const id = this.playTalk(name, priority, opts);
|
|
1896
|
+
this.talkDefs.delete(name);
|
|
1897
|
+
return id;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* 読み上げられない文字を調べます。再生はしません。
|
|
1901
|
+
*
|
|
1902
|
+
* 登録する前に確かめるときに使います。読めない文字がテキストのどこにあるかも返します。
|
|
1903
|
+
*
|
|
1904
|
+
* ```js
|
|
1905
|
+
* const got = voice.checkTalk('今日は 3ガツ デス');
|
|
1906
|
+
* // { ok: false, kana: 6, unknown: [{ ch: '今', at: 0, kind: 'kanji' }, ...] }
|
|
1907
|
+
* ```
|
|
1908
|
+
*
|
|
1909
|
+
* @param {string} text 調べるテキスト
|
|
1910
|
+
* @returns {{ok: boolean, kana: number,
|
|
1911
|
+
* unknown: Array<{ch: string, at: number, kind: string}>}}
|
|
1912
|
+
* `ok` は読めない文字が無ければ true、`kana` は読めた文字数、
|
|
1913
|
+
* `at` はテキスト内の位置、`kind` は文字の種別
|
|
1914
|
+
*/
|
|
1915
|
+
checkTalk(text) {
|
|
1916
|
+
return checkTalk(text);
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* 波形の生成だけを行います。再生はしません。
|
|
1920
|
+
*
|
|
1921
|
+
* `playTalk()` は初回に波形を生成するため、その分だけ再生の開始が遅れます。
|
|
1922
|
+
* 複数を重ねて再生するとき、この遅れがずれになります。2 人分を続けて
|
|
1923
|
+
* `playTalk()` すると、2 人目は 1 人目の生成が終わってから始まります。
|
|
1924
|
+
* 先に両方を生成しておけば、ずれずに重なります。
|
|
1925
|
+
*
|
|
1926
|
+
* @param {string|string[]} name `defineTalk()` で登録した名前。配列で渡せます
|
|
1927
|
+
* @returns {void}
|
|
1928
|
+
*/
|
|
1929
|
+
prepareTalk(name) {
|
|
1930
|
+
if (Array.isArray(name)) {
|
|
1931
|
+
for (const n of name) this.prepareTalk(n);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const def = this.talkDefs.get(name);
|
|
1935
|
+
if (!def || !this.ctx || def.buffer) return;
|
|
1936
|
+
const { rate, data } = renderTalk(def.text, def.opts);
|
|
1937
|
+
const buf = this.ctx.createBuffer(1, data.length, rate);
|
|
1938
|
+
buf.getChannelData(0).set(data);
|
|
1939
|
+
def.buffer = buf;
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* 登録済みのテキストを再生します。
|
|
1943
|
+
*
|
|
1944
|
+
* 波形は初回に生成し、名前ごとに保持します。2 回目以降は作り直しません。
|
|
1945
|
+
* 同時再生数(`maxTalk`)を超えると、優先度の低いものが停止します。
|
|
1946
|
+
*
|
|
1947
|
+
* @param {string|string[]} name `defineTalk()` で登録した名前。
|
|
1948
|
+
* 配列を渡すと、すべての波形を生成してから続けて再生します
|
|
1949
|
+
* @param {number} [priority=0] 値が大きいほど優先されます
|
|
1950
|
+
* @param {object} [opts]
|
|
1951
|
+
* `gain` は音量の倍率、`rate` は再生速度(1 より大きいと速く高くなります)
|
|
1952
|
+
* @returns {number|number[]|undefined} 再生 ID。配列を渡したときは配列。
|
|
1953
|
+
* 再生できなかったときは undefined
|
|
1954
|
+
*/
|
|
1955
|
+
playTalk(name, priority = 0, opts = {}) {
|
|
1956
|
+
this.unlock();
|
|
1957
|
+
if (Array.isArray(name)) {
|
|
1958
|
+
this.prepareTalk(name);
|
|
1959
|
+
return name.map((n) => this.playTalk(n, priority, opts));
|
|
1960
|
+
}
|
|
1961
|
+
const def = this.talkDefs.get(name);
|
|
1962
|
+
if (!def || !this.ctx) return;
|
|
1963
|
+
const room = Math.max(1, this.maxTalk | 0) || 1;
|
|
1964
|
+
const talking = this.seVoices.filter((v2) => v2.talk);
|
|
1965
|
+
for (let i = 0; i <= talking.length - room; i++) this._stopVoice(talking[i]);
|
|
1966
|
+
const now = this.ctx.currentTime;
|
|
1967
|
+
this._cleanupSE(now);
|
|
1968
|
+
if (this.seVoices.some((v2) => v2.exclusive && v2.priority > priority)) return;
|
|
1969
|
+
while (this._usedVoices() + 1 > this.maxVoices) {
|
|
1970
|
+
let low = null;
|
|
1971
|
+
for (const v2 of this.seVoices) {
|
|
1972
|
+
if (v2.priority >= priority) continue;
|
|
1973
|
+
if (!low || v2.priority < low.priority) low = v2;
|
|
1974
|
+
}
|
|
1975
|
+
if (!low) return 0;
|
|
1976
|
+
this._stopVoice(low);
|
|
1977
|
+
}
|
|
1978
|
+
this.prepareTalk(name);
|
|
1979
|
+
const gain = this.ctx.createGain();
|
|
1980
|
+
gain.gain.value = (def.opts.gain ?? 1) * 0.9;
|
|
1981
|
+
gain.connect(this._out());
|
|
1982
|
+
const src = this.ctx.createBufferSource();
|
|
1983
|
+
src.buffer = def.buffer;
|
|
1984
|
+
src.connect(gain);
|
|
1985
|
+
const when = now + 0.02;
|
|
1986
|
+
src.start(when);
|
|
1987
|
+
const v = {
|
|
1988
|
+
gain,
|
|
1989
|
+
nodes: [src],
|
|
1990
|
+
priority,
|
|
1991
|
+
voices: 1,
|
|
1992
|
+
noise: 0,
|
|
1993
|
+
endTime: when + def.buffer.duration,
|
|
1994
|
+
exclusive: !!opts.exclusive,
|
|
1995
|
+
talk: name,
|
|
1996
|
+
id: ++this._seSeq
|
|
1997
|
+
};
|
|
1998
|
+
this.seVoices.push(v);
|
|
1999
|
+
return v.id;
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* 保持している波形を破棄します。
|
|
2003
|
+
*
|
|
2004
|
+
* `defineTalk()` は名前ごとに波形を持ち続けます。作り直さずに済ませるためですが、
|
|
2005
|
+
* その分メモリを使います。使わなくなったものは破棄できます。
|
|
2006
|
+
*
|
|
2007
|
+
* ```js
|
|
2008
|
+
* voice.forgetTalk('hello'); // 名前を指定して破棄
|
|
2009
|
+
* voice.forgetTalk(['demo1', 'demo2']); // まとめて破棄
|
|
2010
|
+
* voice.forgetTalk(); // すべて破棄
|
|
2011
|
+
* ```
|
|
2012
|
+
*
|
|
2013
|
+
* 再生中のものは最後まで再生されます。波形は再生側が参照しています。
|
|
2014
|
+
*
|
|
2015
|
+
* @param {string|string[]} [name] 登録名。省略するとすべて破棄します
|
|
2016
|
+
* @returns {void}
|
|
2017
|
+
*/
|
|
2018
|
+
forgetTalk(name) {
|
|
2019
|
+
if (name == null) {
|
|
2020
|
+
this.talkDefs.clear();
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
for (const n of Array.isArray(name) ? name : [name]) this.talkDefs.delete(n);
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* 再生中のものを停止します。
|
|
2027
|
+
*
|
|
2028
|
+
* @param {string|number} [what] 登録名、または `playTalk()` の戻り値。
|
|
2029
|
+
* 省略するとすべて停止します
|
|
2030
|
+
* @returns {void}
|
|
2031
|
+
*/
|
|
2032
|
+
stopTalk(what) {
|
|
2033
|
+
for (const v of [...this.seVoices]) {
|
|
2034
|
+
if (!v.talk) continue;
|
|
2035
|
+
if (what !== void 0 && v.talk !== what && v.id !== what) continue;
|
|
2036
|
+
this._stopVoice(v);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
// src/wav.js
|
|
2042
|
+
function encodeWAV(buffer) {
|
|
2043
|
+
const ch = buffer.numberOfChannels;
|
|
2044
|
+
const frames = buffer.length;
|
|
2045
|
+
const rate = buffer.sampleRate;
|
|
2046
|
+
const bytes = 2;
|
|
2047
|
+
const dataSize = frames * ch * bytes;
|
|
2048
|
+
const out = new Uint8Array(44 + dataSize);
|
|
2049
|
+
const view = new DataView(out.buffer);
|
|
2050
|
+
let p = 0;
|
|
2051
|
+
const str = (t) => {
|
|
2052
|
+
for (const c of t) out[p++] = c.charCodeAt(0);
|
|
2053
|
+
};
|
|
2054
|
+
const u32 = (v) => {
|
|
2055
|
+
view.setUint32(p, v, true);
|
|
2056
|
+
p += 4;
|
|
2057
|
+
};
|
|
2058
|
+
const u16 = (v) => {
|
|
2059
|
+
view.setUint16(p, v, true);
|
|
2060
|
+
p += 2;
|
|
2061
|
+
};
|
|
2062
|
+
str("RIFF");
|
|
2063
|
+
u32(36 + dataSize);
|
|
2064
|
+
str("WAVE");
|
|
2065
|
+
str("fmt ");
|
|
2066
|
+
u32(16);
|
|
2067
|
+
u16(1);
|
|
2068
|
+
u16(ch);
|
|
2069
|
+
u32(rate);
|
|
2070
|
+
u32(rate * ch * bytes);
|
|
2071
|
+
u16(ch * bytes);
|
|
2072
|
+
u16(8 * bytes);
|
|
2073
|
+
str("data");
|
|
2074
|
+
u32(dataSize);
|
|
2075
|
+
const src = [];
|
|
2076
|
+
for (let c = 0; c < ch; c++) src.push(buffer.getChannelData(c));
|
|
2077
|
+
for (let i = 0; i < frames; i++) {
|
|
2078
|
+
for (let c = 0; c < ch; c++) {
|
|
2079
|
+
const v = Math.max(-1, Math.min(1, src[c][i]));
|
|
2080
|
+
view.setInt16(p, Math.round(v < 0 ? v * 32768 : v * 32767), true);
|
|
2081
|
+
p += 2;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
return out;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/version.js
|
|
2088
|
+
var RETROTALK_VERSION = "2.1.0";
|
|
2089
|
+
|
|
2090
|
+
// src/index.js
|
|
2091
|
+
Object.assign(TalkAudio, {
|
|
2092
|
+
renderTalk,
|
|
2093
|
+
talkFrames,
|
|
2094
|
+
parseTalk,
|
|
2095
|
+
checkTalk,
|
|
2096
|
+
checkAqua,
|
|
2097
|
+
voicevoxText,
|
|
2098
|
+
voicevoxInfo,
|
|
2099
|
+
encodeWAV,
|
|
2100
|
+
TALK_DEFAULTS,
|
|
2101
|
+
TALK_STYLES,
|
|
2102
|
+
VERSION: RETROTALK_VERSION
|
|
2103
|
+
});
|
|
2104
|
+
var index_default = TalkAudio;
|
|
2105
|
+
|
|
2106
|
+
// src/global.js
|
|
2107
|
+
globalThis.MmsxxRetroTalk = index_default;
|
|
2108
|
+
})();
|