feat(网页版): 官网首页与 Demo 支持中英切换,默认跟随浏览器语言

页面文案默认英文,导航栏加语言开关,选择记在 localStorage,Demo 的内置提示词
也各留一套。中文标题与副标题仍允许 /api/public/config 覆盖,方便自部署方改文案。
后端与 server/README 的措辞统一改为 AI cleanup,和客户端的「AI 整理」对齐。
This commit is contained in:
crosswk committed 2026-08-13 22:09:42 +08:00
1 parent 79c3f8a61c
commit f7f37252fb
6 files changed
+309 -88

No files matched your search

+5 -5
View File
@@ -2,9 +2,9 @@
# 🎙️ SayIt
**开口即成稿 — 用说话代替打字,AI 实时把口语变成书面表达。**
**Just say it, and write well**
Self-hosted speech-to-text server with real-time ASR + LLM polishing.
Self-hosted speech-to-text server with real-time ASR and AI cleanup.
[Quick Start](#quick-start) · [Architecture](#architecture) · [Configuration](#configuration) · [Deployment](#deployment) · [License](#license)
@@ -14,7 +14,7 @@ Self-hosted speech-to-text server with real-time ASR + LLM polishing.
## What is SayIt?
SayIt is a self-hosted speech-to-text service that combines real-time ASR (Qwen3-ASR) with LLM text polishing. It provides:
SayIt is a self-hosted speech-to-text service that combines real-time ASR (Qwen3-ASR) with AI cleanup. It provides:
- **Browser Demo** — Record and transcribe directly in the browser, no install needed
- **Windows Desktop App** — Push-to-talk with automatic paste into any application
@@ -26,7 +26,7 @@ Everything runs on a single server with one GPU.
## Features
- 🎯 **Real-time ASR** — Qwen3-ASR-1.7B with vLLM acceleration, <2s latency
- ✨ **LLM Polishing** — Converts spoken language to written text (Azure OpenAI / Groq / Ollama)
- ✨ **AI Cleanup** — Converts spoken language to written text (Azure OpenAI / Groq / Ollama)
- 🔥 **Hotword Boosting** — Custom vocabulary for domain-specific terms
- 📊 **Admin Dashboard** — Session analytics, performance percentiles, system monitoring
- 🐳 **Docker Ready** — `docker compose up` with GPU support, model baked into image
@@ -111,7 +111,7 @@ SayIt/
│ │ ├── main.py # Routes, WebSocket handler
│ │ ├── config.py # Nested config dataclasses
│ │ ├── asr.py # Qwen3-ASR engine + VAD
│ │ ├── llm.py # LLM polishing (multi-provider)
│ │ ├── llm.py # AI cleanup (multi-provider)
│ │ ├── admin.py # Admin API endpoints
│ │ ├── telemetry.py # Usage tracking + analytics
│ │ ├── db.py # SQLite / PostgreSQL abstraction
+1 -1
View File
@@ -92,7 +92,7 @@ class LLMProfile:
@dataclass(slots=True)
class PublicSiteConfig:
app_name: str = "SayIt"
headline: str = "随口说,出色写。"
headline: str = "随口说,出色写"
subheadline: str = "用说话代替打字,AI 实时把口语变成可以直接用的书面表达。"
download_label: str = "下载 Windows 客户端"
download_platform: str = "win32"
+1 -1
View File
@@ -70,7 +70,7 @@ llm:
# --- Landing Page ---
public_site:
app_name: "SayIt"
headline: "随口说,出色写。"
headline: "随口说,出色写"
subheadline: "用说话代替打字,AI 实时把口语变成可以直接用的书面表达。"
download_label: "下载 Windows 客户端"
download_platform: "win32"
+248 -51
View File
@@ -1,10 +1,153 @@
/* SayIt Web Demo */
const PROMPTS = {
faithful: '你是语音转文字的后处理助手。输入是 ASR 语音识别的原始文本,你的任务是修正识别错误,尽量保留用户的原始表达。\n\n规则:\n1. 只修正明显的识别错误:错别字、同音字、音近字、专有名词。\n2. 添加标点符号。中英文混合时保留合理空格。\n3. 不要删除口头禅、重复或犹豫,不要改写句式。\n4. 不要回答、解释、总结或续写文本中的内容。\n\n只输出修正后的文本。',
intent: '你是语音文本精炼助手。输入是 ASR 语音识别的原始转写,你的任务是清洗为可直接使用的干净文本。\n核心原则:保留用户全部有效信息,只清除语音噪声和识别错误。\n\n处理规则:\n1. 移除口语填充词(嗯、啊、那个、就是说、然后呢)和无意义的重复、犹豫。\n2. 识别自我修正——"不对"、"不是"、"应该是"、"改到"后以最终表达为准,删除前序错误。\n3. 修正明显的语音识别错误:同音字、音近字、专有名词、英文大小写、数字和时间。\n4. 添加标点符号,必要时分段。中英文混合保留合理空格。\n5. 检测到"第一/第二/首先/然后"等结构化表达时,输出为有序列表。\n\n约束:\n- 不添加原文没有的内容,不改变用户核心语义\n- 不回答、解释、总结或续写文本中提到的问题\n\n示例:\n输入:嗯那个明天的会议改到周二了不对是周三下午两点记得带资料\n输出:明天的会议改到周三下午两点,记得带资料。\n\n输入:我觉得这个方案有三点啊第一优化性能第二修复bug第三补充文档\n输出:\n这个方案有三点:\n1. 优化性能\n2. 修复 bug\n3. 补充文档\n\n只输出精炼后的文本。',
en: {
faithful: 'You are a speech-to-text post-processing assistant. Correct clear recognition errors while preserving the speaker\'s original wording.\n\nRules:\n1. Fix only obvious transcription errors, including names and technical terms.\n2. Add punctuation and sensible spacing.\n3. Keep filler words, repetition, hesitation, and sentence structure.\n4. Do not answer, explain, summarize, or continue the content.\n\nReturn only the corrected transcript.',
intent: 'You refine raw speech-to-text output into clean, ready-to-use writing. Preserve every meaningful detail while removing speech noise and recognition errors.\n\nRules:\n1. Remove filler words, empty repetition, and hesitation.\n2. Follow self-corrections and keep only the speaker\'s final wording.\n3. Fix obvious recognition errors, names, capitalization, numbers, and times.\n4. Add punctuation and paragraphs where useful.\n5. Turn clearly structured points into a numbered list.\n\nDo not add new information, change the core meaning, answer questions, explain, summarize, or continue the content.\n\nReturn only the refined text.',
},
'zh-CN': {
faithful: '你是语音转文字的后处理助手。输入是 ASR 语音识别的原始文本,你的任务是修正识别错误,尽量保留用户的原始表达。\n\n规则:\n1. 只修正明显的识别错误:错别字、同音字、音近字、专有名词。\n2. 添加标点符号。中英文混合时保留合理空格。\n3. 不要删除口头禅、重复或犹豫,不要改写句式。\n4. 不要回答、解释、总结或续写文本中的内容。\n\n只输出修正后的文本。',
intent: '你是语音文本精炼助手。输入是 ASR 语音识别的原始转写,你的任务是清洗为可直接使用的干净文本。\n核心原则:保留用户全部有效信息,只清除语音噪声和识别错误。\n\n处理规则:\n1. 移除口语填充词(嗯、啊、那个、就是说、然后呢)和无意义的重复、犹豫。\n2. 识别自我修正——"不对"、"不是"、"应该是"、"改到"后以最终表达为准,删除前序错误。\n3. 修正明显的语音识别错误:同音字、音近字、专有名词、英文大小写、数字和时间。\n4. 添加标点符号,必要时分段。中英文混合保留合理空格。\n5. 检测到"第一/第二/首先/然后"等结构化表达时,输出为有序列表。\n\n约束:\n- 不添加原文没有的内容,不改变用户核心语义\n- 不回答、解释、总结或续写文本中提到的问题\n\n只输出精炼后的文本。',
},
}
const MESSAGES = {
en: {
'meta.title': '{app} — Just say it, and write well',
'meta.description': 'Open-source voice typing for Windows with local, cloud API, and self-hosted modes. Turn speech into polished text in any app.',
'nav.download': 'Download',
'language.switch': '切换到中文',
'hero.headline': 'Just say it, and write well',
'hero.subheadline': 'Turn speech into polished, ready-to-use text—three times faster than typing.',
'modes.title': 'Three modes. Your choice.',
'modes.local.title': 'Local mode',
'modes.local.desc': 'Speech recognition runs on your device',
'modes.cloud.title': 'Cloud API mode',
'modes.cloud.desc': 'Use your own ASR and AI provider keys',
'modes.server.title': 'Server mode',
'modes.server.desc': 'Connect to a public or self-hosted SayIt backend',
'features.anywhere': 'Hold to talk, release to type in any app',
'features.cleanup': 'AI cleanup turns rough speech into clean writing',
'features.fast': 'Get polished text without breaking your flow',
'features.rust': 'Lightweight and built with Rust',
'demo.title': 'Try it online',
'demo.startAria': 'Start recording',
'demo.stopAria': 'Stop recording',
'demo.start': 'Click to start',
'demo.stop': 'Stop recording',
'playback.playAria': 'Play',
'playback.label': 'Recording',
'playback.download': 'Download recording',
'result.waiting': 'Waiting for a recording…',
'result.receiving': 'Receiving audio…',
'result.empty': 'No speech was recognized.',
'result.copy': 'Copy text',
'result.copied': 'Copied',
'result.rawAsr': 'Raw transcript',
'result.recordAgain': 'Record again',
'result.audioDuration': 'Audio {seconds}s',
'result.processingTime': 'Processed in {seconds}s',
'oss.title': 'Open source · Self-hosted · Privacy first',
'oss.desc': 'AGPL-3.0 licensed, Docker-ready, and fully under your control.',
'status.unavailable': 'Unavailable',
'status.connecting': 'Connecting',
'status.connectionFailed': 'Connection failed',
'status.disconnected': 'Disconnected',
'status.ready': 'Ready',
'status.requestFailed': 'Request failed',
'status.notReady': 'Service not ready',
'status.recording': 'Recording',
'status.processing': 'Processing',
'status.recordingFailed': 'Recording failed',
'status.initFailed': 'Could not initialize',
'error.noRecording': 'No recording to replay',
'error.wsMissing': 'WebSocket address is not configured',
'error.replayTimeout': 'Replay timed out',
'error.replayConnection': 'Replay connection failed',
'error.replayClosed': 'Replay connection closed',
'error.replayFailed': 'Replay failed',
'error.config': 'Config request returned {status}',
'error.websocket': 'WebSocket connection failed',
'error.audioContext': 'This browser does not support AudioContext',
'rerun.asrRunning': 'Transcribing again',
'rerun.asrFailed': 'Transcription failed',
'rerun.aiRunning': 'Cleaning up again',
'rerun.aiFailed': 'AI cleanup failed',
'rerun.noResult': 'No result was generated.',
},
'zh-CN': {
'meta.title': '{app} — 随口说,出色写',
'meta.description': '开源 Windows 语音输入工具,支持本地、云 API 和自部署模式,在任何应用中把口语变成可直接使用的文字。',
'nav.download': '下载',
'language.switch': 'Switch to English',
'hero.headline': '随口说,出色写',
'hero.subheadline': '说话比打字快 3 倍,AI 实时把口语变成可以直接用的书面表达。',
'modes.title': '三种模式,按需选择',
'modes.local.title': '本地模式',
'modes.local.desc': '语音识别在本机运行,数据不出设备',
'modes.cloud.title': '云 API 模式',
'modes.cloud.desc': '无需 GPU,对接云端 ASR 和 AI',
'modes.server.title': '服务器模式',
'modes.server.desc': 'Docker 一键部署,GPU 加速推理',
'features.anywhere': '按住说话,松开即输入到任何应用',
'features.cleanup': 'AI 自动去除口头禅,输出书面表达',
'features.fast': '说完即出稿,不打断工作流',
'features.rust': 'Rust 构建,轻量安全',
'demo.title': '在线体验',
'demo.startAria': '开始录音',
'demo.stopAria': '结束录音',
'demo.start': '点击录音体验',
'demo.stop': '结束录音',
'playback.playAria': '播放',
'playback.label': '本次录音',
'playback.download': '下载录音',
'result.waiting': '等待录音…',
'result.receiving': '正在接收语音…',
'result.empty': '本次没有识别到有效文本。',
'result.copy': '复制文本',
'result.copied': '已复制',
'result.rawAsr': 'ASR 原文',
'result.recordAgain': '重新录音',
'result.audioDuration': '语音 {seconds}s',
'result.processingTime': '识别 {seconds}s',
'oss.title': '开源 · 自托管 · 隐私优先',
'oss.desc': 'AGPL-3.0 开源协议,Docker 一键部署,数据完全自控。',
'status.unavailable': '未开放',
'status.connecting': '连接中',
'status.connectionFailed': '连接失败',
'status.disconnected': '已断开',
'status.ready': '准备就绪',
'status.requestFailed': '请求失败',
'status.notReady': '服务未就绪',
'status.recording': '录音中',
'status.processing': '处理中',
'status.recordingFailed': '录音失败',
'status.initFailed': '初始化失败',
'error.noRecording': '没有可回放的录音',
'error.wsMissing': 'WebSocket 地址未配置',
'error.replayTimeout': '回放请求超时',
'error.replayConnection': '回放连接出错',
'error.replayClosed': '回放连接已关闭',
'error.replayFailed': '回放失败',
'error.config': '配置接口返回 {status}',
'error.websocket': 'WebSocket 连接失败',
'error.audioContext': '浏览器不支持 AudioContext',
'rerun.asrRunning': '重新识别中',
'rerun.asrFailed': '识别失败',
'rerun.aiRunning': '重新润色中',
'rerun.aiFailed': '润色失败',
'rerun.noResult': '未生成结果。',
},
}
function detectLocale() {
const saved = localStorage.getItem('sayit.web.locale')
if (saved === 'en' || saved === 'zh-CN') return saved
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
return languages.some((tag) => String(tag).toLowerCase().startsWith('zh')) ? 'zh-CN' : 'en'
}
const state = {
locale: detectLocale(), statusKey: 'status.ready', statusDot: '', lastResult: null,
config: null, ws: null, wsReady: false, recording: false,
mediaStream: null, audioCtx: null, workletNode: null, sourceNode: null,
analyser: null,
@@ -24,6 +167,7 @@ const state = {
const $ = (id) => document.getElementById(id)
const els = {
brandName: $('brandName'), headline: $('headline'), subheadline: $('subheadline'),
languageToggle: $('languageToggle'),
navDlBtn: $('navDlBtn'), dlBtn: $('dlBtn'), dlLabel: $('dlLabel'),
stateDot: $('stateDot'), stateText: $('stateText'), timer: $('timer'),
recordButton: $('recordButton'), recordLabel: $('recordLabel'),
@@ -56,8 +200,45 @@ const els = {
}
/* ── Helpers ── */
const setStateText = (text, dotClass) => {
els.stateText.textContent = text
const tr = (key, vars = {}) => {
const template = MESSAGES[state.locale]?.[key] ?? MESSAGES.en[key] ?? key
return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`))
}
function applyBranding() {
const appName = state.config?.app_name || 'SayIt'
document.title = tr('meta.title', { app: appName })
document.querySelector('meta[name="description"]')?.setAttribute('content', tr('meta.description'))
document.querySelector('meta[property="og:title"]')?.setAttribute('content', document.title)
document.querySelector('meta[property="og:description"]')?.setAttribute('content', tr('meta.description'))
els.brandName.textContent = appName
const useCustomChinese = state.locale === 'zh-CN'
els.headline.textContent = useCustomChinese && state.config?.headline
? state.config.headline
: tr('hero.headline')
els.subheadline.textContent = useCustomChinese && state.config?.subheadline
? state.config.subheadline
: tr('hero.subheadline')
}
function applyLocale() {
document.documentElement.lang = state.locale
document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n) })
document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)) })
document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.setAttribute('title', tr(el.dataset.i18nTitle)) })
document.querySelectorAll('[data-i18n-tooltip]').forEach((el) => { el.dataset.tooltip = tr(el.dataset.i18nTooltip) })
els.languageToggle.textContent = state.locale === 'en' ? '中文' : 'EN'
els.languageToggle.setAttribute('aria-label', tr('language.switch'))
applyBranding()
setRecordUI(state.recording)
setStateText(state.statusKey, state.statusDot)
if (state.lastResult) updateResult(state.lastResult)
}
const setStateText = (key, dotClass) => {
state.statusKey = key
state.statusDot = dotClass
els.stateText.textContent = tr(key)
els.stateDot.className = 'state-dot' + (dotClass ? ' ' + dotClass : '')
}
const setResultText = (el, text, placeholder) => {
@@ -68,9 +249,10 @@ const setResultText = (el, text, placeholder) => {
}
/* Unified result card updater */
function updateResult(opts) {
state.lastResult = opts
const primary = opts.llmText || opts.asrText || ''
const asr = opts.asrText || ''
setResultText(els.resultText, primary, opts.placeholder || '等待录音...')
setResultText(els.resultText, primary, opts.placeholder || tr(opts.placeholderKey || 'result.waiting'))
if (els.asrText && asr && opts.llmText) { els.resultDetails.classList.remove('hidden'); els.asrText.textContent = asr }
else if (els.resultDetails) els.resultDetails.classList.add('hidden')
if (!els.resultMeta) return
@@ -78,12 +260,20 @@ function updateResult(opts) {
const tags = []
if (opts.asrModel) { const short = opts.asrModel.includes('/') ? opts.asrModel.split('/').pop() : opts.asrModel; tags.push(`ASR: ${short}`) }
if (opts.llmModel) tags.push(`AI: ${opts.llmProvider ? opts.llmProvider + ' / ' : ''}${opts.llmModel}`)
let html = ''
if (opts.status) { html = `<span class="meta-text">${opts.status}</span>`; els.resultMeta.innerHTML = html; return }
if (opts.durationSec) html += `<span class="meta-text">语音 ${opts.durationSec.toFixed(1)}s</span>`
if (total) html += `<span class="meta-sep">|</span><span class="meta-text">识别 ${(total / 1000).toFixed(1)}s (ASR ${am}ms${lm ? ' + LLM ' + lm + 'ms' : ''})</span>`
if (tags.length) html += tags.map(t => `<span class="meta-tag">${t}</span>`).join('')
els.resultMeta.innerHTML = html
const addMeta = (className, text) => {
const span = document.createElement('span')
span.className = className
span.textContent = text
els.resultMeta.appendChild(span)
}
els.resultMeta.replaceChildren()
if (opts.statusKey) { addMeta('meta-text', tr(opts.statusKey)); return }
if (opts.durationSec) addMeta('meta-text', tr('result.audioDuration', { seconds: opts.durationSec.toFixed(1) }))
if (total) {
addMeta('meta-sep', '|')
addMeta('meta-text', `${tr('result.processingTime', { seconds: (total / 1000).toFixed(1) })} (ASR ${am}ms${lm ? ' + LLM ' + lm + 'ms' : ''})`)
}
tags.forEach((tag) => addMeta('meta-tag', tag))
}
const fmtClock = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
@@ -91,7 +281,8 @@ const setRecordUI = (rec) => {
els.micIcon.style.display = rec ? 'none' : ''
els.stopIcon.style.display = rec ? '' : 'none'
els.recordButton.classList.toggle('stop', rec)
els.recordLabel.textContent = rec ? '结束录音' : '点击录音体验'
els.recordLabel.textContent = tr(rec ? 'demo.stop' : 'demo.start')
els.recordButton.setAttribute('aria-label', tr(rec ? 'demo.stopAria' : 'demo.startAria'))
}
const updateDownloadLinks = (url) => {
;[els.navDlBtn, els.dlBtn].forEach(el => { if (el) el.href = url || '#download' })
@@ -279,9 +470,9 @@ function getWsUrl() {
function replayAudio(options) {
const chunks = state.pcmChunks
if (!chunks.length) return Promise.reject(new Error('没有可回放的录音'))
if (!chunks.length) return Promise.reject(new Error(tr('error.noRecording')))
const wsUrl = getWsUrl()
if (!wsUrl) return Promise.reject(new Error('WebSocket 地址未配置'))
if (!wsUrl) return Promise.reject(new Error(tr('error.wsMissing')))
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl)
@@ -290,12 +481,12 @@ function replayAudio(options) {
const timeout = setTimeout(() => {
if (finished) return; finished = true
try { socket.close() } catch (_) {}
reject(new Error('回放请求超时'))
reject(new Error(tr('error.replayTimeout')))
}, 30000)
const cleanup = () => { clearTimeout(timeout); try { socket.close() } catch (_) {} }
socket.onerror = () => { if (!finished) { finished = true; cleanup(); reject(new Error('回放连接出错')) } }
socket.onclose = () => { if (!finished && finalResult) { finished = true; cleanup(); resolve(finalResult) } else if (!finished) { finished = true; cleanup(); reject(new Error('回放连接已关闭')) } }
socket.onerror = () => { if (!finished) { finished = true; cleanup(); reject(new Error(tr('error.replayConnection'))) } }
socket.onclose = () => { if (!finished && finalResult) { finished = true; cleanup(); resolve(finalResult) } else if (!finished) { finished = true; cleanup(); reject(new Error(tr('error.replayClosed'))) } }
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let p; try { p = JSON.parse(event.data) } catch (_) { return }
@@ -315,7 +506,7 @@ function replayAudio(options) {
return
}
if (t === 'done') { if (finalResult) { finished = true; cleanup(); resolve(finalResult) } return }
if (t === 'error') { finished = true; cleanup(); reject(new Error(String(p.message || '回放失败'))) }
if (t === 'error') { finished = true; cleanup(); reject(new Error(String(p.message || tr('error.replayFailed')))) }
}
})
}
@@ -323,26 +514,22 @@ function replayAudio(options) {
/* ── Config ── */
async function loadConfig() {
const res = await fetch('/api/public/config', { cache: 'no-store' })
if (!res.ok) throw new Error(`配置接口返回 ${res.status}`)
if (!res.ok) throw new Error(tr('error.config', { status: res.status }))
state.config = await res.json()
document.title = state.config.app_name || 'SayIt'
els.brandName.textContent = state.config.app_name || 'SayIt'
els.headline.textContent = state.config.headline || '随口说,出色写。'
els.subheadline.textContent = state.config.subheadline || '用说话代替打字,AI 实时把口语变成可以直接用的书面表达。'
applyBranding()
updateDownloadLinks(state.config.download_url)
if (els.dlLabel && state.config.download_version) els.dlLabel.textContent = `Windows v${state.config.download_version}`
const wd = state.config.web_demo || {}
state.llmEnabled = Boolean(wd.llm_enabled)
if (!wd.enabled) { els.recordButton.disabled = true; setStateText('未开放', ''); return }
setStateText('连接中', '')
if (!state.llmEnabled && els.aiMeta) els.aiMeta.textContent = '当前未启用 AI 校准'
if (!wd.enabled) { els.recordButton.disabled = true; setStateText('status.unavailable', ''); return }
setStateText('status.connecting', '')
}
/* ── WebSocket ── */
function scheduleReconnect() {
setTimeout(() => {
if (!state.ws || state.ws.readyState === WebSocket.CLOSED)
connectWebSocket().catch(() => setStateText('连接失败', ''))
connectWebSocket().catch(() => setStateText('status.connectionFailed', ''))
}, 2000)
}
async function connectWebSocket() {
@@ -353,10 +540,10 @@ async function connectWebSocket() {
socket.binaryType = 'arraybuffer'
state.ws = socket
socket.onopen = () => resolve()
socket.onerror = () => { state.wsReady = false; reject(new Error('WebSocket 连接失败')) }
socket.onerror = () => { state.wsReady = false; reject(new Error(tr('error.websocket'))) }
socket.onclose = () => {
state.wsReady = false
if (!state.recording) setStateText('已断开', '')
if (!state.recording) setStateText('status.disconnected', '')
scheduleReconnect()
}
socket.onmessage = (event) => {
@@ -365,12 +552,12 @@ async function connectWebSocket() {
const t = String(p.type || '')
if (t === 'ready') {
state.wsReady = true; state.llmEnabled = Boolean(p.llm)
setStateText('准备就绪', 'ready')
setStateText('status.ready', 'ready')
return
}
if (t === 'asr') {
const text = String(p.text || '')
updateResult({ asrText: text, asrMs: p.asr_ms, durationSec: p.duration_sec, placeholder: '正在接收语音...' })
updateResult({ asrText: text, asrMs: p.asr_ms, durationSec: p.duration_sec, placeholderKey: 'result.receiving' })
return
}
if (t === 'final') {
@@ -380,13 +567,13 @@ async function connectWebSocket() {
state.lastAsrText = asr; state.lastAiText = llm
updateResult({
asrText: asr, llmText: state.llmEnabled ? llm : '', asrMs: am, llmMs: state.llmEnabled ? lm : 0,
durationSec: p.duration_sec, placeholder: '本次没有识别到有效文本。',
durationSec: p.duration_sec, placeholderKey: 'result.empty',
asrModel: p.asr_model, llmProvider: ld.provider, llmModel: ld.model,
})
return
}
if (t === 'error') { setStateText('请求失败', ''); return }
if (t === 'done' && !state.recording) setStateText('准备就绪', 'ready')
if (t === 'error') { setStateText('status.requestFailed', ''); return }
if (t === 'done' && !state.recording) setStateText('status.ready', 'ready')
}
})
}
@@ -411,7 +598,7 @@ async function ensureAudio() {
await teardownAudio()
state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: false, noiseSuppression: true } })
const Ctor = window.AudioContext || window.webkitAudioContext
if (!Ctor) throw new Error('浏览器不支持 AudioContext')
if (!Ctor) throw new Error(tr('error.audioContext'))
state.audioCtx = createAudioCtx(Ctor)
await state.audioCtx.audioWorklet.addModule('/pcm-worklet.js')
state.sourceNode = state.audioCtx.createMediaStreamSource(state.mediaStream)
@@ -440,21 +627,21 @@ async function ensureAudio() {
async function startRecording() {
if (!state.config?.web_demo?.enabled || state.recording) return
if (!state.wsReady || !state.ws || state.ws.readyState !== WebSocket.OPEN) {
setStateText('服务未就绪', ''); return
setStateText('status.notReady', ''); return
}
await ensureAudio()
state.pcmChunks = []
state.recording = true
state.lastAsrText = ''; state.lastAiText = ''
setRecordUI(true)
setStateText('录音中', 'recording')
setStateText('status.recording', 'recording')
// Show result section, hide playback & clear
els.resultSection.classList.remove('hidden')
if (els.aiResultSection) els.aiResultSection.classList.remove('hidden')
els.playbackSection.classList.add('hidden')
els.clearWrap.classList.add('hidden')
// Reset results
updateResult({ placeholder: '正在接收语音...' })
updateResult({ placeholderKey: 'result.receiving' })
resetTimer(); startTimer(); startBlob()
state.ws.send(JSON.stringify({ cmd: 'start', disable_ai: !state.llmEnabled }))
}
@@ -462,7 +649,7 @@ async function stopRecording() {
if (!state.recording) return
state.recording = false
setRecordUI(false)
setStateText('处理中', '')
setStateText('status.processing', '')
clearInterval(state.timerId); state.timerId = null
if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send(JSON.stringify({ cmd: 'stop' }))
setupPlayback()
@@ -480,10 +667,11 @@ function clearResults() {
// Reset state
state.pcmChunks = []
state.lastAsrText = ''; state.lastAiText = ''
state.lastResult = null
if (state.audioEl) { state.audioEl.pause(); URL.revokeObjectURL(state.audioEl.src); state.audioEl = null }
// Reset timer display
els.timer.textContent = '00:00'
setStateText('准备就绪', 'ready')
setStateText('status.ready', 'ready')
}
/* ── Copy ── */
@@ -491,40 +679,41 @@ async function copyCard(textEl, btn) {
const text = String(textEl.textContent || '').trim()
if (!text || textEl.classList.contains('placeholder')) return
await navigator.clipboard.writeText(text)
btn.dataset.tooltip = '已复制'
btn.dataset.tooltip = tr('result.copied')
btn.classList.add('copied')
setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tooltip = '复制文本' }, 1500)
setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tooltip = tr('result.copy') }, 1500)
}
/* ── Rerun ASR ── */
async function rerunAsr() {
if (!state.pcmChunks.length) return
updateResult({ placeholder: '正在重新识别...', status: '重新识别中' })
updateResult({ placeholderKey: 'result.receiving', statusKey: 'rerun.asrRunning' })
try {
const hwText = (els.hotwordInput.value || '').trim()
const hotwords = hwText ? hwText.split(/[,,]/).map(s => s.trim()).filter(Boolean) : undefined
const result = await replayAudio({ hotwords, disableAi: true })
state.lastAsrText = result.asrText
updateResult({ asrText: result.asrText, asrMs: result.asrMs, durationSec: result.durationSec, placeholder: '本次没有识别到有效文本。' })
updateResult({ asrText: result.asrText, asrMs: result.asrMs, durationSec: result.durationSec, placeholderKey: 'result.empty' })
} catch (e) {
updateResult({ placeholder: e.message, status: '识别失败' })
updateResult({ placeholder: e.message, statusKey: 'rerun.asrFailed' })
}
}
/* ── Rerun LLM ── */
async function rerunLlm() {
if (!state.pcmChunks.length) return
updateResult({ placeholder: '正在重新润色...', status: '重新润色中' })
updateResult({ placeholderKey: 'result.receiving', statusKey: 'rerun.aiRunning' })
try {
const hwText = (els.hotwordInput.value || '').trim()
const hotwords = hwText ? hwText.split(/[,,]/).map(s => s.trim()).filter(Boolean) : undefined
const systemPrompt = PROMPTS[state.promptMode] || PROMPTS.faithful
const localePrompts = PROMPTS[state.locale] || PROMPTS.en
const systemPrompt = localePrompts[state.promptMode] || localePrompts.faithful
const result = await replayAudio({ hotwords, systemPrompt })
state.lastAiText = result.llmText
state.lastAsrText = result.asrText
updateResult({ asrText: result.asrText, llmText: result.llmText, asrMs: result.asrMs, llmMs: result.llmMs, durationSec: result.durationSec, placeholder: '未生成结果。' })
updateResult({ asrText: result.asrText, llmText: result.llmText, asrMs: result.asrMs, llmMs: result.llmMs, durationSec: result.durationSec, placeholderKey: 'rerun.noResult' })
} catch (e) {
updateResult({ placeholder: e.message, status: '润色失败' })
updateResult({ placeholder: e.message, statusKey: 'rerun.aiFailed' })
}
}
@@ -537,6 +726,8 @@ function setPromptMode(mode) {
/* ── Boot ── */
async function boot() {
applyLocale()
// Init gooey blob
initBars()
initOrbs(BLOB_CFG.orbCount)
@@ -545,12 +736,18 @@ async function boot() {
// Copy buttons
if (els.copyResult) els.copyResult.addEventListener('click', () => void copyCard(els.resultText, els.copyResult).catch(() => {}))
els.languageToggle.addEventListener('click', () => {
state.locale = state.locale === 'en' ? 'zh-CN' : 'en'
localStorage.setItem('sayit.web.locale', state.locale)
applyLocale()
})
// Record button
els.recordButton.addEventListener('click', () => {
void (state.recording ? stopRecording() : startRecording()).catch(e => {
state.recording = false; setRecordUI(false)
clearInterval(state.timerId); state.timerId = null
setStateText('录音失败', '')
setStateText('status.recordingFailed', '')
console.error('Recording error:', e)
})
})
@@ -602,7 +799,7 @@ async function boot() {
await loadConfig()
if (state.config?.web_demo?.enabled) await connectWebSocket()
} catch (e) {
setStateText('初始化失败', '')
setStateText('status.initFailed', '')
console.error('Boot error:', e)
}
}
+36 -29
View File
@@ -1,9 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SayIt</title>
<title>SayIt — Just say it, and write well</title>
<meta name="description" content="Open-source voice typing for Windows with local, cloud API, and self-hosted modes. Turn speech into polished text in any app.">
<meta property="og:title" content="SayIt — Just say it, and write well">
<meta property="og:description" content="Open-source voice typing for Windows with local, cloud API, and self-hosted modes.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sayitapp.site/">
<meta property="og:image" content="https://sayitapp.site/logo.png">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
@@ -23,46 +29,47 @@
<strong class="brand-name" id="brandName">SayIt</strong>
</div>
<div class="nav-actions">
<button class="lang-toggle" id="languageToggle" type="button" aria-label="切换到中文">中文</button>
<a class="nav-link" href="https://github.com/crosswk/SayIt" target="_blank" rel="noopener" id="navGithub" aria-label="GitHub">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</a>
<a class="btn-pill btn-black" id="navDlBtn" href="#download">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Download</span>
<span data-i18n="nav.download">Download</span>
</a>
</div>
</header>
<!-- Hero -->
<section class="hero">
<h1 id="headline">随口说,出色写。</h1>
<p class="lead" id="subheadline">说话比打字快 3 倍,AI 实时把口语变成可以直接用的书面表达。</p>
<h1 id="headline">Just say it, and write well</h1>
<p class="lead" id="subheadline">Turn speech into polished, ready-to-use text—three times faster than typing.</p>
</section>
<!-- Modes -->
<section class="modes">
<h2 class="section-heading">三种模式,按需选择</h2>
<h2 class="section-heading" data-i18n="modes.title">Three modes. Your choice.</h2>
<div class="mode-grid">
<div class="mode-card">
<div class="mode-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<strong>本地模式</strong>
<span>语音识别在本机运行,数据不出设备</span>
<strong data-i18n="modes.local.title">Local mode</strong>
<span data-i18n="modes.local.desc">Speech recognition runs on your device</span>
</div>
<div class="mode-card">
<div class="mode-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
</div>
<strong>云 API 模式</strong>
<span>无需 GPU,对接云端 ASR 和 AI</span>
<strong data-i18n="modes.cloud.title">Cloud API mode</strong>
<span data-i18n="modes.cloud.desc">Use your own ASR and AI provider keys</span>
</div>
<div class="mode-card">
<div class="mode-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<strong>服务器模式</strong>
<span>Docker 一键部署,GPU 加速推理</span>
<strong data-i18n="modes.server.title">Server mode</strong>
<span data-i18n="modes.server.desc">Connect to a public or self-hosted SayIt backend</span>
</div>
</div>
</section>
@@ -72,26 +79,26 @@
<div class="feature-row">
<div class="feature-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="9" y="1" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="23" x2="12" y2="19"/></svg>
<span>按住说话,松开即输入到任何应用</span>
<span data-i18n="features.anywhere">Hold to talk, release to type in any app</span>
</div>
<div class="feature-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 16l3-3m0 0l5-9 3 3-9 5-2 1 0 0z" stroke-linejoin="round"/></svg>
<span>AI 自动去除口头禅,输出书面表达</span>
<span data-i18n="features.cleanup">AI cleanup turns rough speech into clean writing</span>
</div>
<div class="feature-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linejoin="round"/></svg>
<span>说完即出稿,不打断工作流</span>
<span data-i18n="features.fast">Get polished text without breaking your flow</span>
</div>
<div class="feature-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke-linejoin="round"/></svg>
<span>Rust 构建,轻量安全</span>
<span data-i18n="features.rust">Lightweight and built with Rust</span>
</div>
</div>
</section>
<!-- Demo -->
<section class="demo-section" id="demo">
<h2 class="section-heading">在线体验</h2>
<h2 class="section-heading" data-i18n="demo.title">Try it online</h2>
<div class="card demo-card">
<div class="demo-inner">
<div class="orb-area">
@@ -117,15 +124,15 @@
</div>
</div>
<div class="demo-controls">
<button class="rec-btn" id="recordButton" type="button" aria-label="点击录音">
<button class="rec-btn" id="recordButton" type="button" aria-label="Start recording" data-i18n-aria="demo.startAria">
<svg id="micIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="1" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="23" x2="12" y2="19"/></svg>
<svg id="stopIcon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
</button>
<div class="demo-meta">
<div class="timer" id="timer">00:00</div>
<div class="state"><span id="stateText">准备就绪</span><span class="state-dot" id="stateDot"></span></div>
<div class="state"><span id="stateText">Ready</span><span class="state-dot" id="stateDot"></span></div>
</div>
<div class="rec-label" id="recordLabel">点击录音体验</div>
<div class="rec-label" id="recordLabel">Click to start</div>
</div>
</div>
</div>
@@ -135,12 +142,12 @@
<section class="playback-section hidden" id="playbackSection">
<div class="card playback-card">
<div class="playback-row">
<button class="play-btn" id="playBtn" type="button" aria-label="播放">
<button class="play-btn" id="playBtn" type="button" aria-label="Play" data-i18n-aria="playback.playAria">
<svg id="playIco" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="pauseIco" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="display:none"><line x1="6" y1="4" x2="6" y2="20"/><line x1="18" y1="4" x2="18" y2="20"/></svg>
</button>
<div class="playback-track">
<div class="playback-labels"><span>本次录音</span><span id="playTime">0:00 / 0:00</span></div>
<div class="playback-labels"><span data-i18n="playback.label">Recording</span><span id="playTime">0:00 / 0:00</span></div>
<div class="track-bg"><div class="track-fill" id="trackFill"></div><div class="track-thumb" id="trackThumb"></div></div>
<input type="range" class="track-input" id="trackInput" min="0" max="1" step="0.001" value="0">
</div>
@@ -151,7 +158,7 @@
<button class="rate-btn" data-rate="2">2x</button>
<button class="rate-btn" data-rate="2.5">2.5x</button>
</div>
<button class="icon-btn" id="dlAudioBtn" type="button" title="下载录音">
<button class="icon-btn" id="dlAudioBtn" type="button" title="Download recording" data-i18n-title="playback.download">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
@@ -162,8 +169,8 @@
<section class="result-section hidden" id="resultSection">
<div class="card result-card">
<div class="result-primary">
<div class="result-text" id="resultText">等待录音...</div>
<button class="icon-btn copy-btn" id="copyResult" type="button" data-tooltip="复制文本">
<div class="result-text" id="resultText">Waiting for a recording…</div>
<button class="icon-btn copy-btn" id="copyResult" type="button" data-tooltip="Copy text" data-i18n-tooltip="result.copy" aria-label="Copy text" data-i18n-aria="result.copy">
<svg class="ico-copy" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<svg class="ico-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button>
@@ -171,7 +178,7 @@
<div class="result-meta" id="resultMeta"></div>
<div class="result-details hidden" id="resultDetails">
<div class="result-detail-row">
<span class="detail-label">ASR 原文</span>
<span class="detail-label" data-i18n="result.rawAsr">Raw transcript</span>
<div class="detail-text" id="asrText"></div>
</div>
</div>
@@ -182,7 +189,7 @@
<div class="hidden" id="clearWrap" style="text-align:center">
<button class="btn-pill btn-white" id="clearBtn" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-11.36L1 10"/></svg>
重新录音
<span data-i18n="result.recordAgain">Record again</span>
</button>
</div>
@@ -192,8 +199,8 @@
<div class="oss-inner">
<img class="oss-logo" src="/logo-72.png" alt="SayIt" width="40" height="40">
<div class="oss-text">
<strong>开源 · 自托管 · 隐私优先</strong>
<span>AGPL-3.0 开源协议,Docker 一键部署,数据完全自控。</span>
<strong data-i18n="oss.title">Open source · Self-hosted · Privacy first</strong>
<span data-i18n="oss.desc">AGPL-3.0 licensed, Docker-ready, and fully under your control.</span>
</div>
<div class="oss-actions">
<a class="btn-pill btn-black" id="dlBtn" href="#download">
+18 -1
View File
@@ -34,6 +34,11 @@ button { font: inherit; border: none; background: none; cursor: pointer; }
.brand strong { font-size: 18px; font-weight: 500; color: var(--black); }
.brand-name { font-family: 'Nunito', sans-serif; font-weight: 800; font-size: 22px; color: var(--black); }
.nav-actions { display: flex; align-items: center; gap: 10px; }
.lang-toggle {
min-width: 44px; height: 36px; padding: 0 12px; border-radius: 9999px;
color: var(--stone); border: 1px solid var(--border); font-size: 13px;
}
.lang-toggle:hover { color: var(--black); border-color: var(--silver); }
.nav-link {
width: 36px; height: 36px; border-radius: 9999px; display: grid; place-items: center;
color: var(--stone); border: 1px solid var(--border);
@@ -170,7 +175,7 @@ h1 { font-size: clamp(36px, 6vw, 48px); line-height: 1.0; letter-spacing: -0.03e
.copy-btn.copied .ico-copy { display: none; }
.copy-btn.copied .ico-check { display: block; }
.copy-btn.copied { color: #22c55e; }
.copy-btn.copied::before { content: '已复制'; }
.copy-btn.copied::before { content: attr(data-tooltip); }
.rerun-btn::before { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); padding: 4px 8px; border-radius: 9999px; background: var(--black); color: var(--white); font-size: 11px; white-space: nowrap; opacity: 0; pointer-events: none; }
.rerun-btn:hover::before { opacity: 1; }
.rerun-btn.spinning svg { animation: spin 0.8s linear infinite; }
@@ -232,3 +237,15 @@ h1 { font-size: clamp(36px, 6vw, 48px); line-height: 1.0; letter-spacing: -0.03e
.oss-actions { justify-content: center; }
.footer-row { flex-direction: column; gap: 8px; }
}
@media (max-width: 420px) {
.shell { width: min(100% - 28px, 960px); }
.brand-icon { width: 40px; height: 40px; }
.nav-actions { gap: 6px; }
.nav-link { display: none; }
.btn-pill { padding-inline: 16px; }
.playback-row { flex-wrap: wrap; }
.playback-track { min-width: calc(100% - 52px); }
.rate-btns { margin-left: 50px; }
.oss-actions { flex-wrap: wrap; }
}