日本語（カタカナ）を音節単位で分かち書き【Javascript】

# 概要
カタカナを音節に分けるJavascriptプログラムを書きました。
基本は[以前書いた記事](https://qiita.com/shimajiroxyz/items/9580f185217fc9738f33)のJavascript版になりますが、より正確に音節を区切れるように一部改良しています。

# 音節とは

日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「５・７・５」を数えるときの区切り方で、長音（ー）、促音（ッ）、撥音（ン）も１拍と数えます。それに対し、音節では長音、促音、撥音（以降、これらをまとめて特殊モーラと呼びます）は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。「バーン」のように特殊モーラが連続した場合は３以上のモーラ数で１音節となります。

|元文|モーラ分かち書き|音節分かち書き|
|---|---|---|
|ガッキューシンブン|ガ/ッ/キュ/ー/シ/ン/ブ/ン|ガッ/キュー/シン/ブン|
|アウトバーン|ア/ウ/ト/バ/ー/ン|ア/ウ/ト/バーン|

参考
[音節 - Wikipedia](https://ja.wikipedia.org/wiki/%E9%9F%B3%E7%AF%80)
[モーラ - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%A2%E3%83%BC%E3%83%A9)

# 音節分かち書きの考え方
基本的には、特殊モーラをその直前のカナ（モーラ）とひとまとまりとし、それ以外の部分はモーラの単位で分かち書きをすればよいです。ただし特殊モーラが連続した場合の扱いには少し注意が必要です。
また「カア」が「カー」と発音されるように、母音はときどき長音として発音されることがあります。そこでこのような母音も直前のカナと１つの音節としたほうが直感にあう分け方になります。
そこで今回は特殊モーラと母音を考慮した音節分かち書きの実現を目指します。

なお、日本語では、カナ表記とそれを実際にどう発音するかは必ずしも厳密に決まっていません。
例えば「オオウ(Ohhh／奥羽）」は「オーウ」と１音節と考えることも「オオ」「ウ」のように２音節と考えることもどちらもできます。

参考（カナと発音の対応が曖昧な単語の例が示されています）
https://qiita.com/hypnos/items/4f31dff6241a907fba9b

厳密に対処するためには、単語ごとの発音辞書のようなものを持つことが有効ですが、今回はカタカナの情報しか得られない前提の元、ある程度割り切って、実装します。
今回の実装が必ずしもすべての場合における正解ではないことに注意してください。

# モーラ分かち書き
音節分かち書きの前に、その基本となるモーラ分かち書きについて考えます。
モーラ分かち書きでは２文字で１モーラを形成するカナ（キャ、キュ、キョなど）があることに注意し、２文字カナ、１文字カナの順番でマッチすることによって実現できます。

２文字１モーラのカナを形成するパターンは以下のとおりです。

||正規表現|意味|例|
|---|---|---|---|
|①|[ウクスツヌフムユルグズヅブプヴ][ァヮィェォ]|ウ段＋「ァ/ヮ/ィ/ェ/ォ」|ウァ、フィ|
|②|[キシシニヒミリギジヂビピ][ャュェョ]|イ段（「イ」を除く）＋「ャ/ュ/ェ/ョ」|キャ、シェ|
|③|[テデ][ャィュョ]|「テ/デ」＋「ャ/ィ/ュ/ョ」|テャ、デュ|

1文字カナは`[ァ-ヴー]`の正規表現でマッチ可能です。

２文字のカナから順番にマッチすることに注意して、モーラ分かち書き関数を以下の通り実装できます。

```javascript
//入力カナをモーラの単位で分かち書きする。
function moraSplit(text){
  let kana = getKanaPattern();
  let re = /[ウクスツヌフムユルグズヅブプ][ァヮィェォ]|[キシチニヒミリギジヂビピテデ][ャュェョ]|[テデ]ィ|[ァ-ヴー]/g;
  text = text.match(re);//match関数で抽出することで分かち書きと同一の結果を得る
  return text;
}
```

出力は以下のような感じです。

```javascript
console.log(moraSplit("ガッキュウシンブン"));
//["ガ", "ッ", "キュ", "ウ", "シ", "ン", "ブ", "ン"]
console.log(moraSplit("アウトバーン"));
//["ア", "ウ", "ト", "バ", "ー", "ン"]
```
# 音節分かち書き
次に音節分かち書きについて考えます。
## 前処理
入力可能な全てのカナのパターンに対して分け方のルールを決めようとすると非常に複雑になってしまうので、今回は日本語的に不自然でないカタカナの並びに限ることとします。
具体的には以下を不自然なカナと定義します。
また、これを修正する関数を作っておきます。

### 不自然な促音、長音の削除
長音の連続や促音の連続は発音上、一意な意味がないので削除します。
促音のあとの長音と、文頭の促音長音も同様に、発音に与える一意な意味がないので、削除します。

```javascript
function removeBarAndSokuonReputation(text){
	text = text.replace(/ー+/g,"ー");//ーの連続を1文字にする
	text = text.replace(/(?<=ッ)[ーッ]+/g,"");//ッの後ろのーまたはッの連続を削除
	text = text.replace(/^[ーッ]+/g,"")//先頭の[ーッ]を削除
	return text;
}

(function(){
	let text = "ーーッーーアーーーッッーンコーーーモチ";
	console.log(removeBarAndSokuonReputation(text));
	//アーッンコーモチ
})();
```

### 不自然な小文字の修正
小文字カナは「ッ」と２文字１モーラのカナの一部である場合を除き、登場させないようにしたいです。
まず長音の後ろの小文字母音は削除します。（例：「アーァ」→「アー」)

また長音直前のカナと同じ種類の母音となる小文字母音は長音に変換します（例：「カァ」→「カー」)
このために、カナ１文字を母音に変換する関数を作ります。その後、小文字母音の直前のカナと小文字母音の母音が一致するかどうかを調べて変換します。

```javascript
//文字を母音に変換
function charToVowel(char){
	if(char == "ー" ){
		//console.log("warning: only ー is input");
		return char;
	}

	//伸ばし棒を除いた末尾の文字を取得
	let last = char[char.length-1];
	for(let i=char.length-1;i>-1;i--){
		last = char[i];
		if(last != "ー")break;
	}
	
	let rows = {
			"ア":"アカサタナハマヤラワガザダバパァャヮ",
			"イ":"イキシチニヒミリギジヂビピィ",
			"ウ":"ウクスツヌフムユルグズヅブプヴゥュ",
			"エ":"エケセテネヘメレゲゼデベペェ",
			"オ":"オコソトノホモヨロゴゾドボポォ",
			"sp":["sp","ン","ッ"]
	}
	let vowel = last;
	for(let v in rows){
		let row = rows[v];
		if(row.includes(last)){
			vowel = v;
			break;
		}
	}
	return vowel;
}

//小文字母音を長音に変換
function smallVowelToBar(text){
	//長音のうしろの小文字母音を長音に
	let replaced_text = text.replace(/(?<=ー)(ァ+|ィ+|ゥ+|ェ+|ォ+)/g,"");
	
	//同じ母音のカナの後ろの小文字母音を長音に
	replaced_text = replaced_text.replace(/[ァ-ヴ](ァ+|ィ+|ゥ+|ェ+|ォ+)/g,function(match){
		let res = match;
		let l2s = { "ア":"ァ", "イ":"ィ", "ウ":"ゥ", "エ":"ェ", "オ":"ォ" }
		//1文字目の母音が2文字目の小文字母音と対応していたら
		let first_vowel = charToVowel(match[0]);
		if(first_vowel in l2s && l2s[first_vowel] == match[1]){
			res = match[0]+"ー";
		}
		//エィやオゥも長音に変換したい場合はコメントを外す
		//else if(first_vowel == "エ" && match[1] == "ィ"){
		//	res = match[0] + "ー";
		//}else if(first_vowel == "オ" && match[1] == "ゥ"){
		//	res = match[0] + "ー";
		//}
		//上記以外の小文字母音の連続に対応(これがないと「ヴァァァ」の「ァァァ」などが残り続ける
		else if(match.length>=3){
			res = match[0]+match[1]+"ー";
		}
		return res;
	});	
	return replaced_text;
}

(function(){
	let text = "キィィアァァヴァァァ";
	console.log(smallVowelToBar(text));
	//キーアーヴァー
})();
```

次に２文字カナの一部でない小文字を大文字にします。

```javascript
//2文字カナの一部でない小文字(ッを除く)を大文字にする
function smallVowelToLarge(text){
	
	let re_a = "(?<![ウクスツヌフムユルグズヅブプヴ])[ァヮェォ]";//ウ段の後ろ以外のァヮェォ
	let re_u = "(?<![トド])ゥ";
	let re_y = "(?<![キシチニヒミリギジヂビピテデ])[ャュョ]";//イ段の後ろ以外のャュョ
	let re_i = "(?<![ウクスツヌフムユルグズヅブプヴテデ])[ィ]";//テデの後ろ以外のィ
	let re = [re_a,re_y,re_u,re_i].join("|");//上記のいずれかにマッチさせる
	
	let s2l = {"ァ":"ア","ィ":"イ","ゥ":"ウ","ェ":"エ","ォ":"オ","ヮ":"ワ","ャ":"ヤ","ュ":"ユ","ョ":"ヨ"}
	//マッチした小文字を大文字にして返す
	let replaced_text = text.replace(new RegExp(re,"g"),function(match){
		let large = s2l[match];
		return large;
	});
	return replaced_text;
}

(function(){
	let text = "キィンィファァキャヴァァトゥァ";
	console.log(smallVowelToLarge(text));
	//キーアーヴァー
})();
```

### 不自然なカナの並びを修正する関数
上記、促音、長音、小文字に関する処理を組み合わせて、不自然なカナの並びを修正する関数を作成します。
小文字を長音に変換する処理がある関係で、小文字に関する処理を先に実施する必要があることに注意してください。

```javascript
//小文字や長音、促音の不自然な並びを解消する
function formatText(text){
	text = smallVowelToBar(text);
	text = smallVowelToLarge(text);
	text = removeBarAndSokuonReputation(text);
	return text;
}
(function(){
	let text = "ーキィーーンィーーーッーーファァァィオゥキャヴァァトゥァ";
	console.log(formatText(text));
	//キーンイファーキヤヴァートゥア
})();
```


## 母音ごとのカナパターンの生成
音節分かち書きのために使うカナのパターンを文字数ごと、母音ごとに生成する関数を作っておきます。
文字数ごとに取得するのは、正規表現で長いカナから順にマッチする必要があるからです。
母音ごとに取得するのは、あとで「カア」などのように実質的に長音とみなせる母音を見つけるためです。

```Javascript
//日本語のカナの正規表現パターンをア段からオ段の音（と全部）に分けて取得
//日本語の場合、「ファ」などのように２文字で１モーラを構成するカナがあることに注意
function getKanaPattern(){
  //ア段からオ段までの1文字カナ集合と「テ」「デ」の集合を定義
  let kana_a = "[アカサタナハマヤラワガザダバパ]";
  let kana_i = "[イキシチニヒミリギジヂビピ]";
  let kana_i2 = kana_i.replace("イ","");//ャュョとくっつける用のイ段
  let kana_u = "[ウクスツヌフムユルグズヅブプヴ]";
  let kana_e = "[エケセテネヘメレゲゼデベペ]";
  let kana_o = "[オコソトノホモヨロヲゴゾドボポ]";
  let kana_td = "[テデ]";
  let kana_td2 = "[トド]";
  //２文字で１モーラになるカナの定義
  let kana_multi_a = "("+[kana_u+"[ァヮ]",kana_i+"ャ",kana_td+"ャ"].join("|")+")";
  let kana_multi_i = "("+[kana_u+"ィ",kana_td+"ィ"].join("|")+")";
  let kana_multi_u = "("+[kana_i+"ュ",kana_td+"ュ",kana_td2+"ゥ"].join("|")+")";
  let kana_multi_e = "("+[kana_u+"ェ",kana_i+"ェ"].join("|")+")";
  let kana_multi_o = "("+[kana_u+"ォ",kana_i+"ョ"].join("|")+")";
  let kana_multi = "("+[kana_u+"[ァィェォ]",kana_td+"[ャィュョ]",kana_i+"[ャュョ]"].join("|")+")";
  
  //ンーッと小文字を除くカナ
  let kana_single_base = "[アイウエオ-ヂツ-モヤユヨ-ロワヲヴ]";
  //２文字で１モーラとなるカナも含めた全カナ集合(ー/ン/ッと小文字単体は除く)の定義
  let kana_base = "("+[kana_multi, kana_single_base].join("|")+")";
  //２文字で１モーラとなるカナも含めた全カナ集合(ー/ン/ッと小文字単体も含む)の定義
  let kana_all = "("+[kana_multi, "[ァ-ヴー]"].join("|")+")";
  
  return {
    "base":kana_base,//ン、ー、ッと小文字を除く2文字および1文字カナ
    "all":kana_all,//ン、ー、ッと小文字も含む2文字および1文字カナ
    "multi_a":kana_multi_a,
    "multi_i":kana_multi_i,
    "multi_u":kana_multi_u,
    "multi_e":kana_multi_e,
    "multi_o":kana_multi_o,
    "multi":kana_multi, //２文字1モーラのカナ全体
    "single_a":kana_a,
    "single_i":kana_i,
    "single_u":kana_u,
    "single_e":kana_e,
    "single_o":kana_o,
    "single_td":kana_td,
    "single_td2":kana_td2,
    "single_base":kana_single_base //ン、ー、ッと小文字を除く1文字カナ
  }
}

console.log(JSON.stringify(getKanaPattern()));
{"base":"(([ウクスツヌフムユルグズヅブプヴ][ァィェォ]|[テデ][ャィュョ]|[イキシチニヒミリギジヂビピ][ャュョ])|[アイウエオ-ヂツ-モヤユヨ-ロワヲヴ])","all":"(([ウクスツヌフムユルグズヅブプヴ][ァィェォ]|[テデ][ャィュョ]|[イキシチニヒミリギジヂビピ][ャュョ])|[ァ-ヴー])","multi_a":"([ウクスツヌフムユルグズヅブプヴ][ァヮ]|[イキシチニヒミリギジヂビピ]ャ|[テデ]ャ)","multi_i":"([ウクスツヌフムユルグズヅブプヴ]ィ|[テデ]ィ)","multi_u":"([イキシチニヒミリギジヂビピ]ュ|[テデ]ュ|[トド]ゥ)","multi_e":"([ウクスツヌフムユルグズヅブプヴ]ェ|[イキシチニヒミリギジヂビピ]ェ)","multi_o":"([ウクスツヌフムユルグズヅブプヴ]ォ|[イキシチニヒミリギジヂビピ]ョ)","multi":"([ウクスツヌフムユルグズヅブプヴ][ァィェォ]|[テデ][ャィュョ]|[イキシチニヒミリギジヂビピ][ャュョ])","single_a":"[アカサタナハマヤラワガザダバパ]","single_i":"[イキシチニヒミリギジヂビピ]","single_u":"[ウクスツヌフムユルグズヅブプヴ]","single_e":"[エケセテネヘメレゲゼデベペ]","single_o":"[オコソトノホモヨロヲゴゾドボポ]","single_td":"[テデ]","single_td2":"[トド]","single_base":"[アイウエオ-ヂツ-モヤユヨ-ロワヲヴ]"}
```

## 促音、長音、撥音を直前のカナとくっつける
特殊モーラが連続したとき、１つの音節として扱うかどうかを考えます。
カナに特殊モーラが１文字続く場合、例えば「アッ」「アー」「アン」続いたときは１音節として扱います。
２文字続く場合、「アーッ」「アーン」「アンッ」は１音節とするのが自然に思えます。
一方で「アッン」は「ン」で言い直す読み方をすることが多そうなので、「アッ」「ン」と分けることにします。
「アンー」は微妙なところなので保留します。
「アッー」は前処理が適切に行われていれば登場しない並びです。
３文字続く場合、「アーンー」は「アー」「ンー」とそれぞれ発音することが多そうなので、２音節に分けます。
「アーンッ」も「ンッ」で言い直しているきがするので「アーンッ」で分けます。
ここから「ン」のうしろに「ー」や「ッ」が来るときは「ンー」「ンッ」のように「ン」始まりで音節を分けるというルールがつくれます。
ここまで考えて、保留していた「アンー」については、「アーンー」の分け方と統一感を持たせるために「ア」「ンー」とすることにします。こうすると「アンーッ」も「ア」「ンーッ」で分けることに迷いがなくなり、都合がいいです。
また前処理により促音と長音は２連続しませんが、撥音は連続しえます。今回は「アンン」のように「ン」が連続したときは「アン」「ン」と「ン」を一つずつ分けて音節とします。「ンン」の発音は実際には「ンー」になることもあるので、完全に正確ではありませんが、「ンー」を意味したいときは「ンー」と入力してもらうことを期待して、このように処理します。

表にまとめると以下のようになります。

|例|処理|
|---|---|
|アッ|１音節|
|アー|１音節|
|アン|直後に「ー」がない限り１音節|
|アッン|「アッ」「ン」の2音節|
|アーッ|１音節|
|アーン|直後に「ー」「ッ」がない限り１音節|
|アンッ|1音節|
|アンー|「ア」「ンー」の２音節|
|アンン|「アン」「ン」の２音節|
|アーンッ|「アー」「ンッ」の２音節|
|アーンー|「アー」「ンー」の２音節|

以上から特殊モーラが３文字以上連続すると必ず２音節以上に分かれるので、以下のように２文字以下の検出パターンを定義すると、これらを上手くマッチさせることができます。

```javascript
//「ッ」「ー」「ン」が２文字続いたときのパターン。「ーン」は後ろに「ー」「ッ」が来るとき以外
let re2 = "ーッ|ンッ|ーン(?![ーッ])";
//「ッ」「ー」「ン」が２文字続いたときのパターン。「ン」は後ろに「ー」が来るとき以外
let re1 = "ー|ッ|ン(?!ー)";//ンは後ろに長音が来るとき以外

//「ン」が音節の頭として登場したときのマッチ
let re_n_bar = "ン([ーッ]|ーッ)";
```

## 母音を直前のカナとマッチさせる
カナの後ろにその母音が続いていた場合、その母音は実質的に長音とみなせるため、合わせて１音節とします（例：「カアカア」→「カア」「カア」）。また「エイ」「オウ」のようなエ段＋イ、オ段＋ウも同様に長音的に扱います（「コウセイ」→「コウ」「セイ」）
ただし、母音の後ろに特殊モーラがあれば、母音とそれらの音をマッチさせることを優先するようにします。（例：「カアー」→「カ」「アー」）。これはこのようにしたほうがプログラムが書きやすいというのと、音節を分けたくない場合は母音を長音に変換してから入力するようにすれば済む（つまりユーザにどう音節を分けたいかの自由度を与えられる）というのが理由です。
同じ母音が２回ずつ続いた場合は、２文字ずつで音節を構成させるようにします（例：「アアアアア」→「アア」「アア」「ア」）

これはカナをア段からオ段に分けて、それらと母音の並びをマッチすることで検出します。

## 余った文字を１モーラずつ分ける
「ッ」「ー」「ン」や母音とマッチしなかった部分は１モーラずつ分離します。
##長い塊から順にマッチさせる
上記までのマッチを長い順から順に適用することで音節分かち書きを実現します。
具体的には以下の順でマッチをします。
実際の関数ではより確実にするために適宜例外処理も入れるようにしています。（「ー」「ッ」が後ろに来る「アーン」はマッチしない、など）

* ２文字カナ＋特殊モーラ （例：ヴァーン、キャッ）
* ２文字カナ＋母音（例：ジョウ、シャア）
* ２文字カナ（例：ヴァ）
* 「ン」＋促音／長音（例：ンーッ、ンー）
* １文字カナ＋特殊モーラ （例：アーッ、カン）
* １文字カナ＋母音（例：カア）
* １文字カナ（例：ア）

これを関数にしたのが以下です。

```javascript
function syllableSplit(text){
	//よく使うカナパターンの取得
	let kana = getKanaPattern();
	//ーンッを前のカナとつなげるときのパターン
	let re2 = "ーッ|ンッ|ーン(?![ーッ])";//ーンは後ろにーッが来るとき以外
	let re1 = "ー|ッ|ン(?!ー)";//ンは後ろに長音が来るとき以外
	let re_back = "("+[re2,re1].join("|")+")";

	//長いものからマッチする
	//２文字カナとーンッのマッチ
	let re_multi_bar = "("+kana["multi"] + re_back + ")";

	//2文字カナと母音のマッチ
	let re_multi_a = kana["multi_a"]+"ア";
	let re_multi_i = kana["multi_i"]+"イ";
	let re_multi_u = kana["multi_u"]+"ウ";
	let re_multi_e = kana["multi_e"]+"[エイ]";
	let re_multi_o = kana["multi_o"]+"(オ|ウ(?![ァィゥェォ]))";
	let re_multi_vowel = "("+[re_multi_a,re_multi_i,re_multi_u,re_multi_e,re_multi_o].join("|")+")";
	re_multi_vowel += "(?![ーンッ])";
	
	//２文字カナ単独のマッチ
	let re_multi_unit = kana["multi"];
	
	//ンとーッのマッチ
	let re_n_bar = "ン([ーッ]|ーッ)";
	
	//１文字カナとーッンのマッチ
	let re_single_bar = "("+kana["single_base"]+re_back+")";

	//１文字カナと母音のマッチ
	let re_single_a = kana["single_a"]+"ア";
	let re_single_i = kana["single_i"]+"イ";
	let re_single_u = kana["single_u"]+"ウ(?![ァィェォ])";
	let re_single_e = kana["single_e"]+"[エイ]";
	let re_single_o = kana["single_o"]+"(オ|ウ(?![ァィェォ]))";
	let re_single_vowel = "("+[re_single_a,re_single_i,re_single_u,re_single_e,re_single_o].join("|")+")";
	//1文字カナ単独のマッチ
	re_single_vowel += "(?![ーンッ])";

	let re_single_unit = "[ァ-ヴー]";
	//上記で定義した条件のオアをとる
	let re = [re_multi_bar, re_multi_vowel, re_multi_unit, re_n_bar, re_single_bar, re_single_vowel, re_single_unit].join("|");
	
	//matchで抽出
	text = text.match(new RegExp(re,"g"));

	return text;
}
```

出力を確認してみます。

```javascript
(function(){
	let texts = [
		"アウトバーン",
		"ーーッアッッウートンッバァァァーーンンッ",
		"コウチョウセンセイ",
		"ガッキュウシンブンノエイセイテキカンテン",
		"オウトクチュウル",
		"チューンナップ",
		"ピュビュッパラッパンッパーンェィ",
		"イェェェェーーーーィイッ"
	]
	for(let text of texts){
		let formatted = formatText(text);
		let splitted = syllableSplit(formatted);
		console.log("raw",text);
		console.log("formatted",formatted);
		console.log("splitted",splitted);
		console.log("formatted==splitted", formatted == splitted.join(""));
	}
})();
```


```
raw アウトバーン
formatted アウトバーン
splitted ["ア", "ウ", "ト", "バーン"]
formatted==splitted true

raw ーーッアッッウートンッバァァァーーンンッ
formatted アッウートンッバーンンッ
splitted ["アッ", "ウー", "トンッ", "バーン", "ンッ"]
formatted==splitted true

raw コウチョウセンセイ
formatted コウチョウセンセイ
splitted ["コウ", "チョウ", "セン", "セイ"]
formatted==splitted true

raw ガッキュウシンブンノエイセイテキカンテン
formatted ガッキュウシンブンノエイセイテキカンテン
splitted ["ガッ", "キュウ", "シン", "ブン", "ノ", "エイ", "セイ", "テ", "キ", "カン", "テン"]
formatted==splitted true

raw オウトクチュウル
formatted オウトクチュウル
splitted ["オウ", "ト", "ク", "チュウ", "ル"]
formatted==splitted true

raw チューンナップ
formatted チューンナップ
splitted ["チューン", "ナッ", "プ"]
formatted==splitted true

raw ピュビュッパラッパンッパーンェィ
formatted ピュビュッパラッパンッパーンエイ
splitted ["ピュ", "ビュッ", "パ", "ラッ", "パンッ", "パーン", "エイ"]
formatted==splitted true

raw イェェェェーーーーィイッ
formatted イエーイッ
splitted ["イ", "エー", "イッ"]
formatted==splitted true
```

# おわりに
日本語カナを可能な限り丁寧に音節に分ける関数をJavascriptで作ってみました。
ホントは小文字カナも長音に直さずにちゃんと扱いたかったのですが、正規表現の管理がしきれなくなってきたので諦めました。なお今回よりも簡単にしたければ、大文字母音も長音に直してしまってから、特殊モーラのみをどう扱うかに注力するのもありかと思います。
音節の分け方は一意ではないので、今回の実装が唯一の正解ではないことに注意してください。
もしよりよい分け方があれば教えていただけると嬉しいです。

今後の課題をメモとして残しておいて、本記事を終わります。
ここまでお読みいただきありがとうございました。

* 単語ごとの発音辞書の利用。特に「エイ」の発音が「エー」、「エイ」、「エ」「イ」のどれなのかを正しく区別できると嬉しいです。例えば「衛生」の「エイ」は「エー」とするが、魚介類の「エイ」は「エイ」（または「エ」「イ」）とするなどです。
* 漢字読み方辞書の利用。単語ごとの発音辞書を用意するまでせずとの、漢字の読み方辞書があれば、音節の切れ目の推測の確度をある程度高めることができそうです。例えば、「講師」であれば「コー」「シ」、子牛が「コ」「ウシ」と、漢字の読み方の切れ目に沿って音節をわける処理を入れることなどが考えられます。
* 母音が省略されるカナの処理。例えば「です」という日本語は「des」のように、末尾の母音が脱落することで１音節とみなせる発音になることが多いです。アプリケーションによりますが、このように実質的に１音節とみなせるパターンをどんどんプログラムに取り入れることはしてもよいと考えています。
* 小文字母音の処理。今回、小文字母音は２文字カナの一部となっている場合を除いて登場させないようにしましたが「ィヤー」みたいな表記は日本語的に発音できないわけではないため、こうしたパターンの分かち書きの方法を考えることはある程度有用と考えられます。
* 特殊モーラや母音の連続の処理。「アンー」は今回は「ア」「ンー」と２音節にしましたが、やはり１音節にしたいときもあります。どういう基準で１音節または２音節とするかはもう少し詰める余地があります。