From 6078afe0aacc6889559ab4a57ffe2d6bc5a2c32a Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 7 Mar 2026 21:39:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E8=8B=B1=E8=AA=9E=E3=81=AETTS=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=82=B8=E3=83=B3=E3=82=92Google=20Cloud=20TTS=20API?= =?UTF-8?q?=E3=81=8B=E3=82=89Gemini=20TTS=20(Vertex=20AI)=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E8=A1=8C=E3=81=97=E3=83=9C=E3=82=A4=E3=82=B9=E9=81=B8?= =?UTF-8?q?=E6=8A=9EUI=E3=82=92=E8=BF=BD=E5=8A=A0=20(#5533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TTSエンジンをGoogle Cloud TTS APIからGemini TTS (Vertex AI)に移行しボイス選択UIを追加 Co-Authored-By: Claude Opus 4.6 (1M context) * テスト修正 * Gemini TTSの出力をMP3エンコードして返すようにし、lamejsのCommonJS互換性問題を修正 Co-Authored-By: Claude Opus 4.6 (1M context) * lamejsの依存を削除しMP3エンコード処理を整理 Co-Authored-By: Claude Opus 4.6 (1M context) * wrapIpaを削除しTTS読み上げ時にカッコ内テキストを除去するようにした Co-Authored-By: Claude Opus 4.6 (1M context) * nameIpaフィールドを全箇所から削除しTTSプロンプトに駅名・路線名の発音指示を追加 Co-Authored-By: Claude Opus 4.6 (1M context) * ssmlEnの型チェック追加、mimeTypeフォールバック修正、正規表現バグ修正、Node.jsバージョンを22に統一 Co-Authored-By: Claude Opus 4.6 (1M context) * Firestore設定読み取りのエラーハンドリング追加、SSMLタグをGemini送信前に除去、バスTTSのカッコ除去を追加 Co-Authored-By: Claude Opus 4.6 (1M context) * 英語音声もKoreをAoedeにし、日本語音声とデフォルトを共通化 * Gemini TTS関数にJSDocを追加しエラーハンドリングを改善、日本語TTSプロンプトを日本語化 Co-Authored-By: Claude Opus 4.6 (1M context) * TTSボイス未選択時のフォールバックテキストをボイス名からプレースホルダーに変更 Co-Authored-By: Claude Opus 4.6 (1M context) * 日本語・英語TTSプロンプトの読み上げ速度を早口に変更 Co-Authored-By: Claude Opus 4.6 (1M context) * AndroidのTTSボイス選択をAlert.alertからModal+FlatListに変更し全ボイスを表示可能に Co-Authored-By: Claude Opus 4.6 (1M context) * functions/READMEのNode.jsバージョンを20から22に修正 Co-Authored-By: Claude Opus 4.6 (1M context) * biome fix * stripSsmlで漢字と読みを併記する形式に変更しGemini TTSの発音精度を改善 Co-Authored-By: Claude Opus 4.6 (1M context) * 日本語TTSをGeminiからGoogle Cloud TTS APIに切り替えクライアントの日本語ボイス設定UIを削除 Co-Authored-By: Claude Opus 4.6 (1M context) * Google TTS APIのレスポンスエラーチェック追加とjaVoiceNameをハードコードに変更 Co-Authored-By: Claude Opus 4.6 (1M context) * biome fix * 不要な変更巻き戻し * 停車中に次駅アナウンス音声を先読みフェッチしUXを改善 Co-Authored-By: Claude Opus 4.6 (1M context) * biome fix * allStopsのフィルター条件をidからgroupIdに修正しslicedStationsの正規化と整合させる Co-Authored-By: Claude Opus 4.6 (1M context) * boundForEnでdirectionalStopsがundefinedの場合に文字列undefinedになるのを修正 Co-Authored-By: Claude Opus 4.6 (1M context) * biome fix * useBusTTSTextのbetweenNextStationにインデックス境界チェックを追加 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/copilot-instructions.md | 4 +- .../workflows/bump_version_on_canary_pr.yml | 2 +- .../workflows/bump_version_on_release_pr.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/typecheck.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- assets/translations/en.json | 3 + assets/translations/ja.json | 3 + docs/bump-version-on-canary-pr.md | 2 +- functions/README.md | 2 +- functions/package-lock.json | 2 +- functions/package.json | 2 +- functions/src/cli/find-orphaned-tts.ts | 57 +- functions/src/cli/find-tts-cache.ts | 14 +- functions/src/funcs/tts.ts | 535 ++++++++++++------ functions/src/funcs/ttsCachePubSub.ts | 33 +- functions/src/utils/encodeMp3.ts | 60 ++ functions/src/utils/legacyIpa.ts | 35 +- functions/src/utils/normalize.ts | 19 +- src/@types/graphql.d.ts | 74 --- src/__fixtures__/line.ts | 4 - src/components/TuningSettings.tsx | 176 +++++- src/constants/asyncStorage.ts | 2 + src/hooks/useBusTTSText.test.tsx | 52 +- src/hooks/useBusTTSText.ts | 149 ++--- .../useGetStationsWithTermination.test.tsx | 1 - src/hooks/useRefreshStation.test.tsx | 1 - src/hooks/useTTS.test.ts | 32 +- src/hooks/useTTS.ts | 88 ++- src/hooks/useTTSText.test.tsx | 30 +- src/hooks/useTTSText.ts | 177 ++---- .../useTransferLinesFromStation.test.tsx | 1 - src/lib/graphql/queries.ts | 7 - src/store/atoms/tuning.ts | 4 + src/utils/__fixtures__/search.ts | 22 - src/utils/jr.ts | 2 - src/utils/ssml.ts | 24 - src/utils/test/factories.ts | 3 - src/utils/ttsSpeechFetcher.test.ts | 74 ++- src/utils/ttsSpeechFetcher.ts | 130 ++++- 41 files changed, 1152 insertions(+), 684 deletions(-) create mode 100644 functions/src/utils/encodeMp3.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index da9c2aae69..4893e98cf7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -79,8 +79,8 @@ src/ ## 開発環境 -- **Node.js 20.x**を使用 -- **npm 10.x**を使用(Node.js 20 同梱版) +- **Node.js 22.x**を使用 +- **npm 10.x**を使用(Node.js 22 同梱版) - Metro キャッシュ問題が発生した場合のみ`expo start --clear`を実行 - GraphQL codegen には`.env.local`に`GQL_API_URL`が必要 diff --git a/.github/workflows/bump_version_on_canary_pr.yml b/.github/workflows/bump_version_on_canary_pr.yml index 43baad6e82..4602817572 100644 --- a/.github/workflows/bump_version_on_canary_pr.yml +++ b/.github/workflows/bump_version_on_canary_pr.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "npm" - name: Install dependencies diff --git a/.github/workflows/bump_version_on_release_pr.yml b/.github/workflows/bump_version_on_release_pr.yml index dde224ebaa..87fd643eba 100644 --- a/.github/workflows/bump_version_on_release_pr.yml +++ b/.github/workflows/bump_version_on_release_pr.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "npm" - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3aa1a7ee60..8a01f394cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 3f3f2035b4..a00725cdc3 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "npm" - name: Install dependencies run: npm ci diff --git a/AGENTS.md b/AGENTS.md index 6d69bcf141..2166e2b32e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ This handbook defines how automation agents collaborate safely and effectively o ## Tooling & Environment Expectations -- Target **Node.js 20.x** and **npm 10.x**. +- Target **Node.js 22.x** and **npm 10.x**. - Run `npm install` when dependencies shift; avoid re-locking packages unless instructed. - Metro cache issues: run `expo start --clear` only when debugging build failures and document the action. - For native builds, rely on project scripts (`npm run android`, `npm run ios`). diff --git a/README.md b/README.md index 17731348b7..35ea45fc99 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This app recreates the authentic experience of Japanese train travel by displayi Before you begin, ensure you have met the following requirements: -- **Node.js 20.x** +- **Node.js 22.x** - **npm 10.x** - **React Native development environment** set up - **Expo CLI** installed globally diff --git a/assets/translations/en.json b/assets/translations/en.json index 8c515babf0..83e93ea9db 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -140,6 +140,9 @@ "tuningItemHeaderDelay": "Header animation delay", "tuningItemHeaderDuration": "Header animation duration", "tuningItemBottomTransitionDelay": "Bottom transition delay", + "tuningItemTTSVoice": "TTS Voice", + "tuningItemTTSJaVoiceName": "Japanese voice name", + "tuningItemTTSEnVoiceName": "English voice name", "tuningItemLocationAccuracy": "location accuracy", "headerDelayTooShortErrorText": "Header animation delay must be greater than the Header animation duration.", "nanErrorText": "The value must be entered numerically.", diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 888a66b865..0b456890ed 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -141,6 +141,9 @@ "tuningItemHeaderDelay": "ヘッダーアニメーション遅延", "tuningItemHeaderDuration": "ヘッダーアニメーション時間", "tuningItemBottomTransitionDelay": "ボトム遷移遅延", + "tuningItemTTSVoice": "TTS ボイス", + "tuningItemTTSJaVoiceName": "日本語ボイス名", + "tuningItemTTSEnVoiceName": "英語ボイス名", "tuningItemLocationAccuracy": "位置情報の精度", "headerDelayTooShortErrorText": "ヘッダーアニメーション遅延はヘッダーアニメーション時間より大きい値である必要があります。", "nanErrorText": "値は数値で入力してください。", diff --git a/docs/bump-version-on-canary-pr.md b/docs/bump-version-on-canary-pr.md index 5034435446..6f015d7df0 100644 --- a/docs/bump-version-on-canary-pr.md +++ b/docs/bump-version-on-canary-pr.md @@ -10,7 +10,7 @@ ## 動作 1. PRのヘッドブランチをチェックアウト -2. npm + Node.js 20 をセットアップ +2. npm + Node.js 22 をセットアップ 3. `npm ci` で依存関係インストール 4. `npm run version:bump -- --no-version-increment` を実行 - アプリのバージョン(`X.Y.Z`)は変更しない diff --git a/functions/README.md b/functions/README.md index 4f98c231cb..7ed5acf437 100644 --- a/functions/README.md +++ b/functions/README.md @@ -24,7 +24,7 @@ These Cloud Functions provide server-side functionality including: ### Prerequisites -- **Node.js** (version 20) +- **Node.js** (version 22) - **npm** package manager - **Firebase CLI** installed globally - **Firebase project** set up and configured diff --git a/functions/package-lock.json b/functions/package-lock.json index 8e4f19ca02..14326c262a 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -24,7 +24,7 @@ "typescript": "^4.9.0" }, "engines": { - "node": "20" + "node": "22" } }, "node_modules/@ampproject/remapping": { diff --git a/functions/package.json b/functions/package.json index 0e96d07544..ffa2f29738 100644 --- a/functions/package.json +++ b/functions/package.json @@ -21,7 +21,7 @@ "find-orphaned-tts:prod": "tsx src/cli/find-orphaned-tts.ts --project trainlcd-ea91e --bucket trainlcd-ea91e.firebasestorage.app" }, "engines": { - "node": "20" + "node": "22" }, "main": "lib/index.js", "dependencies": { diff --git a/functions/src/cli/find-orphaned-tts.ts b/functions/src/cli/find-orphaned-tts.ts index 23a2e3862d..ae513b234b 100644 --- a/functions/src/cli/find-orphaned-tts.ts +++ b/functions/src/cli/find-orphaned-tts.ts @@ -13,7 +13,9 @@ function printUsage(): void { console.error(''); console.error('Options:'); console.error(' --bucket Cloud Storageバケット名(必須)'); - console.error(' --project Firebaseプロジェクトを指定(必須)'); + console.error( + ' --project Firebaseプロジェクトを指定(必須)' + ); console.error(' --delete 孤立ファイルを確認後に削除'); } @@ -54,11 +56,11 @@ function parseArgs(argv: string[]): CliArgs | null { const PAGE_SIZE = 1000; -async function collectStorageIds( +async function collectStorageFiles( bucket: Bucket, prefix: string -): Promise> { - const ids = new Set(); +): Promise> { + const filesById = new Map(); let pageToken: string | undefined; do { @@ -70,23 +72,24 @@ async function collectStorageIds( }); for (const file of files) { - const match = file.name.match( - /caches\/tts\/(?:ja|en)\/(.+)\.mp3$/ - ); + const match = file.name.match(/caches\/tts\/(?:ja|en)\/(.+)\.[^.]+$/); if (match?.[1]) { - ids.add(match[1]); + const id = match[1]; + const existing = filesById.get(id) ?? []; + existing.push(file.name); + filesById.set(id, existing); } } pageToken = (apiResponse as { nextPageToken?: string } | undefined) ?.nextPageToken; if (pageToken) { - process.stdout.write(`\r ${prefix} ... ${ids.size}件取得済み`); + process.stdout.write(`\r ${prefix} ... ${filesById.size}件取得済み`); } } while (pageToken); - process.stdout.write(`\r ${prefix} ... ${ids.size}件 完了\n`); - return ids; + process.stdout.write(`\r ${prefix} ... ${filesById.size}件 完了\n`); + return filesById; } async function main(): Promise { @@ -118,12 +121,16 @@ async function main(): Promise { // Storage のファイルIDをページネーションで取得(メモリ節約) console.log('Storageのファイル一覧を取得中...'); - const [jaIds, enIds] = await Promise.all([ - collectStorageIds(bucket, 'caches/tts/ja/'), - collectStorageIds(bucket, 'caches/tts/en/'), + const [jaFilesById, enFilesById] = await Promise.all([ + collectStorageFiles(bucket, 'caches/tts/ja/'), + collectStorageFiles(bucket, 'caches/tts/en/'), ]); + const jaIds = new Set(jaFilesById.keys()); + const enIds = new Set(enFilesById.keys()); const storageIds = new Set([...jaIds, ...enIds]); - console.log(` Storageファイル数: JA=${jaIds.size}, EN=${enIds.size} (ユニークID: ${storageIds.size})`); + console.log( + ` Storageファイル数: JA=${jaIds.size}, EN=${enIds.size} (ユニークID: ${storageIds.size})` + ); // 孤立ID = Storage にあるが Firestore にない const orphanedIds = [...storageIds].filter((id) => !firestoreIds.has(id)); @@ -167,23 +174,31 @@ async function main(): Promise { console.log(`削除中: ${id}...`); const promises: Promise[] = []; - if (ja) promises.push(bucket.file(`caches/tts/ja/${id}.mp3`).delete()); - if (en) promises.push(bucket.file(`caches/tts/en/${id}.mp3`).delete()); + if (ja) { + for (const path of jaFilesById.get(id) ?? []) { + promises.push(bucket.file(path).delete()); + } + } + if (en) { + for (const path of enFilesById.get(id) ?? []) { + promises.push(bucket.file(path).delete()); + } + } const results = await Promise.allSettled(promises); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length > 0) { for (const f of failed) { - console.warn( - ` 削除失敗: ${(f as PromiseRejectedResult).reason}` - ); + console.warn(` 削除失敗: ${(f as PromiseRejectedResult).reason}`); } } else { deletedCount++; } } - console.log(`\n${deletedCount}/${orphanedFiles.length}件の削除が完了しました。`); + console.log( + `\n${deletedCount}/${orphanedFiles.length}件の削除が完了しました。` + ); } function confirm(prompt: string): Promise { diff --git a/functions/src/cli/find-tts-cache.ts b/functions/src/cli/find-tts-cache.ts index 290333b7a8..21290cd369 100644 --- a/functions/src/cli/find-tts-cache.ts +++ b/functions/src/cli/find-tts-cache.ts @@ -12,7 +12,7 @@ function printUsage(): void { ); console.error(' --exact 部分一致ではなく完全一致で検索'); console.error( - ' --delete 検索結果を確認後、FirestoreドキュメントとストレージのMP3を削除' + ' --delete 検索結果を確認後、Firestoreドキュメントとストレージ音声を削除' ); console.error( ' --bucket Cloud Storageバケット名を指定(--delete時は必須)' @@ -178,14 +178,12 @@ async function main(): Promise { } if (!bucket) { - console.error( - 'Error: --delete には --bucket の指定が必要です。' - ); + console.error('Error: --delete には --bucket の指定が必要です。'); process.exit(1); } const confirmed = await confirm( - `\n上記 ${results.length}件のドキュメントとストレージのMP3ファイルを削除しますか? (y/N): ` + `\n上記 ${results.length}件のドキュメントとストレージ音声ファイルを削除しますか? (y/N): ` ); if (!confirmed) { console.log('削除をキャンセルしました。'); @@ -197,8 +195,10 @@ async function main(): Promise { for (const doc of results) { const data = doc.data(); const id: string = doc.id; - const storagePathJa = `caches/tts/ja/${id}.mp3`; - const storagePathEn = `caches/tts/en/${id}.mp3`; + const storagePathJa = + typeof data.pathJa === 'string' ? data.pathJa : `caches/tts/ja/${id}.mp3`; + const storagePathEn = + typeof data.pathEn === 'string' ? data.pathEn : `caches/tts/en/${id}.mp3`; console.log(`削除中: ${id}...`); diff --git a/functions/src/funcs/tts.ts b/functions/src/funcs/tts.ts index 32d381f7c7..04b96c684c 100644 --- a/functions/src/funcs/tts.ts +++ b/functions/src/funcs/tts.ts @@ -1,7 +1,10 @@ import { createHash } from 'node:crypto'; import { PubSub } from '@google-cloud/pubsub'; +import { type GenerateContentResponse, VertexAI } from '@google-cloud/vertexai'; import * as admin from 'firebase-admin'; +import { defineSecret } from 'firebase-functions/params'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { encodePcmToMp3 } from '../utils/encodeMp3'; import { applyLegacyIpaReplacements } from '../utils/legacyIpa'; import { normalizeRomanText } from '../utils/normalize'; @@ -15,206 +18,372 @@ const firestore = admin.firestore(); const storage = admin.storage(); const pubsub = new PubSub(); -export const tts = onCall({ region: 'asia-northeast1' }, async (req) => { - if (!req.auth) { - throw new HttpsError( - 'failed-precondition', - 'The function must be called while authenticated.' +const googleTtsApiKey = defineSecret('GOOGLE_TTS_API_KEY'); + +const GEMINI_TTS_MODEL = 'gemini-2.5-flash-tts'; +const VERTEX_AI_LOCATION = 'us-central1'; +const GOOGLE_TTS_API_VERSION = 'v1'; + +interface SynthesizedAudio { + audioContent: string; + mimeType?: string; +} + +const sniffAudioMimeType = (audioBuffer: Buffer): string => { + // RIFF....WAVE + if ( + audioBuffer.length >= 12 && + audioBuffer.subarray(0, 4).toString('ascii') === 'RIFF' && + audioBuffer.subarray(8, 12).toString('ascii') === 'WAVE' + ) { + return 'audio/wav'; + } + + // MP3 with ID3 header + if ( + audioBuffer.length >= 3 && + audioBuffer.subarray(0, 3).toString('ascii') === 'ID3' + ) { + return 'audio/mpeg'; + } + + // MP3 frame sync: 11111111 111xxxxx + if ( + audioBuffer.length >= 2 && + audioBuffer[0] === 0xff && + (audioBuffer[1] & 0xe0) === 0xe0 + ) { + return 'audio/mpeg'; + } + + // Fallback: raw PCM/L16 として扱う + return 'audio/pcm'; +}; + +/** 音声バッファが既にMP3であればそのまま返し、PCM/WAVならMP3にエンコードする */ +const ensureMp3 = async ( + audioBuffer: Buffer, + mimeType: string +): Promise<{ buffer: Buffer; mimeType: string }> => { + if (mimeType === 'audio/mpeg') { + return { buffer: audioBuffer, mimeType }; + } + return encodePcmToMp3(audioBuffer); +}; + +/** SSMLタグを除去してプレーンテキストに変換する(Y → Y(X)) */ +const stripSsml = (text: string): string => + text + .replace( + /([^<]*)<\/sub>/gi, + (_match, alias, original) => + original === alias ? alias : `${original}(${alias})` + ) + .replace(//gi, ' ') + .replace(/|<\/speak>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); + +/** + * Gemini TTS を使用してテキストを音声に変換する。 + * + * @param projectId - GCP プロジェクト ID + * @param text - 読み上げ対象テキスト(SSML タグは内部で除去される) + * @param voiceName - 使用する音声名(例: "Aoede") + * @param prompt - 読み上げスタイルを指示するプロンプト(任意) + * @returns Base64 エンコードされた音声データと MIME タイプ + * + * @remarks + * `@google-cloud/vertexai` の型定義には `speechConfig` が含まれていないため、 + * `generationConfig` に対する型アサーションは意図的なものである。 + */ +const synthesizeWithGemini = async ( + projectId: string, + text: string, + voiceName: string, + prompt?: string +): Promise => { + const vertexAI = new VertexAI({ + project: projectId, + location: VERTEX_AI_LOCATION, + }); + + const model = vertexAI.getGenerativeModel({ + model: GEMINI_TTS_MODEL, + generationConfig: { + responseModalities: ['AUDIO'], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { voiceName }, + }, + }, + } as Parameters[0]['generationConfig'], + }); + + const response = await model.generateContent({ + contents: [ + { + role: 'user', + parts: [ + { + text: prompt ? `${prompt}\n${stripSsml(text)}` : stripSsml(text), + }, + ], + }, + ], + }); + + const result: GenerateContentResponse = response.response; + const inlineData = result.candidates?.[0]?.content?.parts?.[0]?.inlineData; + const audioData = inlineData?.data; + + if (!audioData) { + const status = result.candidates?.[0]?.finishReason ?? 'UNKNOWN'; + const usage = result.usageMetadata; + throw new Error( + `Gemini TTS (${GEMINI_TTS_MODEL}) returned no audio data — finishReason: ${status}, usage: ${JSON.stringify(usage)}` ); } - const ssmlJa: string | undefined = req.data.ssmlJa; - if (!(typeof ssmlJa === 'string') || ssmlJa.length === 0) { + return { + audioContent: audioData, + mimeType: inlineData?.mimeType, + }; +}; + +/** Google Cloud Text-to-Speech API を使用して SSML を音声に変換する */ +const synthesizeWithGoogleTts = async ( + ssml: string, + voiceName: string, + languageCode: string +): Promise => { + const apiKey = googleTtsApiKey.value(); + if (!apiKey) { throw new HttpsError( - 'invalid-argument', - `The function must be called with one argument "ssmlJa" containing the message to add.` + 'failed-precondition', + 'GOOGLE_TTS_API_KEY is not set' ); } - let ssmlEn = normalizeRomanText(req.data.ssmlEn) - // Airport Terminal 1・2等 - .replace(/・/g, ' ') - // Otsuka・Teikyo-Daigakuなど - .replace(/・/g, ' ') - // 環状運転の場合に & が含まれる可能性があるため置換 - .replace(/&(?!#\d+;|#x[0-9A-Fa-f]+;|\w+;)/g, 'and') - // 全角記号 - .replace(/[!-/:-@[-`{-~、-〜”・]+/g, ' ') - // 明治神宮前駅等の駅名にバッククォートが含まれる場合があるため除去 - .replace(/`/g, '') - .replace(/JR/gi, 'J-R') - // 都営バスを想定 - .replace(/.Sta\./gi, ' Station') - .replace(/.Univ\./gi, ' University') - .replace(/.Hp\./gi, ' Hospital'); - - // アプリ側でnameIpaによるタグが埋め込まれていない場合はレガシーIPA置換を適用 - if (!ssmlEn.includes(' { + if (!req.auth) { + throw new HttpsError( + 'failed-precondition', + 'The function must be called while authenticated.' + ); + } + + const ssmlJa: string | undefined = req.data.ssmlJa; + if (!(typeof ssmlJa === 'string') || ssmlJa.length === 0) { + throw new HttpsError( + 'invalid-argument', + `The function must be called with one argument "ssmlJa" containing the message to add.` + ); + } + + if (typeof req.data.ssmlEn !== 'string' || req.data.ssmlEn.length === 0) { + throw new HttpsError( + 'invalid-argument', + `The function must be called with one argument "ssmlEn" containing the message to add.` + ); + } + + let ssmlEn = normalizeRomanText(req.data.ssmlEn); + + // タグが埋め込まれていない場合はレガシーIPA置換を適用 + if (!ssmlEn.includes(' { - console.error('Failed to publish cache message:', err); - // キャッシュ失敗はユーザーに影響しないため、エラーは投げない - }); - - return { id, jaAudioContent, enAudioContent }; - } catch (error) { - console.error('TTS API call failed:', error); - throw new HttpsError('internal', 'TTS synthesis failed'); + enVoiceName, + 'Read the following at a brisk, quick pace like a train announcement. The text contains Japanese railway station names and line names in romanized form. Pronounce them accurately:' + ), + ]); + + const enAudioBuffer = Buffer.from(enAudio.audioContent, 'base64'); + const enMp3 = await ensureMp3( + enAudioBuffer, + enAudio.mimeType || sniffAudioMimeType(enAudioBuffer) + ); + + const enAudioContent = enMp3.buffer.toString('base64'); + const enAudioMimeType = enMp3.mimeType; + + const cacheTopic = pubsub.topic('tts-cache'); + cacheTopic + .publishMessage({ + json: { + id, + jaAudioContent, + enAudioContent, + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType, + ssmlJa, + ssmlEn, + voiceJa: jaVoiceName, + voiceEn: enVoiceName, + enModel: GEMINI_TTS_MODEL, + }, + }) + .catch((err) => { + console.error('Failed to publish cache message:', err); + // キャッシュ失敗はユーザーに影響しないため、エラーは投げない + }); + + return { + id, + jaAudioContent, + enAudioContent, + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType, + }; + } catch (error) { + if (error instanceof HttpsError) { + throw error; + } + console.error('TTS API call failed:', error); + throw new HttpsError('internal', 'TTS synthesis failed'); + } } -}); +); diff --git a/functions/src/funcs/ttsCachePubSub.ts b/functions/src/funcs/ttsCachePubSub.ts index 07c20320dd..b1316a3ba2 100644 --- a/functions/src/funcs/ttsCachePubSub.ts +++ b/functions/src/funcs/ttsCachePubSub.ts @@ -5,6 +5,17 @@ import { onMessagePublished } from 'firebase-functions/v2/pubsub'; const firestore = admin.firestore(); const storage = admin.storage(); +const getCacheFileExtension = (mimeType: string): 'mp3' | 'wav' | 'pcm' => { + const normalized = mimeType.toLowerCase(); + if (normalized.includes('mpeg') || normalized.includes('mp3')) { + return 'mp3'; + } + if (normalized.includes('wav')) { + return 'wav'; + } + return 'pcm'; +}; + export const ttsCachePubSub = onMessagePublished( { topic: 'tts-cache', region: 'asia-northeast1' }, async (event) => { @@ -12,6 +23,8 @@ export const ttsCachePubSub = onMessagePublished( id, jaAudioContent, enAudioContent, + jaAudioMimeType, + enAudioMimeType, ssmlJa, ssmlEn, voiceJa, @@ -29,21 +42,31 @@ export const ttsCachePubSub = onMessagePublished( const jaTtsCachePathBase = 'caches/tts/ja'; const jaTtsBuf = Buffer.from(jaAudioContent, 'base64'); - const jaTtsCachePath = `${jaTtsCachePathBase}/${id}.mp3`; + const jaContentType = + typeof jaAudioMimeType === 'string' && jaAudioMimeType + ? jaAudioMimeType + : 'audio/pcm'; + const jaExt = getCacheFileExtension(jaContentType); + const jaTtsCachePath = `${jaTtsCachePathBase}/${id}.${jaExt}`; const enTtsCachePathBase = 'caches/tts/en'; const enTtsBuf = Buffer.from(enAudioContent, 'base64'); - const enTtsCachePath = `${enTtsCachePathBase}/${id}.mp3`; + const enContentType = + typeof enAudioMimeType === 'string' && enAudioMimeType + ? enAudioMimeType + : 'audio/pcm'; + const enExt = getCacheFileExtension(enContentType); + const enTtsCachePath = `${enTtsCachePathBase}/${id}.${enExt}`; await Promise.all([ storage .bucket() .file(jaTtsCachePath) - .save(jaTtsBuf, { contentType: 'audio/mpeg', resumable: false }), + .save(jaTtsBuf, { contentType: jaContentType, resumable: false }), storage .bucket() .file(enTtsCachePath) - .save(enTtsBuf, { contentType: 'audio/mpeg', resumable: false }), + .save(enTtsBuf, { contentType: enContentType, resumable: false }), ]); await firestore @@ -55,9 +78,11 @@ export const ttsCachePubSub = onMessagePublished( id, ssmlJa, pathJa: jaTtsCachePath, + jaAudioMimeType: jaContentType, voiceJa, ssmlEn, pathEn: enTtsCachePath, + enAudioMimeType: enContentType, voiceEn, createdAt: Timestamp.now(), }); diff --git a/functions/src/utils/encodeMp3.ts b/functions/src/utils/encodeMp3.ts new file mode 100644 index 0000000000..d682401235 --- /dev/null +++ b/functions/src/utils/encodeMp3.ts @@ -0,0 +1,60 @@ +import { spawn } from 'node:child_process'; + +const MP3_BITRATE = '128k'; + +/** + * PCM 16-bit LE mono を ffmpeg で MP3 にエンコードする。 + * WAV (RIFF) ヘッダー付きの場合は自動判別される。 + */ +export const encodePcmToMp3 = async ( + pcmBuffer: Buffer, + sampleRate = 24000 +): Promise<{ buffer: Buffer; mimeType: string }> => { + const isWav = + pcmBuffer.length >= 12 && + pcmBuffer.subarray(0, 4).toString('ascii') === 'RIFF' && + pcmBuffer.subarray(8, 12).toString('ascii') === 'WAVE'; + + const inputArgs = isWav + ? ['-i', 'pipe:0'] + : ['-f', 's16le', '-ar', String(sampleRate), '-ac', '1', '-i', 'pipe:0']; + + const args = [ + '-hide_banner', + '-loglevel', + 'error', + ...inputArgs, + '-codec:a', + 'libmp3lame', + '-b:a', + MP3_BITRATE, + '-f', + 'mp3', + 'pipe:1', + ]; + + const mp3Buffer = await new Promise((resolve, reject) => { + const proc = spawn('ffmpeg', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + + const chunks: Buffer[] = []; + proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + + let stderr = ''; + proc.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + proc.on('error', reject); + proc.on('close', (code) => { + if (code !== 0) { + reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`)); + return; + } + resolve(Buffer.concat(chunks as unknown as Uint8Array[])); + }); + + proc.stdin.end(pcmBuffer); + }); + + return { buffer: mp3Buffer, mimeType: 'audio/mpeg' }; +}; diff --git a/functions/src/utils/legacyIpa.ts b/functions/src/utils/legacyIpa.ts index 8ccd3ffc9a..b7dd0f46e4 100644 --- a/functions/src/utils/legacyIpa.ts +++ b/functions/src/utils/legacyIpa.ts @@ -18,10 +18,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /Isesaki/gi, 'いせさき' ) - .replace( - /Mejiro/gi, - 'めじろ' - ) + .replace(/Mejiro/gi, 'めじろ') .replace( /Keisei/gi, 'けいせい' @@ -34,10 +31,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /Meitetsu/gi, 'めいてつ' ) - .replace( - /Seibu/gi, - 'せいぶ' - ) + .replace(/Seibu/gi, 'せいぶ') .replace( /Toride/gi, 'とりで' @@ -46,10 +40,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /Fukiage/gi, 'ふきあげ' ) - .replace( - /\bFuse\b/gi, - 'ふせ' - ) + .replace(/\bFuse\b/gi, 'ふせ') .replace( /\bInagekaigan\b/gi, 'いなげかいがん' @@ -62,10 +53,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /\bKire-Uriwari\b/gi, 'きれうりわり' ) - .replace( - /\bYao\b/gi, - 'やお' - ) + .replace(/\bYao\b/gi, 'やお') .replace( /Shimbashi/gi, 'しんばし' @@ -78,10 +66,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /Shinagawa/gi, 'しながわ' ) - .replace( - /Ueno/gi, - 'うえの' - ) + .replace(/Ueno/gi, 'うえの') .replace( /Ikebukuro/gi, 'いけぶくろ' @@ -102,10 +87,7 @@ export const applyLegacyIpaReplacements = (text: string): string => /Yokohama/gi, 'よこはま' ) - .replace( - /Chiba/gi, - 'ちば' - ) + .replace(/Chiba/gi, 'ちば') .replace( /Kawasaki/gi, 'かわさき' @@ -118,7 +100,4 @@ export const applyLegacyIpaReplacements = (text: string): string => /Ryogoku/gi, 'りょうごく' ) - .replace( - /koen/gi, - 'こえん' - ); + .replace(/koen/gi, 'こえん'); diff --git a/functions/src/utils/normalize.ts b/functions/src/utils/normalize.ts index 127e61b24b..f49aab86da 100644 --- a/functions/src/utils/normalize.ts +++ b/functions/src/utils/normalize.ts @@ -21,5 +21,22 @@ export const normalizeRomanText = (str: string | undefined): string => { ) .join(''); - return removeMacron(replaced.replace('Jr', 'J-R')); + return ( + removeMacron(replaced) + // Airport Terminal 1・2等 + .replace(/・/g, ' ') + // Otsuka・Teikyo-Daigakuなど + .replace(/・/g, ' ') + // 環状運転の場合に & が含まれる可能性があるため置換 + .replace(/&(?!#\d+;|#x[0-9A-Fa-f]+;|\w+;)/g, 'and') + // 全角記号 + .replace(/[!-/:-@[-`{-~、-〜”・]+/g, ' ') + // 明治神宮前駅等の駅名にバッククォートが含まれる場合があるため除去 + .replace(/`/g, '') + .replace(/JR/gi, 'J-R') + // 都営バスを想定 + .replace(/\bSta\./gi, ' Station') + .replace(/\bUniv\./gi, ' University') + .replace(/\bHp\./gi, ' Hospital') + ); }; diff --git a/src/@types/graphql.d.ts b/src/@types/graphql.d.ts index e1b0becd81..9f04543142 100644 --- a/src/@types/graphql.d.ts +++ b/src/@types/graphql.d.ts @@ -60,7 +60,6 @@ export type Line = { lineType: Maybe; nameChinese: Maybe; nameFull: Maybe; - nameIpa: Maybe; nameKatakana: Maybe; nameKorean: Maybe; nameRoman: Maybe; @@ -81,7 +80,6 @@ export type LineNested = { lineType: Maybe; nameChinese: Maybe; nameFull: Maybe; - nameIpa: Maybe; nameKatakana: Maybe; nameKorean: Maybe; nameRoman: Maybe; @@ -256,7 +254,6 @@ export type Station = { longitude: Maybe; name: Maybe; nameChinese: Maybe; - nameIpa: Maybe; nameKatakana: Maybe; nameKorean: Maybe; nameRoman: Maybe; @@ -285,7 +282,6 @@ export type StationNested = { longitude: Maybe; name: Maybe; nameChinese: Maybe; - nameIpa: Maybe; nameKatakana: Maybe; nameKorean: Maybe; nameRoman: Maybe; @@ -424,7 +420,6 @@ export type LineNestedFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -465,7 +460,6 @@ export type LineNestedFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -522,7 +516,6 @@ export type TrainTypeNestedFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -563,7 +556,6 @@ export type TrainTypeNestedFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -608,7 +600,6 @@ export type TrainTypeNestedFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -649,7 +640,6 @@ export type TrainTypeNestedFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -694,7 +684,6 @@ export type StationFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; @@ -729,7 +718,6 @@ export type StationFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -770,7 +758,6 @@ export type StationFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -815,7 +802,6 @@ export type StationFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -856,7 +842,6 @@ export type StationFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -915,7 +900,6 @@ export type StationFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -956,7 +940,6 @@ export type StationFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1001,7 +984,6 @@ export type StationFieldsFragment = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1042,7 +1024,6 @@ export type StationFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1098,7 +1079,6 @@ export type GetStationsNearbyQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; @@ -1133,7 +1113,6 @@ export type GetStationsNearbyQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1174,7 +1153,6 @@ export type GetStationsNearbyQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1219,7 +1197,6 @@ export type GetStationsNearbyQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1260,7 +1237,6 @@ export type GetStationsNearbyQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1319,7 +1295,6 @@ export type GetStationsNearbyQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1360,7 +1335,6 @@ export type GetStationsNearbyQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1405,7 +1379,6 @@ export type GetStationsNearbyQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1446,7 +1419,6 @@ export type GetStationsNearbyQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1495,7 +1467,6 @@ export type StationPresetFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; stationNumbers: | Array<{ @@ -1569,7 +1540,6 @@ export type GetLineListStationsPresetQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; stationNumbers: | Array<{ @@ -1644,7 +1614,6 @@ export type GetLineGroupListStationsPresetQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; stationNumbers: | Array<{ @@ -1714,7 +1683,6 @@ export type StationLightFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1734,7 +1702,6 @@ export type GetLineListStationsLightQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1756,7 +1723,6 @@ export type GetLineStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; @@ -1791,7 +1757,6 @@ export type GetLineStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1832,7 +1797,6 @@ export type GetLineStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1877,7 +1841,6 @@ export type GetLineStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -1918,7 +1881,6 @@ export type GetLineStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -1977,7 +1939,6 @@ export type GetLineStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2018,7 +1979,6 @@ export type GetLineStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2063,7 +2023,6 @@ export type GetLineStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2104,7 +2063,6 @@ export type GetLineStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2160,7 +2118,6 @@ export type GetStationsByNameQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; @@ -2195,7 +2152,6 @@ export type GetStationsByNameQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2236,7 +2192,6 @@ export type GetStationsByNameQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2281,7 +2236,6 @@ export type GetStationsByNameQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2322,7 +2276,6 @@ export type GetStationsByNameQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2381,7 +2334,6 @@ export type GetStationsByNameQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2422,7 +2374,6 @@ export type GetStationsByNameQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2467,7 +2418,6 @@ export type GetStationsByNameQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2508,7 +2458,6 @@ export type GetStationsByNameQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2562,7 +2511,6 @@ export type GetLineGroupStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; @@ -2597,7 +2545,6 @@ export type GetLineGroupStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2638,7 +2585,6 @@ export type GetLineGroupStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2683,7 +2629,6 @@ export type GetLineGroupStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2724,7 +2669,6 @@ export type GetLineGroupStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2783,7 +2727,6 @@ export type GetLineGroupStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2824,7 +2767,6 @@ export type GetLineGroupStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2869,7 +2811,6 @@ export type GetLineGroupStationsQuery = { averageDistance: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameFull: string | null | undefined; nameKatakana: string | null | undefined; nameRoman: string | null | undefined; @@ -2910,7 +2851,6 @@ export type GetLineGroupStationsQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -2959,7 +2899,6 @@ export type LineRouteFieldsFragment = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -2977,7 +2916,6 @@ export type LineRouteFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3029,7 +2967,6 @@ export type TrainTypeRouteFieldsFragment = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3047,7 +2984,6 @@ export type TrainTypeRouteFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3092,7 +3028,6 @@ export type TrainTypeRouteFieldsFragment = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3110,7 +3045,6 @@ export type TrainTypeRouteFieldsFragment = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3178,7 +3112,6 @@ export type GetRouteTypesLightQuery = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3196,7 +3129,6 @@ export type GetRouteTypesLightQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3241,7 +3173,6 @@ export type GetRouteTypesLightQuery = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3259,7 +3190,6 @@ export type GetRouteTypesLightQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3323,7 +3253,6 @@ export type GetStationTrainTypesLightQuery = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3341,7 +3270,6 @@ export type GetStationTrainTypesLightQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; @@ -3386,7 +3314,6 @@ export type GetStationTrainTypesLightQuery = { id: number | null | undefined; color: string | null | undefined; lineType: LineType | null | undefined; - nameIpa: string | null | undefined; nameShort: string | null | undefined; nameRoman: string | null | undefined; lineSymbols: @@ -3404,7 +3331,6 @@ export type GetStationTrainTypesLightQuery = { id: number | null | undefined; groupId: number | null | undefined; name: string | null | undefined; - nameIpa: string | null | undefined; nameRoman: string | null | undefined; nameChinese: string | null | undefined; nameKorean: string | null | undefined; diff --git a/src/__fixtures__/line.ts b/src/__fixtures__/line.ts index 7ca25d67af..e2bb2f58aa 100644 --- a/src/__fixtures__/line.ts +++ b/src/__fixtures__/line.ts @@ -20,7 +20,6 @@ export const TOEI_SHINJUKU_LINE_LOCAL: Line = { id: 99304, averageDistance: undefined, nameShort: '都営新宿線', - nameIpa: undefined, nameKatakana: 'トエイシンジュクセン', nameFull: '都営新宿線', nameRoman: 'Toei Shinjuku Line', @@ -47,7 +46,6 @@ export const TOEI_SHINJUKU_LINE_LOCAL: Line = { id: 9930401, groupId: 1130208, name: '新宿', - nameIpa: undefined, nameKatakana: 'シンジュク', nameRoman: 'Shinjuku', nameChinese: '新宿', @@ -89,7 +87,6 @@ export const RYOMO_LINE: Line = { id: 11341, averageDistance: undefined, nameShort: '両毛線', - nameIpa: undefined, nameKatakana: 'リョウモウセン', nameFull: '両毛線', nameRoman: 'Ryomo Line', @@ -108,7 +105,6 @@ export const RYOMO_LINE: Line = { id: 1134101, groupId: 1131816, name: '高崎', - nameIpa: undefined, nameKatakana: 'タカサキ', nameRoman: 'Takasaki', nameChinese: '高崎', diff --git a/src/components/TuningSettings.tsx b/src/components/TuningSettings.tsx index 3fb336ef6b..46ae03682f 100644 --- a/src/components/TuningSettings.tsx +++ b/src/components/TuningSettings.tsx @@ -1,11 +1,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; import { useAtom, useAtomValue } from 'jotai'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { + ActionSheetIOS, Alert, + FlatList, KeyboardAvoidingView, + Modal, Platform, + Pressable, ScrollView, StyleSheet, Switch, @@ -60,8 +64,33 @@ const styles = StyleSheet.create({ marginLeft: 8, fontWeight: 'bold', }, + picker: { + marginTop: 12, + borderWidth: 1, + borderColor: '#aaa', + paddingHorizontal: 10, + paddingVertical: 8, + width: '50%', + }, }); +const TTS_VOICE_NAMES = [ + 'Achernar', + 'Aoede', + 'Autonoe', + 'Callirrhoe', + 'Despina', + 'Erinome', + 'Gacrux', + 'Kore', + 'Laomedeia', + 'Leda', + 'Pulcherrima', + 'Sulafat', + 'Vindemiatrix', + 'Zephyr', +] as const; + const TuningSettings: React.FC = () => { const [settings, setSettings] = useAtom(tuningState); const isLEDTheme = useAtomValue(isLEDThemeAtom); @@ -69,6 +98,18 @@ const TuningSettings: React.FC = () => { const navigation = useNavigation(); const { left: safeAreaLeft, right: safeAreaRight } = useSafeAreaInsets(); + useEffect(() => { + (async () => { + const enVoice = await AsyncStorage.getItem( + ASYNC_STORAGE_KEYS.TTS_EN_VOICE_NAME + ); + setSettings((prev) => ({ + ...prev, + ttsEnVoiceName: enVoice ?? '', + })); + })(); + }, [setSettings]); + const hasInvalidNumber = settings.bottomTransitionInterval < 0 || settings.headerTransitionDelay < 0 || @@ -126,6 +167,38 @@ const TuningSettings: React.FC = () => { ), })); + const [voicePickerState, setVoicePickerState] = useState<{ + current: string; + onSelect: (voice: string) => void; + } | null>(null); + + const showVoicePicker = ( + current: string, + onSelect: (voice: string) => void + ) => { + if (Platform.OS === 'ios') { + const options = [...TTS_VOICE_NAMES, translate('cancel')]; + ActionSheetIOS.showActionSheetWithOptions( + { + options, + cancelButtonIndex: options.length - 1, + }, + (index) => { + if (index < TTS_VOICE_NAMES.length) { + onSelect(TTS_VOICE_NAMES[index]); + } + } + ); + } else { + setVoicePickerState({ current, onSelect }); + } + }; + + const handleEnVoiceNameChange = (voice: string) => { + setSettings((prev) => ({ ...prev, ttsEnVoiceName: voice })); + AsyncStorage.setItem(ASYNC_STORAGE_KEYS.TTS_EN_VOICE_NAME, voice); + }; + const toggleDevOverlayEnabled = () => setSettings((prev) => ({ ...prev, @@ -245,6 +318,33 @@ const TuningSettings: React.FC = () => { ms + + {translate('tuningItemTTSVoice')} + + + + {translate('tuningItemTTSEnVoiceName')} + + + showVoicePicker(settings.ttsEnVoiceName, handleEnVoiceNameChange) + } + > + + {settings.ttsEnVoiceName || translate('notSpecified')} + + + {isLEDTheme ? ( { + + {voicePickerState && ( + setVoicePickerState(null)} + > + setVoicePickerState(null)} + > + + item} + renderItem={({ item }) => ( + { + voicePickerState.onSelect(item); + setVoicePickerState(null); + }} + > + + {voicePickerState.current === item ? `● ${item}` : item} + + + )} + /> + setVoicePickerState(null)} + > + + {translate('cancel')} + + + + + + )} ); }; diff --git a/src/constants/asyncStorage.ts b/src/constants/asyncStorage.ts index 6ee335e9ac..0f29c5b63f 100644 --- a/src/constants/asyncStorage.ts +++ b/src/constants/asyncStorage.ts @@ -17,6 +17,8 @@ export const ASYNC_STORAGE_KEYS = { WEEKDAY_ALERT_DISMISSED: '@TrainLCD:weekdayAlertDismissed', PARTIALLY_PASS_ALERT_DISMISSED: '@TrainLCD:partiallyPassAlertDismissed', TELEMETRY_ENABLED: '@TrainLCD:telemetryEnabled', + TTS_JA_VOICE_NAME: '@TrainLCD:ttsJaVoiceName', + TTS_EN_VOICE_NAME: '@TrainLCD:ttsEnVoiceName', WALKTHROUGH_COMPLETED: '@TrainLCD:walkthroughCompleted', ROUTE_SEARCH_WALKTHROUGH_COMPLETED: '@TrainLCD:routeSearchWalkthroughCompleted', diff --git a/src/hooks/useBusTTSText.test.tsx b/src/hooks/useBusTTSText.test.tsx index 0563136bee..d045d48eea 100644 --- a/src/hooks/useBusTTSText.test.tsx +++ b/src/hooks/useBusTTSText.test.tsx @@ -110,7 +110,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([]); + expect(result.current).toEqual({ text: [], nextText: [] }); }); }); @@ -148,7 +148,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText?.indexOf('undefined')).toBe(-1); expect(enText?.indexOf('undefined')).toBe(-1); } @@ -162,7 +162,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('をご利用くださいまして、ありがとうございます'); expect(jaText).toContain('次は'); expect(jaText).toContain('このバスは'); @@ -179,7 +179,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('次は'); expect(jaText).not.toContain('ご利用くださいまして'); expect(enText).toContain('The next stop is'); @@ -193,7 +193,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('Arriving at'); }); @@ -207,7 +207,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('をご利用くださいまして'); expect(jaText).toContain('このバスは'); expect(jaText).toContain('次は'); @@ -222,7 +222,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('We will soon make a brief stop at'); }); @@ -236,7 +236,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('今日も'); expect(jaText).toContain('このバスは'); expect(jaText).toContain('次は'); @@ -251,7 +251,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('The next stop is'); }); @@ -265,7 +265,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('今日も'); expect(jaText).toContain('このバスは'); expect(jaText).toContain('次は'); @@ -281,7 +281,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('We will soon be making a brief stop at'); }); @@ -295,7 +295,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('今日も'); expect(jaText).toContain('このバスは'); expect(jaText).toContain('次は'); @@ -310,7 +310,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('The next stop is'); }); @@ -324,7 +324,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('をご利用くださいまして'); expect(jaText).toContain('次は'); expect(jaText).toContain('このバスは'); @@ -339,7 +339,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('We will soon be arriving at'); }); @@ -353,7 +353,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('次は'); expect(enText).toContain('The next stop is'); }); @@ -365,7 +365,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('Arriving at'); }); @@ -379,7 +379,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('今日も'); expect(jaText).toContain('次は'); expect(enText).toContain('This is the'); @@ -392,7 +392,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('The next stop is'); }); @@ -406,7 +406,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('今日も'); expect(jaText).toContain('次は'); expect(enText).toContain('This is the'); @@ -419,7 +419,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('The next stop is'); }); @@ -433,7 +433,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('このバスは'); expect(jaText).toContain('行きです'); expect(jaText).toContain('次は'); @@ -448,7 +448,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText, enText] = result.current; + const [jaText, enText] = result.current.text; expect(jaText).toContain('まもなく'); expect(enText).toContain('We will soon be arriving at'); }); @@ -462,7 +462,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [, enText] = result.current; + const [, enText] = result.current.text; expect(enText).not.toContain('Station Number'); expect(enText).not.toContain('station number'); }); @@ -476,7 +476,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [jaText] = result.current; + const [jaText] = result.current.text; expect(jaText).not.toContain('直通'); }); @@ -487,7 +487,7 @@ describe('useBusTTSText', () => { wrapper: wrapper, } ); - const [, enText] = result.current; + const [, enText] = result.current.text; expect(enText).not.toContain('on the'); }); }); diff --git a/src/hooks/useBusTTSText.ts b/src/hooks/useBusTTSText.ts index 1f1007e061..32443ec65e 100644 --- a/src/hooks/useBusTTSText.ts +++ b/src/hooks/useBusTTSText.ts @@ -1,12 +1,12 @@ import { useAtomValue } from 'jotai'; import { useCallback, useMemo } from 'react'; import type { Maybe, Station } from '~/@types/graphql'; +import { parenthesisRegexp } from '../constants'; import { APP_THEME, type AppTheme } from '../models/Theme'; import stationState from '../store/atoms/station'; import { themeAtom } from '../store/atoms/theme'; import getIsPass from '../utils/isPass'; import katakanaToHiragana from '../utils/kanaToHiragana'; -import { wrapIpa } from '../utils/ssml'; import { useAfterNextStation } from './useAfterNextStation'; import { useBounds } from './useBounds'; import { useCurrentLine } from './useCurrentLine'; @@ -17,6 +17,7 @@ import { useLoopLineBound } from './useLoopLineBound'; import { useNextStation } from './useNextStation'; import { useSlicedStations } from './useSlicedStations'; import { useStoppingState } from './useStoppingState'; +import type { TTSTextResult } from './useTTSText'; const EMPTY_TTS_TEXT = { [APP_THEME.TOKYO_METRO]: { NEXT: '', ARRIVING: '' }, @@ -31,10 +32,17 @@ const EMPTY_TTS_TEXT = { [APP_THEME.JR_KYUSHU]: { NEXT: '', ARRIVING: '' }, }; +const resolveTemplateTheme = (theme: AppTheme): AppTheme => { + if (theme === APP_THEME.LED) return APP_THEME.TOKYO_METRO; + if (theme === APP_THEME.JO || theme === APP_THEME.JL) + return APP_THEME.YAMANOTE; + return theme; +}; + export const useBusTTSText = ( firstSpeech = true, enabled = false -): [string, string] | [] => { +): TTSTextResult => { const theme = useAtomValue(themeAtom); const { selectedBound: selectedBoundOrigin, stations } = @@ -93,22 +101,13 @@ export const useBusTTSText = ( () => isLoopLine ? (loopLineBoundEn?.boundFor?.replaceAll('&', ' and ') ?? '') - : `${directionalStops?.map((s) => wrapIpa(s?.nameRoman, s?.nameIpa)).join(' and ')}`, + : (directionalStops?.map((s) => s?.nameRoman ?? '').join(' and ') ?? + ''), [directionalStops, isLoopLine, loopLineBoundEn?.boundFor] ); - const nextStation = useMemo( - () => - nextStationOrigin && { - ...nextStationOrigin, - nameRoman: wrapIpa( - nextStationOrigin.nameRoman, - nextStationOrigin.nameIpa - ), - }, - [nextStationOrigin] - ); + const nextStation = nextStationOrigin ?? null; // 直通時、同じGroupIDの駅が違う駅として扱われるのを防ぐ(ex. 渋谷の次は渋谷に止まります) const slicedStations = Array.from( @@ -118,29 +117,7 @@ export const useBusTTSText = ( .filter((s) => !!s) as Station[]; const afterNextStationOrigin = useAfterNextStation(); - const afterNextStation = useMemo(() => { - if (!afterNextStationOrigin) { - return undefined; - } - - return { - ...afterNextStationOrigin, - nameRoman: wrapIpa( - afterNextStationOrigin?.nameRoman, - afterNextStationOrigin?.nameIpa - ), - lines: - afterNextStationOrigin.lines?.map( - (l: { - nameRoman: string | null | undefined; - nameIpa: string | null | undefined; - }) => ({ - ...l, - nameRoman: wrapIpa(l.nameRoman, l.nameIpa), - }) - ) ?? [], - } as Station; - }, [afterNextStationOrigin]); + const afterNextStation = afterNextStationOrigin; const nextStationIndex = useMemo( () => slicedStations.findIndex((s) => s.groupId === nextStation?.groupId), @@ -153,7 +130,12 @@ export const useBusTTSText = ( ); const betweenNextStation = useMemo( - () => slicedStations.slice(nextStationIndex + 1, afterNextStationIndex), + () => + nextStationIndex === -1 || + afterNextStationIndex === -1 || + afterNextStationIndex <= nextStationIndex + ? [] + : slicedStations.slice(nextStationIndex + 1, afterNextStationIndex), [afterNextStationIndex, nextStationIndex, slicedStations] ); @@ -161,17 +143,12 @@ export const useBusTTSText = ( const allStops = useMemo( () => - slicedStations - .filter((s) => { - if (s.id === station?.id) { - return false; - } - return !getIsPass(s); - }) - .map((s) => ({ - ...s, - nameRoman: wrapIpa(s.nameRoman, s.nameIpa), - })), + slicedStations.filter((s) => { + if (s.groupId === station?.groupId) { + return false; + } + return !getIsPass(s); + }), [slicedStations, station] ); @@ -725,58 +702,40 @@ export const useBusTTSText = ( station?.line?.company?.nameEnglishShort, ]); - const jaText = useMemo(() => { - if (theme === APP_THEME.LED) { - const tmpl = japaneseTemplate?.TOKYO_METRO?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } - - if (theme === APP_THEME.JO || theme === APP_THEME.JL) { - const tmpl = japaneseTemplate?.YAMANOTE?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const resolved = resolveTemplateTheme(theme); - const tmpl = japaneseTemplate?.[theme]?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - }, [japaneseTemplate, stoppingState, theme]); - - const enText = useMemo(() => { - if (theme === APP_THEME.LED) { - const tmpl = englishTemplate?.TOKYO_METRO?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const jaText = useMemo( + () => japaneseTemplate?.[resolved]?.[stoppingState] ?? '', + [japaneseTemplate, resolved, stoppingState] + ); - if (theme === APP_THEME.JO || theme === APP_THEME.JL) { - const tmpl = englishTemplate?.YAMANOTE?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const enText = useMemo( + () => englishTemplate?.[resolved]?.[stoppingState] ?? '', + [englishTemplate, resolved, stoppingState] + ); - const tmpl = englishTemplate?.[theme]?.[stoppingState]; - if (!tmpl) { - return ''; - } + const nextJaText = useMemo( + () => japaneseTemplate?.[resolved]?.NEXT ?? '', + [japaneseTemplate, resolved] + ); - return tmpl; - }, [englishTemplate, stoppingState, theme]); + const nextEnText = useMemo( + () => englishTemplate?.[resolved]?.NEXT ?? '', + [englishTemplate, resolved] + ); if (!enabled) { - return []; + return { text: [], nextText: [] }; } - return [jaText.trim(), enText.trim()]; + return { + text: [ + jaText.trim().replace(parenthesisRegexp, ''), + enText.trim().replace(parenthesisRegexp, ''), + ], + nextText: [ + nextJaText.trim().replace(parenthesisRegexp, ''), + nextEnText.trim().replace(parenthesisRegexp, ''), + ], + }; }; diff --git a/src/hooks/useGetStationsWithTermination.test.tsx b/src/hooks/useGetStationsWithTermination.test.tsx index 9c08d8a627..1030ba34c4 100644 --- a/src/hooks/useGetStationsWithTermination.test.tsx +++ b/src/hooks/useGetStationsWithTermination.test.tsx @@ -20,7 +20,6 @@ const mkStation = (groupId: number, id: number = groupId): StationType => ({ id, groupId, name: '', - nameIpa: undefined, nameKatakana: '', nameRoman: undefined, nameChinese: undefined, diff --git a/src/hooks/useRefreshStation.test.tsx b/src/hooks/useRefreshStation.test.tsx index ebd3b7b8e6..a6c485ff7e 100644 --- a/src/hooks/useRefreshStation.test.tsx +++ b/src/hooks/useRefreshStation.test.tsx @@ -31,7 +31,6 @@ const mockStation: Station = { id: 1, groupId: 1, name: 'Test Station', - nameIpa: undefined, nameKatakana: 'テストステーション', nameRoman: 'Test Station', nameChinese: undefined, diff --git a/src/hooks/useTTS.test.ts b/src/hooks/useTTS.test.ts index a0253d442f..dfa095cbc0 100644 --- a/src/hooks/useTTS.test.ts +++ b/src/hooks/useTTS.test.ts @@ -3,6 +3,7 @@ import { createStore, Provider } from 'jotai'; import React from 'react'; import speechState from '~/store/atoms/speech'; import { mockFetch } from '~/utils/test/ttsMocks'; +import { clearFetchCache } from '~/utils/ttsSpeechFetcher'; import { useTTS } from './useTTS'; jest.mock('~/utils/isDevApp', () => ({ @@ -22,11 +23,17 @@ jest.mock('./useCurrentLine', () => ({ })); jest.mock('./useTTSText', () => ({ - useTTSText: jest.fn(() => ['ja text', 'en text']), + useTTSText: jest.fn(() => ({ + text: ['ja text', 'en text'], + nextText: ['ja next', 'en next'], + })), })); jest.mock('./useBusTTSText', () => ({ - useBusTTSText: jest.fn(() => ['ja text', 'en text']), + useBusTTSText: jest.fn(() => ({ + text: ['ja text', 'en text'], + nextText: ['ja next', 'en next'], + })), })); jest.mock('./usePrevious', () => ({ @@ -94,6 +101,8 @@ const mockSuccessfulFetch = () => { id: 'tts-id', jaAudioContent: 'QQ==', enAudioContent: 'QQ==', + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType: 'audio/mpeg', }, }), }); @@ -103,13 +112,17 @@ describe('useTTS', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); + clearFetchCache(); mockSuccessfulFetch(); mockCreateAudioPlayer.mockImplementation(() => createMockPlayer()); // テスト間で useTTSText の mock を復元 const { useTTSText } = jest.requireMock('./useTTSText') as { useTTSText: jest.Mock; }; - useTTSText.mockReturnValue(['ja text', 'en text']); + useTTSText.mockReturnValue({ + text: ['ja text', 'en text'], + nextText: ['ja next', 'en next'], + }); }); afterEach(() => { @@ -171,7 +184,7 @@ describe('useTTS', () => { jest.runAllTimers(); await waitFor(() => { - expect(calls.length).toBe(2); + expect(calls.length).toBeGreaterThanOrEqual(2); }); expect(calls[1]).toBe('/tmp/tts-id_en.mp3'); @@ -227,7 +240,10 @@ describe('useTTS', () => { store.set(speechState, defaultSpeechState); // 最初は有効なテキストで再生開始 - useTTSText.mockReturnValue(['ja text', 'en text']); + useTTSText.mockReturnValue({ + text: ['ja text', 'en text'], + nextText: ['ja next', 'en next'], + }); const { rerender } = renderHook(() => useTTS(), { wrapper: createWrapper(store), @@ -238,13 +254,13 @@ describe('useTTS', () => { }); // テキストを空にして再描画 - useTTSText.mockReturnValue(['', '']); + useTTSText.mockReturnValue({ text: ['', ''], nextText: [] }); rerender({}); jest.runAllTimers(); - // 空テキストではfetchが追加で呼ばれない - expect(mockFetch).toHaveBeenCalledTimes(1); + // 空テキストではfetchが追加で呼ばれない(本来のfetch + prefetch = 2回) + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('APIエラー時にfinishPlayingが呼ばれる', async () => { diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index af3b9b9900..1a1f68bff8 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -1,11 +1,14 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { getIdToken } from '@react-native-firebase/auth'; import { setAudioModeAsync } from 'expo-audio'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { DEV_TTS_API_URL, PRODUCTION_TTS_API_URL } from 'react-native-dotenv'; import { TransportType } from '~/@types/graphql'; +import { ASYNC_STORAGE_KEYS } from '../constants'; import speechState from '../store/atoms/speech'; import stationState from '../store/atoms/station'; +import tuningState from '../store/atoms/tuning'; import { computeSuppressionDecision } from '../utils/computeSuppressionDecision'; import { isDevApp } from '../utils/isDevApp'; import { @@ -33,6 +36,8 @@ export const useTTS = (): void => { const { enabled, backgroundEnabled, ttsEnabledLanguages } = useAtomValue(speechState); const { arrived, selectedBound } = useAtomValue(stationState); + const { ttsJaVoiceName, ttsEnVoiceName } = useAtomValue(tuningState); + const setTuning = useSetAtom(tuningState); const currentLine = useCurrentLine(); const stoppingState = useStoppingState(); const prevStoppingState = usePrevious(stoppingState); @@ -49,14 +54,19 @@ export const useTTS = (): void => { const speechWithTextRef = useRef< ((ja: string, en: string) => Promise) | null >(null); - const trainTTSText = useTTSText(firstSpeechRef.current, enabled); - const busTTSText = useBusTTSText(firstSpeechRef.current, enabled); - const ttsText = + const trainTTSResult = useTTSText(firstSpeechRef.current, enabled); + const busTTSResult = useBusTTSText(firstSpeechRef.current, enabled); + const ttsResult = currentLine?.transportType === TransportType.Bus - ? busTTSText - : trainTTSText; + ? busTTSResult + : trainTTSResult; + const ttsText = ttsResult.text; + const prefetchText = ttsResult.nextText; const [prevTextJa, prevTextEn] = usePrevious(ttsText); const [textJa, textEn] = ttsText; + const [prefetchJa, prefetchEn] = prefetchText.length + ? prefetchText + : [undefined, undefined]; const shouldSpeakJapanese = ttsEnabledLanguages.includes('JA'); const shouldSpeakEnglish = ttsEnabledLanguages.includes('EN'); @@ -75,6 +85,20 @@ export const useTTS = (): void => { enHandleRef.current = null; }, []); + useEffect(() => { + (async () => { + const [jaVoice, enVoice] = await Promise.all([ + AsyncStorage.getItem(ASYNC_STORAGE_KEYS.TTS_JA_VOICE_NAME), + AsyncStorage.getItem(ASYNC_STORAGE_KEYS.TTS_EN_VOICE_NAME), + ]); + setTuning((prev) => ({ + ...prev, + ttsJaVoiceName: jaVoice ?? '', + ttsEnVoiceName: enVoice ?? '', + })); + })(); + }, [setTuning]); + useEffect(() => { (async () => { try { @@ -231,6 +255,8 @@ export const useTTS = (): void => { textEn: en, apiUrl: ttsApiUrl, idToken, + jaVoiceName: ttsJaVoiceName || undefined, + enVoiceName: ttsEnVoiceName || undefined, }); if (!fetched) { @@ -245,11 +271,59 @@ export const useTTS = (): void => { finishPlaying(); } }, - [finishPlaying, speakFromPath, ttsApiUrl, user] + [ + finishPlaying, + speakFromPath, + ttsApiUrl, + ttsEnVoiceName, + ttsJaVoiceName, + user, + ] ); speechWithTextRef.current = speechWithText; + // 停車中に次の NEXT アナウンス音声を先読みフェッチする + const prefetchingRef = useRef(false); + useEffect(() => { + if (!enabled || !prefetchJa || !prefetchEn || prefetchingRef.current) { + return; + } + // 現在のテキストと同じなら既にフェッチ済み or これからフェッチされるので不要 + if (prefetchJa === textJa && prefetchEn === textEn) { + return; + } + prefetchingRef.current = true; + (async () => { + try { + const idToken = user && (await getIdToken(user)); + if (!idToken) return; + await fetchSpeechAudio({ + textJa: prefetchJa, + textEn: prefetchEn, + apiUrl: ttsApiUrl, + idToken, + jaVoiceName: ttsJaVoiceName || undefined, + enVoiceName: ttsEnVoiceName || undefined, + }); + } catch (e) { + console.warn('[useTTS] Prefetch failed:', e); + } finally { + prefetchingRef.current = false; + } + })(); + }, [ + enabled, + prefetchJa, + prefetchEn, + textJa, + textEn, + ttsApiUrl, + ttsEnVoiceName, + ttsJaVoiceName, + user, + ]); + useEffect(() => { const currentSelectedBoundId = selectedBound?.id ?? null; const hasSelectedBoundChanged = diff --git a/src/hooks/useTTSText.test.tsx b/src/hooks/useTTSText.test.tsx index 914b5f16d0..56d0aa419d 100644 --- a/src/hooks/useTTSText.test.tsx +++ b/src/hooks/useTTSText.test.tsx @@ -113,7 +113,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - const [jaSSML, enSSML] = result.current; + const [jaSSML, enSSML] = result.current.text; expect(jaSSML?.indexOf('undefined')).toBe(-1); expect(enSSML?.indexOf('undefined')).toBe(-1); } @@ -127,7 +127,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'The next stop is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -139,7 +139,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'Arriving at Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -154,7 +154,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線をご利用のお客様はお乗り換えです。', 'The next station is Shinjuku-sanchome S 2. Passengers changing to the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line, Please transfer at this station.', ]); @@ -166,7 +166,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線をご利用のお客様はお乗り換えです。新宿三丁目を出ますと、曙橋に停まります。', 'We will soon make a brief stop at Shinjuku-sanchome S 2. Passengers changing to the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line, Please transfer at this station.', ]); @@ -181,7 +181,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'The next station is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -193,7 +193,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線は、お乗り換えです。', 'The next station is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -208,7 +208,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'The next stop is Shinjuku-sanchome station number S 2. Transfer here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -220,7 +220,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。新宿三丁目を出ますと、次は、曙橋に停まります。', 'We will soon be making a brief stop at Shinjuku-sanchome station number S 2. Transfer here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line. After leaving Shinjuku-sanchome, We will be stopping at Akebonobashi.', ]); @@ -235,7 +235,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線は、お乗り換えです。', 'The next station is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -247,7 +247,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線は、お乗り換えです。', 'The next station is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -262,7 +262,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。この電車は、各駅停車、本八幡ゆきです。', 'This is the Local train bound for Motoyawata. The next station is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -274,7 +274,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ 'まもなく、新宿三丁目新宿三丁目東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'We will soon be arriving at Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -289,7 +289,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - expect(result.current).toEqual([ + expect(result.current.text).toEqual([ '次は、新宿三丁目です。東京メトロ丸ノ内線東京メトロ副都心線はお乗り換えです。', 'The next stop is Shinjuku-sanchome S 2. Please change here for the Tokyo Metro Marunouchi Line, and the Tokyo Metro Fukutoshin Line.', ]); @@ -301,7 +301,7 @@ describe('Without trainType & With numbering', () => { wrapper: wrapper, } ); - const [jaSSML, enSSML] = result.current; + const [jaSSML, enSSML] = result.current.text; // 英語SSMLがundefinedの場合は空文字列にする const en = typeof enSSML === 'string' ? enSSML : ''; diff --git a/src/hooks/useTTSText.ts b/src/hooks/useTTSText.ts index 7b61056cd1..f536d65789 100644 --- a/src/hooks/useTTSText.ts +++ b/src/hooks/useTTSText.ts @@ -7,7 +7,6 @@ import stationState from '../store/atoms/station'; import { themeAtom } from '../store/atoms/theme'; import getIsPass from '../utils/isPass'; import katakanaToHiragana from '../utils/kanaToHiragana'; -import { wrapIpa } from '../utils/ssml'; import { useAfterNextStation } from './useAfterNextStation'; import { useBounds } from './useBounds'; import { useConnectedLines } from './useConnectedLines'; @@ -23,6 +22,18 @@ import { useStationNumberIndexFunc } from './useStationNumberIndexFunc'; import { useStoppingState } from './useStoppingState'; import { useTransferLines } from './useTransferLines'; +export interface TTSTextResult { + text: [string, string] | []; + nextText: [string, string] | []; +} + +const resolveTemplateTheme = (theme: AppTheme): AppTheme => { + if (theme === APP_THEME.LED) return APP_THEME.TOKYO_METRO; + if (theme === APP_THEME.JO || theme === APP_THEME.JL) + return APP_THEME.YAMANOTE; + return theme; +}; + const EMPTY_TTS_TEXT = { [APP_THEME.TOKYO_METRO]: { NEXT: '', ARRIVING: '' }, [APP_THEME.TY]: { NEXT: '', ARRIVING: '' }, @@ -39,7 +50,7 @@ const EMPTY_TTS_TEXT = { export const useTTSText = ( firstSpeech = true, enabled = false -): [string, string] | [] => { +): TTSTextResult => { const theme = useAtomValue(themeAtom); const { @@ -53,22 +64,8 @@ export const useTTSText = ( const connectedLinesOrigin = useConnectedLines(); const transferLinesOrigin = useTransferLines(); - const connectedLines = useMemo( - () => - connectedLinesOrigin.map((l) => ({ - ...l, - nameRoman: wrapIpa(l.nameRoman, l.nameIpa), - })), - [connectedLinesOrigin] - ); - const transferLines = useMemo( - () => - transferLinesOrigin.map((l) => ({ - ...l, - nameRoman: wrapIpa(l.nameRoman, l.nameIpa), - })), - [transferLinesOrigin] - ); + const connectedLines = connectedLinesOrigin; + const transferLines = transferLinesOrigin; const currentTrainTypeOrigin = useCurrentTrainType(); const loopLineBoundJa = useLoopLineBound(false, 'JA'); const loopLineBoundEn = useLoopLineBound(false, 'EN'); @@ -114,19 +111,7 @@ export const useTTSText = ( [] ); - const currentLine = useMemo( - () => - currentLineOrigin - ? { - ...currentLineOrigin, - nameRoman: wrapIpa( - currentLineOrigin.nameRoman, - currentLineOrigin.nameIpa - ), - } - : null, - [currentLineOrigin] - ); + const currentLine = currentLineOrigin ?? null; const selectedBound = useMemo( () => selectedBoundOrigin ?? null, @@ -182,7 +167,8 @@ export const useTTSText = ( () => isLoopLine ? (loopLineBoundEn?.boundFor?.replaceAll('&', ' and ') ?? '') - : `${directionalStops?.map((s) => wrapIpa(s?.nameRoman, s?.nameIpa)).join(' and ')}`, + : (directionalStops?.map((s) => s?.nameRoman ?? '').join(' and ') ?? + ''), [directionalStops, isLoopLine, loopLineBoundEn?.boundFor] ); @@ -219,17 +205,7 @@ export const useTTSText = ( }${symbol} ${num}.`; }, [nextStationNumber, theme]); - const nextStation = useMemo( - () => - nextStationOrigin && { - ...nextStationOrigin, - nameRoman: wrapIpa( - nextStationOrigin.nameRoman, - nextStationOrigin.nameIpa - ), - }, - [nextStationOrigin] - ); + const nextStation = nextStationOrigin ?? null; // 直通時、同じGroupIDの駅が違う駅として扱われるのを防ぐ(ex. 渋谷の次は渋谷に止まります) const slicedStations = Array.from( @@ -239,29 +215,7 @@ export const useTTSText = ( .filter((s) => !!s) as Station[]; const afterNextStationOrigin = useAfterNextStation(); - const afterNextStation = useMemo(() => { - if (!afterNextStationOrigin) { - return undefined; - } - - return { - ...afterNextStationOrigin, - nameRoman: wrapIpa( - afterNextStationOrigin?.nameRoman, - afterNextStationOrigin?.nameIpa - ), - lines: - afterNextStationOrigin.lines?.map( - (l: { - nameRoman: string | null | undefined; - nameIpa: string | null | undefined; - }) => ({ - ...l, - nameRoman: wrapIpa(l.nameRoman, l.nameIpa), - }) - ) ?? [], - } as Station; - }, [afterNextStationOrigin]); + const afterNextStation = afterNextStationOrigin; const nextStationIndex = useMemo( () => slicedStations.findIndex((s) => s.groupId === nextStation?.groupId), @@ -287,17 +241,12 @@ export const useTTSText = ( const allStops = useMemo( () => - slicedStations - .filter((s) => { - if (s.id === station?.id) { - return false; - } - return !getIsPass(s); - }) - .map((s) => ({ - ...s, - nameRoman: wrapIpa(s.nameRoman, s.nameIpa), - })), + slicedStations.filter((s) => { + if (s.groupId === station?.groupId) { + return false; + } + return !getIsPass(s); + }), [slicedStations, station] ); @@ -1184,58 +1133,40 @@ export const useTTSText = ( yamanoteTrainTypeEn, ]); - const jaText = useMemo(() => { - if (theme === APP_THEME.LED) { - const tmpl = japaneseTemplate?.TOKYO_METRO?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const resolved = resolveTemplateTheme(theme); - if (theme === APP_THEME.JO || theme === APP_THEME.JL) { - const tmpl = japaneseTemplate?.YAMANOTE?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } - - const tmpl = japaneseTemplate?.[theme]?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - }, [japaneseTemplate, stoppingState, theme]); - - const enText = useMemo(() => { - if (theme === APP_THEME.LED) { - const tmpl = englishTemplate?.TOKYO_METRO?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const jaText = useMemo( + () => japaneseTemplate?.[resolved]?.[stoppingState] ?? '', + [japaneseTemplate, resolved, stoppingState] + ); - if (theme === APP_THEME.JO || theme === APP_THEME.JL) { - const tmpl = englishTemplate?.YAMANOTE?.[stoppingState]; - if (!tmpl) { - return ''; - } - return tmpl; - } + const enText = useMemo( + () => englishTemplate?.[resolved]?.[stoppingState] ?? '', + [englishTemplate, resolved, stoppingState] + ); - const tmpl = englishTemplate?.[theme]?.[stoppingState]; - if (!tmpl) { - return ''; - } + const nextJaText = useMemo( + () => japaneseTemplate?.[resolved]?.NEXT ?? '', + [japaneseTemplate, resolved] + ); - return tmpl; - }, [englishTemplate, stoppingState, theme]); + const nextEnText = useMemo( + () => englishTemplate?.[resolved]?.NEXT ?? '', + [englishTemplate, resolved] + ); if (!enabled) { - return []; + return { text: [], nextText: [] }; } - return [jaText.trim(), enText.trim()]; + return { + text: [ + jaText.trim().replace(parenthesisRegexp, ''), + enText.trim().replace(parenthesisRegexp, ''), + ], + nextText: [ + nextJaText.trim().replace(parenthesisRegexp, ''), + nextEnText.trim().replace(parenthesisRegexp, ''), + ], + }; }; diff --git a/src/hooks/useTransferLinesFromStation.test.tsx b/src/hooks/useTransferLinesFromStation.test.tsx index 052d5914ca..a80bf6b5b4 100644 --- a/src/hooks/useTransferLinesFromStation.test.tsx +++ b/src/hooks/useTransferLinesFromStation.test.tsx @@ -37,7 +37,6 @@ const createLineNested = (overrides: Partial = {}): LineNested => ({ lineType: undefined, nameChinese: null, nameFull: 'Line', - nameIpa: null, nameKatakana: 'ライン', nameKorean: '라인', nameRoman: 'Line', diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index 3f53b60062..8f8dbbad6b 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -69,7 +69,6 @@ export const LINE_NESTED_FRAGMENT = gql` id groupId name - nameIpa nameRoman nameChinese nameKorean @@ -82,7 +81,6 @@ export const LINE_NESTED_FRAGMENT = gql` ...TinyTrainTypeFields } lineType - nameIpa nameFull nameKatakana nameRoman @@ -125,7 +123,6 @@ export const STATION_FRAGMENT = gql` id groupId name - nameIpa nameKatakana nameRoman nameChinese @@ -186,7 +183,6 @@ export const STATION_PRESET_FRAGMENT = gql` id groupId name - nameIpa nameRoman stationNumbers { ...StationNumberFields @@ -247,7 +243,6 @@ export const STATION_LIGHT_FRAGMENT = gql` id groupId name - nameIpa nameRoman nameChinese nameKorean @@ -313,7 +308,6 @@ export const LINE_ROUTE_FRAGMENT = gql` id color lineType - nameIpa nameShort nameRoman lineSymbols { @@ -323,7 +317,6 @@ export const LINE_ROUTE_FRAGMENT = gql` id groupId name - nameIpa nameRoman nameChinese nameKorean diff --git a/src/store/atoms/tuning.ts b/src/store/atoms/tuning.ts index c3637a94fc..a2b28d8544 100644 --- a/src/store/atoms/tuning.ts +++ b/src/store/atoms/tuning.ts @@ -12,6 +12,8 @@ export type TuningState = { devOverlayEnabled: boolean; untouchableModeEnabled: boolean; telemetryEnabled: boolean; + ttsJaVoiceName: string; + ttsEnVoiceName: string; }; const tuningState = atom({ @@ -21,6 +23,8 @@ const tuningState = atom({ devOverlayEnabled: true, untouchableModeEnabled: false, telemetryEnabled: false, + ttsJaVoiceName: '', + ttsEnVoiceName: '', }); export default tuningState; diff --git a/src/utils/__fixtures__/search.ts b/src/utils/__fixtures__/search.ts index 686fa1fc3d..0f378b5090 100644 --- a/src/utils/__fixtures__/search.ts +++ b/src/utils/__fixtures__/search.ts @@ -19,7 +19,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ id: 1160303, groupId: 1160303, name: '尼崎', - nameIpa: undefined, nameKatakana: 'アマガサキ', nameRoman: 'Amagasaki', nameChinese: '尼崎', @@ -30,7 +29,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11603, nameShort: 'JR神戸線', - nameIpa: undefined, nameKatakana: 'コウベセン', nameFull: '東海道本線', nameRoman: 'JR Kobe Line', @@ -80,7 +78,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ id: 3500109, groupId: 3500109, name: '尼崎', - nameIpa: undefined, nameKatakana: 'アマガサキ', nameRoman: 'Amagasaki', nameChinese: '尼崎', @@ -91,7 +88,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 35001, nameShort: '阪神本線', - nameIpa: undefined, nameKatakana: 'ハンシンホンセン', nameFull: '阪神本線', nameRoman: 'Hanshin Main Line', @@ -141,7 +137,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ id: 3500111, groupId: 3500111, name: '尼崎センタープール前', - nameIpa: undefined, nameKatakana: 'アマガサキセンタープールマエ', nameRoman: 'Amagasaki-Centerpool-Mae', nameChinese: '尼崎汽艇比赛场前', @@ -152,7 +147,6 @@ export const AMAGASAKI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 35001, nameShort: '阪神本線', - nameIpa: undefined, nameKatakana: 'ハンシンホンセン', nameFull: '阪神本線', nameRoman: 'Hanshin Main Line', @@ -205,7 +199,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 1110617, groupId: 1110617, name: '落合', - nameIpa: undefined, nameKatakana: 'オチアイ', nameRoman: 'Ochiai', nameChinese: '落合', @@ -216,7 +209,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11106, nameShort: '根室本線', - nameIpa: undefined, nameKatakana: 'ネムロホンセン', nameFull: '根室本線', nameRoman: 'Nemuro Main Line', @@ -266,7 +258,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 1120301, groupId: 1120301, name: '陸前落合', - nameIpa: undefined, nameKatakana: 'リクゼンオチアイ', nameRoman: 'Rikuzen-Ochiai', nameChinese: '陸前落合', @@ -277,7 +268,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11203, nameShort: '仙石線', - nameIpa: undefined, nameKatakana: 'センセキセン', nameFull: '仙石線', nameRoman: 'Senseki Line', @@ -327,7 +317,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 9917710, groupId: 9917710, name: '落合川', - nameIpa: undefined, nameKatakana: 'オチアイガワ', nameRoman: 'Ochiaigawa', nameChinese: '落合川', @@ -338,7 +327,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 99177, nameShort: '多摩都市モノレール線', - nameIpa: undefined, nameKatakana: 'タマトシモノレールセン', nameFull: '多摩都市モノレール線', nameRoman: 'Tama Toshi Monorail Line', @@ -388,7 +376,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 1132606, groupId: 1132606, name: '美作落合', - nameIpa: undefined, nameKatakana: 'ミマサカオチアイ', nameRoman: 'Mimasaka-Ochiai', nameChinese: '美作落合', @@ -399,7 +386,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11326, nameShort: '姫新線', - nameIpa: undefined, nameKatakana: 'キシンセン', nameFull: '姫新線', nameRoman: 'Kishin Line', @@ -449,7 +435,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 1132607, groupId: 1132607, name: '備後落合', - nameIpa: undefined, nameKatakana: 'ビンゴオチアイ', nameRoman: 'Bingo-Ochiai', nameChinese: '備後落合', @@ -460,7 +445,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11326, nameShort: '芸備線', - nameIpa: undefined, nameKatakana: 'ゲイビセン', nameFull: '芸備線', nameRoman: 'Geibi Line', @@ -510,7 +494,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 1161210, groupId: 1161210, name: '下落合', - nameIpa: undefined, nameKatakana: 'シモオチアイ', nameRoman: 'Shimo-Ochiai', nameChinese: '下落合', @@ -521,7 +504,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 11612, nameShort: '西武新宿線', - nameIpa: undefined, nameKatakana: 'セイブシンジュクセン', nameFull: '西武新宿線', nameRoman: 'Seibu Shinjuku Line', @@ -571,7 +553,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 2800312, groupId: 2800312, name: '落合', - nameIpa: undefined, nameKatakana: 'オチアイ', nameRoman: 'Ochiai', nameChinese: '落合', @@ -582,7 +563,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 28003, nameShort: '東京メトロ東西線', - nameIpa: undefined, nameKatakana: 'トウザイセン', nameFull: '東京メトロ東西線', nameRoman: 'Tokyo Metro Tozai Line', @@ -632,7 +612,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ id: 2800512, groupId: 2800512, name: '落合南長崎', - nameIpa: undefined, nameKatakana: 'オチアイミナミナガサキ', nameRoman: 'Ochiai-minami-nagasaki', nameChinese: '落合南長崎', @@ -643,7 +622,6 @@ export const OCHIAI_STATIONS_FROM_SEARCH: Station[] = [ __typename: 'LineNested', id: 28005, nameShort: '都営大江戸線', - nameIpa: undefined, nameKatakana: 'オオエドセン', nameFull: '都営地下鉄大江戸線', nameRoman: 'Toei Oedo Line', diff --git a/src/utils/jr.ts b/src/utils/jr.ts index 50a63f2b1a..1cea80b5b6 100644 --- a/src/utils/jr.ts +++ b/src/utils/jr.ts @@ -47,7 +47,6 @@ const omitJRLinesIfThresholdExceeded = (lines: Line[]): Line[] => { : '#000000', nameShort: 'JR線', nameRoman: 'JR Lines', - nameIpa: undefined, nameKatakana: 'JRセン', lineType: LineType.Normal, nameChinese: 'JR线', @@ -92,7 +91,6 @@ const omitJRLinesIfThresholdExceeded = (lines: Line[]): Line[] => { : '#000000', nameShort: '新幹線', nameRoman: 'Shinkansen', - nameIpa: undefined, nameKatakana: 'シンカンセン', lineType: LineType.Normal, nameChinese: '新干线', diff --git a/src/utils/ssml.ts b/src/utils/ssml.ts index 58a85ed3d0..cb3fc7cc46 100644 --- a/src/utils/ssml.ts +++ b/src/utils/ssml.ts @@ -1,29 +1,5 @@ type SSMLElementType = 'sub' | 'phoneme' | 'say-as'; -const escapeXml = (s: string): string => - s - .replace(/&(?!amp;|lt;|gt;|quot;|apos;)/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - -/** - * nameIpaが存在する場合はタグでラップし、なければnameRomanをそのまま返す - */ -export const wrapIpa = ( - nameRoman: string | null | undefined, - nameIpa: string | null | undefined -): string => { - if (!nameRoman) { - return ''; - } - if (!nameIpa) { - return nameRoman; - } - return `${escapeXml(nameRoman)}`; -}; - export class SSMLBuilder { private addSpaceByDefault = true; private elms: string[] = []; diff --git a/src/utils/test/factories.ts b/src/utils/test/factories.ts index 360b469114..0e8f83c058 100644 --- a/src/utils/test/factories.ts +++ b/src/utils/test/factories.ts @@ -43,7 +43,6 @@ export const createStation = ( lineType: LineType.Normal, nameChinese: null, nameFull: 'Test Line', - nameIpa: null, nameKatakana: 'テストライン', nameKorean: null, nameRoman: 'Test Line', @@ -57,7 +56,6 @@ export const createStation = ( lines: [], name: `Station${id}`, nameChinese: null, - nameIpa: null, nameKatakana: `ステーション${id}`, nameKorean: null, nameRoman: `Station${id}`, @@ -87,7 +85,6 @@ export const createLine = ( lineType: LineType.Normal, nameChinese: null, nameFull: 'Test Line', - nameIpa: null, nameKatakana: 'テストライン', nameKorean: null, nameRoman: 'Test Line', diff --git a/src/utils/ttsSpeechFetcher.test.ts b/src/utils/ttsSpeechFetcher.test.ts index d4cd225d11..7d9dbcb733 100644 --- a/src/utils/ttsSpeechFetcher.test.ts +++ b/src/utils/ttsSpeechFetcher.test.ts @@ -1,5 +1,5 @@ import { mockFetch } from '~/utils/test/ttsMocks'; -import { fetchSpeechAudio } from './ttsSpeechFetcher'; +import { clearFetchCache, fetchSpeechAudio } from './ttsSpeechFetcher'; const defaultOptions = { textJa: 'こんにちは', @@ -11,6 +11,7 @@ const defaultOptions = { describe('fetchSpeechAudio', () => { beforeEach(() => { jest.clearAllMocks(); + clearFetchCache(); }); afterEach(() => { @@ -32,8 +33,8 @@ describe('fetchSpeechAudio', () => { expect(result).toEqual({ id: 'tts-123', - pathJa: '/tmp/tts-123_ja.mp3', - pathEn: '/tmp/tts-123_en.mp3', + pathJa: '/tmp/tts-123_ja.wav', + pathEn: '/tmp/tts-123_en.wav', }); expect(mockFetch).toHaveBeenCalledWith( @@ -125,4 +126,71 @@ describe('fetchSpeechAudio', () => { expect(body.data.ssmlJa).toBe('テスト'); expect(body.data.ssmlEn).toBe('test'); }); + + it('PCM MIME の場合は WAV として保存する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-124', + jaAudioContent: 'AAECAw==', + enAudioContent: 'AAECAw==', + jaAudioMimeType: 'audio/pcm;rate=24000', + enAudioMimeType: 'audio/L16;rate=24000', + }, + }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + + expect(result).toEqual({ + id: 'tts-124', + pathJa: '/tmp/tts-124_ja.wav', + pathEn: '/tmp/tts-124_en.wav', + }); + }); + + it('MP3 MIME の場合は MP3 として保存する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-125', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType: 'audio/mp3', + }, + }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + + expect(result).toEqual({ + id: 'tts-125', + pathJa: '/tmp/tts-125_ja.mp3', + pathEn: '/tmp/tts-125_en.mp3', + }); + }); + + it('MIME 不明の場合は WAV として保存する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-126', + jaAudioContent: 'AAECAw==', + enAudioContent: 'AAECAw==', + }, + }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + + expect(result).toEqual({ + id: 'tts-126', + pathJa: '/tmp/tts-126_ja.wav', + pathEn: '/tmp/tts-126_en.wav', + }); + }); }); diff --git a/src/utils/ttsSpeechFetcher.ts b/src/utils/ttsSpeechFetcher.ts index 3cf43405ad..645a7cb4c0 100644 --- a/src/utils/ttsSpeechFetcher.ts +++ b/src/utils/ttsSpeechFetcher.ts @@ -7,21 +7,122 @@ export interface FetchSpeechOptions { textEn: string; apiUrl: string; idToken: string; + jaVoiceName?: string; + enVoiceName?: string; } +const getSampleRateFromMimeType = (mimeType: string): number => { + const rate = mimeType.match(/rate=(\d+)/i)?.[1]; + const parsed = rate ? Number.parseInt(rate, 10) : Number.NaN; + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return 24000; +}; + +const wrapPcm16LeToWav = ( + pcmData: Uint8Array, + sampleRate: number +): Uint8Array => { + const channels = 1; + const bitsPerSample = 16; + const blockAlign = (channels * bitsPerSample) / 8; + const byteRate = sampleRate * blockAlign; + const dataSize = pcmData.length; + const fileSize = 36 + dataSize; + const out = new Uint8Array(44 + dataSize); + const view = new DataView(out.buffer); + + // RIFF header + out.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + view.setUint32(4, fileSize, true); + out.set([0x57, 0x41, 0x56, 0x45], 8); // WAVE + + // fmt chunk + out.set([0x66, 0x6d, 0x74, 0x20], 12); // fmt + view.setUint32(16, 16, true); // PCM chunk size + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitsPerSample, true); + + // data chunk + out.set([0x64, 0x61, 0x74, 0x61], 36); // data + view.setUint32(40, dataSize, true); + out.set(pcmData, 44); + + return out; +}; + +const normalizeAudioForFile = ( + base64Audio: string, + mimeType?: string +): { bytes: Uint8Array; ext: 'mp3' | 'wav' } => { + const normalizedMime = mimeType?.toLowerCase() ?? ''; + const bytes = base64ToUint8Array(base64Audio); + + if (normalizedMime.includes('mpeg') || normalizedMime.includes('mp3')) { + return { bytes, ext: 'mp3' }; + } + + if (normalizedMime.includes('wav')) { + return { bytes, ext: 'wav' }; + } + + if ( + normalizedMime.includes('pcm') || + normalizedMime.includes('l16') || + normalizedMime.includes('linear16') + ) { + const sampleRate = getSampleRateFromMimeType(normalizedMime); + return { + bytes: wrapPcm16LeToWav(bytes, sampleRate), + ext: 'wav', + }; + } + + // MIME不明時はPCM/L16を想定してWAV化する + return { + bytes: wrapPcm16LeToWav(bytes, 24000), + ext: 'wav', + }; +}; + +const fetchCache = new Map< + string, + { id: string; pathJa: string; pathEn: string } +>(); + +const buildCacheKey = (opts: FetchSpeechOptions): string => + `${opts.textJa}\0${opts.textEn}\0${opts.enVoiceName ?? ''}`; + +export const clearFetchCache = (): void => { + fetchCache.clear(); +}; + export const fetchSpeechAudio = async ( options: FetchSpeechOptions ): Promise<{ id: string; pathJa: string; pathEn: string } | null> => { - const { textJa, textEn, apiUrl, idToken } = options; + const { textJa, textEn, apiUrl, idToken, jaVoiceName, enVoiceName } = options; if (!textJa.length || !textEn.length) { return null; } + const cacheKey = buildCacheKey(options); + const cached = fetchCache.get(cacheKey); + if (cached) { + return cached; + } + const reqBody = { data: { ssmlJa: `${textJa.trim()}`, ssmlEn: `${textEn.trim()}`, + ...(jaVoiceName ? { jaVoiceName } : {}), + ...(enVoiceName ? { enVoiceName } : {}), }, }; @@ -51,7 +152,13 @@ export const fetchSpeechAudio = async ( return null; } - const { jaAudioContent, enAudioContent, id } = ttsJson.result; + const { + jaAudioContent, + enAudioContent, + id, + jaAudioMimeType, + enAudioMimeType, + } = ttsJson.result; if (!jaAudioContent || !enAudioContent) { console.warn( @@ -60,17 +167,18 @@ export const fetchSpeechAudio = async ( return null; } - const fileJa = new File(Paths.cache, `${id}_ja.mp3`); - const fileEn = new File(Paths.cache, `${id}_en.mp3`); + const normalizedJa = normalizeAudioForFile(jaAudioContent, jaAudioMimeType); + const normalizedEn = normalizeAudioForFile(enAudioContent, enAudioMimeType); - fileJa.write(base64ToUint8Array(jaAudioContent)); - fileEn.write(base64ToUint8Array(enAudioContent)); + const fileJa = new File(Paths.cache, `${id}_ja.${normalizedJa.ext}`); + const fileEn = new File(Paths.cache, `${id}_en.${normalizedEn.ext}`); - return { - id, - pathJa: fileJa.uri, - pathEn: fileEn.uri, - }; + fileJa.write(normalizedJa.bytes); + fileEn.write(normalizedEn.bytes); + + const result = { id, pathJa: fileJa.uri, pathEn: fileEn.uri }; + fetchCache.set(cacheKey, result); + return result; } catch (error) { console.error('[ttsSpeechFetcher] fetchSpeech error:', error); return null; From 3368484f10b3245295fd2f8cd937c1825cb7129b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:41:56 +0900 Subject: [PATCH 2/2] Bump version for canary release (#5535) Co-authored-by: TinyKitten <32848922+TinyKitten@users.noreply.github.com> --- android/app/build.gradle | 4 +-- app.config.ts | 5 ++-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 77650092cd..92534cf47c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -141,12 +141,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000304 + versionCode 100000305 versionName "10.2.1" } prod { dimension "environment" - versionCode 100000304 + versionCode 100000305 versionName "10.2.1" } } diff --git a/app.config.ts b/app.config.ts index 18753b5ccb..a0aafa6b63 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default ({ config }: ConfigContext) => ({ }, }, ios: { - buildNumber: '2517', + buildNumber: '2518', bundleIdentifier: process.env.EAS_BUILD_PROFILE === 'production' ? 'me.tinykitten.trainlcd' @@ -69,7 +69,7 @@ export default ({ config }: ConfigContext) => ({ ? 'me.tinykitten.trainlcd' : 'me.tinykitten.trainlcd.dev', permissions: [], - versionCode: 100000304, + versionCode: 100000305, }, owner: 'trainlcd', }); @@ -81,3 +81,4 @@ export default ({ config }: ConfigContext) => ({ + diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 3cf4731d00..b8ddbd35f6 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2419,7 +2419,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2458,7 +2458,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2517,7 +2517,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2702,7 +2702,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2741,7 +2741,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2952,7 +2952,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3003,7 +3003,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3054,7 +3054,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3112,7 +3112,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3163,7 +3163,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3220,7 +3220,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3319,7 +3319,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3538,7 +3538,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3594,7 +3594,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3702,7 +3702,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2517; + CURRENT_PROJECT_VERSION = 2518; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO;