Files
jrshikoku/lib/voicepeakAudioSource.ts

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