151 lines
4.0 KiB
TypeScript
151 lines
4.0 KiB
TypeScript
import type { AudioSource } from "expo-audio";
|
|
import { File, Paths } from "expo-file-system";
|
|
import { Platform } from "react-native";
|
|
|
|
export type PreparedVoicepeakAudio =
|
|
| {
|
|
kind: "expo-audio";
|
|
source: AudioSource;
|
|
cleanup?: () => void;
|
|
}
|
|
| {
|
|
kind: "native-webview";
|
|
html: string;
|
|
};
|
|
|
|
const BASE64_CHARS =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
export 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);
|
|
audio.addEventListener("ended", function () {
|
|
if (window.ReactNativeWebView) {
|
|
window.ReactNativeWebView.postMessage("voicepeak-ended");
|
|
}
|
|
});
|
|
audio.addEventListener("error", function () {
|
|
if (window.ReactNativeWebView) {
|
|
window.ReactNativeWebView.postMessage("voicepeak-error");
|
|
}
|
|
});
|
|
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);
|
|
},
|
|
};
|
|
}
|
|
|
|
if (Platform.OS === "ios") {
|
|
// WKWebViewの<audio>はAVAudioSessionを独自に切り替え、再生中の音楽を
|
|
// 停止させることがある。iOSでは一時ファイルをexpo-audioで再生し、
|
|
// setAudioModeAsyncのduckOthers設定を確実に適用する。
|
|
const file = new File(
|
|
Paths.cache,
|
|
`rikka-voicepeak-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2)}.${extension}`
|
|
);
|
|
file.create({ overwrite: true });
|
|
file.write(bytes);
|
|
|
|
return {
|
|
kind: "expo-audio",
|
|
source: { uri: file.uri },
|
|
cleanup: () => {
|
|
if (file.exists) {
|
|
file.delete();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "native-webview",
|
|
html: buildNativeVoicepeakHtml(bytes, extension),
|
|
};
|
|
};
|