215 lines
6.2 KiB
TypeScript
215 lines
6.2 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
|
import { setAudioModeAsync, useAudioPlayer } from "expo-audio";
|
|
import { WebView } from "react-native-webview";
|
|
import { useThemeColors } from "@/lib/theme";
|
|
import {
|
|
requestVoicepeakSpeechBytes,
|
|
VoicepeakRequestError,
|
|
} from "@/lib/voicepeak";
|
|
import {
|
|
createVoicepeakAudioSource,
|
|
type PreparedVoicepeakAudio,
|
|
} from "@/lib/voicepeakAudioSource";
|
|
import type { VoicepeakDebugLogEntry } from "@/lib/voicepeakDebugLog";
|
|
|
|
type DebugAudioAction = "fetch" | "force";
|
|
|
|
export const VoicepeakDebugAudioActions = ({
|
|
log,
|
|
onComplete,
|
|
}: {
|
|
log: VoicepeakDebugLogEntry;
|
|
onComplete?: () => void | Promise<void>;
|
|
}) => {
|
|
const { colors, fixed } = useThemeColors();
|
|
const player = useAudioPlayer(null);
|
|
const [activeAction, setActiveAction] = useState<DebugAudioAction | null>(
|
|
null,
|
|
);
|
|
const [nativeAudio, setNativeAudio] = useState<{
|
|
html: string;
|
|
key: number;
|
|
} | null>(null);
|
|
const requestControllerRef = useRef<AbortController | null>(null);
|
|
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
|
|
|
|
const stopCurrentAudio = useCallback(() => {
|
|
try {
|
|
player.pause();
|
|
} catch {
|
|
// Player may not have a source yet.
|
|
}
|
|
setNativeAudio(null);
|
|
cleanupAudioRef.current?.();
|
|
cleanupAudioRef.current = undefined;
|
|
}, [player]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
requestControllerRef.current?.abort();
|
|
stopCurrentAudio();
|
|
},
|
|
[log.id, stopCurrentAudio],
|
|
);
|
|
|
|
const playAudio = useCallback(
|
|
async (audio: PreparedVoicepeakAudio) => {
|
|
stopCurrentAudio();
|
|
cleanupAudioRef.current =
|
|
audio.kind === "expo-audio" ? audio.cleanup : undefined;
|
|
|
|
await setAudioModeAsync({
|
|
playsInSilentMode: true,
|
|
shouldPlayInBackground: false,
|
|
interruptionMode: "duckOthers",
|
|
});
|
|
|
|
if (audio.kind === "native-webview") {
|
|
setNativeAudio({ html: audio.html, key: Date.now() });
|
|
return;
|
|
}
|
|
|
|
player.replace(audio.source);
|
|
player.volume = 1;
|
|
await player.seekTo(0);
|
|
player.play();
|
|
},
|
|
[player, stopCurrentAudio],
|
|
);
|
|
|
|
const runAction = useCallback(
|
|
async (force: boolean) => {
|
|
if (activeAction) return;
|
|
|
|
const action: DebugAudioAction = force ? "force" : "fetch";
|
|
const controller = new AbortController();
|
|
requestControllerRef.current = controller;
|
|
setActiveAction(action);
|
|
|
|
try {
|
|
const bytes = await requestVoicepeakSpeechBytes({
|
|
text: log.text,
|
|
settings: { enabled: true },
|
|
signal: controller.signal,
|
|
format: log.format,
|
|
force,
|
|
});
|
|
if (controller.signal.aborted) return;
|
|
|
|
const audio = await createVoicepeakAudioSource(bytes, log.format);
|
|
if (controller.signal.aborted) {
|
|
if (audio.kind === "expo-audio") audio.cleanup?.();
|
|
return;
|
|
}
|
|
|
|
await playAudio(audio);
|
|
await onComplete?.();
|
|
} catch (error) {
|
|
const aborted =
|
|
controller.signal.aborted ||
|
|
(error instanceof VoicepeakRequestError && error.code === "ABORTED");
|
|
if (!aborted) {
|
|
const message =
|
|
error instanceof VoicepeakRequestError
|
|
? `${error.message}\n\nHTTP: ${error.status || "-"}\nCode: ${
|
|
error.code
|
|
}\nRequest ID: ${error.requestId || "-"}`
|
|
: error instanceof Error
|
|
? error.message
|
|
: String(error);
|
|
Alert.alert(
|
|
force ? "音声の再作成に失敗しました" : "音声の取得に失敗しました",
|
|
message,
|
|
);
|
|
}
|
|
} finally {
|
|
if (requestControllerRef.current === controller) {
|
|
requestControllerRef.current = null;
|
|
}
|
|
setActiveAction(null);
|
|
}
|
|
},
|
|
[activeAction, log.format, log.text, onComplete, playAudio],
|
|
);
|
|
|
|
const disabled = activeAction !== null;
|
|
const borderColor = colors.borderSecondary ?? "#ccc";
|
|
|
|
return (
|
|
<View style={{ marginTop: 14 }}>
|
|
<Text
|
|
style={{
|
|
marginBottom: 8,
|
|
color: colors.textSecondary ?? colors.text,
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
取得・再生は現在のキャッシュを利用します。再作成はキャッシュを使わず新しい音声を生成します。
|
|
</Text>
|
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
disabled={disabled}
|
|
onPress={() => void runAction(false)}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 11,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: fixed.primary,
|
|
opacity: disabled ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: fixed.primary,
|
|
textAlign: "center",
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{activeAction === "fetch" ? "取得中…" : "取得・再生"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
disabled={disabled}
|
|
onPress={() => void runAction(true)}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 11,
|
|
borderRadius: 8,
|
|
backgroundColor: disabled ? borderColor : fixed.primary,
|
|
opacity: disabled ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{ color: "#fff", textAlign: "center", fontWeight: "600" }}
|
|
>
|
|
{activeAction === "force" ? "再作成中…" : "再作成して再生"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{nativeAudio && (
|
|
<WebView
|
|
key={nativeAudio.key}
|
|
source={{ html: nativeAudio.html }}
|
|
originWhitelist={["*"]}
|
|
javaScriptEnabled
|
|
scrollEnabled={false}
|
|
mediaPlaybackRequiresUserAction={false}
|
|
allowsInlineMediaPlayback
|
|
onMessage={() => setNativeAudio(null)}
|
|
style={{
|
|
position: "absolute",
|
|
width: 1,
|
|
height: 1,
|
|
opacity: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|