はじめに
ベトナム語を勉強していると、わりと早い段階で「読めるけど打てない」という壁にぶつかります。
原因は 声調記号(ダイアクリティカルマーク) です。Vịnh Hạ Long rất nổi tiếng. の1文だけでも ị ạ ấ ổ ế と5つ出てきます。日本語キーボードのままでは1文字も打てません。
普通は UniKey などのベトナム語 IME を入れて TELEX 入力に切り替えるのですが、
- 日本語 IME と併用すると切り替えが地味に面倒
- スマホでは入れられない
- そもそも「打つ練習」がしたいのに、IME を入れる時点で心理的ハードルがある
ということで、ブラウザだけで完結する TELEX 入力練習アプリを作りました。UniKey なしで chaof と打つと chào になります。
作ったもの
Tiếng Việt Drill — ビルド不要の静的 Web アプリです。
学習の流れはこうなっています。
- 画像と訳だけが先に表示される(ベトナム語はまだ伏せられている = アクティブリコール)
- 声調記号を含むフレーズなら、表示直前に TELEX ヒントが入力欄の下に出る(例:
Vinhj Haj Long raats nooir tieengs.) - ベトナム語フレーズが表示され、2回自動で読み上げ(2回目はゆっくり)
- 読み上げ後は自動で音声入力モードに切り替わり、発音をチェック
- キーボードで正しく打てたら次のフレーズへ
あいさつ・食べ物・動物・花・旅行・地名の6カテゴリ、全62フレーズ。UI は11言語対応で、navigator.languages から自動選択されます。
依存ライブラリはゼロ。フレームワークも使わず、素の JS だけです。外部依存は Google Fonts の Be Vietnam Pro のみ。
そもそも TELEX とは
ベトナム語で最も普及している入力方式です。ASCII のキーだけで声調記号を表現します。
| 記号 | 打ち方 | 例 |
|---|---|---|
â ê ô |
同じ母音を2回 |
aa → â |
ă |
a + w
|
aw → ă |
ơ ư |
母音 + w
|
ow → ơ |
đ |
d を2回 |
dd → đ |
声調 sắc ́
|
+ s
|
as → á |
声調 huyền ̀
|
+ f
|
af → à |
声調 hỏi ̉
|
+ r
|
ar → ả |
声調 ngã ̃
|
+ x
|
ax → ã |
声調 nặng ̣
|
+ j
|
aj → ạ |
組み合わせると ees → ế、awf → ằ、dduowcj → được になります。
実装: TELEX 変換エンジン
一番の山場がここでした。単純な文字列置換では済みません。
声調記号は「語」に付く
tieengs と打ったとき、s(sắc)は最後の文字ではなく ế に載ります。つまり声調キーは語のどこで打っても構わない(chaof でも chafo でも chào)し、どの母音に載せるかは語全体を見て決める必要があります。
そこで、1語ぶんの打鍵列を「基字 + 記号」の内部表現に分解してから組み立て直す方式にしました。
/** 1文字を「基字 + 記号」に分解して内部表現にする */
function toLetter(c) {
var upper = c !== c.toLowerCase(), lower = c.toLowerCase();
if (lower === 'đ') return { ch: 'd', dd: true, mark: null, tone: null, upper: upper };
var d = lower.normalize('NFD'), mark = null, tone = null;
for (var i = 1; i < d.length; i++) {
if (d[i] === '\u0302') mark = 'circumflex';
else if (d[i] === '\u0306') mark = 'breve';
else if (d[i] === '\u031B') mark = 'horn';
else tone = d[i];
}
return { ch: d[0], dd: false, mark: mark, tone: tone, upper: upper };
}
Unicode の NFD 正規化で ế → e + ◌̂ + ◌́ に分解できるので、記号の扱いは全部これに乗せています。逆に組み立てるときは NFC に戻すだけ。
声調をどの母音に載せるか
ここがベトナム語の綴り規則そのものです。実装したルールはこうなりました。
function toneIndex(letters) {
var v = []; // 母音の位置
for (var i = 0; i < letters.length; i++) {
if (!letters[i].dd && VOWELS.indexOf(letters[i].ch) >= 0) v.push(i);
}
if (!v.length) return -1;
// qu- / gi- の u・i は母音ではなく子音の一部として扱う
if (v.length > 1 && v[0] === 1 && letters[0] &&
((letters[0].ch === 'q' && letters[1].ch === 'u') ||
(letters[0].ch === 'g' && letters[1].ch === 'i'))) {
v.shift();
}
if (v.length === 1) return v[0];
// 記号付きの母音があればそこに載せる(ươ は後ろの ơ)
for (var j = v.length - 1; j >= 0; j--) if (letters[v[j]].mark) return v[j];
if (v.length >= 3) return v[1];
var hasFinalConsonant = v[v.length - 1] < letters.length - 1;
return hasFinalConsonant ? v[1] : v[0];
}
これで挙動が変わる例:
-
quar→quả(quのuを数えないのでaに載る。数えるとqủaになってしまう) -
hoaf→hòa(終子音なしなので前の母音) -
toans→toán(終子音nがあるので後ろの母音) -
nguowif→người(ươは後ろのơに載せてから、さらにiが来るとơが残る)
gi- の例外まで入れないと giaf が gìa(正しくは già)になります。地味ですが、日常語で普通に踏みます。
打ち間違いの取り消し
TELEX では同じキーを続けて打つと変換を取り消すのが慣例です。ass → as、aaa → aa、ddd → dd。
// 声調キー(母音が出てからのみ有効。同じキーを重ねると解除して文字に戻す)
if (TONE_KEY[lower] && letters.length && hasVowel(letters)) {
if (tone === TONE_KEY[lower]) { tone = null; letters.push(toLetter(c)); }
else tone = TONE_KEY[lower];
continue;
}
「母音が出てからのみ有効」の条件がないと、sao(星)の先頭の s が声調キーとして食われます。
変換済みの文字も打鍵列として解釈する
toLetter() はベトナム語の文字そのものも受け付けるようにしてあります。これが効いてくるのが「UniKey が入っている環境で使われたとき」です。
すでに chào と変換された文字列に対しても内部表現に戻せるので、IME と二重に効いて壊れるということが起きません。
実装: input イベントとの戦い
変換エンジンができても、<input> に繋ぐところが素直にいきませんでした。
素朴に「入力のたびに値全体を Telex.type() に通す」だと、変換が終わった文字を再変換してしまいます。かといって打鍵列を別に持つと、貼り付け・削除・カーソル移動で簡単にズレます。
最終的にこうなりました。
function applyTelex(e) {
var v = el.answer.value;
// 削除・貼り付け・カーソル移動のあとは、そのときの表示を打鍵列として取り直すだけ
if (e.inputType !== 'insertText' || !e.data || el.answer.selectionStart !== v.length) {
state.raw = v;
return;
}
// 直前の表示に足しただけなら打鍵列を伸ばす
var prev = Telex.type(state.raw);
state.raw = (v === prev + e.data) ? state.raw + e.data : v;
var out = Telex.type(state.raw);
if (out !== v) {
el.answer.value = out;
el.answer.setSelectionRange(out.length, out.length);
}
}
ポイントは3つ。
- 末尾への1文字入力のときだけ打鍵列を積み上げる。それ以外は表示内容を打鍵列として取り直す(変換済みの文字も解釈できるので破綻しない)
-
e.inputTypeを見てinsertText以外を弾く。deleteContentBackwardを積み上げると悲惨なことになります - 値を書き換えたら
setSelectionRange()でカーソルを末尾に戻す。これを忘れるとカーソルが毎回先頭に飛びます
そして日本語圏で必須なのがこれ。
if (settings.telex && !e.isComposing) applyTelex(e);
isComposing を見ないと、日本語 IME の変換中にひらがなが勝手にベトナム語化します。「あそび」を打っている最中に何が起きるかは想像にお任せします。
実装: 判定の「厳しさ」を2段階にする
正誤判定用の正規化を2種類用意しました。
/** 比較用の正規化(大文字小文字・句読点・連続スペースを無視。声調記号は残す) */
function normalize(str) {
return (str || '')
.normalize('NFC')
.toLowerCase()
.replace(/[.,!?;:"'“”‘’…()\[\]]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/** 声調・記号をすべて外した素の綴り(発音判定のゆるい比較用) */
function stripDiacritics(str) {
return normalize(str)
.replace(/đ/g, 'd')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
使い分けはこうです。
-
キーボード入力は
normalize()で判定。声調記号は区別する(そこを練習しているので) -
音声認識は
normalize()で一致すれば◎、stripDiacritics()で一致すれば△。ブラウザの認識結果は声調が揺れるので、素の綴りが合っていれば「惜しい」扱いにします
フレーズ表示部分の色付けも同じ発想で、素の文字が合っていれば足りない記号だけを赤くするようにしました。nôi と打ったときに nổi 全体が赤くなるより、「文字は合ってる、声調だけ違う」と分かるほうが学習効率が良いので。
実装: Web Speech API
読み上げと発音チェックは標準 API だけで済ませています。
-
SpeechSynthesis: フレーズ表示時に2回読み上げ(
rateを変えて2回目はゆっくり)。速度は設定から変更可能 -
SpeechRecognition: 読み上げ後に自動でマイク ON。
maxAlternativesを増やして候補を複数もらい、上の2段階判定にかけます
ハマりどころ
-
音声認識は Chrome / Edge のみ。しかも
httpsかlocalhostが必須なので、file://で開くと動きません。ローカルではpython -m http.server 8123経由で開く必要があります - OS にベトナム語の音声が入っていないと読み上げが無音になります。Windows なら「設定 > 時刻と言語 > 音声認識」から音声パックの追加が必要。ここは JS 側ではどうにもならないので、設定画面で音声を選べるようにして逃げました
- 未対応ブラウザでもキーボード練習は普通に動くよう、音声機能は全部オプショナルにしています
実装: 11言語対応
「ベトナム語 → 各言語」の辞書を、ベトナム語のフレーズ文字列そのものをキーにして持つ構造にしました。ID を振ると管理が面倒になるのと、データを見たときに何のことか分かるのが利点です。
'Tôi rất thích phở.': {
t: '私はフォーが大好きです。',
note: '説明文をここに書きます。',
w: {
'Tôi': ['私', ''],
'rất': ['とても', '形容詞・心理動詞の前に置く'],
'thích': ['好む', ''],
'phở': ['フォー', '米の麺料理']
}
}
言語非依存のデータ(フレーズ本体と品詞情報)は js/data.js に分離。
{
vi: 'Tôi rất thích phở.',
words: [
{ w: 'Tôi', pos: 'pronoun' },
{ w: 'rất', pos: 'adverb' },
{ w: 'thích', pos: 'verb' },
{ w: 'phở', pos: 'noun' }
]
}
- 言語ファイルは必要になったときに読み込む(起動時は英語+選択中の言語のみ)
- 未翻訳の言語は英語にフォールバックするので、全言語を一度に書く必要がない
- TELEX ヒントは
viから自動生成されるので、データに書く必要がない - 画像 URL も
viから組み立てる(Tôi rất thích phở.→toi-rat-thich-pho.jpg)。声調記号を外して小文字化しハイフン化するだけ - アラビア語では
dir="rtl"に切り替えるが、ベトナム語のフレーズ・入力欄・TELEX ヒントは常に LTR のまま
?lang=fr のように URL で言語を指定でき、そのまま共有できます。
デプロイと SEO
ビルド不要の静的サイトなので、GitHub Pages にそのまま置いています。
独自ドメイン .vn を取って、Apex 用の A レコード4本を GitHub Pages の IP に向けるだけ。
185.199.108.153
185.199.109.153
185.199.110.153
185.199.111.153
SPA なので SEO は諦めがちですが、いくつか対策を入れました。
- HTML に英語の既定テキストを書いておく。JS を実行しないクローラでも見出し・手順・説明が読める
-
<noscript>にフレーズ一覧を出力(訳と打鍵列つき)。JS 無しでも中身が分かる - 11言語ぶんの
hreflang(+x-default)を?lang=xxの URL で宣言。canonical・og:url・og:localeは表示中の言語に合わせて書き換え - JSON-LD の
WebApplicationで無料・対応言語・学習内容を明示
<noscript> にコンテンツを置くのは古典的な手ですが、こういう「データが主役」のアプリではまだ有効だと思っています。
おわりに
作ってから毎日使っていますが、TELEX は思っていたより早く手に馴染みます。1週間くらいで dduowcj → được が無意識に打てるようになりました。逆に、声調記号を「見て区別する」ほうが後から追いついてくる感じです。
一番作ってよかったのは、UniKey を入れずにスマホのブラウザで練習できることです。移動中に開けるかどうかで継続率がまったく違いました。
フレーズ追加は js/data.js と js/lang/<言語>.js に足すだけなので、PR も歓迎です。

