feat: 语音

This commit is contained in:
电摇小子
2026-08-05 09:45:59 +08:00
committed by Wxw-Gu
parent 69bc6f57e7
commit 7529a67f09
49 changed files with 3013 additions and 154 deletions
+12
View File
@@ -244,6 +244,18 @@ function App(): React.ReactElement {
const timer = window.setTimeout(() => setReportNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [reportNotice])
React.useEffect(() => {
const openVoiceRecognitionSettings = (): void => {
setSettingsCategory('voice-recognition')
setActivePage('settings')
}
window.addEventListener('wxe:open-voice-recognition-settings', openVoiceRecognitionSettings)
return () =>
window.removeEventListener(
'wxe:open-voice-recognition-settings',
openVoiceRecognitionSettings
)
}, [])
React.useEffect(() => {
void window.api.getSettings().then((result) => {
setAppearanceSettings({
+97 -27
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import type { JSX } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import type { JSX, MouseEvent as ReactMouseEvent } from 'react'
import type { VoiceMessageReference, VoiceModelStatus } from '../../../shared/voice-recognition'
interface VoicePlayerProps {
sessionId: string
@@ -24,8 +25,16 @@ export function VoicePlayer({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
const [modelStatus, setModelStatus] = useState<VoiceModelStatus | null>(null)
const [transcribing, setTranscribing] = useState(false)
const [transcript, setTranscript] = useState<string | null>(null)
const [transcriptError, setTranscriptError] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const objectUrlRef = useRef<string | null>(null)
const voiceReference = useMemo<VoiceMessageReference>(
() => ({ sessionId, localId, createTime, svrId }),
[createTime, localId, sessionId, svrId]
)
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
if (globalCurrentAudio && globalCurrentAudio !== audio) {
@@ -144,6 +153,46 @@ export function VoicePlayer({
}
}, [])
const handleTranscribe = useCallback(
async (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
setTranscriptError(null)
const status = await window.api.getVoiceModelStatus()
setModelStatus(status)
if (status.state !== 'ready') return
setTranscribing(true)
try {
const result = await window.api.recognizeVoice(voiceReference)
if (result.success) {
setTranscript(result.transcript?.trim() || '未识别出文字')
setModelStatus(null)
} else if (result.code !== 'CANCELLED') {
setTranscriptError(result.error || '语音识别失败')
}
} catch (recognitionError) {
console.warn('[VoicePlayer] recognition failed:', recognitionError)
setTranscriptError('语音识别失败,请重试')
} finally {
setTranscribing(false)
}
},
[voiceReference]
)
const handleCancelRecognition = useCallback(
async (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
await window.api.cancelVoiceRecognition(voiceReference)
},
[voiceReference]
)
const handleOpenVoiceSettings = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
window.dispatchEvent(new Event('wxe:open-voice-recognition-settings'))
}, [])
const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
@@ -151,33 +200,54 @@ export function VoicePlayer({
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (loading) {
return (
<div className="voice-message voice-loading">
<span className="voice-icon"></span>
<span className="voice-loading-text">...</span>
</div>
)
}
if (error && !audioUrl) {
return (
<div className="voice-message voice-error" onClick={handlePlayPause}>
<span className="voice-icon"></span>
<span className="voice-error-text"></span>
</div>
)
}
return (
<div className="voice-message" onClick={handlePlayPause}>
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
<div className="voice-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
<div className="voice-player">
<div
className={`voice-message ${loading ? 'voice-loading' : ''} ${error && !audioUrl ? 'voice-error' : ''}`}
onClick={handlePlayPause}
>
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
{loading ? (
<span className="voice-loading-text">...</span>
) : error && !audioUrl ? (
<span className="voice-error-text"></span>
) : (
<>
<div className="voice-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</div>
<span className="voice-duration">{formatDuration(audioDuration)}</span>
</>
)}
{transcribing ? (
<button className="voice-text-action" type="button" onClick={handleCancelRecognition}>
</button>
) : (
<button className="voice-text-action" type="button" onClick={handleTranscribe}>
{transcript ? '重新识别' : '转文字'}
</button>
)}
</div>
<span className="voice-duration">{formatDuration(audioDuration)}</span>
{modelStatus && modelStatus.state !== 'ready' && (
<div className="voice-model-panel" onClick={(event) => event.stopPropagation()}>
<span>
{modelStatus.state === 'downloading'
? `离线模型正在下载 ${Math.round(modelStatus.progress * 100)}%`
: modelStatus.state === 'unsupported'
? '当前系统暂不支持语音转文字'
: '请先在设置中准备离线语音模型'}
</span>
<button type="button" onClick={handleOpenVoiceSettings}>
</button>
</div>
)}
{transcribing && <div className="voice-transcript-status">...</div>}
{transcript && <div className="voice-transcript">{transcript}</div>}
{transcriptError && <div className="voice-transcript-error">{transcriptError}</div>}
</div>
)
}
@@ -16,6 +16,22 @@ export function ExportTaskCenter({
onToggle,
onCancel
}: ExportTaskCenterProps): React.ReactElement {
const [copiedJobId, setCopiedJobId] = React.useState('')
const copyTaskLog = async (task: ExportTaskRecord): Promise<void> => {
const log = [
'WechatExplorer 导出任务日志',
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
`会话:${task.contactName}`,
`格式:${task.format.toUpperCase()}`,
`状态:${task.progress.phase}`,
`进度:${task.progress.percent ?? 0}%`,
`错误:${task.progress.error || '未记录具体错误'}`
].join('\n')
await navigator.clipboard.writeText(log)
setCopiedJobId(task.jobId)
}
return (
<>
<button type="button" className="export-task-center-button" onClick={onToggle}>
@@ -37,6 +53,11 @@ export function ExportTaskCenter({
<small>
{task.format.toUpperCase()} · {task.progress.phase}
</small>
{task.progress.error && (
<small className="export-task-error" title={task.progress.error}>
{task.progress.error}
</small>
)}
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
@@ -47,6 +68,11 @@ export function ExportTaskCenter({
</button>
)}
{task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button>
)}
</div>
))
)}
@@ -16,6 +16,7 @@ import type {
GroupMemberName
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
export function ExportWorkspace({
contacts,
@@ -38,6 +39,8 @@ export function ExportWorkspace({
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
@@ -149,6 +152,17 @@ export function ExportWorkspace({
return () => window.clearTimeout(timer)
}, [activeContact])
React.useEffect(() => {
let active = true
void window.api
.getVoiceModelStatus()
.then((next) => active && setVoiceModelStatus(next))
.catch(() => undefined)
return () => {
active = false
}
}, [])
const toggleKind = (value: string): void => {
setSelectedKinds((current) => {
const next = new Set(current)
@@ -208,6 +222,12 @@ export function ExportWorkspace({
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeVoiceTranscripts:
includeVoiceTranscripts &&
includeMedia &&
format === 'html' &&
selectedKinds.has('voice') &&
voiceModelStatus?.state === 'ready',
preferOriginal,
fallbackThumbnail,
keepMissing,
@@ -484,6 +504,20 @@ export function ExportWorkspace({
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={includeVoiceTranscripts && voiceModelStatus?.state === 'ready'}
disabled={
!includeMedia ||
format !== 'html' ||
!selectedKinds.has('voice') ||
voiceModelStatus?.state !== 'ready'
}
onChange={(event) => setIncludeVoiceTranscripts(event.target.checked)}
/>
<span></span>
</label>
</div>
<p className="export-helper-text">
HTML CSVJSON Markdown
@@ -492,6 +526,10 @@ export function ExportWorkspace({
<span></span>
<span></span>
<span></span>
<span>
{voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}
</span>
<span></span>
<span></span>
</div>
@@ -11,6 +11,7 @@ import { AdvancedPage } from './pages/AdvancedPage'
import { CacheCleanupPage } from './pages/CacheCleanupPage'
import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -88,6 +89,8 @@ export function SettingsWorkspace({
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
case 'ai-model':
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
case 'voice-recognition':
return <VoiceRecognitionPage onNotice={onNotice} />
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
@@ -17,7 +17,8 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
{
label: '智能能力',
items: [
{ id: 'ai-model', label: 'AI 模型' },
{ id: 'voice-recognition', label: '语音转文字' },
{ id: 'ai-model', label: 'AI 模型' }
]
},
{
@@ -2,6 +2,7 @@ export type SettingsCategoryId =
| 'account-database'
| 'database-key'
| 'image-key'
| 'voice-recognition'
| 'ai-model'
| 'recall-protection'
| 'local-api'
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { VoiceModelStatus } from '../../../../../shared/voice-recognition'
const SENSEVOICE_URL = 'https://github.com/FunAudioLLM/SenseVoice'
const SHERPA_URL = 'https://github.com/k2-fsa/sherpa-onnx'
const STATUS_LABELS: Record<VoiceModelStatus['state'], string> = {
missing: '未下载',
downloading: '下载中',
ready: '已就绪',
invalid: '需要修复',
error: '下载失败',
unsupported: '暂不支持'
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatPlatform(status: VoiceModelStatus): string {
if (status.platform === 'win32')
return `Windows ${status.architecture === 'x64' ? '64 位' : status.architecture}`
if (status.platform === 'darwin') {
return status.architecture === 'arm64' ? 'macOS Apple 芯片' : 'macOS Intel'
}
return `${status.platform} ${status.architecture}`
}
export function VoiceRecognitionPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [status, setStatus] = useState<VoiceModelStatus | null>(null)
const [busy, setBusy] = useState(false)
const refresh = useCallback(async (): Promise<void> => {
setStatus(await window.api.getVoiceModelStatus())
}, [])
useEffect(() => {
let active = true
void window.api.getVoiceModelStatus().then((next) => active && setStatus(next))
const unsubscribe = window.api.onVoiceModelProgress((next) => {
if (active) setStatus(next)
})
return () => {
active = false
unsubscribe()
}
}, [])
const badgeClass = useMemo(() => {
if (status?.state === 'ready') return 'ready'
if (status?.state === 'downloading') return 'checking'
if (status?.state === 'invalid' || status?.state === 'error') return 'error'
if (status?.state === 'unsupported') return 'unavailable'
return 'warning'
}, [status?.state])
const download = async (): Promise<void> => {
setBusy(true)
setStatus((current) =>
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
)
try {
const result = await window.api.downloadVoiceModel()
setStatus(result.status)
onNotice(result.success ? '离线语音模型已准备好' : result.error || '模型下载失败')
} finally {
setBusy(false)
}
}
const cancelDownload = async (): Promise<void> => {
await window.api.cancelVoiceModelDownload()
onNotice('正在取消模型下载')
}
const removeModel = async (): Promise<void> => {
if (!window.confirm('删除离线语音模型?以后使用语音转文字时需要重新下载。')) return
setBusy(true)
try {
setStatus(await window.api.removeVoiceModel())
onNotice('离线语音模型已删除')
} catch (error) {
onNotice(error instanceof Error ? `模型删除失败:${error.message}` : '模型删除失败')
} finally {
setBusy(false)
}
}
const openDirectory = async (): Promise<void> => {
const result = await window.api.openVoiceModelDirectory()
if (!result.success) onNotice(result.error || '无法打开模型目录')
}
return (
<div className="settings-page voice-recognition-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p>线</p>
</div>
<div className="voice-header-status">
<span className={`settings-status-badge ${badgeClass}`}>
{status?.state === 'downloading'
? `下载中 ${Math.round(status.progress * 100)}%`
: status
? STATUS_LABELS[status.state]
: '检测中'}
</span>
{status?.state === 'downloading' && (
<progress value={status.progress} max={1} aria-label="顶部语音模型下载进度" />
)}
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content voice-recognition-content">
<section className="settings-privacy-notice">
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M12 3 5.5 5.7v5.2c0 4.3 2.7 8.2 6.5 10.1 3.8-1.9 6.5-5.8 6.5-10.1V5.7L12 3Z" />
</svg>
<div>
<strong></strong>
<p>线 AI </p>
</div>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card voice-runtime-card">
<dl>
<div>
<dt></dt>
<dd>{status ? formatPlatform(status) : '检测中...'}</dd>
</div>
<div>
<dt>线</dt>
<dd className={status?.supported ? 'voice-status-success' : 'voice-status-error'}>
{status?.supported ? '支持' : '暂不支持'}
</dd>
</div>
<div>
<dt></dt>
<dd>sherpa-onnx · SenseVoice</dd>
</div>
</dl>
</section>
<h2 className="settings-section-heading">线</h2>
<section className="settings-card voice-model-card">
<div className="voice-model-summary">
<span className="settings-card-kicker">SenseVoice Small INT8</span>
<strong>
{status?.state === 'downloading'
? `正在下载 ${Math.round(status.progress * 100)}%`
: status
? STATUS_LABELS[status.state]
: '正在检测'}
</strong>
<small>
{status
? `版本 ${status.version} · ${formatBytes(status.totalBytes)}`
: '读取模型状态...'}
</small>
{status?.error && <p className="voice-model-error">{status.error}</p>}
<p className="voice-model-license">
<a href={SENSEVOICE_URL} target="_blank" rel="noreferrer">
SenseVoiceMIT
</a>
<span> · </span>
<a href={SHERPA_URL} target="_blank" rel="noreferrer">
sherpa-onnxApache-2.0
</a>
</p>
</div>
<div className="voice-model-actions">
{status?.state === 'downloading' ? (
<button type="button" onClick={() => void cancelDownload()}>
</button>
) : status?.state === 'ready' ? (
<>
<button type="button" onClick={() => void openDirectory()}>
</button>
<button
type="button"
className="settings-danger-button"
disabled={busy}
onClick={() => void removeModel()}
>
</button>
</>
) : (
<button
type="button"
className="settings-primary-button"
disabled={busy || !status?.supported}
onClick={() => void download()}
>
{status?.state === 'invalid' || status?.state === 'error'
? '重新下载模型'
: '下载模型'}
</button>
)}
<button type="button" disabled={busy} onClick={() => void refresh()}>
</button>
</div>
{status?.state === 'downloading' && (
<div className="voice-model-progress">
<div>
<span>{Math.round(status.progress * 100)}%</span>
<small>
{formatBytes(status.downloadedBytes)} / {formatBytes(status.totalBytes)}
</small>
</div>
<progress value={status.progress} max={1} aria-label="语音模型下载进度" />
</div>
)}
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card voice-platform-list">
<div>
<strong>Windows</strong>
<span> Windows 10/11 64 </span>
</div>
<div>
<strong>macOS</strong>
<span> Intel Apple </span>
</div>
</section>
<p className="settings-footnote">
</p>
</div>
</div>
</div>
)
}
+4
View File
@@ -764,6 +764,10 @@
color: var(--wxex-text-primary);
}
.export-task-error {
color: var(--wxex-danger, #b42318);
}
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;
+57
View File
@@ -1,9 +1,66 @@
/* Voice Player */
.voice-player {
display: grid;
min-width: 190px;
gap: 7px;
}
.voice-message {
cursor: pointer;
user-select: none;
}
.voice-text-action,
.voice-model-panel button {
border: 0;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
font-size: 12px;
}
.voice-text-action {
margin-left: auto;
padding: 2px 0 2px 8px;
}
.voice-model-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px 10px;
padding-top: 7px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
white-space: normal;
}
.voice-model-panel progress {
width: 100%;
height: 5px;
}
.voice-transcript,
.voice-transcript-status,
.voice-transcript-error {
padding-top: 7px;
border-top: 1px solid var(--wxex-border);
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
word-break: break-word;
}
.voice-transcript-status {
color: var(--wxex-text-muted);
}
.voice-transcript-error {
color: var(--wxex-danger, #c63c3c);
}
.voice-loading {
opacity: 0.6;
}
@@ -118,6 +118,185 @@
}
}
.voice-runtime-card dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px 32px;
margin: 0;
div {
min-width: 0;
}
dt {
color: var(--wxex-text-muted);
font-size: 11px;
}
dd {
margin: 5px 0 0;
color: var(--wxex-text-primary);
font-size: 13px;
}
}
.voice-header-status {
display: grid;
min-width: 132px;
justify-items: end;
gap: 7px;
progress {
width: 112px;
height: 6px;
accent-color: var(--wxex-brand);
}
}
.voice-status-success {
color: var(--wxex-success, #2e8b68) !important;
}
.voice-status-error,
.voice-model-error {
color: var(--wxex-danger, #c85a5a) !important;
}
.voice-model-license {
margin: 9px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 17px;
a {
color: var(--wxex-brand);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.voice-model-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px 28px;
align-items: center;
}
.voice-model-summary {
min-width: 0;
strong,
small {
display: block;
}
strong {
color: var(--wxex-text-primary);
font-size: 18px;
}
small {
margin-top: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
}
}
.voice-model-error {
margin: 8px 0 0;
font-size: 12px;
line-height: 18px;
}
.voice-model-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
> button {
min-height: 34px;
padding: 7px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
> .settings-primary-button {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
.voice-model-progress {
grid-column: 1 / -1;
display: grid;
gap: 8px;
> div {
display: flex;
justify-content: space-between;
color: var(--wxex-text-secondary);
font-size: 12px;
}
progress {
width: 100%;
height: 7px;
accent-color: var(--wxex-brand);
}
}
.voice-platform-list {
padding-top: 8px;
padding-bottom: 8px;
> div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--wxex-border);
&:last-child {
border-bottom: 0;
}
}
strong {
color: var(--wxex-text-primary);
font-size: 13px;
}
span {
color: var(--wxex-text-secondary);
font-size: 12px;
}
}
@media (max-width: 760px) {
.voice-runtime-card dl,
.voice-model-card {
grid-template-columns: 1fr;
}
.voice-model-actions {
justify-content: flex-start;
}
}
.settings-option-card {
padding: 12px;
}
@@ -308,7 +487,6 @@
--wxex-nav-width: 68px;
--wxex-shell-content-top: 8px;
}
}
.boot-splash.is-quiet {