From c718d32e2d3f9b8961d8935cd536f4882865969c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:46:02 +0900 Subject: [PATCH 01/62] Bump version for canary release (#6867) Co-authored-by: TinyKitten <32848922+TinyKitten@users.noreply.github.com> --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 4551a6828..0e8e81af5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000711 + versionCode 100000713 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000711 + versionCode 100000713 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index da4552ebb..2197f8fbd 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000712 + versionCode = 100000714 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000712 + versionCode = 100000714 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 4102f631c..aaff33b0c 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2885', + buildNumber: '2886', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000711, + versionCode: 100000713, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 1f60c04b2..c73ec334d 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2885; + CURRENT_PROJECT_VERSION = 2886; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 4a4d6e9b31899b68917674b36ad2dfb04cfb1dfc Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 6 Sep 2026 23:36:02 +0900 Subject: [PATCH 02/62] =?UTF-8?q?=E8=8B=B1=E8=AA=9ETTS=E3=81=A7=E3=80=8CKe?= =?UTF-8?q?isei=E3=80=8D=E3=81=8C=E3=80=8C=E3=81=8B=E3=81=84=E3=81=9B?= =?UTF-8?q?=E3=81=84=E3=80=8D=E3=81=A8=E8=AA=AD=E3=81=BE=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E8=AA=A4=E8=AA=AD=E3=82=92=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92?= =?UTF-8?q?=E7=9B=B4=E5=89=8D=E3=81=AE=E8=A1=A8=E8=A8=98=E7=BD=AE=E6=8F=9B?= =?UTF-8?q?=E3=81=A7=E4=BF=AE=E6=AD=A3=20(#6869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/englishReading.test.ts | 37 ++++++++++++++++++++++++++++++++ src/utils/englishReading.ts | 29 +++++++++++++++++++++++++ src/utils/speakableText.test.ts | 13 +++++++++++ src/utils/speakableText.ts | 13 +++++++---- 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/utils/englishReading.test.ts create mode 100644 src/utils/englishReading.ts diff --git a/src/utils/englishReading.test.ts b/src/utils/englishReading.test.ts new file mode 100644 index 000000000..043a59cea --- /dev/null +++ b/src/utils/englishReading.test.ts @@ -0,0 +1,37 @@ +import { fixEnglishReading } from './englishReading'; + +describe('fixEnglishReading', () => { + it('路線名の「Keisei」を英語 TTS が「けいせい」と読む表記へ置換する', () => { + expect(fixEnglishReading('Change here for the Keisei Main Line.')).toBe( + 'Change here for the Kay-say Main Line.' + ); + }); + + it('ハイフン連結の駅名でも語単位で置換する', () => { + expect(fixEnglishReading('The next station is Keisei-Ueno.')).toBe( + 'The next station is Kay-say-Ueno.' + ); + }); + + it('会社名など 1 文中の複数箇所をすべて置換する', () => { + expect( + fixEnglishReading('Keisei Electric Railway operates the Keisei Line.') + ).toBe('Kay-say Electric Railway operates the Kay-say Line.'); + }); + + it('大文字小文字を問わず置換する', () => { + expect(fixEnglishReading('KEISEI SKYLINER and keisei bus')).toBe( + 'Kay-say SKYLINER and Kay-say bus' + ); + }); + + it('別語の一部は置換しない', () => { + expect(fixEnglishReading('Keiseibus')).toBe('Keiseibus'); + }); + + it('対象を含まないテキストはそのまま返す', () => { + expect(fixEnglishReading('The next station is Osaki.')).toBe( + 'The next station is Osaki.' + ); + }); +}); diff --git a/src/utils/englishReading.ts b/src/utils/englishReading.ts new file mode 100644 index 000000000..8c2209c82 --- /dev/null +++ b/src/utils/englishReading.ts @@ -0,0 +1,29 @@ +// 英語音声の TTS エンジンが日本語由来の固有名詞を誤読するケースを、読み上げ +// 直前のプレーンテキストに対する表記置換で補正する。旧外部 TTS 向けの +// `` (IPA) は OS ネイティブ TTS (expo-speech) もリモート TTS も解釈 +// しないため、エンジンが確実に読める英単語の綴りへ倒すしかない。 +// 表示用テキストには適用しないこと (TTS 生成時専用)。 + +type EnglishReadingRule = { + // 置換対象。ASCII 英字の単語境界 (`\b`) で囲み、`Keisei-Ueno` のような + // ハイフン連結の駅名でも語単位で一致させる。 + pattern: RegExp; + reading: string; +}; + +// NOTE: 読み替え先は必ず英語の辞書語 (か、辞書語のハイフン連結) にする。 +// 未知語の綴りを与えると G2P の推定に戻ってしまい、エンジンごとに結果がぶれる。 +const ENGLISH_READING_RULES: readonly EnglishReadingRule[] = [ + // 「Keisei (京成)」は英語 TTS が "ei" を /aɪ/ と推定して「かいせい」と読む + // ため、英単語 "Kay" + "say" で /keɪ.seɪ/ (けいせい) を確定させる。 + { pattern: /\bKeisei\b/gi, reading: 'Kay-say' }, +]; + +/** + * 英語の読み上げ用テキスト内の固有名詞を、TTS エンジンが正しく読める表記へ置換する。 + */ +export const fixEnglishReading = (text: string): string => + ENGLISH_READING_RULES.reduce( + (acc, { pattern, reading }) => acc.replace(pattern, reading), + text + ); diff --git a/src/utils/speakableText.test.ts b/src/utils/speakableText.test.ts index 6f28455b4..8a3304d74 100644 --- a/src/utils/speakableText.test.ts +++ b/src/utils/speakableText.test.ts @@ -38,6 +38,19 @@ describe('toSpeakableText', () => { expect(toSpeakableText('the JR Kobe Line', 'EN')).toBe('the J-R Kobe Line'); }); + it('英語文の「Keisei」を英語 TTS が「けいせい」と読む表記へ置換する', () => { + expect( + toSpeakableText( + 'The next station is Keisei-Ueno, KS 1.', + 'EN' + ) + ).toBe('The next station is Kay-say-Ueno, KS 1.'); + // 日本語文はカタカナ読み (sub alias) がそのまま使われるので対象外 + expect( + toSpeakableText('京成上野', 'JA') + ).toBe('ケイセイウエノ'); + }); + it('英語文に混入した日本語を除去する', () => { // nameRoman 欠落データ等で英語文に日本語が残ると、エンジンが言語を誤判定して // 全文を日本語音声で読んでしまうため最終防衛線として除去する diff --git a/src/utils/speakableText.ts b/src/utils/speakableText.ts index 0e0406522..919cd86e3 100644 --- a/src/utils/speakableText.ts +++ b/src/utils/speakableText.ts @@ -1,3 +1,4 @@ +import { fixEnglishReading } from './englishReading'; import { fixJrReading } from './jrReading'; import { containsJapaneseCharacters, stripJapaneseCharacters } from './phoneme'; import { ssmlToPlainText } from './ssmlToPlainText'; @@ -15,6 +16,8 @@ import getStringBytes from './stringBytes'; * 含まれるため半角スペースへ置き換える。 * - 「JR」は TTS エンジンが "Jr."(ジュニア)と誤読するため、読み方が確定する * 表記へ置換する。 + * - 英語文では「Keisei」など英語 TTS が誤読する固有名詞も、読み方が確定する + * 英単語の綴りへ置換する。 * - 英語文に日本語が残っている場合は除去する。nameRoman が欠落した駅データ等では * wrapPhoneme のローマ字フォールバックが効かず英語文に日本語が混ざることがあり、 * その場合エンジンが言語を誤判定して全文を日本語音声で合成してしまうため。 @@ -30,15 +33,17 @@ export const toSpeakableText = ( language ); - if (language === 'EN' && containsJapaneseCharacters(plain)) { + if (language === 'JA') { + return plain; + } + + if (containsJapaneseCharacters(plain)) { console.warn( '[speakableText] English text contains Japanese characters, stripping:', plain ); - return stripJapaneseCharacters(plain); } - - return plain; + return fixEnglishReading(stripJapaneseCharacters(plain)); }; /** From 30f927ced132ac05ce02883f8f2ad0794ba9e955 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:39:41 +0900 Subject: [PATCH 03/62] Bump version for canary release (#6871) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0e8e81af5..967977e9c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000713 + versionCode 100000715 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000713 + versionCode 100000715 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 2197f8fbd..24cecc8e1 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000714 + versionCode = 100000716 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000714 + versionCode = 100000716 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index aaff33b0c..49ef0962e 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2886', + buildNumber: '2887', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000713, + versionCode: 100000715, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index c73ec334d..d07bbfedd 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2886; + CURRENT_PROJECT_VERSION = 2887; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From f98c2841d360c623ef9bf61104e0ebff9cdb7d66 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 00:05:49 +0900 Subject: [PATCH 04/62] =?UTF-8?q?=E3=83=AA=E3=83=A2=E3=83=BC=E3=83=88TTS?= =?UTF-8?q?=E3=81=B8=E3=81=AF=E3=80=8CKeisei=E3=80=8D=E3=81=AE=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E6=9B=BF=E3=81=88=E3=82=92=E9=81=A9=E7=94=A8=E3=81=9B?= =?UTF-8?q?=E3=81=9A=E5=8E=9F=E6=96=87=E3=81=AE=E3=81=BE=E3=81=BE=E9=80=81?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E7=AB=AF=E6=9C=AB=E5=86=85=E8=94=B5?= =?UTF-8?q?TTS=E9=99=90=E5=AE=9A=E3=81=AB=E5=A4=89=E6=9B=B4=20(#6872)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WS1LSBnYTMfm7CyrzqKxuW Co-authored-by: Claude --- src/hooks/tts/useNativeSpeechEngine.ts | 4 +- src/hooks/tts/useRemoteSpeechEngine.ts | 4 +- src/utils/speakableText.test.ts | 54 +++++++++++++++++++------- src/utils/speakableText.ts | 16 ++++++-- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/hooks/tts/useNativeSpeechEngine.ts b/src/hooks/tts/useNativeSpeechEngine.ts index d7c9b04ea..0625f8f09 100644 --- a/src/hooks/tts/useNativeSpeechEngine.ts +++ b/src/hooks/tts/useNativeSpeechEngine.ts @@ -159,14 +159,14 @@ export const useNativeSpeechEngine = (): SpeechEngine => { const plainJa = request.speakJa && canSpeakJa ? truncateToSpeechLimit( - toSpeakableText(request.ssmlJa, 'JA'), + toSpeakableText(request.ssmlJa, 'JA', 'native'), limit ) : ''; const plainEn = request.speakEn && canSpeakEn ? truncateToSpeechLimit( - toSpeakableText(request.ssmlEn, 'EN'), + toSpeakableText(request.ssmlEn, 'EN', 'native'), limit ) : ''; diff --git a/src/hooks/tts/useRemoteSpeechEngine.ts b/src/hooks/tts/useRemoteSpeechEngine.ts index bea78cf0f..d3194849f 100644 --- a/src/hooks/tts/useRemoteSpeechEngine.ts +++ b/src/hooks/tts/useRemoteSpeechEngine.ts @@ -176,13 +176,13 @@ export const useRemoteSpeechEngine = (): SpeechEngine => { // 合成は文字数課金のため、無効な言語のテキストは送らない const textJa = request.speakJa ? truncateToByteLimit( - toSpeakableText(request.ssmlJa, 'JA'), + toSpeakableText(request.ssmlJa, 'JA', 'remote'), REMOTE_TTS_MAX_INPUT_BYTES ) : ''; const textEn = request.speakEn ? truncateToByteLimit( - toSpeakableText(request.ssmlEn, 'EN'), + toSpeakableText(request.ssmlEn, 'EN', 'remote'), REMOTE_TTS_MAX_INPUT_BYTES ) : ''; diff --git a/src/utils/speakableText.test.ts b/src/utils/speakableText.test.ts index 8a3304d74..9846a2d01 100644 --- a/src/utils/speakableText.test.ts +++ b/src/utils/speakableText.test.ts @@ -11,14 +11,18 @@ describe('toSpeakableText', () => { it('SSML タグを除去し、sub は読み仮名を採用する', () => { expect( - toSpeakableText('次は大崎です', 'JA') + toSpeakableText( + '次は大崎です', + 'JA', + 'native' + ) ).toBe('次はオオサキです'); }); it('日本語の break は読点へ置き換える', () => { - expect(toSpeakableText('次は大崎です', 'JA')).toBe( - '次は、大崎です' - ); + expect( + toSpeakableText('次は大崎です', 'JA', 'native') + ).toBe('次は、大崎です'); }); it('英語の break は半角スペースへ置き換える', () => { @@ -26,37 +30,61 @@ describe('toSpeakableText', () => { expect( toSpeakableText( 'The next station is Osaki, J Y 24.', - 'EN' + 'EN', + 'native' ) ).toBe('The next station is Osaki, J Y 24.'); }); it('「JR」を言語ごとの読み方が確定する表記へ置換する', () => { - expect(toSpeakableText('JR線', 'JA')).toBe( - 'ジェーアールセン' + expect( + toSpeakableText('JR線', 'JA', 'native') + ).toBe('ジェーアールセン'); + expect(toSpeakableText('the JR Kobe Line', 'EN', 'native')).toBe( + 'the J-R Kobe Line' ); - expect(toSpeakableText('the JR Kobe Line', 'EN')).toBe('the J-R Kobe Line'); }); - it('英語文の「Keisei」を英語 TTS が「けいせい」と読む表記へ置換する', () => { + it('端末内蔵 TTS 向けの英語文では「Keisei」を「けいせい」と読む表記へ置換する', () => { expect( toSpeakableText( 'The next station is Keisei-Ueno, KS 1.', - 'EN' + 'EN', + 'native' ) ).toBe('The next station is Kay-say-Ueno, KS 1.'); // 日本語文はカタカナ読み (sub alias) がそのまま使われるので対象外 expect( - toSpeakableText('京成上野', 'JA') + toSpeakableText( + '京成上野', + 'JA', + 'native' + ) ).toBe('ケイセイウエノ'); }); + it('リモート TTS 向けの英語文では「Keisei」を置換せず原文のまま送る', () => { + // 同じ置換を TrainLCD/functions の normalizeRomanText が合成前に行うため、 + // アプリ側では手を入れずサーバーの正規化とキャッシュキーを単一の入力に揃える + expect( + toSpeakableText( + 'The next station is Keisei-Ueno, KS 1.', + 'EN', + 'remote' + ) + ).toBe('The next station is Keisei-Ueno, KS 1.'); + // JR の置換はサーバー側が J-R を素通しする前提なので、リモートでも適用する + expect(toSpeakableText('the JR Kobe Line', 'EN', 'remote')).toBe( + 'the J-R Kobe Line' + ); + }); + it('英語文に混入した日本語を除去する', () => { // nameRoman 欠落データ等で英語文に日本語が残ると、エンジンが言語を誤判定して // 全文を日本語音声で読んでしまうため最終防衛線として除去する const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - expect(toSpeakableText('Arriving at あかさか K 7.', 'EN')).toBe( + expect(toSpeakableText('Arriving at あかさか K 7.', 'EN', 'native')).toBe( 'Arriving at K 7.' ); expect(warnSpy).toHaveBeenCalledWith( @@ -66,7 +94,7 @@ describe('toSpeakableText', () => { }); it('日本語文の日本語は当然除去しない', () => { - expect(toSpeakableText('つぎはあかさかです', 'JA')).toBe( + expect(toSpeakableText('つぎはあかさかです', 'JA', 'native')).toBe( 'つぎはあかさかです' ); }); diff --git a/src/utils/speakableText.ts b/src/utils/speakableText.ts index 919cd86e3..d3c6b340f 100644 --- a/src/utils/speakableText.ts +++ b/src/utils/speakableText.ts @@ -16,15 +16,22 @@ import getStringBytes from './stringBytes'; * 含まれるため半角スペースへ置き換える。 * - 「JR」は TTS エンジンが "Jr."(ジュニア)と誤読するため、読み方が確定する * 表記へ置換する。 - * - 英語文では「Keisei」など英語 TTS が誤読する固有名詞も、読み方が確定する - * 英単語の綴りへ置換する。 + * - 端末内蔵 TTS 向け (`engine: 'native'`) の英語文では、「Keisei」など英語 TTS が + * 誤読する固有名詞も読み方が確定する英単語の綴りへ置換する。リモート TTS + * (`engine: 'remote'`) では同じ置換を TrainLCD/functions の `normalizeRomanText` + * が合成前に行うため、アプリ側では適用せず原文の表記のまま送る。二重に置換 + * しても結果は変わらないが、サーバー側の正規化とキャッシュキーを単一の入力に + * 揃えるため、置換の責務をサーバーに寄せる。 * - 英語文に日本語が残っている場合は除去する。nameRoman が欠落した駅データ等では * wrapPhoneme のローマ字フォールバックが効かず英語文に日本語が混ざることがあり、 * その場合エンジンが言語を誤判定して全文を日本語音声で合成してしまうため。 */ +export type SpeechEngineKind = 'native' | 'remote'; + export const toSpeakableText = ( ssml: string, - language: 'JA' | 'EN' + language: 'JA' | 'EN', + engine: SpeechEngineKind ): string => { const plain = fixJrReading( ssmlToPlainText(ssml, { @@ -43,7 +50,8 @@ export const toSpeakableText = ( plain ); } - return fixEnglishReading(stripJapaneseCharacters(plain)); + const english = stripJapaneseCharacters(plain); + return engine === 'native' ? fixEnglishReading(english) : english; }; /** From a6de12e0474976138ed01f7ece98c2c61e016839 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:07:46 +0900 Subject: [PATCH 05/62] Bump version for canary release (#6874) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 967977e9c..09803847c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000715 + versionCode 100000717 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000715 + versionCode 100000717 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 24cecc8e1..0f9a9a7ad 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000716 + versionCode = 100000718 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000716 + versionCode = 100000718 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 49ef0962e..3d67a419f 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2887', + buildNumber: '2888', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000715, + versionCode: 100000717, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index d07bbfedd..eb8ecb053 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2887; + CURRENT_PROJECT_VERSION = 2888; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From be360b61026d7fc9f612c8f42b91606aead1c507 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 01:21:02 +0900 Subject: [PATCH 06/62] =?UTF-8?q?=E8=8B=B1=E8=AA=9ETTS=E3=81=A7=E3=80=8CSe?= =?UTF-8?q?ibu=E3=80=8D=E3=81=8C=E3=80=8C=E3=81=95=E3=81=84=E3=81=B6?= =?UTF-8?q?=E3=80=8D=E3=81=A8=E8=AA=AD=E3=81=BE=E3=82=8C=E3=82=8B=E8=AA=A4?= =?UTF-8?q?=E8=AA=AD=E3=82=92=E7=AB=AF=E6=9C=AB=E5=86=85=E8=94=B5TTS?= =?UTF-8?q?=E5=90=91=E3=81=91=E3=81=AE=E8=AA=AD=E3=81=BF=E6=9B=BF=E3=81=88?= =?UTF-8?q?=E8=A1=A8=E3=81=AB=E8=BF=BD=E5=8A=A0=E3=81=97=E3=81=A6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(#6876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WS1LSBnYTMfm7CyrzqKxuW Co-authored-by: Claude --- src/utils/englishReading.test.ts | 11 +++++++++++ src/utils/englishReading.ts | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/utils/englishReading.test.ts b/src/utils/englishReading.test.ts index 043a59cea..883626e73 100644 --- a/src/utils/englishReading.test.ts +++ b/src/utils/englishReading.test.ts @@ -25,8 +25,19 @@ describe('fixEnglishReading', () => { ); }); + it('「Seibu」を英語 TTS が「せいぶ」と読む表記へ置換する', () => { + expect(fixEnglishReading('Change here for the Seibu Ikebukuro Line.')).toBe( + 'Change here for the Say-boo Ikebukuro Line.' + ); + expect(fixEnglishReading('The next station is Seibu-Shinjuku.')).toBe( + 'The next station is Say-boo-Shinjuku.' + ); + }); + it('別語の一部は置換しない', () => { expect(fixEnglishReading('Keiseibus')).toBe('Keiseibus'); + // 西武園 (Seibuen) は 1 語なので語単位の一致では対象外 + expect(fixEnglishReading('Seibuen')).toBe('Seibuen'); }); it('対象を含まないテキストはそのまま返す', () => { diff --git a/src/utils/englishReading.ts b/src/utils/englishReading.ts index 8c2209c82..be492c932 100644 --- a/src/utils/englishReading.ts +++ b/src/utils/englishReading.ts @@ -17,6 +17,9 @@ const ENGLISH_READING_RULES: readonly EnglishReadingRule[] = [ // 「Keisei (京成)」は英語 TTS が "ei" を /aɪ/ と推定して「かいせい」と読む // ため、英単語 "Kay" + "say" で /keɪ.seɪ/ (けいせい) を確定させる。 { pattern: /\bKeisei\b/gi, reading: 'Kay-say' }, + // 「Seibu (西武)」も同じく "ei" を /aɪ/ と推定して「さいぶ」と読むため、 + // "Say" + "boo" で /seɪ.buː/ (せいぶ) を確定させる。 + { pattern: /\bSeibu\b/gi, reading: 'Say-boo' }, ]; /** From 57cfb066115040b47df837bbd07018be0ffabed2 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 07:09:53 +0900 Subject: [PATCH 07/62] =?UTF-8?q?=E5=85=A8=E3=83=8A=E3=83=B3=E3=83=90?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=B0=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=81=A7Android=E3=81=AE=E3=82=B0=E3=83=AA=E3=83=95=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E3=82=BA=E3=83=AC=E3=82=92=E8=A3=9C=E6=AD=A3=20(#6875?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 全ナンバリングアイコンでAndroidのグリフ上下ズレを補正 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NcNhShP3R1kZg8MNrvu7kR * 縮小版のナンバリングでも実際の文字サイズから補正量を求める Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NcNhShP3R1kZg8MNrvu7kR --------- Co-authored-by: Claude Opus 5 (1M context) --- src/components/NumberingIconHalfSquare.tsx | 25 ++- src/components/NumberingIconHankyu.tsx | 26 ++- src/components/NumberingIconHanshin.tsx | 26 ++- src/components/NumberingIconKeikyu.tsx | 53 +++++- src/components/NumberingIconKeisei.tsx | 67 ++++++-- src/components/NumberingIconKintetsu.tsx | 19 +- src/components/NumberingIconNTL.tsx | 23 ++- src/components/NumberingIconNankai.tsx | 34 +++- src/components/NumberingIconOdakyu.tsx | 24 ++- src/components/NumberingIconReversedRound.tsx | 95 ++++++++-- .../NumberingIconReversedSquare.tsx | 45 +++-- .../NumberingIconReversedSquareWest.tsx | 10 +- src/components/NumberingIconRound.tsx | 100 +++++++++-- .../NumberingIconRoundHorizontal.tsx | 31 +++- src/components/NumberingIconSanyo.tsx | 45 +++-- src/components/NumberingIconSquare.tsx | 71 +++++--- src/components/NumberingIconTWR.tsx | 21 ++- .../numberingIconGlyphLift.test.tsx | 162 ++++++++++++++++++ src/utils/numberingGlyphLift.test.ts | 118 +++++++++++-- src/utils/numberingGlyphLift.ts | 125 ++++++++++---- 20 files changed, 928 insertions(+), 192 deletions(-) create mode 100644 src/components/numberingIconGlyphLift.test.tsx diff --git a/src/components/NumberingIconHalfSquare.tsx b/src/components/NumberingIconHalfSquare.tsx index 297f4d3e3..7aade0411 100644 --- a/src/components/NumberingIconHalfSquare.tsx +++ b/src/components/NumberingIconHalfSquare.tsx @@ -1,11 +1,12 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingGlyphLift } from '../utils/numberingGlyphLift'; import NumberingIconReversedSquare from './NumberingIconReversedSquare'; import Typography from './Typography'; @@ -18,6 +19,15 @@ type Props = { withOutline?: boolean; }; +const FONT = 'MyriadPro'; +const SYMBOL_SIZE = isTablet ? 22 * 1.5 : 22; +const NUMBER_SIZE = isTablet ? 37 * 1.5 : 37; + +// Androidのグリフ上寄り補正。番号は白地の箱の中で単独に中央寄せされるため、 +// 記号と番号はそれぞれ1行として求める +const SYMBOL_GLYPH_LIFT = numberingGlyphLift(SYMBOL_SIZE, FONT); +const NUMBER_GLYPH_LIFT = numberingGlyphLift(NUMBER_SIZE, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderWidth: 2, @@ -33,11 +43,13 @@ const styles = StyleSheet.create({ borderColor: 'white', }, lineSymbol: { - fontSize: isTablet ? 22 * 1.5 : 22, - lineHeight: isTablet ? 22 * 1.5 : 22, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: SYMBOL_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.MyriadPro, - marginTop: 4, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 4 : 0, }, stationNumberContainer: { backgroundColor: 'white', @@ -49,8 +61,9 @@ const styles = StyleSheet.create({ }, stationNumber: { color: '#231f20', - fontSize: isTablet ? 37 * 1.5 : 37, - lineHeight: isTablet ? 37 * 1.5 : 37, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: NUMBER_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.MyriadPro, }, diff --git a/src/components/NumberingIconHankyu.tsx b/src/components/NumberingIconHankyu.tsx index 9228ccea0..8173c9505 100644 --- a/src/components/NumberingIconHankyu.tsx +++ b/src/components/NumberingIconHankyu.tsx @@ -1,7 +1,8 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingStackedGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -10,6 +11,16 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 21 * 1.5 : 21; +const NUMBER_SIZE = isTablet ? 35 * 1.5 : 35; + +// Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -27,15 +38,18 @@ const styles = StyleSheet.create({ backgroundColor: 'white', }, lineSymbol: { - fontSize: isTablet ? 21 * 1.5 : 21, - lineHeight: isTablet ? 21 * 1.5 : 21, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: isTablet ? 4 : 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 4 : 2) : 0, }, stationNumber: { - fontSize: isTablet ? 35 * 1.5 : 35, - lineHeight: isTablet ? 35 * 1.5 : 35, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, marginTop: isTablet ? -4 : -2, diff --git a/src/components/NumberingIconHanshin.tsx b/src/components/NumberingIconHanshin.tsx index f88fc4391..2d0e40ca4 100644 --- a/src/components/NumberingIconHanshin.tsx +++ b/src/components/NumberingIconHanshin.tsx @@ -1,7 +1,8 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingStackedGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -10,6 +11,16 @@ type Props = { withOutline?: boolean; }; +const FONT = 'VerdanaBold'; +const SYMBOL_SIZE = isTablet ? 21 * 1.5 : 21; +const NUMBER_SIZE = isTablet ? 35 * 1.5 : 35; + +// Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -27,15 +38,18 @@ const styles = StyleSheet.create({ backgroundColor: 'white', }, lineSymbol: { - fontSize: isTablet ? 21 * 1.5 : 21, - lineHeight: isTablet ? 21 * 1.5 : 21, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.VerdanaBold, - marginTop: isTablet ? 4 : 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 4 : 2) : 0, }, stationNumber: { - fontSize: isTablet ? 35 * 1.5 : 35, - lineHeight: isTablet ? 35 * 1.5 : 35, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.VerdanaBold, marginTop: isTablet ? -4 : -2, diff --git a/src/components/NumberingIconKeikyu.tsx b/src/components/NumberingIconKeikyu.tsx index eb0f076ee..f8d1b8bb7 100644 --- a/src/components/NumberingIconKeikyu.tsx +++ b/src/components/NumberingIconKeikyu.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { FONTS } from '~/constants'; import isTablet from '~/utils/isTablet'; +import { numberingStackedGlyphLift } from '~/utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -10,6 +11,32 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FuturaLTPro'; +const SYMBOL_SIZE = isTablet ? 16 * 1.5 : 16; +const NUMBER_SIZE = isTablet ? 26 * 1.5 : 26; + +// Androidのグリフ上寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる +const glyphLiftStyles = StyleSheet.create({ + normal: { + transform: numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } + ), + }, + // longStationNumberAdditional は fontSize だけを縮め lineHeight は据え置くので、 + // 行の高さは NUMBER_SIZE のまま + longNumber: { + transform: numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { + fontSize: isTablet ? 20 * 1.5 : 20, + lineHeight: NUMBER_SIZE, + font: FONT, + } + ), + }, +}); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -28,15 +55,15 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: '#00386D', - fontSize: isTablet ? 16 * 1.5 : 16, - lineHeight: isTablet ? 16 * 1.5 : 16, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, stationNumber: { color: '#00386D', - fontSize: isTablet ? 26 * 1.5 : 26, - lineHeight: isTablet ? 26 * 1.5 : 26, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, @@ -54,17 +81,27 @@ const NumberingIconKeikyu: React.FC = ({ const [lineSymbol, ...stationNumberRest] = stationNumberRaw.split('-'); const stationNumber = stationNumberRest.join('-'); const isIncludesSubNumber = stationNumber.includes('-'); + // 記号と番号で同じ値を当てないと両者の間隔が変わるので、1つ選んで両方に渡す + const glyphLift = isIncludesSubNumber + ? glyphLiftStyles.longNumber + : glyphLiftStyles.normal; const stationNumberTextStyles = useMemo(() => { if (isIncludesSubNumber) { - return [styles.stationNumber, styles.longStationNumberAdditional]; + return [ + styles.stationNumber, + styles.longStationNumberAdditional, + glyphLift, + ]; } - return styles.stationNumber; - }, [isIncludesSubNumber]); + return [styles.stationNumber, glyphLift]; + }, [isIncludesSubNumber, glyphLift]); return ( - {lineSymbol} + + {lineSymbol} + {stationNumber} diff --git a/src/components/NumberingIconKeisei.tsx b/src/components/NumberingIconKeisei.tsx index 2c97c4359..26b11569e 100644 --- a/src/components/NumberingIconKeisei.tsx +++ b/src/components/NumberingIconKeisei.tsx @@ -1,11 +1,15 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +19,34 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 22 * 1.5 : 22; +const NUMBER_SIZE = isTablet ? 26 * 1.5 : 26; + +// Androidのグリフ下寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// 縦に並ぶ2行には同じ値を使い回す +const glyphLiftStyles = StyleSheet.create({ + normal: { + transform: numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } + ), + }, + // longStationNumberAdditional は fontSize だけを縮め lineHeight は据え置くので、 + // 行の高さは NUMBER_SIZE のまま + longNumber: { + transform: numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { + fontSize: isTablet ? 20 * 1.5 : 20, + lineHeight: NUMBER_SIZE, + font: FONT, + } + ), + }, +}); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -32,11 +64,12 @@ const styles = StyleSheet.create({ backgroundColor: 'white', }, lineSymbol: { - fontSize: isTablet ? 22 * 1.5 : 22, - lineHeight: isTablet ? 22 * 1.5 : 22, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: isTablet ? 4 : 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 4 : 2) : 0, }, rootTiny: { width: 20, @@ -51,13 +84,15 @@ const styles = StyleSheet.create({ lineSymbolTiny: { fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, stationNumber: { - fontSize: isTablet ? 26 * 1.5 : 26, - lineHeight: isTablet ? 26 * 1.5 : 26, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, marginTop: isTablet ? -4 : -2, @@ -77,12 +112,20 @@ const NumberingIconKeisei: React.FC = ({ const [lineSymbol, ...stationNumberRest] = stationNumberRaw.split('-'); const stationNumber = stationNumberRest.join('-'); const isIncludesSubNumber = stationNumber.includes('-'); + // 記号と番号で同じ値を当てないと両者の間隔が変わるので、1つ選んで両方に渡す + const glyphLift = isIncludesSubNumber + ? glyphLiftStyles.longNumber + : glyphLiftStyles.normal; const stationNumberTextStyles = useMemo(() => { if (isIncludesSubNumber) { - return [styles.stationNumber, styles.longStationNumberAdditional]; + return [ + styles.stationNumber, + styles.longStationNumberAdditional, + glyphLift, + ]; } - return styles.stationNumber; - }, [isIncludesSubNumber]); + return [styles.stationNumber, glyphLift]; + }, [isIncludesSubNumber, glyphLift]); if (size === NUMBERING_ICON_SIZE.SMALL) { return ( @@ -97,7 +140,9 @@ const NumberingIconKeisei: React.FC = ({ return ( - + {lineSymbol} diff --git a/src/components/NumberingIconKintetsu.tsx b/src/components/NumberingIconKintetsu.tsx index 121ac42ef..c4d94f6bf 100644 --- a/src/components/NumberingIconKintetsu.tsx +++ b/src/components/NumberingIconKintetsu.tsx @@ -1,11 +1,12 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +16,14 @@ type Props = { withOutline?: boolean; }; +// Androidのグリフ下寄り補正。LARGE は lineHeight を指定しておらず +// CustomLineHeightSpan を通らないため、iOS と同じ描画になり補正しない +const MEDIUM_GLYPH_LIFT = numberingGlyphLift( + isTablet ? 25 * 1.5 : 25, + 'FrutigerNeueLTProBold' +); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, 'FrutigerNeueLTProBold'); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: isTablet ? 8 * 1.5 : 8, @@ -65,15 +74,19 @@ const styles = StyleSheet.create({ fontFamily: FONTS.FrutigerNeueLTProBold, fontSize: isTablet ? 25 * 1.5 : 25, lineHeight: isTablet ? 25 * 1.5 : 25, - marginTop: isTablet ? 8 : 4, + transform: MEDIUM_GLYPH_LIFT, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 8 : 4) : 0, }, lineSymbolTiny: { color: 'white', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, stationNumber: { color: 'white', diff --git a/src/components/NumberingIconNTL.tsx b/src/components/NumberingIconNTL.tsx index 946bce172..5d2660ab4 100644 --- a/src/components/NumberingIconNTL.tsx +++ b/src/components/NumberingIconNTL.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import { StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingStackedGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -9,6 +10,18 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 18 * 1.5 : 18; +const NUMBER_SIZE = isTablet ? 28 * 1.5 : 28; + +// Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる。 +// inner は上詰めで中央寄せしていないため marginTop は位置指定そのものと解釈し、 +// プラットフォーム分岐は入れずフォント由来のズレだけを打ち消す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: isTablet ? 14 : 10, @@ -34,15 +47,17 @@ const styles = StyleSheet.create({ borderRadius: isTablet ? 6 : 4, }, lineSymbol: { - lineHeight: isTablet ? 18 * 1.5 : 18, - fontSize: isTablet ? 18 * 1.5 : 18, + lineHeight: SYMBOL_SIZE, + fontSize: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, marginTop: 4, }, stationNumber: { - lineHeight: isTablet ? 28 * 1.5 : 28, - fontSize: isTablet ? 28 * 1.5 : 28, + lineHeight: NUMBER_SIZE, + fontSize: NUMBER_SIZE, + transform: GLYPH_LIFT, marginTop: -4, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, diff --git a/src/components/NumberingIconNankai.tsx b/src/components/NumberingIconNankai.tsx index df59461df..f7af452a6 100644 --- a/src/components/NumberingIconNankai.tsx +++ b/src/components/NumberingIconNankai.tsx @@ -1,5 +1,5 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import Svg, { Ellipse } from 'react-native-svg'; import { FONTS, @@ -7,6 +7,10 @@ import { type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; const ICON_SIZE = isTablet ? 72 * 1.5 : 72; @@ -21,6 +25,17 @@ type Props = { withOutline?: boolean; }; +const SYMBOL_SIZE = isTablet ? 18 * 1.5 : 18; +const NUMBER_SIZE = isTablet ? 32 * 1.5 : 32; + +// Androidのグリフ上寄り補正。記号と番号でフォントが違うので両方のメトリクスから求める。 +// 記号と番号で異なる値を使うと両者の間隔まで変わるため同じ値を使い回す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: 'FuturaLTPro' }, + { fontSize: NUMBER_SIZE, font: 'MyriadPro' } +); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, 'FuturaLTPro'); + const styles = StyleSheet.create({ root: { position: 'relative', @@ -42,11 +57,13 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: 'white', - fontSize: isTablet ? 18 * 1.5 : 18, - lineHeight: isTablet ? 18 * 1.5 : 18, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: isTablet ? 8 : 4, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 8 : 4) : 0, }, rootTiny: { width: 20, @@ -62,14 +79,17 @@ const styles = StyleSheet.create({ color: 'white', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, stationNumber: { color: 'white', - fontSize: isTablet ? 32 * 1.5 : 32, - lineHeight: isTablet ? 32 * 1.5 : 32, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, marginTop: isTablet ? -4 * 1.2 : -4, textAlign: 'center', fontFamily: FONTS.MyriadPro, diff --git a/src/components/NumberingIconOdakyu.tsx b/src/components/NumberingIconOdakyu.tsx index 59349e2a5..a4829a46a 100644 --- a/src/components/NumberingIconOdakyu.tsx +++ b/src/components/NumberingIconOdakyu.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingStackedGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -11,6 +12,16 @@ type Props = { shouldGrayscale?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 22 * 1.5 : 22; +const NUMBER_SIZE = isTablet ? 32 * 1.5 : 32; + +// Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2.2 + 2, @@ -29,17 +40,20 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: '#221714', - fontSize: isTablet ? 22 * 1.5 : 22, - lineHeight: isTablet ? 22 * 1.5 : 22, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: Platform.select({ android: -2, ios: 8 }), + // Androidの下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.select({ android: 0, ios: 8 }), letterSpacing: -1, }, stationNumber: { color: '#221714', - fontSize: isTablet ? 32 * 1.5 : 32, - lineHeight: isTablet ? 32 * 1.5 : 32, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, marginTop: isTablet ? -4 : -2, diff --git a/src/components/NumberingIconReversedRound.tsx b/src/components/NumberingIconReversedRound.tsx index f45604b94..60b941d72 100644 --- a/src/components/NumberingIconReversedRound.tsx +++ b/src/components/NumberingIconReversedRound.tsx @@ -1,11 +1,15 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +19,42 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FuturaLTPro'; +const SYMBOL_SIZE = isTablet ? 24 * 1.5 : 24; +const LONG_SYMBOL_SIZE = isTablet ? 20 * 1.5 : 20; +const NUMBER_SIZE = isTablet ? 26 * 1.5 : 26; + +const SYMBOL_LINE = { fontSize: SYMBOL_SIZE, font: FONT } as const; +const LONG_SYMBOL_LINE = { fontSize: LONG_SYMBOL_SIZE, font: FONT } as const; +const NUMBER_LINE = { fontSize: NUMBER_SIZE, font: FONT } as const; +// longStationNumberAdditional は fontSize だけを縮め lineHeight は据え置くので、 +// 行の高さは NUMBER_SIZE のまま +const LONG_NUMBER_LINE = { + fontSize: isTablet ? 20 * 1.5 : 20, + lineHeight: NUMBER_SIZE, + font: FONT, +} as const; + +// Androidのグリフ上寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// 縦に並ぶ2行には同じ値を使い回す。記号・番号のどちらにも縮小版があり、実際に使う +// 組み合わせでズレ量が変わるので4通り用意する +const glyphLiftStyles = StyleSheet.create({ + normal: { transform: numberingStackedGlyphLift(SYMBOL_LINE, NUMBER_LINE) }, + longSymbol: { + transform: numberingStackedGlyphLift(LONG_SYMBOL_LINE, NUMBER_LINE), + }, + longNumber: { + transform: numberingStackedGlyphLift(SYMBOL_LINE, LONG_NUMBER_LINE), + }, + longBoth: { + transform: numberingStackedGlyphLift(LONG_SYMBOL_LINE, LONG_NUMBER_LINE), + }, +}); + +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 : 14, FONT); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); +const TINY_LONG_GLYPH_LIFT = numberingGlyphLift(5, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -33,15 +73,15 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: 'white', - fontSize: isTablet ? 24 * 1.5 : 24, - lineHeight: isTablet ? 24 * 1.5 : 24, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, lineSymbolLong: { color: 'white', - fontSize: isTablet ? 20 * 1.5 : 20, - lineHeight: isTablet ? 20 * 1.5 : 20, + fontSize: LONG_SYMBOL_SIZE, + lineHeight: LONG_SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, @@ -69,30 +109,36 @@ const styles = StyleSheet.create({ color: 'white', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolTinyLong: { color: 'white', fontSize: 5, lineHeight: 5, + transform: TINY_LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolMedium: { color: 'white', fontSize: isTablet ? 24 : 14, lineHeight: isTablet ? 24 : 14, + transform: MEDIUM_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, stationNumber: { color: 'white', - fontSize: isTablet ? 26 * 1.5 : 26, - lineHeight: isTablet ? 26 * 1.5 : 26, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, marginTop: isTablet ? -4 : -2, @@ -112,12 +158,28 @@ const NumberingIconReversedRound: React.FC = ({ const [lineSymbol, ...stationNumberRest] = stationNumberRaw.split('-'); const stationNumber = stationNumberRest.join('-'); const isIncludesSubNumber = stationNumber.includes('-'); + const isLongSymbol = lineSymbol.length === 2; + // 記号と番号で同じ値を当てないと両者の間隔が変わるので、1つ選んで両方に渡す + const glyphLift = useMemo(() => { + if (isLongSymbol) { + return isIncludesSubNumber + ? glyphLiftStyles.longBoth + : glyphLiftStyles.longSymbol; + } + return isIncludesSubNumber + ? glyphLiftStyles.longNumber + : glyphLiftStyles.normal; + }, [isLongSymbol, isIncludesSubNumber]); const stationNumberTextStyles = useMemo(() => { if (isIncludesSubNumber) { - return [styles.stationNumber, styles.longStationNumberAdditional]; + return [ + styles.stationNumber, + styles.longStationNumberAdditional, + glyphLift, + ]; } - return styles.stationNumber; - }, [isIncludesSubNumber]); + return [styles.stationNumber, glyphLift]; + }, [isIncludesSubNumber, glyphLift]); if (size === NUMBERING_ICON_SIZE.SMALL) { return ( @@ -147,9 +209,10 @@ const NumberingIconReversedRound: React.FC = ({ {lineSymbol} diff --git a/src/components/NumberingIconReversedSquare.tsx b/src/components/NumberingIconReversedSquare.tsx index 968a7fc6b..c1d9ce802 100644 --- a/src/components/NumberingIconReversedSquare.tsx +++ b/src/components/NumberingIconReversedSquare.tsx @@ -1,11 +1,15 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -16,6 +20,20 @@ type Props = { withOutline?: boolean; }; +const FONT = 'MyriadPro'; +const SYMBOL_SIZE = isTablet ? 22 * 1.5 : 22; +const NUMBER_SIZE = isTablet ? 37 * 1.5 : 35; +const MEDIUM_SIZE = isTablet ? 18 * 1.5 : 18; + +// Androidのグリフ上寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// 縦に並ぶ2行には同じ値を使い回す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(MEDIUM_SIZE, FONT); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderWidth: 2, @@ -53,29 +71,36 @@ const styles = StyleSheet.create({ borderColor: 'white', }, lineSymbol: { - fontSize: isTablet ? 22 * 1.5 : 22, - lineHeight: isTablet ? 22 * 1.5 : 22, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.MyriadPro, - marginTop: 4, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 4 : 0, }, lineSymbolMedium: { - fontSize: isTablet ? 18 * 1.5 : 18, - lineHeight: isTablet ? 18 * 1.5 : 18, + fontSize: MEDIUM_SIZE, + lineHeight: MEDIUM_SIZE, + transform: MEDIUM_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.MyriadPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, lineSymbolTiny: { fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.MyriadPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, stationNumber: { - fontSize: isTablet ? 37 * 1.5 : 35, - lineHeight: isTablet ? 37 * 1.5 : 35, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, marginTop: isTablet ? -4 * 1.2 : -4, textAlign: 'center', fontFamily: FONTS.MyriadPro, diff --git a/src/components/NumberingIconReversedSquareWest.tsx b/src/components/NumberingIconReversedSquareWest.tsx index a1aa4832b..2ca6231c9 100644 --- a/src/components/NumberingIconReversedSquareWest.tsx +++ b/src/components/NumberingIconReversedSquareWest.tsx @@ -2,7 +2,7 @@ import type React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; -import { numberingGlyphLift } from '../utils/numberingGlyphLift'; +import { numberingStackedGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -13,8 +13,14 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const TEXT_SIZE = isTablet ? 30 * 1.5 : 30; + // Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる -const GLYPH_LIFT = numberingGlyphLift(isTablet ? 30 * 1.5 : 30); +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: TEXT_SIZE, font: FONT }, + { fontSize: TEXT_SIZE, font: FONT } +); const styles = StyleSheet.create({ optionalBorder: { diff --git a/src/components/NumberingIconRound.tsx b/src/components/NumberingIconRound.tsx index 3833232ca..e4e7ef606 100644 --- a/src/components/NumberingIconRound.tsx +++ b/src/components/NumberingIconRound.tsx @@ -1,11 +1,15 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +19,43 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FuturaLTPro'; +const SYMBOL_SIZE = isTablet ? 24 * 1.5 : 24; +const LONG_SYMBOL_SIZE = isTablet ? 20 * 1.5 : 20; +const NUMBER_SIZE = isTablet ? 26 * 1.5 : 26; + +const SYMBOL_LINE = { fontSize: SYMBOL_SIZE, font: FONT } as const; +const LONG_SYMBOL_LINE = { fontSize: LONG_SYMBOL_SIZE, font: FONT } as const; +const NUMBER_LINE = { fontSize: NUMBER_SIZE, font: FONT } as const; +// longStationNumberAdditional は fontSize だけを縮め lineHeight は据え置くので、 +// 行の高さは NUMBER_SIZE のまま +const LONG_NUMBER_LINE = { + fontSize: isTablet ? 20 * 1.5 : 20, + lineHeight: NUMBER_SIZE, + font: FONT, +} as const; + +// Androidのグリフ上寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// 縦に並ぶ2行には同じ値を使い回す。記号・番号のどちらにも縮小版があり、実際に使う +// 組み合わせでズレ量が変わるので4通り用意する +const glyphLiftStyles = StyleSheet.create({ + normal: { transform: numberingStackedGlyphLift(SYMBOL_LINE, NUMBER_LINE) }, + longSymbol: { + transform: numberingStackedGlyphLift(LONG_SYMBOL_LINE, NUMBER_LINE), + }, + longNumber: { + transform: numberingStackedGlyphLift(SYMBOL_LINE, LONG_NUMBER_LINE), + }, + longBoth: { + transform: numberingStackedGlyphLift(LONG_SYMBOL_LINE, LONG_NUMBER_LINE), + }, +}); + +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 : 14, FONT); +const MEDIUM_LONG_GLYPH_LIFT = numberingGlyphLift(isTablet ? 16 : 11, FONT); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); +const TINY_LONG_GLYPH_LIFT = numberingGlyphLift(5, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -33,15 +74,15 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: '#221714', - fontSize: isTablet ? 24 * 1.5 : 24, - lineHeight: isTablet ? 24 * 1.5 : 24, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, lineSymbolLong: { color: '#221714', - fontSize: isTablet ? 20 * 1.5 : 20, - lineHeight: isTablet ? 20 * 1.5 : 20, + fontSize: LONG_SYMBOL_SIZE, + lineHeight: LONG_SYMBOL_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, @@ -69,39 +110,47 @@ const styles = StyleSheet.create({ color: '#221714', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolTinyLong: { color: '#221714', fontSize: 5, lineHeight: 5, + transform: TINY_LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolMedium: { color: '#221714', fontSize: isTablet ? 24 : 14, lineHeight: isTablet ? 24 : 14, + transform: MEDIUM_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, lineSymbolMediumLong: { color: '#221714', fontSize: isTablet ? 16 : 11, lineHeight: isTablet ? 16 : 11, + transform: MEDIUM_LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, alignSelf: 'center', }, stationNumber: { color: '#221714', - fontSize: isTablet ? 26 * 1.5 : 26, - lineHeight: isTablet ? 26 * 1.5 : 26, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, marginTop: isTablet ? -4 : -2, @@ -121,12 +170,28 @@ const NumberingIconRound: React.FC = ({ const [lineSymbol, ...stationNumberRest] = stationNumberRaw.split('-'); const stationNumber = stationNumberRest.join('-'); const isIncludesSubNumber = stationNumber.includes('-'); + const isLongSymbol = lineSymbol.length === 2; + // 記号と番号で同じ値を当てないと両者の間隔が変わるので、1つ選んで両方に渡す + const glyphLift = useMemo(() => { + if (isLongSymbol) { + return isIncludesSubNumber + ? glyphLiftStyles.longBoth + : glyphLiftStyles.longSymbol; + } + return isIncludesSubNumber + ? glyphLiftStyles.longNumber + : glyphLiftStyles.normal; + }, [isLongSymbol, isIncludesSubNumber]); const stationNumberTextStyles = useMemo(() => { if (isIncludesSubNumber) { - return [styles.stationNumber, styles.longStationNumberAdditional]; + return [ + styles.stationNumber, + styles.longStationNumberAdditional, + glyphLift, + ]; } - return styles.stationNumber; - }, [isIncludesSubNumber]); + return [styles.stationNumber, glyphLift]; + }, [isIncludesSubNumber, glyphLift]); if (size === NUMBERING_ICON_SIZE.SMALL) { return ( @@ -164,9 +229,10 @@ const NumberingIconRound: React.FC = ({ {lineSymbol} diff --git a/src/components/NumberingIconRoundHorizontal.tsx b/src/components/NumberingIconRoundHorizontal.tsx index dfb026f96..d35feb6c2 100644 --- a/src/components/NumberingIconRoundHorizontal.tsx +++ b/src/components/NumberingIconRoundHorizontal.tsx @@ -1,11 +1,12 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +16,16 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FuturaLTPro'; + +// Androidのグリフ上寄り補正。いずれも1行なのでサイズごとに求める +const GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 * 1.5 : 24, FONT); +const LONG_GLYPH_LIFT = numberingGlyphLift(isTablet ? 16 * 1.5 : 16, FONT); +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 : 14, FONT); +const MEDIUM_LONG_GLYPH_LIFT = numberingGlyphLift(isTablet ? 16 : 11, FONT); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); +const TINY_LONG_GLYPH_LIFT = numberingGlyphLift(5, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -35,6 +46,7 @@ const styles = StyleSheet.create({ color: '#221714', fontSize: isTablet ? 24 * 1.5 : 24, lineHeight: isTablet ? 24 * 1.5 : 24, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, @@ -42,6 +54,7 @@ const styles = StyleSheet.create({ color: '#221714', fontSize: isTablet ? 16 * 1.5 : 16, lineHeight: isTablet ? 16 * 1.5 : 16, + transform: LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, @@ -69,33 +82,41 @@ const styles = StyleSheet.create({ color: '#221714', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolTinyLong: { color: '#221714', fontSize: 5, lineHeight: 5, + transform: TINY_LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 1, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 1 : 0, }, lineSymbolMedium: { color: '#221714', fontSize: isTablet ? 24 : 14, lineHeight: isTablet ? 24 : 14, + transform: MEDIUM_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, lineSymbolMediumLong: { color: '#221714', fontSize: isTablet ? 16 : 11, lineHeight: isTablet ? 16 : 11, + transform: MEDIUM_LONG_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, alignSelf: 'center', }, }); diff --git a/src/components/NumberingIconSanyo.tsx b/src/components/NumberingIconSanyo.tsx index 2076cafbe..01a5558bb 100644 --- a/src/components/NumberingIconSanyo.tsx +++ b/src/components/NumberingIconSanyo.tsx @@ -1,11 +1,15 @@ import type React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { FONTS, NUMBERING_ICON_SIZE, type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -15,6 +19,20 @@ type Props = { withOutline?: boolean; }; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 20 * 1.5 : 20; +const NUMBER_SIZE = isTablet ? 35 * 1.5 : 35; +const SMALL_SYMBOL_SIZE = isTablet ? 18 * 1.5 : 18; + +// Androidのグリフ下寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// 縦に並ぶ2行には同じ値を使い回す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); +const SMALL_GLYPH_LIFT = numberingGlyphLift(SMALL_SYMBOL_SIZE, FONT); +const TINY_GLYPH_LIFT = numberingGlyphLift(10, FONT); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -41,11 +59,13 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: 'white', - fontSize: isTablet ? 20 * 1.5 : 20, - lineHeight: isTablet ? 20 * 1.5 : 20, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: isTablet ? 4 * 1.2 : 4, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 4 * 1.2 : 4) : 0, }, rootTiny: { width: 20, @@ -83,22 +103,27 @@ const styles = StyleSheet.create({ color: 'white', fontSize: 10, lineHeight: 10, + transform: TINY_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: 2, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, }, lineSymbolSmall: { color: 'white', - fontSize: isTablet ? 18 * 1.5 : 18, - lineHeight: isTablet ? 18 * 1.5 : 18, + fontSize: SMALL_SYMBOL_SIZE, + lineHeight: SMALL_SYMBOL_SIZE, + transform: SMALL_GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: isTablet ? 6 : 4, + // Androidの上下ズレは GLYPH_LIFT が打ち消すので、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? (isTablet ? 6 : 4) : 0, }, stationNumber: { color: 'white', - fontSize: isTablet ? 35 * 1.5 : 35, - lineHeight: isTablet ? 35 * 1.5 : 35, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, marginTop: isTablet ? -2 * 1.2 : -2, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, diff --git a/src/components/NumberingIconSquare.tsx b/src/components/NumberingIconSquare.tsx index f76930361..435326dcb 100644 --- a/src/components/NumberingIconSquare.tsx +++ b/src/components/NumberingIconSquare.tsx @@ -6,7 +6,10 @@ import { type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; -import { numberingGlyphLift } from '../utils/numberingGlyphLift'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -21,15 +24,39 @@ type Props = { const TLC_SCALE = 0.7; +const FONT = 'FrutigerNeueLTProBold'; +const SYMBOL_SIZE = isTablet ? 24 * 1.5 : 24; +const NUMBER_SIZE = isTablet ? 32 * 1.5 : 32; +const TLC_SYMBOL_SIZE = isTablet + ? Math.round(24 * 1.5 * TLC_SCALE) + : Math.round(24 * TLC_SCALE); +const TLC_NUMBER_SIZE = isTablet + ? Math.round(32 * 1.5 * TLC_SCALE) + : Math.round(32 * TLC_SCALE); +const TLC_COMPACT_SYMBOL_SIZE = isTablet ? 12 : 8; +const TLC_COMPACT_NUMBER_SIZE = isTablet ? 15 : 10; + // Androidのグリフ下寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 -// アイコンごとに基準の行高から1つだけ求めて使い回す -const GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 * 1.5 : 24); -const TLC_GLYPH_LIFT = numberingGlyphLift( - isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE) +// 縦に並ぶ2行には同じ値を使い回す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: FONT }, + { fontSize: NUMBER_SIZE, font: FONT } +); +const TLC_LABEL_LIFT = numberingGlyphLift(TLC_SYMBOL_SIZE, FONT); +const TLC_ICON_LIFT = numberingStackedGlyphLift( + { fontSize: TLC_SYMBOL_SIZE, font: FONT }, + { fontSize: TLC_NUMBER_SIZE, font: FONT } ); -const TLC_COMPACT_GLYPH_LIFT = numberingGlyphLift(isTablet ? 12 : 8); -const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 32 : 20); -const SMALL_GLYPH_LIFT = numberingGlyphLift(10); +const TLC_COMPACT_LABEL_LIFT = numberingGlyphLift( + TLC_COMPACT_SYMBOL_SIZE, + FONT +); +const TLC_COMPACT_ICON_LIFT = numberingStackedGlyphLift( + { fontSize: TLC_COMPACT_SYMBOL_SIZE, font: FONT }, + { fontSize: TLC_COMPACT_NUMBER_SIZE, font: FONT } +); +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 32 : 20, FONT); +const SMALL_GLYPH_LIFT = numberingGlyphLift(10, FONT); const styles = StyleSheet.create({ optionalBorder: { @@ -76,10 +103,8 @@ const styles = StyleSheet.create({ : Math.round(24 * TLC_SCALE), fontFamily: FONTS.FrutigerNeueLTProBold, includeFontPadding: false, - lineHeight: isTablet - ? Math.round(24 * 1.5 * TLC_SCALE) - : Math.round(24 * TLC_SCALE), - transform: TLC_GLYPH_LIFT, + lineHeight: TLC_SYMBOL_SIZE, + transform: TLC_LABEL_LIFT, }, tlcIconRoot: { width: isTablet @@ -100,10 +125,8 @@ const styles = StyleSheet.create({ backgroundColor: 'white', }, tlcLineSymbol: { - lineHeight: isTablet - ? Math.round(24 * 1.5 * TLC_SCALE) - : Math.round(24 * TLC_SCALE), - transform: TLC_GLYPH_LIFT, + lineHeight: TLC_SYMBOL_SIZE, + transform: TLC_ICON_LIFT, fontSize: isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE), @@ -113,10 +136,8 @@ const styles = StyleSheet.create({ color: '#231e1f', }, tlcStationNumber: { - lineHeight: isTablet - ? Math.round(32 * 1.5 * TLC_SCALE) - : Math.round(32 * TLC_SCALE), - transform: TLC_GLYPH_LIFT, + lineHeight: TLC_NUMBER_SIZE, + transform: TLC_ICON_LIFT, fontSize: isTablet ? Math.round(32 * 1.5 * TLC_SCALE) : Math.round(32 * TLC_SCALE), @@ -135,7 +156,7 @@ const styles = StyleSheet.create({ paddingHorizontal: isTablet ? 2 : 1, }, tlcTextCompact: { - transform: TLC_COMPACT_GLYPH_LIFT, + transform: TLC_COMPACT_LABEL_LIFT, color: 'white', textAlign: 'center', fontSize: isTablet ? 12 : 8, @@ -154,8 +175,8 @@ const styles = StyleSheet.create({ backgroundColor: 'white', }, tlcLineSymbolCompact: { - lineHeight: isTablet ? 12 : 8, - transform: TLC_COMPACT_GLYPH_LIFT, + lineHeight: TLC_COMPACT_SYMBOL_SIZE, + transform: TLC_COMPACT_ICON_LIFT, fontSize: isTablet ? 12 : 8, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, @@ -163,8 +184,8 @@ const styles = StyleSheet.create({ color: '#231e1f', }, tlcStationNumberCompact: { - lineHeight: isTablet ? 15 : 10, - transform: TLC_COMPACT_GLYPH_LIFT, + lineHeight: TLC_COMPACT_NUMBER_SIZE, + transform: TLC_COMPACT_ICON_LIFT, fontSize: isTablet ? 15 : 10, marginTop: isTablet ? -2 : -1, textAlign: 'center', diff --git a/src/components/NumberingIconTWR.tsx b/src/components/NumberingIconTWR.tsx index 6f0a4d989..86b91febd 100644 --- a/src/components/NumberingIconTWR.tsx +++ b/src/components/NumberingIconTWR.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import { StyleSheet, View } from 'react-native'; import { FONTS } from '~/constants'; import isTablet from '~/utils/isTablet'; +import { numberingStackedGlyphLift } from '~/utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -10,6 +11,16 @@ type Props = { withOutline?: boolean; }; +const SYMBOL_SIZE = isTablet ? 22 * 1.5 : 22; +const NUMBER_SIZE = isTablet ? 35 * 1.5 : 35; + +// Androidのグリフ上寄り補正。記号と番号でフォントが違うので両方のメトリクスから求める。 +// 記号と番号で異なる値を使うと両者の間隔まで変わるため同じ値を使い回す +const GLYPH_LIFT = numberingStackedGlyphLift( + { fontSize: SYMBOL_SIZE, font: 'FuturaLTPro' }, + { fontSize: NUMBER_SIZE, font: 'MyriadPro' } +); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, @@ -37,15 +48,17 @@ const styles = StyleSheet.create({ }, lineSymbol: { color: 'white', - fontSize: isTablet ? 22 * 1.5 : 22, - lineHeight: isTablet ? 22 * 1.5 : 22, + fontSize: SYMBOL_SIZE, + lineHeight: SYMBOL_SIZE, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FuturaLTPro, }, stationNumber: { color: 'white', - fontSize: isTablet ? 35 * 1.5 : 35, - lineHeight: isTablet ? 35 * 1.5 : 35, + fontSize: NUMBER_SIZE, + lineHeight: NUMBER_SIZE, + transform: GLYPH_LIFT, marginTop: isTablet ? -4 * 1.2 : -4, textAlign: 'center', fontFamily: FONTS.MyriadPro, diff --git a/src/components/numberingIconGlyphLift.test.tsx b/src/components/numberingIconGlyphLift.test.tsx new file mode 100644 index 000000000..9870b080b --- /dev/null +++ b/src/components/numberingIconGlyphLift.test.tsx @@ -0,0 +1,162 @@ +import { render } from '@testing-library/react-native'; +import { Platform, StyleSheet, Text, type TextStyle } from 'react-native'; +import { MARK_SHAPE, NUMBERING_ICON_SIZE } from '~/constants'; + +jest.mock('~/utils/isTablet', () => ({ + __esModule: true, + default: false, +})); + +jest.mock('react-native-svg', () => { + const { View } = require('react-native'); + return { + __esModule: true, + default: (props: unknown) => , + Ellipse: (props: unknown) => , + Polygon: (props: unknown) => , + Svg: (props: unknown) => , + }; +}); + +// 補正は StyleSheet.create の評価時に決まるため、アイコンを読み込む前に +// Android へ切り替えておく必要がある(iOSで補正が入らないことは +// numberingGlyphLift のユニットテストで担保している) +Object.defineProperty(Platform, 'OS', { value: 'android' }); +const NumberingIcon = require('./NumberingIcon').default; + +/** + * Android でグリフの上下ズレを打ち消す対象。`lineHeight` を指定したテキストは + * RN Android の CustomLineHeightSpan を通るため、フォントごとの補正が必要になる。 + * + * ここに無いシェイプ(REVERSED_SQUARE_HORIZONTAL / REVERSED_ROUND_HORIZONTAL / + * NEW_SHUTTLE / KEIHAN / KEIO / SMR / NISHITETSU / IZUHAKONE / + * MONOCHROME_ROUND)は、実機計測でズレが 1dp 未満だったか、`lineHeight` を + * 指定しておらず CustomLineHeightSpan を通らないため対象外。 + */ +const CORRECTED = [ + { shape: MARK_SHAPE.SQUARE, stationNumber: 'JY-01' }, + { shape: MARK_SHAPE.REVERSED_SQUARE_WEST, stationNumber: 'A-01' }, + { shape: MARK_SHAPE.KEISEI, stationNumber: 'KS-01' }, + { shape: MARK_SHAPE.SANYO, stationNumber: 'SY-01' }, + { shape: MARK_SHAPE.HANKYU, stationNumber: 'HK-01' }, + { shape: MARK_SHAPE.NTL, stationNumber: 'NT-01' }, + { shape: MARK_SHAPE.ODAKYU, stationNumber: 'OH-01' }, + // 近鉄は LARGE が lineHeight を指定していないため MEDIUM で見る + { + shape: MARK_SHAPE.KINTETSU, + stationNumber: 'A-01', + size: NUMBERING_ICON_SIZE.MEDIUM, + }, + { shape: MARK_SHAPE.ROUND, stationNumber: 'M-01' }, + { shape: MARK_SHAPE.ROUND_HORIZONTAL, stationNumber: 'M-01' }, + { shape: MARK_SHAPE.REVERSED_ROUND, stationNumber: 'N-01' }, + { shape: MARK_SHAPE.KEIKYU, stationNumber: 'KK-01' }, + { shape: MARK_SHAPE.REVERSED_SQUARE, stationNumber: 'A-01' }, + { shape: MARK_SHAPE.HALF_SQUARE, stationNumber: 'A-01' }, + { shape: MARK_SHAPE.NANKAI, stationNumber: 'NK-01' }, + { shape: MARK_SHAPE.TWR, stationNumber: 'R-01' }, + { shape: MARK_SHAPE.HANSHIN, stationNumber: 'HS-01' }, +]; + +/** + * 記号や番号を縮める派生スタイルを持つシェイプ。`longStationNumberAdditional` は + * fontSize だけを縮めて lineHeight は据え置くため、通常サイズと同じ補正量では合わない。 + */ +const SIZE_VARIANTS = [ + { shape: MARK_SHAPE.ROUND, normal: 'M-01', variant: 'M-01-1' }, + { shape: MARK_SHAPE.REVERSED_ROUND, normal: 'N-01', variant: 'N-01-1' }, + { shape: MARK_SHAPE.KEISEI, normal: 'KS-01', variant: 'KS-01-1' }, + { shape: MARK_SHAPE.KEIKYU, normal: 'KK-01', variant: 'KK-01-1' }, +]; + +/** + * 2文字の記号は縮小版のスタイルに切り替わる。こちらは lineHeight も一緒に縮むため + * 補正量の差は 0.2dp 未満にしかならず、物理ピクセルへ丸めると通常サイズと一致しうる。 + * そのため補正量が変わることまでは要求せず、2行で同じ値を使うことだけを見る。 + */ +const LONG_SYMBOL_VARIANTS = [ + { shape: MARK_SHAPE.ROUND, variant: 'MB-01' }, + { shape: MARK_SHAPE.REVERSED_ROUND, variant: 'NB-01' }, +]; + +/** lineHeight を指定しているテキストだけが補正の対象になる */ +const linedTextStyles = ( + shape: string, + stationNumber: string, + size: string = NUMBERING_ICON_SIZE.LARGE +) => + render( + + ) + .UNSAFE_getAllByType(Text) + .map((node) => (StyleSheet.flatten(node.props.style) ?? {}) as TextStyle) + .filter((style) => typeof style.lineHeight === 'number'); + +const liftsOf = (styles: TextStyle[]) => + styles.map((style) => (style.transform as { translateY: number }[])[0]); + +describe('ナンバリングアイコンのグリフ位置補正', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe.each(CORRECTED)('$shape', ({ shape, stationNumber, size }) => { + it('lineHeightを指定した全てのテキストに補正が入る', () => { + const styles = linedTextStyles(shape, stationNumber, size); + expect(styles.length).toBeGreaterThan(0); + for (const style of styles) { + expect(style.transform).toEqual([{ translateY: expect.any(Number) }]); + } + }); + + it('記号と番号で同じ補正量を使い両者の間隔を変えない', () => { + // HALF_SQUARE は番号が独立した白地の箱に入るため行ごとに値が異なる + if (shape === MARK_SHAPE.HALF_SQUARE) { + return; + } + const lifts = liftsOf(linedTextStyles(shape, stationNumber, size)); + for (const lift of lifts) { + expect(lift).toEqual(lifts[0]); + } + }); + }); + + describe.each(SIZE_VARIANTS)( + '$shape ($variant)', + ({ shape, normal, variant }) => { + it('縮小版のスタイルでも記号と番号で同じ補正量を使う', () => { + const lifts = liftsOf(linedTextStyles(shape, variant)); + expect(lifts.length).toBeGreaterThan(0); + for (const lift of lifts) { + expect(lift).toEqual(lifts[0]); + } + }); + + // longStationNumberAdditional は fontSize だけを縮めて lineHeight を据え置くので、 + // 通常サイズと同じ補正量では ink 全体が 1dp ほどズレる + it('実際に使う文字サイズの組み合わせから補正量を出している', () => { + const [normalLift] = liftsOf(linedTextStyles(shape, normal)); + const [variantLift] = liftsOf(linedTextStyles(shape, variant)); + expect(variantLift).not.toEqual(normalLift); + }); + } + ); + + describe.each(LONG_SYMBOL_VARIANTS)( + '$shape ($variant)', + ({ shape, variant }) => { + it('縮小版の記号でも記号と番号で同じ補正量を使う', () => { + const lifts = liftsOf(linedTextStyles(shape, variant)); + expect(lifts.length).toBeGreaterThan(0); + for (const lift of lifts) { + expect(lift).toEqual(lifts[0]); + } + }); + } + ); +}); diff --git a/src/utils/numberingGlyphLift.test.ts b/src/utils/numberingGlyphLift.test.ts index 469142dd6..c64a227a1 100644 --- a/src/utils/numberingGlyphLift.test.ts +++ b/src/utils/numberingGlyphLift.test.ts @@ -1,5 +1,8 @@ -import { Platform } from 'react-native'; -import { numberingGlyphLift } from './numberingGlyphLift'; +import { PixelRatio, Platform } from 'react-native'; +import { + numberingGlyphLift, + numberingStackedGlyphLift, +} from './numberingGlyphLift'; describe('numberingGlyphLift', () => { const originalOS = Platform.OS; @@ -12,34 +15,115 @@ describe('numberingGlyphLift', () => { const setOS = (os: typeof Platform.OS) => Object.defineProperty(Platform, 'OS', { value: os }); - it('iOSでは補正しない', () => { + const translateY = (transform: { translateY: number }[]) => + transform[0].translateY; + + it('iOSではどのフォントでも補正しない', () => { setOS('ios'); - expect(numberingGlyphLift(24)).toEqual([]); + expect(numberingGlyphLift(24, 'FrutigerNeueLTProBold')).toEqual([]); + expect(numberingGlyphLift(24, 'FuturaLTPro')).toEqual([]); + expect(numberingGlyphLift(24, 'MyriadPro')).toEqual([]); + expect(numberingGlyphLift(24, 'VerdanaBold')).toEqual([]); + expect( + numberingStackedGlyphLift( + { fontSize: 24, font: 'FrutigerNeueLTProBold' }, + { fontSize: 32, font: 'FrutigerNeueLTProBold' } + ) + ).toEqual([]); + }); + + // FrutigerNeueLTPro-Bold は ink が行ボックスの下寄りに描かれるため上へ持ち上げる + it('下寄りに描画されるフォントは上方向(負)へ補正する', () => { + setOS('android'); + for (const fontSize of [8, 10, 12, 20, 24, 30, 32, 45, 48]) { + expect( + translateY(numberingGlyphLift(fontSize, 'FrutigerNeueLTProBold')) + ).toBeLessThan(0); + expect( + translateY(numberingGlyphLift(fontSize, 'VerdanaBold')) + ).toBeLessThan(0); + } }); - // FrutigerNeueLTPro-Bold のメトリクスから導かれる比率は約 0.084em - it('Androidでは行の高さに比例して上方向へ補正する', () => { + // myriadpro-bold / FuturaLTPro-Bold は逆に上寄りなので押し下げる + it('上寄りに描画されるフォントは下方向(正)へ補正する', () => { setOS('android'); - expect(numberingGlyphLift(8)).toEqual([{ translateY: -1 }]); - expect(numberingGlyphLift(24)).toEqual([{ translateY: -2 }]); - expect(numberingGlyphLift(36)).toEqual([{ translateY: -3 }]); + for (const fontSize of [10, 16, 22, 24, 35]) { + expect( + translateY(numberingGlyphLift(fontSize, 'MyriadPro')) + ).toBeGreaterThan(0); + expect( + translateY(numberingGlyphLift(fontSize, 'FuturaLTPro')) + ).toBeGreaterThan(0); + } }); - it('補正量は行の高さに対して単調増加する', () => { + it('補正量は文字サイズに対して単調に増える', () => { setOS('android'); - const lifts = [8, 12, 20, 24, 32, 48].map( - (lh) => -numberingGlyphLift(lh)[0].translateY + const lifts = [8, 12, 20, 24, 32, 48].map((fontSize) => + Math.abs( + translateY(numberingGlyphLift(fontSize, 'FrutigerNeueLTProBold')) + ) ); for (let i = 1; i < lifts.length; i++) { expect(lifts[i]).toBeGreaterThanOrEqual(lifts[i - 1]); } }); - it('補正量は常に上方向(負)になる', () => { + it('文字がぼやけないよう物理ピクセル境界に丸める', () => { setOS('android'); - for (const lineHeight of [8, 10, 12, 17, 20, 24, 30, 32, 45, 48]) { - const [{ translateY }] = numberingGlyphLift(lineHeight); - expect(translateY).toBeLessThan(0); - } + const lift = translateY(numberingGlyphLift(24, 'FrutigerNeueLTProBold')); + expect(PixelRatio.roundToNearestPixel(lift)).toBe(lift); + }); +}); + +describe('numberingStackedGlyphLift', () => { + const originalOS = Platform.OS; + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { value: originalOS }); + jest.clearAllMocks(); + }); + + const setOS = (os: typeof Platform.OS) => + Object.defineProperty(Platform, 'OS', { value: os }); + + const translateY = (transform: { translateY: number }[]) => + transform[0].translateY; + + it('番号が大きいほど ink 全体は下に伸びるので補正は下方向へ寄る', () => { + setOS('android'); + const symbol = { fontSize: 22, font: 'FrutigerNeueLTProBold' } as const; + const small = numberingStackedGlyphLift(symbol, { + fontSize: 26, + font: 'FrutigerNeueLTProBold', + }); + const large = numberingStackedGlyphLift(symbol, { + fontSize: 40, + font: 'FrutigerNeueLTProBold', + }); + expect(translateY(large)).toBeGreaterThan(translateY(small)); + }); + + it('記号と番号が同じ行なら単独行の補正と一致する', () => { + setOS('android'); + const line = { fontSize: 30, font: 'FrutigerNeueLTProBold' } as const; + expect(numberingStackedGlyphLift(line, line)).toEqual( + numberingGlyphLift(line.fontSize, line.font) + ); + }); + + it('記号と番号でフォントが違っても両方のメトリクスから求める', () => { + setOS('android'); + const symbol = { fontSize: 22, font: 'FuturaLTPro' } as const; + const sameFont = numberingStackedGlyphLift(symbol, { + fontSize: 35, + font: 'FuturaLTPro', + }); + const mixedFont = numberingStackedGlyphLift(symbol, { + fontSize: 35, + font: 'MyriadPro', + }); + expect(translateY(mixedFont)).not.toBe(translateY(sameFont)); }); }); diff --git a/src/utils/numberingGlyphLift.ts b/src/utils/numberingGlyphLift.ts index ae97c1ebf..5fe30b1fb 100644 --- a/src/utils/numberingGlyphLift.ts +++ b/src/utils/numberingGlyphLift.ts @@ -1,45 +1,110 @@ -import { Platform } from 'react-native'; +import { PixelRatio, Platform } from 'react-native'; /** - * FrutigerNeueLTPro-Bold の縦メトリクス(unitsPerEm=1000 に対する比率)。 - * android/app/src/main/assets/fonts/FrutigerNeueLTPro-Bold.ttf の hhea / OS/2 の値。 + * ナンバリングアイコンで使う同梱フォントの縦メトリクス + * (unitsPerEm に対する比率)。 + * + * `ascent` / `descent` は hhea、`capHeight` は OS/2 の sCapHeight + * (Verdana は OS/2 v1 で sCapHeight を持たないため `H` のアウトラインの yMax)。 */ -const FONT_ASCENT = 1.13; -const FONT_DESCENT = 0.264; -const FONT_CAP_HEIGHT = 0.698; +const FONT_METRICS = { + // android/app/src/main/assets/fonts/FrutigerNeueLTPro-Bold.ttf + FrutigerNeueLTProBold: { ascent: 1.13, descent: 0.264, capHeight: 0.698 }, + // android/app/src/main/assets/fonts/FuturaLTPro-Bold.ttf + FuturaLTPro: { ascent: 0.825, descent: 0.175, capHeight: 0.754 }, + // android/app/src/main/assets/fonts/myriadpro-bold.ttf + MyriadPro: { ascent: 0.75, descent: 0.25, capHeight: 0.674 }, + // android/app/src/main/assets/fonts/verdana-bold.ttf + VerdanaBold: { ascent: 1.005, descent: 0.21, capHeight: 0.727 }, +} as const; + +export type NumberingGlyphFont = keyof typeof FONT_METRICS; + +/** ナンバリングの1行分 */ +export type NumberingGlyphLine = { + fontSize: number; + /** + * 省略時は `fontSize` と同値。`longStationNumberAdditional` のように + * `fontSize` だけを上書きするスタイルでは、実際に効く `lineHeight` を明示する。 + */ + lineHeight?: number; + font: NumberingGlyphFont; +}; /** - * Android でナンバリングのグリフが行ボックス内で下寄りになる分の比率。 + * 行ボックスの上端からベースラインまでの距離。 * * RN Android の CustomLineHeightSpan は lineHeight とフォントの ascent+descent の差 - * (leading) を上下へ等分するため、行ボックスの中心はフォントの ascent/descent ボックスの - * 中心に一致する。ナンバリングの記号と番号は大文字と数字だけでディセンダを持たないので、 - * ベースラインより下の descent がそのまま余白として残り、グリフが下寄りに見える。 - * ズレ量は (ascent - descent) - capHeight で、その半分を引き上げると上下が揃う。 - * 式から lineHeight が消えることからも分かるとおり、補正量は文字サイズに比例する。 + * (leading) を上下へ等分するため、ベースラインは行ボックスの中心から + * fontSize × (ascent - descent) / 2 だけ下に来る。 * - * 端末やOSではなくフォントに依存する値である点に注意: - * フォントは APK 同梱で `Typography` も allowFontScaling={false} のため、端末や - * フォントサイズ設定では変わらない。一方 myriadpro-bold は -0.087em、FuturaLTPro-Bold は - * -0.052em と符号が逆になるので、別フォントのアイコンにそのまま流用してはいけない。 - * React Native 0.86.2 の CustomLineHeightSpan を前提にしているので、RN のメジャー - * アップグレード時は実機で見た目を確認すること。 + * 端末やOSではなくフォントに依存する値である点に注意: フォントは APK 同梱で + * `Typography` も allowFontScaling={false} のため、端末やフォントサイズ設定では + * 変わらない。React Native 0.86.2 の CustomLineHeightSpan を前提にしているので、 + * RN のメジャーアップグレード時は実機で見た目を確認すること。 + */ +const baselineOffset = ({ + fontSize, + lineHeight = fontSize, + font, +}: NumberingGlyphLine) => { + const { ascent, descent } = FONT_METRICS[font]; + return lineHeight / 2 + (fontSize * (ascent - descent)) / 2; +}; + +/** 行ボックスの上端から、大文字・数字の上端(= ベースライン - capHeight)までの距離 */ +const inkTopOffset = (line: NumberingGlyphLine) => + baselineOffset(line) - line.fontSize * FONT_METRICS[line.font].capHeight; + +/** + * 行ボックスの下端から ink の下端までの距離。 + * ナンバリングの記号と番号は大文字と数字だけでディセンダを持たないので、 + * ink の下端はベースラインそのものになる。 + */ +const inkBottomOffset = (line: NumberingGlyphLine) => + (line.lineHeight ?? line.fontSize) - baselineOffset(line); + +/** + * translateY はレイアウトに影響しないぶん端数がそのまま描画位置になるので、 + * 文字がぼやけないよう物理ピクセル境界に丸める。 */ -const GLYPH_LIFT_RATIO = (FONT_ASCENT - FONT_DESCENT - FONT_CAP_HEIGHT) / 2; +const toTransform = (lift: number) => + Platform.OS === 'android' + ? [{ translateY: PixelRatio.roundToNearestPixel(-lift) }] + : []; /** - * Android のグリフ下寄り分を打ち消す transform を返す(iOS は行ボックス内で中央に - * 描かれるため補正しない)。 + * 1行だけのテキストで、グリフを行ボックスの上下中央に揃える transform を返す + * (iOS は lineHeight が font.lineHeight より小さいと RN が補正を当てないため、 + * この補正は Android のみ)。 * - * marginTop の負値では兄弟要素ごと動いてアイコン全体が縮むため、レイアウトに影響しない - * transform で文字だけを持ち上げる。 + * marginTop の負値では兄弟要素ごと動いてアイコン全体が縮むため、レイアウトに + * 影響しない transform で文字だけを動かす。 * - * 記号と番号で異なる値を渡すと両者の間隔まで変わってしまうので、1つのアイコンでは - * 必ず同じ戻り値を使い回すこと。 + * ズレの向きはフォント依存で、FrutigerNeueLTPro-Bold は下寄り、 + * myriadpro-bold と FuturaLTPro-Bold は上寄りと符号まで変わる。 + */ +export const numberingGlyphLift = ( + fontSize: number, + font: NumberingGlyphFont +) => { + const line = { fontSize, font }; + return toTransform((inkTopOffset(line) - inkBottomOffset(line)) / 2); +}; + +/** + * 記号と番号が縦に並ぶアイコンで、2行の ink 全体をアイコンの上下中央へ揃える + * transform を返す。 + * + * 記号と番号で異なる値を当てると両者の間隔まで変わってしまうので、戻り値は + * 必ず両方の Text で同じものを使い回すこと。行ごとに最適な量が違っても、 + * 揃えたいのは ink 全体の位置なので 1 つの値で足りる。 * - * @param baseLineHeight 基準にする行の高さ(本リポジトリでは lineHeight === fontSize) + * 上下の余白は「上の行の ink 上端までの余白」と「下の行の ink 下端からの余白」で + * 決まり、その差の半分が中心のズレになる。記号側の marginTop はレイアウト箱ごと + * 下げてしまうため、Android では当てないこと(iOS 側の視覚補正として残す)。 */ -export const numberingGlyphLift = (baseLineHeight: number) => - Platform.OS === 'android' - ? [{ translateY: -Math.round(baseLineHeight * GLYPH_LIFT_RATIO) }] - : []; +export const numberingStackedGlyphLift = ( + symbol: NumberingGlyphLine, + stationNumber: NumberingGlyphLine +) => toTransform((inkTopOffset(symbol) - inkBottomOffset(stationNumber)) / 2); From ce25d9f4263a4043ff9db41cf0e1dc4391beae36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:21:22 +0900 Subject: [PATCH 08/62] Bump version for canary release (#6878) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 09803847c..2baf3102f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000717 + versionCode 100000719 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000717 + versionCode 100000719 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 0f9a9a7ad..654d94461 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000718 + versionCode = 100000720 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000718 + versionCode = 100000720 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 3d67a419f..12e1e447b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2888', + buildNumber: '2889', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000717, + versionCode: 100000719, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index eb8ecb053..239f7d018 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2888; + CURRENT_PROJECT_VERSION = 2889; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 9a2d0bcd1bae4aad168023e958015c86a383b834 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 09:18:06 +0900 Subject: [PATCH 09/62] =?UTF-8?q?Kotlin=E3=83=87=E3=83=BC=E3=83=A2?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E3=83=A1=E3=82=BF=E3=82=B9=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E4=B8=8A=E9=99=90=E3=82=92=E6=98=8E=E7=A4=BA=E3=81=97?= =?UTF-8?q?=E3=81=A6Android=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E3=83=93?= =?UTF-8?q?=E3=83=AB=E3=83=89=E3=81=AEOutOfMemoryError=E3=82=92=E8=A7=A3?= =?UTF-8?q?=E6=B6=88=20(#6879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/gradle.properties | 11 ++++++- docs/android-build-workflows.md | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 03d5cc320..d0cbfea6e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,16 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +# ヒープは据え置き。枯渇していたのは Metaspace だけなので、上限を上げるのはそちらに絞る。 +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m + +# Kotlin コンパイルデーモンの JVM 引数。未指定だと org.gradle.jvmargs をそのまま +# 継承するため、1 プロセスで node_modules 配下の全ネイティブモジュール + +# :app + :wearable を続けてコンパイルすると Metaspace が枯渇し +# `OutOfMemoryError: Metaspace` でリリースビルドが停止する +# (CI 実測: ubuntu-22.04 の :wearable:compileDevReleaseKotlin で発生)。 +# 継承任せだと Gradle 側の変更に引きずられるので、値が同じでも明示しておく。 +kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/docs/android-build-workflows.md b/docs/android-build-workflows.md index 944744ae3..db39f6d9e 100644 --- a/docs/android-build-workflows.md +++ b/docs/android-build-workflows.md @@ -128,3 +128,58 @@ Both modules build with R8 enabled each upload step passes its own module's `mapping.txt`. Keep the `mappingFile` path aligned with the module of that step — a mismatched mapping silently corrupts crash deobfuscation for the affected bundle. + +## Build memory + +A release build compiles Kotlin for every native module under `node_modules` +plus `:app` and `:wearable` in a single Gradle invocation. All of those +compilations share one Kotlin compile daemon, so its Metaspace grows across the +whole run. + +The Kotlin daemon inherits `org.gradle.jvmargs` when `kotlin.daemon.jvmargs` is +not set. With the previous `-XX:MaxMetaspaceSize=512m` the daemon ran out of +Metaspace part-way through the build: + +```text +Exception in thread "RMI TCP Connection(idle)" java.lang.OutOfMemoryError: Metaspace +> Task :wearable:compileDevReleaseKotlin FAILED +``` + +The daemon then kept thrashing instead of exiting, so the job burned the rest of +its `timeout-minutes: 45` budget and surfaced only as +`The operation was canceled.` — read the log above the cancellation to see the +real cause. + +`android/gradle.properties` therefore raises the Metaspace ceiling and pins the +Kotlin daemon explicitly rather than letting it inherit: + +| Property | Heap | Metaspace (before → after) | +| --- | --- | --- | +| `org.gradle.jvmargs` | `-Xmx2048m` | `512m` → `1024m` | +| `kotlin.daemon.jvmargs` | `-Xmx2048m` | inherited `512m` → `1024m` | + +**Only the Metaspace ceiling changes.** Both heap ceilings stay at the `2048m` +the build already ran with — it compiled every native module, `:app`, and R8 +without a heap OOM, so raising `-Xmx` would treat a symptom the build never had. + +Do not read a heap ceiling as free headroom. Metaspace is committed lazily, so +its ceiling mostly just converts a runaway allocation into a clear error. A heap +ceiling is different: the JVM defers full GCs and lets the heap grow toward +`-Xmx`, so a larger value raises real resident memory. Two JVMs run +concurrently here, and Gradle worker processes, R8, and Node sit alongside them, +so budget the *sum* of the ceilings against the runner rather than each one on +its own. + +This repository is public, so its jobs get the 4-vCPU / 16 GB standard runner +(the 2-vCPU / 8 GB tier applies to private repositories). The ceilings above +total 4 GB of heap and 2 GB of Metaspace — but a ceiling is not a memory +budget. Gradle worker processes, R8, Node, and each JVM's own native memory +(thread stacks, code cache, direct buffers) all sit on top of those numbers, +and none of that has been measured here. So do not read the difference between +the ceilings and the runner's RAM as available headroom, least of all on the +8 GB tier. Before raising any of these values, measure peak RSS across the +whole build on the runner you actually target, and prefer a larger runner over +ceilings the runner cannot back. + +Keep `kotlin.daemon.jvmargs` set whenever `org.gradle.jvmargs` changes, +otherwise the daemon silently picks up the Gradle value again. From 83dbb5ed23c865c2cb6e832c6ca5c76c87c7908d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:21:59 +0900 Subject: [PATCH 10/62] Bump version for canary release (#6881) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2baf3102f..728b99796 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000719 + versionCode 100000721 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000719 + versionCode 100000721 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 654d94461..fa7d1105d 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000720 + versionCode = 100000722 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000720 + versionCode = 100000722 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 12e1e447b..24795f153 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2889', + buildNumber: '2890', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000719, + versionCode: 100000721, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 239f7d018..c700b864a 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2889; + CURRENT_PROJECT_VERSION = 2890; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 99f9a4940c6f6adf94f34282adb8d6be357b7956 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 8 Sep 2026 19:55:31 +0900 Subject: [PATCH 11/62] =?UTF-8?q?iOS=E3=81=A7=E3=83=AA=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=88TTS=E3=81=8C=E4=BD=BF=E3=81=88=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=9B=9E=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=82=92VOICEVOX?= =?UTF-8?q?=EF=BC=88=E7=AB=AF=E6=9C=AB=E5=86=85=E5=90=88=E6=88=90=EF=BC=89?= =?UTF-8?q?=E3=81=A7=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E3=82=8B=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#6882)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * iOS本体アプリにVOICEVOX COREの合成モジュールとフレームワーク取得スクリプトを追加 - ios/Modules/VoicevoxTTS: 辞書・VVM を受け取り日本語を WAV へ合成する RN ネイティブモジュール (Swift + ObjC ブリッジ) - voicevox_core 0.17.0 / voicevox_onnxruntime 1.23.2 の xcframework を scripts/fetch-voicevox-frameworks.mjs が SHA-256 検証付きで取得し ios/Frameworks へ展開する (展開物は gitignore) - Xcode プロジェクトは ProdTrainLCD / CanaryTrainLCD にだけソースとフレームワークを追加し、App Clip (非圧縮 15MB 上限) には含めない - npm run ios と iOS ビルドワークフローの pod install 前に取得ステップを追加 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * iOSでリモートTTSが使えない回の日本語をVOICEVOXで読み上げるフォールバックエンジンを追加 - Remote Config に voicevox_tts_enabled_ios / voicevox_tts_manifest_url_ios / voicevox_tts_style_id_ios を追加 (未配信時は無効) - src/lib/voicevox: マニフェスト (zod) と辞書・VVM のダウンロード・SHA-256 検証・バージョン管理 - useVoicevoxSpeechEngine: 日本語を VOICEVOX で合成して expo-audio で再生し、英語は端末内蔵 TTS へ委譲する - useTTS の経路を リモート → VOICEVOX → 端末内蔵 TTS の順にし、使えない条件では従来どおり端末内蔵 TTS へ倒す - scripts/build-voicevox-manifest.mjs: 配信用マニフェストの生成スクリプト Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX:No.7のクレジットをライセンス画面へ追加しオンデバイスTTSの設計書を整備 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * 自動アナウンス有効化時にオフライン用日本語音声のダウンロードを尋ね設定画面に進捗と操作を表示 - VOICEVOX 資産の取得はユーザーの同意 (MMKV VOICEVOX_DOWNLOAD_CONSENTED) を得てから始め、同意後は中断・更新分を自動で再開する - ダウンロード進捗をファイル横断で合算して購読可能にし (getVoicevoxAssetsStatus / useVoicevoxAssetsStatus)、キャンセルと削除を追加 - TTS 設定画面: 有効化時の同意ダイアログと、未取得 / 取得中 (進捗バー) / 取得済み / 失敗の各状態を出すパネル Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOXネイティブモジュールの参照がiOS限定であることを検証するテストを追加 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * CodeRabbitの指摘に対応しApp Clipでのクレジット非表示・日本語無効時の同意省略・キャンセル直後の再要求などを修正 - VoicevoxTTSModule: deinit が合成キュー上で走った場合の queue.sync デッドロックを回避し、VVM オープン失敗時に合成器を解放する - fetch-voicevox-frameworks: バージョンマーカーだけでなく Info.plist の存在も確認してから取得を省略する - assets: キャンセル直後に requestVoicevoxAssetsDownload が呼ばれても同意を記録し、中断が片付いてから取り直す - Licenses: App Clip では iosOnly のクレジットを出さない (テスト追加) - TTSSettings: 日本語を読まない設定では同意ダイアログを出さない (テスト追加) - manifest.test: 相対パス検証を他の検証と切り分ける - docs: シェルで解釈されない URL プレースホルダーに変更し、3 エンジンの選択順を追記 - 新規の公開関数へ JSDoc を付与 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * リモートTTS設計書のRemote Config節を3エンジン構成の説明に揃える Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX資産の配信手順をリポジトリ外の作業ディレクトリで行う形に直し配信先の構成を追記 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX資産をR2へ配信する公開スクリプトを追加しスクリプト群をBiomeで整形 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX資産の取得手順を失敗時に停止する形にしマニフェストURLのhttps必須を明記 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX資産の配信スクリプトをwrangler不要のCloudflare API直接呼び出しにしトークンを環境変数に置かずに済むようにする Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb * VOICEVOX資産の配信で公開済みversionを別内容で上書きしないよう検査を追加 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb --------- Co-authored-by: Claude --- .github/workflows/build_ios_canary.yml | 3 + .github/workflows/build_ios_production.yml | 3 + .gitignore | 3 + assets/translations/en.json | 14 + assets/translations/ja.json | 14 + docs/README.md | 1 + docs/ios-app-clip.md | 10 + docs/spec/tts/on-device-tts-ios.md | 242 ++++++++ docs/spec/tts/remote-tts.md | 25 +- ios/Frameworks/voicevox-frameworks.json | 17 + .../VoicevoxTTS/VoicevoxTTSModule.swift | 417 +++++++++++++ .../VoicevoxTTS/VoicevoxTTSModuleBridge.m | 39 ++ ios/TrainLCD.xcodeproj/project.pbxproj | 62 ++ package.json | 3 +- scripts/build-voicevox-manifest.mjs | 90 +++ scripts/fetch-voicevox-frameworks.mjs | 92 +++ scripts/publish-voicevox-assets.mjs | 316 ++++++++++ src/constants/index.ts | 1 + src/constants/storage.ts | 5 + src/constants/voicevox.ts | 40 ++ src/hooks/tts/useVoicevoxSpeechEngine.test.ts | 389 ++++++++++++ src/hooks/tts/useVoicevoxSpeechEngine.ts | 262 ++++++++ src/hooks/useTTS.test.ts | 100 +++ src/hooks/useTTS.ts | 33 +- src/hooks/useVoicevoxAssetsStatus.ts | 24 + src/lib/remoteConfig.test.ts | 56 ++ src/lib/remoteConfig.ts | 84 +++ src/lib/voicevox/assets.test.ts | 517 ++++++++++++++++ src/lib/voicevox/assets.ts | 573 ++++++++++++++++++ src/lib/voicevox/manifest.test.ts | 102 ++++ src/lib/voicevox/manifest.ts | 94 +++ src/screens/Licenses.test.tsx | 76 +++ src/screens/Licenses.tsx | 27 +- src/screens/TTSSettings.test.tsx | 199 ++++++ src/screens/TTSSettings.tsx | 233 ++++++- .../native/ios/voicevoxTtsModule.test.ts | 52 ++ src/utils/native/ios/voicevoxTtsModule.ts | 63 ++ 37 files changed, 4264 insertions(+), 17 deletions(-) create mode 100644 docs/spec/tts/on-device-tts-ios.md create mode 100644 ios/Frameworks/voicevox-frameworks.json create mode 100644 ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift create mode 100644 ios/Modules/VoicevoxTTS/VoicevoxTTSModuleBridge.m create mode 100644 scripts/build-voicevox-manifest.mjs create mode 100644 scripts/fetch-voicevox-frameworks.mjs create mode 100644 scripts/publish-voicevox-assets.mjs create mode 100644 src/constants/voicevox.ts create mode 100644 src/hooks/tts/useVoicevoxSpeechEngine.test.ts create mode 100644 src/hooks/tts/useVoicevoxSpeechEngine.ts create mode 100644 src/hooks/useVoicevoxAssetsStatus.ts create mode 100644 src/lib/voicevox/assets.test.ts create mode 100644 src/lib/voicevox/assets.ts create mode 100644 src/lib/voicevox/manifest.test.ts create mode 100644 src/lib/voicevox/manifest.ts create mode 100644 src/screens/Licenses.test.tsx create mode 100644 src/utils/native/ios/voicevoxTtsModule.test.ts create mode 100644 src/utils/native/ios/voicevoxTtsModule.ts diff --git a/.github/workflows/build_ios_canary.yml b/.github/workflows/build_ios_canary.yml index 7778fd96b..671c086bf 100644 --- a/.github/workflows/build_ios_canary.yml +++ b/.github/workflows/build_ios_canary.yml @@ -63,6 +63,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Fetch VOICEVOX CORE frameworks + run: npm run ios:frameworks + - name: Install CocoaPods dependencies working-directory: ios run: pod install --repo-update diff --git a/.github/workflows/build_ios_production.yml b/.github/workflows/build_ios_production.yml index 385d604a8..4b6009a91 100644 --- a/.github/workflows/build_ios_production.yml +++ b/.github/workflows/build_ios_production.yml @@ -63,6 +63,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Fetch VOICEVOX CORE frameworks + run: npm run ios:frameworks + - name: Install CocoaPods dependencies working-directory: ios run: pod install --repo-update diff --git a/.gitignore b/.gitignore index 363c4fe6d..171164cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,6 @@ dist/ # jj は未追跡ファイルも自動スナップショットするため、trap が走らない終了に備えて無視する ruleset_backup.json ruleset_relaxed.json + +# VOICEVOX CORE の xcframework は scripts/fetch-voicevox-frameworks.mjs が取得する +/ios/Frameworks/*.xcframework/ diff --git a/assets/translations/en.json b/assets/translations/en.json index e2c07f154..351eaab36 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -225,6 +225,20 @@ "odptGtfs": "GTFS (Public Transportation Open Data Center)", "odptDisclaimer": "The public transportation data used in this application is provided by the Public Transportation Open Data Center. The data is based on information provided by public transportation operators, and its accuracy and completeness are not guaranteed. Please do not contact the public transportation operators directly regarding the content of this application.", "otherOss": "Other Open Source Software", + "voicevoxNo7Terms": "No.7 Terms of Use (VOICEVOX voice library)", + "later": "Later", + "download": "Download", + "voicevoxDownloadTitle": "Offline Japanese Voice", + "voicevoxDownloadPrompt": "Download the Japanese voice data (about %{size}MB) used when there is no signal, such as in tunnels? Regular announcements will keep using voices synthesized over the internet. You can also download it later from the Auto Announce settings.", + "voicevoxNotDownloadedDescription": "When offline, Japanese announcements are read with a voice synthesized on the device (VOICEVOX:No.7). Downloading uses about %{size}MB of data.", + "voicevoxDownloadAction": "Download (about %{size}MB)", + "voicevoxDownloading": "Downloading", + "voicevoxInstalled": "Downloaded (%{size})", + "voicevoxInstalledDescription": "When offline, Japanese announcements are read with this voice.", + "voicevoxDownloadFailed": "The download failed. Check your connection and try again.", + "voicevoxDelete": "Delete", + "voicevoxDeleteConfirmTitle": "Delete the voice data?", + "voicevoxDeleteConfirmMessage": "After deletion, the device's built-in voice is used when offline. You can download it again later.", "linesNearbyAtStation": "Lines at %{stationName} Station", "busStopsNearby": "Nearby bus routes", "themeConfirmApply": "Apply", diff --git a/assets/translations/ja.json b/assets/translations/ja.json index f4c989b27..5842b37a4 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -226,6 +226,20 @@ "odptGtfs": "GTFS (公共交通オープンデータセンター)", "odptDisclaimer": "本アプリケーションが利用する公共交通データは、公共交通オープンデータセンターにおいて提供されるものです。公共交通事業者により提供されたデータを元にしていますが、必ずしも正確・完全なものとは限りません。本アプリケーションの表示内容について、公共交通事業者への直接の問い合わせは行わないでください。", "otherOss": "その他オープンソースソフトウェア", + "voicevoxNo7Terms": "No.7 利用規約 (VOICEVOX 音声ライブラリ)", + "later": "あとで", + "download": "ダウンロード", + "voicevoxDownloadTitle": "オフライン用の日本語音声", + "voicevoxDownloadPrompt": "圏外やトンネルで通信できないときに使う日本語の音声データ(約%{size}MB)をダウンロードしますか?通常のアナウンスはこれまでどおりインターネット経由で合成した音声を使います。あとから自動アナウンスの設定画面でもダウンロードできます。", + "voicevoxNotDownloadedDescription": "通信できないときに、端末内で合成した日本語の音声(VOICEVOX:No.7)で読み上げます。ダウンロードには約%{size}MBの通信が発生します。", + "voicevoxDownloadAction": "ダウンロード(約%{size}MB)", + "voicevoxDownloading": "ダウンロード中", + "voicevoxInstalled": "ダウンロード済み(%{size})", + "voicevoxInstalledDescription": "通信できないときは、この音声で日本語のアナウンスを読み上げます。", + "voicevoxDownloadFailed": "ダウンロードに失敗しました。通信環境を確認して再試行してください。", + "voicevoxDelete": "削除", + "voicevoxDeleteConfirmTitle": "音声データを削除しますか?", + "voicevoxDeleteConfirmMessage": "削除すると、通信できないときは端末の読み上げ音声に戻ります。あとから再ダウンロードできます。", "linesNearbyAtStation": "%{stationName}駅の路線", "busStopsNearby": "周辺のバス路線", "themeConfirmApply": "適用", diff --git a/docs/README.md b/docs/README.md index a4a62d02a..bd303c5ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,3 +14,4 @@ - [AI コードレビュー (GPT-5.6 Sol)](./ai-code-review-workflow.md) - [AIエージェント設計書](./spec/ai-agent/architecture.md) - [リモートTTS(iOS)設計書](./spec/tts/remote-tts.md) +- [オンデバイスTTS(VOICEVOX / iOS)設計書](./spec/tts/on-device-tts-ios.md) diff --git a/docs/ios-app-clip.md b/docs/ios-app-clip.md index 6d2ee72d3..962ccdb00 100644 --- a/docs/ios-app-clip.md +++ b/docs/ios-app-clip.md @@ -61,6 +61,16 @@ App Clip ターゲットは Xcode の同期グループ エントリポイントは `AppDelegate.swift` の `@UIApplicationMain` が生成するので、 `main.m` / `AppDelegate.h` / `AppDelegate.m` は置かないこと。 +## VOICEVOX(オンデバイス TTS)は App Clip に含めない + +`ios/Modules/VoicevoxTTS` と `ios/Frameworks/voicevox_*.xcframework` は本体ターゲット +(`ProdTrainLCD` / `CanaryTrainLCD`)にだけ追加してある。App Clip は deployment target +16.4 のため非圧縮バイナリの上限が 15MB で、2 つのフレームワーク(約 18MB)を埋め込めない。 +JS 側は `NativeModules.VoicevoxTTSModule` の有無で判定するため、Clip では従来どおり +「リモート TTS → 端末内蔵 TTS」で読み上げる。Xcode でファイルを追加し直すときも +Clip ターゲットへ入れないこと。詳細は +[オンデバイス TTS (VOICEVOX) 設計書](./spec/tts/on-device-tts-ios.md)。 + ## 起動直後クラッシュの切り分け記録(2026-08-12) PR #6672(白画面修正)と #6675(アーカイブ修正)を取り込んだ canary diff --git a/docs/spec/tts/on-device-tts-ios.md b/docs/spec/tts/on-device-tts-ios.md new file mode 100644 index 000000000..08d249012 --- /dev/null +++ b/docs/spec/tts/on-device-tts-ios.md @@ -0,0 +1,242 @@ +# オンデバイス TTS (VOICEVOX) 設計書 — iOS + +iOS でリモート TTS([リモート TTS 設計書](./remote-tts.md))が使えない回の**日本語**を、 +Apple 内蔵 TTS のコンパクト音声ではなく [VOICEVOX CORE](https://github.com/VOICEVOX/voicevox_core) +で端末内合成して読み上げる。英語は VOICEVOX が話せないため、従来どおり端末内蔵 TTS +(`expo-speech`)で読む。 + +背景: Apple 内蔵 TTS の日本語は既定でコンパクト版が選ばれ、Enhanced / Premium 音声は +ユーザーが設定アプリから手動でダウンロードしない限り使えない(アプリからダウンロードを +起動する API は無い)。リモート TTS が主経路の iOS では、圏外・トンネル・API 障害のときだけ +この機械的な声が突然流れることになり、体験を大きく損なっていた。 + +## 読み上げ経路 + +```text +useTTS + ├─ useRemoteSpeechEngine Worker /tts (Google Cloud TTS) → expo-audio … 主経路 + ├─ useVoicevoxSpeechEngine 日本語: VOICEVOX CORE → WAV → expo-audio … リモート不可時 + │ 英語 : useNativeSpeechEngine へ委譲 + └─ useNativeSpeechEngine 端末内蔵 TTS (expo-speech) で日英とも … 最終フォールバック +``` + +`useVoicevoxSpeechEngine` は次のいずれかを満たさないと `onUnavailable` を返し、その回は +端末内蔵 TTS が日英とも読む。 + +- ネイティブモジュール `VoicevoxTTSModule` がある(iOS 本体アプリのみ。App Clip / Android には無い) +- Remote Config `voicevox_tts_enabled_ios` が `true` +- 辞書と音声モデルの取得・検証が完了している([資産の取得](#資産の取得)) +- Remote Config のスタイル ID が読み込んだ音声モデルに含まれている + +日本語の再生が始まった後の失敗(英語側の失敗を含む)は `onSettled` で終える。途中まで +読んだ放送を頭から読み直さない、という `SpeechEngine` の契約はリモート TTS と同じ。 + +合成器の初期化(VVM の展開)は初回の発話まで遅らせる。リモート TTS が使える通常時は +VOICEVOX を一切メモリへ載せないためで、代わりに初回フォールバック時は合成器の構築分だけ +発話が遅れる。ネイティブ側はメモリ警告を受けると合成器を解放し、次の合成時に同じ設定で +作り直す。 + +### テキスト + +日本語文は端末内蔵 TTS と同じ `toSpeakableText(ssml, 'JA', 'native')` を通す。 +`` の読み(カタカナ)が渡り、`` は「、」になる。VOICEVOX の +Open JTalk がカタカナから読みとアクセントを推定するため、**読みは固定されるがアクセントは +推定任せ**である点は Apple / Google と変わらない。`create_audio_query_from_kana` +(AquesTalk 風記法)はアクセント核の指定が必須で、アプリにも StationAPI にもアクセント情報が +無いため使っていない。 + +### 速度 + +アナウンス速度設定(`ttsSpeedPreferenceAtom`)を `VOICEVOX_SPEED_SCALES` で +`AudioQuery.speedScale` へ写像する。倍率はリモート TTS の `REMOTE_TTS_SPEED_RATES` と同じ。 +ネイティブ側は `voicevox_synthesizer_tts` ではなく `create_audio_query` → `speedScale` +書き換え → `synthesis` の 2 段で合成する。 + +## ネイティブモジュール + +`ios/Modules/VoicevoxTTS/`(Swift + ObjC ブリッジ)。既存の `LiveActivity` などと同じく +pod ではなく Xcode の本体ターゲットへ直接追加してある。 + +| メソッド | 役割 | +| --- | --- | +| `setup({ openJtalkDicDir, voiceModelPaths, cpuNumThreads })` | ONNX Runtime 初期化・辞書読込・合成器生成・VVM 読込。同じ設定なら no-op。読み込んだスタイル ID を返す | +| `synthesize({ text, styleId, speedScale, outputPath })` | WAV(24kHz / 16bit / mono)を `outputPath` へ書く | +| `release()` | 合成器と辞書を解放する | +| `sha256(path)` | ダウンロード検証用。100MB 超の辞書を JS へ読み込まずにハッシュする | +| `setExcludedFromBackup(path)` | 取得した資産を iCloud バックアップから除外する | + +VOICEVOX CORE の C API はスレッドセーフを保証していないため、全操作を 1 本の直列キューで +実行する。iOS 向けリリース物は ONNX Runtime をロード時に動的リンクする +(`VOICEVOX_LINK_ONNXRUNTIME`)ため、`voicevox_onnxruntime.framework` も埋め込む。 + +### フレームワークの取得 + +`ios/Frameworks/voicevox-frameworks.json` に固定したバージョン・URL・SHA-256 のとおり、 +`scripts/fetch-voicevox-frameworks.mjs` が GitHub Releases から zip を取得・検証して +`ios/Frameworks/*.xcframework` へ展開する(展開物は `.gitignore` 済み)。 + +- ローカル: `npm run ios` の前段で自動実行される(`npm run ios:frameworks` 単体でも可) +- CI: `.github/workflows/build_ios_*.yml` の `pod install` 前 + +| フレームワーク | バージョン | 展開後 (arm64) | 最低 iOS | +| --- | --- | --- | --- | +| `voicevox_core.xcframework` | 0.17.0 | 約 2.6MB | 16.2 | +| `voicevox_onnxruntime.xcframework` | 1.23.2 | 約 15.3MB | 16.0 | + +いずれも正規の `.framework` バンドル形式で、App Store 提出時に `.dylib` が弾かれる +(voicevox_core Issue #715)問題は現行の配布物では起きない。バージョンを上げるときは JSON の +`url` と `sha256` を両方更新すること。 + +### App Clip には含めない + +App Clip(`ProdAppClip` / `CanaryAppClip`)は deployment target が 16.4 のため非圧縮 +バイナリの上限が 15MB で、上記 2 フレームワーク(約 18MB)を埋め込めない。そのため +モジュールのソースもフレームワークも本体ターゲットにだけ追加し、App Clip は従来どおり +「リモート TTS → 端末内蔵 TTS」の経路のままにする。JS 側は `NativeModules.VoicevoxTTSModule` +の有無で判定するので、Clip 向けの分岐は要らない。 + +## 資産の取得 + +辞書(Open JTalk)と音声モデル(VVM)はアプリに同梱せず、初回にダウンロードする。 +処理は `src/lib/voicevox/assets.ts`。 + +- 置き場は `Paths.document/voicevox//`。`cache` はストレージ逼迫時に OS が消しうる + ため使わない。iCloud バックアップからはネイティブ側で除外する。 +- マニフェストの `version` が変わったら新しいディレクトリへ取り直し、完了後に旧バージョンを + 削除する。 +- 各ファイルはサイズと SHA-256 で検証する。検証済みの状態は MMKV(`VOICEVOX_ASSETS`)に記録し、 + 次回起動以降はファイルの存在とサイズだけを確認して再ハッシュしない。 +- 取得は同時に 1 本だけ走らせ、失敗後は `VOICEVOX_ASSET_RETRY_INTERVAL_MS` の間再試行しない。 +- 約 160MB の通信になるため、**ユーザーの同意なしには取得しない**([同意と進捗表示](#同意と進捗表示))。 + 同意後は `useVoicevoxSpeechEngine` のマウント(= TTS が有効)と Remote Config の更新を + トリガーに、中断した取得や更新版の取得を自動で再開する。発話時点で未取得なら、その回は + 端末内蔵 TTS で読み、取得は裏で続ける。 + +### 同意と進捗表示 + +設定画面(`src/screens/TTSSettings.tsx`)が担う。 + +- **同意ダイアログ**: 自動アナウンスを有効化した瞬間に、VOICEVOX が使える構成 + (`phase !== 'unsupported'`)で未取得なら「オフライン用の日本語音声(約 160MB)を + ダウンロードしますか?」を出す。既存の注意ダイアログが先に出る場合はキューで続けて表示 + される。「ダウンロード」で `requestVoicevoxAssetsDownload()`(同意を MMKV + `VOICEVOX_DOWNLOAD_CONSENTED` に記録して直ちに取得)、「あとで」なら何もしない。 + 自動アナウンス自体はどちらでも有効になる。 +- **パネル**: 設定画面の速度設定の下に「オフライン用の日本語音声」の枠を出し、状態ごとに + 操作を変える。状態は `useVoicevoxAssetsStatus()`(`getVoicevoxAssetsStatus()` の + `useSyncExternalStore` 版)で購読する。 + + | phase | 表示 | 操作 | + | --- | --- | --- | + | `not_downloaded` | 説明と概算サイズ | ダウンロード | + | `downloading` | 進捗バーと `%`・取得済み / 合計 MB | キャンセル(同意も取り消す。取得済みファイルは残り、次回は続きから) | + | `installed` | 取得済みサイズ | 削除(確認ダイアログ → 合成器を解放してディレクトリごと削除・同意も取り消す) | + | `error` | 失敗の案内 | 再試行(失敗後の待機時間を無視して取得) | + | `unsupported` | 出さない | — | + + 進捗は `File.downloadFileAsync` の `onProgress` をファイル横断で合算し、検証済みで + 飛ばしたファイル分も取得済みに含める。通知は全体の 0.5% ごとに間引き、設定画面の + 再描画を抑える。 + +### マニフェスト + +Remote Config `voicevox_tts_manifest_url_ios` が指す JSON。形式は +`src/lib/voicevox/manifest.ts`(zod スキーマ)が正。`scripts/build-voicevox-manifest.mjs` で +生成する。 + +```bash +set -euo pipefail # 取得・展開・生成のどこかで失敗したら、そこで止める + +# 配信ディレクトリをリポジトリの外に用意する +# (リポジトリ直下の assets/ は画像・フォント置き場なので流用しない) +WORK=~/voicevox-assets +mkdir -p "$WORK/2026-09-08" && cd "$WORK/2026-09-08" +# --fail: HTTP エラーを成功扱いにしない / --retry: 一時的な通信エラーは再試行する +curl -LO --fail --retry 3 https://github.com/r9y9/open_jtalk/releases/download/v1.11.1/open_jtalk_dic_utf_8-1.11.tar.gz +tar xzf open_jtalk_dic_utf_8-1.11.tar.gz && rm open_jtalk_dic_utf_8-1.11.tar.gz +curl -LO --fail --retry 3 https://github.com/VOICEVOX/voicevox_vvm/releases/download/0.16.4/6.vvm +cd - # リポジトリへ戻る + +# マニフェストを生成する (version は省略時に今日の日付)。 +# 出力先は資産ディレクトリの外にする (中に書くと manifest.json 自身が配信対象に混ざる) +node scripts/build-voicevox-manifest.mjs "$WORK/2026-09-08" https://example.invalid/voicevox/2026-09-08 2026-09-08 > "$WORK/manifest.json" +# 生成に失敗するとリダイレクト先に空ファイルだけが残るので、中身があることを確かめる +test -s "$WORK/manifest.json" +``` + +できあがる `$WORK` は次の構成で、これをそのまま配信先(例では `https://example.invalid/voicevox/`)へ置く。 + +```text +voicevox/ +├── manifest.json +└── 2026-09-08/ … base-url に対応するディレクトリ + ├── 6.vvm … VOICEVOX:No.7 (style 29/30/31) + └── open_jtalk_dic_utf_8-1.11/ + ├── sys.dic unk.dic char.bin matrix.bin + ├── left-id.def right-id.def pos-id.def rewrite.def + └── COPYING +``` + +資産は `base-url` 配下に同じ相対パスで置く必要があるが、`manifest.json` は `base-url` の外でもよい。 +ただし Remote Config `voicevox_tts_manifest_url_ios` に入れる URL は **https 必須** +(`http://` はアプリ側の検証で弾かれ、資産を取得しない)。取得は認証なしの GET なので、 +https で公開できる静的ホスティングであれば何でもよい。ファイルサイズの目安は辞書が 107MB(`sys.dic` が 103MB)、 +VVM が 55〜63MB。 + +配信先が Cloudflare R2(バケット `trainlcd-assets`、独自ドメイン `assets.trainlcd.app`)なら、 +バケット作成・ドメイン紐付け・アップロード・マニフェスト生成と配置・到達確認を +`scripts/publish-voicevox-assets.mjs` が一括で行う。Cloudflare API を `curl` で直接呼ぶだけなので +wrangler のインストールは不要で、何度実行しても同じ結果になる。 + +```bash +# 手元の Mac で実行する場合。トークンはシェルの環境変数で渡す +export CLOUDFLARE_ACCOUNT_ID=… CLOUDFLARE_ZONE_ID=… CLOUDFLARE_API_TOKEN=… +node scripts/publish-voicevox-assets.mjs "$WORK/2026-09-08" 2026-09-08 +# → Remote Config: voicevox_tts_manifest_url_ios = https://assets.trainlcd.app/voicevox/manifest.json +``` + +トークンに必要な権限は「Workers R2 Storage: Edit」と、`trainlcd.app` ゾーンの「DNS: Edit」 +(独自ドメインの紐付けが DNS レコードを作る)だけでよい。作業後は失効させて構わない。 +Claude Code のクラウド環境から実行する場合は、トークンを環境変数に入れず、環境設定の +「API credentials」に `api.cloudflare.com` 向けの Bearer トークンとして登録する(プロキシが +Authorization ヘッダーを付けるので、セッションからはトークンが見えない)。アカウント ID と +ゾーン ID は秘密ではないので環境変数でよい。 +マニフェストは `voicevox/manifest.json` の固定 URL に 5 分キャッシュで置くので、資産を差し替えても +Remote Config の値は変えなくてよい。 + +資産を差し替えるときは新しい `version`(別ディレクトリ)で同じ手順を繰り返す。 +アプリは `version` の変化で全ファイルを取り直し、旧ディレクトリを消す。 + +## Remote Config + +| キー | 型 | フォールバック | 役割 | +| --- | --- | --- | --- | +| `voicevox_tts_enabled_ios` | boolean | `false` | VOICEVOX フォールバックの有効化 | +| `voicevox_tts_manifest_url_ios` | string (https) | 未設定 | マニフェスト JSON の URL。未設定なら有効でも資産を取得できない | +| `voicevox_tts_style_id_ios` | integer ≥ 0 | `30` | スタイル ID。配信した VVM に含まれる ID を指定する | + +`remote_tts_enabled_ios` とは独立している。リモート合成を主経路のまま「フォールバック先だけ」を +差し替える設計で、`voicevox_tts_enabled_ios` を `false` にすれば取得済みの資産があっても +端末内蔵 TTS へ戻る。 + +## 音声とクレジット + +既定は **No.7「アナウンス」**(`6.vvm` / スタイル ID 30)。No.7 の規約は個人の非商用利用を +クレジット表記のみで許諾している(商用利用は事前確認が必要)。本アプリは課金・広告を持たない +個人開発のため非商用利用に当たるが、**課金や広告を将来入れる場合は再確認が必要**。 + +VOICEVOX 音声モデルの利用規約([voicevox_vvm README](https://github.com/VOICEVOX/voicevox_vvm))は +「VOICEVOX を利用したことがわかるクレジット表記」を求める。ライセンス画面 +(`src/screens/Licenses.tsx`)に `VOICEVOX:No.7` を iOS でのみ表示している。スタイル ID を +別キャラクターへ変えるときは、そのキャラクターの規約とクレジット表記も合わせて変えること。 + +## 未計測の項目 + +この実装は Linux 上で作成しており、iOS 実機での動作確認・計測は未実施。取り込み前に +次を確認すること。 + +- Xcode でのビルド(`pod install` 後にアーカイブが通るか。フレームワークの埋め込みと署名) +- 初回フォールバック時の発話開始までの時間(合成器構築 + 合成)と 2 回目以降の合成時間 +- 位置情報・Live Activity と同居した背景実行中のメモリ(合成器常駐時)と発熱 +- カタカナ入力時のアクセント。漢字を渡した方が自然なら `toSpeakableText` の + VOICEVOX 向け分岐を検討する diff --git a/docs/spec/tts/remote-tts.md b/docs/spec/tts/remote-tts.md index b8858423c..3185f0576 100644 --- a/docs/spec/tts/remote-tts.md +++ b/docs/spec/tts/remote-tts.md @@ -13,22 +13,30 @@ 課金が発生するため、`remote_tts_enabled_android` を `true` で配信したときにだけ `/tts` を 参照する。 -リモート合成に失敗した回は、その放送だけ端末内蔵 TTS で読み上げる。圏外・トンネル・ +リモート合成に失敗した回は、その放送だけ端末内で読み上げる。圏外・トンネル・ API 障害でアナウンスが丸ごと欠落しないようにするためのフォールバックで、恒久的な -切り替えではない(次の放送では再びリモート合成を試みる)。 +切り替えではない(次の放送では再びリモート合成を試みる)。iOS 本体アプリでは、この +フォールバックの日本語を VOICEVOX(端末内合成)で読ませることができる。詳細は +[オンデバイス TTS (VOICEVOX) 設計書](./on-device-tts-ios.md)。 ## 構成 ```text useTTS ────────────────── 放送タイミング・抑止判定・音声セッション・保留キュー - ├─ useRemoteSpeechEngine /tts へ合成要求 → expo-audio で再生 - └─ useNativeSpeechEngine リモートを使わない構成の常用経路 / リモートのフォールバック + ├─ useRemoteSpeechEngine /tts へ合成要求 → expo-audio で再生 + ├─ useVoicevoxSpeechEngine iOS 本体アプリのフォールバック (日本語のみ VOICEVOX、英語は委譲) + └─ useNativeSpeechEngine リモートを使わない構成の常用経路 / 最終フォールバック ``` エンジンの選択は放送直前に `isRemoteTTSEnabled()`(`src/lib/remoteConfig.ts`)を引いて 決めるため、起動後に Remote Config が届いた場合も次の放送から反映される。 +`isRemoteTTSEnabled()` が決めるのは「リモート合成を試すか、最初から端末内で読むか」だけで、 +端末内で読む側はまず `useVoicevoxSpeechEngine` を試し、VOICEVOX が使える条件(iOS 本体アプリの +ネイティブモジュール・`voicevox_tts_enabled_ios`・検証済みの資産・音声モデル内のスタイル ID)が +そろわなければ `useNativeSpeechEngine` へ倒れる。条件の詳細は +[オンデバイス TTS (VOICEVOX) 設計書](./on-device-tts-ios.md) を参照。 -両エンジンは `SpeechEngine` (`src/hooks/tts/speechEngine.ts`) を実装する。 +各エンジンは `SpeechEngine` (`src/hooks/tts/speechEngine.ts`) を実装する。 - `speak(request, callbacks)` — `onSettled` か `onUnavailable` のどちらかが ちょうど 1 回だけ呼ばれる。 @@ -212,7 +220,8 @@ Remote Config のキーは 2 系統あり、役割が異なる。 (`useTTSFeatureEnabled`)。`false` のときは読み上げを行わず、設定画面のトグルも 無効化する。フォールバックは `true`(提供する)。 - `remote_tts_enabled_ios` / `remote_tts_enabled_android` — 読み上げエンジンの選択 - (`isRemoteTTSEnabled`)。`true` でリモート合成、`false` で端末内蔵 TTS。 + (`isRemoteTTSEnabled`)。`true` でリモート合成、`false` で端末内合成 + (iOS 本体アプリは VOICEVOX を試してから端末内蔵 TTS、それ以外は端末内蔵 TTS)。 フォールバックは iOS が `true`、Android が `false`。 両者は独立しているため、次のような運用ができる。 @@ -220,8 +229,8 @@ Remote Config のキーは 2 系統あり、役割が異なる。 - **Android でもリモート合成を使う**: `remote_tts_enabled_android` を `true` にする。 段階的に開放したい場合はこのキーだけで切り戻せる。 - **リモート合成のコスト・障害から退避する**: `remote_tts_enabled_*` を `false` にすると、 - TTS 機能は維持したまま端末内蔵 TTS へ倒れる。読み上げごと止めたい場合のみ - `tts_enabled_*` を `false` にする。 + TTS 機能は維持したまま端末内合成(VOICEVOX → 端末内蔵 TTS の順)へ倒れる。 + 読み上げごと止めたい場合のみ `tts_enabled_*` を `false` にする。 iOS / Android 以外(web など)はリモート再生経路を持たないため、`isRemoteTTSEnabled()` は常に `false` を返す。 diff --git a/ios/Frameworks/voicevox-frameworks.json b/ios/Frameworks/voicevox-frameworks.json new file mode 100644 index 000000000..08d8a178e --- /dev/null +++ b/ios/Frameworks/voicevox-frameworks.json @@ -0,0 +1,17 @@ +{ + "//": "iOS 本体アプリにだけ埋め込む VOICEVOX CORE の動的フレームワーク。scripts/fetch-voicevox-frameworks.mjs がこの定義どおりに取得・検証し、同じディレクトリへ展開する(展開物は .gitignore 済み)。バージョンを上げるときは url と sha256 を両方更新すること。", + "frameworks": [ + { + "name": "voicevox_core", + "version": "0.17.0", + "url": "https://github.com/VOICEVOX/voicevox_core/releases/download/0.17.0/voicevox_core-xcframework-0.17.0.zip", + "sha256": "e634b0fd7e09924a4c9c4f6e8b5c0da0790a7649d89f289cbcad1b97976212be" + }, + { + "name": "voicevox_onnxruntime", + "version": "1.23.2", + "url": "https://github.com/VOICEVOX/onnxruntime-builder/releases/download/voicevox_onnxruntime-1.23.2/voicevox_onnxruntime-ios-xcframework-1.23.2.zip", + "sha256": "c9c33b2653125441d6a2475194ed7971a908687c0d99d8944337b0ca85ea646d" + } + ] +} diff --git a/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift b/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift new file mode 100644 index 000000000..a08a58462 --- /dev/null +++ b/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift @@ -0,0 +1,417 @@ +import CryptoKit +import Foundation +import UIKit +import voicevox_core + +// VOICEVOX CORE (https://github.com/VOICEVOX/voicevox_core) で日本語アナウンスを +// 端末内で合成する React Native モジュール。 +// +// - iOS 向けのリリース物は ONNX Runtime をロード時に動的リンクする +// (`VOICEVOX_LINK_ONNXRUNTIME`) ため、voicevox_onnxruntime.framework も +// 一緒に埋め込む。初期化は `voicevox_onnxruntime_init_once` で行う。 +// - Open JTalk 辞書と音声モデル (VVM) はアプリに同梱せず、JS 側 +// (src/lib/voicevox/assets.ts) が初回利用時にダウンロードしたパスを `setup` で受け取る。 +// - VOICEVOX CORE の C API はスレッドセーフを保証していないため、全操作を +// 1 本の直列キューで実行する。合成は数秒かかることがあるので JS スレッドや +// メインスレッドでは決して実行しない。 +// - 合成器は VVM を展開したまま保持するため常駐メモリが大きい。メモリ警告を +// 受けたら解放し、次の合成時に同じ設定で作り直す。 +@objc(VoicevoxTTSModule) +final class VoicevoxTTSModule: NSObject { + private struct Config: Equatable { + let openJtalkDicDir: String + let voiceModelPaths: [String] + let cpuNumThreads: UInt16 + } + + private let queue = DispatchQueue( + label: "me.tinykitten.trainlcd.voicevox", qos: .userInitiated) + // queue 上で実行中かを判定するためのキー。queue.async のクロージャが self の最後の + // 所有者になると deinit が queue 上で走るため、そこで queue.sync するとデッドロックする + private let queueSpecificKey = DispatchSpecificKey() + + // 以下は queue 上でのみ触る + private var config: Config? + private var openJtalk: OpaquePointer? + private var synthesizer: OpaquePointer? + private var loadedStyleIds: [UInt32] = [] + + private var memoryWarningObserver: NSObjectProtocol? + + override init() { + super.init() + queue.setSpecific(key: queueSpecificKey, value: true) + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self = self else { return } + self.queue.async { + // 設定は残し、次回の synthesize で再構築する + self.teardownSynthesizer() + } + } + } + + deinit { + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } + if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { + teardownSynthesizer() + } else { + queue.sync { + teardownSynthesizer() + } + } + } + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + // MARK: - Errors + + private enum VoicevoxError: Error, CustomStringConvertible { + case invalidArgument(String) + case core(String, VoicevoxResultCode) + case notInitialized + case io(String) + + var description: String { + switch self { + case .invalidArgument(let message): + return message + case .core(let operation, let code): + let message = String(cString: voicevox_error_result_to_message(code)) + return "\(operation) failed: \(message) (code \(code))" + case .notInitialized: + return "synthesizer is not initialized" + case .io(let message): + return message + } + } + + var code: String { + switch self { + case .invalidArgument: return "invalid_argument" + case .core: return "voicevox_core_error" + case .notInitialized: return "not_initialized" + case .io: return "io_error" + } + } + } + + // C API の戻り値 VoicevoxResultCode は Swift では Int32 の typealias として + // 取り込まれ、VOICEVOX_RESULT_OK などの定数は rawValue を持つ列挙として取り込まれる + // (cbindgen 生成ヘッダーの `enum X {…}; typedef int32_t X;` パターン)。 + private static let resultOK = VoicevoxResultCode(VOICEVOX_RESULT_OK.rawValue) + + private func check(_ result: VoicevoxResultCode, _ operation: String) throws { + if result != Self.resultOK { + throw VoicevoxError.core(operation, result) + } + } + + private func rejectWithError(_ reject: RCTPromiseRejectBlock, _ error: Error) { + if let error = error as? VoicevoxError { + reject(error.code, error.description, nil) + } else { + reject("voicevox_error", error.localizedDescription, error) + } + } + + // MARK: - Lifecycle (queue 上で呼ぶ) + + private func teardownSynthesizer() { + if let synthesizer = synthesizer { + voicevox_synthesizer_delete(synthesizer) + self.synthesizer = nil + } + if let openJtalk = openJtalk { + voicevox_open_jtalk_rc_delete(openJtalk) + self.openJtalk = nil + } + loadedStyleIds = [] + } + + private func buildSynthesizer(_ config: Config) throws { + teardownSynthesizer() + + var onnxruntime: OpaquePointer? + try check(voicevox_onnxruntime_init_once(&onnxruntime), "voicevox_onnxruntime_init_once") + + var openJtalk: OpaquePointer? + try check( + voicevox_open_jtalk_rc_new(config.openJtalkDicDir, &openJtalk), + "voicevox_open_jtalk_rc_new") + self.openJtalk = openJtalk + + var options = voicevox_make_default_initialize_options() + options.acceleration_mode = VoicevoxAccelerationMode( + VOICEVOX_ACCELERATION_MODE_CPU.rawValue) + options.cpu_num_threads = config.cpuNumThreads + + var synthesizer: OpaquePointer? + do { + try check( + voicevox_synthesizer_new(onnxruntime, openJtalk, options, &synthesizer), + "voicevox_synthesizer_new") + } catch { + teardownSynthesizer() + throw error + } + self.synthesizer = synthesizer + + var styleIds: [UInt32] = [] + for path in config.voiceModelPaths { + var model: OpaquePointer? + do { + try check( + voicevox_voice_model_file_open(path, &model), "voicevox_voice_model_file_open") + } catch { + // 失敗した合成器を残すと ensureSynthesizer が再構築を省略してしまう + teardownSynthesizer() + throw error + } + defer { + if let model = model { + voicevox_voice_model_file_delete(model) + } + } + let loadOptions = voicevox_make_default_load_voice_model_options() + do { + try check( + voicevox_synthesizer_load_voice_model(synthesizer, model, loadOptions), + "voicevox_synthesizer_load_voice_model") + } catch { + teardownSynthesizer() + throw error + } + if let model = model { + styleIds.append(contentsOf: Self.styleIds(ofModel: model)) + } + } + loadedStyleIds = styleIds + self.config = config + } + + // VVM のメタ情報 JSON からスタイル ID を取り出す。JS 側が Remote Config で + // 指定したスタイルがモデルに含まれているかを検証できるようにするため。 + private static func styleIds(ofModel model: OpaquePointer) -> [UInt32] { + guard let json = voicevox_voice_model_file_create_metas_json(model) else { + return [] + } + defer { voicevox_json_free(json) } + let data = Data(bytes: json, count: strlen(json)) + guard + let speakers = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + var ids: [UInt32] = [] + for speaker in speakers { + guard let styles = speaker["styles"] as? [[String: Any]] else { continue } + for style in styles { + if let id = style["id"] as? UInt32 { + ids.append(id) + } else if let id = style["id"] as? Int, id >= 0 { + ids.append(UInt32(id)) + } + } + } + return ids + } + + private func ensureSynthesizer() throws { + if synthesizer != nil { + return + } + guard let config = config else { + throw VoicevoxError.notInitialized + } + try buildSynthesizer(config) + } + + // MARK: - JS API + + // 辞書ディレクトリと VVM のパスを受け取り合成器を初期化する。同じ設定で既に + // 初期化済みなら何もしない。設定が変わっていれば作り直す。 + @objc(setup:resolver:rejecter:) + func setup( + _ options: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let dicDir = options["openJtalkDicDir"] as? String, !dicDir.isEmpty else { + self.rejectWithError(reject, VoicevoxError.invalidArgument("openJtalkDicDir is required")) + return + } + guard let modelPaths = options["voiceModelPaths"] as? [String], !modelPaths.isEmpty + else { + self.rejectWithError(reject, VoicevoxError.invalidArgument("voiceModelPaths is required")) + return + } + let threads = (options["cpuNumThreads"] as? NSNumber)?.uint16Value ?? 0 + let config = Config( + openJtalkDicDir: dicDir, voiceModelPaths: modelPaths, cpuNumThreads: threads) + + queue.async { + do { + if self.synthesizer == nil || self.config != config { + try self.buildSynthesizer(config) + } + resolve([ + "coreVersion": String(cString: voicevox_get_version()), + "styleIds": self.loadedStyleIds.map { NSNumber(value: $0) }, + ]) + } catch { + self.rejectWithError(reject, error) + } + } + } + + // テキストを合成し WAV ファイル (24kHz / 16bit / mono) を outputPath へ書き出す。 + // speedScale は VOICEVOX の AudioQuery.speedScale (1.0 が標準)。 + @objc(synthesize:resolver:rejecter:) + func synthesize( + _ options: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let text = options["text"] as? String, !text.isEmpty else { + self.rejectWithError(reject, VoicevoxError.invalidArgument("text is required")) + return + } + guard let styleId = (options["styleId"] as? NSNumber)?.uint32Value else { + self.rejectWithError(reject, VoicevoxError.invalidArgument("styleId is required")) + return + } + guard let outputPath = options["outputPath"] as? String, !outputPath.isEmpty else { + self.rejectWithError(reject, VoicevoxError.invalidArgument("outputPath is required")) + return + } + let speedScale = (options["speedScale"] as? NSNumber)?.doubleValue ?? 1.0 + + queue.async { + do { + try self.ensureSynthesizer() + guard let synthesizer = self.synthesizer else { + throw VoicevoxError.notInitialized + } + + // 速度を変えるため tts ではなく audio_query → synthesis の 2 段で合成する + var queryJson: UnsafeMutablePointer? + try self.check( + voicevox_synthesizer_create_audio_query(synthesizer, text, styleId, &queryJson), + "voicevox_synthesizer_create_audio_query") + guard let queryJson = queryJson else { + throw VoicevoxError.io("audio query was not returned") + } + defer { voicevox_json_free(queryJson) } + + let query = try Self.applySpeedScale( + Data(bytes: queryJson, count: strlen(queryJson)), speedScale: speedScale) + + // JSONSerialization の出力は UTF-8 なので Swift の String に戻し、C 文字列として渡す + let queryString = String(decoding: query, as: UTF8.self) + var wavLength: UInt = 0 + var wav: UnsafeMutablePointer? + let synthesisOptions = voicevox_make_default_synthesis_options() + try self.check( + voicevox_synthesizer_synthesis( + synthesizer, queryString, styleId, synthesisOptions, &wavLength, &wav), + "voicevox_synthesizer_synthesis") + guard let wav = wav else { + throw VoicevoxError.io("wav buffer was not returned") + } + defer { voicevox_wav_free(wav) } + + let data = Data(bytes: wav, count: Int(wavLength)) + let url = URL(fileURLWithPath: outputPath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + + resolve([ + "path": outputPath, + "bytes": NSNumber(value: data.count), + ]) + } catch { + self.rejectWithError(reject, error) + } + } + } + + // AudioQuery JSON の speedScale だけを書き換える。AudioQuery の他フィールドは + // VOICEVOX CORE が生成したものをそのまま返す。 + private static func applySpeedScale(_ json: Data, speedScale: Double) throws -> Data { + guard var query = try JSONSerialization.jsonObject(with: json) as? [String: Any] else { + throw VoicevoxError.io("audio query is not a JSON object") + } + // 極端な値で合成が破綻しないよう VOICEVOX エディタと同じ範囲へ丸める + query["speedScale"] = min(max(speedScale, 0.5), 2.0) + return try JSONSerialization.data(withJSONObject: query) + } + + // 合成器と辞書を解放する。設定も破棄するので、次に使うときは setup からやり直す。 + @objc(release:rejecter:) + func release( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + queue.async { + self.teardownSynthesizer() + self.config = nil + resolve(nil) + } + } + + // ダウンロードした辞書・VVM の完全性検証用。100MB 超のファイルを JS へ読み込まずに + // ストリーミングでハッシュする。 + @objc(sha256:resolver:rejecter:) + func sha256( + _ path: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + DispatchQueue.global(qos: .utility).async { + do { + let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? handle.close() } + var hasher = SHA256() + while true { + let chunk = try handle.read(upToCount: 1 << 20) ?? Data() + if chunk.isEmpty { + break + } + hasher.update(data: chunk) + } + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + resolve(digest) + } catch { + reject("io_error", "failed to hash \(path): \(error.localizedDescription)", error) + } + } + } + + // ダウンロードした辞書・VVM は再取得できるので iCloud バックアップから除外する + @objc(setExcludedFromBackup:resolver:rejecter:) + func setExcludedFromBackup( + _ path: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + do { + var url = URL(fileURLWithPath: path) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) + resolve(nil) + } catch { + reject("io_error", "failed to exclude \(path) from backup: \(error.localizedDescription)", error) + } + } +} diff --git a/ios/Modules/VoicevoxTTS/VoicevoxTTSModuleBridge.m b/ios/Modules/VoicevoxTTS/VoicevoxTTSModuleBridge.m new file mode 100644 index 000000000..5daaf64fb --- /dev/null +++ b/ios/Modules/VoicevoxTTS/VoicevoxTTSModuleBridge.m @@ -0,0 +1,39 @@ +#import + +// VOICEVOX CORE で日本語アナウンスを端末内で合成するモジュール。 +// 本体アプリ (ProdTrainLCD / CanaryTrainLCD) のターゲットにだけ含める。 +// App Clip は非圧縮 15MB の上限があり、依存する xcframework (約 18MB) を +// 埋め込めないため、App Clip ターゲットにはこのモジュールもフレームワークも追加しない。 +// JS 側は NativeModules.VoicevoxTTSModule の有無で利用可否を判定する。 +@interface RCT_EXTERN_MODULE(VoicevoxTTSModule, NSObject) + +RCT_EXTERN_METHOD(setup: + (NSDictionary *) options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject + ) + +RCT_EXTERN_METHOD(synthesize: + (NSDictionary *) options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject + ) + +RCT_EXTERN_METHOD(release: + (RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject + ) + +RCT_EXTERN_METHOD(sha256: + (NSString *) path + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject + ) + +RCT_EXTERN_METHOD(setExcludedFromBackup: + (NSString *) path + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject + ) + +@end diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index c700b864a..238d44a3f 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -175,6 +175,18 @@ E17316B63D0F7567C6320AB8 /* Pods_TrainLCD_ProdTrainLCD_ProdAppClip.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18AD948BDA8B985185BF581E /* Pods_TrainLCD_ProdTrainLCD_ProdAppClip.framework */; }; F0FA9708AB0E5181C6C5B9AC /* Pods_TrainLCD_CanaryTrainLCD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0010B0125192EABD3451C0 /* Pods_TrainLCD_CanaryTrainLCD.framework */; }; F4B63ECCFF1E13D682E1521B /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB98D2CD9F0AA772720FE54 /* ExpoModulesProvider.swift */; }; + 034E18274969DF7BE2882EB8 /* voicevox_core.xcframework in Embed VOICEVOX Frameworks */ = {isa = PBXBuildFile; fileRef = 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 0FF39F5D0A49E41EB1EA32A5 /* voicevox_core.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */; }; + 2BED474610B48FAB66889596 /* VoicevoxTTSModuleBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 16E59D17B873F7E1EE2CC414 /* VoicevoxTTSModuleBridge.m */; }; + 5854D94B7C45A24B772F4C26 /* voicevox_onnxruntime.xcframework in Embed VOICEVOX Frameworks */ = {isa = PBXBuildFile; fileRef = C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 773F187C31E9F7D99FD20229 /* voicevox_onnxruntime.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */; }; + 7BAD79A2AC0CE13A0DDAEC7D /* voicevox_onnxruntime.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */; }; + 84AD78F7990C555F08E40A8F /* VoicevoxTTSModuleBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 16E59D17B873F7E1EE2CC414 /* VoicevoxTTSModuleBridge.m */; }; + 8D91FEA5C8557E145B38FCAB /* VoicevoxTTSModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7A8A674239EDADCFF81C01 /* VoicevoxTTSModule.swift */; }; + 909BE60BF04DB25F49754147 /* voicevox_onnxruntime.xcframework in Embed VOICEVOX Frameworks */ = {isa = PBXBuildFile; fileRef = C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A137FA0A4654D23ABD7CB0CB /* voicevox_core.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */; }; + BF2246691D825AB3690F41A3 /* VoicevoxTTSModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7A8A674239EDADCFF81C01 /* VoicevoxTTSModule.swift */; }; + F2FB547263AC27839E2BA748 /* voicevox_core.xcframework in Embed VOICEVOX Frameworks */ = {isa = PBXBuildFile; fileRef = 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -361,6 +373,30 @@ name = "Embed App Clips"; runOnlyForDeploymentPostprocessing = 0; }; + 56DF8F6A854B1D6F2EDA35E6 /* Embed VOICEVOX Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 034E18274969DF7BE2882EB8 /* voicevox_core.xcframework in Embed VOICEVOX Frameworks */, + 909BE60BF04DB25F49754147 /* voicevox_onnxruntime.xcframework in Embed VOICEVOX Frameworks */, + ); + name = "Embed VOICEVOX Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + EC086E96C00B7F575BA7DFE8 /* Embed VOICEVOX Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + F2FB547263AC27839E2BA748 /* voicevox_core.xcframework in Embed VOICEVOX Frameworks */, + 5854D94B7C45A24B772F4C26 /* voicevox_onnxruntime.xcframework in Embed VOICEVOX Frameworks */, + ); + name = "Embed VOICEVOX Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -477,6 +513,10 @@ ED55A77FA788DF3A9157FF17 /* Pods-TrainLCD-CanaryTrainLCD.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TrainLCD-CanaryTrainLCD.debug.xcconfig"; path = "Target Support Files/Pods-TrainLCD-CanaryTrainLCD/Pods-TrainLCD-CanaryTrainLCD.debug.xcconfig"; sourceTree = ""; }; F6451AE21C3A0B2B656DFA0A /* Pods-TrainLCD-CanaryTrainLCD-CanaryAppClip.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TrainLCD-CanaryTrainLCD-CanaryAppClip.release.xcconfig"; path = "Target Support Files/Pods-TrainLCD-CanaryTrainLCD-CanaryAppClip/Pods-TrainLCD-CanaryTrainLCD-CanaryAppClip.release.xcconfig"; sourceTree = ""; }; FE868CC1D7400801A4A21956 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-CanaryAppClip/ExpoModulesProvider.swift"; sourceTree = ""; }; + 16E59D17B873F7E1EE2CC414 /* VoicevoxTTSModuleBridge.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VoicevoxTTSModuleBridge.m; path = Modules/VoicevoxTTS/VoicevoxTTSModuleBridge.m; sourceTree = SOURCE_ROOT; }; + 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = voicevox_core.xcframework; path = Frameworks/voicevox_core.xcframework; sourceTree = SOURCE_ROOT; }; + 8B7A8A674239EDADCFF81C01 /* VoicevoxTTSModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VoicevoxTTSModule.swift; path = Modules/VoicevoxTTS/VoicevoxTTSModule.swift; sourceTree = SOURCE_ROOT; }; + C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = voicevox_onnxruntime.xcframework; path = Frameworks/voicevox_onnxruntime.xcframework; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -529,6 +569,8 @@ buildActionMask = 2147483647; files = ( 2667437868F4ECAB42F4D2D5 /* Pods_TrainLCD_ProdTrainLCD.framework in Frameworks */, + A137FA0A4654D23ABD7CB0CB /* voicevox_core.xcframework in Frameworks */, + 7BAD79A2AC0CE13A0DDAEC7D /* voicevox_onnxruntime.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -537,6 +579,8 @@ buildActionMask = 2147483647; files = ( F0FA9708AB0E5181C6C5B9AC /* Pods_TrainLCD_CanaryTrainLCD.framework in Frameworks */, + 0FF39F5D0A49E41EB1EA32A5 /* voicevox_core.xcframework in Frameworks */, + 773F187C31E9F7D99FD20229 /* voicevox_onnxruntime.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -682,6 +726,8 @@ DDBDDB801B0581BB480C71FA /* Pods_TrainLCD_CanaryTrainLCD_CanaryAppClip.framework */, 2A1E102603F9A5547B14502E /* Pods_TrainLCD_ProdTrainLCD.framework */, 18AD948BDA8B985185BF581E /* Pods_TrainLCD_ProdTrainLCD_ProdAppClip.framework */, + 66444D136EDFC2C78EAC0E6B /* voicevox_core.xcframework */, + C65F93A55BA37FD75F5BD2A6 /* voicevox_onnxruntime.xcframework */, ); name = Frameworks; sourceTree = ""; @@ -864,6 +910,7 @@ 94C1657C29B99DC2007329A6 /* LiveActivity */, 94D3F4792E40A00800000000 /* PresetsWidget */, 94C1657B29B99D9F007329A6 /* SensitiveNotification */, + C8F56C4760CAD93134A68299 /* VoicevoxTTS */, ); name = Modules; sourceTree = ""; @@ -963,6 +1010,15 @@ name = ProdTrainLCD; sourceTree = ""; }; + C8F56C4760CAD93134A68299 /* VoicevoxTTS */ = { + isa = PBXGroup; + children = ( + 8B7A8A674239EDADCFF81C01 /* VoicevoxTTSModule.swift */, + 16E59D17B873F7E1EE2CC414 /* VoicevoxTTSModuleBridge.m */, + ); + path = VoicevoxTTS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -975,6 +1031,7 @@ 73378AEDEDDF9B3778E232F0 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, + EC086E96C00B7F575BA7DFE8 /* Embed VOICEVOX Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 94CB9CE22588EC950030FBE2 /* Embed Watch Content */, @@ -1005,6 +1062,7 @@ CB5CF8F4812BA85EC2C7AFDC /* [Expo] Configure project */, 942CB5B72A32AA96008339D2 /* Sources */, 942CB5C12A32AA96008339D2 /* Frameworks */, + 56DF8F6A854B1D6F2EDA35E6 /* Embed VOICEVOX Frameworks */, 942CB5C32A32AA96008339D2 /* Resources */, 942CB5CE2A32AA96008339D2 /* Bundle React Native code and images */, 942CB5D02A32AA96008339D2 /* Embed Watch Content */, @@ -2219,6 +2277,8 @@ 94D3F4662E40A00300000001 /* OpenTrainLCDIntent.swift in Sources */, 94C1658629B99E0A007329A6 /* SensitiveNotificationModuleBridge.m in Sources */, 94C1658029B99DEB007329A6 /* LiveActivityModuleBridge.m in Sources */, + 8D91FEA5C8557E145B38FCAB /* VoicevoxTTSModule.swift in Sources */, + 2BED474610B48FAB66889596 /* VoicevoxTTSModuleBridge.m in Sources */, 94D3F4742E40A00600000001 /* PresetsWidgetModule.swift in Sources */, 94D3F4772E40A00700000001 /* PresetsWidgetModuleBridge.m in Sources */, 9DB892EB978E1EA030B325CF /* ExpoModulesProvider.swift in Sources */, @@ -2236,6 +2296,8 @@ 94D3F4672E40A00300000002 /* OpenTrainLCDIntent.swift in Sources */, 942CB5BF2A32AA96008339D2 /* SensitiveNotificationModuleBridge.m in Sources */, 942CB5C02A32AA96008339D2 /* LiveActivityModuleBridge.m in Sources */, + BF2246691D825AB3690F41A3 /* VoicevoxTTSModule.swift in Sources */, + 84AD78F7990C555F08E40A8F /* VoicevoxTTSModuleBridge.m in Sources */, 94D3F4752E40A00600000002 /* PresetsWidgetModule.swift in Sources */, 94D3F4782E40A00700000002 /* PresetsWidgetModuleBridge.m in Sources */, F4B63ECCFF1E13D682E1521B /* ExpoModulesProvider.swift in Sources */, diff --git a/package.json b/package.json index 0d89bbb3f..4a8b98812 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "scripts": { "start": "expo start", "android": "cross-env APP_VARIANT=dev expo run:android -- --variant devDebug --app-id me.tinykitten.trainlcd.dev", - "ios": "cross-env APP_VARIANT=dev expo run:ios", + "ios": "npm run ios:frameworks && cross-env APP_VARIANT=dev expo run:ios", + "ios:frameworks": "node scripts/fetch-voicevox-frameworks.mjs", "web": "expo start --web", "lint": "biome check ./src", "format": "biome format ./src --write", diff --git a/scripts/build-voicevox-manifest.mjs b/scripts/build-voicevox-manifest.mjs new file mode 100644 index 000000000..822e90d2a --- /dev/null +++ b/scripts/build-voicevox-manifest.mjs @@ -0,0 +1,90 @@ +// VOICEVOX の辞書・音声モデルを配信するためのマニフェスト JSON を生成する。 +// +// 使い方: +// node scripts/build-voicevox-manifest.mjs [version] > manifest.json +// +// : 配信するファイルを配置したディレクトリ。次の構成を想定する。 +// /open_jtalk_dic_utf_8-1.11/ … Open JTalk 辞書 (展開済み) +// /6.vvm … 音声モデル (VVM) +// : 各ファイルを配信する URL の接頭辞 (https://…/voicevox/) +// [version] : 資産セットの識別子。省略時は今日の日付 (YYYY-MM-DD) +// +// 生成物の形式は src/lib/voicevox/manifest.ts を参照。ファイルは と +// 同じ相対パスで 配下へアップロードし、manifest.json の URL を +// Remote Config の voicevox_tts_manifest_url_ios で配信する。 +// +// 辞書は https://github.com/r9y9/open_jtalk/releases (open_jtalk_dic_utf_8-1.11.tar.gz)、 +// VVM は https://github.com/VOICEVOX/voicevox_vvm/releases から取得する。 +// VVM の利用規約 (クレジット表記など) は voicevox_vvm の README を参照。 +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const [assetsDir, baseUrl, versionArg] = process.argv.slice(2); +if (!assetsDir || !baseUrl) { + console.error( + 'usage: node scripts/build-voicevox-manifest.mjs [version]' + ); + process.exit(1); +} +if (!baseUrl.startsWith('https://')) { + console.error('base-url must start with https://'); + process.exit(1); +} + +const version = versionArg ?? new Date().toISOString().slice(0, 10); + +const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return walk(full); + } + // 隠しファイル (.DS_Store など) は配信対象にしない + return entry.name.startsWith('.') ? [] : [full]; + }); + +const files = walk(assetsDir) + .map((full) => { + const path = relative(assetsDir, full).split(sep).join('/'); + const buffer = readFileSync(full); + return { + path, + url: `${baseUrl.replace(/\/$/, '')}/${path}`, + sha256: createHash('sha256').update(buffer).digest('hex'), + bytes: statSync(full).size, + }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + +const voiceModels = files + .map((file) => file.path) + .filter((path) => path.endsWith('.vvm')); +const dicDirs = [ + ...new Set( + files + .filter((file) => file.path.endsWith('/sys.dic')) + .map((file) => file.path.slice(0, -'/sys.dic'.length)) + ), +]; + +if (voiceModels.length === 0) { + console.error('no .vvm file found'); + process.exit(1); +} +if (dicDirs.length !== 1) { + console.error( + `expected exactly one Open JTalk dictionary, found ${dicDirs.length}` + ); + process.exit(1); +} + +const manifest = { + schemaVersion: 1, + version, + openJtalkDicDir: dicDirs[0], + voiceModels, + files, +}; + +process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); diff --git a/scripts/fetch-voicevox-frameworks.mjs b/scripts/fetch-voicevox-frameworks.mjs new file mode 100644 index 000000000..01621f737 --- /dev/null +++ b/scripts/fetch-voicevox-frameworks.mjs @@ -0,0 +1,92 @@ +// iOS 本体アプリへ埋め込む VOICEVOX CORE の xcframework を取得する。 +// +// ios/Frameworks/voicevox-frameworks.json に固定したバージョン・URL・SHA-256 の +// とおりに zip を取得し、検証してから ios/Frameworks/.xcframework へ展開する。 +// 展開物はリポジトリに含めない(数十 MB のバイナリで、GitHub Releases から +// 再現可能に取得できるため)。既に同じバージョンが展開済みなら何もしない。 +// +// 実行タイミング: +// - ローカル: `npm run ios` の前段(package.json の ios スクリプト) +// - CI: .github/workflows/build_ios_*.yml の pod install 前 +// +// Android ビルドや Jest には不要なので postinstall には繋いでいない。 + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const frameworksDir = resolve(scriptDir, '..', 'ios', 'Frameworks'); +const manifestPath = join(frameworksDir, 'voicevox-frameworks.json'); + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + +const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex'); + +const download = async (url) => { + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`${url}: HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +}; + +for (const framework of manifest.frameworks) { + const { name, version, url, sha256 } = framework; + const destination = join(frameworksDir, `${name}.xcframework`); + // 展開済みバージョンの目印。zip の中身と同じディレクトリに置いて一緒に消えるようにする + const versionMarker = join(destination, '.trainlcd-version'); + + // マーカーだけでなく xcframework の実体 (Info.plist) も確認する。中身が消えた + // 不完全な展開物にマーカーだけ残っていると、ここで飛ばした後の Xcode ビルドが失敗する + if ( + existsSync(versionMarker) && + existsSync(join(destination, 'Info.plist')) && + readFileSync(versionMarker, 'utf8').trim() === version + ) { + console.log(`[voicevox] ${name} ${version} is already installed`); + continue; + } + + console.log(`[voicevox] downloading ${name} ${version}`); + const zip = await download(url); + const actual = sha256Hex(zip); + if (actual !== sha256) { + throw new Error( + `[voicevox] SHA-256 mismatch for ${name}: expected ${sha256}, got ${actual}` + ); + } + + const tempDir = mkdtempSync(join(tmpdir(), 'voicevox-')); + try { + const zipPath = join(tempDir, `${name}.zip`); + writeFileSync(zipPath, zip); + // zip のトップレベルが .xcframework/ なので、展開先の親へそのまま展開する + rmSync(destination, { recursive: true, force: true }); + execFileSync( + 'unzip', + ['-q', '-o', zipPath, `${name}.xcframework/*`, '-d', frameworksDir], + { + stdio: 'inherit', + } + ); + if (!existsSync(join(destination, 'Info.plist'))) { + throw new Error( + `[voicevox] ${name}.xcframework was not found in the archive` + ); + } + writeFileSync(versionMarker, `${version}\n`); + console.log(`[voicevox] installed ${name} ${version} -> ${destination}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/scripts/publish-voicevox-assets.mjs b/scripts/publish-voicevox-assets.mjs new file mode 100644 index 000000000..830eaae51 --- /dev/null +++ b/scripts/publish-voicevox-assets.mjs @@ -0,0 +1,316 @@ +// VOICEVOX の辞書・音声モデルを Cloudflare R2 へ配信し、マニフェストを生成して置く。 +// +// 使い方: +// CLOUDFLARE_ACCOUNT_ID=… CLOUDFLARE_ZONE_ID=… \ +// node scripts/publish-voicevox-assets.mjs [version] +// +// : build-voicevox-manifest.mjs と同じ構成のディレクトリ +// (open_jtalk_dic_utf_8-1.11/ と *.vvm)。manifest.json は含めない。 +// [version] : 資産セットの識別子。省略時は今日の日付 (YYYY-MM-DD) +// +// 環境変数: +// CLOUDFLARE_ACCOUNT_ID アカウント ID (秘密ではない) +// CLOUDFLARE_ZONE_ID 独自ドメインを置くゾーン (trainlcd.app) の ID (秘密ではない) +// CLOUDFLARE_API_TOKEN 省略可。Workers R2 Storage: Edit と対象ゾーンの DNS: Edit を持つトークン。 +// 手元の Mac で実行するときだけ設定する。Claude Code のクラウド環境では +// 「API credentials」に api.cloudflare.com 向けとして登録しておけば +// プロキシが Authorization ヘッダーを付けるので、環境変数には入れない +// VOICEVOX_R2_BUCKET バケット名 (既定: trainlcd-assets) +// VOICEVOX_ASSETS_HOST バケットに紐付ける独自ドメイン (既定: assets.trainlcd.app) +// +// 配信先の構成: +// https:///voicevox/manifest.json … 5 分キャッシュ。Remote Config に入れる固定 URL +// https:///voicevox// … 不変なので長期キャッシュ +// +// Cloudflare API (https://api.cloudflare.com/client/v4) を curl で直接呼ぶ。wrangler を使わないのは、 +// wrangler がローカルにトークンを要求するため、上記のプロキシ経由の認証と両立しないから。 +// オブジェクトの PUT は wrangler `r2 object put` と同じエンドポイントと +// ヘッダー (content-type / cache-control) を使う。 +// 何度実行しても同じ結果になる (バケット・ドメインは存在すれば作らない、 +// 公開済みの version は内容が同一なら再アップロードせず、内容が違えば拒否する)。 +import { execFileSync, spawnSync } from 'node:child_process'; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [assetsDirArg, versionArg] = process.argv.slice(2); +if (!assetsDirArg) { + console.error( + 'usage: node scripts/publish-voicevox-assets.mjs [version]' + ); + process.exit(1); +} +for (const name of ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_ZONE_ID']) { + if (!process.env[name]) { + console.error(`${name} is not set`); + process.exit(1); + } +} + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; +const zoneId = process.env.CLOUDFLARE_ZONE_ID; +const assetsDir = resolve(assetsDirArg); +const version = versionArg ?? new Date().toISOString().slice(0, 10); +const bucket = process.env.VOICEVOX_R2_BUCKET ?? 'trainlcd-assets'; +const host = process.env.VOICEVOX_ASSETS_HOST ?? 'assets.trainlcd.app'; +const prefix = `voicevox/${version}`; +const baseUrl = `https://${host}/${prefix}`; +const manifestKey = 'voicevox/manifest.json'; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const apiBase = 'https://api.cloudflare.com/client/v4'; + +const workDir = mkdtempSync(join(tmpdir(), 'voicevox-publish-')); +process.on('exit', () => rmSync(workDir, { recursive: true, force: true })); + +// トークンはコマンドラインに載せず (ps で見える)、curl の -H @file で渡す +const authHeaderFile = join(workDir, 'auth-header'); +const hasLocalToken = Boolean(process.env.CLOUDFLARE_API_TOKEN); +if (hasLocalToken) { + writeFileSync( + authHeaderFile, + `Authorization: Bearer ${process.env.CLOUDFLARE_API_TOKEN}\n`, + { mode: 0o600 } + ); +} + +/** + * Cloudflare API を呼び、レスポンス JSON を返す。HTTP エラーはメッセージ付きで投げる。 + * body は JSON (json) かファイル (file) のどちらか。 + */ +const cf = ( + method, + path, + { json, file, headers = {}, allow404 = false } = {} +) => { + const args = [ + '-sS', + '-X', + method, + '--retry', + '3', + '--retry-all-errors', + '-o', + join(workDir, 'response.json'), + '-w', + '%{http_code}', + ]; + if (hasLocalToken) { + args.push('-H', `@${authHeaderFile}`); + } + for (const [name, value] of Object.entries(headers)) { + args.push('-H', `${name}: ${value}`); + } + if (json !== undefined) { + args.push( + '-H', + 'Content-Type: application/json', + '--data', + JSON.stringify(json) + ); + } + if (file !== undefined) { + args.push('--data-binary', `@${file}`); + } + args.push(`${apiBase}${path}`); + const result = spawnSync('curl', args, { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(`curl ${method} ${path} failed: ${result.stderr.trim()}`); + } + const status = Number(result.stdout.trim()); + const text = readResponse(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch { + // オブジェクト系エンドポイントは JSON 以外を返すことがある + } + if (status === 404 && allow404) { + return null; + } + if (status < 200 || status >= 300 || (parsed && parsed.success === false)) { + const detail = + parsed?.errors?.map((e) => `${e.code}: ${e.message}`).join('; ') ?? + text.slice(0, 300); + throw new Error(`${method} ${path} -> HTTP ${status}: ${detail}`); + } + // オブジェクトの GET は本文そのもの (JSON とは限らない) を返す + return parsed ?? text; +}; +const readResponse = () => readFileSync(join(workDir, 'response.json'), 'utf8'); + +const step = (title) => console.log(`\n== ${title}`); + +// スタックトレースではなく原因だけを出す。認証系の失敗は対処法も添える +process.on('uncaughtException', (error) => { + console.error(`\nerror: ${error.message}`); + if (/HTTP (400|401|403)/.test(error.message)) { + console.error( + 'Cloudflare API の認証に失敗した。手元なら CLOUDFLARE_API_TOKEN を、' + + 'Claude Code のクラウド環境なら環境設定の API credentials (api.cloudflare.com) を確認する' + ); + } + process.exit(1); +}); + +step('auth'); +const who = cf('GET', '/user/tokens/verify'); +console.log(`token status: ${who.result?.status ?? 'unknown'}`); + +step(`bucket ${bucket}`); +const buckets = + cf('GET', `/accounts/${accountId}/r2/buckets`).result?.buckets ?? []; +if (buckets.some((b) => b.name === bucket)) { + console.log('exists'); +} else { + cf('POST', `/accounts/${accountId}/r2/buckets`, { json: { name: bucket } }); + console.log('created'); +} + +step(`custom domain ${host}`); +const domains = + cf('GET', `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`).result + ?.domains ?? []; +if (domains.some((d) => d.domain === host)) { + console.log('already attached'); +} else { + cf('POST', `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, { + json: { domain: host, zoneId, enabled: true, minTLS: '1.2' }, + }); + console.log( + 'attached (DNS レコードは Cloudflare が自動で作る。反映まで数分かかることがある)' + ); +} + +const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return walk(full); + } + return entry.name.startsWith('.') ? [] : [full]; + }); + +const contentTypeOf = (path) => + path.endsWith('.def') || path.endsWith('COPYING') + ? 'text/plain' + : 'application/octet-stream'; + +const objectPath = (key) => + `/accounts/${accountId}/r2/buckets/${bucket}/objects/${key + .split('/') + .map(encodeURIComponent) + .join('/')}`; + +const putObject = (key, file, contentType, cacheControl) => + cf('PUT', objectPath(key), { + file, + headers: { 'content-type': contentType, 'cache-control': cacheControl }, + }); + +step('manifest'); +const files = walk(assetsDir).sort(); +if (files.some((full) => full.endsWith('manifest.json'))) { + console.error('assets-dir must not contain manifest.json'); + process.exit(1); +} +const manifest = execFileSync( + process.execPath, + [ + join(repoRoot, 'scripts/build-voicevox-manifest.mjs'), + assetsDir, + baseUrl, + version, + ], + { encoding: 'utf8' } +); +const manifestPath = join(workDir, 'manifest.json'); +writeFileSync(manifestPath, manifest); +console.log(`${JSON.parse(manifest).files.length} files, version ${version}`); + +// 資産は 1 年間 immutable としてキャッシュされるので、同じ version を別の内容で +// 上書きすると、キャッシュ済みの旧ファイルと新しいマニフェストの SHA-256 が食い違い、 +// アプリ側の検証が失敗する。公開済みの version は、内容が同一 (再実行) の場合だけ通す。 +// 公開の完了印として version 配下にもマニフェストを置き、それを検査に使う +// (全ファイルのアップロード後に置くので、途中で失敗した実行は未完了として再開できる)。 +step(`existing version check ${prefix}/`); +const versionedManifestKey = `${prefix}/manifest.json`; +const published = cf('GET', objectPath(versionedManifestKey), { + allow404: true, +}); +// cf() は JSON として解釈できた本文をオブジェクトで返すので、文字列とオブジェクトの両方を受ける +const fingerprint = (data) => + JSON.stringify( + (typeof data === 'string' ? JSON.parse(data) : data).files + .map(({ path, sha256, bytes }) => [path, sha256, bytes]) + .sort() + ); +let alreadyPublished = false; +if (published === null) { + console.log('not published yet'); +} else if (fingerprint(published) === fingerprint(manifest)) { + alreadyPublished = true; + console.log('already published with identical content; skipping upload'); +} else { + throw new Error( + `version ${version} は既に別の内容で公開済み。資産を変えるときは新しい version を指定する` + ); +} + +if (!alreadyPublished) { + step(`upload ${prefix}/`); + for (const full of files) { + const path = relative(assetsDir, full).split(sep).join('/'); + console.log(`put ${path} (${statSync(full).size} bytes)`); + putObject( + `${prefix}/${path}`, + full, + contentTypeOf(path), + 'public, max-age=31536000, immutable' + ); + } + // 完了印。これが置かれるまで、この version は「未公開」として扱われる + putObject( + versionedManifestKey, + manifestPath, + 'application/json', + 'public, max-age=31536000, immutable' + ); +} + +step(`publish ${manifestKey}`); +putObject(manifestKey, manifestPath, 'application/json', 'public, max-age=300'); + +step('verify'); +const manifestUrl = `https://${host}/${manifestKey}`; +let failed = false; +for (const url of [ + manifestUrl, + ...JSON.parse(manifest).files.map((file) => file.url), +]) { + const result = spawnSync( + 'curl', + ['-sS', '-I', '-o', '/dev/null', '-w', '%{http_code}', url], + { encoding: 'utf8' } + ); + const status = result.stdout.trim(); + console.log(`${status} ${url}`); + if (status !== '200') { + failed = true; + } +} +if (failed) { + console.error( + '一部の URL に到達できない。独自ドメインの反映待ちなら数分後に再実行する' + ); + process.exitCode = 1; +} + +console.log(`\nRemote Config: voicevox_tts_manifest_url_ios = ${manifestUrl}`); diff --git a/src/constants/index.ts b/src/constants/index.ts index 9dd8bdfe8..938c0d9fa 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -20,3 +20,4 @@ export * from './theme'; export * from './threshold'; export * from './tts'; export * from './url'; +export * from './voicevox'; diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 4b34910ef..1a8160d26 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -43,6 +43,11 @@ export const STORAGE_KEYS = { '@TrainLCD:portraitPromoPromptLastShownAt', PORTRAIT_PROMO_BANNER_COUNT: '@TrainLCD:portraitPromoBannerCount', PORTRAIT_PROMO_APPEARANCE_SEEN: '@TrainLCD:portraitPromoAppearanceSeen', + // 取得済みの VOICEVOX 辞書・音声モデル(iOS)。src/lib/voicevox/assets.ts が管理する + VOICEVOX_ASSETS: '@TrainLCD:voicevoxAssets', + // ユーザーが VOICEVOX 資産 (約 160MB) のダウンロードに同意したか。同意後は + // 中断した取得や更新版の取得を自動で再開する + VOICEVOX_DOWNLOAD_CONSENTED: '@TrainLCD:voicevoxDownloadConsented', } as const; export type StorageKeys = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; diff --git a/src/constants/voicevox.ts b/src/constants/voicevox.ts new file mode 100644 index 000000000..ea5bca3b5 --- /dev/null +++ b/src/constants/voicevox.ts @@ -0,0 +1,40 @@ +import { + TTS_SPEED_PREFERENCE, + type TTSSpeedPreference, +} from '~/models/TTSSpeed'; + +// iOS でリモート TTS が使えない回の日本語フォールバックに使う VOICEVOX CORE の既定値。 +// 有効化・配信 URL・スタイル ID は Remote Config (voicevox_tts_*_ios) が決める。 +// 設計は docs/spec/tts/on-device-tts-ios.md を参照。 + +// 既定のスタイル ID。No.7「アナウンス」(voicevox_vvm の 6.vvm / スタイル ID 30)。 +// 車内放送に最も近い声色で、個人の非商用利用はクレジット表記のみで可 +// (https://voiceseven.com/#j0200)。ライセンス画面のクレジットと対応させること。 +export const VOICEVOX_DEFAULT_STYLE_ID = 30; + +// アナウンス速度設定を VOICEVOX の AudioQuery.speedScale へ写像する。 +// リモート TTS の speakingRate (REMOTE_TTS_SPEED_RATES) と同じ倍率にして、 +// フォールバック時も設定どおりの速さで読ませる。 +export const VOICEVOX_SPEED_SCALES: Record = { + [TTS_SPEED_PREFERENCE.SLOW]: 0.85, + [TTS_SPEED_PREFERENCE.NORMAL]: 1.0, + [TTS_SPEED_PREFERENCE.FAST]: 1.15, +}; + +// 合成に使う CPU スレッド数。0 は VOICEVOX CORE が環境に合わせて決める。 +export const VOICEVOX_CPU_NUM_THREADS = 0; + +// 辞書・音声モデルの置き場 (Paths.document 配下)。バージョンごとのサブディレクトリを +// 切り、切り替え後に旧バージョンを削除する。 +export const VOICEVOX_ASSET_DIR_NAME = 'voicevox'; + +// マニフェスト取得のタイムアウト(ミリ秒)。資産本体のダウンロードには適用しない。 +export const VOICEVOX_MANIFEST_FETCH_TIMEOUT_MS = 15_000; + +// 資産の取得に失敗したあと、次に再試行するまでの最短間隔(ミリ秒)。 +// 圏外で毎回の放送ごとにマニフェスト取得を試みて電池を消費しないようにする。 +export const VOICEVOX_ASSET_RETRY_INTERVAL_MS = 5 * 60_000; + +// 同意ダイアログに出す資産サイズの目安 (MB)。実サイズはマニフェスト取得後に分かるため、 +// ダイアログでは概算だけを示す (Open JTalk 辞書 約 107MB + VVM 約 55MB)。 +export const VOICEVOX_ASSET_APPROX_MB = 160; diff --git a/src/hooks/tts/useVoicevoxSpeechEngine.test.ts b/src/hooks/tts/useVoicevoxSpeechEngine.test.ts new file mode 100644 index 000000000..7268d2a36 --- /dev/null +++ b/src/hooks/tts/useVoicevoxSpeechEngine.test.ts @@ -0,0 +1,389 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import { createElement, type ReactNode } from 'react'; +import { TTS_SPEED_PREFERENCE } from '~/models/TTSSpeed'; +import { ttsSpeedPreferenceAtom } from '~/store/atoms/speech'; +import type { + SpeechEngine, + SpeechEngineCallbacks, + SpeechEngineRequest, +} from './speechEngine'; +import { useVoicevoxSpeechEngine } from './useVoicevoxSpeechEngine'; + +const mockSetup = jest.fn(); +const mockSynthesize = jest.fn(); +let mockModuleAvailable = true; +jest.mock('~/utils/native/ios/voicevoxTtsModule', () => ({ + getVoicevoxTtsModule: () => + mockModuleAvailable + ? { + setup: (o: unknown) => mockSetup(o), + synthesize: (o: unknown) => mockSynthesize(o), + } + : null, +})); + +let mockEnabled = true; +let mockStyleId = 30; +const mockRemoteConfigListeners = new Set<() => void>(); +jest.mock('~/lib/remoteConfig', () => ({ + isVoicevoxTTSEnabled: () => mockEnabled, + getVoicevoxTTSStyleId: () => mockStyleId, + subscribeRemoteConfig: (listener: () => void) => { + mockRemoteConfigListeners.add(listener); + return () => { + mockRemoteConfigListeners.delete(listener); + }; + }, +})); + +const installedAssets = { + version: 'v1', + openJtalkDicDir: '/docs/voicevox/v1/dic', + voiceModelPaths: ['/docs/voicevox/v1/6.vvm'], +}; +let mockInstalled: typeof installedAssets | null = installedAssets; +const mockEnsureAssets = jest.fn(async () => mockInstalled); +jest.mock('~/lib/voicevox/assets', () => ({ + getInstalledVoicevoxAssets: () => mockInstalled, + ensureVoicevoxAssets: () => mockEnsureAssets(), + fileUriToPath: (uri: string) => uri.replace(/^file:\/\//, ''), +})); + +const mockFileDelete = jest.fn(); +jest.mock('expo-file-system', () => ({ + Paths: { cache: 'file:///cache' }, + File: class { + public uri: string; + constructor(base: string, name: string) { + this.uri = `${base}/${name}`; + } + delete() { + mockFileDelete(this.uri); + } + }, +})); + +type PlayAudioOptions = { + uri: string; + onFinish: () => void; + onError: (error: unknown) => void; +}; +const playAudioCalls: PlayAudioOptions[] = []; +const mockPlayAudio = jest.fn((options: PlayAudioOptions) => { + playAudioCalls.push(options); + return { player: { uri: options.uri }, listener: { remove: jest.fn() } }; +}); +const mockSafeRemovePlayer = jest.fn(); +jest.mock('~/utils/ttsAudioPlayer', () => ({ + playAudio: (options: PlayAudioOptions) => mockPlayAudio(options), + safeRemoveListener: jest.fn(), + safeRemovePlayer: (p: unknown) => mockSafeRemovePlayer(p), +})); + +const englishSpeak = jest.fn(); +const englishStop = jest.fn(); +const englishEngine: SpeechEngine = { + speak: (request, callbacks) => englishSpeak(request, callbacks), + stop: () => englishStop(), +}; + +const defaultRequest: SpeechEngineRequest = { + ssmlJa: '次は大崎終点です', + ssmlEn: 'The next station is Osaki, J Y 24.', + speakJa: true, + speakEn: true, +}; + +const flushAsync = async () => { + await act(async () => { + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + }); +}; + +const renderEngine = (speed: keyof typeof TTS_SPEED_PREFERENCE = 'NORMAL') => { + const store = createStore(); + store.set(ttsSpeedPreferenceAtom, TTS_SPEED_PREFERENCE[speed]); + return renderHook(() => useVoicevoxSpeechEngine(englishEngine), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(Provider, { store }, children), + }); +}; + +const callbacks = (): SpeechEngineCallbacks & { + onSpeechStarted: jest.Mock; + onSettled: jest.Mock; + onUnavailable: jest.Mock; +} => ({ + onSpeechStarted: jest.fn(), + onSettled: jest.fn(), + onUnavailable: jest.fn(), +}); + +describe('useVoicevoxSpeechEngine', () => { + beforeEach(() => { + jest.clearAllMocks(); + playAudioCalls.length = 0; + mockModuleAvailable = true; + mockEnabled = true; + mockStyleId = 30; + mockInstalled = installedAssets; + mockRemoteConfigListeners.clear(); + mockSetup.mockResolvedValue({ coreVersion: '0.17.0', styleIds: [29, 30] }); + mockSynthesize.mockImplementation(async (o: { outputPath: string }) => ({ + path: o.outputPath, + bytes: 1234, + })); + }); + + it('マウント時に資産の取得を始める', () => { + renderEngine(); + expect(mockEnsureAssets).toHaveBeenCalledTimes(1); + }); + + it('Remote Config の更新でも資産の取得を試みる', () => { + mockEnabled = false; + renderEngine(); + expect(mockEnsureAssets).not.toHaveBeenCalled(); + + mockEnabled = true; + act(() => { + for (const listener of mockRemoteConfigListeners) { + listener(); + } + }); + expect(mockEnsureAssets).toHaveBeenCalledTimes(1); + }); + + it('日本語を VOICEVOX で合成して再生し、英語は委譲する', async () => { + const cb = callbacks(); + const { result } = renderEngine('FAST'); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + + expect(mockSetup).toHaveBeenCalledWith({ + openJtalkDicDir: '/docs/voicevox/v1/dic', + voiceModelPaths: ['/docs/voicevox/v1/6.vvm'], + cpuNumThreads: 0, + }); + // SSML はプレーンテキストへ変換され、読み (alias) と「、」の区切りが残る + expect(mockSynthesize).toHaveBeenCalledWith({ + text: '次はオオサキ、、終点です', + styleId: 30, + speedScale: 1.15, + outputPath: expect.stringMatching(/^\/cache\/voicevox_\d+_1\.wav$/), + }); + expect(cb.onSpeechStarted).toHaveBeenCalledTimes(1); + expect(playAudioCalls).toHaveLength(1); + expect(playAudioCalls[0]?.uri).toMatch(/^file:\/\/\/cache\/voicevox_/); + expect(englishSpeak).not.toHaveBeenCalled(); + + // 日本語の再生完了で英語へ (speakJa=false で委譲) + act(() => { + playAudioCalls[0]?.onFinish(); + }); + expect(mockFileDelete).toHaveBeenCalledTimes(1); + expect(englishSpeak).toHaveBeenCalledWith( + { ...defaultRequest, speakJa: false }, + expect.objectContaining({ onSettled: expect.any(Function) }) + ); + expect(cb.onSettled).not.toHaveBeenCalled(); + + act(() => { + (englishSpeak.mock.calls[0]?.[1] as SpeechEngineCallbacks).onSettled(); + }); + expect(cb.onSettled).toHaveBeenCalledTimes(1); + expect(cb.onUnavailable).not.toHaveBeenCalled(); + }); + + it('英語を読まない回は日本語の再生完了で終える', async () => { + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak({ ...defaultRequest, speakEn: false }, cb); + await flushAsync(); + act(() => { + playAudioCalls[0]?.onFinish(); + }); + + expect(englishSpeak).not.toHaveBeenCalled(); + expect(cb.onSettled).toHaveBeenCalledTimes(1); + }); + + it('日本語を読まない回は VOICEVOX を使わず英語エンジンへそのまま渡す', () => { + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak({ ...defaultRequest, speakJa: false }, cb); + + expect(mockSynthesize).not.toHaveBeenCalled(); + expect(englishSpeak).toHaveBeenCalledWith( + { ...defaultRequest, speakJa: false }, + cb + ); + }); + + it('英語エンジンが使えなくても、日本語を読んだ後なので onSettled で終える', async () => { + englishSpeak.mockImplementation((_r: unknown, c: SpeechEngineCallbacks) => + c.onUnavailable?.() + ); + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + act(() => { + playAudioCalls[0]?.onFinish(); + }); + + expect(cb.onSettled).toHaveBeenCalledTimes(1); + expect(cb.onUnavailable).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'ネイティブモジュールが無い', + () => { + mockModuleAvailable = false; + }, + ], + [ + 'Remote Config で無効', + () => { + mockEnabled = false; + }, + ], + [ + '資産が未取得', + () => { + mockInstalled = null; + }, + ], + ])('%s なら onUnavailable を返す', (_label, arrange) => { + arrange(); + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + + expect(cb.onUnavailable).toHaveBeenCalledTimes(1); + expect(cb.onSettled).not.toHaveBeenCalled(); + expect(mockSynthesize).not.toHaveBeenCalled(); + }); + + it('資産が未取得の回は取得を促す', () => { + mockInstalled = null; + const { result } = renderEngine(); + mockEnsureAssets.mockClear(); + + result.current.speak(defaultRequest, callbacks()); + + expect(mockEnsureAssets).toHaveBeenCalledTimes(1); + }); + + it('合成に失敗した回は onUnavailable を返し、次回は setup をやり直す', async () => { + mockSetup.mockRejectedValueOnce(new Error('boom')); + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + expect(cb.onUnavailable).toHaveBeenCalledTimes(1); + expect(cb.onSpeechStarted).not.toHaveBeenCalled(); + + const cb2 = callbacks(); + result.current.speak(defaultRequest, cb2); + await flushAsync(); + expect(mockSetup).toHaveBeenCalledTimes(2); + expect(cb2.onSpeechStarted).toHaveBeenCalledTimes(1); + }); + + it('指定したスタイル ID が音声モデルに無ければ使わない', async () => { + mockStyleId = 99; + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + + expect(cb.onUnavailable).toHaveBeenCalledTimes(1); + expect(mockSynthesize).not.toHaveBeenCalled(); + }); + + it('同じ資産バージョンなら setup は 1 回だけ', async () => { + const { result } = renderEngine(); + + result.current.speak(defaultRequest, callbacks()); + await flushAsync(); + act(() => { + playAudioCalls[0]?.onFinish(); + }); + result.current.speak(defaultRequest, callbacks()); + await flushAsync(); + + expect(mockSetup).toHaveBeenCalledTimes(1); + expect(mockSynthesize).toHaveBeenCalledTimes(2); + }); + + it('stop で再生を解放し、古い発話のコールバックは無視する', async () => { + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + + act(() => { + result.current.stop(); + }); + expect(mockSafeRemovePlayer).toHaveBeenCalled(); + expect(mockFileDelete).toHaveBeenCalledTimes(1); + expect(englishStop).toHaveBeenCalledTimes(1); + + act(() => { + playAudioCalls[0]?.onFinish(); + }); + expect(englishSpeak).not.toHaveBeenCalled(); + expect(cb.onSettled).not.toHaveBeenCalled(); + }); + + it('合成中に stop されたら結果を再生せずファイルを消す', async () => { + let resolveSynthesis: ((v: unknown) => void) | null = null; + mockSynthesize.mockImplementation( + () => + new Promise((resolve) => { + resolveSynthesis = resolve; + }) + ); + const cb = callbacks(); + const { result } = renderEngine(); + + result.current.speak(defaultRequest, cb); + await flushAsync(); + act(() => { + result.current.stop(); + }); + await act(async () => { + resolveSynthesis?.({ path: '/cache/x.wav', bytes: 1 }); + }); + await flushAsync(); + + expect(playAudioCalls).toHaveLength(0); + expect(mockFileDelete).toHaveBeenCalled(); + expect(cb.onSpeechStarted).not.toHaveBeenCalled(); + expect(cb.onSettled).not.toHaveBeenCalled(); + }); + + it('アンマウント時に停止する', async () => { + const { result, unmount } = renderEngine(); + result.current.speak(defaultRequest, callbacks()); + await flushAsync(); + + unmount(); + + expect(mockSafeRemovePlayer).toHaveBeenCalled(); + expect(englishStop).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/tts/useVoicevoxSpeechEngine.ts b/src/hooks/tts/useVoicevoxSpeechEngine.ts new file mode 100644 index 000000000..35172b64e --- /dev/null +++ b/src/hooks/tts/useVoicevoxSpeechEngine.ts @@ -0,0 +1,262 @@ +import { File, Paths } from 'expo-file-system'; +import { useAtomValue } from 'jotai'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + VOICEVOX_CPU_NUM_THREADS, + VOICEVOX_SPEED_SCALES, +} from '~/constants/voicevox'; +import { + getVoicevoxTTSStyleId, + isVoicevoxTTSEnabled, + subscribeRemoteConfig, +} from '~/lib/remoteConfig'; +import { + ensureVoicevoxAssets, + fileUriToPath, + getInstalledVoicevoxAssets, + type VoicevoxInstalledAssets, +} from '~/lib/voicevox/assets'; +import { ttsSpeedPreferenceAtom } from '~/store/atoms/speech'; +import { getVoicevoxTtsModule } from '~/utils/native/ios/voicevoxTtsModule'; +import { toSpeakableText } from '~/utils/speakableText'; +import { + type PlayAudioHandle, + playAudio, + safeRemoveListener, + safeRemovePlayer, +} from '~/utils/ttsAudioPlayer'; +import type { + SpeechEngine, + SpeechEngineCallbacks, + SpeechEngineRequest, +} from './speechEngine'; + +/** + * iOS でリモート TTS が使えない回に、日本語だけを VOICEVOX CORE (端末内合成) で + * 読み上げるエンジン。英語は VOICEVOX が話せないため、引数で受け取った + * エンジン (端末内蔵 TTS) へ委譲する。 + * + * 使える条件が 1 つでも欠けると onUnavailable を返し、呼び出し側 (useTTS) が + * その回だけ端末内蔵 TTS で日英とも読み上げる。 + * - ネイティブモジュールがある (本体アプリの iOS のみ。App Clip / Android は無い) + * - Remote Config (voicevox_tts_enabled_ios) で有効化されている + * - 辞書と音声モデルの取得・検証が完了している + * + * 合成器の初期化 (VVM の展開) は初回の発話まで遅らせる。常駐メモリを増やしたく + * ないためで、リモート TTS が使える通常時は VOICEVOX を一切メモリへ載せない。 + */ +export const useVoicevoxSpeechEngine = ( + englishEngine: SpeechEngine +): SpeechEngine => { + // 発話ごとに採番する世代 ID。停止や新規発話で古い合成の継続を破棄する + const runIdRef = useRef(0); + const jaHandleRef = useRef(null); + const jaFileRef = useRef(null); + // setup 済みの資産バージョン。バージョンが変わったら setup し直す + const setupRef = useRef<{ + version: string; + promise: Promise; + } | null>(null); + + const speedPreference = useAtomValue(ttsSpeedPreferenceAtom); + const speedRef = useRef(speedPreference); + useEffect(() => { + speedRef.current = speedPreference; + }, [speedPreference]); + + // 有効化されたら資産の取得を始めておく。発話時に取得を待つと放送に間に合わない + // ため、TTS が有効な間 (このフックがマウントされている間) にバックグラウンドで + // 揃えておき、発話時は取得済みかどうかだけを見る。 + useEffect(() => { + const kick = () => { + if (getVoicevoxTtsModule() && isVoicevoxTTSEnabled()) { + void ensureVoicevoxAssets(); + } + }; + kick(); + return subscribeRemoteConfig(kick); + }, []); + + const releaseJaPlayer = useCallback(() => { + safeRemoveListener(jaHandleRef.current?.listener ?? null); + safeRemovePlayer(jaHandleRef.current?.player ?? null); + jaHandleRef.current = null; + const file = jaFileRef.current; + jaFileRef.current = null; + if (file) { + try { + file.delete(); + } catch {} + } + }, []); + + const stop = useCallback(() => { + runIdRef.current += 1; + releaseJaPlayer(); + englishEngine.stop(); + }, [englishEngine, releaseJaPlayer]); + + const ensureSetup = useCallback( + (assets: VoicevoxInstalledAssets): Promise => { + const current = setupRef.current; + if (current && current.version === assets.version) { + return current.promise; + } + const module = getVoicevoxTtsModule(); + if (!module) { + return Promise.reject( + new Error('VOICEVOX native module is unavailable') + ); + } + const promise = module + .setup({ + openJtalkDicDir: assets.openJtalkDicDir, + voiceModelPaths: assets.voiceModelPaths, + cpuNumThreads: VOICEVOX_CPU_NUM_THREADS, + }) + .then((result) => { + const styleId = getVoicevoxTTSStyleId(); + if (!result.styleIds.includes(styleId)) { + throw new Error( + `style ${styleId} is not in the loaded voice models (available: ${result.styleIds.join(',')})` + ); + } + }) + .catch((e) => { + // 失敗した setup を覚えたままにすると以後ずっと不可になるため、次回やり直す + if (setupRef.current?.promise === promise) { + setupRef.current = null; + } + throw e; + }); + setupRef.current = { version: assets.version, promise }; + return promise; + }, + [] + ); + + const speak = useCallback( + (request: SpeechEngineRequest, callbacks: SpeechEngineCallbacks) => { + const runId = runIdRef.current + 1; + runIdRef.current = runId; + const isStaleRun = () => runIdRef.current !== runId; + const reportUnavailable = () => { + if (callbacks.onUnavailable) { + callbacks.onUnavailable(); + return; + } + callbacks.onSettled(); + }; + + // 日本語を読まない回は VOICEVOX の出番が無い。英語エンジンへそのまま渡す + if (!request.speakJa) { + englishEngine.speak(request, callbacks); + return; + } + + const module = getVoicevoxTtsModule(); + if (!module || !isVoicevoxTTSEnabled()) { + reportUnavailable(); + return; + } + const assets = getInstalledVoicevoxAssets(); + if (!assets) { + // 未取得なら取得を促しつつ、この回は諦める + void ensureVoicevoxAssets(); + reportUnavailable(); + return; + } + + const text = toSpeakableText(request.ssmlJa, 'JA', 'native'); + if (!text) { + reportUnavailable(); + return; + } + + void (async () => { + let file: File | null = null; + try { + await ensureSetup(assets); + if (isStaleRun()) { + return; + } + file = new File(Paths.cache, `voicevox_${Date.now()}_${runId}.wav`); + await module.synthesize({ + text, + styleId: getVoicevoxTTSStyleId(), + speedScale: VOICEVOX_SPEED_SCALES[speedRef.current], + outputPath: fileUriToPath(file.uri), + }); + } catch (e) { + if (file) { + try { + file.delete(); + } catch {} + } + if (isStaleRun()) { + return; + } + console.warn('[useVoicevoxSpeechEngine] synthesis failed:', e); + reportUnavailable(); + return; + } + if (isStaleRun()) { + try { + file.delete(); + } catch {} + return; + } + + callbacks.onSpeechStarted?.(); + + const settle = () => { + if (isStaleRun()) { + return; + } + callbacks.onSettled(); + }; + + // 日本語の再生が始まった後は、英語側の失敗も含めて onSettled で終える + // (途中まで読んだ放送を頭から読み直さない) + const speakEnglish = () => { + if (!request.speakEn) { + settle(); + return; + } + englishEngine.speak( + { ...request, speakJa: false }, + { onSettled: settle, onUnavailable: settle } + ); + }; + + const finishJa = () => { + releaseJaPlayer(); + if (isStaleRun()) { + return; + } + speakEnglish(); + }; + + jaFileRef.current = file; + try { + jaHandleRef.current = playAudio({ + uri: file.uri, + onFinish: finishJa, + onError: finishJa, + }); + } catch (e) { + console.warn( + '[useVoicevoxSpeechEngine] JA playback failed to start:', + e + ); + finishJa(); + } + })(); + }, + [englishEngine, ensureSetup, releaseJaPlayer] + ); + + useEffect(() => stop, [stop]); + + return useMemo(() => ({ speak, stop }), [speak, stop]); +}; diff --git a/src/hooks/useTTS.test.ts b/src/hooks/useTTS.test.ts index c71e6f447..ec9c445f0 100644 --- a/src/hooks/useTTS.test.ts +++ b/src/hooks/useTTS.test.ts @@ -61,6 +61,27 @@ jest.mock('./tts/useRemoteSpeechEngine', () => ({ useRemoteSpeechEngine: () => mockRemoteEngine, })); +// VOICEVOX (端末内合成) エンジンも同様に差し替える。実装は +// useVoicevoxSpeechEngine.test.ts で検証する。既定は「使えない」状態にして、 +// 従来どおり端末内蔵 TTS へ倒れる経路を通す。 +const voicevoxSpeakUnavailable = ( + _request: unknown, + callbacks: { onUnavailable?: () => void; onSettled: () => void } +) => { + (callbacks.onUnavailable ?? callbacks.onSettled)(); +}; +const mockVoicevoxSpeak = jest.fn(voicevoxSpeakUnavailable); +const mockVoicevoxStop = jest.fn(); +const mockVoicevoxEngine = { + speak: (...args: unknown[]) => + (mockVoicevoxSpeak as unknown as (...a: unknown[]) => void)(...args), + stop: (...args: unknown[]) => mockVoicevoxStop(...args), +}; + +jest.mock('./tts/useVoicevoxSpeechEngine', () => ({ + useVoicevoxSpeechEngine: () => mockVoicevoxEngine, +})); + jest.mock('./useCurrentLine', () => ({ useCurrentLine: jest.fn(() => undefined), })); @@ -137,6 +158,7 @@ describe('useTTS', () => { mockedIsRemoteTTSEnabled.mockImplementation(() => Platform.OS === 'ios'); // 個別テストで差し替えたリモートエンジンの挙動を既定へ戻す mockRemoteSpeak.mockImplementation(remoteSpeakUnavailable); + mockVoicevoxSpeak.mockImplementation(voicevoxSpeakUnavailable); // テスト間で useTTSText の mock を復元 const { useTTSText } = jest.requireMock('./useTTSText') as { useTTSText: jest.Mock; @@ -1021,4 +1043,82 @@ describe('useTTS', () => { ); }); }); + describe('VOICEVOX (端末内合成) へのフォールバック', () => { + it('[iOS] リモートTTSが使えない回はまず VOICEVOX を試し、使えなければ端末内蔵TTSで読む', async () => { + const store = createStore(); + store.set(speechState, defaultSpeechState); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + await flushAsync(); + + expect(mockRemoteSpeak).toHaveBeenCalledTimes(1); + expect(mockVoicevoxSpeak).toHaveBeenCalledTimes(1); + expect(mockVoicevoxSpeak).toHaveBeenCalledWith( + { + ssmlJa: 'ja text', + ssmlEn: 'en text', + speakJa: true, + speakEn: true, + }, + expect.objectContaining({ onUnavailable: expect.any(Function) }) + ); + // VOICEVOX が onUnavailable を返したので端末内蔵 TTS が日英とも読む + expect(mockSpeak).toHaveBeenCalledTimes(2); + }); + + it('[iOS] VOICEVOX が読み上げた回は端末内蔵TTSを直接は使わない', async () => { + mockVoicevoxSpeak.mockImplementation( + ( + _request: unknown, + callbacks: { onSpeechStarted?: () => void; onSettled: () => void } + ) => { + callbacks.onSpeechStarted?.(); + callbacks.onSettled(); + } + ); + + const store = createStore(); + store.set(speechState, defaultSpeechState); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + await flushAsync(); + + expect(mockVoicevoxSpeak).toHaveBeenCalledTimes(1); + expect(mockSpeak).not.toHaveBeenCalled(); + + // 完了で再生パイプラインが解放される (ダッキング解除) + expect(mockSetAudioModeAsync).toHaveBeenCalledWith( + expect.objectContaining({ interruptionMode: 'mixWithOthers' }) + ); + }); + + it('[iOS] Remote Config でリモートTTSが無効でも VOICEVOX を先に試す', async () => { + mockedIsRemoteTTSEnabled.mockReturnValue(false); + + const store = createStore(); + store.set(speechState, defaultSpeechState); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + await flushAsync(); + + expect(mockRemoteSpeak).not.toHaveBeenCalled(); + expect(mockVoicevoxSpeak).toHaveBeenCalledTimes(1); + expect(mockSpeak).toHaveBeenCalledTimes(2); + }); + + it('アンマウント時は VOICEVOX の読み上げも停止する', async () => { + const store = createStore(); + store.set(speechState, defaultSpeechState); + + const { unmount } = renderHook(() => useTTS(), { + wrapper: createWrapper(store), + }); + await flushAsync(); + mockVoicevoxStop.mockClear(); + + unmount(); + + expect(mockVoicevoxStop).toHaveBeenCalled(); + }); + }); }); diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 62b25f152..388ada8ab 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -9,6 +9,7 @@ import { computeSuppressionDecision } from '../utils/computeSuppressionDecision' import type { SpeechEngineRequest } from './tts/speechEngine'; import { useNativeSpeechEngine } from './tts/useNativeSpeechEngine'; import { useRemoteSpeechEngine } from './tts/useRemoteSpeechEngine'; +import { useVoicevoxSpeechEngine } from './tts/useVoicevoxSpeechEngine'; import { useCurrentLine } from './useCurrentLine'; import { usePrevious } from './usePrevious'; import { useStoppingState } from './useStoppingState'; @@ -67,11 +68,17 @@ export const useTTS = (): void => { // 使えないときのフォールバックも担うため、どちらのプラットフォームでも用意しておく。 const nativeEngine = useNativeSpeechEngine(); const remoteEngine = useRemoteSpeechEngine(); + // iOS でリモート合成が使えない回の日本語を VOICEVOX (端末内合成) で読み上げる。 + // 英語は端末内蔵 TTS へ委譲する。Remote Config (voicevox_tts_enabled_ios) で + // 有効化され、辞書・音声モデルの取得が済んでいるときだけ使われ、それ以外は + // onUnavailable を返すので端末内蔵 TTS に倒れる。 + const voicevoxEngine = useVoicevoxSpeechEngine(nativeEngine); const stopAllEngines = useCallback(() => { remoteEngine.stop(); + voicevoxEngine.stop(); nativeEngine.stop(); - }, [nativeEngine, remoteEngine]); + }, [nativeEngine, remoteEngine, voicevoxEngine]); // アンマウント時のクリーンアップから参照する。エンジンの識別子を effect の // 依存に入れると、識別子が変わっただけでクリーンアップが走って発話中の @@ -225,11 +232,26 @@ export const useTTS = (): void => { return; } + // 端末内で読み上げる経路。まず VOICEVOX (日本語のみ・iOS 本体アプリで + // 有効化済みのときだけ) を試し、使えなければ端末内蔵 TTS で日英とも読む。 + const speakOnDevice = () => { + voicevoxEngine.speak(request, { + onSpeechStarted, + onSettled, + onUnavailable: () => { + if (isStaleRun()) { + return; + } + nativeEngine.speak(request, { onSpeechStarted, onSettled }); + }, + }); + }; + // 読み上げ直前に Remote Config を引き、リモート合成(Worker 経由の - // Google Cloud TTS)と端末内蔵 TTS のどちらで読み上げるかを決める。起動後に + // Google Cloud TTS)と端末内合成のどちらで読み上げるかを決める。起動後に // 設定が届いた場合も次の放送から反映される。 if (!isRemoteTTSEnabled()) { - nativeEngine.speak(request, { onSpeechStarted, onSettled }); + speakOnDevice(); return; } @@ -237,7 +259,7 @@ export const useTTS = (): void => { onSpeechStarted, onSettled, // 圏外・トンネル・API 障害で合成できなかった回は、その放送だけ - // 端末内蔵 TTS で読み上げてアナウンスの欠落を防ぐ + // 端末内で読み上げてアナウンスの欠落を防ぐ onUnavailable: () => { if (isStaleRun()) { return; @@ -245,7 +267,7 @@ export const useTTS = (): void => { console.warn( '[useTTS] Remote TTS unavailable, falling back to on-device TTS' ); - nativeEngine.speak(request, { onSpeechStarted, onSettled }); + speakOnDevice(); }, }); })(); @@ -258,6 +280,7 @@ export const useTTS = (): void => { setDuckingActiveAsync, shouldSpeakEnglish, shouldSpeakJapanese, + voicevoxEngine, ] ); diff --git a/src/hooks/useVoicevoxAssetsStatus.ts b/src/hooks/useVoicevoxAssetsStatus.ts new file mode 100644 index 000000000..360b82e6c --- /dev/null +++ b/src/hooks/useVoicevoxAssetsStatus.ts @@ -0,0 +1,24 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { subscribeRemoteConfig } from '~/lib/remoteConfig'; +import { + getVoicevoxAssetsStatus, + subscribeVoicevoxAssets, + type VoicevoxAssetsStatus, +} from '~/lib/voicevox/assets'; + +/** + * VOICEVOX 資産 (辞書・音声モデル) の取得状態のリアクティブ版。設定画面の + * 進捗表示に使う。取得の進行 (subscribeVoicevoxAssets) と、有効化・配信 URL を + * 決める Remote Config の到着 (subscribeRemoteConfig) の両方で再評価する。 + */ +export const useVoicevoxAssetsStatus = (): VoicevoxAssetsStatus => { + const subscribe = useCallback((listener: () => void) => { + const unsubscribeAssets = subscribeVoicevoxAssets(listener); + const unsubscribeRemoteConfig = subscribeRemoteConfig(listener); + return () => { + unsubscribeAssets(); + unsubscribeRemoteConfig(); + }; + }, []); + return useSyncExternalStore(subscribe, getVoicevoxAssetsStatus); +}; diff --git a/src/lib/remoteConfig.test.ts b/src/lib/remoteConfig.test.ts index 84d1a1ce0..d017b4bb1 100644 --- a/src/lib/remoteConfig.test.ts +++ b/src/lib/remoteConfig.test.ts @@ -4,11 +4,14 @@ import { getEtaFallbackArrivalConfirmMarginSec, getEtaFallbackMaxDurationMin, getMaxPermitAccuracy, + getVoicevoxTTSManifestUrl, + getVoicevoxTTSStyleId, isAIAgentFeatureEnabled, isEtaAssistEnabled, isForceNotArrivedOnLowAccuracyEnabled, isRemoteTTSEnabled, isTTSFeatureEnabled, + isVoicevoxTTSEnabled, resetRemoteConfigCache, setupRemoteConfig, subscribeRemoteConfig, @@ -464,6 +467,59 @@ describe('isRemoteTTSEnabled(リモートTTS切替スイッチ)', () => { }); }); +describe('VOICEVOX フォールバック(voicevox_tts_*_ios)', () => { + it('未配信時は無効で、配信 URL は null、スタイル ID は No.7 アナウンス', () => { + setPlatformOS('ios'); + expect(isVoicevoxTTSEnabled()).toBe(false); + expect(getVoicevoxTTSManifestUrl()).toBeNull(); + expect(getVoicevoxTTSStyleId()).toBe(30); + }); + + it('Remote Config で有効化すると iOS だけ有効になる', async () => { + mockRemoteConfig({ + max_permit_accuracy: 1500, + voicevox_tts_enabled_ios: true, + voicevox_tts_manifest_url_ios: 'https://assets.example.com/m.json', + voicevox_tts_style_id_ios: 14, + }); + await setupRemoteConfig(); + + setPlatformOS('ios'); + expect(isVoicevoxTTSEnabled()).toBe(true); + expect(getVoicevoxTTSManifestUrl()).toBe( + 'https://assets.example.com/m.json' + ); + expect(getVoicevoxTTSStyleId()).toBe(14); + // Android はネイティブモジュールを持たないため常に無効 + setPlatformOS('android'); + expect(isVoicevoxTTSEnabled()).toBe(false); + }); + + it('https 以外の URL・負のスタイル ID・型違いは無視してフォールバックする', async () => { + mockRemoteConfig({ + max_permit_accuracy: 1500, + voicevox_tts_enabled_ios: 'true', + voicevox_tts_manifest_url_ios: 'http://assets.example.com/m.json', + voicevox_tts_style_id_ios: -1, + }); + await setupRemoteConfig(); + + setPlatformOS('ios'); + expect(isVoicevoxTTSEnabled()).toBe(false); + expect(getVoicevoxTTSManifestUrl()).toBeNull(); + expect(getVoicevoxTTSStyleId()).toBe(30); + }); + + it('スタイル ID 0 (四国めたん あまあま) も受理する', async () => { + mockRemoteConfig({ + max_permit_accuracy: 1500, + voicevox_tts_style_id_ios: 0, + }); + await setupRemoteConfig(); + expect(getVoicevoxTTSStyleId()).toBe(0); + }); +}); + describe('isAIAgentFeatureEnabled(サーバー側キルスイッチ)', () => { it('falls back to false before setup', () => { expect(isAIAgentFeatureEnabled()).toBe(false); diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index ceefa2aba..011a05fbb 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -1,5 +1,6 @@ import { Platform } from 'react-native'; import { MAX_PERMIT_ACCURACY } from '~/constants/location'; +import { VOICEVOX_DEFAULT_STYLE_ID } from '~/constants/voicevox'; import { workerUrl } from './workerApi'; // Cloudflare Worker(/config/remote) 配信の設定キー。Worker 側のレスポンスキーと一致させる。 @@ -33,6 +34,17 @@ export const REMOTE_CONFIG_KEYS = { // 端末内蔵 TTS(expo-speech)で読み上げる。 REMOTE_TTS_ENABLED_IOS: 'remote_tts_enabled_ios', REMOTE_TTS_ENABLED_ANDROID: 'remote_tts_enabled_android', + // iOS でリモート TTS が使えない回の日本語フォールバックを、端末内蔵 TTS の代わりに + // VOICEVOX CORE (端末内合成) で読み上げるかどうか。辞書と音声モデル (約 180MB) を + // 初回に取得するため、配信 URL とセットで有効化する。未配信・取得失敗時は無効 + // (従来どおり端末内蔵 TTS へフォールバック)。App Clip と Android は対象外。 + VOICEVOX_TTS_ENABLED_IOS: 'voicevox_tts_enabled_ios', + // VOICEVOX の辞書・音声モデルを列挙したマニフェスト JSON の URL。 + // 形式は src/lib/voicevox/manifest.ts を参照。 + VOICEVOX_TTS_MANIFEST_URL_IOS: 'voicevox_tts_manifest_url_ios', + // VOICEVOX のスタイル ID (話者と声色)。配信した音声モデルに含まれる ID を指定する。 + // 未配信時は VOICEVOX_DEFAULT_STYLE_ID (No.7 アナウンス)。 + VOICEVOX_TTS_STYLE_ID_IOS: 'voicevox_tts_style_id_ios', // AIエージェント(行き先相談)機能の有効/無効。障害・コスト超過時にサーバー側から // エントリポイントごと機能を止められるようにするキルスイッチ。 AI_AGENT_ENABLED: 'ai_agent_enabled', @@ -50,6 +62,9 @@ type RemoteConfigResponse = { tts_enabled_android?: boolean; remote_tts_enabled_ios?: boolean; remote_tts_enabled_android?: boolean; + voicevox_tts_enabled_ios?: boolean; + voicevox_tts_manifest_url_ios?: string; + voicevox_tts_style_id_ios?: number; ai_agent_enabled?: boolean; }; @@ -79,6 +94,10 @@ const REMOTE_TTS_ENABLED_ANDROID_FALLBACK = false; // 未配信・取得失敗時は安全側に倒して無効とする。 const AI_AGENT_ENABLED_FALLBACK = false; +// VOICEVOX フォールバックのフォールバック既定値。約 180MB の辞書・音声モデルを +// 端末へ取得する機能のため、Remote Config で明示的に有効化されたときだけ動かす。 +const VOICEVOX_TTS_ENABLED_IOS_FALLBACK = false; + // リモート設定の数値は「有限かつ正」のみ受理する(0・負値・非数はフォールバックへ倒す)。 // 真偽値や配列は Number() で 1 や 5 に化けるため、number 型に限定してから検証する。 const parsePositiveFiniteNumber = (value: unknown): number | null => { @@ -88,6 +107,27 @@ const parsePositiveFiniteNumber = (value: unknown): number | null => { return Number.isFinite(value) && value > 0 ? value : null; }; +// 配信 URL は https のみ受理する。辞書・音声モデルという実行に直結するバイナリを +// 取得する先なので、平文 HTTP や不正な文字列はフォールバック(未配信)へ倒す。 +const parseHttpsUrl = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!/^https:\/\/[^\s]+$/.test(trimmed)) { + return null; + } + return trimmed; +}; + +// スタイル ID は 0 以上の整数のみ受理する(0 は四国めたん あまあま)。 +const parseNonNegativeInteger = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return null; + } + return value; +}; + // getMaxPermitAccuracy / isForceNotArrivedOnLowAccuracyEnabled 等はGPS更新のたびに // 呼ばれるホットパスのため、起動時に /config/remote から取得した値をモジュール内に // キャッシュし、以降は同期的に返す。取得失敗時はキャッシュせずフォールバックを返す。 @@ -101,6 +141,9 @@ let cachedTTSEnabledAndroid: boolean | null = null; let cachedRemoteTTSEnabledIOS: boolean | null = null; let cachedRemoteTTSEnabledAndroid: boolean | null = null; let cachedAIAgentEnabled: boolean | null = null; +let cachedVoicevoxTTSEnabledIOS: boolean | null = null; +let cachedVoicevoxTTSManifestUrlIOS: string | null = null; +let cachedVoicevoxTTSStyleIdIOS: number | null = null; // setupRemoteConfig は起動時に非同期で完了するため、初回レンダー後にキャッシュが // 更新されても React は再レンダーしない。UI(FxTTS・設定画面)が useSyncExternalStore @@ -133,6 +176,9 @@ export const resetRemoteConfigCache = (): void => { cachedRemoteTTSEnabledIOS = null; cachedRemoteTTSEnabledAndroid = null; cachedAIAgentEnabled = null; + cachedVoicevoxTTSEnabledIOS = null; + cachedVoicevoxTTSManifestUrlIOS = null; + cachedVoicevoxTTSStyleIdIOS = null; notifyRemoteConfigListeners(); }; @@ -185,6 +231,17 @@ export const setupRemoteConfig = async (): Promise => { if (typeof data.ai_agent_enabled === 'boolean') { cachedAIAgentEnabled = data.ai_agent_enabled; } + if (typeof data.voicevox_tts_enabled_ios === 'boolean') { + cachedVoicevoxTTSEnabledIOS = data.voicevox_tts_enabled_ios; + } + const manifestUrl = parseHttpsUrl(data.voicevox_tts_manifest_url_ios); + if (manifestUrl != null) { + cachedVoicevoxTTSManifestUrlIOS = manifestUrl; + } + const styleId = parseNonNegativeInteger(data.voicevox_tts_style_id_ios); + if (styleId != null) { + cachedVoicevoxTTSStyleIdIOS = styleId; + } notifyRemoteConfigListeners(); }; @@ -291,3 +348,30 @@ export const isAIAgentFeatureEnabled = (): boolean => { } return AI_AGENT_ENABLED_FALLBACK; }; + +/** + * iOS でリモート TTS が使えない回の日本語フォールバックを VOICEVOX (端末内合成) で + * 読み上げるかどうかを同期的に取得する。isRemoteTTSEnabled とは独立しており、 + * リモート合成を使う構成のまま「フォールバック先だけ」を差し替える。有効でも + * 辞書・音声モデルが未取得の間や App Clip では従来どおり端末内蔵 TTS が使われる。 + * Android は対象外(ネイティブモジュールを持たない)のため常に false。 + */ +export const isVoicevoxTTSEnabled = (): boolean => { + if (Platform.OS !== 'ios') { + return false; + } + return cachedVoicevoxTTSEnabledIOS ?? VOICEVOX_TTS_ENABLED_IOS_FALLBACK; +}; + +/** + * VOICEVOX の辞書・音声モデルを列挙したマニフェスト JSON の URL。未配信なら null で、 + * その場合は有効化されていても資産を取得できないため VOICEVOX は使われない。 + */ +export const getVoicevoxTTSManifestUrl = (): string | null => + cachedVoicevoxTTSManifestUrlIOS; + +/** + * VOICEVOX のスタイル ID。未配信・不正値ならフォールバック(No.7 アナウンス)。 + */ +export const getVoicevoxTTSStyleId = (): number => + cachedVoicevoxTTSStyleIdIOS ?? VOICEVOX_DEFAULT_STYLE_ID; diff --git a/src/lib/voicevox/assets.test.ts b/src/lib/voicevox/assets.test.ts new file mode 100644 index 000000000..66d11d017 --- /dev/null +++ b/src/lib/voicevox/assets.test.ts @@ -0,0 +1,517 @@ +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { + cancelVoicevoxAssetsDownload, + deleteVoicevoxAssets, + ensureVoicevoxAssets, + fileUriToPath, + getInstalledVoicevoxAssets, + getVoicevoxAssetsStatus, + hasVoicevoxDownloadConsent, + requestVoicevoxAssetsDownload, + resetVoicevoxAssetsStateForTest, + subscribeVoicevoxAssets, +} from './assets'; + +// --- expo-file-system をインメモリで模倣する ------------------------------- +// 実ファイルは触らず、「存在するファイルとそのサイズ」だけを Map で管理する。 +const mockFiles = new Map(); +const mockDownload = jest.fn(); + +jest.mock('expo-file-system', () => { + class MockDirectory { + public uri: string; + constructor(...parts: Array) { + this.uri = parts + .map((p) => (typeof p === 'string' ? p : p.uri)) + .join('/') + .replace(/\/+/g, '/') + .replace(/^file:\/+/, 'file:///'); + } + get name() { + return this.uri.split('/').filter(Boolean).pop() ?? ''; + } + get exists() { + return [...mockFiles.keys()].some((key) => + key.startsWith(`${this.uri}/`) + ); + } + create() {} + delete() { + for (const key of [...mockFiles.keys()]) { + if (key.startsWith(`${this.uri}/`)) { + mockFiles.delete(key); + } + } + } + list() { + const prefix = `${this.uri}/`; + const names = new Set(); + for (const key of mockFiles.keys()) { + if (key.startsWith(prefix)) { + names.add(key.slice(prefix.length).split('/')[0] ?? ''); + } + } + return [...names].map((n) => new MockDirectory(this.uri, n)); + } + } + class MockFile { + public uri: string; + constructor(...parts: Array) { + this.uri = parts + .map((p) => (typeof p === 'string' ? p : p.uri)) + .join('/') + .replace(/\/+/g, '/') + .replace(/^file:\/+/, 'file:///'); + } + get exists() { + return mockFiles.has(this.uri); + } + get size() { + return mockFiles.get(this.uri) ?? null; + } + get parentDirectory() { + return new MockDirectory(this.uri.slice(0, this.uri.lastIndexOf('/'))); + } + delete() { + mockFiles.delete(this.uri); + } + static downloadFileAsync = ( + url: string, + destination: MockFile, + options?: unknown + ) => mockDownload(url, destination, options); + } + return { + Paths: { document: new MockDirectory('file:///docs') }, + Directory: MockDirectory, + File: MockFile, + }; +}); + +// --- ネイティブモジュール ------------------------------------------------------ +const mockSha256 = jest.fn(); +const mockSetExcludedFromBackup = jest.fn(); +const mockRelease = jest.fn(async () => undefined); +let mockModuleAvailable = true; +jest.mock('~/utils/native/ios/voicevoxTtsModule', () => ({ + getVoicevoxTtsModule: () => + mockModuleAvailable + ? { + sha256: (path: string) => mockSha256(path), + setExcludedFromBackup: (path: string) => + mockSetExcludedFromBackup(path), + release: () => mockRelease(), + } + : null, +})); + +// --- Remote Config / fetch ---------------------------------------------------- +let mockManifestUrl: string | null = 'https://cfg.example.com/manifest.json'; +let mockEnabled = true; +jest.mock('~/lib/remoteConfig', () => ({ + getVoicevoxTTSManifestUrl: () => mockManifestUrl, + isVoicevoxTTSEnabled: () => mockEnabled, +})); + +const mockFetch = jest.fn(); +jest.mock('expo/fetch', () => ({ + fetch: (...args: unknown[]) => mockFetch(...args), +})); + +const manifest = { + schemaVersion: 1, + version: 'v1', + openJtalkDicDir: 'dic', + voiceModels: ['6.vvm'], + files: [ + { + path: 'dic/sys.dic', + url: 'https://assets.example.com/dic/sys.dic', + sha256: 'a'.repeat(64), + bytes: 100, + }, + { + path: '6.vvm', + url: 'https://assets.example.com/6.vvm', + sha256: 'b'.repeat(64), + bytes: 50, + }, + ], +}; + +const expectedSha: Record = { + 'file:///docs/voicevox/v1/dic/sys.dic': 'a'.repeat(64), + 'file:///docs/voicevox/v1/6.vvm': 'b'.repeat(64), +}; + +const mockManifestResponse = (body: unknown, ok = true) => { + mockFetch.mockResolvedValueOnce({ + ok, + status: ok ? 200 : 500, + json: async () => body, + }); +}; + +describe('voicevox/assets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFiles.clear(); + storage.remove(STORAGE_KEYS.VOICEVOX_ASSETS); + // 既定は同意済みとして、取得の流れ自体を検証する。同意ゲートは個別に検証する + storage.set(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED, 'true'); + resetVoicevoxAssetsStateForTest(); + mockModuleAvailable = true; + mockEnabled = true; + mockManifestUrl = 'https://cfg.example.com/manifest.json'; + // ダウンロードは宛先にマニフェストどおりのサイズのファイルを作る + mockDownload.mockImplementation( + async (url: string, destination: { uri: string }) => { + const entry = manifest.files.find((f) => f.url === url); + mockFiles.set(destination.uri, entry?.bytes ?? 0); + return destination; + } + ); + mockSha256.mockImplementation( + async (path: string) => expectedSha[`file://${path}`] ?? 'deadbeef' + ); + }); + + it('fileUriToPath は file:// を外してデコードする', () => { + expect(fileUriToPath('file:///docs/voicevox/v1/6.vvm')).toBe( + '/docs/voicevox/v1/6.vvm' + ); + expect(fileUriToPath('file:///docs/%E9%9F%B3%E5%A3%B0/6.vvm')).toBe( + '/docs/音声/6.vvm' + ); + }); + + it('マニフェストどおりに取得・検証して記録し、パスを返す', async () => { + mockManifestResponse(manifest); + + const result = await ensureVoicevoxAssets(); + + expect(result).toEqual({ + version: 'v1', + openJtalkDicDir: '/docs/voicevox/v1/dic', + voiceModelPaths: ['/docs/voicevox/v1/6.vvm'], + totalBytes: 150, + }); + expect(mockDownload).toHaveBeenCalledTimes(2); + expect(mockSha256).toHaveBeenCalledWith('/docs/voicevox/v1/dic/sys.dic'); + expect(mockSetExcludedFromBackup).toHaveBeenCalledWith('/docs/voicevox'); + expect( + JSON.parse(storage.getString(STORAGE_KEYS.VOICEVOX_ASSETS) ?? 'null') + ).toMatchObject({ version: 'v1', voiceModels: ['6.vvm'] }); + // 取得済みは同期的にも参照できる + expect(getInstalledVoicevoxAssets()).toEqual(result); + }); + + it('検証済みのファイルは再ダウンロードしない', async () => { + mockFiles.set('file:///docs/voicevox/v1/dic/sys.dic', 100); + mockFiles.set('file:///docs/voicevox/v1/6.vvm', 50); + mockManifestResponse(manifest); + + await ensureVoicevoxAssets(); + + expect(mockDownload).not.toHaveBeenCalled(); + expect(mockSha256).toHaveBeenCalledTimes(2); + }); + + it('ハッシュが合わないダウンロードは削除して失敗にする', async () => { + mockSha256.mockResolvedValue('0'.repeat(64)); + mockManifestResponse(manifest); + + const result = await ensureVoicevoxAssets(); + + expect(result).toBeNull(); + expect(mockFiles.has('file:///docs/voicevox/v1/dic/sys.dic')).toBe(false); + expect(storage.getString(STORAGE_KEYS.VOICEVOX_ASSETS)).toBeUndefined(); + }); + + it('記録があればファイルの存在とサイズだけで復元し、再ハッシュしない', async () => { + mockManifestResponse(manifest); + await ensureVoicevoxAssets(); + resetVoicevoxAssetsStateForTest(); + mockSha256.mockClear(); + + expect(getInstalledVoicevoxAssets()).toEqual({ + version: 'v1', + openJtalkDicDir: '/docs/voicevox/v1/dic', + voiceModelPaths: ['/docs/voicevox/v1/6.vvm'], + totalBytes: 150, + }); + expect(mockSha256).not.toHaveBeenCalled(); + }); + + it('記録どおりのファイルが欠けていれば未取得として扱う', async () => { + mockManifestResponse(manifest); + await ensureVoicevoxAssets(); + resetVoicevoxAssetsStateForTest(); + mockFiles.delete('file:///docs/voicevox/v1/6.vvm'); + + expect(getInstalledVoicevoxAssets()).toBeNull(); + }); + + it('バージョンが変わったら取り直し、旧バージョンを削除する', async () => { + mockManifestResponse(manifest); + await ensureVoicevoxAssets(); + expect(mockFiles.has('file:///docs/voicevox/v1/6.vvm')).toBe(true); + + const next = { + ...manifest, + version: 'v2', + files: manifest.files.map((f) => ({ + ...f, + url: f.url.replace('example.com', 'example.com/v2'), + })), + }; + mockDownload.mockImplementation( + async (url: string, destination: { uri: string }) => { + const entry = next.files.find((f) => f.url === url); + mockFiles.set(destination.uri, entry?.bytes ?? 0); + return destination; + } + ); + mockSha256.mockImplementation( + async (path: string) => + expectedSha[`file://${path.replace('/v2/', '/v1/')}`] ?? '' + ); + mockManifestResponse(next); + + const result = await ensureVoicevoxAssets(); + + expect(result?.version).toBe('v2'); + expect(mockFiles.has('file:///docs/voicevox/v2/6.vvm')).toBe(true); + expect(mockFiles.has('file:///docs/voicevox/v1/6.vvm')).toBe(false); + }); + + it('同時に呼ばれても取得は 1 本にまとめる', async () => { + mockManifestResponse(manifest); + + const [a, b] = await Promise.all([ + ensureVoicevoxAssets(), + ensureVoicevoxAssets(), + ]); + + expect(a).toEqual(b); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('マニフェスト取得に失敗した直後は再試行しない', async () => { + mockManifestResponse({}, false); + expect(await ensureVoicevoxAssets()).toBeNull(); + + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('ネイティブモジュールが無い構成 (App Clip・Android) では何もしない', async () => { + mockModuleAvailable = false; + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('配信 URL が未設定なら何もしない', async () => { + mockManifestUrl = null; + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('不正なマニフェストは弾いて null を返す', async () => { + mockManifestResponse({ ...manifest, files: [] }); + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(mockDownload).not.toHaveBeenCalled(); + }); + describe('同意ゲート', () => { + it('未同意なら ensureVoicevoxAssets は何もしない', async () => { + storage.remove(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED); + + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(getVoicevoxAssetsStatus().phase).toBe('not_downloaded'); + }); + + it('requestVoicevoxAssetsDownload は同意を記録して直ちに取得する', async () => { + storage.remove(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED); + mockManifestResponse(manifest); + + const result = await requestVoicevoxAssetsDownload(); + + expect(result?.version).toBe('v1'); + expect(hasVoicevoxDownloadConsent()).toBe(true); + }); + + it('失敗直後でも requestVoicevoxAssetsDownload は待機せず再試行する', async () => { + mockManifestResponse({}, false); + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(getVoicevoxAssetsStatus()).toMatchObject({ + phase: 'error', + errorMessage: 'manifest fetch failed: 500', + }); + + mockManifestResponse(manifest); + expect((await requestVoicevoxAssetsDownload())?.version).toBe('v1'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('Remote Config で無効なら取得しない', async () => { + mockEnabled = false; + expect(await ensureVoicevoxAssets()).toBeNull(); + expect(await requestVoicevoxAssetsDownload()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(getVoicevoxAssetsStatus().phase).toBe('unsupported'); + }); + }); + + describe('進捗と状態', () => { + it('取得中はファイルごとの進捗を合計バイト数に対して通知する', async () => { + const seen: Array<[string, number, number]> = []; + subscribeVoicevoxAssets(() => { + const status = getVoicevoxAssetsStatus(); + seen.push([status.phase, status.downloadedBytes, status.totalBytes]); + }); + mockDownload.mockImplementation( + async ( + url: string, + destination: { uri: string }, + options: { onProgress: (p: { bytesWritten: number }) => void } + ) => { + const entry = manifest.files.find((f) => f.url === url); + options.onProgress({ + bytesWritten: Math.floor((entry?.bytes ?? 0) / 2), + }); + mockFiles.set(destination.uri, entry?.bytes ?? 0); + return destination; + } + ); + mockManifestResponse(manifest); + + await ensureVoicevoxAssets(); + + expect(seen).toEqual( + expect.arrayContaining([ + ['downloading', 0, 150], + ['downloading', 50, 150], + ['downloading', 100, 150], + ['downloading', 125, 150], + ['downloading', 150, 150], + ['installed', 150, 150], + ]) + ); + expect(getVoicevoxAssetsStatus()).toMatchObject({ + phase: 'installed', + version: 'v1', + totalBytes: 150, + }); + }); + + it('検証済みで飛ばしたファイルも進捗に含める', async () => { + mockFiles.set('file:///docs/voicevox/v1/dic/sys.dic', 100); + const seen: number[] = []; + subscribeVoicevoxAssets(() => { + seen.push(getVoicevoxAssetsStatus().downloadedBytes); + }); + mockManifestResponse(manifest); + + await ensureVoicevoxAssets(); + + expect(seen).toContain(100); + expect(mockDownload).toHaveBeenCalledTimes(1); + }); + + it('状態が変わらない限り同じスナップショットを返す', () => { + const a = getVoicevoxAssetsStatus(); + const b = getVoicevoxAssetsStatus(); + expect(a).toBe(b); + expect(a.phase).toBe('not_downloaded'); + }); + }); + + describe('キャンセルと削除', () => { + it('キャンセルすると取得を中断し同意を取り消す', async () => { + let abortSignal: AbortSignal | null = null; + mockDownload.mockImplementation( + ( + _url: string, + _destination: { uri: string }, + options: { signal: AbortSignal } + ) => + new Promise((_resolve, reject) => { + abortSignal = options.signal; + options.signal.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ); + mockManifestResponse(manifest); + + const pending = ensureVoicevoxAssets(); + // マニフェスト取得と最初のダウンロード開始まで進める + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + expect(getVoicevoxAssetsStatus().phase).toBe('downloading'); + expect(abortSignal).not.toBeNull(); + + cancelVoicevoxAssetsDownload(); + expect(await pending).toBeNull(); + + expect(hasVoicevoxDownloadConsent()).toBe(false); + expect(getVoicevoxAssetsStatus()).toMatchObject({ + phase: 'not_downloaded', + errorMessage: null, + }); + }); + + it('キャンセル直後の再要求は、中断した取得が片付いてから取り直す', async () => { + let rejectDownload: ((e: unknown) => void) | null = null; + mockDownload.mockImplementationOnce( + ( + _url: string, + _destination: { uri: string }, + options: { signal: AbortSignal } + ) => + new Promise((_resolve, reject) => { + rejectDownload = reject; + options.signal.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ); + mockManifestResponse(manifest); + const first = ensureVoicevoxAssets(); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + expect(getVoicevoxAssetsStatus().phase).toBe('downloading'); + + cancelVoicevoxAssetsDownload(); + // 中断が片付く前に「ダウンロード」を押し直した + mockManifestResponse(manifest); + const second = requestVoicevoxAssetsDownload(); + expect(rejectDownload).not.toBeNull(); + + expect(await first).toBeNull(); + expect((await second)?.version).toBe('v1'); + expect(hasVoicevoxDownloadConsent()).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(getVoicevoxAssetsStatus().phase).toBe('installed'); + }); + + it('削除すると合成器を解放し、ファイルと記録と同意を消す', async () => { + mockManifestResponse(manifest); + await ensureVoicevoxAssets(); + expect(getVoicevoxAssetsStatus().phase).toBe('installed'); + + await deleteVoicevoxAssets(); + + expect(mockRelease).toHaveBeenCalledTimes(1); + expect(mockFiles.size).toBe(0); + expect(storage.getString(STORAGE_KEYS.VOICEVOX_ASSETS)).toBeUndefined(); + expect(hasVoicevoxDownloadConsent()).toBe(false); + expect(getInstalledVoicevoxAssets()).toBeNull(); + expect(getVoicevoxAssetsStatus().phase).toBe('not_downloaded'); + }); + }); +}); diff --git a/src/lib/voicevox/assets.ts b/src/lib/voicevox/assets.ts new file mode 100644 index 000000000..37b1ef285 --- /dev/null +++ b/src/lib/voicevox/assets.ts @@ -0,0 +1,573 @@ +import { fetch } from 'expo/fetch'; +import { Directory, File, Paths } from 'expo-file-system'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { + VOICEVOX_ASSET_DIR_NAME, + VOICEVOX_ASSET_RETRY_INTERVAL_MS, + VOICEVOX_MANIFEST_FETCH_TIMEOUT_MS, +} from '~/constants/voicevox'; +import { + getVoicevoxTTSManifestUrl, + isVoicevoxTTSEnabled, +} from '~/lib/remoteConfig'; +import { storage } from '~/lib/storage'; +import { getVoicevoxTtsModule } from '~/utils/native/ios/voicevoxTtsModule'; +import { + parseVoicevoxManifest, + type VoicevoxManifest, + type VoicevoxManifestFile, +} from './manifest'; + +// VOICEVOX の辞書 (Open JTalk) と音声モデル (VVM) を端末へ取得・検証して保持する。 +// +// - 置き場は Paths.document/voicevox// 。cache はストレージ逼迫時に OS が +// 消しうるため使わない。iCloud バックアップからはネイティブ側で除外する。 +// - マニフェスト (src/lib/voicevox/manifest.ts) の version が変わったら新しい +// ディレクトリへ取り直し、完了後に旧バージョンを削除する。 +// - 各ファイルはサイズと SHA-256 で検証する。ハッシュは 100MB 超の辞書を JS へ +// 読み込まずに済むようネイティブ (CryptoKit) で計算する。 +// - 検証済みの状態は MMKV (VOICEVOX_ASSETS) に記録し、次回起動以降はファイルの存在と +// サイズだけを確認して再ハッシュしない。 +// - 約 160MB の取得はユーザーの同意 (VOICEVOX_DOWNLOAD_CONSENTED) を得てから始める。 +// 自動アナウンスを有効化したときに設定画面がダイアログで同意を取り、同意後は +// 中断した取得や更新版の取得を起動時に自動で再開する。 +// - 取得は同時に 1 本だけ走らせ、失敗後は一定時間再試行しない (圏外で放送のたびに +// マニフェスト取得を試みて電池を浪費しないため)。 +// - 進捗と状態は subscribeVoicevoxAssets / getVoicevoxAssetsStatus で購読できる +// (設定画面の進捗表示用。useSyncExternalStore 互換)。 + +// ネイティブモジュールへ渡す、取得済み資産の絶対パス (file:// を除いたもの) +export interface VoicevoxInstalledAssets { + version: string; + openJtalkDicDir: string; + voiceModelPaths: string[]; + // 資産一式の合計バイト数 (設定画面の表示用) + totalBytes: number; +} + +// 取得の進行状況。設定画面はこれを購読して表示する。 +export type VoicevoxAssetsPhase = + // ネイティブモジュールが無い (App Clip / Android)・Remote Config で無効・配信 URL 未設定 + 'unsupported' | 'not_downloaded' | 'downloading' | 'installed' | 'error'; + +export interface VoicevoxAssetsStatus { + phase: VoicevoxAssetsPhase; + // downloading のときの進捗。totalBytes はマニフェストの合計で、取得済み分 + // (検証済みで飛ばしたファイルを含む) が downloadedBytes に入る + downloadedBytes: number; + totalBytes: number; + // installed のときのバージョン + version: string | null; + // error のときのメッセージ (ログ・表示用) + errorMessage: string | null; +} + +// MMKV へ保存する検証済み状態 +interface InstalledRecord { + version: string; + openJtalkDicDir: string; + voiceModels: string[]; + files: Array<{ path: string; bytes: number }>; +} + +interface DownloadProgressState { + downloadedBytes: number; + totalBytes: number; +} + +let installedCache: VoicevoxInstalledAssets | null = null; +let inFlight: Promise | null = null; +let lastFailureAt = 0; +let lastErrorMessage: string | null = null; +let progress: DownloadProgressState | null = null; +let abortController: AbortController | null = null; + +const listeners = new Set<() => void>(); +let statusSnapshot: VoicevoxAssetsStatus | null = null; + +const notify = (): void => { + // 次の getVoicevoxAssetsStatus で作り直させる + statusSnapshot = null; + for (const listener of listeners) { + listener(); + } +}; + +/** + * 状態変化の購読 (useSyncExternalStore 互換)。Remote Config の変化は含まないので、 + * 表示側は subscribeRemoteConfig も合わせて購読する + */ +export const subscribeVoicevoxAssets = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * テスト用。モジュール内の状態を初期化する + */ +export const resetVoicevoxAssetsStateForTest = (): void => { + installedCache = null; + inFlight = null; + lastFailureAt = 0; + lastErrorMessage = null; + progress = null; + abortController = null; + statusSnapshot = null; + listeners.clear(); +}; + +/** + * expo-file-system の file:// URI をネイティブ API に渡せるパスへ変換する + */ +export const fileUriToPath = (uri: string): string => + decodeURIComponent(uri.replace(/^file:\/\//, '')); + +const rootDirectory = (): Directory => + new Directory(Paths.document, VOICEVOX_ASSET_DIR_NAME); + +const versionDirectory = (version: string): Directory => + new Directory(rootDirectory(), version); + +const fileFor = (dir: Directory, relativePath: string): File => + new File(dir, ...relativePath.split('/')); + +const isSupported = (): boolean => + getVoicevoxTtsModule() !== null && + isVoicevoxTTSEnabled() && + getVoicevoxTTSManifestUrl() !== null; + +/** + * ユーザーが取得に同意済みか。同意はダウンロードのキャンセルか資産の削除で取り消す + */ +export const hasVoicevoxDownloadConsent = (): boolean => + storage.getString(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED) === 'true'; + +const setDownloadConsent = (consented: boolean): void => { + if (consented) { + storage.set(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED, 'true'); + } else { + storage.remove(STORAGE_KEYS.VOICEVOX_DOWNLOAD_CONSENTED); + } +}; + +const readInstalledRecord = (): InstalledRecord | null => { + const raw = storage.getString(STORAGE_KEYS.VOICEVOX_ASSETS); + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.version !== 'string' || + typeof parsed.openJtalkDicDir !== 'string' || + !Array.isArray(parsed.voiceModels) || + !Array.isArray(parsed.files) + ) { + return null; + } + return parsed as InstalledRecord; + } catch { + return null; + } +}; + +const toInstalledAssets = ( + record: InstalledRecord +): VoicevoxInstalledAssets => { + const dir = versionDirectory(record.version); + return { + version: record.version, + openJtalkDicDir: fileUriToPath( + new Directory(dir, ...record.openJtalkDicDir.split('/')).uri + ), + voiceModelPaths: record.voiceModels.map((model) => + fileUriToPath(fileFor(dir, model).uri) + ), + totalBytes: record.files.reduce((sum, file) => sum + file.bytes, 0), + }; +}; + +// 記録どおりのファイルがサイズ込みで揃っているか (再ハッシュはしない) +const isRecordIntact = (record: InstalledRecord): boolean => { + const dir = versionDirectory(record.version); + return record.files.every((entry) => { + const file = fileFor(dir, entry.path); + return file.exists && file.size === entry.bytes; + }); +}; + +/** + * 取得・検証済みの資産を同期的に返す。未取得なら null。 + * 起動後の初回呼び出しでは MMKV の記録とファイルの存在を突き合わせる。 + */ +export const getInstalledVoicevoxAssets = + (): VoicevoxInstalledAssets | null => { + if (installedCache) { + return installedCache; + } + const record = readInstalledRecord(); + if (!record) { + return null; + } + try { + if (!isRecordIntact(record)) { + return null; + } + } catch (e) { + console.warn('[voicevox/assets] failed to inspect installed assets:', e); + return null; + } + installedCache = toInstalledAssets(record); + return installedCache; + }; + +/** + * 表示用の状態を同期的に返す。値が変わらない限り同じオブジェクトを返すので + * useSyncExternalStore の getSnapshot に渡せる。 + */ +export const getVoicevoxAssetsStatus = (): VoicevoxAssetsStatus => { + const installed = getInstalledVoicevoxAssets(); + let next: VoicevoxAssetsStatus; + if (!isSupported()) { + next = { + phase: 'unsupported', + downloadedBytes: 0, + totalBytes: 0, + version: null, + errorMessage: null, + }; + } else if (progress) { + next = { + phase: 'downloading', + downloadedBytes: progress.downloadedBytes, + totalBytes: progress.totalBytes, + version: installed?.version ?? null, + errorMessage: null, + }; + } else if (lastErrorMessage) { + next = { + phase: 'error', + downloadedBytes: 0, + totalBytes: 0, + version: installed?.version ?? null, + errorMessage: lastErrorMessage, + }; + } else if (installed) { + next = { + phase: 'installed', + downloadedBytes: installed.totalBytes, + totalBytes: installed.totalBytes, + version: installed.version, + errorMessage: null, + }; + } else { + next = { + phase: 'not_downloaded', + downloadedBytes: 0, + totalBytes: 0, + version: null, + errorMessage: null, + }; + } + + const prev = statusSnapshot; + if ( + prev && + prev.phase === next.phase && + prev.downloadedBytes === next.downloadedBytes && + prev.totalBytes === next.totalBytes && + prev.version === next.version && + prev.errorMessage === next.errorMessage + ) { + return prev; + } + statusSnapshot = next; + return next; +}; + +const fetchManifest = async ( + url: string, + signal: AbortSignal +): Promise => { + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal.addEventListener('abort', onAbort); + const timeoutId = setTimeout( + () => controller.abort(), + VOICEVOX_MANIFEST_FETCH_TIMEOUT_MS + ); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`manifest fetch failed: ${response.status}`); + } + return parseVoicevoxManifest(await response.json()); + } finally { + clearTimeout(timeoutId); + signal.removeEventListener('abort', onAbort); + } +}; + +const verifyFile = async ( + file: File, + expected: VoicevoxManifestFile, + sha256: (path: string) => Promise +): Promise => { + if (!file.exists || file.size !== expected.bytes) { + return false; + } + const digest = await sha256(fileUriToPath(file.uri)); + return digest.toLowerCase() === expected.sha256; +}; + +const installFile = async ( + dir: Directory, + entry: VoicevoxManifestFile, + sha256: (path: string) => Promise, + signal: AbortSignal, + onProgress: (bytesWritten: number) => void +): Promise => { + const file = fileFor(dir, entry.path); + if (await verifyFile(file, entry, sha256)) { + onProgress(entry.bytes); + return; + } + file.parentDirectory.create({ intermediates: true, idempotent: true }); + if (file.exists) { + file.delete(); + } + await File.downloadFileAsync(entry.url, file, { + idempotent: true, + signal, + onProgress: ({ bytesWritten }) => { + // Content-Length が無い場合も bytesWritten は進む。マニフェストのサイズを + // 上限にして、サーバー側の計上違いで 100% を超えないようにする + onProgress(Math.min(bytesWritten, entry.bytes)); + }, + }); + if (!(await verifyFile(file, entry, sha256))) { + // 壊れたファイルを残すと次回の存在チェックをすり抜けるため必ず消す + try { + file.delete(); + } catch {} + throw new Error(`downloaded file failed verification: ${entry.path}`); + } + onProgress(entry.bytes); +}; + +// 現行バージョン以外のディレクトリを削除する。失敗しても取得結果には影響させない +const pruneOtherVersions = (currentVersion: string): void => { + const root = rootDirectory(); + if (!root.exists) { + return; + } + for (const item of root.list()) { + if (item instanceof Directory && item.name !== currentVersion) { + try { + item.delete(); + } catch (e) { + console.warn('[voicevox/assets] failed to prune old assets:', e); + } + } + } +}; + +const installFromManifest = async ( + manifest: VoicevoxManifest, + signal: AbortSignal +): Promise => { + const module = getVoicevoxTtsModule(); + if (!module) { + throw new Error('VOICEVOX native module is unavailable'); + } + const sha256 = (path: string) => module.sha256(path); + + const root = rootDirectory(); + root.create({ intermediates: true, idempotent: true }); + const dir = versionDirectory(manifest.version); + dir.create({ intermediates: true, idempotent: true }); + + const totalBytes = manifest.files.reduce((sum, file) => sum + file.bytes, 0); + let completedBytes = 0; + let lastNotifiedBytes = 0; + // onProgress は 1 秒に何度も届く。設定画面を無駄に再描画しないよう、 + // 全体の 0.5% 進むか完了したときだけ通知する (約 160MB なら 800KB ごと) + const notifyThreshold = totalBytes / 200; + progress = { downloadedBytes: 0, totalBytes }; + notify(); + + // 辞書 (10 ファイル前後) と VVM を順に取得する。並列にしても回線が律速で、 + // 途中で失敗したときに揃わない中間状態が増えるだけなので直列で進める。 + for (const entry of manifest.files) { + if (signal.aborted) { + throw new DOMException('download cancelled', 'AbortError'); + } + await installFile(dir, entry, sha256, signal, (bytesWritten) => { + const downloadedBytes = Math.min( + completedBytes + bytesWritten, + totalBytes + ); + progress = { downloadedBytes, totalBytes }; + if ( + downloadedBytes - lastNotifiedBytes >= notifyThreshold || + downloadedBytes === totalBytes + ) { + lastNotifiedBytes = downloadedBytes; + notify(); + } + }); + completedBytes += entry.bytes; + } + + try { + await module.setExcludedFromBackup(fileUriToPath(root.uri)); + } catch (e) { + console.warn('[voicevox/assets] failed to exclude from backup:', e); + } + + const record: InstalledRecord = { + version: manifest.version, + openJtalkDicDir: manifest.openJtalkDicDir, + voiceModels: manifest.voiceModels, + files: manifest.files.map(({ path, bytes }) => ({ path, bytes })), + }; + storage.set(STORAGE_KEYS.VOICEVOX_ASSETS, JSON.stringify(record)); + pruneOtherVersions(manifest.version); + + installedCache = toInstalledAssets(record); + return installedCache; +}; + +const isAbortError = (e: unknown): boolean => + e instanceof Error && e.name === 'AbortError'; + +const runEnsure = ( + manifestUrl: string +): Promise => { + const controller = new AbortController(); + abortController = controller; + lastErrorMessage = null; + notify(); + + const task = (async () => { + try { + const manifest = await fetchManifest(manifestUrl, controller.signal); + const installed = getInstalledVoicevoxAssets(); + if (installed && installed.version === manifest.version) { + return installed; + } + const result = await installFromManifest(manifest, controller.signal); + console.warn( + `[voicevox/assets] installed version ${result.version} (${manifest.files.length} files)` + ); + return result; + } catch (e) { + if (isAbortError(e) || controller.signal.aborted) { + // ユーザーのキャンセル。エラーではなく未取得へ戻す + return getInstalledVoicevoxAssets(); + } + lastFailureAt = Date.now(); + lastErrorMessage = e instanceof Error ? e.message : String(e); + console.warn('[voicevox/assets] failed to prepare assets:', e); + // 取得に失敗しても、既に揃っている旧バージョンがあればそれを使い続ける + return getInstalledVoicevoxAssets(); + } finally { + progress = null; + if (abortController === controller) { + abortController = null; + } + inFlight = null; + notify(); + } + })(); + inFlight = task; + return task; +}; + +/** + * 同意済みなら、マニフェストを取得して資産が揃っていなければダウンロード・検証する。 + * 揃った資産のパスを返す。VOICEVOX が使えない構成 (ネイティブモジュール無し・ + * 無効・配信 URL 未設定)、未同意、取得失敗時は取得済みの資産 (無ければ null)。 + * 同時に複数回呼ばれても取得は 1 本にまとめ、失敗直後の再試行は間隔を空ける。 + */ +export const ensureVoicevoxAssets = + (): Promise => { + if (inFlight) { + return inFlight; + } + const manifestUrl = getVoicevoxTTSManifestUrl(); + if (!isSupported() || !manifestUrl || !hasVoicevoxDownloadConsent()) { + return Promise.resolve(getInstalledVoicevoxAssets()); + } + if (Date.now() - lastFailureAt < VOICEVOX_ASSET_RETRY_INTERVAL_MS) { + return Promise.resolve(getInstalledVoicevoxAssets()); + } + return runEnsure(manifestUrl); + }; + +/** + * ユーザーの明示的な操作で取得を始める (同意ダイアログの「ダウンロード」や + * 設定画面の再試行)。同意を記録し、失敗後の待機時間を無視して直ちに取得する。 + */ +export const requestVoicevoxAssetsDownload = + (): Promise => { + const manifestUrl = getVoicevoxTTSManifestUrl(); + if (!isSupported() || !manifestUrl) { + return Promise.resolve(getInstalledVoicevoxAssets()); + } + setDownloadConsent(true); + lastFailureAt = 0; + const current = inFlight; + if (current) { + if (!abortController?.signal.aborted) { + return current; + } + // キャンセル直後で中断中の取得が片付くのを待ち、その間に再びキャンセルされて + // いなければ取り直す (中断した取得は再開されないため) + return current.then(() => + hasVoicevoxDownloadConsent() + ? ensureVoicevoxAssets() + : getInstalledVoicevoxAssets() + ); + } + return runEnsure(manifestUrl); + }; + +/** + * 進行中の取得を中断し、同意も取り消す (次回起動時に勝手に再開しない)。 + * 取得済みのファイルは残すので、再度ダウンロードすると続きから進む。 + */ +export const cancelVoicevoxAssetsDownload = (): void => { + setDownloadConsent(false); + abortController?.abort(); +}; + +/** + * 取得済みの資産を削除して同意も取り消す。合成器が VVM を開いたままだと + * 削除に失敗しうるため、先にネイティブ側を解放する。 + */ +export const deleteVoicevoxAssets = async (): Promise => { + cancelVoicevoxAssetsDownload(); + if (inFlight) { + await inFlight; + } + try { + await getVoicevoxTtsModule()?.release(); + } catch (e) { + console.warn('[voicevox/assets] failed to release synthesizer:', e); + } + storage.remove(STORAGE_KEYS.VOICEVOX_ASSETS); + installedCache = null; + lastErrorMessage = null; + const root = rootDirectory(); + try { + if (root.exists) { + root.delete(); + } + } catch (e) { + console.warn('[voicevox/assets] failed to delete assets:', e); + } + notify(); +}; diff --git a/src/lib/voicevox/manifest.test.ts b/src/lib/voicevox/manifest.test.ts new file mode 100644 index 000000000..f974cb80e --- /dev/null +++ b/src/lib/voicevox/manifest.test.ts @@ -0,0 +1,102 @@ +import { parseVoicevoxManifest } from './manifest'; + +const validManifest = { + schemaVersion: 1, + version: '2026-09-08', + openJtalkDicDir: 'open_jtalk_dic_utf_8-1.11', + voiceModels: ['6.vvm'], + files: [ + { + path: 'open_jtalk_dic_utf_8-1.11/sys.dic', + url: 'https://assets.example.com/voicevox/sys.dic', + sha256: 'a'.repeat(64), + bytes: 103_000_000, + }, + { + path: '6.vvm', + url: 'https://assets.example.com/voicevox/6.vvm', + sha256: 'b'.repeat(64), + bytes: 55_000_000, + }, + ], +}; + +describe('parseVoicevoxManifest', () => { + it('正しいマニフェストを受理する', () => { + expect(parseVoicevoxManifest(validManifest)).toEqual(validManifest); + }); + + it('未知の schemaVersion は弾く', () => { + expect(() => + parseVoicevoxManifest({ ...validManifest, schemaVersion: 2 }) + ).toThrow(); + }); + + it('ディレクトリの外へ出る相対パスは弾く', () => { + for (const path of [ + '../sys.dic', + '/abs/sys.dic', + 'dic/../../x', + 'dic//sys.dic', + 'dic/./sys.dic', + ]) { + // 辞書と音声モデルは残し、パス検証だけで弾かれることを確かめる + expect(() => + parseVoicevoxManifest({ + ...validManifest, + files: [ + { ...validManifest.files[0], path }, + validManifest.files[0], + validManifest.files[1], + ], + }) + ).toThrow(/unsafe relative path/); + } + }); + + it('https 以外の URL は弾く', () => { + expect(() => + parseVoicevoxManifest({ + ...validManifest, + files: [ + { ...validManifest.files[0], url: 'http://assets.example.com/x' }, + validManifest.files[1], + ], + }) + ).toThrow(); + }); + + it('files に無い音声モデルを指定していたら弾く', () => { + expect(() => + parseVoicevoxManifest({ ...validManifest, voiceModels: ['7.vvm'] }) + ).toThrow(/7\.vvm/); + }); + + it('辞書ディレクトリ配下のファイルが 1 つも無ければ弾く', () => { + expect(() => + parseVoicevoxManifest({ ...validManifest, openJtalkDicDir: 'other' }) + ).toThrow(/openJtalkDicDir/); + }); + + it('SHA-256 は 16 進 64 桁のみ受理する', () => { + expect(() => + parseVoicevoxManifest({ + ...validManifest, + files: [ + { ...validManifest.files[0], sha256: 'A'.repeat(64) }, + validManifest.files[1], + ], + }) + ).toThrow(); + }); + + it('重複するパスは弾く', () => { + expect(() => + parseVoicevoxManifest({ + ...validManifest, + files: [validManifest.files[0], validManifest.files[0]], + voiceModels: ['open_jtalk_dic_utf_8-1.11/sys.dic'], + }) + ).toThrow(/duplicate/); + }); +}); diff --git a/src/lib/voicevox/manifest.ts b/src/lib/voicevox/manifest.ts new file mode 100644 index 000000000..c49c1e795 --- /dev/null +++ b/src/lib/voicevox/manifest.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; + +// VOICEVOX の辞書・音声モデルを列挙するマニフェスト。Remote Config の +// voicevox_tts_manifest_url_ios が指す JSON で、scripts/build-voicevox-manifest.mjs が +// 生成する。アプリはこれをもとに資産を取得・検証し、version ごとのディレクトリへ置く。 +// +// { +// "schemaVersion": 1, +// "version": "2026-09-08", +// "openJtalkDicDir": "open_jtalk_dic_utf_8-1.11", +// "voiceModels": ["6.vvm"], +// "files": [ +// { "path": "open_jtalk_dic_utf_8-1.11/sys.dic", "url": "https://…/sys.dic", +// "sha256": "…", "bytes": 103123456 }, +// … +// ] +// } + +// ディレクトリ配下に閉じた相対パスだけを許す。`..`・先頭 `/`・空セグメントは +// 取得先ディレクトリの外へ書き込みうるため弾く。 +const RELATIVE_PATH_SEGMENT = /^[A-Za-z0-9._-]+$/; +const isSafeRelativePath = (value: string): boolean => { + const segments = value.split('/'); + return segments.every( + (segment) => + segment.length > 0 && + segment !== '.' && + segment !== '..' && + RELATIVE_PATH_SEGMENT.test(segment) + ); +}; + +const relativePathSchema = z + .string() + .min(1) + .refine(isSafeRelativePath, { message: 'unsafe relative path' }); + +const httpsUrlSchema = z + .string() + .url() + .refine((value) => value.startsWith('https://'), { + message: 'url must use https', + }); + +export const voicevoxManifestFileSchema = z.object({ + path: relativePathSchema, + url: httpsUrlSchema, + sha256: z.string().regex(/^[0-9a-f]{64}$/), + bytes: z.number().int().positive(), +}); + +export const voicevoxManifestSchema = z + .object({ + schemaVersion: z.literal(1), + // 資産セットの識別子。変わると全ファイルを取り直し、旧ディレクトリを消す + version: z + .string() + .min(1) + .regex(/^[A-Za-z0-9._-]+$/), + openJtalkDicDir: relativePathSchema, + voiceModels: z.array(relativePathSchema).min(1), + files: z.array(voicevoxManifestFileSchema).min(1), + }) + .superRefine((manifest, ctx) => { + const paths = new Set(manifest.files.map((file) => file.path)); + if (paths.size !== manifest.files.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'files must not contain duplicate paths', + }); + } + for (const model of manifest.voiceModels) { + if (!paths.has(model)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `voice model ${model} is not listed in files`, + }); + } + } + const dicPrefix = `${manifest.openJtalkDicDir}/`; + if (!manifest.files.some((file) => file.path.startsWith(dicPrefix))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'openJtalkDicDir has no files', + }); + } + }); + +export type VoicevoxManifest = z.infer; +export type VoicevoxManifestFile = z.infer; + +/** マニフェスト JSON を検証して返す。不正なら zod のエラーを投げる。 */ +export const parseVoicevoxManifest = (json: unknown): VoicevoxManifest => + voicevoxManifestSchema.parse(json); diff --git a/src/screens/Licenses.test.tsx b/src/screens/Licenses.test.tsx new file mode 100644 index 000000000..3c1115204 --- /dev/null +++ b/src/screens/Licenses.test.tsx @@ -0,0 +1,76 @@ +import { render } from '@testing-library/react-native'; +import { FlatList, Platform } from 'react-native'; +import { isClip } from 'react-native-app-clip'; +import Licenses from './Licenses'; + +jest.mock('~/utils/isDevApp', () => ({ + isDevApp: false, +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn() }), +})); + +jest.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ showActionSheetWithOptions: jest.fn() }), +})); + +jest.mock('react-native-app-clip', () => ({ + isClip: jest.fn(() => false), +})); +const mockedIsClip = jest.mocked(isClip); + +jest.mock('~/components/FooterTabBar', () => () => null); +jest.mock('~/components/SettingsHeader', () => ({ + SettingsHeader: () => null, +})); +jest.mock('~/components/Button', () => () => null); +jest.mock('~/translation', () => ({ + translate: (key: string) => key, +})); + +// jest-expo の既定 Platform.OS は 'ios'。プラットフォーム別の表示を検証する際は +// 明示的に切り替え、afterEach で必ず元へ戻す。 +const originalPlatformOS = Platform.OS; +const setPlatformOS = (os: typeof Platform.OS) => { + Object.defineProperty(Platform, 'OS', { value: os, configurable: true }); +}; + +describe('Licenses', () => { + afterEach(() => { + jest.clearAllMocks(); + mockedIsClip.mockReturnValue(false); + setPlatformOS(originalPlatformOS); + }); + + // 一覧は FlatList で末尾の項目は初期描画されないため、描画結果ではなく + // FlatList に渡した data の id で判定する + const renderedIds = (): string[] => { + const screen = render(); + const list = screen.UNSAFE_getByType(FlatList); + return (list.props.data as Array<{ id: string }>).map((item) => item.id); + }; + + // VOICEVOX は iOS 本体アプリだけが使う素材なので、クレジットもそこでだけ出す + describe('VOICEVOX:No.7 のクレジット (iosOnly)', () => { + it('[iOS 本体アプリ] 表示する', () => { + setPlatformOS('ios'); + expect(renderedIds()).toContain('voicevox_no7'); + }); + + it('[iOS App Clip] Platform.OS が ios でも表示しない', () => { + setPlatformOS('ios'); + mockedIsClip.mockReturnValue(true); + const ids = renderedIds(); + expect(ids).not.toContain('voicevox_no7'); + // 他の項目は従来どおり + expect(ids).toContain('other_oss'); + expect(ids).toContain('ekidata_jp'); + }); + + it('[Android] 表示しない', () => { + setPlatformOS('android'); + expect(renderedIds()).not.toContain('voicevox_no7'); + }); + }); +}); diff --git a/src/screens/Licenses.tsx b/src/screens/Licenses.tsx index f3c392571..c93a95c1b 100644 --- a/src/screens/Licenses.tsx +++ b/src/screens/Licenses.tsx @@ -10,6 +10,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import { isClip } from 'react-native-app-clip'; import Button from '~/components/Button'; import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; @@ -35,6 +36,7 @@ type LicenseId = | 'tokyu_bus' | 'hakodate_city' | 'roboto' + | 'voicevox_no7' | 'other_oss'; type LicenseItem = { @@ -43,6 +45,9 @@ type LicenseItem = { icon: string; href: string; devOnly: boolean; + // iOS 本体アプリだけが使う素材 (VOICEVOX など) のクレジット。App Clip も + // Platform.OS は 'ios' だがネイティブモジュールを持たないため対象外 + iosOnly?: boolean; } & ( | { license?: undefined; licenseUrl?: undefined } | { license: string; licenseUrl: string } @@ -143,6 +148,10 @@ const ListFooter = ({ const CC_BY_URL = 'https://creativecommons.org/licenses/by/4.0/'; const APACHE_2_URL = 'https://www.apache.org/licenses/LICENSE-2.0'; const MIT_URL = 'https://opensource.org/licenses/MIT'; +// VOICEVOX 音声ライブラリ「No.7」の利用規約。iOS のオンデバイス TTS フォールバック +// (docs/spec/tts/on-device-tts-ios.md) で使う音声で、クレジット表記が利用条件 +const VOICEVOX_URL = 'https://voicevox.hiroshiba.jp/'; +const VOICEVOX_NO7_TERMS_URL = 'https://voiceseven.com/#j0200'; const ODPT_BASIC_LICENSE_URL = 'https://developer.odpt.org/terms/data_basic_license.html'; // 公共交通オープンデータセンターのGTFSデータ利用規約 (ckan.odpt.org の函館市電データセットが指定するライセンス) @@ -281,6 +290,17 @@ const Licenses: React.FC = () => { licenseUrl: APACHE_2_URL, devOnly: false, }, + { + // クレジット表記「VOICEVOX:No.7」は規約で定められた文言のため翻訳しない + id: 'voicevox_no7', + title: 'VOICEVOX:No.7', + icon: '🗣️', + href: VOICEVOX_URL, + license: translate('voicevoxNo7Terms'), + licenseUrl: VOICEVOX_NO7_TERMS_URL, + devOnly: false, + iosOnly: true, + }, { id: 'other_oss', title: translate('otherOss'), @@ -291,7 +311,12 @@ const Licenses: React.FC = () => { devOnly: false, }, ] as const - ).filter((it) => (isDevApp ? true : !it.devOnly)), + ).filter( + (it) => + (isDevApp ? true : !it.devOnly) && + (!('iosOnly' in it && it.iosOnly) || + (Platform.OS === 'ios' && !isClip())) + ), [] ); diff --git a/src/screens/TTSSettings.test.tsx b/src/screens/TTSSettings.test.tsx index 47af9837a..a98d30c5a 100644 --- a/src/screens/TTSSettings.test.tsx +++ b/src/screens/TTSSettings.test.tsx @@ -41,6 +41,38 @@ jest.mock('react-native-app-clip', () => ({ isClip: jest.fn(() => false), })); +// オフライン用日本語音声 (VOICEVOX) の取得状態。既定は「対象外」にして既存の +// テストに影響させず、プロンプトと進捗表示のテストで状態を差し替える。 +type MockVoicevoxStatus = { + phase: + | 'unsupported' + | 'not_downloaded' + | 'downloading' + | 'installed' + | 'error'; + downloadedBytes: number; + totalBytes: number; + version: string | null; + errorMessage: string | null; +}; +let mockVoicevoxStatus: MockVoicevoxStatus = { + phase: 'unsupported', + downloadedBytes: 0, + totalBytes: 0, + version: null, + errorMessage: null, +}; +const mockRequestVoicevoxAssetsDownload = jest.fn(async () => null); +const mockCancelVoicevoxAssetsDownload = jest.fn(); +const mockDeleteVoicevoxAssets = jest.fn(async () => undefined); +jest.mock('~/lib/voicevox/assets', () => ({ + getVoicevoxAssetsStatus: () => mockVoicevoxStatus, + subscribeVoicevoxAssets: () => () => {}, + requestVoicevoxAssetsDownload: () => mockRequestVoicevoxAssetsDownload(), + cancelVoicevoxAssetsDownload: () => mockCancelVoicevoxAssetsDownload(), + deleteVoicevoxAssets: () => mockDeleteVoicevoxAssets(), +})); + jest.mock('~/components/FooterTabBar', () => () => null); jest.mock('~/components/SettingsHeader', () => ({ SettingsHeader: () => null, @@ -92,6 +124,14 @@ describe('TTSSettingsScreen', () => { jest.clearAllMocks(); resetDialogPresentationForTests(); setPlatformOS(originalPlatformOS); + mockVoicevoxStatus = { + phase: 'unsupported', + downloadedBytes: 0, + totalBytes: 0, + version: null, + errorMessage: null, + }; + storage.remove(STORAGE_KEYS.TTS_NOTICE); }); it('日本語をOFFにしても英語はONのままになる', () => { @@ -353,4 +393,163 @@ describe('TTSSettingsScreen', () => { }); }); }); + describe('オフライン用日本語音声 (VOICEVOX)', () => { + const setStatus = (status: Partial) => { + mockVoicevoxStatus = { ...mockVoicevoxStatus, ...status }; + }; + + it('[iOS] 有効化時に未取得なら、注意ダイアログの後にダウンロードを尋ねる', () => { + setPlatformOS('ios'); + setStatus({ phase: 'not_downloaded' }); + // 注意ダイアログは表示済みとして、ダウンロードの確認だけを出す + storage.set(STORAGE_KEYS.TTS_NOTICE, 'true'); + + const { getByLabelText } = renderWithSpeechState({ enabled: false }); + fireEvent.press(getByLabelText('toEnabled')); + + const snapshot = getDialogPresentationSnapshot(); + expect(snapshot).toMatchObject({ + visible: true, + request: { + title: 'voicevoxDownloadTitle', + message: 'voicevoxDownloadPrompt', + }, + }); + + const download = snapshot.request?.buttons.find( + (button) => button.text === 'download' + ); + download?.onPress?.(); + expect(mockRequestVoicevoxAssetsDownload).toHaveBeenCalledTimes(1); + }); + + it('[iOS] 「あとで」を選んでもダウンロードは始まらず、自動アナウンスは有効になる', () => { + setPlatformOS('ios'); + setStatus({ phase: 'not_downloaded' }); + storage.set(STORAGE_KEYS.TTS_NOTICE, 'true'); + + const { getByLabelText, store } = renderWithSpeechState({ + enabled: false, + }); + fireEvent.press(getByLabelText('toEnabled')); + + const later = getDialogPresentationSnapshot().request?.buttons.find( + (button) => button.text === 'later' + ); + expect(later?.style).toBe('cancel'); + later?.onPress?.(); + expect(mockRequestVoicevoxAssetsDownload).not.toHaveBeenCalled(); + expect(store.get(speechState).enabled).toBe(true); + }); + + it('[iOS] 日本語を読まない設定 (英語のみ) では尋ねない', () => { + setPlatformOS('ios'); + setStatus({ phase: 'not_downloaded' }); + storage.set(STORAGE_KEYS.TTS_NOTICE, 'true'); + + const { getByLabelText, store } = renderWithSpeechState({ + enabled: false, + ttsEnabledLanguages: ['EN'], + }); + fireEvent.press(getByLabelText('toEnabled')); + + expect(getDialogPresentationSnapshot().request).toBeNull(); + expect(mockRequestVoicevoxAssetsDownload).not.toHaveBeenCalled(); + expect(store.get(speechState).enabled).toBe(true); + }); + + it('[iOS] 取得済みなら有効化時に尋ねない', () => { + setPlatformOS('ios'); + setStatus({ phase: 'installed', totalBytes: 160_000_000, version: 'v1' }); + storage.set(STORAGE_KEYS.TTS_NOTICE, 'true'); + + const { getByLabelText } = renderWithSpeechState({ enabled: false }); + fireEvent.press(getByLabelText('toEnabled')); + + expect(getDialogPresentationSnapshot().request).toBeNull(); + }); + + it('[iOS] 対象外 (未配信・App Clip) ならパネルもプロンプトも出さない', () => { + setPlatformOS('ios'); + storage.set(STORAGE_KEYS.TTS_NOTICE, 'true'); + + const { getByLabelText, queryByText } = renderWithSpeechState({ + enabled: false, + }); + expect(queryByText('voicevoxDownloadTitle')).toBeNull(); + + fireEvent.press(getByLabelText('toEnabled')); + expect(getDialogPresentationSnapshot().request).toBeNull(); + }); + + it('[Android] 状態にかかわらずパネルを出さない', () => { + setPlatformOS('android'); + setStatus({ phase: 'not_downloaded' }); + + const { queryByText } = renderWithSpeechState({ enabled: true }); + + expect(queryByText('voicevoxDownloadTitle')).toBeNull(); + }); + + it('未取得ならサイズ付きのダウンロード操作を出し、押すと取得を始める', () => { + setPlatformOS('ios'); + setStatus({ phase: 'not_downloaded' }); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + expect(getByText('voicevoxNotDownloadedDescription')).toBeTruthy(); + fireEvent.press(getByText('voicevoxDownloadAction')); + expect(mockRequestVoicevoxAssetsDownload).toHaveBeenCalledTimes(1); + }); + + it('取得中は進捗とキャンセルを出す', () => { + setPlatformOS('ios'); + setStatus({ + phase: 'downloading', + downloadedBytes: 72_000_000, + totalBytes: 160_000_000, + }); + + const { getByText, getByTestId } = renderWithSpeechState({ + enabled: true, + }); + + expect(getByText('voicevoxDownloading 45%')).toBeTruthy(); + expect(getByText('72MB / 160MB')).toBeTruthy(); + expect( + getByTestId('voicevox-progressbar').props.accessibilityValue + ).toMatchObject({ now: 45 }); + fireEvent.press(getByText('cancel')); + expect(mockCancelVoicevoxAssetsDownload).toHaveBeenCalledTimes(1); + }); + + it('取得済みならサイズと削除を出し、削除は確認ダイアログを経て実行する', () => { + setPlatformOS('ios'); + setStatus({ phase: 'installed', totalBytes: 162_000_000, version: 'v1' }); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + expect(getByText('voicevoxInstalled')).toBeTruthy(); + fireEvent.press(getByText('voicevoxDelete')); + + const snapshot = getDialogPresentationSnapshot(); + expect(snapshot.request?.title).toBe('voicevoxDeleteConfirmTitle'); + expect(mockDeleteVoicevoxAssets).not.toHaveBeenCalled(); + snapshot.request?.buttons + .find((button) => button.style === 'destructive') + ?.onPress?.(); + expect(mockDeleteVoicevoxAssets).toHaveBeenCalledTimes(1); + }); + + it('失敗時は再試行を出す', () => { + setPlatformOS('ios'); + setStatus({ phase: 'error', errorMessage: 'manifest fetch failed: 500' }); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + expect(getByText('voicevoxDownloadFailed')).toBeTruthy(); + fireEvent.press(getByText('retry')); + expect(mockRequestVoicevoxAssetsDownload).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/screens/TTSSettings.tsx b/src/screens/TTSSettings.tsx index e0c398d88..f49026255 100644 --- a/src/screens/TTSSettings.tsx +++ b/src/screens/TTSSettings.tsx @@ -18,6 +18,14 @@ import { StatePanel } from '~/components/ToggleButton'; import Typography from '~/components/Typography'; import { useRemoteTTSEnabled } from '~/hooks/useRemoteTTSEnabled'; import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled'; +import { useVoicevoxAssetsStatus } from '~/hooks/useVoicevoxAssetsStatus'; +import { + cancelVoicevoxAssetsDownload, + deleteVoicevoxAssets, + getVoicevoxAssetsStatus, + requestVoicevoxAssetsDownload, + type VoicevoxAssetsStatus, +} from '~/lib/voicevox/assets'; import { TTS_SPEED_PREFERENCE, type TTSSpeedPreference, @@ -27,7 +35,11 @@ import speechState, { ttsSpeedPreferenceAtom } from '~/store/atoms/speech'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import { translate } from '~/translation'; import { showDialog } from '~/utils/dialogPresentation'; -import { STATUS_URL, STORAGE_KEYS } from '../constants'; +import { + STATUS_URL, + STORAGE_KEYS, + VOICEVOX_ASSET_APPROX_MB, +} from '../constants'; import { storage } from '../lib/storage'; type SettingItem = { @@ -161,6 +173,145 @@ const SpeedSettingsItem = ({ ); }; +const formatMegabytes = (bytes: number): string => + `${Math.max(1, Math.round(bytes / 1_000_000))}MB`; + +// iOS のオフライン用日本語音声 (VOICEVOX) のダウンロード状況。取得はユーザーの +// 同意で始まるため、状態ごとに「ダウンロード / キャンセル / 再試行 / 削除」を出す。 +// 詳細は docs/spec/tts/on-device-tts-ios.md +const VoicevoxAssetsPanel = ({ + status, + onPressDownload, + onPressCancel, + onPressDelete, +}: { + status: VoicevoxAssetsStatus; + onPressDownload: () => void; + onPressCancel: () => void; + onPressDelete: () => void; +}) => { + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const colors = useAppColors(); + + const percent = + status.totalBytes > 0 + ? Math.min( + 100, + Math.floor((status.downloadedBytes / status.totalBytes) * 100) + ) + : 0; + + const actionStyle = { + marginTop: 12, + fontSize: 18, + fontWeight: 'bold' as const, + color: colors.accent, + }; + + return ( + <> + + {translate('voicevoxDownloadTitle')} + + + {status.phase === 'downloading' ? ( + <> + + {`${translate('voicevoxDownloading')} ${percent}%`} + + + + + + {`${formatMegabytes(status.downloadedBytes)} / ${formatMegabytes(status.totalBytes)}`} + + + {translate('cancel')} + + + ) : null} + {status.phase === 'installed' ? ( + <> + + {translate('voicevoxInstalled', { + size: formatMegabytes(status.totalBytes), + })} + + + {translate('voicevoxInstalledDescription')} + + + {translate('voicevoxDelete')} + + + ) : null} + {status.phase === 'not_downloaded' || status.phase === 'error' ? ( + <> + + {status.phase === 'error' + ? translate('voicevoxDownloadFailed') + : translate('voicevoxNotDownloadedDescription', { + size: VOICEVOX_ASSET_APPROX_MB, + })} + + + {status.phase === 'error' + ? translate('retry') + : translate('voicevoxDownloadAction', { + size: VOICEVOX_ASSET_APPROX_MB, + })} + + + ) : null} + + + ); +}; + const ListFooter = ({ ttsLanguageItems, ttsEnabledLanguages, @@ -169,9 +320,13 @@ const ListFooter = ({ remoteTTSEnabled, speechEnabled, ttsFeatureEnabled, + voicevoxStatus, onToggleTTSLanguage, onSelectTTSSpeed, onPressServiceStatus, + onPressVoicevoxDownload, + onPressVoicevoxCancel, + onPressVoicevoxDelete, onPressOK, }: { ttsLanguageItems: TTSLanguageSettingItem[]; @@ -181,9 +336,13 @@ const ListFooter = ({ remoteTTSEnabled: boolean; speechEnabled: boolean; ttsFeatureEnabled: boolean; + voicevoxStatus: VoicevoxAssetsStatus; onToggleTTSLanguage: (language: TTSLanguage) => void; onSelectTTSSpeed: (preference: TTSSpeedPreference) => void; onPressServiceStatus: () => void; + onPressVoicevoxDownload: () => void; + onPressVoicevoxCancel: () => void; + onPressVoicevoxDelete: () => void; onPressOK: () => void; }) => { const colors = useAppColors(); @@ -258,6 +417,17 @@ const ListFooter = ({ ) : null} + {/* オフライン用の日本語音声 (VOICEVOX)。ネイティブモジュールを持つ iOS 本体 + アプリで Remote Config により有効化されているときだけ表示する (App Clip・ + Android・未配信時は unsupported) */} + {Platform.OS === 'ios' && voicevoxStatus.phase !== 'unsupported' ? ( + + ) : null} {/* iOSはリモート合成のため品質案内は不要。案内文はAndroidのTTSエンジン設定を 指す内容なので、該当する設定を持たないweb等でも出さない */} {Platform.OS === 'android' ? ( @@ -329,6 +499,8 @@ const TTSSettingsScreen: React.FC = () => { const ttsFeatureEnabled = useTTSFeatureEnabled(); // 速度設定はリモート合成でのみ効くため、同じく購読して表示を切り替える。 const remoteTTSEnabled = useRemoteTTSEnabled(); + // オフライン用日本語音声 (VOICEVOX) の取得状況。進捗表示と操作に使う。 + const voicevoxStatus = useVoicevoxAssetsStatus(); const SETTING_ITEMS: SettingItem[] = [ { @@ -411,6 +583,34 @@ const TTSSettingsScreen: React.FC = () => { ]); } + // 有効化したタイミングで、オフライン用の日本語音声 (約 160MB) を取得するか + // 尋ねる。取得はこの同意で始まり、進捗は下のパネルに出る。同意しなくても + // 自動アナウンスは使え、通信できない回だけ端末の読み上げ音声になる。 + // 注意ダイアログが先に出る場合はその後に続けて表示される (キュー)。 + // 日本語を読まない設定では日本語専用の資産を勧める意味が無いので尋ねない。 + if ( + flag && + Platform.OS === 'ios' && + ttsEnabledLanguages.includes('JA') && + getVoicevoxAssetsStatus().phase === 'not_downloaded' + ) { + showDialog( + translate('voicevoxDownloadTitle'), + translate('voicevoxDownloadPrompt', { + size: VOICEVOX_ASSET_APPROX_MB, + }), + [ + { text: translate('later'), style: 'cancel' }, + { + text: translate('download'), + onPress: (): void => { + void requestVoicevoxAssetsDownload(); + }, + }, + ] + ); + } + storage.set(STORAGE_KEYS.SPEECH_ENABLED, flag ? 'true' : 'false'); setSpeechState((prev) => ({ ...prev, @@ -424,7 +624,7 @@ const TTSSettingsScreen: React.FC = () => { ); } }, - [setSpeechState, ttsFeatureEnabled] + [setSpeechState, ttsEnabledLanguages, ttsFeatureEnabled] ); const handleToggleBgTTS = useCallback( @@ -594,6 +794,31 @@ const TTSSettingsScreen: React.FC = () => { ] ); + const handleVoicevoxDownloadPress = useCallback(() => { + void requestVoicevoxAssetsDownload(); + }, []); + + const handleVoicevoxCancelPress = useCallback(() => { + cancelVoicevoxAssetsDownload(); + }, []); + + const handleVoicevoxDeletePress = useCallback(() => { + showDialog( + translate('voicevoxDeleteConfirmTitle'), + translate('voicevoxDeleteConfirmMessage'), + [ + { text: translate('cancel'), style: 'cancel' }, + { + text: translate('voicevoxDelete'), + style: 'destructive', + onPress: (): void => { + void deleteVoicevoxAssets(); + }, + }, + ] + ); + }, []); + const handleServiceStatusPress = useCallback(() => { Linking.openURL(STATUS_URL).catch((error) => { console.error('Failed to open service status page', error); @@ -635,9 +860,13 @@ const TTSSettingsScreen: React.FC = () => { remoteTTSEnabled={remoteTTSEnabled} speechEnabled={effectiveSpeechEnabled} ttsFeatureEnabled={ttsFeatureEnabled} + voicevoxStatus={voicevoxStatus} onToggleTTSLanguage={handleToggleTTSLanguage} onSelectTTSSpeed={handleSelectTTSSpeed} onPressServiceStatus={handleServiceStatusPress} + onPressVoicevoxDownload={handleVoicevoxDownloadPress} + onPressVoicevoxCancel={handleVoicevoxCancelPress} + onPressVoicevoxDelete={handleVoicevoxDeletePress} onPressOK={() => navigation.goBack()} /> } diff --git a/src/utils/native/ios/voicevoxTtsModule.test.ts b/src/utils/native/ios/voicevoxTtsModule.test.ts new file mode 100644 index 000000000..f73d5d015 --- /dev/null +++ b/src/utils/native/ios/voicevoxTtsModule.test.ts @@ -0,0 +1,52 @@ +import type { getVoicevoxTtsModule as GetVoicevoxTtsModule } from './voicevoxTtsModule'; + +// react-native の index は各エクスポートを遅延 require するため、モジュールの +// 評価も getVoicevoxTtsModule の呼び出しも同じ分離レジストリの中で行う +// (外へ出てから呼ぶと外側の Platform を読んでしまう)。 +const loadAndCall = (os: 'ios' | 'android', registerModule: boolean) => { + let result: ReturnType | undefined; + jest.isolateModules(() => { + const rn = require('react-native') as { + Platform: { OS: string }; + NativeModules: Record; + }; + Object.defineProperty(rn.Platform, 'OS', { + value: os, + configurable: true, + }); + if (registerModule) { + rn.NativeModules.VoicevoxTTSModule = fakeModule; + } else { + delete rn.NativeModules.VoicevoxTTSModule; + } + const { getVoicevoxTtsModule } = require('./voicevoxTtsModule') as { + getVoicevoxTtsModule: typeof GetVoicevoxTtsModule; + }; + result = getVoicevoxTtsModule(); + }); + return result; +}; + +const fakeModule = { + setup: jest.fn(), + synthesize: jest.fn(), + release: jest.fn(), + sha256: jest.fn(), + setExcludedFromBackup: jest.fn(), +}; + +describe('getVoicevoxTtsModule', () => { + it('[iOS] ネイティブモジュールが登録されていればそれを返す', () => { + expect(loadAndCall('ios', true)).toBe(fakeModule); + }); + + it('[iOS] App Clip のようにモジュールが無いビルドでは null', () => { + expect(loadAndCall('ios', false)).toBeNull(); + }); + + // Android に VOICEVOX の実装は無い。万一同名のモジュールが登録されていても + // JS 側では使わない (iOS 限定機能)。 + it('[Android] モジュールが登録されていても常に null', () => { + expect(loadAndCall('android', true)).toBeNull(); + }); +}); diff --git a/src/utils/native/ios/voicevoxTtsModule.ts b/src/utils/native/ios/voicevoxTtsModule.ts new file mode 100644 index 000000000..025103f92 --- /dev/null +++ b/src/utils/native/ios/voicevoxTtsModule.ts @@ -0,0 +1,63 @@ +import { NativeModules, Platform } from 'react-native'; + +// ios/Modules/VoicevoxTTS の JS 側インターフェース。 +// モジュールは本体アプリ (ProdTrainLCD / CanaryTrainLCD) にしか含まれない。App Clip は +// 依存する xcframework を埋め込めない (非圧縮 15MB 制限) ため意図的に含めておらず、 +// Android には実装が無い。呼び出し側は getVoicevoxTtsModule() が null を返す構成を +// 「VOICEVOX は使えない」として扱い、端末内蔵 TTS へ倒す。 + +export interface VoicevoxSetupOptions { + // Open JTalk 辞書ディレクトリ (file:// を除いた絶対パス) + openJtalkDicDir: string; + // 読み込む音声モデル (VVM) の絶対パス + voiceModelPaths: string[]; + // 合成に使う CPU スレッド数。0 で自動 + cpuNumThreads: number; +} + +export interface VoicevoxSetupResult { + coreVersion: string; + // 読み込んだ音声モデルに含まれるスタイル ID + styleIds: number[]; +} + +export interface VoicevoxSynthesizeOptions { + text: string; + styleId: number; + // AudioQuery.speedScale (1.0 が標準) + speedScale: number; + // 書き出す WAV の絶対パス。親ディレクトリは無ければ作られる + outputPath: string; +} + +export interface VoicevoxSynthesizeResult { + path: string; + bytes: number; +} + +export interface VoicevoxTtsNativeModule { + setup: (options: VoicevoxSetupOptions) => Promise; + synthesize: ( + options: VoicevoxSynthesizeOptions + ) => Promise; + release: () => Promise; + // ファイルの SHA-256 (16 進小文字)。100MB 超の辞書を JS へ読み込まずに検証する + sha256: (path: string) => Promise; + // iCloud バックアップ対象から外す (再取得できる資産のため) + setExcludedFromBackup: (path: string) => Promise; +} + +const { VoicevoxTTSModule } = NativeModules as { + VoicevoxTTSModule?: VoicevoxTtsNativeModule; +}; + +/** + * VOICEVOX のネイティブモジュールを返す。iOS 本体アプリ以外 (Android・App Clip・web) や + * モジュールが登録されていないビルドでは null で、呼び出し側は VOICEVOX を使わない。 + */ +export const getVoicevoxTtsModule = (): VoicevoxTtsNativeModule | null => { + if (Platform.OS !== 'ios') { + return null; + } + return VoicevoxTTSModule ?? null; +}; From 63ccb3d99aeec43ed4da84b139cf7f33aa84ebae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:58:09 +0900 Subject: [PATCH 12/62] Bump version for canary release (#6886) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 728b99796..a3d10086d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000721 + versionCode 100000723 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000721 + versionCode 100000723 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index fa7d1105d..400a7c7dc 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000722 + versionCode = 100000724 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000722 + versionCode = 100000724 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 24795f153..631653efb 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2890', + buildNumber: '2891', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000721, + versionCode: 100000723, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 238d44a3f..daf3e8227 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2524,7 +2524,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2564,7 +2564,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2809,7 +2809,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3061,7 +3061,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3113,7 +3113,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3164,7 +3164,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3222,7 +3222,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3273,7 +3273,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3330,7 +3330,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3379,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3431,7 +3431,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3650,7 +3650,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3706,7 +3706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3756,7 +3756,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3814,7 +3814,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2890; + CURRENT_PROJECT_VERSION = 2891; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 0ea3b5d4526134726e3424378e5dc6d2f0069083 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 8 Sep 2026 20:28:26 +0900 Subject: [PATCH 13/62] =?UTF-8?q?VOICEVOX=E3=83=A2=E3=82=B8=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=A7=E6=9B=96=E6=98=A7=E3=81=AB=E3=81=AA?= =?UTF-8?q?=E3=82=8BVoicevoxResultCode=E5=9E=8B=E3=81=AE=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=82=92Int32=E3=81=AB=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88?= =?UTF-8?q?=E3=81=A6iOS=E3=83=93=E3=83=AB=E3=83=89=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(#6887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb Co-authored-by: Claude --- .../VoicevoxTTS/VoicevoxTTSModule.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift b/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift index a08a58462..eca3f2a42 100644 --- a/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift +++ b/ios/Modules/VoicevoxTTS/VoicevoxTTSModule.swift @@ -76,7 +76,9 @@ final class VoicevoxTTSModule: NSObject { private enum VoicevoxError: Error, CustomStringConvertible { case invalidArgument(String) - case core(String, VoicevoxResultCode) + // C API の戻り値型。ヘッダーが `enum VoicevoxResultCode` と `typedef int32_t VoicevoxResultCode` + // の両方を定義するため Swift では型名 `VoicevoxResultCode` の参照が曖昧になる。typedef の実体である Int32 で扱う + case core(String, Int32) case notInitialized case io(String) @@ -104,12 +106,17 @@ final class VoicevoxTTSModule: NSObject { } } - // C API の戻り値 VoicevoxResultCode は Swift では Int32 の typealias として - // 取り込まれ、VOICEVOX_RESULT_OK などの定数は rawValue を持つ列挙として取り込まれる - // (cbindgen 生成ヘッダーの `enum X {…}; typedef int32_t X;` パターン)。 - private static let resultOK = VoicevoxResultCode(VOICEVOX_RESULT_OK.rawValue) - - private func check(_ result: VoicevoxResultCode, _ operation: String) throws { + // cbindgen 生成ヘッダーは `enum X {…}; typedef int32_t X;` の形で、Swift には列挙と + // Int32 の typealias が同名で取り込まれる。型名 `VoicevoxResultCode` / + // `VoicevoxAccelerationMode` を Swift 側で書くと "ambiguous for type lookup" になるため、 + // typedef の実体 Int32 で扱い、定数はヘッダーの値をそのまま書く (フレームワークの版は + // ios/Frameworks/voicevox-frameworks.json で固定している)。 + // voicevox_core.h: VOICEVOX_RESULT_OK = 0 + private static let resultOK: Int32 = 0 + // voicevox_core.h: VOICEVOX_ACCELERATION_MODE_CPU = 1 + private static let accelerationModeCPU: Int32 = 1 + + private func check(_ result: Int32, _ operation: String) throws { if result != Self.resultOK { throw VoicevoxError.core(operation, result) } @@ -150,8 +157,7 @@ final class VoicevoxTTSModule: NSObject { self.openJtalk = openJtalk var options = voicevox_make_default_initialize_options() - options.acceleration_mode = VoicevoxAccelerationMode( - VOICEVOX_ACCELERATION_MODE_CPU.rawValue) + options.acceleration_mode = Self.accelerationModeCPU options.cpu_num_threads = config.cpuNumThreads var synthesizer: OpaquePointer? From 472a1889b9172df76b36282ea8383192b7b12b2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:30:47 +0900 Subject: [PATCH 14/62] Bump version for canary release (#6889) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index a3d10086d..962f9ecdf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000723 + versionCode 100000725 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000723 + versionCode 100000725 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 400a7c7dc..47785b3c2 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000724 + versionCode = 100000726 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000724 + versionCode = 100000726 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 631653efb..e8307405b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2891', + buildNumber: '2892', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000723, + versionCode: 100000725, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index daf3e8227..e0a163623 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2524,7 +2524,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2564,7 +2564,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2809,7 +2809,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3061,7 +3061,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3113,7 +3113,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3164,7 +3164,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3222,7 +3222,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3273,7 +3273,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3330,7 +3330,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3379,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3431,7 +3431,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3650,7 +3650,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3706,7 +3706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3756,7 +3756,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3814,7 +3814,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2891; + CURRENT_PROJECT_VERSION = 2892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 4218017262d089bfe7137c280dcdbd0ae0394e31 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 8 Sep 2026 21:15:22 +0900 Subject: [PATCH 15/62] =?UTF-8?q?voicevox=5Fonnxruntime=E3=81=AECFBundleId?= =?UTF-8?q?entifier=E3=81=AB=E5=90=AB=E3=81=BE=E3=82=8C=E3=82=8B=E3=82=A2?= =?UTF-8?q?=E3=83=B3=E3=83=80=E3=83=BC=E3=82=B9=E3=82=B3=E3=82=A2=E3=82=92?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E6=99=82=E3=81=AB=E3=83=8F=E3=82=A4=E3=83=95?= =?UTF-8?q?=E3=83=B3=E3=81=B8=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=97=E3=81=A6?= =?UTF-8?q?iOS=E3=81=AEArchive=E6=A4=9C=E8=A8=BC=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=20(#6890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/spec/tts/on-device-tts-ios.md | 9 ++ scripts/fetch-voicevox-frameworks.mjs | 143 +++++++++++++++------ scripts/fetch-voicevox-frameworks.test.js | 145 ++++++++++++++++++++++ 3 files changed, 262 insertions(+), 35 deletions(-) create mode 100644 scripts/fetch-voicevox-frameworks.test.js diff --git a/docs/spec/tts/on-device-tts-ios.md b/docs/spec/tts/on-device-tts-ios.md index 08d249012..340737ab7 100644 --- a/docs/spec/tts/on-device-tts-ios.md +++ b/docs/spec/tts/on-device-tts-ios.md @@ -87,6 +87,15 @@ VOICEVOX CORE の C API はスレッドセーフを保証していないため (voicevox_core Issue #715)問題は現行の配布物では起きない。バージョンを上げるときは JSON の `url` と `sha256` を両方更新すること。 +`voicevox_onnxruntime` 1.23.2 の iOS スライスは `CFBundleIdentifier` が +`jp.hiroshiba.voicevox.voicevox_onnxruntime` とアンダースコアを含んでおり、Apple の規則 +(英数字・ハイフン・ピリオドのみ)に反するため Xcode の archive 時検証で +`had an invalid CFBundleIdentifier in its Info.plist` として失敗する。取得スクリプトは展開後に +各スライスの `Info.plist` を走査してアンダースコアをハイフンへ置き換える(`voicevox_core` は +元から `voicevox-core` なので対象外)。配布物は未署名で Xcode が埋め込み時に署名し直すため、 +この書き換えで署名は壊れない。取得を省略した場合も毎回走る冪等な処理なので、展開済みの +ローカル環境でも `npm run ios:frameworks` を一度実行すれば反映される。 + ### App Clip には含めない App Clip(`ProdAppClip` / `CanaryAppClip`)は deployment target が 16.4 のため非圧縮 diff --git a/scripts/fetch-voicevox-frameworks.mjs b/scripts/fetch-voicevox-frameworks.mjs index 01621f737..284a5cdf7 100644 --- a/scripts/fetch-voicevox-frameworks.mjs +++ b/scripts/fetch-voicevox-frameworks.mjs @@ -3,19 +3,23 @@ // ios/Frameworks/voicevox-frameworks.json に固定したバージョン・URL・SHA-256 の // とおりに zip を取得し、検証してから ios/Frameworks/.xcframework へ展開する。 // 展開物はリポジトリに含めない(数十 MB のバイナリで、GitHub Releases から -// 再現可能に取得できるため)。既に同じバージョンが展開済みなら何もしない。 +// 再現可能に取得できるため)。既に同じバージョンが展開済みなら取得はしない。 // // 実行タイミング: // - ローカル: `npm run ios` の前段(package.json の ios スクリプト) // - CI: .github/workflows/build_ios_*.yml の pod install 前 // // Android ビルドや Jest には不要なので postinstall には繋いでいない。 +// +// 環境変数 VOICEVOX_FRAMEWORKS_DIR で取得先ディレクトリ(定義 JSON の置き場所)を +// 差し替えられる。テストが一時ディレクトリで実行するためのもので、通常は指定しない。 import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -25,7 +29,9 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); -const frameworksDir = resolve(scriptDir, '..', 'ios', 'Frameworks'); +const frameworksDir = process.env.VOICEVOX_FRAMEWORKS_DIR + ? resolve(process.env.VOICEVOX_FRAMEWORKS_DIR) + : resolve(scriptDir, '..', 'ios', 'Frameworks'); const manifestPath = join(frameworksDir, 'voicevox-frameworks.json'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); @@ -40,6 +46,65 @@ const download = async (url) => { return Buffer.from(await response.arrayBuffer()); }; +// xcframework 配下の各スライス (/.framework/Info.plist) を列挙する。 +// macOS スライスは Versions/ 配下にシンボリックリンクを張っているので辿らない +// (iOS アプリには埋め込まれず、リンク先の実体は Versions/A 側で列挙される)。 +const listFrameworkInfoPlists = (dir, found = []) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + listFrameworkInfoPlists(path, found); + } else if ( + entry.name === 'Info.plist' && + /\.framework$|[\\/]Resources$/.test(dirname(path)) + ) { + found.push(path); + } + } + return found; +}; + +// CFBundleIdentifier に使える文字は英数字・ハイフン・ピリオドだけで、アンダースコアは不可。 +// voicevox_onnxruntime 1.23.2 の iOS スライスは `jp.hiroshiba.voicevox.voicevox_onnxruntime` +// を名乗っており、Xcode の archive 時検証 (-validate-for-store) が +// "had an invalid CFBundleIdentifier in its Info.plist" で失敗する。展開後にアンダースコアを +// ハイフンへ置き換える。配布物は未署名 (_CodeSignature 無し) で、Xcode が埋め込み時に +// 署名し直すため Info.plist の書き換えは署名を壊さない。 +// 既に展開済みの環境にも効くよう、取得を省略した場合にも毎回呼ぶ(冪等)。 +const normalizeBundleIdentifiers = (xcframeworkDir) => { + const rewritten = []; + for (const plistPath of listFrameworkInfoPlists(xcframeworkDir)) { + const source = readFileSync(plistPath, 'utf8'); + // 配布物の Info.plist は XML 形式。バイナリ plist だと下の置換が効かないので明示的に止める + if (source.startsWith('bplist')) { + throw new Error( + `[voicevox] ${plistPath} is a binary plist; convert it to XML before normalizing CFBundleIdentifier` + ); + } + const pattern = + /(CFBundleIdentifier<\/key>\s*)([^<]*)(<\/string>)/; + const match = source.match(pattern); + if (!match) { + continue; + } + const identifier = match[2]; + if (!identifier.includes('_')) { + continue; + } + const normalized = identifier.replaceAll('_', '-'); + writeFileSync( + plistPath, + source.replace(pattern, `$1${normalized}$3`), + 'utf8' + ); + rewritten.push({ plistPath, from: identifier, to: normalized }); + } + return rewritten; +}; + for (const framework of manifest.frameworks) { const { name, version, url, sha256 } = framework; const destination = join(frameworksDir, `${name}.xcframework`); @@ -48,45 +113,53 @@ for (const framework of manifest.frameworks) { // マーカーだけでなく xcframework の実体 (Info.plist) も確認する。中身が消えた // 不完全な展開物にマーカーだけ残っていると、ここで飛ばした後の Xcode ビルドが失敗する - if ( + const installed = existsSync(versionMarker) && existsSync(join(destination, 'Info.plist')) && - readFileSync(versionMarker, 'utf8').trim() === version - ) { + readFileSync(versionMarker, 'utf8').trim() === version; + + if (installed) { console.log(`[voicevox] ${name} ${version} is already installed`); - continue; - } + } else { + console.log(`[voicevox] downloading ${name} ${version}`); + const zip = await download(url); + const actual = sha256Hex(zip); + if (actual !== sha256) { + throw new Error( + `[voicevox] SHA-256 mismatch for ${name}: expected ${sha256}, got ${actual}` + ); + } - console.log(`[voicevox] downloading ${name} ${version}`); - const zip = await download(url); - const actual = sha256Hex(zip); - if (actual !== sha256) { - throw new Error( - `[voicevox] SHA-256 mismatch for ${name}: expected ${sha256}, got ${actual}` - ); + const tempDir = mkdtempSync(join(tmpdir(), 'voicevox-')); + try { + const zipPath = join(tempDir, `${name}.zip`); + writeFileSync(zipPath, zip); + // zip のトップレベルが .xcframework/ なので、展開先の親へそのまま展開する + rmSync(destination, { recursive: true, force: true }); + execFileSync( + 'unzip', + ['-q', '-o', zipPath, `${name}.xcframework/*`, '-d', frameworksDir], + { + stdio: 'inherit', + } + ); + if (!existsSync(join(destination, 'Info.plist'))) { + throw new Error( + `[voicevox] ${name}.xcframework was not found in the archive` + ); + } + writeFileSync(versionMarker, `${version}\n`); + console.log(`[voicevox] installed ${name} ${version} -> ${destination}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } } - const tempDir = mkdtempSync(join(tmpdir(), 'voicevox-')); - try { - const zipPath = join(tempDir, `${name}.zip`); - writeFileSync(zipPath, zip); - // zip のトップレベルが .xcframework/ なので、展開先の親へそのまま展開する - rmSync(destination, { recursive: true, force: true }); - execFileSync( - 'unzip', - ['-q', '-o', zipPath, `${name}.xcframework/*`, '-d', frameworksDir], - { - stdio: 'inherit', - } + for (const { plistPath, from, to } of normalizeBundleIdentifiers( + destination + )) { + console.log( + `[voicevox] normalized CFBundleIdentifier ${from} -> ${to} (${plistPath})` ); - if (!existsSync(join(destination, 'Info.plist'))) { - throw new Error( - `[voicevox] ${name}.xcframework was not found in the archive` - ); - } - writeFileSync(versionMarker, `${version}\n`); - console.log(`[voicevox] installed ${name} ${version} -> ${destination}`); - } finally { - rmSync(tempDir, { recursive: true, force: true }); } } diff --git a/scripts/fetch-voicevox-frameworks.test.js b/scripts/fetch-voicevox-frameworks.test.js new file mode 100644 index 000000000..51a5a7bb4 --- /dev/null +++ b/scripts/fetch-voicevox-frameworks.test.js @@ -0,0 +1,145 @@ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const scriptPath = path.resolve(__dirname, 'fetch-voicevox-frameworks.mjs'); + +const infoPlist = (identifier) => ` + + + +\tCFBundleExecutable +\tvoicevox_onnxruntime +\tCFBundleIdentifier +\t${identifier} +\tCFBundleName +\tvoicevox_onnxruntime +\tCFBundlePackageType +\tFMWK + + +`; + +// 展開済み扱いになる xcframework を組み立てる。取得を省略する経路 +// (.trainlcd-version と xcframework 直下の Info.plist が揃っている) を通すことで +// ネットワークに触れずに正規化処理だけを検証できる +const setupFrameworksDir = ({ name, version, identifiers }) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'voicevox-frameworks-')); + fs.writeFileSync( + path.join(dir, 'voicevox-frameworks.json'), + JSON.stringify({ + frameworks: [ + { + name, + version, + url: 'https://example.invalid/never-downloaded.zip', + sha256: '0'.repeat(64), + }, + ], + }) + ); + const xcframework = path.join(dir, `${name}.xcframework`); + fs.mkdirSync(xcframework, { recursive: true }); + fs.writeFileSync(path.join(xcframework, '.trainlcd-version'), `${version}\n`); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + '\nCFBundlePackageTypeXFWK\n' + ); + const slicePlists = {}; + for (const [slice, identifier] of Object.entries(identifiers)) { + const frameworkDir = path.join(xcframework, slice, `${name}.framework`); + fs.mkdirSync(frameworkDir, { recursive: true }); + const plistPath = path.join(frameworkDir, 'Info.plist'); + fs.writeFileSync(plistPath, infoPlist(identifier)); + slicePlists[slice] = plistPath; + } + return { dir, xcframework, slicePlists }; +}; + +const runScript = (frameworksDir) => + execFileSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { ...process.env, VOICEVOX_FRAMEWORKS_DIR: frameworksDir }, + }); + +const readIdentifier = (plistPath) => + fs + .readFileSync(plistPath, 'utf8') + .match(/CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/)[1]; + +describe('fetch-voicevox-frameworks', () => { + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('CFBundleIdentifier のアンダースコアを全スライスでハイフンに置き換える', () => { + const { dir, slicePlists } = setupFrameworksDir({ + name: 'voicevox_onnxruntime', + version: '1.23.2', + identifiers: { + 'ios-arm64': 'jp.hiroshiba.voicevox.voicevox_onnxruntime', + 'ios-arm64_x86_64-simulator': + 'jp.hiroshiba.voicevox.voicevox_onnxruntime', + }, + }); + tempDirs.push(dir); + + const stdout = runScript(dir); + + expect(stdout).toContain( + 'voicevox_onnxruntime 1.23.2 is already installed' + ); + expect(stdout).toContain( + 'normalized CFBundleIdentifier jp.hiroshiba.voicevox.voicevox_onnxruntime -> jp.hiroshiba.voicevox.voicevox-onnxruntime' + ); + for (const plistPath of Object.values(slicePlists)) { + expect(readIdentifier(plistPath)).toBe( + 'jp.hiroshiba.voicevox.voicevox-onnxruntime' + ); + // 識別子以外(実行ファイル名やバンドル名)は書き換えない + expect(fs.readFileSync(plistPath, 'utf8')).toBe( + infoPlist('jp.hiroshiba.voicevox.voicevox-onnxruntime') + ); + } + }); + + it('既に有効な CFBundleIdentifier はそのままにし、再実行しても変化しない', () => { + const { dir, slicePlists } = setupFrameworksDir({ + name: 'voicevox_core', + version: '0.17.0', + identifiers: { + 'ios-arm64': 'jp.hiroshiba.voicevox.voicevox-core', + }, + }); + tempDirs.push(dir); + const plistPath = slicePlists['ios-arm64']; + const before = fs.statSync(plistPath).mtimeMs; + + const first = runScript(dir); + const second = runScript(dir); + + expect(first).not.toContain('normalized CFBundleIdentifier'); + expect(second).not.toContain('normalized CFBundleIdentifier'); + expect(readIdentifier(plistPath)).toBe( + 'jp.hiroshiba.voicevox.voicevox-core' + ); + expect(fs.statSync(plistPath).mtimeMs).toBe(before); + }); + + it('バイナリ plist は黙って素通しせずエラーにする', () => { + const { dir, slicePlists } = setupFrameworksDir({ + name: 'voicevox_onnxruntime', + version: '1.23.2', + identifiers: { 'ios-arm64': 'placeholder' }, + }); + tempDirs.push(dir); + fs.writeFileSync(slicePlists['ios-arm64'], 'bplist00'); + + expect(() => runScript(dir)).toThrow(/is a binary plist/); + }); +}); From ca137e98ab033c776c11158f3719d150c1b148c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:17:32 +0900 Subject: [PATCH 16/62] Bump version for canary release (#6892) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 962f9ecdf..a32585580 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000725 + versionCode 100000727 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000725 + versionCode 100000727 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 47785b3c2..b8344879d 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000726 + versionCode = 100000728 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000726 + versionCode = 100000728 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index e8307405b..d5fcda66e 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2892', + buildNumber: '2893', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000725, + versionCode: 100000727, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index e0a163623..f1cd6e835 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2524,7 +2524,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2564,7 +2564,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2809,7 +2809,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3061,7 +3061,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3113,7 +3113,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3164,7 +3164,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3222,7 +3222,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3273,7 +3273,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3330,7 +3330,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3379,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3431,7 +3431,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3650,7 +3650,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3706,7 +3706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3756,7 +3756,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3814,7 +3814,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2892; + CURRENT_PROJECT_VERSION = 2893; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From bce46573d6e33820fe71105df489b22724bc2cf9 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 8 Sep 2026 21:52:59 +0900 Subject: [PATCH 17/62] =?UTF-8?q?VOICEVOX=E3=83=95=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=A0=E3=83=AF=E3=83=BC=E3=82=AF=E3=82=92=E5=8F=96=E5=BE=97?= =?UTF-8?q?=E6=99=82=E3=81=ABad-hoc=E3=81=A7=E7=BD=B2=E5=90=8D=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=A6=E7=BD=B2=E5=90=8D=E8=AD=98=E5=88=A5?= =?UTF-8?q?=E5=AD=90=E3=82=92CFBundleIdentifier=E3=81=AB=E6=8F=83=E3=81=88?= =?UTF-8?q?TestFlight=E3=82=A2=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E6=A4=9C=E8=A8=BC=E5=A4=B1=E6=95=97=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#6893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01XhiZDbeC767daH6qcLpArb Co-authored-by: Claude --- docs/spec/tts/on-device-tts-ios.md | 26 +++++++--- scripts/fetch-voicevox-frameworks.mjs | 58 ++++++++++++++++++++- scripts/fetch-voicevox-frameworks.test.js | 63 ++++++++++++++++++++++- 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/docs/spec/tts/on-device-tts-ios.md b/docs/spec/tts/on-device-tts-ios.md index 340737ab7..2af990835 100644 --- a/docs/spec/tts/on-device-tts-ios.md +++ b/docs/spec/tts/on-device-tts-ios.md @@ -87,14 +87,24 @@ VOICEVOX CORE の C API はスレッドセーフを保証していないため (voicevox_core Issue #715)問題は現行の配布物では起きない。バージョンを上げるときは JSON の `url` と `sha256` を両方更新すること。 -`voicevox_onnxruntime` 1.23.2 の iOS スライスは `CFBundleIdentifier` が -`jp.hiroshiba.voicevox.voicevox_onnxruntime` とアンダースコアを含んでおり、Apple の規則 -(英数字・ハイフン・ピリオドのみ)に反するため Xcode の archive 時検証で -`had an invalid CFBundleIdentifier in its Info.plist` として失敗する。取得スクリプトは展開後に -各スライスの `Info.plist` を走査してアンダースコアをハイフンへ置き換える(`voicevox_core` は -元から `voicevox-core` なので対象外)。配布物は未署名で Xcode が埋め込み時に署名し直すため、 -この書き換えで署名は壊れない。取得を省略した場合も毎回走る冪等な処理なので、展開済みの -ローカル環境でも `npm run ios:frameworks` を一度実行すれば反映される。 +`voicevox_onnxruntime` 1.23.2 の配布物はそのまま埋め込むと App Store 向けの検証に 2 段階で +弾かれるため、取得スクリプトが展開後に次の 2 つを補正する。取得を省略した場合も毎回走る冪等な +処理なので、展開済みのローカル環境でも `npm run ios:frameworks` を一度実行すれば反映される。 + +1. **`CFBundleIdentifier` の正規化**: iOS スライスの識別子が + `jp.hiroshiba.voicevox.voicevox_onnxruntime` とアンダースコアを含んでおり、Apple の規則 + (英数字・ハイフン・ピリオドのみ)に反するため Xcode の archive 時検証で + `had an invalid CFBundleIdentifier in its Info.plist` として失敗する。各スライスの + `Info.plist` を走査してアンダースコアをハイフンへ置き換える(`voicevox_core` は元から + `voicevox-core` なので対象外)。 +1. **ad-hoc での署名し直し**: `voicevox_onnxruntime` のバイナリには識別子 + `libvoicevox_onnxruntime.1` の ad-hoc 署名が埋め込まれている。Xcode は埋め込み時に + `--preserve-metadata=identifier` で既存の識別子を引き継ぐため、App Store Connect への + アップロードが `Invalid Code Signature Identifier ... must match its Bundle Identifier` で + 失敗する。`codesign --force --sign - --identifier ` で各スライスの + framework を署名し直し、識別子をバンドル識別子に揃える(未署名の `voicevox_core` は Xcode が + `CFBundleIdentifier` から識別子を導出するので元々問題無いが、配布物の署名状態に依存しないよう + 一律に署名し直す)。`codesign` は macOS にしか無いので、それ以外の環境では省略する。 ### App Clip には含めない diff --git a/scripts/fetch-voicevox-frameworks.mjs b/scripts/fetch-voicevox-frameworks.mjs index 284a5cdf7..e60746f43 100644 --- a/scripts/fetch-voicevox-frameworks.mjs +++ b/scripts/fetch-voicevox-frameworks.mjs @@ -11,8 +11,9 @@ // // Android ビルドや Jest には不要なので postinstall には繋いでいない。 // -// 環境変数 VOICEVOX_FRAMEWORKS_DIR で取得先ディレクトリ(定義 JSON の置き場所)を -// 差し替えられる。テストが一時ディレクトリで実行するためのもので、通常は指定しない。 +// 環境変数(いずれもテスト用で、通常は指定しない): +// - VOICEVOX_FRAMEWORKS_DIR: 取得先ディレクトリ(定義 JSON の置き場所)を差し替える +// - VOICEVOX_CODESIGN: 署名し直しに使う codesign コマンドを差し替える。空文字なら署名し直しを省略する import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -36,6 +37,15 @@ const manifestPath = join(frameworksDir, 'voicevox-frameworks.json'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +// 署名し直しに使う codesign。macOS 以外には無いので、そこでは省略する +// (iOS ビルドは macOS でしか行わないため実害は無い)。 +const codesignCommand = + process.env.VOICEVOX_CODESIGN !== undefined + ? process.env.VOICEVOX_CODESIGN + : process.platform === 'darwin' + ? 'codesign' + : ''; + const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex'); const download = async (url) => { @@ -105,6 +115,38 @@ const normalizeBundleIdentifiers = (xcframeworkDir) => { return rewritten; }; +// 各スライスの framework を ad-hoc で署名し直し、署名の識別子を CFBundleIdentifier に揃える。 +// voicevox_onnxruntime 1.23.2 のバイナリには識別子 `libvoicevox_onnxruntime.1` の ad-hoc 署名が +// 埋め込まれており、Xcode は埋め込み時に `--preserve-metadata=identifier` で既存の識別子を +// 引き継ぐため、App Store Connect のアップロード検証が +// "Invalid Code Signature Identifier ... must match its Bundle Identifier" で失敗する。 +// 未署名の voicevox_core は Xcode が CFBundleIdentifier から識別子を導出するので問題無いが、 +// 配布物の署名状態に依存しないよう全スライスを一律に署名し直す(冪等)。 +const resignFrameworks = (xcframeworkDir) => { + const resigned = []; + for (const plistPath of listFrameworkInfoPlists(xcframeworkDir)) { + const frameworkDir = dirname(plistPath); + // macOS スライス (Versions/A/Resources/Info.plist) は iOS アプリに埋め込まれないので対象外 + if (!frameworkDir.endsWith('.framework')) { + continue; + } + const match = readFileSync(plistPath, 'utf8').match( + /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/ + ); + if (!match) { + continue; + } + const identifier = match[1]; + execFileSync( + codesignCommand, + ['--force', '--sign', '-', '--identifier', identifier, frameworkDir], + { stdio: 'inherit' } + ); + resigned.push({ frameworkDir, identifier }); + } + return resigned; +}; + for (const framework of manifest.frameworks) { const { name, version, url, sha256 } = framework; const destination = join(frameworksDir, `${name}.xcframework`); @@ -162,4 +204,16 @@ for (const framework of manifest.frameworks) { `[voicevox] normalized CFBundleIdentifier ${from} -> ${to} (${plistPath})` ); } + + if (codesignCommand === '') { + console.log( + `[voicevox] skipped re-signing ${name} (codesign is unavailable on this platform)` + ); + } else { + for (const { frameworkDir, identifier } of resignFrameworks(destination)) { + console.log( + `[voicevox] re-signed ${frameworkDir} with identifier ${identifier}` + ); + } + } } diff --git a/scripts/fetch-voicevox-frameworks.test.js b/scripts/fetch-voicevox-frameworks.test.js index 51a5a7bb4..0c1c63d68 100644 --- a/scripts/fetch-voicevox-frameworks.test.js +++ b/scripts/fetch-voicevox-frameworks.test.js @@ -57,12 +57,30 @@ const setupFrameworksDir = ({ name, version, identifiers }) => { return { dir, xcframework, slicePlists }; }; -const runScript = (frameworksDir) => +// VOICEVOX_CODESIGN を空にして署名し直しを省略する(テスト用の framework にはバイナリが無く、 +// macOS 上で本物の codesign を呼ぶと失敗する)。署名し直しの検証は fakeCodesign で行う +const runScript = (frameworksDir, { codesign = '' } = {}) => execFileSync(process.execPath, [scriptPath], { encoding: 'utf8', - env: { ...process.env, VOICEVOX_FRAMEWORKS_DIR: frameworksDir }, + env: { + ...process.env, + VOICEVOX_FRAMEWORKS_DIR: frameworksDir, + VOICEVOX_CODESIGN: codesign, + }, }); +// 受け取った引数を 1 呼び出し 1 行で記録するだけの偽 codesign +const fakeCodesign = (dir) => { + const logPath = path.join(dir, 'codesign.log'); + const scriptPath = path.join(dir, 'codesign'); + fs.writeFileSync( + scriptPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\n`, + { mode: 0o755 } + ); + return { scriptPath, logPath }; +}; + const readIdentifier = (plistPath) => fs .readFileSync(plistPath, 'utf8') @@ -131,6 +149,47 @@ describe('fetch-voicevox-frameworks', () => { expect(fs.statSync(plistPath).mtimeMs).toBe(before); }); + it('各スライスの framework を CFBundleIdentifier を識別子にして ad-hoc で署名し直す', () => { + const { dir, xcframework } = setupFrameworksDir({ + name: 'voicevox_onnxruntime', + version: '1.23.2', + identifiers: { + 'ios-arm64': 'jp.hiroshiba.voicevox.voicevox_onnxruntime', + 'ios-arm64_x86_64-simulator': + 'jp.hiroshiba.voicevox.voicevox_onnxruntime', + }, + }); + tempDirs.push(dir); + const codesign = fakeCodesign(dir); + + const stdout = runScript(dir, { codesign: codesign.scriptPath }); + + const calls = fs.readFileSync(codesign.logPath, 'utf8').trim().split('\n'); + expect(calls.sort()).toEqual( + [ + `--force --sign - --identifier jp.hiroshiba.voicevox.voicevox-onnxruntime ${path.join(xcframework, 'ios-arm64', 'voicevox_onnxruntime.framework')}`, + `--force --sign - --identifier jp.hiroshiba.voicevox.voicevox-onnxruntime ${path.join(xcframework, 'ios-arm64_x86_64-simulator', 'voicevox_onnxruntime.framework')}`, + ].sort() + ); + expect(stdout).toContain( + 'with identifier jp.hiroshiba.voicevox.voicevox-onnxruntime' + ); + expect(stdout).not.toContain('skipped re-signing'); + }); + + it('codesign が無い環境では署名し直しを省略して続行する', () => { + const { dir } = setupFrameworksDir({ + name: 'voicevox_core', + version: '0.17.0', + identifiers: { 'ios-arm64': 'jp.hiroshiba.voicevox.voicevox-core' }, + }); + tempDirs.push(dir); + + const stdout = runScript(dir, { codesign: '' }); + + expect(stdout).toContain('skipped re-signing voicevox_core'); + }); + it('バイナリ plist は黙って素通しせずエラーにする', () => { const { dir, slicePlists } = setupFrameworksDir({ name: 'voicevox_onnxruntime', From 2f5f16e60101f97fafed3e387d24d4152bd4ecf7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:54:44 +0900 Subject: [PATCH 18/62] Bump version for canary release (#6895) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index a32585580..2e8e271cc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000727 + versionCode 100000729 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000727 + versionCode 100000729 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index b8344879d..76e638227 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000728 + versionCode = 100000730 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000728 + versionCode = 100000730 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index d5fcda66e..232b7d307 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2893', + buildNumber: '2894', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000727, + versionCode: 100000729, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index f1cd6e835..d6a35814c 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2524,7 +2524,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2564,7 +2564,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2809,7 +2809,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3061,7 +3061,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3113,7 +3113,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3164,7 +3164,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3222,7 +3222,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3273,7 +3273,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3330,7 +3330,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3379,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3431,7 +3431,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3650,7 +3650,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3706,7 +3706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3756,7 +3756,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3814,7 +3814,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2893; + CURRENT_PROJECT_VERSION = 2894; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 1909ee424bd7a177b4427425735f8293df06f9e7 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 9 Sep 2026 01:11:30 +0900 Subject: [PATCH 19/62] =?UTF-8?q?=E3=82=AA=E3=83=95=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E7=94=A8=E6=97=A5=E6=9C=AC=E8=AA=9E=E9=9F=B3=E5=A3=B0?= =?UTF-8?q?=E3=81=AE=E6=A1=88=E5=86=85=E6=96=87=E3=82=92=E3=83=80=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89=E3=81=97=E3=81=AA=E3=81=8F?= =?UTF-8?q?=E3=81=A6=E3=82=82=E7=AB=AF=E6=9C=AB=E5=86=85=E8=94=B5=E9=9F=B3?= =?UTF-8?q?=E5=A3=B0=E3=81=A7=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E3=82=89?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E3=81=93=E3=81=A8=E3=81=8C=E5=88=86=E3=81=8B?= =?UTF-8?q?=E3=82=8B=E8=A1=A8=E7=8F=BE=E3=81=AB=E6=94=B9=E3=82=81=E3=82=8B?= =?UTF-8?q?=20(#6897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/translations/en.json | 8 ++++---- assets/translations/ja.json | 8 ++++---- docs/spec/tts/on-device-tts-ios.md | 9 ++++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 351eaab36..ba5e0960b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -229,16 +229,16 @@ "later": "Later", "download": "Download", "voicevoxDownloadTitle": "Offline Japanese Voice", - "voicevoxDownloadPrompt": "Download the Japanese voice data (about %{size}MB) used when there is no signal, such as in tunnels? Regular announcements will keep using voices synthesized over the internet. You can also download it later from the Auto Announce settings.", - "voicevoxNotDownloadedDescription": "When offline, Japanese announcements are read with a voice synthesized on the device (VOICEVOX:No.7). Downloading uses about %{size}MB of data.", + "voicevoxDownloadPrompt": "Even without this download, announcements still play with the device's built-in voice when there is no signal. Download the more natural Japanese voice data (VOICEVOX:No.7, about %{size}MB) to use it in tunnels and other offline situations. Regular announcements keep using voices synthesized over the internet. You can also download it later from the Auto Announce settings.", + "voicevoxNotDownloadedDescription": "Without this download, the device's built-in voice reads announcements when offline. Download it to read offline Japanese announcements with a more natural voice (VOICEVOX:No.7). Uses about %{size}MB of data.", "voicevoxDownloadAction": "Download (about %{size}MB)", "voicevoxDownloading": "Downloading", "voicevoxInstalled": "Downloaded (%{size})", - "voicevoxInstalledDescription": "When offline, Japanese announcements are read with this voice.", + "voicevoxInstalledDescription": "When offline, Japanese announcements are read with this voice. Announcements still play with the device's built-in voice if you delete it.", "voicevoxDownloadFailed": "The download failed. Check your connection and try again.", "voicevoxDelete": "Delete", "voicevoxDeleteConfirmTitle": "Delete the voice data?", - "voicevoxDeleteConfirmMessage": "After deletion, the device's built-in voice is used when offline. You can download it again later.", + "voicevoxDeleteConfirmMessage": "After deletion, the device's built-in voice reads announcements when offline. You can download it again later.", "linesNearbyAtStation": "Lines at %{stationName} Station", "busStopsNearby": "Nearby bus routes", "themeConfirmApply": "Apply", diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 5842b37a4..3a92e9a51 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -230,16 +230,16 @@ "later": "あとで", "download": "ダウンロード", "voicevoxDownloadTitle": "オフライン用の日本語音声", - "voicevoxDownloadPrompt": "圏外やトンネルで通信できないときに使う日本語の音声データ(約%{size}MB)をダウンロードしますか?通常のアナウンスはこれまでどおりインターネット経由で合成した音声を使います。あとから自動アナウンスの設定画面でもダウンロードできます。", - "voicevoxNotDownloadedDescription": "通信できないときに、端末内で合成した日本語の音声(VOICEVOX:No.7)で読み上げます。ダウンロードには約%{size}MBの通信が発生します。", + "voicevoxDownloadPrompt": "通信できないときのアナウンスは、ダウンロードしなくても端末内蔵の読み上げ音声で流れます。より自然な日本語の音声データ(VOICEVOX:No.7、約%{size}MB)をダウンロードすると、圏外やトンネルでもその音声で読み上げます。通常のアナウンスはこれまでどおりインターネット経由で合成した音声です。あとから自動アナウンスの設定画面でもダウンロードできます。", + "voicevoxNotDownloadedDescription": "ダウンロードしなくても、通信できないときは端末内蔵の読み上げ音声でアナウンスします。ダウンロードすると、通信できないときの日本語をより自然な音声(VOICEVOX:No.7)で読み上げます。約%{size}MBの通信が発生します。", "voicevoxDownloadAction": "ダウンロード(約%{size}MB)", "voicevoxDownloading": "ダウンロード中", "voicevoxInstalled": "ダウンロード済み(%{size})", - "voicevoxInstalledDescription": "通信できないときは、この音声で日本語のアナウンスを読み上げます。", + "voicevoxInstalledDescription": "通信できないときは、この音声で日本語のアナウンスを読み上げます。削除しても、端末内蔵の読み上げ音声でアナウンスは流れます。", "voicevoxDownloadFailed": "ダウンロードに失敗しました。通信環境を確認して再試行してください。", "voicevoxDelete": "削除", "voicevoxDeleteConfirmTitle": "音声データを削除しますか?", - "voicevoxDeleteConfirmMessage": "削除すると、通信できないときは端末の読み上げ音声に戻ります。あとから再ダウンロードできます。", + "voicevoxDeleteConfirmMessage": "削除しても、通信できないときは端末内蔵の読み上げ音声でアナウンスします。あとから再ダウンロードできます。", "linesNearbyAtStation": "%{stationName}駅の路線", "busStopsNearby": "周辺のバス路線", "themeConfirmApply": "適用", diff --git a/docs/spec/tts/on-device-tts-ios.md b/docs/spec/tts/on-device-tts-ios.md index 2af990835..76897f176 100644 --- a/docs/spec/tts/on-device-tts-ios.md +++ b/docs/spec/tts/on-device-tts-ios.md @@ -136,9 +136,12 @@ App Clip(`ProdAppClip` / `CanaryAppClip`)は deployment target が 16.4 の 設定画面(`src/screens/TTSSettings.tsx`)が担う。 - **同意ダイアログ**: 自動アナウンスを有効化した瞬間に、VOICEVOX が使える構成 - (`phase !== 'unsupported'`)で未取得なら「オフライン用の日本語音声(約 160MB)を - ダウンロードしますか?」を出す。既存の注意ダイアログが先に出る場合はキューで続けて表示 - される。「ダウンロード」で `requestVoicevoxAssetsDownload()`(同意を MMKV + (`phase !== 'unsupported'`)で未取得なら「オフライン用の日本語音声」の同意ダイアログを + 出す。文言は「ダウンロードしなくても通信できないときは端末内蔵の読み上げ音声で流れる」 + 「ダウンロードすると圏外・トンネルでもより自然な音声(VOICEVOX:No.7)で読める」の + 2 点を明示し、取得が再生の前提だと読めないようにする(パネルの未取得時の説明・削除確認も + 同じ趣旨)。既存の注意ダイアログが先に出る場合はキューで続けて表示される。 + 「ダウンロード」で `requestVoicevoxAssetsDownload()`(同意を MMKV `VOICEVOX_DOWNLOAD_CONSENTED` に記録して直ちに取得)、「あとで」なら何もしない。 自動アナウンス自体はどちらでも有効になる。 - **パネル**: 設定画面の速度設定の下に「オフライン用の日本語音声」の枠を出し、状態ごとに From 0bfff19291065dc86b73c44070ec67e5fa286498 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 9 Sep 2026 01:39:23 +0900 Subject: [PATCH 20/62] =?UTF-8?q?=E9=80=9F=E5=BA=A6=E3=83=95=E3=82=A3?= =?UTF-8?q?=E3=83=AB=E3=82=BF=E3=81=AE=E5=9F=BA=E6=BA=96=E3=82=92EMA?= =?UTF-8?q?=E5=BE=8C=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E7=94=9F=E5=BA=A7?= =?UTF-8?q?=E6=A8=99=E3=81=AB=E3=81=97=E3=81=A6=E6=96=B0=E5=B9=B9=E7=B7=9A?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E3=81=A7=E3=81=AE=E7=8F=BE=E5=9C=A8=E5=9C=B0?= =?UTF-8?q?=E5=87=8D=E7=B5=90=E3=82=92=E4=BF=AE=E6=AD=A3=20(#6898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/atoms/location.test.ts | 155 +++++++++++++++++++++++++++++++ src/store/atoms/location.ts | 90 +++++++++++++++--- 2 files changed, 234 insertions(+), 11 deletions(-) diff --git a/src/store/atoms/location.test.ts b/src/store/atoms/location.test.ts index bcba91638..7ff714dcd 100644 --- a/src/store/atoms/location.test.ts +++ b/src/store/atoms/location.test.ts @@ -190,3 +190,158 @@ describe('setLocation', () => { }); }); }); + +// 東北新幹線の一ノ関〜仙台に相当する南北方向の直線を走らせる。 +// 緯度1度 ≒ 111,320m として、速度から1サンプルあたりの緯度差を求める。 +const METERS_PER_DEG_LAT = 111_320; + +const runConstantSpeed = ({ + speedKmh, + accuracy, + sampleIntervalMs, + samples, + startLat = 38.9, + startTimestamp = 1_000, +}: { + speedKmh: number; + accuracy: number; + sampleIntervalMs: number; + samples: number; + startLat?: number; + startTimestamp?: number; +}) => { + const degPerSample = + ((speedKmh / 3.6) * (sampleIntervalMs / 1000)) / METERS_PER_DEG_LAT; + + let maxLagMeters = 0; + let lastLagMeters = 0; + for (let i = 0; i < samples; i++) { + const trueLat = startLat - degPerSample * i; + setLocation( + makeLocation( + trueLat, + 140.9, + accuracy, + startTimestamp + sampleIntervalMs * i + ) + ); + const shownLat = store.get(locationAtom)?.coords.latitude ?? trueLat; + lastLagMeters = Math.abs(shownLat - trueLat) * METERS_PER_DEG_LAT; + maxLagMeters = Math.max(maxLagMeters, lastLagMeters); + } + return { maxLagMeters, lastLagMeters }; +}; + +// 定速走行時にEMAが構造的に持つ追従遅れ ((1-α)/α)·v·dt。 +// 速度フィルタが正常に受理し続けている限り、ズレはこの値付近で頭打ちになる。 +// 逆にこれを大きく超える場合は棄却ループで位置が凍結していることを意味する。 +const expectedEmaLagMeters = ( + speedKmh: number, + alpha: number, + sampleIntervalMs: number +) => ((1 - alpha) / alpha) * (speedKmh / 3.6) * (sampleIntervalMs / 1000); + +describe('高速走行時の追従', () => { + beforeEach(() => { + resetLocationState(); + setStationLineType(LineType.Normal); + }); + + // 回帰: 速度フィルタの基準にEMA後の座標を使うと、EMAの追従遅れが変位へ上乗せされ + // 算出速度が実速度の1/α倍に膨らむ。実効しきい値がα×360km/hまで下がり、 + // 新幹線の320km/h走行が丸ごと棄却されて位置が数十km手前で凍結していた。 + it.each([ + // alphaはgetSmoothingAlphaの区分に対応する + { accuracy: 30, alpha: 0.8, label: '精度良好' }, + { accuracy: 100, alpha: 0.6, label: '精度中' }, + { accuracy: 300, alpha: 0.3, label: '精度不良' }, + ])( + '320km/hで5分走り続けても位置が凍結しない($label)', + ({ accuracy, alpha }) => { + const speedKmh = 320; + const sampleIntervalMs = 1000; + const { maxLagMeters, lastLagMeters } = runConstantSpeed({ + speedKmh, + accuracy, + sampleIntervalMs, + samples: 300, + }); + + // 修正前はこの条件で98%以上の測位が棄却され、5分間で10km以上ズレていた。 + // ズレがEMA固有の追従遅れの範囲に収まっていれば受理し続けられている。 + const emaLag = expectedEmaLagMeters(speedKmh, alpha, sampleIntervalMs); + expect(maxLagMeters).toBeLessThan(emaLag * 1.1); + // 時間が経ってもズレが増えない(凍結して開き続けない)こと + expect(lastLagMeters).toBeLessThan(emaLag * 1.1); + } + ); + + it('130km/hの在来線速度でも追従する', () => { + const { maxLagMeters } = runConstantSpeed({ + speedKmh: 130, + accuracy: 100, + sampleIntervalMs: 1000, + samples: 300, + }); + + // 到着判定圏(ARRIVED_MAX_THRESHOLD=200m)に十分収まること + expect(maxLagMeters).toBeLessThan(50); + }); + + // 回帰: 測位が長時間途切れたあとEMAで混ぜると新しい測位のα割しか反映されず、 + // 残った遅れが次の変位へ乗って再棄却され、復帰できなくなっていた。 + it('測位が30分途切れたあと最初の測位で現在地へ復帰する', () => { + setLocation(makeLocation(38.9, 140.9, 30, 1_000)); + + // 30分後、約160km南下した地点で測位が再開する + const resumedLat = 38.9 - 160_000 / METERS_PER_DEG_LAT; + setLocation(makeLocation(resumedLat, 140.9, 100, 1_000 + 30 * 60 * 1000)); + + // スムージングを挟まず生の座標へスナップすること + expect(store.get(locationAtom)?.coords.latitude).toBeCloseTo(resumedLat, 6); + }); + + // 回帰: STALE_REFERENCE_MS は「これ以上古い」基準なので、境界値ちょうどでも + // スナップする(比較が > だと境界でEMAが掛かり生座標へ張り付かない) + it('途切れがSTALE_REFERENCE_MSちょうど(30秒)でも生座標へスナップする', () => { + setLocation(makeLocation(38.9, 140.9, 30, 1_000)); + + // ちょうど30秒後、速度としては妥当な範囲(約1.6km先 ≒ 53m/s)で測位が再開する + const resumedLat = 38.9 - 1_600 / METERS_PER_DEG_LAT; + setLocation(makeLocation(resumedLat, 140.9, 100, 1_000 + 30_000)); + + expect(store.get(locationAtom)?.coords.latitude).toBeCloseTo(resumedLat, 9); + }); + + it('連続棄却が上限に達したら基準を張り直して凍結から復帰する', () => { + setLocation(makeLocation(38.9, 140.9, 30, 1_000)); + + // 1秒間隔で物理的にありえない距離のジャンプを送り続ける + const jumpLat = 36.0; + for (let i = 1; i <= 5; i++) { + setLocation(makeLocation(jumpLat, 140.9, 30, 1_000 + i * 1000)); + } + + // 5回目(MAX_CONSECUTIVE_SPEED_REJECTIONS)で基準を張り直し、座標が反映される + expect(store.get(locationAtom)?.coords.latitude).toBe(jumpLat); + }); + + // 回帰: 「基準が古い」判定が速度フィルタより先に return していると、 + // 測位間隔が空いた直後の1点に限ってワープ対策が無効になる + it('30秒以上途切れたあとでも物理的にありえないジャンプは棄却する', () => { + setLocation(makeLocation(38.9, 140.9, 30, 1_000)); + + // 60秒後に約500km離れた地点(≒8300m/s)へ飛ぶ測位が届く + const warpLat = 38.9 - 500_000 / METERS_PER_DEG_LAT; + setLocation(makeLocation(warpLat, 140.9, 30, 1_000 + 60_000)); + + expect(store.get(locationAtom)?.coords.latitude).toBe(38.9); + }); + + it('単発のジャンプは従来どおり棄却する', () => { + setLocation(makeLocation(38.9, 140.9, 30, 1_000)); + setLocation(makeLocation(36.0, 140.9, 30, 2_000)); + + expect(store.get(locationAtom)?.coords.latitude).toBe(38.9); + }); +}); diff --git a/src/store/atoms/location.ts b/src/store/atoms/location.ts index 847be100b..edc0848aa 100644 --- a/src/store/atoms/location.ts +++ b/src/store/atoms/location.ts @@ -10,6 +10,20 @@ const MAX_ACCURACY_HISTORY = 12; // 物理的にありえない速度でのジャンプを棄却する閾値(m/s ≒ 360km/h) const MAX_PLAUSIBLE_SPEED = 100; + +// 速度フィルタが連続して棄却できる回数の上限。棄却しても基準座標は更新しないため、 +// 基準側が実際の現在地から乖離している場合は正常な測位が延々と弾かれ、位置が +// 永久に凍結する。この回数に達したら「基準の方が誤っている」と判断し、届いた +// 測位で基準を張り直す。 +const MAX_CONSECUTIVE_SPEED_REJECTIONS = 5; + +// 基準座標がこれ以上古い場合、EMAの基準としては意味を持たないため、速度フィルタを +// 通過したうえでスムージングせず新しい測位へスナップし、基準を張り直す。 +// バックグラウンド測位の抑止(「常に許可」未設定)やトンネルで測位が数十分途切れた +// あと、EMAで混ぜると新しい測位のα割しか反映されず、残った遅れがそのまま次の +// 変位へ乗って再び速度超過になる、という復帰不能ループを防ぐ。 +const STALE_REFERENCE_MS = 30_000; + // GPS精度に応じたスムージング重みを返す(精度が良いほど新しい値を信頼する) const getSmoothingAlpha = (accuracy: number | null): number => { if (accuracy == null || accuracy <= 0) { @@ -57,17 +71,29 @@ export const backgroundLocationTrackingAtom = atom(false); // 下流の処理が「現在位置を信用できない=走行中」と扱えるようにする。 export const locationAccuracyOutlierAtom = atom(false); -// 速度フィルタ・EMAスムージングの基準として使う「最後にフィルタ処理を通過した位置」 +// EMAスムージングの基準として使う「最後にフィルタ処理を通過した位置」 // 地下鉄モード中は更新しないため、モード復帰後にノイジーなprevで誤棄却されるのを防ぐ const lastFilteredLocationAtom = atom(null); +// 速度フィルタの基準として使う「最後に受理した“生の”座標」。 +// EMA後の座標を基準にすると、EMAの追従遅れ(定速時 ((1-α)/α)·v·dt)が変位へ上乗せ +// され、算出速度が実速度の 1/α 倍に膨らむ。実効的なしきい値が α×360km/h まで下がり、 +// 精度が良くても288km/h、精度200m超では108km/hで棄却が始まるため、新幹線の320km/h +// 走行が丸ごと弾かれて位置が凍結していた。速度判定は生座標同士で行う。 +const lastRawLocationAtom = atom(null); + +// 速度フィルタが連続で棄却した回数。MAX_CONSECUTIVE_SPEED_REJECTIONSの判定に使う。 +let consecutiveSpeedRejections = 0; + // テスト用: モジュール内部の状態をリセットする export const resetLocationState = () => { store.set(locationAtom, null); store.set(rawLocationAtom, null); store.set(accuracyHistoryAtom, []); store.set(lastFilteredLocationAtom, null); + store.set(lastRawLocationAtom, null); store.set(locationAccuracyOutlierAtom, false); + consecutiveSpeedRejections = 0; }; // ワープ対策フィルタによる棄却有無を記録する。handleTrackingLocationから @@ -84,6 +110,21 @@ export const setRawLocation = (location: Location.LocationObject) => { store.set(rawLocationAtom, location); }; +// スムージングせず測位をそのまま反映し、EMA基準・速度フィルタ基準の双方を張り直す。 +// 基準が無い(初回起動・地下鉄からの復帰)、基準が古すぎる、基準が誤っていると判断した +// 場合に使う。基準を両方とも生の測位へ揃えるのが要点で、片方だけ残すと次回の変位に +// 古い遅れが乗って誤棄却の引き金になる。 +const resyncLocationReference = ( + location: Location.LocationObject, + updatedHistory: number[] +) => { + store.set(locationAtom, location); + store.set(lastFilteredLocationAtom, location); + store.set(lastRawLocationAtom, location); + store.set(accuracyHistoryAtom, updatedHistory); + consecutiveSpeedRejections = 0; +}; + // 受理した測位が反映される唯一の入口。継続測位の正常系に加え、ワンショット取得や // 手動選択(StationSearchModal/useInitialNearbyStation/Privacy等)もここを通る。 export const setLocation = (location: Location.LocationObject) => { @@ -95,6 +136,7 @@ export const setLocation = (location: Location.LocationObject) => { store.set(locationAccuracyOutlierAtom, false); const filteredPrev = store.get(lastFilteredLocationAtom); + const rawPrev = store.get(lastRawLocationAtom); const currentHistory = store.get(accuracyHistoryAtom); const newAccuracy = location.coords.accuracy; @@ -110,28 +152,34 @@ export const setLocation = (location: Location.LocationObject) => { currentLineType === LineType.Subway && !isAccuracyStable(updatedHistory); // スムージングスキップ時はフィルタ・スムージングを全てスキップする - // UIには生の座標を反映するが、フィルタ基準(lastFilteredLocationAtom)は更新しない + // UIには生の座標を反映するが、EMA基準(lastFilteredLocationAtom)も速度フィルタ基準 + // (lastRawLocationAtom)も更新しない。地上復帰時は基準が古いためSTALE_REFERENCE_MSの + // 判定に掛かり、そこで張り直される if (skipSmoothing) { store.set(locationAtom, location); store.set(accuracyHistoryAtom, updatedHistory); return; } - // フィルタ基準となるprevが無い場合(初回起動時や地下鉄→地上の復帰直後) - if (filteredPrev == null) { - store.set(locationAtom, location); - store.set(lastFilteredLocationAtom, location); - store.set(accuracyHistoryAtom, updatedHistory); + // 基準が無い場合(初回起動時や地下鉄→地上の復帰直後) + if (filteredPrev == null || rawPrev == null) { + resyncLocationReference(location, updatedHistory); return; } - // 前回の座標が存在する場合、速度ベースの異常値フィルタを適用 - const dt = (location.timestamp - filteredPrev.timestamp) / 1000; // 秒 + // 前回の座標が存在する場合、速度ベースの異常値フィルタを適用する。 + // 基準は「生の前回座標」であってEMA後の座標ではない(lastRawLocationAtom参照)。 + // + // 測位が長く途切れたあとでも、このフィルタは先に必ず通す。意味を失うのはEMAの方 + // (下のSTALE_REFERENCE_MS判定)だけで、変位÷経過時間という速度の妥当性検査は + // 経過時間が延びても成立するため。ここを飛ばすと、間隔が空いた直後の1点に限って + // ワープ対策が無効になる。 + const dt = (location.timestamp - rawPrev.timestamp) / 1000; // 秒 if (dt > 0) { const dist = getDistance( { - latitude: filteredPrev.coords.latitude, - longitude: filteredPrev.coords.longitude, + latitude: rawPrev.coords.latitude, + longitude: rawPrev.coords.longitude, }, { latitude: location.coords.latitude, @@ -142,11 +190,29 @@ export const setLocation = (location: Location.LocationObject) => { // 物理的にありえない速度の場合は座標を棄却し、前回値を維持する if (speed > MAX_PLAUSIBLE_SPEED) { + consecutiveSpeedRejections += 1; + // 棄却が続くのは基準側が誤っている可能性が高い。位置が凍結したまま + // 復帰できなくなるのを避けるため、上限に達したら基準を張り直す。 + if (consecutiveSpeedRejections >= MAX_CONSECUTIVE_SPEED_REJECTIONS) { + resyncLocationReference(location, updatedHistory); + return; + } store.set(accuracyHistoryAtom, updatedHistory); return; } } + consecutiveSpeedRejections = 0; + + // 速度としては妥当だが基準が古すぎる場合、EMAの基準としては使えないため + // スムージングせず生の座標へスナップして基準を張り直す。長く途切れたあとに + // EMAで混ぜると新しい測位のα割しか反映されず、残った遅れが次回の変位へ乗って + // 再棄却…という復帰不能ループの原因になる。 + if (location.timestamp - rawPrev.timestamp >= STALE_REFERENCE_MS) { + resyncLocationReference(location, updatedHistory); + return; + } + // EMA(指数移動平均)で座標をスムージングする // 精度が良いほどαが大きくなり、新しい測位値をより信頼する const alpha = getSmoothingAlpha(newAccuracy); @@ -168,5 +234,7 @@ export const setLocation = (location: Location.LocationObject) => { store.set(locationAtom, smoothedLocation); store.set(lastFilteredLocationAtom, smoothedLocation); + // 速度フィルタの基準はスムージング前の生座標を保持する + store.set(lastRawLocationAtom, location); store.set(accuracyHistoryAtom, updatedHistory); }; From 6158650fada10090852ccd356e35cf11ddc6404f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:47:21 +0900 Subject: [PATCH 21/62] Bump version for canary release (#6901) --- android/app/build.gradle | 4 +-- android/wearable/build.gradle.kts | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2e8e271cc..0c1cf33bc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -154,12 +154,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000729 + versionCode 100000731 versionName "10.15.0" } prod { dimension "environment" - versionCode 100000729 + versionCode 100000731 versionName "10.15.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 76e638227..503e435a8 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000730 + versionCode = 100000732 versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000730 + versionCode = 100000732 versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 232b7d307..c601ff4a2 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2894', + buildNumber: '2895', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000729, + versionCode: 100000731, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index d6a35814c..65584f6c2 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2524,7 +2524,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2564,7 +2564,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2623,7 +2623,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2809,7 +2809,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3061,7 +3061,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3113,7 +3113,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3164,7 +3164,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3222,7 +3222,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3273,7 +3273,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3330,7 +3330,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3379,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3431,7 +3431,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3650,7 +3650,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3706,7 +3706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3756,7 +3756,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3814,7 +3814,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2894; + CURRENT_PROJECT_VERSION = 2895; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 20ea9eb8bacb5fde3766edcd21317b8a1266a0ad Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 9 Sep 2026 02:05:32 +0900 Subject: [PATCH 22/62] =?UTF-8?q?=E6=B8=AC=E4=BD=8D=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3=E3=82=92=E5=AE=9F=E9=9A=9B?= =?UTF-8?q?=E3=81=AB=E9=80=9A=E3=81=97=E3=81=A6=E6=A4=9C=E8=A8=BC=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=81=9F=E3=82=81=E3=81=AEGPX=E7=94=9F=E6=88=90?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=81=A8=E6=96=B0?= =?UTF-8?q?=E5=B9=B9=E7=B7=9A=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AB=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#6899)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/location-simulation.md | 92 + ios/SampleTohokuShinkansen.gpx | 6246 +++++++++++++++++++++++++++++ package.json | 1 + scripts/generate-location-gpx.mjs | 395 ++ 4 files changed, 6734 insertions(+) create mode 100644 docs/location-simulation.md create mode 100644 ios/SampleTohokuShinkansen.gpx create mode 100644 scripts/generate-location-gpx.mjs diff --git a/docs/location-simulation.md b/docs/location-simulation.md new file mode 100644 index 000000000..6db93f878 --- /dev/null +++ b/docs/location-simulation.md @@ -0,0 +1,92 @@ +# 位置情報シミュレーション (GPX) + +測位まわりの変更を検証するための GPX を生成し、iOS シミュレータへ流し込む手順。 + +## なぜオートモードでは検証できないか + +アプリのオートモード (`src/hooks/useSimulationMode.ts`) は擬似的な現在地を作るが、 +書き込み先は `store.set(locationAtom, ...)` で、**`setLocation` を経由しない**。 + +そのため `src/store/atoms/location.ts` の次の処理を一切通らない。 + +- ワープ対策の速度フィルタ (`MAX_PLAUSIBLE_SPEED`) +- EMA スムージング (`getSmoothingAlpha`) +- 基準座標の張り直し (`STALE_REFERENCE_MS` / `MAX_CONSECUTIVE_SPEED_REJECTIONS`) +- 精度フィルタ (`handleTrackingLocation` の `MAX_PERMIT_ACCURACY`) + +これらを含めた測位パイプライン全体を検証するには、OS 側から測位を流し込む必要がある。 + +```text +Core Location → watchPositionAsync → handleTrackingLocation → setLocation → locationAtom + ↑ GPX ならここを通る + +useSimulationMode ─────────────────────────────────────────────────────────→ locationAtom + ↑ オートモードはここを飛ばす +``` + +## GPX を生成する + +`scripts/generate-location-gpx.mjs` が StationAPI の実際の駅座標から GPX を生成する。 +速度プロファイルはアプリ本体の `generateTrainSpeedProfile` をそのまま使うため、 +加減速の挙動はオートモードと同じモデルになる。 + +まず路線の駅 ID を調べる。 + +```bash +npm run gpx:generate -- --line 1004 --list +``` + +生成する。 + +```bash +npm run gpx:generate -- \ + --line 1004 --from 100418 --to 100411 --max-speed 320 \ + --skip 100417,100416,100415,100413,100412 \ + --out ios/SampleTohokuShinkansen.gpx +``` + +主なオプションは次のとおり。`--help` で全件を表示できる。 + +| オプション | 説明 | +| ---- | ---- | +| `--line` | 路線 ID。東北新幹線は `1004` | +| `--from` / `--to` | 始点・終点の駅 ID。API の並び順に関わらず指定した向きで走る | +| `--max-speed` | 最高速度 (km/h)。既定は `320` | +| `--skip` | 通過駅の駅 ID (カンマ区切り)。停車しないだけで経路上は通過する | +| `--dwell` | 停車駅での停車時間 (秒)。既定は `60` | +| `--api` | StationAPI の URL。既定は `$GQL_API_URL` か本番エンドポイント | + +出力は 1 秒間隔の `` 列になる。iOS は `distanceInterval` 基準で概ね 1Hz 配信のため、 +実機の更新間隔に近い。 + +## 同梱している GPX + +| ファイル | 内容 | +| ---- | ---- | +| `ios/SampleJY.gpx` | 山手線。外部ツールで記録した実走行ログ | +| `ios/SampleTohokuShinkansen.gpx` | 東北新幹線 盛岡→仙台。最高 320km/h、一ノ関に停車、通過駅 5 駅を経由 | + +`SampleTohokuShinkansen.gpx` は盛岡以南の 320km/h 区間を再現するためのもの。 +経路上の 8 駅すべてに `ARRIVED_MAX_THRESHOLD` (200m) 以内まで接近するので、 +各駅の到着判定・通過判定をそのまま観測できる。 + +## シミュレータで再生する + +1. Xcode でワークスペースを開く。 +1. メニューの Debug > Simulate Location > Add GPX File to Workspace… で GPX を選ぶ。 +1. アプリを起動した状態で Debug > Simulate Location から追加した GPX を選ぶ。 + +Xcode は `