VoicePaint 是一款纯语音驱动的 AI 绘画 Web 应用。 用户不通过键盘、不通过鼠标,从登录到出图、从修改到删除, 全程用语音完成。
| 维度 | 截图原文要求 | 本文对应章节 |
|---|---|---|
| ① | 用户无法使用鼠标或键盘,纯语音模式 | §1 |
| ② | 重点描述软件对模糊指令的识别与容错机制 | §2 |
| ③ | 语音到绘图的响应延迟量化指标 | §3 |
| ④ | 重点描述复杂指令的拆解与执行 | §4 |
设计目标:从登录到出图,零键盘、零鼠标。 落地手段分三档:
| 档位 | 用户操作 | 系统反馈 | 适用场景 |
|---|---|---|---|
| 手动档 | 按住 / 点击麦克风说话 | 松开即送识别 | 桌面 + 移动 |
| 免提档 | 不动手,等系统自动检测到有效语音 | 1.8s 静音自动识别 | 双手占用时 |
| 全免提档 | 打开「全语音模式」开关,AI 持续监听 | 完整对话循环 | 评委演示终态 |
麦克风按钮 VoiceRecorder.vue 在移动端监听
touchstart / touchend(按住说话),在桌面端监听
mousedown / mouseup / mouseleave(点击 + 释放 + 离开兜底),并通过
click 事件做 toggle 容错:
@mousedown="!isMobile && handleButtonDown($event)"
@mouseleave="!isMobile && handleButtonUp($event)"
@touchstart.prevent="isMobile && handleButtonDown($event)"
@touchend.prevent="isMobile && handleButtonUp($event)"
@click="handleButtonClick"开关打开后全屏不再需要任何手动操作——AI 持续监听、有效语音后自动送识别、 处理完后自动重新开录。右上角指示器实时显示运行状态:
<div v-if="fullVoiceMode"
class="absolute -top-1 px-3 py-1 rounded-full
bg-violet-500/10 border border-violet-500/20 ...">
<div class="w-1.5 h-1.5 rounded-full bg-violet-500 animate-pulse"></div>
<span class="text-[9px] font-black text-violet-500 uppercase tracking-widest">
全语音模式活跃中
</span>
</div>全语音模式的自动循环逻辑(HomeView.vue)——处理完成后 3s 内自动重新开录,用户全程零触碰:
drawStore.setStatus('done')
setTimeout(() => {
drawStore.setStatus('idle')
if (fullVoiceMode.value) handleStartRecording()
}, 3000) // 3s 后自动循环前端实现了一套 VAD(Voice Activity Detection)状态机,关键参数:
const SILENCE_THRESHOLD = 0.12 // 音量阈值
const SILENCE_DURATION = 1800 // 沉默 1.8 秒触发回调
const MIN_SPEAKING_DURATION = 200 // 确认开始说话需 0.2s
const MAX_DURATION = 30_000 // 录音最大时长 30 秒VAD 状态机核心逻辑:
if (drawStore.fullVoiceMode) {
if (level > SILENCE_THRESHOLD) {
if (speakingStartTime === null) {
speakingStartTime = Date.now()
}
if (!isSpeaking.value && Date.now() - speakingStartTime > MIN_SPEAKING_DURATION) {
isSpeaking.value = true
}
silenceStart = null
} else {
if (!isSpeaking.value) { speakingStartTime = null }
if (isSpeaking.value) {
if (silenceStart === null) { silenceStart = Date.now() }
else if (Date.now() - silenceStart > SILENCE_DURATION) {
if (options?.onSilence) options.onSilence() // 触发停止录音回调
}
}
}
}
getUserMedia 的所有已知失败模式都做了精确分类与中文提示:
// msg 取值:
// '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风'
// '未检测到麦克风设备,请连接后重试'
// '麦克风被其他应用占用,请关闭后重试'
// '无法访问麦克风,请检查浏览器权限设置'
throw new Error(msg)thinking / looking / generating 状态下,麦克风按钮被 disabled(VoiceRecorder.vue 第 19 行),用户无法在 AI 思考期间打断并重新开录。当前是"AI 完成这一轮 → 自动重新开录"的串行模型,而非"用户随时可插话"的抢占式模型。改进方向:分两路麦克风流(一条送识别、一条送打断检测)。识别容错是本产品的核心竞争力——用户用语音说"我想要那种……就是……呃……小猫", 系统必须能:
iterate 动作(基于当前图修改)| 层级 | 处理对象 | 实现位置 |
|---|---|---|
| L1 浏览器层 | 环境噪音 / 回声 | Web Audio API:echoCancellation + noiseSuppression + autoGainControl |
| L2 ASR 引擎层 | 口语不顺滑 / 数字规整 / 标点 | 火山引擎 enable_ddc + enable_itn + enable_punc |
| L3 后端关键词层 | 删除类硬指令 | 12 个删除关键词 + 4 个"直接出图"关键词,绕开 LLM 直接执行 |
| L4 LLM 意图层 | 模糊语义理解 | DeepSeek-V3 + Function Calling 强制返回 12 类 action |
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})火山引擎 Seed-ASR 启用了口语顺滑 (DDC)、数字规范化 (ITN)、 标点恢复 (Punc) 三项后处理:
request: {
model_name: "seed-asr",
enable_speaker_info: true,
ssd_version: "200",
enable_itn: true, // 数字规范化 (Inverse Text Normalization)
enable_punc: true, // 标点恢复
enable_ddc: true, // 口语顺滑 (Disfluency Detection and Correction)
}
delete 是最危险、最容易被误识别的指令。为了避免 LLM 误判,
后端在 LLM 之前做关键词硬匹配,命中即直接执行:
const deleteKeywords = [
"删除","删掉","移除","不要",
"删了","去掉","清理",
"这张不要了","这张删了","这张删掉",
"不需要了","不想要了",
];
const trimmedText = asrText.trim().replace(/[,。!?,.!?]/g, "");
if (deleteKeywords.some((kw) => trimmedText.includes(kw))) {
return {
action: "delete",
prompt: "",
confidence: 1.0,
completeness: 0,
user_feedback: "好的,已为您删除当前图片。",
watermark: false,
} as DrawingIntent;
}draw / "直接出图" 同理做硬匹配:
const isForceDraw =
asrText.includes("直接画") ||
asrText.includes("直接出图") ||
asrText.includes("不用问了") ||
asrText.includes("随便画");LLM 强制通过 Function Calling 返回结构化结果(不允许自由生成文本):
tools: [{
type: "function",
function: {
name: "process_drawing_intent",
description: "根据用户的语音指令,处理绘图意图并生成执行参数",
parameters: { /* DrawingIntent schema */ },
},
}],
tool_choice: {
type: "function",
function: { name: "process_drawing_intent" },
},置信度分级处理(绘图类要求高 / 闲聊类宽松):
const isDrawingAction = ["new","iterate","adjust"].includes(args.action);
if (isDrawingAction && args.confidence < 0.5) {
return { ...args, action: "unknown",
user_feedback: "我没听清楚,请再说一次?" };
}
if (args.confidence < 0.1) {
return this.createUnknownIntent("未识别到有效意图");
}| Action | 语义 | 触发示例 |
|---|---|---|
new | 全新创作 | "画一只猫" |
iterate | 基于当前图迭代(保留原图风格) | "把猫换成英短" |
adjust | 调整当前图 | "再亮一点" |
clarify | 追问细节 | 描述不完整时 |
confirm | 确认并保存 | "就这样吧" |
undo | 撤销 | "回到上一张" |
delete | 删除当前图 | "删除这张" |
select | 打开画廊中的图 | "打开第三张" |
chat | 闲聊 | "你是谁" |
visual_chat | 视觉问答 | "这张图怎么样" |
exit_voice_mode | 退出全语音 | "退出全语音" |
unknown | 无法识别 | 沉默 / 乱码 |
delete vs iterate vs clarify 的边界在 system prompt 中由 LLM 判断,没有专门的规则集。易出现"这图不行"被识别为 delete(实际用户希望重新画)。改进方向:建立"情绪词 + 否定词"特征库,结合历史会话上下文。voice.service.ts 第 74-94 行),需要用户手动重说。VoiceCommand schema 中虽然定义了 retry 状态枚举,但实际代码中从未使用。seed-asr 中文普通话。用户说"画一个 cyberpunk style 的猫" 会被识别但意图分类可能误判。延迟是语音交互的生命线——用户对着麦克风说完一句话, 等待超过 5 秒没有任何视觉反馈,就会产生"是不是坏了"的怀疑。 目标指标:
| 阶段 | 目标延迟 |
|---|---|
| ASR 识别 | < 2 s(用户说完 → 文字出现) |
| LLM 意图识别 | < 1.5 s |
| 图片生成 | < 8 s |
| 端到端总延迟 | < 12 s |
HomeView.vue 在 processUserIntent 调用时记录 startTime,
在 generateImage 返回时计算 responseTime:
const responseTime = Date.now() - startTime| 阶段 | 优化措施 |
|---|---|
| ASR 轮询 | 火山引擎固定 1.5s 间隔轮询,最多 20 次(≈30s 超时)。腾讯云为整段同步,延迟更低但弱网下失败率高。 |
| LLM 推理 | DeepSeek-V3,温度 0.3,max_tokens 1024。SiliconFlow 503 / 50508 时指数退避重试 2 次(llm.service.ts 第 28-43 行)。 |
| 图片生成 | Seedream 5.0(豆包),2K 默认分辨率,axios.post 无超时设置(依赖 ARK 平台 SLA)。 |
| 前端进度条 | 0-60% 快(每 200ms +2~7%),60-90% 中速(+0.5~2%),90-99% 极慢(+0.1~0.4%),永不达到 100% 直到图片返回——UX 心理技巧,避免"卡在 99%"焦虑。 |
| 音效反馈 | 5 种音效在识别开始 / 停止 / 成功 / 完成 / 切换时机播放,让用户听得到系统在做事,降低等待焦虑。 |
1.8s 静音触发比固定录音时长节省大量无效等待: 用户说"画一只猫,3 秒后停顿思考构图,5 秒后说'要英短的'"—— 系统在停顿 1.8s 时就立即把前一段送去识别, 不等用户说完,体感延迟降低 60% 以上。
responseTime 只记录了"LLM 开始 → 图片返回"的耗时,ASR 阶段、视觉聊天阶段、OSS 上传阶段均无独立埋点。要做到"哪个环节超过阈值就告警"需要全链路 timestamp 字段。语音描述天然是"短而不全"的——用户很难一次性说清楚"一只在夕阳下草地上、 戴墨镜、穿风衣的英短猫"。本产品设计了三层拆解机制:
澄清轮次由后端程序严格控制,不受 LLM 自由发挥影响。
核心控制逻辑(draw.service.ts):
const initialTurnCount = currentDraft.turnCount || 0;
const isContinuingGuidance =
initialTurnCount > 0 &&
(intent.action === "new" || intent.action === "clarify");
if ((isNewDrawing && !isForceDraw) || isContinuingGuidance) {
currentDraft.turnCount = initialTurnCount + 1;
// 核心:每轮 20%,且保证不回退
const newCompleteness = Math.min(currentDraft.turnCount * 0.2, 1.0);
currentDraft.completeness = Math.max(
currentDraft.completeness || 0, newCompleteness,
);
intent.completeness = currentDraft.completeness;
// 强制规则:至少追问 5 轮
if (currentDraft.turnCount < 5) {
intent.action = "clarify";
}
}5 轮澄清的维度 + 兜底选项(LLM 未返回时由后端硬编码兜底,确保不会卡住):
// 第 1 轮:画风
"为了画出更好的效果,您希望这张图是什么画风的?"
["写实风格","动漫风格","赛博朋克","唯美油画"]
// 第 2 轮:构图比例
"明白了,那您对画面的构图比例有要求吗?"
["1:1 正方形","16:9 宽屏","9:16 竖屏","4:3 比例"]
// 第 3 轮:光影氛围
"好的,画面中需要加入什么样的光影氛围呢?"
["温暖阳光","神秘月光","霓虹灯光","自然柔光"]
// 第 4 轮:角色穿着 / 细节
"最后,您对主体角色的穿着或细节有什么补充吗?"
["日常休闲","华丽古风","科幻机甲","简约时尚"]
// 第 5 轮:背景环境
"为了达到完美的效果,我们再确认最后一个细节:您对背景环境有什么具体要求吗?"
["室内场景","户外自然","城市街道","科幻背景"]Math.max),避免 LLM 误判为低分导致轮次重置clarify_question: null,后端永远有东西问
用户说"把猫换成英短"或"再亮一点"时,当前画布的图片作为参考图传给 Seedream, 实现基于原图的迭代:
// 只有在 action 是 iterate 或 adjust 时,默认使用当前图作为参考图
let referenceImages: string[] | undefined = intent.reference_images;
if (
!referenceImages &&
(intent.action === "iterate" || intent.action === "adjust") &&
currentImageUrl
) {
referenceImages = [currentImageUrl];
}
// 如果是 new 动作,明确清空参考图
if (intent.action === "new") {
referenceImages = undefined;
}
// 传给 Seedream 的 referenceImages 参数
return {
model: preferredModel || "doubao-seedream-5-0-260128",
prompt: intent.prompt,
size: intent.size || "2K",
aspect_ratio: intent.aspect_ratio || "1:1",
image: referenceImages, // ←── 参考图
watermark: intent.watermark ?? false,
};
每张图通过 parentImageId 记录父子关系,前端画廊按 parentImageId
聚合显示修改链:
const latestImage = await this.imageService.findLatestBySession(session.sessionId);
const parentImageId = intent.action !== "new" ? latestImage?.imageId : undefined;"这张图怎么样"需要图片 + 文字多模态输入。 Qwen-VL 通过 SiliconFlow 中转:
async visionChat(asrText: string, imageUrl: string,
context?: Array<{role:string; content:string}>): Promise<string> {
const model = MODEL_CONFIGS["qwen-vision"].model;
messages.push({
role: "user",
content: [
{ type: "text", text: asrText },
{ type: "image_url", image_url: { url: imageUrl } },
],
});
}| 操作 | 触发 | 实现 | 可逆? |
|---|---|---|---|
undo | "回到上一张" | 从 history 取 history[1],设置 currentImage | 可继续 undo |
delete | "删除这张" | 数据库软删除 isDeleted: true,从 history 移除 | 不可逆(无 undo 队列) |
if (intent.action === "undo") {
const history = await this.imageService.getImageHistory(userId, session.sessionId);
if (history.length > 1) {
const previousImage = history[1];
imageUrl = previousImage.imageUrlOss || previousImage.imageUrl;
return { action:"undo", imageUrl, message:"已回到上一张图片" };
}
}history[1]),不支持"回到上 N 张"。改进方向:维护一个 undoStack 记录 currentImage 变更历史,支持多级回退。isDeleted: true),但没有提供恢复接口。用户说"删错了,恢复一下"会失败。改进方向:增加 restore action + 最近 10 张的回收站 UI。currentDraft 存在 session 上,单 session 单任务——用户不能同时维护两个"创作中"的草稿。
drawStore.ts 中定义的 8 态状态机是整个交互的核心,
所有功能都收敛到这 8 个状态:
export type DrawStatus =
'idle' | 'recording' | 'recognizing' | 'thinking' |
'looking' | 'generating' | 'done' | 'error'| 状态 | 含义 | 触发动作 | 下一态 |
|---|---|---|---|
idle | 空闲 | 用户按麦克风 / 全语音模式自动开录 | recording |
recording | 录音中 | 用户松开 / 1.8s 静音 / 30s 超时 | recognizing |
recognizing | ASR 识别中 | 文字返回 | thinking |
thinking | LLM 意图识别中 | action = visual_chat | looking |
looking | 视觉模型看图 | Qwen-VL 返回 | done |
generating | Seedream 出图 | 图片 URL 返回 | done |
done | 完成 | 3s 后 / 全语音模式自动重新开录 | idle |
error | 出错 | 网络失败 / ASR 失败 / 置信度过低 | idle |
| 方法 | 模型 | 用途 | 触发 action |
|---|---|---|---|
recognizeIntent | deepseek-ai/DeepSeek-V3 | 意图分类 + Prompt 改写 | 所有 |
visionChat | Qwen/Qwen3.6-35B-A3B | 视觉问答 | visual_chat |
summarizeImageTitle | deepseek-ai/DeepSeek-V3 | 生成图片中文标题 | 出图后 |
generateTaskSummary | deepseek-ai/DeepSeek-V3 | 对话摘要 | 归档时 |
summarizeChat | deepseek-ai/DeepSeek-V3 | 对话压缩 | 归档时 |
绘图生成统一走 Seedream 5.0(doubao-seedream-5-0-260128),
通过火山方舟 API 调用。
| 用途 | 文件路径 |
|---|---|
| 意图识别系统 Prompt | voice-paint-be/src/llm/prompts/intent-recognition.ts |
| 意图 Schema (action 枚举) | voice-paint-be/src/llm/tools/drawing-intent.tool.ts |
| LLM Service (DeepSeek 调用) | voice-paint-be/src/llm/llm.service.ts |
| Draw Service (澄清 / 删除 / 撤销) | voice-paint-be/src/draw/draw.service.ts |
| Seedream 出图 | voice-paint-be/src/draw/services/seedream.service.ts |
| ASR 引擎工厂 | voice-paint-be/src/voice/services/asr-provider.factory.ts |
| 火山 ASR | voice-paint-be/src/voice/services/volc-speech.service.ts |
| 腾讯云 ASR | voice-paint-be/src/draw/services/tencent-asr.service.ts |
| 录音 composable | voice-paint-fe/src/composables/useRecorder.ts |
| 录音按钮组件 | voice-paint-fe/src/components/VoiceRecorder.vue |
| 音效系统 | voice-paint-fe/src/composables/useAudio.ts |
| 主视图 | voice-paint-fe/src/views/HomeView.vue |
| 状态管理 | voice-paint-fe/src/stores/drawStore.ts |
VoicePaint 的核心赌注是:"用户说不清楚没关系,我们帮你一步步对齐"。
未完成的部分(TTS 播报、用户插话打断、降级模型、删除恢复、撤回多张) 都在本文档相应章节中明确列出,不是失败而是迭代路线。