From cf32ea605dcdd718b8eaf419646ef8af41e224f6 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 10 Aug 2026 14:46:29 +0800 Subject: [PATCH] feat: mock continuous voice test editor --- .../src/components/pages/TestCasesPage.tsx | 11 +- .../continuous-voice-editor-mock.tsx | 742 ++++++++++++++++++ .../test-cases/input-mode-picker.tsx | 4 +- .../test-cases/next-reply-editor.tsx | 2 +- frontend/src/data/test-suites.ts | 3 + 5 files changed, 758 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/test-cases/continuous-voice-editor-mock.tsx diff --git a/frontend/src/components/pages/TestCasesPage.tsx b/frontend/src/components/pages/TestCasesPage.tsx index d2579e2..4f8ef69 100644 --- a/frontend/src/components/pages/TestCasesPage.tsx +++ b/frontend/src/components/pages/TestCasesPage.tsx @@ -32,6 +32,7 @@ import { } from "@/components/layout/list-page-layout"; import { TopbarPortal } from "@/components/layout/topbar-portal"; import { InputModePicker } from "@/components/test-cases/input-mode-picker"; +import { ContinuousVoiceEditorMock } from "@/components/test-cases/continuous-voice-editor-mock"; import { NextReplyEditorBody, type CaseEditorDraft, @@ -534,6 +535,8 @@ function SuiteDetailView({ const validationMessage = draft ? getTestCaseValidationMessage(draft) : "请先选择测试用例"; + const isContinuousVoicePreview = + draft?.inputMode === "fixed_script_continuous_voice"; const canSave = Boolean(draft?.name.trim()) && dirty && @@ -869,7 +872,11 @@ function SuiteDetailView({ />
- {dirty && validationMessage ? ( + {isContinuousVoicePreview ? ( + + 前端预览 · 暂不可保存 + + ) : dirty && validationMessage ? ( + ) : draft.inputMode === "fixed_script_continuous_voice" ? ( + ) : (
diff --git a/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx b/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx new file mode 100644 index 0000000..63ded55 --- /dev/null +++ b/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx @@ -0,0 +1,742 @@ +"use client"; + +/** Frontend-only interaction mock for fixed, real-time audio replay test cases. */ + +import { + AudioLines, + ChevronDown, + Clock3, + FileAudio, + Pause, + Play, + Plus, + Settings2, + Target, + Trash2, + Upload, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs"; +import { SectionCard } from "@/components/editor/section-card"; +import { + OverallCriteriaFields, + type CaseEditorDraft, +} from "@/components/test-cases/next-reply-editor"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +type VoiceSectionId = "sample" | "segments" | "overall"; + +type VoiceSegmentDraft = { + id: string; + startMs: number; + endMs: number; + transcript: string; + asrExpected: string; + responseExpected: string; + toolExpected: string; + maxLatencyMs: number | null; +}; + +const VOICE_SECTIONS = [ + { id: "sample", label: "语音样本" }, + { id: "segments", label: "参考片段" }, + { id: "overall", label: "整体评估" }, +] as const; + +const DEMO_DURATION_MS = 28_400; +const DEMO_WAVEFORM = Array.from({ length: 144 }, (_, index) => { + const speechWindow = + (index >= 6 && index <= 27) || + (index >= 47 && index <= 73) || + (index >= 92 && index <= 119); + if (!speechWindow) return 0.08 + ((index * 13) % 5) * 0.012; + return Math.min( + 1, + 0.22 + Math.abs(Math.sin(index * 0.71)) * 0.62 + ((index * 7) % 9) * 0.015, + ); +}); + +const DEMO_SEGMENTS: VoiceSegmentDraft[] = [ + { + id: "voice-segment-1", + startMs: 1_200, + endMs: 4_800, + transcript: "你好,我想咨询一下事故处理。", + asrExpected: "应识别出“事故处理”", + responseExpected: "助手应主动询问是否有人受伤,并引导用户说明事故情况。", + toolExpected: "", + maxLatencyMs: 2_000, + }, + { + id: "voice-segment-2", + startMs: 9_600, + endMs: 14_100, + transcript: "对方车辆撞了我,但是没有人受伤。", + asrExpected: "应识别出“没有人受伤”", + responseExpected: "助手应进入无人伤事故处理流程,不应建议拨打急救电话。", + toolExpected: "", + maxLatencyMs: 2_000, + }, + { + id: "voice-segment-3", + startMs: 18_400, + endMs: 22_700, + transcript: "我应该先拍照还是先挪车?", + asrExpected: "应识别拍照与挪车的先后问题", + responseExpected: "助手应说明先确保安全、记录现场,再按条件移车。", + toolExpected: "", + maxLatencyMs: 2_500, + }, +]; + +function formatTime(ms: number): string { + const seconds = Math.max(0, ms) / 1000; + const minutes = Math.floor(seconds / 60); + const remainder = seconds - minutes * 60; + return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +async function decodeWaveform(file: File, buckets = 144): Promise { + const AudioContextClass = window.AudioContext; + const context = new AudioContextClass(); + try { + const buffer = await context.decodeAudioData(await file.arrayBuffer()); + const channel = buffer.getChannelData(0); + const bucketSize = Math.max(1, Math.floor(channel.length / buckets)); + return Array.from({ length: buckets }, (_, bucketIndex) => { + const start = bucketIndex * bucketSize; + const end = Math.min(channel.length, start + bucketSize); + let peak = 0; + for (let index = start; index < end; index += 1) { + peak = Math.max(peak, Math.abs(channel[index])); + } + return Math.max(0.04, Math.min(1, peak)); + }); + } finally { + await context.close(); + } +} + +export function ContinuousVoiceEditorMock({ + draft, +}: { + draft: CaseEditorDraft; +}) { + const scrollContainerRef = useRef(null); + const fileInputRef = useRef(null); + const audioRef = useRef(null); + const objectUrlRef = useRef(null); + const sectionRefs = useRef>({ + sample: null, + segments: null, + overall: null, + }); + + const [activeSection, setActiveSection] = + useState("sample"); + const [sampleName, setSampleName] = useState("事故咨询连续语音.wav"); + const [sampleSize, setSampleSize] = useState(1_840_000); + const [sampleIsDemo, setSampleIsDemo] = useState(true); + const [audioUrl, setAudioUrl] = useState(null); + const [durationMs, setDurationMs] = useState(DEMO_DURATION_MS); + const [currentMs, setCurrentMs] = useState(0); + const [playing, setPlaying] = useState(false); + const [waveform, setWaveform] = useState(DEMO_WAVEFORM); + const [fileError, setFileError] = useState(""); + const [segments, setSegments] = useState(DEMO_SEGMENTS); + const [overallCriteria, setOverallCriteria] = useState( + draft.overallCriteria, + ); + const [expandedSegmentId, setExpandedSegmentId] = useState( + DEMO_SEGMENTS[0].id, + ); + const [tailWaitSeconds, setTailWaitSeconds] = useState("8"); + const [timeoutSeconds, setTimeoutSeconds] = useState("90"); + + useEffect( + () => () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + }, + [], + ); + + const timelineTicks = useMemo(() => { + const tickCount = 4; + return Array.from({ length: tickCount + 1 }, (_, index) => ({ + left: `${(index / tickCount) * 100}%`, + label: formatTime((durationMs * index) / tickCount), + })); + }, [durationMs]); + + function scrollToSection(sectionId: VoiceSectionId) { + const container = scrollContainerRef.current; + const section = sectionRefs.current[sectionId]; + if (!container || !section) return; + setActiveSection(sectionId); + container.scrollTo({ + top: + container.scrollTop + + section.getBoundingClientRect().top - + container.getBoundingClientRect().top, + behavior: "smooth", + }); + } + + async function selectAudio(file: File | undefined) { + if (!file) return; + setFileError(""); + setPlaying(false); + setCurrentMs(0); + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + const url = URL.createObjectURL(file); + objectUrlRef.current = url; + setAudioUrl(url); + setSampleName(file.name); + setSampleSize(file.size); + setSampleIsDemo(false); + try { + setWaveform(await decodeWaveform(file)); + } catch { + setWaveform(DEMO_WAVEFORM); + setFileError("浏览器暂时无法解析此格式,已保留示意波形。后端接入后会统一转码。"); + } + } + + async function togglePlayback() { + const audio = audioRef.current; + if (!audio || !audioUrl) return; + if (playing) { + audio.pause(); + return; + } + await audio.play(); + } + + function updateSegment(id: string, patch: Partial) { + setSegments((current) => + current.map((segment) => + segment.id === id ? { ...segment, ...patch } : segment, + ), + ); + } + + function addSegment() { + const previous = segments[segments.length - 1]; + const startMs = Math.min(durationMs, (previous?.endMs ?? 0) + 1_000); + const next: VoiceSegmentDraft = { + id: `voice-segment-${Date.now()}`, + startMs, + endMs: Math.min(durationMs, startMs + 3_000), + transcript: "", + asrExpected: "", + responseExpected: "", + toolExpected: "", + maxLatencyMs: 2_000, + }; + setSegments((current) => [...current, next]); + setExpandedSegmentId(next.id); + } + + function removeSegment(id: string) { + setSegments((current) => current.filter((segment) => segment.id !== id)); + setExpandedSegmentId((current) => (current === id ? "" : current)); + } + + return ( +
+ scrollToSection(sectionId as VoiceSectionId)} + className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8" + contentClassName="mx-auto w-full max-w-3xl" + /> + +
+
+
+ + 前端 Mock + + 用于确认编辑流程;音频和片段修改不会保存,也还不能进入批量运行。 +
+ +
{ + sectionRefs.current.sample = element; + }} + className="scroll-mt-3" + > + } + title="连续语音样本" + description="按原始时间轴 1× 回放;助手播报期间仍继续输入" + action={ + + } + > + void selectAudio(event.target.files?.[0])} + /> + +
+ +
{ + sectionRefs.current.segments = element; + }} + className="scroll-mt-3" + > + } + title="参考片段与观察点" + description="片段用于对齐参考转写和预期,不会改变原始音频时间轴" + action={ + + } + > +
+ {segments.map((segment, index) => { + const expanded = expandedSegmentId === segment.id; + return ( +
+
+ + +
+ + {expanded && ( +
+
+ + updateSegment(segment.id, { startMs }) + } + /> + updateSegment(segment.id, { endMs })} + /> + +
+ +
+