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/src/kana.js ADDED
@@ -0,0 +1,388 @@
1
+ // SPDX-FileCopyrightText: 2026 harayoki
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // しゃべらせる文字を読み取ります。音は作りません。
5
+ //
6
+ // カタカナ(ひらがなも読めます)を 1 拍ずつに分けて、子音と母音の組にします。
7
+ // 数字と英字は決まった読みに直し、記号は間や抑揚の指示として拾います。
8
+ //
9
+ // ここで読み取ったものを talk.js が波形にします。
10
+ // aquestalk.js と voicevox.js も、別の書き方をこの形に揃えてから渡してきます。
11
+
12
+ /**
13
+ * 母音のフォルマント [F1, F2, F3](Hz)。日本語の 5 母音
14
+ * @private
15
+ */
16
+ const VOWELS = {
17
+ a: [800, 1200, 2800],
18
+ i: [300, 2300, 3000],
19
+ u: [350, 1200, 2200],
20
+ e: [500, 1900, 2600],
21
+ o: [500, 900, 2600],
22
+ n: [280, 1200, 2500], // 撥音「ン」。鼻に抜ける音として弱く長く鳴らす
23
+ };
24
+
25
+ /**
26
+ * 子音の作り方。
27
+ * burst = 短い破裂(カ行・タ行・パ行)
28
+ * noise = こすれる音(サ行・ハ行)
29
+ * voiced = 有声のまま狭める(ナ行・マ行・ラ行・ヤ行・ワ行)
30
+ * なし = 母音だけ(ア行)
31
+ * @private
32
+ */
33
+ const CONSONANTS = {
34
+ k: { kind: 'burst', dur: 0.018, gap: 0.030, tone: 2200 },
35
+ g: { kind: 'burst', dur: 0.016, gap: 0.012, tone: 1400, voiced: true },
36
+ s: { kind: 'noise', dur: 0.075, tone: 5200 },
37
+ z: { kind: 'noise', dur: 0.045, tone: 4200, voiced: true },
38
+ t: { kind: 'burst', dur: 0.014, gap: 0.026, tone: 3000 },
39
+ d: { kind: 'burst', dur: 0.014, gap: 0.010, tone: 1600, voiced: true },
40
+ n: { kind: 'voiced', dur: 0.045, nasal: true },
41
+ h: { kind: 'noise', dur: 0.060, tone: 1800 },
42
+ b: { kind: 'burst', dur: 0.014, gap: 0.010, tone: 900, voiced: true },
43
+ p: { kind: 'burst', dur: 0.014, gap: 0.024, tone: 1200 },
44
+ m: { kind: 'voiced', dur: 0.045, nasal: true },
45
+ y: { kind: 'voiced', dur: 0.035 },
46
+ r: { kind: 'voiced', dur: 0.028 },
47
+ w: { kind: 'voiced', dur: 0.035 },
48
+ f: { kind: 'noise', dur: 0.060, tone: 2600 },
49
+ ts: { kind: 'burst', dur: 0.014, gap: 0.020, tone: 4600 },
50
+ ch: { kind: 'noise', dur: 0.055, tone: 3400 },
51
+ sh: { kind: 'noise', dur: 0.070, tone: 3000 },
52
+ j: { kind: 'noise', dur: 0.045, tone: 2800, voiced: true },
53
+ };
54
+
55
+ /**
56
+ * カタカナ 1 文字 -> [子音, 母音]。'' は子音なし
57
+ * @private
58
+ */
59
+ const KANA = {
60
+ ア: ['', 'a'], イ: ['', 'i'], ウ: ['', 'u'], エ: ['', 'e'], オ: ['', 'o'],
61
+ カ: ['k', 'a'], キ: ['k', 'i'], ク: ['k', 'u'], ケ: ['k', 'e'], コ: ['k', 'o'],
62
+ ガ: ['g', 'a'], ギ: ['g', 'i'], グ: ['g', 'u'], ゲ: ['g', 'e'], ゴ: ['g', 'o'],
63
+ サ: ['s', 'a'], シ: ['sh', 'i'], ス: ['s', 'u'], セ: ['s', 'e'], ソ: ['s', 'o'],
64
+ ザ: ['z', 'a'], ジ: ['j', 'i'], ズ: ['z', 'u'], ゼ: ['z', 'e'], ゾ: ['z', 'o'],
65
+ タ: ['t', 'a'], チ: ['ch', 'i'], ツ: ['ts', 'u'], テ: ['t', 'e'], ト: ['t', 'o'],
66
+ ダ: ['d', 'a'], ヂ: ['j', 'i'], ヅ: ['z', 'u'], デ: ['d', 'e'], ド: ['d', 'o'],
67
+ ナ: ['n', 'a'], ニ: ['n', 'i'], ヌ: ['n', 'u'], ネ: ['n', 'e'], ノ: ['n', 'o'],
68
+ ハ: ['h', 'a'], ヒ: ['h', 'i'], フ: ['f', 'u'], ヘ: ['h', 'e'], ホ: ['h', 'o'],
69
+ バ: ['b', 'a'], ビ: ['b', 'i'], ブ: ['b', 'u'], ベ: ['b', 'e'], ボ: ['b', 'o'],
70
+ パ: ['p', 'a'], ピ: ['p', 'i'], プ: ['p', 'u'], ペ: ['p', 'e'], ポ: ['p', 'o'],
71
+ マ: ['m', 'a'], ミ: ['m', 'i'], ム: ['m', 'u'], メ: ['m', 'e'], モ: ['m', 'o'],
72
+ ヤ: ['y', 'a'], ユ: ['y', 'u'], ヨ: ['y', 'o'],
73
+ ラ: ['r', 'a'], リ: ['r', 'i'], ル: ['r', 'u'], レ: ['r', 'e'], ロ: ['r', 'o'],
74
+ ワ: ['w', 'a'], ヲ: ['', 'o'], ン: ['', 'n'],
75
+ // 小書きの「ヮ」。「クヮ」は本来 1 拍ですが、唇を丸めた k は用意していません。
76
+ // ワ と同じ 1 拍として扱い、`( )` でくくれば 1 拍として歌えます
77
+ ヮ: ['w', 'a'],
78
+ // 古い仮名。いまは イ エ と同じに読みます。飛ばしても得は無く、
79
+ // 読めるようにしておけば古い綴りの詞をそのまま渡せます
80
+ ヰ: ['', 'i'], ヱ: ['', 'e'],
81
+ ヴ: ['b', 'u'],
82
+ };
83
+ /**
84
+ * 小さい仮名。直前の拍の母音を差し替える
85
+ * @private
86
+ */
87
+ const SMALL = { ャ: 'a', ュ: 'u', ョ: 'o', ァ: 'a', ィ: 'i', ゥ: 'u', ェ: 'e', ォ: 'o' };
88
+
89
+ /**
90
+ * 拗音になる小書き。`ャ` `ュ` `ョ` がイ段のあとに来たときだけです。
91
+ *
92
+ * 「キャ」は「カ」の母音を差し替えたものではなく、子音そのものが変わります。
93
+ * 舌が硬口蓋(上あごの高いところ)へ寄ったまま `k` を作ります。
94
+ * 母音だけ差し替えていたころ、「キャ」は「カ」と同じ音になっていました。
95
+ *
96
+ * `ァ` `ィ` `ゥ` `ェ` `ォ` は別です。「フェ」「ジェ」「ティ」のように
97
+ * 外来語を書くためのもので、子音は変わりません(母音の差し替えだけです)。
98
+ * @private
99
+ */
100
+ const PALATAL_SMALL = 'ャュョ';
101
+
102
+ /**
103
+ * 小書きの仮名を、直前の拍へ効かせる。
104
+ * @param {Array} out 拍の並び
105
+ * @param {string} ch 小書きの仮名
106
+ * @private
107
+ */
108
+ function applySmall(out, ch) {
109
+ if (!out.length) return;
110
+ const last = out[out.length - 1];
111
+ // イ段 + ャュョ は拗音。子音が口蓋化する(talk.js が渡りを入れる)
112
+ if (PALATAL_SMALL.includes(ch) && last.v === 'i') last.pal = true;
113
+ last.v = SMALL[ch];
114
+ }
115
+
116
+ /**
117
+ * 数字の読み。1 文字ずつ、決まった読みで読みます。
118
+ *
119
+ * `100` は「ヒャク」ではなく「イチ ゼロ ゼロ」です。桁で読もうとすると
120
+ * 「どこからどこまでが 1 つの数か」を決めることになり、そこから先は
121
+ * 辞書の仕事になります(「1000」は「セン」か「イッセン」か、
122
+ * 「4」は「ヨン」か「シ」か、前後で変わります)。
123
+ * そう読ませたいときは、かなで書いてください。
124
+ * @private
125
+ */
126
+ const DIGITS = {
127
+ 0: 'ゼロ', 1: 'イチ', 2: 'ニ', 3: 'サン', 4: 'ヨン',
128
+ 5: 'ゴ', 6: 'ロク', 7: 'ナナ', 8: 'ハチ', 9: 'キュウ',
129
+ };
130
+
131
+ /**
132
+ * 英字の読み。1 字ずつ、字の名前で読みます。
133
+ *
134
+ * `CPU` は「シーピーユー」です。単語としては読みません。英語の綴りと読みの
135
+ * 対応は規則になっていないので、読むには辞書が要ります(`make` と `machine`、
136
+ * `read` と `read`)。字の名前なら 26 個で足ります。
137
+ * @private
138
+ */
139
+ const LATIN = {
140
+ A: 'エー', B: 'ビー', C: 'シー', D: 'ディー', E: 'イー', F: 'エフ',
141
+ G: 'ジー', H: 'エイチ', I: 'アイ', J: 'ジェー', K: 'ケー', L: 'エル',
142
+ M: 'エム', N: 'エヌ', O: 'オー', P: 'ピー', Q: 'キュー', R: 'アール',
143
+ S: 'エス', T: 'ティー', U: 'ユー', V: 'ブイ', W: 'ダブリュー',
144
+ X: 'エックス', Y: 'ワイ', Z: 'ゼット',
145
+ };
146
+
147
+ /**
148
+ * 全角を半角へ寄せる。数字も英字も、全角で書かれることがある
149
+ * @private
150
+ */
151
+ function toHalf(ch) {
152
+ return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
153
+ ? String.fromCharCode(ch.charCodeAt(0) - 0xfee0) : ch;
154
+ }
155
+
156
+ /**
157
+ * その字に決まった読みがあれば返す(数字と英字)。無ければ `null`
158
+ * @private
159
+ */
160
+ function readingOf(ch) {
161
+ const half = toHalf(ch);
162
+ return DIGITS[half] || LATIN[half.toUpperCase()] || null;
163
+ }
164
+
165
+ /**
166
+ * ひらがなをカタカナへ寄せる
167
+ * @private
168
+ */
169
+ function toKatakana(text) {
170
+ return text.replace(/[ぁ-ゖ]/g, (c) =>
171
+ String.fromCharCode(c.charCodeAt(0) + 0x60));
172
+ }
173
+
174
+ /**
175
+ * 読める文字かどうかだけを調べます。読み上げはしません。
176
+ *
177
+ * 辞書を持っていないので漢字は読めません(数字と英字は 1 字ずつ読みます)。
178
+ * `parseTalk()` は読めない文字を黙って飛ばすため、そのままでは
179
+ * 「なぜかそこだけ抜ける」ことになります。鳴らす前に確かめられるようにしてあります。
180
+ *
181
+ * ```js
182
+ * const got = checkTalk('今日は 3ガツ デス');
183
+ * // { ok: false, kana: 6, unknown: [{ ch: '今', at: 0, kind: 'kanji' }, ...] }
184
+ * ```
185
+ *
186
+ * @param {string} text しゃべらせる文
187
+ * @returns {{ok:boolean, kana:number, unknown:Array<{ch:string, at:number, kind:string}>}}
188
+ * `kind` は `kanji` / `other`
189
+ * @alias TalkAudio.checkTalk
190
+ * @static
191
+ */
192
+ export function checkTalk(text) {
193
+ const s = toKatakana(String(text));
194
+ const unknown = [];
195
+ let kana = 0;
196
+ for (let i = 0; i < s.length; i++) {
197
+ const ch = s[i];
198
+ // 読める字と、意味のある記号は飛ばす
199
+ if (KANA[ch] || SMALL[ch] || ch === 'ー' || ch === 'ッ') { kana++; continue; }
200
+ // 数字と英字は読める。読みの拍のぶんだけ数える
201
+ // (小さい仮名と長音は数に入らない)
202
+ const said = readingOf(ch);
203
+ if (said) { kana += [...said].filter((c) => !SMALL[c] && c !== 'ー').length; continue; }
204
+ if ('  、,。!!??…〜~'.includes(ch)) continue;
205
+ // くくり。歌えば音符 1 つ、しゃべれば持ち上げ。長さは持たない
206
+ if ('()()'.includes(ch)) continue;
207
+ // 三点リーダを半角で書く人もいる。`.` 単体は読めない字のまま
208
+ if (ch === '.' && s.slice(i, i + 3) === '...') { i += 2; continue; }
209
+ // 抑揚を上げ下げする記号。長さを持たないので、かなとしては数えない
210
+ if ('><><'.includes(ch)) continue;
211
+ // アクセントの記号(`'` 核 / `/` 句切り / `+` 続ける / `;` 長めの間)と、
212
+ // 母音を残す `^`、読み飛ばす `_`。どれも長さを持たない
213
+ if ("'\u2018\u2019\uff07/+;^^__".includes(ch)) continue;
214
+ if (ch === '\n' || ch === '\r' || ch === '\t') continue;
215
+ // `@p200` のような指示は読み飛ばす(長さを持たない記号)。
216
+ // 受け付ける記号は parseTalk と揃える。`v`(音量)を落としていたので、
217
+ // 読めているのに「読めない字」と出ていた
218
+ if (ch === '@') {
219
+ const k = s[i + 1];
220
+ let j = i + 2;
221
+ while (j < s.length && s[j] >= '0' && s[j] <= '9') j++;
222
+ if (j > i + 2 && 'psvPSV'.includes(k)) { i = j - 1; continue; }
223
+ }
224
+ const kind = /[\u4e00-\u9fff\u3005]/.test(ch) ? 'kanji' : 'other';
225
+ unknown.push({ ch, at: i, kind });
226
+ }
227
+ return { ok: unknown.length === 0, kana, unknown };
228
+ }
229
+
230
+ /**
231
+ * 文字列を拍の並びに変換します。
232
+ *
233
+ * 波形を作る前の段です。`renderTalk()` が内部で呼ぶので、ふつうは直接
234
+ * 使いません。書いたものがどう読まれるかを確かめたいときに使えます。
235
+ *
236
+ * ```js
237
+ * const moras = parseTalk('コンニチワ、@p200 イイ テンキ デス。');
238
+ * ```
239
+ *
240
+ * 返す拍は次の値を持ちます。
241
+ * c 子音(無ければ '')
242
+ * v 母音(`n` は撥音)
243
+ * hold 母音を伸ばす倍率(「ー」で増える)
244
+ * stop 直前で詰まる(「ッ」)
245
+ * pause 休み(空白、読点)
246
+ * set 途中で変える指示(`@p` `@s` `@v`)
247
+ * tune 抑揚の高さを上げ下げする(`>` `<`。半音単位。長さは持たない)
248
+ * brk 文の切れ目(読点、句点)。空白には付かない
249
+ * mark どの記号で切れたか(`.` `,` `!` `?` `!?` `…`)
250
+ * wave 伸ばしながら揺らす(`〜`)
251
+ * pal 拗音(「キャ」のように子音が口蓋化する)
252
+ * tie 直前の拍と同じ音符で歌う(`( )` でくくったところ)
253
+ * lift しゃべるときに持ち上げる(`( )` でくくったところ)
254
+ *
255
+ * @param {string} text かな。ひらがなでもカタカナでもかまいません(内部で揃えます)
256
+ * @returns {Array<object>} 拍の並び
257
+ * @alias TalkAudio.parseTalk
258
+ * @static
259
+ */
260
+ export function parseTalk(text) {
261
+ const s = toKatakana(String(text));
262
+ const out = [];
263
+ let pendingStop = false;
264
+ // 次の 1 拍は母音を無声化しない(`^`)。記号は長さを持たない
265
+ let pendingVoiced = false;
266
+ // くくったところは、音符 1 つで歌う。
267
+ // 2 つめから `tie` を立てて、talk.js が 1 つの音符を分けて当てる。
268
+ // 「チュー」のように字は 2 つでも 1 拍のところを、歌詞のまま書けるようにする
269
+ let group = 0;
270
+ // くくったところは持ち上げる。`!` は最後の 1 字しか上げないので、
271
+ // 文まるごと張りたいときはここでくくる(talk.js が上げる)
272
+ let lift = false;
273
+ // 番号で回す。`@p200` のような指示は後ろの数字まで読むので、
274
+ // 1 文字ずつ取り出すだけでは足りない
275
+ for (let i = 0; i < s.length; i++) {
276
+ const ch = s[i];
277
+ if (ch === 'ー') { // 長音: 直前を伸ばす
278
+ if (out.length) out[out.length - 1].hold += 1;
279
+ continue;
280
+ }
281
+ // 伸ばしながら揺らす。`ー` は真っ直ぐ伸びるが、こちらは終わりが揺れる。
282
+ // 「ソウデスネ〜」の、あの言い方(talk.js が揺らす)
283
+ if (ch === '〜' || ch === '~') {
284
+ if (out.length) {
285
+ out[out.length - 1].hold += 1;
286
+ out[out.length - 1].wave = true;
287
+ }
288
+ continue;
289
+ }
290
+ if (ch === 'ッ') { pendingStop = true; continue; }
291
+ // 無声化する母音を、その 1 拍だけ残す。
292
+ //
293
+ // 「デス」の「ス」のように、規則で母音を無声化しているところがある。
294
+ // 残してほしい場面は書いた人にしか分からないので、記号で指定する
295
+ // (`デ^ス` なら「ス」が残る)。
296
+ // 記号列の `_`(無声化)は読み飛ばす。無声化するほうは規則に任せる
297
+ if (ch === '^' || ch === '^') { pendingVoiced = true; continue; }
298
+ // くくりは 1 種類。効き方は、歌っているかどうかで決まる。
299
+ //
300
+ // 歌うときは、くくった中を音符 1 つで歌う(`tie`)。
301
+ // しゃべるときは、くくった中を持ち上げる(`lift`)。
302
+ // 歌には音符が高さを持っていて持ち上げようがなく、しゃべりには音符が無い。
303
+ // 同じ文字列に 2 つの読み方が生じることはなく、渡し方でどちらか決まる。
304
+ // 半角でも全角でもよい
305
+ if (ch === '(' || ch === '(') { group = 1; lift = true; continue; }
306
+ if (ch === ')' || ch === ')') { group = 0; lift = false; continue; }
307
+ // 文の途中で変える指示。`@p200` で高さ、`@s150` で速さ、`@v8` で音量。
308
+ // 書いたところから後ろに効く。
309
+ //
310
+ // 長さを持たない記号なので、置いても文の長さは変わらない
311
+ // (歌のときは休みとして読み飛ばすだけ)
312
+ if (ch === '@') {
313
+ const k = s[i + 1];
314
+ let j = i + 2, n = '';
315
+ while (j < s.length && s[j] >= '0' && s[j] <= '9') n += s[j++];
316
+ i = j - 1;
317
+ const kind = { p: 'pitch', s: 'speed', v: 'vol' }[String(k).toLowerCase()];
318
+ if (n && kind) {
319
+ // 音量だけ 0〜15 で書く。0〜1 に直して持つ
320
+ const val = kind === 'speed' ? +n / 100
321
+ : kind === 'vol' ? Math.max(0, Math.min(15, +n)) / 15
322
+ : +n;
323
+ out.push({ pause: 0.0001, set: { [kind]: val } });
324
+ }
325
+ continue;
326
+ }
327
+ // 抑揚の高さを、その場から上げ下げする。1 つで半音。
328
+ // しゃべりで 1 オクターブ動かすと別人になってしまうので、半音にしてある。
329
+ // 句読点で 0 に戻る(talk.js が戻す)。文をまたいで持ち越すと、
330
+ // 長い文の途中から声が上がりっぱなしになる
331
+ if (ch === '>' || ch === '>') { out.push({ pause: 0.0001, tune: 1 }); continue; }
332
+ if (ch === '<' || ch === '<') { out.push({ pause: 0.0001, tune: -1 }); continue; }
333
+ if (SMALL[ch]) { applySmall(out, ch); continue; }
334
+ // 区切りには印を付ける。空白は間でしかないが、読点と句点は文の切れ目なので、
335
+ // 抑揚を取り直す目印になる(talk.js が見る)。
336
+ // アンダースコアも間。空白と同じだが、目で見て分かるので、
337
+ // 区切りを数えたいところで使える(記号列の `_` とは別のもの)
338
+ if (ch === ' ' || ch === ' ' || ch === '_' || ch === '_') {
339
+ out.push({ pause: 0.12 }); continue;
340
+ }
341
+ if (ch === '、' || ch === ',') { out.push({ pause: 0.12, brk: true, mark: ',' }); continue; }
342
+ // どの記号で切れたかも持つ。「。」と「!」と「?」では言い方が変わるので、
343
+ // 切れ目としてまとめてしまうと、そこを鳴らし分けられない(talk.js が見る)
344
+ if ('。!!??…'.includes(ch) || (ch === '.' && s.slice(i, i + 3) === '...')) {
345
+ const norm = (c) => (c === '!' ? '!' : c === '?' ? '?' : c);
346
+ const a = norm(ch), b = norm(s[i + 1] || '');
347
+ let mark;
348
+ // `!?` は 2 つではなく 1 つの記号として扱う。驚いて問い返すのは
349
+ // 「言い切る」と「尋ねる」を足したものなので、別に扱う
350
+ if ((a === '!' && b === '?') || (a === '?' && b === '!')) { mark = '!?'; i++; }
351
+ else if (a === '…') mark = '…';
352
+ else if (a === '.') { mark = '…'; i += 2; }
353
+ else mark = a === '!' ? '!' : a === '?' ? '?' : '.';
354
+ // `……` は 1 つにまとめる。続けて書いても切れ目は 1 回
355
+ if (mark === '…') while (s[i + 1] === '…') i++;
356
+ // 言いさしは間も長い。黙るところまでが言いさし
357
+ out.push({ pause: mark === '…' ? 0.42 : 0.24, brk: true, mark });
358
+ continue;
359
+ }
360
+ // 数字と英字は読みへ展開する。決まった読みをそのまま並べるだけで、
361
+ // 前後は見ない(理由は DIGITS と LATIN の説明にある)
362
+ const said = readingOf(ch);
363
+ if (said) {
364
+ for (const c of said) {
365
+ if (c === 'ー') { // 「エー」の「ー」
366
+ if (out.length) out[out.length - 1].hold += 1;
367
+ continue;
368
+ }
369
+ if (c === 'ッ') { pendingStop = true; continue; } // 「エックス」の「ッ」
370
+ if (SMALL[c]) { applySmall(out, c); continue; } // 「キュー」の「ュ」
371
+ const kk = KANA[c];
372
+ out.push({ c: kk[0], v: kk[1], hold: 0, stop: pendingStop,
373
+ ...(pendingVoiced ? { devoice: false } : null) });
374
+ pendingStop = false;
375
+ pendingVoiced = false;
376
+ }
377
+ continue;
378
+ }
379
+ const k = KANA[ch];
380
+ if (!k) continue; // 読めない字は飛ばす
381
+ out.push({ c: k[0], v: k[1], hold: 0, stop: pendingStop, tie: group > 1, lift,
382
+ ...(pendingVoiced ? { devoice: false } : null) });
383
+ if (group) group++;
384
+ pendingStop = false;
385
+ pendingVoiced = false;
386
+ }
387
+ return out;
388
+ }
@@ -0,0 +1,163 @@
1
+ // SPDX-FileCopyrightText: 2026 harayoki
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // 音を出すための基底クラスです。AudioContext と出力先、再生中の音の管理だけを持ちます。
5
+ //
6
+ // 音声合成そのものは扱いません。TalkAudio がこれを継承して、
7
+ // 音声の登録と再生を足します。
8
+
9
+ /**
10
+ * 音を出すための基底クラスです。
11
+ *
12
+ * `AudioContext` と出力先、再生中の音の管理だけを持ちます。音声合成は扱いません。
13
+ * 使うのは `MmsxxRetroTalk` のほうで、このクラスはそれが継承しているものです。
14
+ */
15
+ export class SimpleAudio {
16
+ /**
17
+ * @param {AudioContext} ctx 呼び出し側で作った AudioContext。
18
+ * これが無いと再生できません(`unlock()` を参照)
19
+ * @param {object} [opts]
20
+ * @param {number} [opts.maxVoices=8] 同時に再生できる音の数
21
+ */
22
+ constructor(ctx, opts = {}) {
23
+ /** @type {AudioContext|null} */
24
+ this.ctx = ctx || null;
25
+ /** 再生中の音。空きがあれば重ねて鳴ります */
26
+ this.seVoices = [];
27
+ this.maxVoices = opts.maxVoices ?? 8;
28
+ /** 継承先が使う枠。このクラスでは 0 のままです */
29
+ this.bgmVoices = 0;
30
+ /** 再生ごとの管理番号。指定して停止するために使います */
31
+ this._seSeq = 0;
32
+ /** 継承先が使う枠。このクラスでは false のままです */
33
+ this._sePausedAll = false;
34
+ /** 継承先が使う枠。このクラスでは 0 のままです */
35
+ this._keepSec = 0;
36
+ }
37
+
38
+ /**
39
+ * 再生できる状態にします。
40
+ *
41
+ * ユーザーがページを一度操作したあとに呼びます。ブラウザの制限によるものです。
42
+ * `talk()` と `playTalk()` は内部で呼ぶので、通常は呼び出す必要がありません。
43
+ *
44
+ * AudioContext はここでは作りません。呼び出し側で作って渡してください。
45
+ *
46
+ * ```js
47
+ * const voice = new TalkAudio(new AudioContext());
48
+ * ```
49
+ *
50
+ * ブラウザが同時に作れる AudioContext の数には上限があり、
51
+ * ライブラリがそれぞれ勝手に作ると足りなくなります。
52
+ *
53
+ * @throws {Error} AudioContext が渡されていないとき
54
+ */
55
+ unlock() {
56
+ if (!this.ctx) {
57
+ throw new Error('SimpleAudio: AudioContext を渡してください'
58
+ + ' — new TalkAudio(new AudioContext())');
59
+ }
60
+ if (this.ctx.state === 'suspended') this.ctx.resume();
61
+ }
62
+
63
+ /**
64
+ * 出力の記録を開始します。継承先が実装します。
65
+ * `_out()` から呼ばれるため、ここでは空のまま用意してあります
66
+ */
67
+ _startTap() { /* 継承先が実装する */ }
68
+
69
+ /** 再生が終わった音を片づける */
70
+ _cleanupSE(now) {
71
+ this.seVoices = this.seVoices.filter((v) => {
72
+ if (now < v.endTime) return true;
73
+ // くり返しの途中なら、まだ片づけない
74
+ if (v.left !== 0 && v.timer) return true;
75
+ // 一時停止中のものは、解除したときに鳴り直すので残す。
76
+ // システム側の音は一時停止中でも鳴り切るので、そのまま片づける
77
+ if (!v.system && (v.paused || this._sePausedAll)) return true;
78
+ if (v.timer) { clearTimeout(v.timer); v.timer = 0; }
79
+ try { v.gain.disconnect(); } catch (e) { /* already gone */ }
80
+ return false;
81
+ });
82
+ }
83
+
84
+ /** 再生中の音の数 */
85
+ _usedVoices() {
86
+ return this.bgmVoices + this.seVoices.reduce((n, v) => n + v.voices, 0);
87
+ }
88
+
89
+ /** 音を 1 つ止める */
90
+ _stopVoice(v) {
91
+ if (v.timer) { clearTimeout(v.timer); v.timer = 0; }
92
+ v.left = 0; // 繰り返しの予約が残っていても、もう積まない
93
+ for (const n of v.nodes) { try { n.stop(0); } catch (e) { /* stopped */ } }
94
+ try { v.gain.disconnect(); } catch (e) { /* already gone */ }
95
+ this.seVoices = this.seVoices.filter(x => x !== v);
96
+ }
97
+
98
+ /**
99
+ * 音の出力先を返します。すべての音がここを通るため、
100
+ * ここ 1 か所で全体の消音と音量を扱えます
101
+ */
102
+ _out() {
103
+ if (!this.ctx) return null;
104
+ if (!this._bus) {
105
+ // 出力は 2 段。手前(bus)で記録して、後ろ(master)で消音する。
106
+ // こうしておくと、消音していても記録には鳴っていた音が入る
107
+ this._master = this.ctx.createGain();
108
+ this._master.gain.value = this._muted ? 0 : this.volume;
109
+ this._master.connect(this.ctx.destination);
110
+ this._bus = this.ctx.createGain();
111
+ this._bus.gain.value = 1;
112
+ this._bus.connect(this._master);
113
+ // 記録の指示が先に来ていたら、ここで始める
114
+ if (this._keepSec > 0) this._startTap(this._keepSec);
115
+ }
116
+ return this._bus;
117
+ }
118
+
119
+ /**
120
+ * 消音します。再生は止めずに出力だけを閉じるので、
121
+ * 戻したときは続きから聞こえます。
122
+ *
123
+ * @param {boolean} [on] 省略すると切り替えます
124
+ * @returns {boolean} いま消音しているか
125
+ */
126
+ mute(on) {
127
+ this._muted = (on === undefined) ? !this._muted : !!on;
128
+ this._out(); // 出口を用意させる
129
+ const out = this._master; // 絞るのは後ろの段。記録するほうは絞らない
130
+ if (out) {
131
+ const t = this.ctx.currentTime;
132
+ out.gain.cancelScheduledValues(t);
133
+ // ぷつっと切れないよう、ごく短く滑らせる
134
+ out.gain.setValueAtTime(out.gain.value, t);
135
+ out.gain.linearRampToValueAtTime(this._muted ? 0 : this.volume, t + 0.05);
136
+ }
137
+ return this._muted;
138
+ }
139
+
140
+ /**
141
+ * 音量です。0 〜 8 で、初期値は 1 です。
142
+ *
143
+ * 1 を超えると増幅します。上げすぎると歪むので、聞きながら決めてください。
144
+ * 消音は `mute()` の役目で、このプロパティは音量だけを扱います。
145
+ */
146
+ get volume() { return this._vol == null ? 1 : this._vol; }
147
+
148
+ set volume(v) {
149
+ // 1 を超えてよい。出力には増幅の余裕を取ってある。
150
+ // 初期値は 1 なので、指定しなければ素の音量になる。
151
+ this._vol = Math.max(0, Math.min(8, Number(v) || 0));
152
+ if (this._master && !this._muted) {
153
+ const t = this.ctx.currentTime;
154
+ this._master.gain.cancelScheduledValues(t);
155
+ this._master.gain.setValueAtTime(this._master.gain.value, t);
156
+ this._master.gain.linearRampToValueAtTime(this._vol, t + 0.05);
157
+ }
158
+ }
159
+
160
+ /** いま消音しているか */
161
+ get muted() { return !!this._muted; }
162
+
163
+ }
package/src/sing.js ADDED
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: 2026 harayoki
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // 歌わせるときに使う、小さな MML です。
5
+ //
6
+ // 声の高さと長さを決める命令だけを解釈します。音色や定位のように
7
+ // 声に効かないものは、書かれていたら警告として返します。
8
+
9
+ /**
10
+ * 音名 → 半音
11
+ * @private
12
+ */
13
+ const SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
14
+
15
+ /**
16
+ * 歌わせるための MML を、音符の並びに変換します。
17
+ *
18
+ * `renderTalk()` の `sing` オプションに渡す文字列を解釈するために使います。
19
+ * 直接呼ぶことはありません。
20
+ *
21
+ * 解釈するのは次の命令だけです。
22
+ *
23
+ * | 命令 | 意味 |
24
+ * | --- | --- |
25
+ * | `cdefgab` | 音符。うしろの `+` `#` で半音上げ、`-` で下げ |
26
+ * | `r` | 休み |
27
+ * | `4` `8.` | 長さ。全音符の何分の 1 か。`.` で 1.5 倍 |
28
+ * | `l` | 長さの初期値 |
29
+ * | `o` `>` `<` | オクターブ |
30
+ * | `t` | 速さ。四分音符が 1 分間に何個か |
31
+ * | `&` | 前の音を伸ばす |
32
+ * | `v` | 音量。0〜15 |
33
+ * | `q` | 音の切り方。0〜8。8 で長さいっぱい、4 なら半分で切る |
34
+ *
35
+ * これ以外の命令は、`warn` に名前を入れて読み飛ばします。
36
+ *
37
+ * @param {string} mml 歌の MML
38
+ * @returns {{notes:Array<{hz:number, sec:number, rest:boolean, vol:number, q:number}>,
39
+ * warn:string[]}}
40
+ * `hz` は休みのとき 0、`sec` は秒、`vol` と `q` は 0〜1 に直したもの。
41
+ * `warn` は解釈しなかった命令の一覧
42
+ * @private
43
+ */
44
+ export function parseSing(mml) {
45
+ const src = String(mml).toLowerCase();
46
+ const notes = [];
47
+ const warn = [];
48
+ let octave = 4, defLen = 4, tempo = 120, pos = 0, vol = 1, q = 1;
49
+ /** 同じ命令は 1 回だけ並べる */
50
+ const add = (list, what) => { if (!list.includes(what)) list.push(what); };
51
+
52
+ const readNumber = () => {
53
+ let n = '';
54
+ while (pos < src.length && src[pos] >= '0' && src[pos] <= '9') n += src[pos++];
55
+ return n === '' ? null : parseInt(n, 10);
56
+ };
57
+ const readDuration = () => {
58
+ const len = readNumber() ?? defLen;
59
+ let d = 240 / tempo / Math.max(1, len);
60
+ let dot = d;
61
+ while (src[pos] === '.') { pos++; dot /= 2; d += dot; }
62
+ return d;
63
+ };
64
+
65
+ while (pos < src.length) {
66
+ const ch = src[pos++];
67
+ if (' \n\t\r|'.includes(ch)) continue;
68
+ if (SEMI[ch] !== undefined) {
69
+ let semi = SEMI[ch];
70
+ while (src[pos] === '+' || src[pos] === '#') { semi++; pos++; }
71
+ while (src[pos] === '-') { semi--; pos++; }
72
+ const sec = readDuration();
73
+ const midi = (octave + 1) * 12 + semi;
74
+ notes.push({ hz: 440 * Math.pow(2, (midi - 69) / 12), sec, rest: false, vol, q });
75
+ continue;
76
+ }
77
+ if (ch === 'r') { notes.push({ hz: 0, sec: readDuration(), rest: true, vol, q: 1 }); continue; }
78
+ if (ch === '&') {
79
+ // 前の音を伸ばす。うしろに書いた高さは見ない(長さだけ足す)
80
+ while (pos < src.length && ' \n\t\r'.includes(src[pos])) pos++;
81
+ if (SEMI[src[pos]] !== undefined && notes.length) {
82
+ pos++;
83
+ while ('+#-'.includes(src[pos])) pos++;
84
+ notes[notes.length - 1].sec += readDuration();
85
+ }
86
+ continue;
87
+ }
88
+ if (ch === 'o') { octave = Math.max(1, Math.min(8, readNumber() ?? octave)); continue; }
89
+ if (ch === '>') { octave = Math.min(8, octave + 1); continue; }
90
+ if (ch === '<') { octave = Math.max(1, octave - 1); continue; }
91
+ if (ch === 'l') { defLen = readNumber() ?? defLen; continue; }
92
+ if (ch === 't') { tempo = Math.max(20, readNumber() ?? tempo); continue; }
93
+ // 中身ごと読み飛ばして、命令 1 つにつき 1 回だけ warn に入れる。
94
+ // `@{pulse50}` を 1 字ずつ並べても、どこが悪いのか分からない
95
+ if (ch === '@') {
96
+ let kind = '';
97
+ if (src[pos] && src[pos] !== '{') kind = src[pos++];
98
+ if (src[pos] === '{') { while (pos < src.length && src[pos] !== '}') pos++; pos++; }
99
+ else readNumber();
100
+ add(warn, '@' + kind);
101
+ continue;
102
+ }
103
+ // 和音は囲みごと読み飛ばす。声は 1 つしか出せない
104
+ if (ch === "'") {
105
+ while (pos < src.length && src[pos] !== "'") pos++;
106
+ pos++;
107
+ readDuration();
108
+ add(warn, "'…'");
109
+ continue;
110
+ }
111
+ // 0〜15 を 0〜1 に直して持つ。書いた場所から後ろに効く
112
+ if (ch === 'v') {
113
+ vol = Math.max(0, Math.min(15, readNumber() ?? 15)) / 15;
114
+ continue;
115
+ }
116
+ // 0〜8 を 0〜1 に直して持つ。切ったぶんは次の音符までの間になる
117
+ if (ch === 'q') {
118
+ q = Math.max(0, Math.min(8, readNumber() ?? 8)) / 8;
119
+ continue;
120
+ }
121
+ if ('$p'.includes(ch)) { readNumber(); add(warn, ch); continue; }
122
+ if ('[]'.includes(ch)) { readNumber(); add(warn, ch); continue; }
123
+ add(warn, ch);
124
+ }
125
+ return { notes, warn };
126
+ }