Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/utils/englishReading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,43 @@ describe('fixEnglishReading', () => {
);
});

it('「Mine」を英語 TTS が「みね」と読む表記へ置換する', () => {
expect(fixEnglishReading('Change here for the Mine Line.')).toBe(
'Change here for the Me-nay Line.'
);
expect(fixEnglishReading('The next station is MINE.')).toBe(
'The next station is Me-nay.'
);
expect(fixEnglishReading('The next station is Nishi-Mine.')).toBe(
'The next station is Nishi-Me-nay.'
);
});

it('語中の「mine」も前後にハイフンを挟んで置換する', () => {
expect(fixEnglishReading('The next station is Tsurugamine.')).toBe(
'The next station is Tsuruga-me-nay.'
);
expect(fixEnglishReading('Mitsumineguchi')).toBe('Mitsu-me-nay-guchi');
expect(fixEnglishReading('Minenobu')).toBe('Me-nay-nobu');
expect(fixEnglishReading('Takamine')).toBe('Taka-me-nay');
// マクロン付きの文字も語の一部として扱う
expect(fixEnglishReading('Minami-Ōmine')).toBe('Minami-Ō-me-nay');
});

it('二重に適用しても結果が変わらない', () => {
expect(fixEnglishReading(fixEnglishReading('Mine Line'))).toBe(
'Me-nay Line'
);
expect(fixEnglishReading(fixEnglishReading('Tsurugamine'))).toBe(
'Tsuruga-me-nay'
);
});

it('別語の一部は置換しない', () => {
expect(fixEnglishReading('Keiseibus')).toBe('Keiseibus');
// 西武園 (Seibuen) は 1 語なので語単位の一致では対象外
expect(fixEnglishReading('Seibuen')).toBe('Seibuen');
expect(fixEnglishReading('Minami-Urawa')).toBe('Minami-Urawa');
});

it('対象を含まないテキストはそのまま返す', () => {
Expand Down
33 changes: 29 additions & 4 deletions src/utils/englishReading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,28 @@
// 表示用テキストには適用しないこと (TTS 生成時専用)。

type EnglishReadingRule = {
// 置換対象。ASCII 英字の単語境界 (`\b`) で囲み、`Keisei-Ueno` のような
// ハイフン連結の駅名でも語単位で一致させる。
// 置換対象。原則は ASCII 英字の単語境界 (`\b`) で囲み、`Keisei-Ueno` のような
// ハイフン連結の駅名でも語単位で一致させる。語中も置換する場合 (Mine) は
// reading を関数にして、前後の文字との区切りを補う。
pattern: RegExp;
reading: string;
reading: string | ((match: string, offset: number, text: string) => string);
};

// ローマ字表記の文字か。端末内蔵 TTS 向けの経路ではマクロンを除去しないため、
// 「Ō」などのラテン拡張文字も語の一部として扱う。
const isRomanLetter = (char: string | undefined): boolean =>
char !== undefined && /[A-Za-z\u00C0-\u024F]/.test(char);

// 「mine」を読み替え先へ置換する。Tsurugamine のように語中にある場合も置換し、
// 前後の文字とつながって別の綴りとして読まれないよう、接する側にハイフンを挟む。
// 先頭の大文字・小文字は元の綴りに合わせる (Mine → Me-nay, Takamine → Taka-me-nay)。
// TrainLCD/functions の normalizeRomanText (replaceMine) と同じ置換にして、
// リモート TTS が無効な回とも読みを揃える。
const toMineReading = (match: string, offset: number, text: string) => {
const before = isRomanLetter(text[offset - 1]) ? '-' : '';
const after = isRomanLetter(text[offset + match.length]) ? '-' : '';
const head = /[A-Z]/.test(match.charAt(0)) ? 'Me' : 'me';
return `${before}${head}-nay${after}`;
};

// NOTE: 読み替え先は必ず英語の辞書語 (か、辞書語のハイフン連結) にする。
Expand All @@ -20,13 +38,20 @@ const ENGLISH_READING_RULES: readonly EnglishReadingRule[] = [
// 「Seibu (西武)」も同じく "ei" を /aɪ/ と推定して「さいぶ」と読むため、
// "Say" + "boo" で /seɪ.buː/ (せいぶ) を確定させる。
{ pattern: /\bSeibu\b/gi, reading: 'Say-boo' },
// 「Mine (美祢・峰など)」は英語 TTS が英単語 "mine" (まいん) として読むため、
// "Me" + "nay" で /mi.neɪ/ (みね) に寄せる。Tsurugamine・Mitsumineguchi の
// ように語中にある場合も対象にするため、単語境界では区切らない。
{ pattern: /mine/gi, reading: toMineReading },
];

/**
* 英語の読み上げ用テキスト内の固有名詞を、TTS エンジンが正しく読める表記へ置換する。
*/
export const fixEnglishReading = (text: string): string =>
ENGLISH_READING_RULES.reduce(
(acc, { pattern, reading }) => acc.replace(pattern, reading),
(acc, { pattern, reading }) =>
typeof reading === 'string'
? acc.replace(pattern, reading)
: acc.replace(pattern, reading),
text
);
Loading