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