Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d72e3720 | |||
| 8e40583d66 |
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, Platform } from "react-native";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { Input, Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { AS } from "../../storageControl";
|
||||
@@ -9,6 +9,10 @@ import { useThemeColors } from "@/lib/theme";
|
||||
import { Asset } from "expo-asset";
|
||||
import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import type { AudioSource } from "expo-audio";
|
||||
import {
|
||||
DEFAULT_VOICEPEAK_BASE_URL,
|
||||
normalizeVoicepeakBaseUrl,
|
||||
} from "@/lib/voicepeak";
|
||||
|
||||
const previewSound = require("../../assets/sound/rikka-test.mp3");
|
||||
|
||||
@@ -16,6 +20,12 @@ export const SoundSettings = () => {
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const [delayAnnouncement, setDelayAnnouncement] = useState(false);
|
||||
const [voicepeakEnabled, setVoicepeakEnabled] = useState(false);
|
||||
const [voicepeakBaseUrl, setVoicepeakBaseUrl] = useState(
|
||||
DEFAULT_VOICEPEAK_BASE_URL
|
||||
);
|
||||
const [voicepeakApiToken, setVoicepeakApiToken] = useState("");
|
||||
const [voicepeakSpeaker, setVoicepeakSpeaker] = useState("");
|
||||
|
||||
// expo-asset でローカルパスを取得し、expo-audio に渡す
|
||||
const [resolvedSource, setResolvedSource] = useState<AudioSource>(null);
|
||||
@@ -54,11 +64,31 @@ export const SoundSettings = () => {
|
||||
const previewPlayer = useAudioPlayer(resolvedSource);
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT)
|
||||
.then((v) => setDelayAnnouncement(v === true || v === "true"))
|
||||
.catch(() => {
|
||||
// 未設定時はデフォルト値 false のまま
|
||||
});
|
||||
Promise.all([
|
||||
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_ENABLED).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_BASE_URL).catch(
|
||||
() => DEFAULT_VOICEPEAK_BASE_URL
|
||||
),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_API_TOKEN).catch(() => ""),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_SPEAKER).catch(() => ""),
|
||||
]).then(
|
||||
([delayValue, enabledValue, baseUrlValue, tokenValue, speakerValue]) => {
|
||||
setDelayAnnouncement(delayValue === true || delayValue === "true");
|
||||
setVoicepeakEnabled(enabledValue === true || enabledValue === "true");
|
||||
setVoicepeakBaseUrl(
|
||||
typeof baseUrlValue === "string" && baseUrlValue.trim()
|
||||
? normalizeVoicepeakBaseUrl(baseUrlValue)
|
||||
: DEFAULT_VOICEPEAK_BASE_URL
|
||||
);
|
||||
setVoicepeakApiToken(
|
||||
typeof tokenValue === "string" ? tokenValue : ""
|
||||
);
|
||||
setVoicepeakSpeaker(
|
||||
typeof speakerValue === "string" ? speakerValue : ""
|
||||
);
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const playPreview = useCallback(async () => {
|
||||
@@ -85,6 +115,18 @@ export const SoundSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveVoicepeakValue = useCallback(
|
||||
(key: string, value: string) => {
|
||||
AS.setItem(key, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleVoicepeakToggle = (value: boolean) => {
|
||||
setVoicepeakEnabled(value);
|
||||
AS.setItem(STORAGE_KEYS.VOICEPEAK_ENABLED, value.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem
|
||||
@@ -112,6 +154,95 @@ export const SoundSettings = () => {
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 15,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary ?? "#ccc",
|
||||
backgroundColor: colors.surface,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ flex: 1, fontSize: 16, color: colors.text }}>
|
||||
Voicepeak 発車案内
|
||||
</Text>
|
||||
<Switch
|
||||
value={voicepeakEnabled}
|
||||
onValueChange={handleVoicepeakToggle}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: colors.textSecondary ?? colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
トップメニューの最寄り駅・お気に入り駅で表示中の LED に合わせて、
|
||||
発車時刻 2 分前から Voicepeak API の案内音声を再生します。
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
label="Voicepeak API URL"
|
||||
value={voicepeakBaseUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder={DEFAULT_VOICEPEAK_BASE_URL}
|
||||
onChangeText={setVoicepeakBaseUrl}
|
||||
onBlur={() => {
|
||||
const normalized = normalizeVoicepeakBaseUrl(voicepeakBaseUrl);
|
||||
setVoicepeakBaseUrl(normalized);
|
||||
saveVoicepeakValue(STORAGE_KEYS.VOICEPEAK_BASE_URL, normalized);
|
||||
}}
|
||||
containerStyle={{ paddingHorizontal: 0 }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="API トークン"
|
||||
value={voicepeakApiToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
placeholder="Bearer トークンを入力"
|
||||
onChangeText={setVoicepeakApiToken}
|
||||
onBlur={() =>
|
||||
saveVoicepeakValue(
|
||||
STORAGE_KEYS.VOICEPEAK_API_TOKEN,
|
||||
voicepeakApiToken.trim()
|
||||
)
|
||||
}
|
||||
containerStyle={{ paddingHorizontal: 0 }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="話者名(任意)"
|
||||
value={voicepeakSpeaker}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
placeholder="例: Koharu Rikka"
|
||||
onChangeText={setVoicepeakSpeaker}
|
||||
onBlur={() =>
|
||||
saveVoicepeakValue(
|
||||
STORAGE_KEYS.VOICEPEAK_SPEAKER,
|
||||
voicepeakSpeaker.trim()
|
||||
)
|
||||
}
|
||||
containerStyle={{ paddingHorizontal: 0, marginBottom: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, FC } from "react";
|
||||
import { View, useWindowDimensions, Text } from "react-native";
|
||||
import React, { useState, useEffect, FC, useCallback, useMemo, useRef } from "react";
|
||||
import { View, useWindowDimensions, Text, Platform } from "react-native";
|
||||
import { objectIsEmpty } from "@/lib/objectIsEmpty";
|
||||
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
|
||||
import { useAreaInfo } from "@/stateBox/useAreaInfo";
|
||||
@@ -13,6 +13,20 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
|
||||
import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
import {
|
||||
buildVoicepeakAnnouncementKey,
|
||||
buildVoicepeakAnnouncementText,
|
||||
getVoicepeakAnnouncementStage,
|
||||
hasVoicepeakConfiguration,
|
||||
loadVoicepeakSettings,
|
||||
requestVoicepeakSpeech,
|
||||
type VoicepeakSettings,
|
||||
} from "@/lib/voicepeak";
|
||||
import { EMPTY_NATIVE_VOICEPEAK_HTML } from "@/lib/voicepeakAudioSource";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -49,7 +63,7 @@ type props = {
|
||||
export const LED_vision: FC<props> = (props) => {
|
||||
const { station } = props;
|
||||
|
||||
const { navigate, addListener, isFocused } = useNavigation();
|
||||
const { navigate, addListener } = useNavigation();
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const [stationDiagram, setStationDiagram] = useState<{
|
||||
[key: string]: string;
|
||||
@@ -59,8 +73,29 @@ export const LED_vision: FC<props> = (props) => {
|
||||
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
|
||||
const [isInfoArea, setIsInfoArea] = useState(false);
|
||||
const { areaInfo, areaStationID } = useAreaInfo();
|
||||
const { allTrainDiagram } = useAllTrainDiagram();
|
||||
const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
|
||||
const { fixed } = useThemeColors();
|
||||
const [voicepeakSettings, setVoicepeakSettings] =
|
||||
useState<VoicepeakSettings | null>(null);
|
||||
const announcementPlayer = useAudioPlayer(null);
|
||||
const announcedKeysRef = useRef<Set<string>>(new Set());
|
||||
const pendingAnnouncementKeyRef = useRef<string | null>(null);
|
||||
const currentRequestRef = useRef<AbortController | null>(null);
|
||||
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
|
||||
const [nativeVoicepeakHtml, setNativeVoicepeakHtml] = useState(
|
||||
EMPTY_NATIVE_VOICEPEAK_HTML
|
||||
);
|
||||
const [nativeVoicepeakPlaybackKey, setNativeVoicepeakPlaybackKey] = useState(0);
|
||||
|
||||
const refreshVoicepeakSettings = useCallback(() => {
|
||||
loadVoicepeakSettings()
|
||||
.then((settings) => {
|
||||
setVoicepeakSettings(settings);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to load Voicepeak settings", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem("LEDSettings/trainIDSwitch").then((data) => {
|
||||
@@ -72,7 +107,17 @@ export const LED_vision: FC<props> = (props) => {
|
||||
AS.getItem("LEDSettings/finalSwitch").then((data) => {
|
||||
setFinalSwitch(data === "true");
|
||||
});
|
||||
}, []);
|
||||
|
||||
refreshVoicepeakSettings();
|
||||
|
||||
const unsubscribe = addListener("focus", refreshVoicepeakSettings);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
currentRequestRef.current?.abort();
|
||||
cleanupAudioRef.current?.();
|
||||
};
|
||||
}, [addListener, refreshVoicepeakSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
|
||||
@@ -117,6 +162,139 @@ export const LED_vision: FC<props> = (props) => {
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
|
||||
|
||||
const voicepeakCandidates = useMemo(() => {
|
||||
if (!currentTrain?.length || !allCustomTrainData) return [];
|
||||
|
||||
return selectedTrain
|
||||
.map((train) => {
|
||||
const currentTrainData = getCurrentTrainData(
|
||||
train.train,
|
||||
currentTrain,
|
||||
allCustomTrainData
|
||||
);
|
||||
const currentTrainStatus = currentTrain.find(
|
||||
(currentTrainItem) => currentTrainItem.num === train.train
|
||||
);
|
||||
const stage = currentTrainData
|
||||
? getVoicepeakAnnouncementStage({
|
||||
station: station[0],
|
||||
train,
|
||||
currentTrainData,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!currentTrainData || !stage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: buildVoicepeakAnnouncementKey(station[0], train, stage),
|
||||
text: buildVoicepeakAnnouncementText({
|
||||
station: station[0],
|
||||
train,
|
||||
currentTrainData,
|
||||
stage,
|
||||
delayMinutes:
|
||||
typeof currentTrainStatus?.delay === "number"
|
||||
? currentTrainStatus.delay
|
||||
: 0,
|
||||
}),
|
||||
departureTime: train.time,
|
||||
priority: stage === "departure" ? 0 : 1,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is NonNullable<typeof candidate> => !!candidate)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
a.departureTime.localeCompare(b.departureTime)
|
||||
);
|
||||
}, [allCustomTrainData, currentTrain, selectedTrain, station]);
|
||||
|
||||
const playVoicepeakAnnouncement = useCallback(
|
||||
async (candidate: { key: string; text: string }) => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
return;
|
||||
}
|
||||
if (pendingAnnouncementKeyRef.current === candidate.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAnnouncementKeyRef.current = candidate.key;
|
||||
currentRequestRef.current?.abort();
|
||||
|
||||
const controller = new AbortController();
|
||||
currentRequestRef.current = controller;
|
||||
|
||||
try {
|
||||
const audio = await requestVoicepeakSpeech({
|
||||
text: candidate.text,
|
||||
settings: voicepeakSettings,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
cleanupAudioRef.current?.();
|
||||
cleanupAudioRef.current =
|
||||
audio.kind === "expo-audio" ? audio.cleanup : undefined;
|
||||
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: false,
|
||||
interruptionMode: "duckOthers",
|
||||
});
|
||||
|
||||
if (audio.kind === "native-webview") {
|
||||
setNativeVoicepeakHtml(audio.html);
|
||||
setNativeVoicepeakPlaybackKey((current) => current + 1);
|
||||
} else {
|
||||
if (announcementPlayer.playing) {
|
||||
announcementPlayer.pause();
|
||||
}
|
||||
|
||||
announcementPlayer.replace(audio.source);
|
||||
announcementPlayer.volume = 1;
|
||||
await announcementPlayer.seekTo(0);
|
||||
announcementPlayer.play();
|
||||
}
|
||||
announcedKeysRef.current.add(candidate.key);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("Failed to play Voicepeak announcement", error);
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestRef.current === controller) {
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
if (pendingAnnouncementKeyRef.current === candidate.key) {
|
||||
pendingAnnouncementKeyRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[announcementPlayer, voicepeakSettings]
|
||||
);
|
||||
|
||||
const checkVoicepeakAnnouncement = useCallback(() => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = voicepeakCandidates.find(
|
||||
(item) => !announcedKeysRef.current.has(item.key)
|
||||
);
|
||||
|
||||
if (!candidate) return;
|
||||
|
||||
void playVoicepeakAnnouncement(candidate);
|
||||
}, [playVoicepeakAnnouncement, voicepeakCandidates, voicepeakSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, [checkVoicepeakAnnouncement]);
|
||||
|
||||
useInterval(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, 15000);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const adjustedWidth = width * 0.98;
|
||||
return (
|
||||
@@ -129,6 +307,23 @@ export const LED_vision: FC<props> = (props) => {
|
||||
marginHorizontal: width * 0.01,
|
||||
}}
|
||||
>
|
||||
{Platform.OS !== "web" && (
|
||||
<WebView
|
||||
key={nativeVoicepeakPlaybackKey}
|
||||
source={{ html: nativeVoicepeakHtml }}
|
||||
originWhitelist={["*"]}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsInlineMediaPlayback
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Header station={station[0]} />
|
||||
|
||||
<View
|
||||
|
||||
@@ -109,6 +109,18 @@ export const STORAGE_KEYS = {
|
||||
/** 駅固定モード遅延速報案内機能(サウンド) */
|
||||
SOUND_DELAY_ANNOUNCEMENT: 'soundDelayAnnouncement',
|
||||
|
||||
/** Voicepeak 発車案内機能の有効化スイッチ */
|
||||
VOICEPEAK_ENABLED: 'voicepeakEnabled',
|
||||
|
||||
/** Voicepeak API ベースURL */
|
||||
VOICEPEAK_BASE_URL: 'voicepeakBaseUrl',
|
||||
|
||||
/** Voicepeak API トークン */
|
||||
VOICEPEAK_API_TOKEN: 'voicepeakApiToken',
|
||||
|
||||
/** Voicepeak 話者名 */
|
||||
VOICEPEAK_SPEAKER: 'voicepeakSpeaker',
|
||||
|
||||
/** カラーテーマ設定 ("light" | "system" | "dark") */
|
||||
COLOR_THEME: 'colorTheme',
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import dayjs from "dayjs";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes";
|
||||
import { getTrainType } from "@/lib/getTrainType";
|
||||
import { AS } from "@/storageControl";
|
||||
import {
|
||||
createVoicepeakAudioSource,
|
||||
type PreparedVoicepeakAudio,
|
||||
} from "@/lib/voicepeakAudioSource";
|
||||
|
||||
export const DEFAULT_VOICEPEAK_BASE_URL = "https://voicepeak-api.haruk.in";
|
||||
|
||||
export type VoicepeakSettings = {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
speaker: string;
|
||||
};
|
||||
|
||||
export type VoicepeakAnnouncementStage = "advance" | "departure";
|
||||
|
||||
type VoicepeakSpeechRequest = {
|
||||
text: string;
|
||||
settings: VoicepeakSettings;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const readStoredString = async (key: string, fallback = "") => {
|
||||
try {
|
||||
const value = await AS.getItem(key);
|
||||
return typeof value === "string" ? value : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeVoicepeakBaseUrl = (value?: string) =>
|
||||
(value?.trim() || DEFAULT_VOICEPEAK_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
export const loadVoicepeakSettings = async (): Promise<VoicepeakSettings> => {
|
||||
const [enabled, baseUrl, apiToken, speaker] = await Promise.all([
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_ENABLED, "false"),
|
||||
readStoredString(
|
||||
STORAGE_KEYS.VOICEPEAK_BASE_URL,
|
||||
DEFAULT_VOICEPEAK_BASE_URL
|
||||
),
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_API_TOKEN),
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_SPEAKER),
|
||||
]);
|
||||
|
||||
return {
|
||||
enabled: enabled === "true",
|
||||
baseUrl: normalizeVoicepeakBaseUrl(baseUrl),
|
||||
apiToken: apiToken.trim(),
|
||||
speaker: speaker.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const hasVoicepeakConfiguration = (settings: VoicepeakSettings) =>
|
||||
settings.enabled &&
|
||||
normalizeVoicepeakBaseUrl(settings.baseUrl).length > 0 &&
|
||||
settings.apiToken.length > 0;
|
||||
|
||||
export const buildVoicepeakAnnouncementKey = (
|
||||
station: StationProps,
|
||||
train: eachTrainDiagramType,
|
||||
stage: VoicepeakAnnouncementStage
|
||||
) => {
|
||||
const [hourText] = train.time.split(":");
|
||||
const hour = Number.parseInt(hourText, 10);
|
||||
const serviceDate = dayjs()
|
||||
.subtract(Number.isNaN(hour) ? 0 : hour < 4 ? 1 : 0, "day")
|
||||
.format("YYYY-MM-DD");
|
||||
|
||||
return [
|
||||
serviceDate,
|
||||
stage,
|
||||
station.StationNumber || station.Station_JP,
|
||||
train.train,
|
||||
train.time,
|
||||
train.lastStation,
|
||||
].join(":");
|
||||
};
|
||||
|
||||
const getDepartureTiming = (timeText: string) => {
|
||||
const [hourText, minuteText] = timeText.split(":");
|
||||
const hour = Number.parseInt(hourText, 10);
|
||||
const minute = Number.parseInt(minuteText, 10);
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
|
||||
|
||||
const now = dayjs();
|
||||
const departureTime = now
|
||||
.set("hour", hour < 4 ? hour + 24 : hour)
|
||||
.set("minute", minute)
|
||||
.set("second", 0)
|
||||
.set("millisecond", 0);
|
||||
|
||||
return {
|
||||
now,
|
||||
departureTime,
|
||||
diffSeconds: departureTime.diff(now, "second"),
|
||||
};
|
||||
};
|
||||
|
||||
const getTrainNumberSuffix = (
|
||||
trainId: string,
|
||||
trainNumDistance: string | null | undefined
|
||||
) => {
|
||||
if (
|
||||
trainNumDistance === undefined ||
|
||||
trainNumDistance === null ||
|
||||
trainNumDistance === "" ||
|
||||
Number.isNaN(Number.parseInt(trainNumDistance, 10))
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trainNumber =
|
||||
Number.parseInt(trainId.replace(/\D/g, ""), 10) -
|
||||
Number.parseInt(trainNumDistance, 10);
|
||||
|
||||
return Number.isNaN(trainNumber) ? "" : `${trainNumber}号`;
|
||||
};
|
||||
|
||||
const buildTrainDestination = (station: StationProps, train: eachTrainDiagramType) =>
|
||||
train.lastStation === "当駅止"
|
||||
? `${station.Station_JP}止まり`
|
||||
: `${train.lastStation}行き`;
|
||||
|
||||
const buildTrainLabel = (
|
||||
trainTypeName: string,
|
||||
trainName: string,
|
||||
trainNumberSuffix: string
|
||||
) => [trainTypeName, trainName, trainNumberSuffix].filter(Boolean).join(" ");
|
||||
|
||||
export const getVoicepeakAnnouncementStage = ({
|
||||
station,
|
||||
train,
|
||||
currentTrainData,
|
||||
}: {
|
||||
station: StationProps;
|
||||
train: eachTrainDiagramType;
|
||||
currentTrainData: CustomTrainData;
|
||||
}): VoicepeakAnnouncementStage | null => {
|
||||
if (train.isThrough) return null;
|
||||
if (train.lastStation === station.Station_JP) return null;
|
||||
|
||||
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
|
||||
if (trainType.data === "notService") return null;
|
||||
|
||||
const timing = getDepartureTiming(train.time);
|
||||
if (!timing) return null;
|
||||
|
||||
if (timing.diffSeconds <= 0 && timing.diffSeconds > -60) {
|
||||
return "departure";
|
||||
}
|
||||
|
||||
if (timing.diffSeconds > 0 && timing.diffSeconds < 120) {
|
||||
return "advance";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildVoicepeakAnnouncementText = ({
|
||||
station,
|
||||
train,
|
||||
currentTrainData,
|
||||
stage,
|
||||
delayMinutes,
|
||||
}: {
|
||||
station: StationProps;
|
||||
train: eachTrainDiagramType;
|
||||
currentTrainData: CustomTrainData;
|
||||
stage: VoicepeakAnnouncementStage;
|
||||
delayMinutes?: number;
|
||||
}) => {
|
||||
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
|
||||
const trainNumberSuffix = getTrainNumberSuffix(
|
||||
train.train,
|
||||
currentTrainData.train_num_distance
|
||||
);
|
||||
const destination = buildTrainDestination(station, train);
|
||||
const trainLabel = buildTrainLabel(
|
||||
trainType.name,
|
||||
currentTrainData.train_name,
|
||||
trainNumberSuffix
|
||||
);
|
||||
|
||||
if (stage === "advance") {
|
||||
const trainInfoText = currentTrainData.train_info?.trim();
|
||||
const [hourText, minuteText] = train.time.split(":");
|
||||
const parsedDelay =
|
||||
typeof delayMinutes === "number" && Number.isFinite(delayMinutes) && delayMinutes > 0
|
||||
? `${delayMinutes}分遅れで`
|
||||
: "定刻で";
|
||||
const prefix = [
|
||||
"次の列車は",
|
||||
`${Number.parseInt(hourText || "0", 10)}時`,
|
||||
`${Number.parseInt(minuteText || "0", 10)}分発`,
|
||||
trainLabel,
|
||||
`${destination}です。`,
|
||||
"この列車は",
|
||||
"現在",
|
||||
parsedDelay,
|
||||
"運転しております。",
|
||||
].join(" ");
|
||||
|
||||
if (trainInfoText) {
|
||||
return `${prefix} ${trainInfoText}`;
|
||||
}
|
||||
|
||||
return `${prefix} まもなく発車します。`;
|
||||
}
|
||||
|
||||
const platformText = train.platformNum?.trim()
|
||||
? `${train.platformNum.trim()}番線より`
|
||||
: "";
|
||||
const pieces = [
|
||||
"間もなく",
|
||||
platformText,
|
||||
trainLabel,
|
||||
destination,
|
||||
"が",
|
||||
"発車します。",
|
||||
"ご注意ください。",
|
||||
].filter(Boolean);
|
||||
|
||||
return pieces.join(" ");
|
||||
};
|
||||
|
||||
export const requestVoicepeakSpeech = async ({
|
||||
text,
|
||||
settings,
|
||||
signal,
|
||||
}: VoicepeakSpeechRequest): Promise<PreparedVoicepeakAudio> => {
|
||||
const payload: {
|
||||
text: string;
|
||||
format: "mp3";
|
||||
speaker?: string;
|
||||
} = {
|
||||
text,
|
||||
format: "mp3",
|
||||
};
|
||||
|
||||
if (settings.speaker) {
|
||||
payload.speaker = settings.speaker;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${normalizeVoicepeakBaseUrl(settings.baseUrl)}/v1/speech`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
errorText || `Voicepeak API request failed with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return createVoicepeakAudioSource(bytes, "mp3");
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AudioSource } from "expo-audio";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export type PreparedVoicepeakAudio =
|
||||
| {
|
||||
kind: "expo-audio";
|
||||
source: AudioSource;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
| {
|
||||
kind: "native-webview";
|
||||
html: string;
|
||||
};
|
||||
|
||||
const BASE64_CHARS =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
const encodeBase64 = (bytes: Uint8Array) => {
|
||||
let encoded = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const byte1 = bytes[index] ?? 0;
|
||||
const byte2 = bytes[index + 1] ?? 0;
|
||||
const byte3 = bytes[index + 2] ?? 0;
|
||||
const combined = (byte1 << 16) | (byte2 << 8) | byte3;
|
||||
|
||||
encoded += BASE64_CHARS[(combined >> 18) & 0x3f];
|
||||
encoded += BASE64_CHARS[(combined >> 12) & 0x3f];
|
||||
encoded +=
|
||||
index + 1 < bytes.length ? BASE64_CHARS[(combined >> 6) & 0x3f] : "=";
|
||||
encoded += index + 2 < bytes.length ? BASE64_CHARS[combined & 0x3f] : "=";
|
||||
}
|
||||
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const buildNativeVoicepeakHtml = (
|
||||
bytes: Uint8Array,
|
||||
extension: "mp3" | "wav"
|
||||
) => {
|
||||
const mimeType = extension === "wav" ? "audio/wav" : "audio/mpeg";
|
||||
const base64 = encodeBase64(bytes);
|
||||
const source = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<audio id="voicepeak" autoplay playsinline src="${source}"></audio>
|
||||
<script>
|
||||
(function () {
|
||||
var audio = document.getElementById("voicepeak");
|
||||
if (!audio) return;
|
||||
var start = function () {
|
||||
var result = audio.play();
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch(function () {});
|
||||
}
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
audio.addEventListener("canplay", start);
|
||||
start();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export const EMPTY_NATIVE_VOICEPEAK_HTML = `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
|
||||
export const createVoicepeakAudioSource = async (
|
||||
bytes: Uint8Array,
|
||||
extension: "mp3" | "wav" = "mp3"
|
||||
): Promise<PreparedVoicepeakAudio> => {
|
||||
if (Platform.OS === "web") {
|
||||
const normalizedBytes = new Uint8Array(bytes.byteLength);
|
||||
normalizedBytes.set(bytes);
|
||||
const blob = new Blob([normalizedBytes], {
|
||||
type: extension === "wav" ? "audio/wav" : "audio/mpeg",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
return {
|
||||
kind: "expo-audio",
|
||||
source: { uri: objectUrl },
|
||||
cleanup: () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "native-webview",
|
||||
html: buildNativeVoicepeakHtml(bytes, extension),
|
||||
};
|
||||
};
|
||||
+2
-2
@@ -60,6 +60,7 @@
|
||||
"expo-keep-awake": "~55.0.4",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity",
|
||||
"expo-localization": "~55.0.9",
|
||||
"expo-location": "~55.1.4",
|
||||
"expo-notifications": "~55.0.13",
|
||||
@@ -92,8 +93,7 @@
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity"
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
|
||||
Reference in New Issue
Block a user