语音转文字Demo

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 语音转文本 Demo</title>
<style>
:root {
--bg: #f3efe8;
--bg-strong: #fffaf2;
--panel: rgba(255, 250, 243, 0.86);
--panel-strong: #fffdf9;
--text: #1f1a14;
--muted: #6c6358;
--line: rgba(79, 62, 43, 0.16);
--accent: #c8541a;
--accent-strong: #8f3608;
--info: #176f6a;
--info-soft: rgba(23, 111, 106, 0.12);
--success: #1f7c57;
--success-soft: rgba(31, 124, 87, 0.12);
--warning: #ac5c08;
--warning-soft: rgba(172, 92, 8, 0.12);
--danger: #b03c2d;
--danger-soft: rgba(176, 60, 45, 0.12);
--shadow: 0 28px 70px rgba(69, 45, 18, 0.14);
--radius-xl: 30px;
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 12px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: Aptos, "Microsoft YaHei UI", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 190, 118, 0.35), transparent 30%),
radial-gradient(circle at 85% 20%, rgba(23, 111, 106, 0.16), transparent 24%),
linear-gradient(135deg, #efe7dc 0%, var(--bg) 44%, #faf6ee 100%);
padding: 28px 16px 40px;
}
button,
textarea {
font: inherit;
}
.app-shell {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(300px, 430px) minmax(320px, 1fr);
gap: 24px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.intro {
padding: 28px;
position: sticky;
top: 22px;
overflow: hidden;
}
.intro::after {
content: "";
position: absolute;
inset: auto -24px -40px auto;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(200, 84, 26, 0.18), transparent 68%);
pointer-events: none;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(200, 84, 26, 0.1);
color: var(--accent-strong);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 5px rgba(200, 84, 26, 0.14);
}
h1 {
margin: 18px 0 14px;
font-size: clamp(34px, 6vw, 58px);
line-height: 0.94;
letter-spacing: -0.03em;
}
.intro-copy,
.note-list,
.support-copy,
.helper {
color: var(--muted);
}
.intro-copy {
margin: 0 0 22px;
font-size: 15px;
line-height: 1.72;
}
.note-list {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.note-item {
padding: 14px 16px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(79, 62, 43, 0.1);
line-height: 1.65;
font-size: 14px;
}
.workspace {
padding: 24px;
}
.status-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
background: var(--info-soft);
color: var(--info);
}
.status-pill::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 5px rgba(23, 111, 106, 0.12);
}
.status-pill.listening {
background: rgba(200, 84, 26, 0.12);
color: var(--accent-strong);
}
.status-pill.stopped {
background: var(--success-soft);
color: var(--success);
}
.status-pill.unsupported,
.status-pill.permission-denied,
.status-pill.error,
.status-pill.no-speech {
background: var(--danger-soft);
color: var(--danger);
}
.support-copy {
max-width: 320px;
font-size: 13px;
line-height: 1.6;
text-align: right;
}
.panel-grid {
display: grid;
gap: 18px;
}
.callout {
display: grid;
gap: 8px;
padding: 18px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.74), rgba(255, 250, 243, 0.94));
border: 1px solid rgba(79, 62, 43, 0.12);
}
.callout strong {
font-size: 15px;
}
.callout p {
margin: 0;
color: var(--muted);
line-height: 1.68;
font-size: 14px;
}
.callout.warning {
background: linear-gradient(135deg, rgba(255, 243, 226, 0.92), rgba(255, 250, 243, 0.98));
border-color: rgba(172, 92, 8, 0.18);
}
.controls {
display: grid;
gap: 16px;
padding: 20px;
border-radius: var(--radius-lg);
background: var(--panel-strong);
border: 1px solid var(--line);
}
.controls-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.controls-head h2,
.result-head h2 {
margin: 0;
font-size: 20px;
}
.helper {
font-size: 13px;
line-height: 1.6;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.btn {
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn:focus-visible,
.result-area:focus-visible {
outline: 3px solid rgba(23, 111, 106, 0.24);
outline-offset: 2px;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
box-shadow: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff9f1;
box-shadow: 0 16px 28px rgba(143, 54, 8, 0.22);
}
.btn-secondary {
background: rgba(79, 62, 43, 0.08);
color: var(--text);
}
.btn-ghost {
background: rgba(23, 111, 106, 0.08);
color: var(--info);
}
.meter-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 18px;
align-items: center;
}
.pulse {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 108px;
height: 108px;
border-radius: 50%;
background: radial-gradient(circle, rgba(200, 84, 26, 0.18), rgba(200, 84, 26, 0.04));
}
.pulse::before,
.pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(200, 84, 26, 0.18);
opacity: 0;
}
.pulse.active::before {
animation: ping 1.8s ease-out infinite;
}
.pulse.active::after {
animation: ping 1.8s ease-out 0.6s infinite;
}
.pulse-core {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
box-shadow: 0 10px 22px rgba(143, 54, 8, 0.26);
}
.meter-copy {
display: grid;
gap: 8px;
}
.meter-copy strong {
font-size: 16px;
}
.meter-copy p {
margin: 0;
color: var(--muted);
line-height: 1.65;
font-size: 14px;
}
.error-box {
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(176, 60, 45, 0.08);
border: 1px solid rgba(176, 60, 45, 0.18);
color: var(--danger);
line-height: 1.65;
font-size: 14px;
}
.result-card {
display: grid;
gap: 14px;
padding: 20px;
border-radius: var(--radius-lg);
background: var(--panel-strong);
border: 1px solid var(--line);
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.result-meta {
font-size: 13px;
color: var(--muted);
}
.result-area {
width: 100%;
min-height: 280px;
resize: vertical;
padding: 16px 18px;
border-radius: var(--radius-md);
border: 1px solid rgba(79, 62, 43, 0.16);
background: rgba(255, 255, 255, 0.8);
color: var(--text);
line-height: 1.72;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.result-area:focus {
border-color: rgba(23, 111, 106, 0.46);
box-shadow: 0 0 0 4px rgba(23, 111, 106, 0.1);
transform: translateY(-1px);
}
.result-area::placeholder {
color: #8d8378;
}
.result-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.micro-copy {
font-size: 13px;
color: var(--muted);
line-height: 1.6;
}
.copy-feedback {
min-height: 20px;
font-size: 13px;
color: var(--success);
}
.copy-feedback.error {
color: var(--danger);
}
.footer-note {
font-size: 13px;
line-height: 1.6;
color: var(--muted);
}
@keyframes ping {
0% {
transform: scale(1);
opacity: 0.72;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.intro {
position: static;
}
.support-copy {
text-align: left;
}
}
@media (max-width: 720px) {
body {
padding: 16px 12px 30px;
}
.workspace,
.intro {
padding: 18px;
}
.status-bar,
.controls-head,
.result-head,
.meter-row {
grid-template-columns: 1fr;
display: grid;
}
.meter-row {
gap: 14px;
justify-items: start;
}
.pulse {
width: 92px;
height: 92px;
}
h1 {
font-size: 40px;
}
}
</style>
</head>
<body>
<div id="app" class="app-shell">
<aside class="card intro">
<div class="eyebrow">Vue Speech Demo</div>
<h1>把中文语音直接转成可编辑文本</h1>
<p class="intro-copy">
这是一个独立的 HTML 单页 Demo,使用 Vue 3 CDN 和浏览器原生语音识别能力完成实时转写。它适合桌面 Chrome
和 Edge 的快速演示,不依赖后端,也不会修改你现有的页面结构。
</p>
<ul class="note-list">
<li class="note-item">
默认识别语言为 <code>zh-CN</code>,只在拿到最终识别结果后追加到结果框,不显示临时抖动文本。
</li>
<li class="note-item">
首次使用时,浏览器会请求麦克风权限。如果被拒绝,页面会进入明确的错误态并给出恢复提示。
</li>
<li class="note-item">
这个版本聚焦“能演示、能录、能复制、状态清楚”,方便你后续接聊天输入框或 AI 接口。
</li>
</ul>
</aside>
<main class="card workspace">
<div class="status-bar">
<div :class="['status-pill', statusClass]">{{ statusText }}</div>
<div class="support-copy">
推荐浏览器: 桌面 Chrome / Edge
<br />
当前模式: 浏览器原生语音识别
</div>
</div>
<div class="panel-grid">
<section :class="['callout', browserSupported ? '' : 'warning']" aria-live="polite">
<strong>{{ browserSupported ? '兼容性与权限提示' : '当前浏览器不支持语音识别' }}</strong>
<p>{{ statusDetail }}</p>
</section>
<section class="controls" aria-labelledby="controlTitle">
<div class="controls-head">
<h2 id="controlTitle">录音控制区</h2>
<div class="helper">开始识别后你可以连续说话,再手动停止并查看本轮最终转写。</div>
</div>
<div class="meter-row">
<div :class="['pulse', isListening ? 'active' : '']" aria-hidden="true">
<div class="pulse-core"></div>
</div>
<div class="meter-copy">
<strong>{{ isListening ? '正在监听麦克风' : '等待开始识别' }}</strong>
<p>
{{ isListening
? '浏览器正在捕获语音并等待最终结果。你可以自然停顿,也可以点击“停止识别”结束本轮。'
: '点击“开始识别”后,页面会请求浏览器麦克风权限,并在本页将最终文本追加到结果区。' }}
</p>
</div>
</div>
<div class="button-row">
<button class="btn btn-primary" type="button" @click="startRecognition" :disabled="!canStart">
开始识别
</button>
<button class="btn btn-secondary" type="button" @click="stopRecognition" :disabled="!canStop">
停止识别
</button>
<button class="btn btn-secondary" type="button" @click="clearTranscript">
清空文本
</button>
<button class="btn btn-ghost" type="button" @click="copyTranscript" :disabled="!transcript.trim()">
复制结果
</button>
</div>
<div v-if="errorMessage" class="error-box" role="alert" aria-live="assertive">
{{ errorMessage }}
</div>
</section>
<section class="result-card" aria-labelledby="resultTitle">
<div class="result-head">
<h2 id="resultTitle">转写结果区</h2>
<div class="result-meta">{{ transcriptStats }}</div>
</div>
<textarea
v-model="transcript"
class="result-area"
spellcheck="false"
placeholder="识别完成后的文本会追加到这里。你也可以手动编辑这段内容。"
aria-label="语音转文本结果"
></textarea>
<div class="result-toolbar">
<div class="micro-copy">
结果区支持手动补充和修改。连续多轮识别时,新文本会按行追加,避免覆盖之前的内容。
</div>
<div :class="['copy-feedback', copyState === 'error' ? 'error' : '']">{{ copyMessage }}</div>
</div>
<div class="footer-note">
如果你后续要把它接进聊天场景,可以直接把这里的 `transcript` 状态绑定到消息输入框。
</div>
</section>
</div>
</main>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
recognition: null,
browserSupported: false,
isListening: false,
transcript: "",
statusKey: "initial",
statusText: "未开始",
statusDetail: "点击“开始识别”即可使用浏览器麦克风转写中文语音。首次使用时请允许麦克风权限。",
errorMessage: "",
copyMessage: "",
copyState: "idle",
manualStopRequested: false,
sessionHasResult: false,
lastErrorCode: ""
};
},
computed: {
canStart() {
return this.browserSupported && !this.isListening;
},
canStop() {
return this.browserSupported && this.isListening;
},
statusClass() {
if (this.statusKey === "listening") {
return "listening";
}
if (this.statusKey === "stopped") {
return "stopped";
}
if (
this.statusKey === "unsupported" ||
this.statusKey === "permission-denied" ||
this.statusKey === "error" ||
this.statusKey === "no-speech"
) {
return this.statusKey;
}
return "";
},
transcriptStats() {
const trimmed = this.transcript.trim();
const charCount = trimmed.length;
if (!charCount) {
return "还没有文本";
}
const lineCount = trimmed.split(/\n+/).filter(Boolean).length;
return `共 ${charCount} 个字符 · ${lineCount} 段内容`;
}
},
mounted() {
this.initRecognition();
},
beforeUnmount() {
if (this.recognition && this.isListening) {
this.recognition.stop();
}
},
methods: {
initRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
this.browserSupported = false;
this.setStatus(
"unsupported",
"浏览器不支持",
"当前浏览器没有提供 SpeechRecognition 能力。请使用桌面 Chrome 或 Edge 打开这个页面。"
);
this.errorMessage = "无法初始化语音识别。建议切换到最新版本的桌面 Chrome 或 Edge。";
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => {
this.isListening = true;
this.sessionHasResult = false;
this.lastErrorCode = "";
this.copyMessage = "";
this.copyState = "idle";
this.errorMessage = "";
this.setStatus(
"listening",
"识别中",
"浏览器正在监听你的麦克风。保持清晰发音,完成后点击“停止识别”。"
);
};
recognition.onresult = (event) => {
let finalText = "";
for (let index = event.resultIndex; index < event.results.length; index += 1) {
const result = event.results[index];
const transcriptPart = result && result[0] && typeof result[0].transcript === "string"
? result[0].transcript
: "";
if (result.isFinal && transcriptPart.trim()) {
finalText += transcriptPart.trim();
}
}
if (finalText) {
this.sessionHasResult = true;
this.appendTranscript(finalText);
this.setStatus(
"listening",
"识别中",
"已拿到新的最终结果。你可以继续说话,也可以现在停止本轮识别。"
);
}
};
recognition.onerror = (event) => {
if (event.error === "aborted" && this.manualStopRequested) {
return;
}
this.lastErrorCode = event.error || "unknown";
this.isListening = false;
this.manualStopRequested = false;
const errorMap = {
"not-allowed": {
key: "permission-denied",
text: "麦克风权限被拒绝",
detail: "浏览器没有获得麦克风访问权限。请允许权限后刷新页面或重新尝试。",
message: "请在地址栏或站点权限设置中允许麦克风访问,然后再次点击“开始识别”。"
},
"service-not-allowed": {
key: "permission-denied",
text: "语音服务不可用",
detail: "浏览器拒绝了语音识别服务访问。通常与权限设置或系统限制有关。",
message: "请确认浏览器和系统没有禁用语音服务,并允许麦克风权限。"
},
"audio-capture": {
key: "error",
text: "未检测到麦克风",
detail: "浏览器没有发现可用的录音设备,请检查麦克风是否连接或被其他程序占用。",
message: "请确认当前电脑已经连接并启用了麦克风设备。"
},
"network": {
key: "error",
text: "识别服务异常",
detail: "浏览器原生语音识别服务暂时不可用,请检查网络后再试。",
message: "网络异常会影响浏览器语音识别,稍后重试通常可以恢复。"
},
"no-speech": {
key: "no-speech",
text: "未识别到语音",
detail: "本轮没有识别到清晰语音内容。请靠近麦克风并清楚说话后重新开始。",
message: "没有写入任何脏文本,你可以直接重新开始识别。"
},
"language-not-supported": {
key: "error",
text: "语言不受支持",
detail: "当前浏览器不支持 zh-CN 语音识别配置。",
message: "请切换到支持中文语音识别的桌面 Chrome 或 Edge。"
}
};
const mapped =
errorMap[event.error] || {
key: "error",
text: "识别出错",
detail: "浏览器语音识别过程中发生了未知错误,请稍后再试。",
message: `错误代码: ${event.error || "unknown"}`
};
this.setStatus(mapped.key, mapped.text, mapped.detail);
this.errorMessage = mapped.message;
};
recognition.onend = () => {
const hadError = Boolean(this.lastErrorCode);
this.isListening = false;
if (hadError) {
this.lastErrorCode = "";
return;
}
if (this.manualStopRequested) {
this.manualStopRequested = false;
if (this.sessionHasResult) {
this.setStatus(
"stopped",
"已停止",
"本轮识别已结束,最终文本已经追加到结果区。你可以继续编辑,或再次开始新的录音。"
);
} else {
this.setStatus(
"no-speech",
"未识别到语音",
"你结束了本轮识别,但浏览器没有返回任何最终文本。请靠近麦克风重新尝试。"
);
}
return;
}
if (this.sessionHasResult) {
this.setStatus(
"stopped",
"已停止",
"浏览器已自动结束当前识别,本轮最终文本已经保留在结果区。"
);
} else {
this.setStatus(
"no-speech",
"未识别到语音",
"浏览器结束了本轮识别,但没有获取到最终文本。请重新开始并保持清晰发音。"
);
}
};
this.recognition = recognition;
this.browserSupported = true;
this.setStatus(
"initial",
"未开始",
"你的浏览器已具备语音识别能力。点击“开始识别”后,允许麦克风权限即可开始中文转写。"
);
},
setStatus(key, text, detail) {
this.statusKey = key;
this.statusText = text;
this.statusDetail = detail;
},
appendTranscript(text) {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
this.transcript = this.transcript.trim()
? `${this.transcript.trim()}\n${normalized}`
: normalized;
},
startRecognition() {
if (!this.browserSupported || !this.recognition || this.isListening) {
return;
}
this.manualStopRequested = false;
this.sessionHasResult = false;
this.lastErrorCode = "";
this.errorMessage = "";
try {
this.recognition.start();
} catch (error) {
this.setStatus(
"error",
"无法启动识别",
"浏览器当前不能立即开始新一轮识别。请稍等一瞬后再次尝试。"
);
this.errorMessage = error instanceof Error ? error.message : "识别启动失败。";
}
},
stopRecognition() {
if (!this.recognition || !this.isListening) {
return;
}
this.manualStopRequested = true;
this.errorMessage = "";
try {
this.recognition.stop();
} catch (error) {
this.manualStopRequested = false;
this.setStatus(
"error",
"停止失败",
"浏览器没有成功结束当前识别,请稍后重试。"
);
this.errorMessage = error instanceof Error ? error.message : "停止识别失败。";
}
},
clearTranscript() {
this.transcript = "";
this.copyMessage = "";
this.copyState = "idle";
if (this.browserSupported && !this.isListening && this.statusKey === "stopped") {
this.setStatus(
"initial",
"未开始",
"文本已清空。你可以点击“开始识别”,继续进行新的中文语音转写。"
);
}
},
async copyTranscript() {
const content = this.transcript.trim();
if (!content) {
this.copyState = "error";
this.copyMessage = "当前没有可复制的文本。";
return;
}
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(content);
} else {
const tempTextarea = document.createElement("textarea");
tempTextarea.value = content;
tempTextarea.setAttribute("readonly", "readonly");
tempTextarea.style.position = "fixed";
tempTextarea.style.opacity = "0";
document.body.appendChild(tempTextarea);
tempTextarea.select();
document.execCommand("copy");
document.body.removeChild(tempTextarea);
}
this.copyState = "success";
this.copyMessage = "识别结果已复制到剪贴板。";
} catch (error) {
this.copyState = "error";
this.copyMessage = "复制失败,请手动选择文本后再复制。";
}
}
}
}).mount("#app");
</script>
</body>
</html>
扫描二维码,在手机上阅读