Files
jrshikoku/components/Settings/VoicepeakDebugAudioActions.tsx

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>
);
};