merge dark mode and google OAuth (#35)
* feat(api): implement Google OAuth login and user management - Added Google OpenID Connect login functionality, allowing users to authenticate using their Google accounts. - Created new endpoints for Google login, including user registration and linking existing accounts. - Introduced Google token verification logic and error handling for authentication failures. - Updated environment configuration to include Google OAuth client IDs and verification settings. - Enhanced user model to support OpenID and linked Google accounts. This feature improves user experience by enabling seamless sign-in with Google, while maintaining security and integrity of user data. * fix(auth): wire staging Google token verifier * chore(deps): update expo to version 55.0.6 and adjust @expo/env dependency in pnpm-lock.yaml * chore(deps): update Babel dependencies to version 7.29.7 in package-lock.json * feat(auth): enhance phone login for China users - Updated phone login functionality to support only mainland China (+86) mobile numbers. - Added user prompts and descriptions for phone login, including confirmation and cancellation options. - Adjusted translations for both English and Chinese to reflect the new phone login requirements. - Updated Google OAuth client IDs in configuration files for production and staging environments. * chore(deps): add peer flag to use-sync-external-store in package-lock.json * chore(deps): add @emnapi/core and @emnapi/runtime to package-lock.json * fix(app-expo): align Android native dependencies * fix(app-expo): normalize lockfile for npm 10 * fix(config): update environment variable handling to use static access - Introduced a static mapping for public environment variables to ensure proper access during the release bundle. - Updated the `requirePublicEnv` and `optionalPublicEnv` functions to reference the new `PUBLIC_ENV` object instead of directly accessing `process.env`. - Added comments to clarify the necessity of static access for certain environment variables. * feat(app-expo): dark mode, FAQ i18n, eval ASR, and theme cleanup (#34) * feat(app-expo): dark mode, FAQ i18n, version CI, and theme cleanup Implement light/dark scene colors across chat, reading, and headers; remove default/brand theme picker and ThemeVariablesProvider. Localize FAQ in-app, fix dark-mode text visibility, and remove the unused /api/faqs endpoint. Align About/version with Expo config and inject APP_VERSION in CI builds. Also includes phone E164 auth/SMS updates, eval ASR page, and related API work. * revert: remove phone E.164 changes from dark-mode branch These auth/SMS internationalization updates were accidentally bundled into the dark-mode commit; restore 11-digit CN phone flow and drop related API, migration, and Expo UI work from this branch. * fix: address PR review issues for dark mode and eval ASR Use light foreground colors for sepia reading in dark mode, fix chat send button contrast, stream-limit eval ASR uploads, restore LiveTester phone validation, and remove unused AudioSegmenter code. * fix(app-expo): improve chat send button contrast in light and dark mode Add dedicated send button colors (accent fill in dark, primary fill in light), use RNText to avoid NativeWind overrides, and restore dark labels in light mode for readable composer actions. --------- Co-authored-by: Kevin <kevin@brighteng.org> --------- Co-authored-by: penghanyuan <penghanyuan@gmail.com> Co-authored-by: Kevin <kevin@brighteng.org>
This commit is contained in:
@@ -11,6 +11,7 @@ import MemoirPage from "./pages/MemoirPage";
|
||||
import MemoirStoriesPage from "./pages/MemoirStoriesPage";
|
||||
import PlaygroundPage from "./pages/PlaygroundPage";
|
||||
import LiveTesterPage from "./pages/LiveTesterPage";
|
||||
import AsrPage from "./pages/AsrPage";
|
||||
|
||||
function RouteOutlet({ route }: { route: AppRoute }) {
|
||||
switch (route) {
|
||||
@@ -22,6 +23,8 @@ function RouteOutlet({ route }: { route: AppRoute }) {
|
||||
return <MemoirStoriesPage />;
|
||||
case "live":
|
||||
return <LiveTesterPage />;
|
||||
case "asr":
|
||||
return <AsrPage />;
|
||||
default:
|
||||
return <PlaygroundPage />;
|
||||
}
|
||||
|
||||
@@ -55,3 +55,59 @@ export async function api<T>(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** multipart 上传(勿设 Content-Type,由浏览器写入 boundary)。 */
|
||||
export async function apiMultipart<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
init?: Omit<RequestInit, "body" | "method">,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
requestId?: string;
|
||||
status: number;
|
||||
}> {
|
||||
const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
...init,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Internal-Eval-Key": apiKey,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
body: formData,
|
||||
signal: init?.signal,
|
||||
});
|
||||
const text = await r.text();
|
||||
let data: T | undefined;
|
||||
try {
|
||||
data = text ? (JSON.parse(text) as T) : undefined;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!r.ok) {
|
||||
const parsed = parseApiError(data, text || r.statusText);
|
||||
return {
|
||||
ok: false,
|
||||
status: r.status,
|
||||
error: parsed.message,
|
||||
errorCode: parsed.errorCode,
|
||||
requestId: parsed.requestId,
|
||||
};
|
||||
}
|
||||
return { ok: true, data, status: r.status };
|
||||
} catch (e: unknown) {
|
||||
const name = e instanceof Error ? e.name : "";
|
||||
if (name === "AbortError") {
|
||||
return { ok: false, status: 0, error: "aborted" };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: e instanceof Error ? e.message : "network error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const NAV: { route: AppRoute; label: string; sub?: string }[] = [
|
||||
{ route: "memoir", label: "Memoir", sub: "章节对照" },
|
||||
{ route: "memoir-stories", label: "Stories", sub: "故事成稿" },
|
||||
{ route: "live", label: "实机联调", sub: "主站聊天/回忆录" },
|
||||
{ route: "asr", label: "ASR", sub: "语音转写" },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
|
||||
@@ -2367,3 +2367,79 @@ code {
|
||||
overflow: auto;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
/* ASR transcribe page */
|
||||
|
||||
.eval-asr-page .eval-asr-file {
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.eval-asr-record-field {
|
||||
min-width: min(100%, 280px);
|
||||
}
|
||||
|
||||
.eval-asr-record-controls {
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eval-asr-recording-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--danger-text);
|
||||
animation: eval-asr-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes eval-asr-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.eval-asr-recording-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.eval-asr-preview {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.eval-asr-audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.eval-asr-result {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.eval-asr-result__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.eval-asr-transcript {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.65;
|
||||
font-size: var(--text-body);
|
||||
padding: var(--s-3);
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
min-height: 6rem;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.eval-asr-transcript-empty {
|
||||
margin: 0;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AppRoute } from "../types";
|
||||
|
||||
const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories", "live"];
|
||||
const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories", "live", "asr"];
|
||||
|
||||
function parseHash(): AppRoute {
|
||||
const raw = window.location.hash.slice(1).split("?")[0] || "playground";
|
||||
|
||||
341
app-eval-web/src/pages/AsrPage.tsx
Normal file
341
app-eval-web/src/pages/AsrPage.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiMultipart } from "../api";
|
||||
import { CopyTextButton } from "../components/CopyTextButton";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { usePushNotice } from "../context/NoticeContext";
|
||||
import {
|
||||
convertBlobToWav16kMono,
|
||||
formatBytes,
|
||||
formatDurationMs,
|
||||
pickRecorderMimeType,
|
||||
} from "../utils/audioToWav16k";
|
||||
|
||||
type AsrTranscribeResponse = {
|
||||
text: string;
|
||||
format: string;
|
||||
audio_bytes: number;
|
||||
};
|
||||
|
||||
type RecordPhase = "idle" | "recording" | "recorded";
|
||||
|
||||
export default function AsrPage() {
|
||||
const pushNotice = usePushNotice();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const recordChunksRef = useRef<Blob[]>([]);
|
||||
const recordStartedAtRef = useRef<number>(0);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioLabel, setAudioLabel] = useState("");
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [voiceFormat, setVoiceFormat] = useState<string | null>(null);
|
||||
const [recordPhase, setRecordPhase] = useState<RecordPhase>("idle");
|
||||
const [recordElapsedMs, setRecordElapsedMs] = useState(0);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastMeta, setLastMeta] = useState<string | null>(null);
|
||||
|
||||
const revokeAudioUrl = useCallback(() => {
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
audioUrlRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setPreviewBlob = useCallback(
|
||||
(blob: Blob, label: string, format: string | null) => {
|
||||
revokeAudioUrl();
|
||||
const url = URL.createObjectURL(blob);
|
||||
audioUrlRef.current = url;
|
||||
setAudioBlob(blob);
|
||||
setAudioLabel(label);
|
||||
setAudioUrl(url);
|
||||
setVoiceFormat(format);
|
||||
setTranscript("");
|
||||
setLastMeta(null);
|
||||
},
|
||||
[revokeAudioUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revokeAudioUrl();
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, [revokeAudioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recordPhase !== "recording") return;
|
||||
const id = window.setInterval(() => {
|
||||
setRecordElapsedMs(Date.now() - recordStartedAtRef.current);
|
||||
}, 200);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recordPhase]);
|
||||
|
||||
const stopStream = () => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
const onFileChange = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const ext = file.name.includes(".")
|
||||
? file.name.split(".").pop()?.toLowerCase()
|
||||
: "";
|
||||
const formatHint =
|
||||
ext === "wav"
|
||||
? "wav"
|
||||
: ext === "mp3"
|
||||
? "mp3"
|
||||
: ext === "m4a" || ext === "mp4"
|
||||
? "m4a"
|
||||
: ext === "aac"
|
||||
? "aac"
|
||||
: ext === "webm" || ext === "ogg"
|
||||
? "ogg-opus"
|
||||
: null;
|
||||
setPreviewBlob(file, file.name, formatHint);
|
||||
pushNotice(`已选择 ${file.name}(${formatBytes(file.size)})`, "success");
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
pushNotice("当前浏览器不支持麦克风录音", "error");
|
||||
return;
|
||||
}
|
||||
const mimeType = pickRecorderMimeType();
|
||||
if (!mimeType) {
|
||||
pushNotice("当前浏览器不支持 MediaRecorder", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
recordChunksRef.current = [];
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) recordChunksRef.current.push(ev.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
stopStream();
|
||||
void (async () => {
|
||||
const raw = new Blob(recordChunksRef.current, { type: mimeType });
|
||||
if (!raw.size) {
|
||||
pushNotice("录音为空,请重试", "error");
|
||||
setRecordPhase("idle");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const wav = await convertBlobToWav16kMono(raw);
|
||||
const durationMs = Date.now() - recordStartedAtRef.current;
|
||||
setPreviewBlob(
|
||||
wav,
|
||||
`recording-${new Date().toISOString().slice(11, 19)}.wav`,
|
||||
"wav",
|
||||
);
|
||||
setRecordPhase("recorded");
|
||||
pushNotice(
|
||||
`录音完成 ${formatDurationMs(durationMs)},已转为 16kHz WAV`,
|
||||
"success",
|
||||
);
|
||||
} catch (e) {
|
||||
pushNotice(
|
||||
e instanceof Error ? e.message : "录音转码失败",
|
||||
"error",
|
||||
);
|
||||
setRecordPhase("idle");
|
||||
}
|
||||
})();
|
||||
};
|
||||
recordStartedAtRef.current = Date.now();
|
||||
setRecordElapsedMs(0);
|
||||
setRecordPhase("recording");
|
||||
recorder.start(250);
|
||||
} catch (e) {
|
||||
stopStream();
|
||||
pushNotice(
|
||||
e instanceof Error ? e.message : "无法访问麦克风",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (recorder && recorder.state !== "inactive") {
|
||||
recorder.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
};
|
||||
|
||||
const transcribe = async () => {
|
||||
if (!audioBlob) {
|
||||
pushNotice("请先上传或录制音频", "error");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setTranscript("");
|
||||
setLastMeta(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
const filename = audioLabel || "audio.wav";
|
||||
form.append("file", audioBlob, filename);
|
||||
const path =
|
||||
voiceFormat != null
|
||||
? `/internal/api/evaluation/asr/transcribe?format=${encodeURIComponent(voiceFormat)}`
|
||||
: "/internal/api/evaluation/asr/transcribe";
|
||||
const r = await apiMultipart<AsrTranscribeResponse>(path, form);
|
||||
if (!r.ok) {
|
||||
pushNotice(r.error ?? "转写失败", "error");
|
||||
return;
|
||||
}
|
||||
const data = r.data!;
|
||||
setTranscript(data.text);
|
||||
setLastMeta(
|
||||
`format=${data.format} · ${formatBytes(data.audio_bytes)} · ${data.text.length} 字`,
|
||||
);
|
||||
pushNotice("转写完成", "success");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAudio = () => {
|
||||
if (recordPhase === "recording") stopRecording();
|
||||
revokeAudioUrl();
|
||||
setAudioBlob(null);
|
||||
setAudioLabel("");
|
||||
setAudioUrl(null);
|
||||
setVoiceFormat(null);
|
||||
setRecordPhase("idle");
|
||||
setRecordElapsedMs(0);
|
||||
setTranscript("");
|
||||
setLastMeta(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="eval-main eval-main--memoir eval-asr-page">
|
||||
<h1 className="eval-page-title">ASR · 语音转写</h1>
|
||||
<p className="eval-page-intro eval-page-intro--memoir">
|
||||
上传音频或使用麦克风录音,调用与主站相同的腾讯云 ASR(
|
||||
<code className="eval-mono-inline">16k_zh_large</code>
|
||||
)并展示转写文本。录音会自动转为 16kHz 单声道 WAV;上传支持 m4a / mp3 / wav 等常见格式。
|
||||
</p>
|
||||
|
||||
<div className="eval-memoir-toolbar">
|
||||
<div className="eval-memoir-toolbar__primary">
|
||||
<div className="eval-memoir-field">
|
||||
<span className="eval-label eval-label--muted">上传音频</span>
|
||||
<div className="eval-memoir-field__control">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="eval-input eval-asr-file"
|
||||
accept="audio/*,.m4a,.mp3,.wav,.aac,.webm,.ogg"
|
||||
onChange={(e) => void onFileChange(e.target.files?.[0] ?? null)}
|
||||
aria-label="选择音频文件"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eval-memoir-field eval-asr-record-field">
|
||||
<span className="eval-label eval-label--muted">麦克风录音</span>
|
||||
<div className="eval-memoir-field__control eval-asr-record-controls">
|
||||
{recordPhase === "recording" ? (
|
||||
<>
|
||||
<span className="eval-asr-recording-dot" aria-hidden />
|
||||
<span className="eval-muted eval-asr-recording-time">
|
||||
录音中 {formatDurationMs(recordElapsedMs)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="eval-btn eval-btn--danger"
|
||||
onClick={stopRecording}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="eval-btn eval-btn--primary"
|
||||
disabled={busy}
|
||||
onClick={() => void startRecording()}
|
||||
>
|
||||
开始录音
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eval-memoir-toolbar__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="eval-btn eval-btn--primary"
|
||||
disabled={busy || !audioBlob}
|
||||
onClick={() => void transcribe()}
|
||||
>
|
||||
{busy ? "转写中…" : "开始转写"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="eval-btn eval-btn--ghost"
|
||||
disabled={busy && !audioBlob}
|
||||
onClick={clearAudio}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audioUrl ? (
|
||||
<section className="eval-memoir-panel eval-asr-preview">
|
||||
<div className="eval-memoir-panel__header">
|
||||
<h2 className="eval-memoir-panel__title">音频预览</h2>
|
||||
<p className="eval-memoir-panel__sub eval-muted">
|
||||
{audioLabel}
|
||||
{voiceFormat ? ` · ${voiceFormat}` : ""}
|
||||
{audioBlob ? ` · ${formatBytes(audioBlob.size)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<audio className="eval-asr-audio" controls src={audioUrl}>
|
||||
您的浏览器不支持 audio 元素。
|
||||
</audio>
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="尚未选择音频"
|
||||
description="上传文件或点击「开始录音」,然后点「开始转写」。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="eval-memoir-panel eval-memoir-panel--full eval-asr-result">
|
||||
<div className="eval-memoir-panel__header eval-asr-result__header">
|
||||
<div>
|
||||
<h2 className="eval-memoir-panel__title">转写结果</h2>
|
||||
{lastMeta ? (
|
||||
<p className="eval-memoir-panel__sub eval-muted">{lastMeta}</p>
|
||||
) : (
|
||||
<p className="eval-memoir-panel__sub eval-muted">
|
||||
转写完成后显示在此,可复制到剪贴板。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{transcript ? (
|
||||
<CopyTextButton text={transcript} label="复制全文" />
|
||||
) : null}
|
||||
</div>
|
||||
{transcript ? (
|
||||
<div className="eval-asr-transcript">{transcript}</div>
|
||||
) : (
|
||||
<p className="eval-muted eval-asr-transcript-empty">
|
||||
{busy ? "正在调用 ASR…" : "暂无转写文本"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -27,4 +27,4 @@ export type FixtureDetailResponse = {
|
||||
memoir_sections?: { title: string; body: string }[];
|
||||
};
|
||||
|
||||
export type AppRoute = "playground" | "memoir" | "memoir-stories" | "live";
|
||||
export type AppRoute = "playground" | "memoir" | "memoir-stories" | "live" | "asr";
|
||||
|
||||
119
app-eval-web/src/utils/audioToWav16k.ts
Normal file
119
app-eval-web/src/utils/audioToWav16k.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/** 将浏览器录制的音频转为 16kHz 单声道 WAV,匹配腾讯云 16k_zh_large 引擎。 */
|
||||
export async function convertBlobToWav16kMono(blob: Blob): Promise<Blob> {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const decodeCtx = new AudioContext();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await decodeCtx.decodeAudioData(arrayBuffer.slice(0));
|
||||
} finally {
|
||||
await decodeCtx.close();
|
||||
}
|
||||
|
||||
const mono = mixToMono(decoded);
|
||||
const monoBuffer = new AudioBuffer({
|
||||
length: mono.length,
|
||||
numberOfChannels: 1,
|
||||
sampleRate: decoded.sampleRate,
|
||||
});
|
||||
monoBuffer.copyToChannel(mono, 0);
|
||||
|
||||
const targetRate = 16000;
|
||||
const frames = Math.max(1, Math.ceil(monoBuffer.duration * targetRate));
|
||||
const offline = new OfflineAudioContext(1, frames, targetRate);
|
||||
const source = offline.createBufferSource();
|
||||
source.buffer = monoBuffer;
|
||||
source.connect(offline.destination);
|
||||
source.start();
|
||||
const rendered = await offline.startRendering();
|
||||
return encodeWav(rendered);
|
||||
}
|
||||
|
||||
function mixToMono(buffer: AudioBuffer): Float32Array {
|
||||
const len = buffer.length;
|
||||
const channels = buffer.numberOfChannels;
|
||||
const out = new Float32Array(len);
|
||||
if (channels === 1) {
|
||||
out.set(buffer.getChannelData(0));
|
||||
return out;
|
||||
}
|
||||
for (let i = 0; i < len; i++) {
|
||||
let sum = 0;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
sum += buffer.getChannelData(c)[i] ?? 0;
|
||||
}
|
||||
out[i] = sum / channels;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeWav(buffer: AudioBuffer): Blob {
|
||||
const channel = buffer.getChannelData(0);
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const bytesPerSample = 2;
|
||||
const blockAlign = bytesPerSample;
|
||||
const dataSize = channel.length * bytesPerSample;
|
||||
const headerSize = 44;
|
||||
const arrayBuffer = new ArrayBuffer(headerSize + dataSize);
|
||||
const view = new DataView(arrayBuffer);
|
||||
|
||||
writeString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(view, 8, "WAVE");
|
||||
writeString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bytesPerSample * 8, true);
|
||||
writeString(view, 36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let offset = headerSize;
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, channel[i] ?? 0));
|
||||
view.setInt16(
|
||||
offset,
|
||||
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
|
||||
true,
|
||||
);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString(view: DataView, offset: number, text: string) {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
view.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
export function pickRecorderMimeType(): string | undefined {
|
||||
const candidates = [
|
||||
"audio/mp4",
|
||||
"audio/webm;codecs=opus",
|
||||
"audio/webm",
|
||||
"audio/ogg;codecs=opus",
|
||||
];
|
||||
for (const type of candidates) {
|
||||
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms: number): string {
|
||||
const totalSec = Math.max(0, Math.round(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user