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,275 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 harayoki
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// 音声の登録と再生を扱います。SimpleAudio を継承しています。
|
|
5
|
+
//
|
|
6
|
+
// 波形そのものは talk.js の renderTalk() が作ります。このファイルが行うのは、
|
|
7
|
+
// テキストに名前を付けて覚えること、作った波形を保持すること、
|
|
8
|
+
// 空きを確保して再生することの 3 つです。
|
|
9
|
+
|
|
10
|
+
import { SimpleAudio } from './simple_audio.js';
|
|
11
|
+
import { renderTalk } from './talk.js';
|
|
12
|
+
import { checkTalk } from './kana.js';
|
|
13
|
+
import { checkAqua } from './aquestalk.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `talk()` が内部で作る名前の印。人が書く名前とは重ならない
|
|
17
|
+
* @private
|
|
18
|
+
*/
|
|
19
|
+
const AUTO_TALK = '\u0000talk:';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* RetroTalk の入口です。読み込むと `MmsxxRetroTalk` という名前で使えます。
|
|
23
|
+
*
|
|
24
|
+
* 音声の登録と再生を扱います。`SimpleAudio` を継承しているので、
|
|
25
|
+
* 音量と消音の操作もここから行えます。
|
|
26
|
+
*
|
|
27
|
+
* @extends SimpleAudio
|
|
28
|
+
* @example
|
|
29
|
+
* const voice = new MmsxxRetroTalk(new AudioContext());
|
|
30
|
+
* voice.talk('コンニチワ');
|
|
31
|
+
*/
|
|
32
|
+
export class TalkAudio extends SimpleAudio {
|
|
33
|
+
/**
|
|
34
|
+
* @param {AudioContext} ctx 呼び出し側で作った AudioContext
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {number} [opts.maxVoices=8] 同時に再生できる音の数
|
|
37
|
+
* @param {number} [opts.maxTalk=1] 同時に再生できるセリフの数
|
|
38
|
+
*/
|
|
39
|
+
constructor(ctx, opts = {}) {
|
|
40
|
+
super(ctx, opts);
|
|
41
|
+
/** 登録したテキスト。音声ファイルは持たず、再生時に波形を作ります */
|
|
42
|
+
this.talkDefs = new Map();
|
|
43
|
+
/**
|
|
44
|
+
* 同時に再生できるセリフの数。
|
|
45
|
+
*
|
|
46
|
+
* 重なると聞き取れないため、初期値は 1 です。
|
|
47
|
+
* 複数の人物が同時に話す場面では増やします。
|
|
48
|
+
*
|
|
49
|
+
* @type {number}
|
|
50
|
+
*/
|
|
51
|
+
this.maxTalk = opts.maxTalk ?? 1;
|
|
52
|
+
// `talk()` が使い捨ての名前を作るための番号
|
|
53
|
+
this._talkSeq = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* テキストに名前を付けて登録します。再生はしません。
|
|
58
|
+
*
|
|
59
|
+
* 同じテキストを何度も再生するときに使います。作った波形は名前ごとに
|
|
60
|
+
* 保持するので、2 回目以降は作り直しません。
|
|
61
|
+
*
|
|
62
|
+
* `@` で始まる指示をテキストに混ぜられます。指示はそこから後ろに効きます。
|
|
63
|
+
*
|
|
64
|
+
* `@p<Hz>` 声の高さ。例 `@p200`
|
|
65
|
+
* `@s<%>` 話す速さ。例 `@s150`(1.5 倍)
|
|
66
|
+
* `@v<0-15>` 音量。例 `@v8`
|
|
67
|
+
*
|
|
68
|
+
* ```js
|
|
69
|
+
* voice.defineTalk('two', 'ワタシデス。@p180 イエ、ワタシデス。');
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* 抑揚は句読点(`、` `。`)で元に戻ります。文全体で少しずつ下がるため、
|
|
73
|
+
* 切れ目が無いと長い文の終わりで音が沈みます。空白では戻りません。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} name 登録名
|
|
76
|
+
* @param {string} text カタカナ。空白と句読点は間になります
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* 音声のパラメータ。一覧は `renderTalk()` の説明にあります。
|
|
79
|
+
* `unknown` は読めない文字が混ざったときの扱いで、
|
|
80
|
+
* `warn`(初期値)/ `error` / `ignore` から選びます
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
defineTalk(name, text, opts = {}) {
|
|
84
|
+
// 読めない文字は登録した時点で知らせる。再生してから
|
|
85
|
+
// 「そこだけ抜けた」と気づくのでは遅い
|
|
86
|
+
const how = opts.unknown ?? 'warn';
|
|
87
|
+
if (how !== 'ignore') {
|
|
88
|
+
// アクセント記号列で渡すときは、記号を除いてから調べる。
|
|
89
|
+
// VOICEVOX の読みは JSON なので、文字として調べても意味が無い
|
|
90
|
+
const got = opts.format === 'voicevox' ? { ok: true, unknown: [] }
|
|
91
|
+
: opts.format === 'aquestalk' ? checkAqua(text) : checkTalk(text);
|
|
92
|
+
if (!got.ok) {
|
|
93
|
+
const what = got.unknown.slice(0, 8).map((u) => u.ch).join(' ');
|
|
94
|
+
const more = got.unknown.length > 8 ? ` ほか ${got.unknown.length - 8} 字` : '';
|
|
95
|
+
// 内部で作った名前は読めないので、テキストのほうを出す
|
|
96
|
+
const label = name.startsWith(AUTO_TALK) ? text : name;
|
|
97
|
+
const say = `[RetroTalk] talk "${label}": 読めない字があります — ${what}${more}`
|
|
98
|
+
+ '(漢字と数字は読みにしてから渡します)';
|
|
99
|
+
if (how === 'error') throw new Error(say);
|
|
100
|
+
console.warn(say);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.talkDefs.set(name, { text, opts, buffer: null });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* テキストをその場で再生します。
|
|
108
|
+
*
|
|
109
|
+
* `defineTalk()` と `playTalk()` をまとめて行う短い書き方です。
|
|
110
|
+
*
|
|
111
|
+
* ```js
|
|
112
|
+
* voice.talk('コンニチワ');
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* 登録は残らないので、作った波形も保持しません。同じテキストを
|
|
116
|
+
* 繰り返し再生するなら `defineTalk()` で名前を付けてください。
|
|
117
|
+
*
|
|
118
|
+
* @param {string} text カタカナ。`defineTalk()` と同じ
|
|
119
|
+
* @param {object} [opts] 音声のパラメータ。`defineTalk()` と同じ
|
|
120
|
+
* @param {number} [priority=0] `playTalk()` と同じ
|
|
121
|
+
* @returns {number|undefined} 再生 ID。再生できなかったときは undefined
|
|
122
|
+
*/
|
|
123
|
+
talk(text, opts = {}, priority = 0) {
|
|
124
|
+
const name = AUTO_TALK + (++this._talkSeq);
|
|
125
|
+
this.defineTalk(name, text, opts);
|
|
126
|
+
const id = this.playTalk(name, priority, opts);
|
|
127
|
+
// 再生を始めたら登録を消す。波形は再生側が参照しているので最後まで鳴る。
|
|
128
|
+
// 消さないと、名前の分からないものが溜まり続けて捨てられなくなる
|
|
129
|
+
this.talkDefs.delete(name);
|
|
130
|
+
return id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 読み上げられない文字を調べます。再生はしません。
|
|
135
|
+
*
|
|
136
|
+
* 登録する前に確かめるときに使います。読めない文字がテキストのどこにあるかも返します。
|
|
137
|
+
*
|
|
138
|
+
* ```js
|
|
139
|
+
* const got = voice.checkTalk('今日は 3ガツ デス');
|
|
140
|
+
* // { ok: false, kana: 6, unknown: [{ ch: '今', at: 0, kind: 'kanji' }, ...] }
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* @param {string} text 調べるテキスト
|
|
144
|
+
* @returns {{ok: boolean, kana: number,
|
|
145
|
+
* unknown: Array<{ch: string, at: number, kind: string}>}}
|
|
146
|
+
* `ok` は読めない文字が無ければ true、`kana` は読めた文字数、
|
|
147
|
+
* `at` はテキスト内の位置、`kind` は文字の種別
|
|
148
|
+
*/
|
|
149
|
+
checkTalk(text) {
|
|
150
|
+
return checkTalk(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 波形の生成だけを行います。再生はしません。
|
|
155
|
+
*
|
|
156
|
+
* `playTalk()` は初回に波形を生成するため、その分だけ再生の開始が遅れます。
|
|
157
|
+
* 複数を重ねて再生するとき、この遅れがずれになります。2 人分を続けて
|
|
158
|
+
* `playTalk()` すると、2 人目は 1 人目の生成が終わってから始まります。
|
|
159
|
+
* 先に両方を生成しておけば、ずれずに重なります。
|
|
160
|
+
*
|
|
161
|
+
* @param {string|string[]} name `defineTalk()` で登録した名前。配列で渡せます
|
|
162
|
+
* @returns {void}
|
|
163
|
+
*/
|
|
164
|
+
prepareTalk(name) {
|
|
165
|
+
// 重ねる分をまとめて作る。1 つずつ呼ばせると、書き忘れた分だけずれる
|
|
166
|
+
if (Array.isArray(name)) { for (const n of name) this.prepareTalk(n); return; }
|
|
167
|
+
const def = this.talkDefs.get(name);
|
|
168
|
+
if (!def || !this.ctx || def.buffer) return;
|
|
169
|
+
// 粗さを保ったまま再生したいので、生成したサンプリング周波数のまま
|
|
170
|
+
// AudioBuffer を作る。再生時にブラウザが補間する
|
|
171
|
+
const { rate, data } = renderTalk(def.text, def.opts);
|
|
172
|
+
const buf = this.ctx.createBuffer(1, data.length, rate);
|
|
173
|
+
buf.getChannelData(0).set(data);
|
|
174
|
+
def.buffer = buf;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 登録済みのテキストを再生します。
|
|
179
|
+
*
|
|
180
|
+
* 波形は初回に生成し、名前ごとに保持します。2 回目以降は作り直しません。
|
|
181
|
+
* 同時再生数(`maxTalk`)を超えると、優先度の低いものが停止します。
|
|
182
|
+
*
|
|
183
|
+
* @param {string|string[]} name `defineTalk()` で登録した名前。
|
|
184
|
+
* 配列を渡すと、すべての波形を生成してから続けて再生します
|
|
185
|
+
* @param {number} [priority=0] 値が大きいほど優先されます
|
|
186
|
+
* @param {object} [opts]
|
|
187
|
+
* `gain` は音量の倍率、`rate` は再生速度(1 より大きいと速く高くなります)
|
|
188
|
+
* @returns {number|number[]|undefined} 再生 ID。配列を渡したときは配列。
|
|
189
|
+
* 再生できなかったときは undefined
|
|
190
|
+
*/
|
|
191
|
+
playTalk(name, priority = 0, opts = {}) {
|
|
192
|
+
// 再生の前に必ず有効にする。呼び出し側に unlock() を書かせると、
|
|
193
|
+
// 書き忘れが「音が鳴らない」という形で出てしまう
|
|
194
|
+
this.unlock();
|
|
195
|
+
// 重ねるときは配列で渡す。先にすべての波形を作ってから再生するので、
|
|
196
|
+
// 2 人目が 1 人目の生成を待たずに済む。
|
|
197
|
+
// 2 番目の引数は優先度なので、名前を並べる形にはできない
|
|
198
|
+
if (Array.isArray(name)) {
|
|
199
|
+
this.prepareTalk(name);
|
|
200
|
+
return name.map((n) => this.playTalk(n, priority, opts));
|
|
201
|
+
}
|
|
202
|
+
const def = this.talkDefs.get(name);
|
|
203
|
+
if (!def || !this.ctx) return;
|
|
204
|
+
// 同時に再生できるセリフの数を超えた分は、古いほうから止める
|
|
205
|
+
const room = Math.max(1, this.maxTalk | 0) || 1;
|
|
206
|
+
const talking = this.seVoices.filter((v) => v.talk);
|
|
207
|
+
for (let i = 0; i <= talking.length - room; i++) this._stopVoice(talking[i]);
|
|
208
|
+
const now = this.ctx.currentTime;
|
|
209
|
+
this._cleanupSE(now);
|
|
210
|
+
if (this.seVoices.some(v => v.exclusive && v.priority > priority)) return;
|
|
211
|
+
// 空きが無ければ、優先度の低いものを止めて作る
|
|
212
|
+
while (this._usedVoices() + 1 > this.maxVoices) {
|
|
213
|
+
let low = null;
|
|
214
|
+
for (const v of this.seVoices) {
|
|
215
|
+
if (v.priority >= priority) continue;
|
|
216
|
+
if (!low || v.priority < low.priority) low = v;
|
|
217
|
+
}
|
|
218
|
+
if (!low) return 0;
|
|
219
|
+
this._stopVoice(low);
|
|
220
|
+
}
|
|
221
|
+
this.prepareTalk(name);
|
|
222
|
+
const gain = this.ctx.createGain();
|
|
223
|
+
gain.gain.value = (def.opts.gain ?? 1) * 0.9;
|
|
224
|
+
gain.connect(this._out());
|
|
225
|
+
const src = this.ctx.createBufferSource();
|
|
226
|
+
src.buffer = def.buffer;
|
|
227
|
+
src.connect(gain);
|
|
228
|
+
const when = now + 0.02;
|
|
229
|
+
src.start(when);
|
|
230
|
+
const v = {
|
|
231
|
+
gain, nodes: [src], priority, voices: 1, noise: 0,
|
|
232
|
+
endTime: when + def.buffer.duration, exclusive: !!opts.exclusive,
|
|
233
|
+
talk: name, id: ++this._seSeq,
|
|
234
|
+
};
|
|
235
|
+
this.seVoices.push(v);
|
|
236
|
+
return v.id;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 保持している波形を破棄します。
|
|
241
|
+
*
|
|
242
|
+
* `defineTalk()` は名前ごとに波形を持ち続けます。作り直さずに済ませるためですが、
|
|
243
|
+
* その分メモリを使います。使わなくなったものは破棄できます。
|
|
244
|
+
*
|
|
245
|
+
* ```js
|
|
246
|
+
* voice.forgetTalk('hello'); // 名前を指定して破棄
|
|
247
|
+
* voice.forgetTalk(['demo1', 'demo2']); // まとめて破棄
|
|
248
|
+
* voice.forgetTalk(); // すべて破棄
|
|
249
|
+
* ```
|
|
250
|
+
*
|
|
251
|
+
* 再生中のものは最後まで再生されます。波形は再生側が参照しています。
|
|
252
|
+
*
|
|
253
|
+
* @param {string|string[]} [name] 登録名。省略するとすべて破棄します
|
|
254
|
+
* @returns {void}
|
|
255
|
+
*/
|
|
256
|
+
forgetTalk(name) {
|
|
257
|
+
if (name == null) { this.talkDefs.clear(); return; }
|
|
258
|
+
for (const n of (Array.isArray(name) ? name : [name])) this.talkDefs.delete(n);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 再生中のものを停止します。
|
|
263
|
+
*
|
|
264
|
+
* @param {string|number} [what] 登録名、または `playTalk()` の戻り値。
|
|
265
|
+
* 省略するとすべて停止します
|
|
266
|
+
* @returns {void}
|
|
267
|
+
*/
|
|
268
|
+
stopTalk(what) {
|
|
269
|
+
for (const v of [...this.seVoices]) {
|
|
270
|
+
if (!v.talk) continue;
|
|
271
|
+
if (what !== undefined && v.talk !== what && v.id !== what) continue;
|
|
272
|
+
this._stopVoice(v);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
package/src/version.js
ADDED
package/src/voicevox.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 harayoki
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// VOICEVOX プロジェクトファイル(.vvproj)を解析して、RetroTalk で使えるデータを抜き出します。
|
|
5
|
+
// /audio_query が返す JSON にも対応していますが未検証です。
|
|
6
|
+
|
|
7
|
+
import { parseTalk } from './kana.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 読み上げ指示パート(`query`)をソースデータから取り出します。
|
|
11
|
+
*
|
|
12
|
+
* `.vvproj` は文がいくつも入っているので、同じ並び順のまま返します。
|
|
13
|
+
* `/audio_query` の返り値は 1 つだけなので、そのまま 1 つ返します。
|
|
14
|
+
*
|
|
15
|
+
* @param {object} data 読み込んだ JSON
|
|
16
|
+
* @returns {Array<object>} query の並び
|
|
17
|
+
* @private
|
|
18
|
+
*/
|
|
19
|
+
function queries(data) {
|
|
20
|
+
const talk = data && data.talk;
|
|
21
|
+
if (talk && talk.audioItems) {
|
|
22
|
+
const keys = talk.audioKeys || Object.keys(talk.audioItems);
|
|
23
|
+
return keys.map((k) => talk.audioItems[k])
|
|
24
|
+
.filter((it) => it && it.query)
|
|
25
|
+
.map((it) => it.query);
|
|
26
|
+
}
|
|
27
|
+
if (data && (data.accentPhrases || data.accent_phrases)) return [data];
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 拍 1 つの長さ(秒)。`consonantLength` と `vowelLength` を足す
|
|
33
|
+
* @private
|
|
34
|
+
*/
|
|
35
|
+
const moraSec = (mo) => (+mo.consonantLength || +mo.consonant_length || 0)
|
|
36
|
+
+ (+mo.vowelLength || +mo.vowel_length || 0);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 項目名の書き方が 2 通りある
|
|
40
|
+
* (`vowelLength` と `vowel_length`)
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
const pick = (o, a, b, def) => {
|
|
44
|
+
const v = o[a] ?? o[b];
|
|
45
|
+
return v === undefined || v === null ? def : v;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// JSON から読む項目。
|
|
49
|
+
//
|
|
50
|
+
// moras[].text 読み(カタカナ)
|
|
51
|
+
// moras[].vowel 大文字(A I U E O)なら無声化。N は撥音
|
|
52
|
+
// moras[].pitch その拍の高さ。自然対数の Hz。0 は無声
|
|
53
|
+
// moras[].vowelLength その拍の長さ(秒)。consonantLength と足して使う
|
|
54
|
+
// accent アクセント核の位置
|
|
55
|
+
// pauseMora 句のあとの間
|
|
56
|
+
//
|
|
57
|
+
// パラメータは query に文ごとで入っている。
|
|
58
|
+
//
|
|
59
|
+
// pitchScale オクターブで数える(f0 × 2^pitchScale)ので、
|
|
60
|
+
// 対数で持っている pitch には pitchScale × ln2 を足す
|
|
61
|
+
// intonationScale 文まるごとの平均からの差を広げる / 狭める。
|
|
62
|
+
// アクセント句ごとではない
|
|
63
|
+
// speedScale 速さ。長さを割る
|
|
64
|
+
// pauseLengthScale 間の長さを掛ける
|
|
65
|
+
//
|
|
66
|
+
// これ以外は 拍の並び 1つになる。
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 文ごとの設定を適用したうえで、拍を平らに並べます。
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<object>} list `query` の並び
|
|
72
|
+
* @param {boolean} meta パラメータを掛けるか。外すと素の読みだけになる
|
|
73
|
+
* @returns {Array<object>} 拍と間の並び(`{ kana, pitch, sec }` か `{ gap }`)
|
|
74
|
+
* @private
|
|
75
|
+
*/
|
|
76
|
+
function flatten(list, meta) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const q of list) {
|
|
79
|
+
const speedScale = meta ? (+pick(q, 'speedScale', 'speed_scale', 1) || 1) : 1;
|
|
80
|
+
const pitchScale = meta ? (+pick(q, 'pitchScale', 'pitch_scale', 0) || 0) : 0;
|
|
81
|
+
const intoScale = meta ? +pick(q, 'intonationScale', 'intonation_scale', 1) : 1;
|
|
82
|
+
const pauseScale = meta ? (+pick(q, 'pauseLengthScale', 'pause_length_scale', 1) || 1) : 1;
|
|
83
|
+
const pre = meta ? (+pick(q, 'prePhonemeLength', 'pre_phoneme_length', 0) || 0) : 0;
|
|
84
|
+
const post = meta ? (+pick(q, 'postPhonemeLength', 'post_phoneme_length', 0) || 0) : 0;
|
|
85
|
+
const phrases = q.accentPhrases || q.accent_phrases || [];
|
|
86
|
+
// 抑揚は、文まるごとの平均からの差を伸ばす。声の高さそのものは動かさない。
|
|
87
|
+
// 無声化した拍(高さを持たない)は、平均に入れない
|
|
88
|
+
const said = [];
|
|
89
|
+
for (const ph of phrases) {
|
|
90
|
+
for (const mo of ph.moras || []) if (+mo.pitch > 0) said.push(+mo.pitch);
|
|
91
|
+
}
|
|
92
|
+
const mid = said.length ? said.reduce((a, b) => a + b, 0) / said.length : 0;
|
|
93
|
+
const into = intoScale >= 0 ? intoScale : 1;
|
|
94
|
+
if (pre > 0) out.push({ gap: pre / speedScale });
|
|
95
|
+
for (const ph of phrases) {
|
|
96
|
+
const moras = ph.moras || [];
|
|
97
|
+
for (const mo of moras) {
|
|
98
|
+
const p = +mo.pitch || 0;
|
|
99
|
+
out.push({
|
|
100
|
+
kana: String(mo.text || ''),
|
|
101
|
+
// `pitchScale` はオクターブなので、対数へ直してから足す
|
|
102
|
+
pitch: p > 0 ? mid + (p - mid) * into + pitchScale * Math.LN2 : 0,
|
|
103
|
+
sec: moraSec(mo) / speedScale,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const pau = ph.pauseMora || ph.pause_mora;
|
|
107
|
+
if (pau) out.push({ gap: moraSec(pau) * pauseScale / speedScale });
|
|
108
|
+
}
|
|
109
|
+
if (post > 0) out.push({ gap: post / speedScale });
|
|
110
|
+
out.push({ end: true }); // セリフの切れ目
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* VOICEVOX の会話情報を、RetroTalk 向けのテキストに変換します。
|
|
117
|
+
*
|
|
118
|
+
* VOICEVOX から抑揚と速度と音声を読み込めます。話者の情報は落としています。
|
|
119
|
+
* 拍ごとの高さと長さは `@p` と `@s` に、間は `、` と `。` に置き換えます。
|
|
120
|
+
*
|
|
121
|
+
* VOICEVOX の無声音の指定は無視します。(RetroTalk は自動で無声音の正誤とします)。
|
|
122
|
+
*
|
|
123
|
+
* @param {object|string} src `.vvproj` か `/audio_query` の返り
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* `speed` は RetroTalk 側の速さ。長さはこれに合わせて置き直します(高さはそのまま)。
|
|
126
|
+
* `usePitch` を外すと `@p` を、`useLength` を外すと `@s` を書きません。
|
|
127
|
+
* `useMeta` を外すと VOICEVOX 側のパラメータ(話速・抑揚・間)を掛けません。
|
|
128
|
+
* すべて外すと、読みと切れ目だけになります
|
|
129
|
+
* @returns {string} RetroTalk の文
|
|
130
|
+
* @alias TalkAudio.voicevoxText
|
|
131
|
+
* @static
|
|
132
|
+
*/
|
|
133
|
+
export function voicevoxText(src, opts = {}) {
|
|
134
|
+
let data = src;
|
|
135
|
+
if (typeof data === 'string') {
|
|
136
|
+
try { data = JSON.parse(data); } catch (e) { data = null; }
|
|
137
|
+
}
|
|
138
|
+
const list = queries(data);
|
|
139
|
+
if (!list.length) {
|
|
140
|
+
console.warn('[RetroTalk] VOICEVOX の読みが見つかりません'
|
|
141
|
+
+ '(.vvproj の talk か、/audio_query の返しを渡してください)');
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
const usePitch = opts.usePitch !== false;
|
|
145
|
+
const useLength = opts.useLength !== false;
|
|
146
|
+
const flat = flatten(list, opts.useMeta !== false);
|
|
147
|
+
|
|
148
|
+
// 長さだけ、RetroTalk の速さに合わせて置き直す。
|
|
149
|
+
// VOICEVOX の秒数をそのまま使うと、RetroTalk の拍の長さと噛み合わない
|
|
150
|
+
const base0 = +opts.speed > 0 ? +opts.speed : 1;
|
|
151
|
+
const secs = flat.filter((f) => f.sec > 0).map((f) => f.sec);
|
|
152
|
+
const midSec = secs.length ? secs.reduce((a, b) => a + b, 0) / secs.length : 0;
|
|
153
|
+
/** 高さは、そのまま Hz にする(`pitch` は自然対数の Hz) */
|
|
154
|
+
const toHz = (p) => Math.max(50, Math.min(800, Math.round(Math.exp(p))));
|
|
155
|
+
/**
|
|
156
|
+
* 長さの値を変換する(%)。
|
|
157
|
+
*
|
|
158
|
+
* VOICEVOX の拍は 2 倍以上に伸び縮みするが、
|
|
159
|
+
* RetroTalk の拍はもともと子音のぶんだけで長さが違う。、
|
|
160
|
+
* そのまま扱うと速くなるところが速くなりすぎる。平方根で調整して 上と下も抑制する。
|
|
161
|
+
*/
|
|
162
|
+
const toSpeed = (sec) => Math.round(100 * Math.max(0.6, Math.min(1.8,
|
|
163
|
+
base0 * Math.sqrt(midSec / sec))));
|
|
164
|
+
|
|
165
|
+
let out = '';
|
|
166
|
+
let speed = Math.round(base0 * 100);
|
|
167
|
+
let hz = 0;
|
|
168
|
+
for (const f of flat) {
|
|
169
|
+
if (f.end) { out += '。'; continue; }
|
|
170
|
+
if (f.gap !== undefined) {
|
|
171
|
+
// 間は、書ける長さに丸める。`、` が 0.12 秒、`。` が 0.24 秒。
|
|
172
|
+
// 秒より細かいところは移らない。
|
|
173
|
+
// 長さを引き継がないときは、切れ目だけを `、` で置く
|
|
174
|
+
if (!useLength) out += '、';
|
|
175
|
+
else if (f.gap >= 0.2) out += '。';
|
|
176
|
+
else if (f.gap >= 0.08) out += '、';
|
|
177
|
+
else out += ' ';
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (usePitch && f.pitch > 0 && toHz(f.pitch) !== hz) {
|
|
181
|
+
hz = toHz(f.pitch);
|
|
182
|
+
out += '@p' + hz;
|
|
183
|
+
}
|
|
184
|
+
if (useLength && midSec > 0 && f.sec > 0) {
|
|
185
|
+
const want = toSpeed(f.sec);
|
|
186
|
+
if (want !== speed) { speed = want; out += '@s' + speed; }
|
|
187
|
+
}
|
|
188
|
+
out += f.kana;
|
|
189
|
+
}
|
|
190
|
+
// 切れ目が重なったところは 1 つにする。長いほうを残す。
|
|
191
|
+
// セリフの終わりの 。 の直後に、次のセリフの頭の間が続くことがある
|
|
192
|
+
return out
|
|
193
|
+
.replace(/[、。 ]*。[、。 ]*/g, '。')
|
|
194
|
+
.replace(/[、 ]*、[、 ]*/g, '、')
|
|
195
|
+
.replace(/ +/g, ' ')
|
|
196
|
+
.replace(/^[、。 ]+/, '')
|
|
197
|
+
.trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* データが正しく読めるかどうかを調べます。
|
|
202
|
+
*
|
|
203
|
+
* `voicevoxText()` は読み取れたものだけを返す。
|
|
204
|
+
* 読めなかったときは空の文字列が返る。
|
|
205
|
+
*
|
|
206
|
+
* @param {object|string} src `.vvproj` か `/audio_query` の返り
|
|
207
|
+
* @returns {{ok:boolean, version:string, lines:number, moras:number}}
|
|
208
|
+
* `version` は VOICEVOX のバージョン、`lines` は文の数、`moras` は拍の数
|
|
209
|
+
* @alias TalkAudio.voicevoxInfo
|
|
210
|
+
* @static
|
|
211
|
+
*/
|
|
212
|
+
export function voicevoxInfo(src) {
|
|
213
|
+
let data = src;
|
|
214
|
+
if (typeof data === 'string') {
|
|
215
|
+
try { data = JSON.parse(data); } catch (e) { data = null; }
|
|
216
|
+
}
|
|
217
|
+
const list = queries(data);
|
|
218
|
+
let moras = 0;
|
|
219
|
+
for (const q of list) {
|
|
220
|
+
for (const ph of q.accentPhrases || q.accent_phrases || []) {
|
|
221
|
+
moras += (ph.moras || []).length;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
ok: list.length > 0 && moras > 0,
|
|
226
|
+
version: String((data && (data.appVersion || data.app_version)) || ''),
|
|
227
|
+
lines: list.length,
|
|
228
|
+
moras,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* VOICEVOX の読みを、拍の並びに変換します。`parseTalk()` と同じ形で返します。
|
|
234
|
+
*
|
|
235
|
+
* いったん RetroTalk の文に変換してから読み直します。、
|
|
236
|
+
* 表示される文と実際になる音が必ず同じものになります。
|
|
237
|
+
*
|
|
238
|
+
* @param {object|string} src `.vvproj` か `/audio_query` の返り(文字列でもよい)
|
|
239
|
+
* @param {object} [opts] `voicevoxText()` と同じ
|
|
240
|
+
* @returns {Array} 拍の並び
|
|
241
|
+
* @private
|
|
242
|
+
*/
|
|
243
|
+
export function parseVoicevox(src, opts = {}) {
|
|
244
|
+
return parseTalk(voicevoxText(src, opts));
|
|
245
|
+
}
|
package/src/wav.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 harayoki
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 音声データを WAV(RIFF / 16 ビット PCM)のバイト列に変換します。
|
|
6
|
+
*
|
|
7
|
+
* 外部に保存したいときに使います。
|
|
8
|
+
*
|
|
9
|
+
* `renderTalk()` が返すのは `{ rate, data }` なので、包み直してから渡します。
|
|
10
|
+
*
|
|
11
|
+
* ```js
|
|
12
|
+
* const got = MmsxxRetroTalk.renderTalk('コンニチワ');
|
|
13
|
+
* const wav = MmsxxRetroTalk.encodeWAV({
|
|
14
|
+
* sampleRate: got.rate, numberOfChannels: 1, length: got.data.length,
|
|
15
|
+
* getChannelData: () => got.data,
|
|
16
|
+
* });
|
|
17
|
+
* const url = URL.createObjectURL(new Blob([wav], { type: 'audio/wav' }));
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Web Audio を必要としないため、ブラウザの外でも実行できます。
|
|
21
|
+
*
|
|
22
|
+
* @param {{sampleRate: number, numberOfChannels: number, length: number,
|
|
23
|
+
* getChannelData: function(number): Float32Array}} buffer
|
|
24
|
+
* AudioBuffer、または同じ形のオブジェクト
|
|
25
|
+
* @returns {Uint8Array} 44 バイトのヘッダーと、それに続く音声データ
|
|
26
|
+
* @alias TalkAudio.encodeWAV
|
|
27
|
+
* @static
|
|
28
|
+
*/
|
|
29
|
+
export function encodeWAV(buffer) {
|
|
30
|
+
const ch = buffer.numberOfChannels;
|
|
31
|
+
const frames = buffer.length;
|
|
32
|
+
const rate = buffer.sampleRate;
|
|
33
|
+
const bytes = 2; // 16 ビット
|
|
34
|
+
const dataSize = frames * ch * bytes;
|
|
35
|
+
const out = new Uint8Array(44 + dataSize);
|
|
36
|
+
const view = new DataView(out.buffer);
|
|
37
|
+
let p = 0;
|
|
38
|
+
const str = (t) => { for (const c of t) out[p++] = c.charCodeAt(0); };
|
|
39
|
+
const u32 = (v) => { view.setUint32(p, v, true); p += 4; };
|
|
40
|
+
const u16 = (v) => { view.setUint16(p, v, true); p += 2; };
|
|
41
|
+
|
|
42
|
+
str('RIFF'); u32(36 + dataSize); str('WAVE');
|
|
43
|
+
str('fmt '); u32(16); u16(1); u16(ch);
|
|
44
|
+
u32(rate); u32(rate * ch * bytes); u16(ch * bytes); u16(8 * bytes);
|
|
45
|
+
str('data'); u32(dataSize);
|
|
46
|
+
|
|
47
|
+
const src = [];
|
|
48
|
+
for (let c = 0; c < ch; c++) src.push(buffer.getChannelData(c));
|
|
49
|
+
for (let i = 0; i < frames; i++) {
|
|
50
|
+
for (let c = 0; c < ch; c++) {
|
|
51
|
+
const v = Math.max(-1, Math.min(1, src[c][i]));
|
|
52
|
+
// 負の側だけ 32768 倍するのは、-1 をちょうど -32768 に合わせるため。
|
|
53
|
+
// 両側を 32767 倍すると、いちばん低いところがわずかに浅くなる
|
|
54
|
+
view.setInt16(p, Math.round(v < 0 ? v * 0x8000 : v * 0x7fff), true);
|
|
55
|
+
p += 2;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|