Compare commits

...

2 Commits

6 changed files with 738 additions and 13 deletions
+137 -6
View File
@@ -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>
);
+200 -5
View File
@@ -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
+12
View File
@@ -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',
+272
View File
@@ -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");
};
+115
View File
@@ -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
View File
@@ -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",