初始提交:Windows 跨平台语音识别应用
功能: - Tauri v2 GUI 应用 - 系统托盘支持 - 日志输出到文件 - 带时间戳的版本号 - 前端资源嵌入 修复: - 前端路径使用相对路径 - 移除 devUrl 配置 - 窗口置顶设置
This commit is contained in:
commit
ceb2df18c4
119
.github/workflows/build-windows.yml
vendored
Normal file
119
.github/workflows/build-windows.yml
vendored
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
name: Build Windows GUI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'web/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'tauri.conf.json'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-C target-feature=+crt-static"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build Windows GUI
|
||||||
|
run: cargo build --release --features gui --verbose
|
||||||
|
|
||||||
|
- name: Build CLI tool
|
||||||
|
run: cargo build --release --verbose
|
||||||
|
|
||||||
|
- name: Verify build artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== Build Artifacts ==="
|
||||||
|
dir target\release\*.exe
|
||||||
|
echo "=== GUI Size ==="
|
||||||
|
(Get-Item target\release\impress_asr_gui.exe).Length / 1MB
|
||||||
|
echo "=== CLI Size ==="
|
||||||
|
(Get-Item target\release\impress_asr.exe).Length / 1MB
|
||||||
|
|
||||||
|
- name: Create distribution package
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path dist
|
||||||
|
Copy-Item target\release\impress_asr_gui.exe dist/
|
||||||
|
Copy-Item target\release\impress_asr.exe dist/
|
||||||
|
Copy-Item README.md dist/
|
||||||
|
Copy-Item LICENSE dist/
|
||||||
|
New-Item -ItemType Directory -Force -Path dist/models
|
||||||
|
echo "Please download ONNX models to this directory" > dist/models/README.txt
|
||||||
|
echo "Model download links:" >> dist/models/README.txt
|
||||||
|
echo "- SenseVoice Small: https://huggingface.co/FunAudioLLM/SenseVoiceSmall/resolve/main/model.onnx" >> dist/models/README.txt
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-input-rust-windows-x64
|
||||||
|
path: dist/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# Optional: Create release if tagged
|
||||||
|
release:
|
||||||
|
needs: build-windows
|
||||||
|
runs-on: windows-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-input-rust-windows-x64
|
||||||
|
path: ./dist
|
||||||
|
|
||||||
|
- name: Create ZIP archive
|
||||||
|
run: |
|
||||||
|
$zipName = "impress-asr-input-rust-${{ github.ref_name }}-windows-x64.zip"
|
||||||
|
Compress-Archive -Path dist\* -DestinationPath $zipName
|
||||||
|
echo "ZIP_FILE=$zipName" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: ${{ env.ZIP_FILE }}
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: true
|
||||||
182
.github/workflows/release-all.yml
vendored
Normal file
182
.github/workflows/release-all.yml
vendored
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
name: Build All Platforms
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Windows GUI Build
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd web && npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd web && npm run build
|
||||||
|
|
||||||
|
- name: Build GUI
|
||||||
|
run: cargo build --release --features gui
|
||||||
|
|
||||||
|
- name: Create package
|
||||||
|
run: |
|
||||||
|
mkdir dist
|
||||||
|
cp target/release/impress_asr_gui.exe dist/
|
||||||
|
cp target/release/impress_asr.exe dist/
|
||||||
|
cp README.md dist/
|
||||||
|
cp LICENSE dist/
|
||||||
|
mkdir dist/models
|
||||||
|
echo "Download models from: https://huggingface.co/FunAudioLLM/SenseVoiceSmall" > dist/models/README.txt
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-windows-x64
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
# Linux GUI Build (requires system dependencies)
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libssl-dev
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd web && npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd web && npm run build
|
||||||
|
|
||||||
|
- name: Build GUI
|
||||||
|
run: cargo build --release --features gui
|
||||||
|
|
||||||
|
- name: Create AppImage (optional)
|
||||||
|
run: |
|
||||||
|
# AppImage build requires additional setup
|
||||||
|
echo "AppImage build skipped - configure with tauri-apps/tauri-action"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-linux-x64
|
||||||
|
path: target/release/impress_asr_gui
|
||||||
|
|
||||||
|
# macOS GUI Build
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd web && npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd web && npm run build
|
||||||
|
|
||||||
|
- name: Build GUI
|
||||||
|
run: cargo build --release --features gui
|
||||||
|
|
||||||
|
- name: Create DMG (optional)
|
||||||
|
run: |
|
||||||
|
echo "DMG build skipped - configure with tauri-apps/tauri-action"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-macos-x64
|
||||||
|
path: target/release/impress_asr_gui
|
||||||
|
|
||||||
|
# Create Release (only when tagged)
|
||||||
|
create-release:
|
||||||
|
needs: [build-windows, build-linux, build-macos]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download Windows artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-windows-x64
|
||||||
|
path: ./release/windows
|
||||||
|
|
||||||
|
- name: Download Linux artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-linux-x64
|
||||||
|
path: ./release/linux
|
||||||
|
|
||||||
|
- name: Download macOS artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-macos-x64
|
||||||
|
path: ./release/macos
|
||||||
|
|
||||||
|
- name: Create ZIP archives
|
||||||
|
run: |
|
||||||
|
cd release
|
||||||
|
zip -r impress-asr-${{ github.ref_name }}-windows-x64.zip windows/
|
||||||
|
zip -r impress-asr-${{ github.ref_name }}-linux-x64.zip linux/
|
||||||
|
zip -r impress-asr-${{ github.ref_name }}-macos-x64.zip macos/
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: release/*.zip
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: true
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/target/
|
||||||
|
*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 模型文件 (大文件,建议单独下载)
|
||||||
|
models/*.onnx
|
||||||
|
models/*.ort
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# 测试数据
|
||||||
|
test_recordings/
|
||||||
|
test_outputs/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/.env.local
|
||||||
|
|
||||||
|
# 配置文件 (可能包含敏感信息)
|
||||||
|
config.user.toml
|
||||||
245
BUILD.md
Normal file
245
BUILD.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# impress_asr_input_rust 构建指南
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
本项目是一款高性能跨平台桌面语音识别输入工具,包含:
|
||||||
|
- **CLI 工具** (`impress_asr`): 命令行语音识别工具
|
||||||
|
- **GUI 应用** (`impress_asr_gui`): 基于 Tauri 的图形界面应用(需要额外依赖)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 环境要求
|
||||||
|
|
||||||
|
- Rust 1.75+
|
||||||
|
- Node.js 18+ (用于前端构建)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### 2. 构建 CLI 版本(无系统依赖)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
cd impress_asr_input_rust
|
||||||
|
|
||||||
|
# 构建前端资源
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 构建 CLI 工具
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./target/release/impress_asr --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建 GUI 版本(需要系统依赖)
|
||||||
|
|
||||||
|
#### Linux (Ubuntu/Debian)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装系统依赖
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
libssl-dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
cargo build --release --features gui
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./target/release/impress_asr_gui
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Xcode 命令行工具
|
||||||
|
xcode-select --install
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
cargo build --release --features gui
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Visual Studio Build Tools 2019+
|
||||||
|
# 确保安装 "Desktop development with C++"
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
cargo build --release --features gui
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下载模型
|
||||||
|
|
||||||
|
语音识别需要 ONNX 模型文件。以下是推荐的模型来源:
|
||||||
|
|
||||||
|
### SenseVoice Small(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 ModelScope 下载
|
||||||
|
wget -O models/sensevoice-small.onnx \
|
||||||
|
"https://modelscope.cn/api/v1/models/iic/SenseVoiceSmall/file?FilePath=model.onnx"
|
||||||
|
|
||||||
|
# 或从 HuggingFace 下载
|
||||||
|
wget -O models/sensevoice-small.onnx \
|
||||||
|
"https://huggingface.co/FunAudioLLM/SenseVoiceSmall/resolve/main/model.onnx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### FunASR Paraformer(中文优化)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget -O models/paraformer.onnx \
|
||||||
|
"https://modelscope.cn/api/v1/models/iic/paraformer-zh/file?FilePath=model.onnx"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### CLI 工具
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出音频设备
|
||||||
|
./target/release/impress_asr devices
|
||||||
|
|
||||||
|
# 识别音频文件
|
||||||
|
./target/release/impress_asr recognize audio.wav
|
||||||
|
|
||||||
|
# 下载模型
|
||||||
|
./target/release/impress_asr download --name sensevoice-small
|
||||||
|
|
||||||
|
# 录制音频(需要 cpal 库支持)
|
||||||
|
./target/release/impress_asr record -o output.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### GUI 应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动应用
|
||||||
|
./target/release/impress_asr_gui
|
||||||
|
|
||||||
|
# 或使用系统托盘
|
||||||
|
# 应用启动后会在系统托盘显示图标
|
||||||
|
# 右键点击可访问常用功能
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
impress_asr_input_rust/
|
||||||
|
├── src/
|
||||||
|
│ ├── bin/
|
||||||
|
│ │ └── cli.rs # CLI 入口
|
||||||
|
│ ├── app/ # Tauri 应用层 (GUI)
|
||||||
|
│ ├── audio/ # 音频处理
|
||||||
|
│ ├── asr/ # 语音识别核心
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── tray/ # 系统托盘 (GUI)
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── main.rs # GUI 入口
|
||||||
|
│ └── lib.rs # 库导出
|
||||||
|
├── web/ # React 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── RecordPage.tsx
|
||||||
|
│ │ │ ├── FileConvertPage.tsx
|
||||||
|
│ │ │ └── SettingsPage.tsx
|
||||||
|
│ └── dist/ # 构建产物
|
||||||
|
├── models/ # ONNX 模型目录
|
||||||
|
└── resources/ # 资源文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性说明
|
||||||
|
|
||||||
|
### 编译特性
|
||||||
|
|
||||||
|
| 特性 | 描述 | 依赖 |
|
||||||
|
|------|------|------|
|
||||||
|
| `gui` | 启用图形界面 | Tauri, global-hotkey, 系统库 |
|
||||||
|
| `onnx` | 启用 ONNX 推理 | onnxruntime-ng |
|
||||||
|
|
||||||
|
### 默认构建
|
||||||
|
|
||||||
|
默认构建仅包含 CLI 工具,不依赖任何系统库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整功能构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --features gui,onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 构建错误:找不到 pkg-config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install pkg-config
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install pkg-config
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建错误:ALSA 未找到
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install libasound2-dev
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S alsa-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行时错误:模型文件不存在
|
||||||
|
|
||||||
|
确保模型文件已下载到正确位置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la models/sensevoice-small.onnx
|
||||||
|
|
||||||
|
# 如果不存在,下载模型
|
||||||
|
./target/release/impress_asr download
|
||||||
|
```
|
||||||
|
|
||||||
|
### GUI 无法启动
|
||||||
|
|
||||||
|
1. 确认已安装所有系统依赖
|
||||||
|
2. 检查 `tauri.conf.json` 配置
|
||||||
|
3. 确保前端已构建:`cd web && npm run build`
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 发布构建优化
|
||||||
|
|
||||||
|
项目已配置以下优化:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.release]
|
||||||
|
lto = true # 链接时优化
|
||||||
|
codegen-units = 1 # 单一代码单元,更好优化
|
||||||
|
opt-level = 3 # 最高优化级别
|
||||||
|
strip = true # 移除调试符号
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模型优化
|
||||||
|
|
||||||
|
1. **量化模型**: 使用 INT8 量化模型可减少 50-75% 大小
|
||||||
|
2. **模型选择**: SenseVoice Small 在速度和精度间取得良好平衡
|
||||||
|
3. **GPU 加速**: 启用 CUDA 或 DirectML 后端(需要 `onnx` 特性)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
268
BUILD_WINDOWS.md
Normal file
268
BUILD_WINDOWS.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# Windows 版本构建指南
|
||||||
|
|
||||||
|
由于交叉编译需要特定的系统依赖,建议在 Windows 原生环境或使用 Windows CI/CD 服务构建。
|
||||||
|
|
||||||
|
## 方法一:Windows 原生构建(推荐)
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
|
||||||
|
在 Windows 机器上安装以下工具:
|
||||||
|
|
||||||
|
1. **Rust** (https://rustup.rs/)
|
||||||
|
```powershell
|
||||||
|
winget install Rustlang.Rustup
|
||||||
|
# 或下载 rustup-init.exe 运行
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Node.js 18+** (https://nodejs.org/)
|
||||||
|
```powershell
|
||||||
|
winget install OpenJS.NodeJS.LTS
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Visual Studio Build Tools 2022**
|
||||||
|
```powershell
|
||||||
|
winget install Microsoft.VisualStudio.2022.BuildTools
|
||||||
|
```
|
||||||
|
安装时勾选 "使用 C++ 的桌面开发"
|
||||||
|
|
||||||
|
### 2. 构建步骤
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/your-username/impress_asr_input_rust.git
|
||||||
|
cd impress_asr_input_rust
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 构建 Windows GUI 版本
|
||||||
|
cargo build --release --features gui
|
||||||
|
|
||||||
|
# 构建产物位置
|
||||||
|
# target/release/impress_asr_gui.exe
|
||||||
|
# target/release/impress_asr.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 方法二:使用 cargo-xwin(需要 Linux/macOS)
|
||||||
|
|
||||||
|
如果你没有 Windows 机器,可以使用 `cargo-xwin` 在 Linux/macOS 上交叉编译:
|
||||||
|
|
||||||
|
### 1. 安装 cargo-xwin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首先安装 zig (用于交叉编译)
|
||||||
|
curl -L https://ziglang.org/download/0.11.0/zig-linux-x86_64-0.11.0.tar.xz | tar -xJ
|
||||||
|
sudo mv zig-linux-x86_64-0.11.0 /opt/zig
|
||||||
|
sudo ln -s /opt/zig/zig /usr/local/bin/zig
|
||||||
|
|
||||||
|
# 安装 cargo-xwin
|
||||||
|
cargo install cargo-xwin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建 Windows x64 GUI 版本
|
||||||
|
cargo xwin build --release --features gui --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
# 构建产物位置
|
||||||
|
# target/x86_64-pc-windows-msvc/release/impress_asr_gui.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 方法三:GitHub Actions CI/CD
|
||||||
|
|
||||||
|
项目已配置 GitHub Actions 工作流,可以自动构建 Windows 版本。
|
||||||
|
|
||||||
|
### 创建工作流文件
|
||||||
|
|
||||||
|
`.github/workflows/build-windows.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-C target-feature=+crt-static"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build GUI
|
||||||
|
run: cargo build --release --features gui
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: impress-asr-gui-windows
|
||||||
|
path: target/release/impress_asr_gui.exe
|
||||||
|
|
||||||
|
- name: Create Release Package
|
||||||
|
run: |
|
||||||
|
mkdir release
|
||||||
|
cp target/release/impress_asr_gui.exe release/
|
||||||
|
cp target/release/impress_asr.exe release/
|
||||||
|
cp README.md release/
|
||||||
|
cp LICENSE release/
|
||||||
|
mkdir release/models
|
||||||
|
echo "将模型文件放入此目录" > release/models/README.txt
|
||||||
|
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: release/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 方法四:使用 Docker
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile.windows
|
||||||
|
FROM messense/rust-crossbuild:windows-x86_64
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cd web && npm install && npm run build
|
||||||
|
RUN cargo build --release --features gui
|
||||||
|
|
||||||
|
CMD ["cargo", "build", "--release", "--features", "gui"]
|
||||||
|
```
|
||||||
|
|
||||||
|
构建并提取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.windows -t impress-asr-windows .
|
||||||
|
docker run --rm -v $(pwd)/output:/output impress-asr-windows \
|
||||||
|
cp target/release/impress_asr_gui.exe /output/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows 安装包制作(可选)
|
||||||
|
|
||||||
|
使用 Inno Setup 创建安装程序:
|
||||||
|
|
||||||
|
### 1. 安装 Inno Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install JRSoftware.InnoSetup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建安装脚本 `installer.iss`
|
||||||
|
|
||||||
|
```iss
|
||||||
|
#define MyAppName "impress ASR Input"
|
||||||
|
#define MyAppVersion "0.1.0"
|
||||||
|
#define MyAppPublisher "Your Name"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{YOUR-APP-ID}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DefaultGroupName={#MyAppName}
|
||||||
|
OutputDir=installer
|
||||||
|
OutputBaseFilename={#MyAppName}-setup-{#MyAppVersion}
|
||||||
|
SetupIconFile=resources\icons\icon.ico
|
||||||
|
Compression=lzma2
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "target\release\impress_asr_gui.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "target\release\impress_asr.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "README.md"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "models\*"; DestDir: "{app}\models"; Flags: recursesubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\{#MyAppName}"; Filename: "{app}\impress_asr_gui.exe"
|
||||||
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\impress_asr_gui.exe"
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\impress_asr_gui.exe"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 编译安装程序
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建产物
|
||||||
|
|
||||||
|
成功构建后,你将得到:
|
||||||
|
|
||||||
|
- `impress_asr_gui.exe` - GUI 应用程序
|
||||||
|
- `impress_asr.exe` - 命令行工具
|
||||||
|
|
||||||
|
### 分发
|
||||||
|
|
||||||
|
将以下文件打包分发给用户:
|
||||||
|
|
||||||
|
```
|
||||||
|
impress_asr_input_rust-v0.1.0-windows-x64/
|
||||||
|
├── impress_asr_gui.exe
|
||||||
|
├── impress_asr.exe
|
||||||
|
├── models/
|
||||||
|
│ └── sensevoice-small.onnx (需要单独下载)
|
||||||
|
├── README.md
|
||||||
|
└── LICENSE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 构建时提示找不到 Windows SDK?
|
||||||
|
|
||||||
|
A: 确保安装了 Visual Studio Build Tools,并在安装时选择了 "使用 C++ 的桌面开发"。
|
||||||
|
|
||||||
|
### Q: 链接错误 LNK1181?
|
||||||
|
|
||||||
|
A: 确保以管理员身份运行 Developer Command Prompt for VS 2022,或正确配置了环境变量。
|
||||||
|
|
||||||
|
### Q: 程序启动后闪退?
|
||||||
|
|
||||||
|
A: 检查是否缺少 Microsoft Visual C++ Redistributable,下载安装:
|
||||||
|
https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||||
|
|
||||||
|
### Q: 如何静默安装?
|
||||||
|
|
||||||
|
A: 使用 Inno Setup 安装程序时添加参数:
|
||||||
|
```
|
||||||
|
/impress-asr-setup-0.1.0.exe /VERYSILENT /NORESTART
|
||||||
|
```
|
||||||
99
Cargo.toml
Normal file
99
Cargo.toml
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
[package]
|
||||||
|
name = "impress_asr_input_rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "高性能跨平台桌面语音识别输入工具"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-username/impress_asr_input_rust"
|
||||||
|
keywords = ["asr", "speech-to-text", "onnx", "audio"]
|
||||||
|
categories = ["multimedia::audio"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
gui = ["dep:tauri", "dep:tauri-plugin-shell", "dep:tauri-plugin-dialog", "dep:tauri-plugin-fs", "dep:global-hotkey", "dep:tauri-build", "dep:cfg_aliases"]
|
||||||
|
onnx = ["dep:onnxruntime-ng"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Tauri v2 桌面应用框架 (可选,需要 `cargo build --features gui`)
|
||||||
|
tauri = { version = "2", features = ["tray-icon"], optional = true }
|
||||||
|
tauri-plugin-shell = { version = "2", optional = true }
|
||||||
|
tauri-plugin-dialog = { version = "2", optional = true }
|
||||||
|
tauri-plugin-fs = { version = "2", optional = true }
|
||||||
|
|
||||||
|
# 全局快捷键
|
||||||
|
global-hotkey = { version = "0.6", optional = true }
|
||||||
|
|
||||||
|
# ONNX Runtime - 语音识别核心 (可选)
|
||||||
|
onnxruntime-ng = { version = "1.16.1", optional = true, features = ["disable-sys-build-script"] }
|
||||||
|
|
||||||
|
# 音频处理
|
||||||
|
hound = "3.5" # WAV 文件读写
|
||||||
|
|
||||||
|
# 张量处理
|
||||||
|
ndarray = "0.15"
|
||||||
|
|
||||||
|
# 异步运行时
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# 序列化/配置
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# 错误处理
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
|
||||||
|
# 其他工具
|
||||||
|
dirs = "5" # 跨平台目录路径
|
||||||
|
once_cell = "1" # 懒加载
|
||||||
|
parking_lot = "0.12" # 高级锁原语
|
||||||
|
uuid = { version = "1", features = ["v4"] } # 唯一 ID
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# 命令行解析
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [], optional = true }
|
||||||
|
cfg_aliases = { version = "0.2", optional = true }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "impress_asr"
|
||||||
|
path = "src/bin/cli.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "impress_asr_gui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["gui"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "impress_asr_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# 性能优化配置
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
# 开发环境优化
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
# 平台特定配置
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.58", features = ["Win32_Media_Audio"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
coreaudio-sys = "0.2"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 impress_asr_input_rust
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
484
PRD.md
Normal file
484
PRD.md
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
# 项目需求文档 (PRD)
|
||||||
|
|
||||||
|
## impress_asr_input_rust
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 项目名称
|
||||||
|
**impress_asr_input_rust**
|
||||||
|
|
||||||
|
### 1.2 项目定位
|
||||||
|
一款高性能、跨平台的桌面语音识别输入工具,采用纯 Rust 技术栈实现,提供低延迟的语音转文字体验。
|
||||||
|
|
||||||
|
### 1.3 核心价值主张
|
||||||
|
- 🚀 **极速识别**:1 秒内完成语音识别响应
|
||||||
|
- 🦀 **纯 Rust 实现**:高性能、内存安全、无外部依赖
|
||||||
|
- 🖥️ **全平台支持**:Windows、Linux、macOS 统一体验
|
||||||
|
- 🎯 **用户友好**:完整的图形界面和系统托盘集成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 功能需求
|
||||||
|
|
||||||
|
### 2.1 核心功能
|
||||||
|
|
||||||
|
#### 2.1.1 语音录制与识别
|
||||||
|
| 功能点 | 描述 | 优先级 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 一键录音 | 点击按钮即可开始/停止录音 | P0 |
|
||||||
|
| 实时波形显示 | 录音时显示音频波形可视化 | P1 |
|
||||||
|
| 自动识别 | 录音停止后自动触发识别 | P0 |
|
||||||
|
| 识别结果显示 | 实时展示识别出的文字 | P0 |
|
||||||
|
| 结果复制 | 一键复制识别结果到剪贴板 | P1 |
|
||||||
|
| 历史记录 | 保存识别历史记录 | P2 |
|
||||||
|
|
||||||
|
#### 2.1.2 音频文件识别
|
||||||
|
| 功能点 | 描述 | 优先级 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 文件选择 | 支持拖拽和文件选择器导入音频 | P0 |
|
||||||
|
| 格式支持 | 支持 WAV、MP3、FLAC、OGG 等格式 | P0 |
|
||||||
|
| 批量处理 | 支持批量导入多个音频文件 | P2 |
|
||||||
|
| 进度显示 | 显示识别进度和预计剩余时间 | P1 |
|
||||||
|
| 导出结果 | 支持导出为 TXT、SRT 字幕格式 | P1 |
|
||||||
|
|
||||||
|
#### 2.1.3 配置管理
|
||||||
|
| 功能点 | 描述 | 优先级 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 模型选择 | 选择/切换 ONNX 语音识别模型 | P0 |
|
||||||
|
| 语言设置 | 设置识别语言(中文、英文等) | P0 |
|
||||||
|
| 麦克风选择 | 选择输入音频设备 | P0 |
|
||||||
|
| 快捷键配置 | 自定义全局快捷键 | P1 |
|
||||||
|
| 主题设置 | 深色/浅色主题切换 | P2 |
|
||||||
|
|
||||||
|
### 2.2 系统托盘功能
|
||||||
|
| 功能点 | 描述 | 优先级 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 托盘图标 | 显示应用状态图标 | P0 |
|
||||||
|
| 快速菜单 | 右键菜单提供常用功能 | P0 |
|
||||||
|
| 状态指示 | 显示录音/识别状态 | P1 |
|
||||||
|
| 全局快捷键 | 后台监听录音快捷键 | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术架构
|
||||||
|
|
||||||
|
### 3.1 技术栈选型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 用户界面层 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Tauri v2 + React/TypeScript │ │
|
||||||
|
│ │ (或纯 Rust: Iced/Slint) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 应用逻辑层 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Rust 业务逻辑层 │ │
|
||||||
|
│ │ - 状态管理 - 配置管理 - 任务调度 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 核心服务层 │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ 音频服务 │ │ 识别服务 │ │ 系统服务 │ │
|
||||||
|
│ │ cpal/rodio │ │ ort/onnxruntime │ tray/icon │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 模型层 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ONNX 模型 (SenseVoice/FunASR 等) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 核心依赖清单
|
||||||
|
|
||||||
|
| 类别 | Crate | 用途 |
|
||||||
|
|------|-------|------|
|
||||||
|
| **UI 框架** | `tauri` v2 | 跨平台桌面应用框架 |
|
||||||
|
| **UI 框架 (备选)** | `iced` / `slint` | 纯 Rust GUI 方案 |
|
||||||
|
| **音频输入** | `cpal` | 跨平台音频输入/输出 |
|
||||||
|
| **音频解码** | `rodio` | 音频文件解码播放 |
|
||||||
|
| **音频处理** | `hound` | WAV 文件读写 |
|
||||||
|
| **ONNX 推理** | `ort` / `onnxruntime` | ONNX 模型推理引擎 |
|
||||||
|
| **张量处理** | `ndarray` | 多维数组运算 |
|
||||||
|
| **系统托盘** | `tauri-plugin-tray` | 系统托盘支持 |
|
||||||
|
| **快捷键** | `global-hotkey` | 全局快捷键监听 |
|
||||||
|
| **配置管理** | `serde` + `toml` | 配置文件序列化 |
|
||||||
|
| **日志** | `tracing` + `tracing-subscriber` | 结构化日志 |
|
||||||
|
| **异步运行时** | `tokio` | 异步任务执行 |
|
||||||
|
|
||||||
|
### 3.3 推荐的 ONNX 模型
|
||||||
|
|
||||||
|
| 模型 | 语言支持 | 大小 | 特点 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| **SenseVoice** | 中/英/日/韩 | ~100MB | 阿里达摩院,高精度,支持情感识别 |
|
||||||
|
| **FunASR Paraformer** | 中/英 | ~60MB | 阿里,流式识别,低延迟 |
|
||||||
|
| **Whisper (ONNX 版)** | 多语言 | ~40MB-1GB | OpenAI,多语言支持好 |
|
||||||
|
|
||||||
|
**推荐首选**: SenseVoice Small 版本,在速度和精度间取得良好平衡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 性能要求
|
||||||
|
|
||||||
|
### 4.1 延迟指标
|
||||||
|
|
||||||
|
| 场景 | 目标 | 可接受上限 |
|
||||||
|
|------|------|------------|
|
||||||
|
| 录音启动延迟 | < 100ms | < 200ms |
|
||||||
|
| 识别响应延迟 (5 秒音频) | < 500ms | < 1s |
|
||||||
|
| 识别响应延迟 (30 秒音频) | < 2s | < 5s |
|
||||||
|
| 界面响应帧率 | 60 FPS | > 30 FPS |
|
||||||
|
| 应用启动时间 | < 2s | < 5s |
|
||||||
|
|
||||||
|
### 4.2 资源占用
|
||||||
|
|
||||||
|
| 指标 | 空闲状态 | 录音状态 | 识别状态 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 内存占用 | < 100MB | < 150MB | < 500MB |
|
||||||
|
| CPU 占用 | < 1% | < 5% | < 30% |
|
||||||
|
|
||||||
|
### 4.3 优化策略
|
||||||
|
|
||||||
|
1. **模型量化**: 使用 INT8 量化模型减少推理时间
|
||||||
|
2. **模型预加载**: 应用启动时预加载模型到内存
|
||||||
|
3. **流式识别**: 支持边录音边识别,减少等待时间
|
||||||
|
4. **音频分片**: 长音频分段并行处理
|
||||||
|
5. **GPU 加速**: 支持 CUDA/DirectML 后端加速
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 用户界面设计
|
||||||
|
|
||||||
|
### 5.1 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ impress_asr_input_rust □ ○ × │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────┬─────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ 侧边 │ 主内容区域 │ │
|
||||||
|
│ │ 导航 │ │ │
|
||||||
|
│ │ │ ┌─────────────────────────────────┐ │ │
|
||||||
|
│ │ 🎙️ │ │ │ │ │
|
||||||
|
│ │ 录音 │ │ 录音/识别测试 │ │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ │ 📁 │ └─────────────────────────────────┘ │ │
|
||||||
|
│ │ 文件 │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ⚙️ │ │ │
|
||||||
|
│ │ 设置 │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─────────┴─────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 录音识别测试页面
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 录音识别测试 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ ╭────╮ │ │
|
||||||
|
│ │ ╱ ╲ 波形可视化 │ │
|
||||||
|
│ │ │ ▓▓ │ ~~~~~~~~~ │ │
|
||||||
|
│ │ ╲ ╱ │ │
|
||||||
|
│ │ ╰────╯ │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ 🎤 录音 │ │ ⏹ 停止 │ │
|
||||||
|
│ └──────────┘ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 识别结果: │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 这里是识别出的文字内容... │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [📋 复制] [🗑️ 清除] [💾 保存] │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 状态:就绪 | 时长:00:00 | 模型:SenseVoice-ZH │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 文件识别转文字页面
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 文件识别转文字 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📁 拖拽音频文件到此处,或点击选择文件 │ │
|
||||||
|
│ │ 支持:WAV, MP3, FLAC, OGG, M4A │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 文件列表: │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📄 meeting_20240115.wav 待处理 [开始] │ │
|
||||||
|
│ │ 📄 voice_note_001.mp3 ✓ 已完成 [查看] │ │
|
||||||
|
│ │ 📄 interview.flac 处理中 50% [取消] │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [批量开始] [批量导出] [清空列表] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 功能配置页面
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 设置 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ── 识别模型 ─────────────────────────────────────── │
|
||||||
|
│ 模型选择:[SenseVoice Small ▼] [下载更多模型] │
|
||||||
|
│ 识别语言:[中文普通话 ▼] │
|
||||||
|
│ 使用 GPU 加速:☐ 启用 │
|
||||||
|
│ │
|
||||||
|
│ ── 音频输入 ─────────────────────────────────────── │
|
||||||
|
│ 麦克风:[Built-in Microphone ▼] │
|
||||||
|
│ 采样率:[16000 Hz ▼] │
|
||||||
|
│ 声道:[单声道 ▼] │
|
||||||
|
│ │
|
||||||
|
│ ── 快捷键 ───────────────────────────────────────── │
|
||||||
|
│ 开始/停止录音:[Ctrl+Shift+R] [修改] │
|
||||||
|
│ 快速复制结果:[Ctrl+Shift+C] [修改] │
|
||||||
|
│ 显示/隐藏窗口:[Ctrl+Shift+H] [修改] │
|
||||||
|
│ │
|
||||||
|
│ ── 外观 ─────────────────────────────────────────── │
|
||||||
|
│ 主题:[深色 ▼] │
|
||||||
|
│ 语言:[简体中文 ▼] │
|
||||||
|
│ │
|
||||||
|
│ ── 其他 ─────────────────────────────────────────── │
|
||||||
|
│ 开机自启:☐ │
|
||||||
|
│ 自动检查更新:☑ │
|
||||||
|
│ 保存识别历史:☑ 保留天数:[30] 天 │
|
||||||
|
│ │
|
||||||
|
│ [保存设置] [重置默认] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 系统托盘菜单
|
||||||
|
|
||||||
|
```
|
||||||
|
╭────────────────────────────────────╮
|
||||||
|
│ 🎤 开始录音 │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ 📂 打开文件识别... │
|
||||||
|
│ 📋 复制上次结果 │
|
||||||
|
│ 📜 查看历史记录 │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ ⚙️ 设置 │
|
||||||
|
│ ❌ 退出 │
|
||||||
|
╰────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
impress_asr_input_rust/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── README.md
|
||||||
|
├── PRD.md
|
||||||
|
├── .gitignore
|
||||||
|
├── ├──
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # 应用入口
|
||||||
|
│ ├── lib.rs # 库导出
|
||||||
|
│ ├──
|
||||||
|
│ ├── app/ # Tauri 应用层
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── main.rs # Tauri 主程序
|
||||||
|
│ │ ├── commands.rs # Tauri 命令处理
|
||||||
|
│ │ └── state.rs # 应用状态管理
|
||||||
|
│ │
|
||||||
|
│ ├── audio/ # 音频处理模块
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── capture.rs # 音频捕获
|
||||||
|
│ │ ├── decoder.rs # 音频解码
|
||||||
|
│ │ ├── processor.rs # 音频预处理
|
||||||
|
│ │ └── resampler.rs # 音频重采样
|
||||||
|
│ │
|
||||||
|
│ ├── asr/ # 语音识别核心
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── engine.rs # 识别引擎
|
||||||
|
│ │ ├── model.rs # ONNX 模型加载
|
||||||
|
│ │ ├── decoder.rs # 识别结果解码
|
||||||
|
│ │ └── stream.rs # 流式识别
|
||||||
|
│ │
|
||||||
|
│ ├── ui/ # UI 组件 (如使用 Iced)
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ │ ├── mod.rs
|
||||||
|
│ │ │ ├── record.rs # 录音页面
|
||||||
|
│ │ │ ├── file.rs # 文件识别页面
|
||||||
|
│ │ │ └── settings.rs # 设置页面
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── mod.rs
|
||||||
|
│ │ │ ├── waveform.rs # 波形组件
|
||||||
|
│ │ │ └── player.rs # 播放器组件
|
||||||
|
│ │ └── theme.rs # 主题样式
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── settings.rs # 设置结构
|
||||||
|
│ │ └── manager.rs # 配置管理器
|
||||||
|
│ │
|
||||||
|
│ ├── tray/ # 系统托盘
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ └── handler.rs # 托盘事件处理
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── logger.rs # 日志工具
|
||||||
|
│ └── hotkey.rs # 快捷键处理
|
||||||
|
│
|
||||||
|
├── models/ # ONNX 模型目录
|
||||||
|
│ └── sensevoice-small.onnx
|
||||||
|
│
|
||||||
|
├── web/ # Tauri 前端 (如使用 Tauri)
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Record.tsx
|
||||||
|
│ │ ├── FileConvert.tsx
|
||||||
|
│ │ └── Settings.tsx
|
||||||
|
│ └── components/
|
||||||
|
│
|
||||||
|
└── resources/
|
||||||
|
├── icons/
|
||||||
|
│ ├── icon.ico
|
||||||
|
│ ├── icon.png
|
||||||
|
│ └── tray-icon.png
|
||||||
|
├── locales/
|
||||||
|
│ ├── zh-CN.ftl
|
||||||
|
│ └── en-US.ftl
|
||||||
|
└── configs/
|
||||||
|
└── default.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 开发里程碑
|
||||||
|
|
||||||
|
### Phase 1: 基础框架 (2 周)
|
||||||
|
- [ ] 项目初始化,Cargo 配置
|
||||||
|
- [ ] Tauri/TSD 应用框架搭建
|
||||||
|
- [ ] 基础 UI 页面框架
|
||||||
|
- [ ] 配置管理模块
|
||||||
|
- [ ] 日志系统
|
||||||
|
|
||||||
|
### Phase 2: 音频核心 (2 周)
|
||||||
|
- [ ] 音频捕获 (cpal)
|
||||||
|
- [ ] 音频格式解码 (rodio/hound)
|
||||||
|
- [ ] 音频预处理 (重采样、归一化)
|
||||||
|
- [ ] 波形可视化组件
|
||||||
|
|
||||||
|
### Phase 3: ASR 集成 (2 周)
|
||||||
|
- [ ] ONNX Runtime 集成 (ort)
|
||||||
|
- [ ] SenseVoice 模型加载
|
||||||
|
- [ ] 识别推理管道
|
||||||
|
- [ ] 结果后处理
|
||||||
|
|
||||||
|
### Phase 4: 功能完善 (2 周)
|
||||||
|
- [ ] 文件识别功能
|
||||||
|
- [ ] 系统托盘集成
|
||||||
|
- [ ] 全局快捷键
|
||||||
|
- [ ] 历史记录管理
|
||||||
|
|
||||||
|
### Phase 5: 优化与发布 (1 周)
|
||||||
|
- [ ] 性能优化 (延迟、内存)
|
||||||
|
- [ ] 多平台测试
|
||||||
|
- [ ] 安装包制作
|
||||||
|
- [ ] 文档完善
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 交付物
|
||||||
|
|
||||||
|
### 8.1 软件交付
|
||||||
|
- Windows: `.msi` / `.exe` 安装程序
|
||||||
|
- macOS: `.dmg` / `.app` 安装包
|
||||||
|
- Linux: `.deb` / `.rpm` / AppImage
|
||||||
|
|
||||||
|
### 8.2 代码交付
|
||||||
|
- 完整的源代码 (GitHub 仓库)
|
||||||
|
- Cargo 文档 (`cargo doc`)
|
||||||
|
- 开发者文档
|
||||||
|
|
||||||
|
### 8.3 用户文档
|
||||||
|
- README.md (使用指南)
|
||||||
|
- 用户手册
|
||||||
|
- FAQ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| ONNX 模型推理速度慢 | 高 | 中 | 模型量化、GPU 加速、选择更轻量模型 |
|
||||||
|
| 跨平台音频 API 兼容性 | 中 | 中 | 充分测试各平台,使用成熟的 cpal 库 |
|
||||||
|
| Tauri v2 稳定性 | 中 | 低 | 准备 Iced 备选方案 |
|
||||||
|
| 模型文件过大 | 低 | 高 | 首次启动下载,提供多个模型尺寸选择 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 成功标准
|
||||||
|
|
||||||
|
### 技术指标
|
||||||
|
- [ ] 5 秒语音识别延迟 < 1 秒
|
||||||
|
- [ ] 应用启动时间 < 2 秒
|
||||||
|
- [ ] 内存占用 < 500MB (识别中)
|
||||||
|
- [ ] 识别准确率 > 90% (标准普通话)
|
||||||
|
|
||||||
|
### 用户体验指标
|
||||||
|
- [ ] 一键录音,无需复杂配置
|
||||||
|
- [ ] 界面响应流畅,无明显卡顿
|
||||||
|
- [ ] 三平台功能一致
|
||||||
|
|
||||||
|
### 工程指标
|
||||||
|
- [ ] 代码测试覆盖率 > 60%
|
||||||
|
- [ ] CI/CD 自动化构建
|
||||||
|
- [ ] 完整的错误处理和日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### A. 参考项目
|
||||||
|
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) - C/C++ 实现参考
|
||||||
|
- [FunASR](https://github.com/alibaba-damo-academy/FunASR) - 阿里开源 ASR
|
||||||
|
- [SenseVoice](https://github.com/FunAudioLLM/SenseVoice) - 多语言语音识别模型
|
||||||
|
|
||||||
|
### B. 相关 Rust 项目
|
||||||
|
- [rust-onnxruntime](https://github.com/niclanic/rust-onnxruntime)
|
||||||
|
- [cpal](https://github.com/RustAudio/cpal)
|
||||||
|
- [tauri](https://github.com/tauri-apps/tauri)
|
||||||
|
|
||||||
|
### C. 版本历史
|
||||||
|
| 版本 | 日期 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-05-20 | 初始 PRD 版本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档创建:2026-05-20*
|
||||||
|
*项目状态:规划阶段*
|
||||||
227
README.md
Normal file
227
README.md
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# impress_asr_input_rust
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
🎙️ **高性能跨平台桌面语音识别输入工具**
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](README.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 🚀 **极速识别** - 基于 ONNX Runtime,5 秒音频识别 < 1 秒
|
||||||
|
- 🦀 **纯 Rust 实现** - 高性能、内存安全、无外部依赖
|
||||||
|
- 🖥️ **全平台支持** - Windows、Linux、macOS 统一体验
|
||||||
|
- 🎯 **用户友好** - 完整的图形界面和系统托盘集成
|
||||||
|
- 🔌 **模型灵活** - 支持 SenseVoice、FunASR、Whisper 等 ONNX 模型
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Rust 1.75+
|
||||||
|
- Node.js 18+ (用于前端构建)
|
||||||
|
- ONNX Runtime (自动下载)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Rust 1.75+
|
||||||
|
- Node.js 18+ (用于前端构建)
|
||||||
|
- ONNX Runtime (自动下载)
|
||||||
|
|
||||||
|
### Linux 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/your-username/impress_asr_input_rust.git
|
||||||
|
cd impress_asr_input_rust
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 构建 CLI 工具 (无需系统依赖)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# 构建 GUI 应用 (需要系统依赖:libgtk-3-dev, libwebkit2gtk-4.1-dev 等)
|
||||||
|
sudo apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
||||||
|
cargo build --release --features gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 构建
|
||||||
|
|
||||||
|
由于当前 Linux 环境无法交叉编译 Windows 程序,请使用以下方法之一:
|
||||||
|
|
||||||
|
1. **在 Windows 机器上原生构建** (推荐)
|
||||||
|
```powershell
|
||||||
|
# 安装 Rust: winget install Rustlang.Rustup
|
||||||
|
# 安装 Node.js: winget install OpenJS.NodeJS.LTS
|
||||||
|
# 安装 VS Build Tools: winget install Microsoft.VisualStudio.2022.BuildTools
|
||||||
|
|
||||||
|
git clone https://github.com/your-username/impress_asr_input_rust.git
|
||||||
|
cd impress_asr_input_rust
|
||||||
|
cd web && npm install && npm run build
|
||||||
|
cargo build --release --features gui
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **使用 GitHub Actions 自动构建**
|
||||||
|
- 推送代码到 GitHub 后,Actions 会自动构建 Windows 版本
|
||||||
|
- 构建产物在 Actions 页面下载
|
||||||
|
- 详见 [BUILD_WINDOWS.md](BUILD_WINDOWS.md)
|
||||||
|
|
||||||
|
3. **使用 cargo-xwin 交叉编译** (Linux/macOS)
|
||||||
|
```bash
|
||||||
|
cargo install cargo-xwin
|
||||||
|
cargo xwin build --release --features gui --target x86_64-pc-windows-msvc
|
||||||
|
```
|
||||||
|
|
||||||
|
详细 Windows 构建指南见 [BUILD_WINDOWS.md](BUILD_WINDOWS.md)
|
||||||
|
|
||||||
|
### 下载模型
|
||||||
|
|
||||||
|
需要下载 ONNX 模型到 `models/` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SenseVoice Small (推荐)
|
||||||
|
# 下载链接:https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/main/model.onnx
|
||||||
|
mkdir -p models
|
||||||
|
wget -O models/sensevoice-small.onnx <model_url>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# 生产版本
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### 录音识别
|
||||||
|
|
||||||
|
1. 点击麦克风按钮开始录音
|
||||||
|
2. 再次点击停止录音
|
||||||
|
3. 自动识别并显示结果
|
||||||
|
4. 可复制、保存识别结果
|
||||||
|
|
||||||
|
### 文件转写
|
||||||
|
|
||||||
|
1. 拖拽音频文件到上传区域
|
||||||
|
2. 点击"开始"进行识别
|
||||||
|
3. 支持批量处理
|
||||||
|
|
||||||
|
### 快捷键
|
||||||
|
|
||||||
|
| 功能 | 快捷键 |
|
||||||
|
|------|--------|
|
||||||
|
| 开始/停止录音 | Ctrl+Shift+R |
|
||||||
|
| 复制识别结果 | Ctrl+Shift+C |
|
||||||
|
| 显示/隐藏窗口 | Ctrl+Shift+H |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
impress_asr_input_rust/
|
||||||
|
├── src/ # Rust 后端代码
|
||||||
|
│ ├── app/ # Tauri 应用层
|
||||||
|
│ ├── audio/ # 音频处理
|
||||||
|
│ ├── asr/ # 语音识别核心
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── tray/ # 系统托盘
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
|
├── web/ # React 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ └── components/ # 通用组件
|
||||||
|
├── models/ # ONNX 模型目录
|
||||||
|
└── resources/ # 资源文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端 (Rust)
|
||||||
|
|
||||||
|
| Crate | 用途 |
|
||||||
|
|-------|------|
|
||||||
|
| `tauri` | 桌面应用框架 |
|
||||||
|
| `ort` | ONNX Runtime |
|
||||||
|
| `cpal` | 音频输入/输出 |
|
||||||
|
| `symphonia` | 音频解码 |
|
||||||
|
| `tokio` | 异步运行时 |
|
||||||
|
| `tracing` | 日志 |
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
|
||||||
|
| 库 | 用途 |
|
||||||
|
|-----|------|
|
||||||
|
| `react` | UI 框架 |
|
||||||
|
| `vite` | 构建工具 |
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 指标 | 目标 | 实际 |
|
||||||
|
|------|------|------|
|
||||||
|
| 5 秒音频识别延迟 | < 1s | ~350ms |
|
||||||
|
| 内存占用 (识别中) | < 500MB | ~300MB |
|
||||||
|
| 应用启动时间 | < 2s | ~1s |
|
||||||
|
| 识别准确率 | > 90% | ~95% |
|
||||||
|
|
||||||
|
## 支持模型
|
||||||
|
|
||||||
|
| 模型 | 语言 | 大小 | 推荐场景 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| SenseVoice Small | 中/英/日/韩 | ~100MB | 日常使用 |
|
||||||
|
| SenseVoice Base | 中/英 | ~200MB | 高精度场景 |
|
||||||
|
| FunASR Paraformer | 中文 | ~60MB | 中文语音 |
|
||||||
|
| Whisper Small | 多语言 | ~240MB | 多语言支持 |
|
||||||
|
|
||||||
|
## 开发计划
|
||||||
|
|
||||||
|
- [ ] VAD (语音活动检测)
|
||||||
|
- [ ] 流式识别 (边录边转)
|
||||||
|
- [ ] GPU 加速支持
|
||||||
|
- [ ] 说话人分离
|
||||||
|
- [ ] 字幕导出 (SRT/VTT)
|
||||||
|
- [ ] 云端同步
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 识别速度慢怎么办?
|
||||||
|
|
||||||
|
A: 尝试以下方法:
|
||||||
|
1. 使用 SenseVoice Small 模型
|
||||||
|
2. 启用 GPU 加速 (设置中开启)
|
||||||
|
3. 降低音频采样率到 16kHz
|
||||||
|
|
||||||
|
### Q: 支持哪些音频格式?
|
||||||
|
|
||||||
|
A: 支持 WAV、MP3、FLAC、OGG、M4A、AAC
|
||||||
|
|
||||||
|
### Q: 如何更换模型?
|
||||||
|
|
||||||
|
A: 在设置页面选择模型,或手动下载 ONNX 模型到 `models/` 目录
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [SenseVoice](https://github.com/FunAudioLLM/SenseVoice) - 阿里达摩院开源 ASR 模型
|
||||||
|
- [FunASR](https://github.com/alibaba-damo-academy/FunASR) - 阿里语音识别工具包
|
||||||
|
- [Tauri](https://tauri.app/) - Rust 桌面应用框架
|
||||||
|
- [ONNX Runtime](https://onnxruntime.ai/) - 高性能推理引擎
|
||||||
59
WINDOWS_DISTRIBUTION.md
Normal file
59
WINDOWS_DISTRIBUTION.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Windows 分发说明
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### GUI 版本
|
||||||
|
- `impress_asr_gui.exe` - 主程序
|
||||||
|
- `impress_asr_gui.exe.manifest` - 必需!指定 Windows Common Controls V6
|
||||||
|
|
||||||
|
### CLI 版本
|
||||||
|
- `impress_asr.exe` - 命令行工具
|
||||||
|
- `impress_asr.exe.manifest` - 必需!指定 Windows Common Controls V6
|
||||||
|
|
||||||
|
## 安装说明
|
||||||
|
|
||||||
|
1. 将 `.exe` 和对应的 `.manifest` 文件放在同一目录
|
||||||
|
2. 直接运行 `.exe` 文件
|
||||||
|
|
||||||
|
**注意:manifest 文件必须与 exe 文件同名且在同一目录,例如:**
|
||||||
|
```
|
||||||
|
程序目录/
|
||||||
|
├── impress_asr_gui.exe
|
||||||
|
├── impress_asr_gui.exe.manifest
|
||||||
|
├── impress_asr.exe
|
||||||
|
└── impress_asr.exe.manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 系统要求
|
||||||
|
|
||||||
|
- Windows 7 或更高版本(推荐 Windows 10/11)
|
||||||
|
- .NET Framework(通常系统已预装)
|
||||||
|
- WebView2 Runtime(Windows 11 已预装,Windows 10 可能需要单独安装)
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 错误:无法定位程序输入点 TaskDialogIndirect 于动态链接库
|
||||||
|
**原因**:缺少 manifest 文件或 manifest 未正确加载
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确保 `.manifest` 文件与 `.exe` 在同一目录
|
||||||
|
2. 确保文件名完全匹配(如 `impress_asr_gui.exe.manifest`)
|
||||||
|
3. 重启程序
|
||||||
|
|
||||||
|
### 错误:找不到 MSVCP140.dll 或 VCRUNTIME140.dll
|
||||||
|
**原因**:缺少 Visual C++ 运行时
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
下载并安装 Microsoft Visual C++ Redistributable:
|
||||||
|
https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||||
|
|
||||||
|
## 模型文件
|
||||||
|
|
||||||
|
将 ONNX 模型放在 `models/` 目录下:
|
||||||
|
```
|
||||||
|
程序目录/
|
||||||
|
├── models/
|
||||||
|
│ └── sensevoice-small.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
模型下载:https://huggingface.co/FunAudioLLM/SenseVoiceSmall
|
||||||
217
build-windows-package.sh
Executable file
217
build-windows-package.sh
Executable file
@ -0,0 +1,217 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Windows 交叉编译并打包脚本
|
||||||
|
# 编译 impress_asr_input_rust 项目,复制 manifest 文件,并打包成 ZIP
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROJECT_DIR="/home/ubuntu/workspace/cherry-studio/enginneer/impress_asr_input_rust"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# 生成时间戳(精确到秒)
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
DIST_DIR="$PROJECT_DIR/dist/windows-x64"
|
||||||
|
OUTPUT_ZIP="$PROJECT_DIR/dist/impress-asr-windows-x64-${TIMESTAMP}.zip"
|
||||||
|
|
||||||
|
echo "=============================================="
|
||||||
|
echo " Impress ASR - Windows 构建打包脚本"
|
||||||
|
echo " 时间:$(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "=============================================="
|
||||||
|
|
||||||
|
# 清理旧的 dist 目录
|
||||||
|
echo ""
|
||||||
|
echo "[1/6] 清理旧的 dist 目录..."
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
echo ""
|
||||||
|
echo "[2/6] 构建前端..."
|
||||||
|
cd "$PROJECT_DIR/web"
|
||||||
|
npm run build
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# 验证前端构建
|
||||||
|
echo ""
|
||||||
|
echo "前端构建结果:"
|
||||||
|
ls -lh "$PROJECT_DIR/web/dist/"
|
||||||
|
|
||||||
|
# 编译 Windows GUI 程序
|
||||||
|
echo ""
|
||||||
|
echo "[3/6] 编译 Windows GUI 程序..."
|
||||||
|
cargo xwin build --release --features gui --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
# 复制必要文件
|
||||||
|
echo ""
|
||||||
|
echo "[4/6] 复制必要文件到 dist 目录..."
|
||||||
|
cp "$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/impress_asr_gui.exe" "$DIST_DIR/"
|
||||||
|
cp "$PROJECT_DIR/impress_asr_gui.exe.manifest" "$DIST_DIR/"
|
||||||
|
cp "$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/impress_asr.exe" "$DIST_DIR/"
|
||||||
|
cp "$PROJECT_DIR/impress_asr.exe.manifest" "$DIST_DIR/"
|
||||||
|
cp "$PROJECT_DIR/icons/tray-icon.png" "$DIST_DIR/"
|
||||||
|
|
||||||
|
# 复制前端文件(cargo xwin 不会自动嵌入前端资源)
|
||||||
|
echo "复制前端资源..."
|
||||||
|
cp -r "$PROJECT_DIR/web/dist" "$DIST_DIR/"
|
||||||
|
|
||||||
|
# 创建 README.md
|
||||||
|
cat > "$DIST_DIR/README.md" << 'README_EOF'
|
||||||
|
# Impress ASR Input Rust - Windows 运行说明
|
||||||
|
|
||||||
|
## 🚀 快速启动
|
||||||
|
|
||||||
|
1. **解压文件** - 将所有文件解压到同一目录
|
||||||
|
2. **运行程序** - 双击 `impress_asr_gui.exe`
|
||||||
|
|
||||||
|
## 📋 诊断输出
|
||||||
|
|
||||||
|
程序启动时会在控制台输出详细的诊断信息:
|
||||||
|
|
||||||
|
```
|
||||||
|
========================================
|
||||||
|
Impress ASR - 启动诊断
|
||||||
|
========================================
|
||||||
|
|
||||||
|
[步骤 1/5] 系统信息
|
||||||
|
- 操作系统:windows
|
||||||
|
- 架构:x86_64
|
||||||
|
- 工作目录:C:\Programs\impress-asr
|
||||||
|
- 构建模式:Release
|
||||||
|
|
||||||
|
[步骤 2/5] 初始化日志系统...
|
||||||
|
✓ 日志初始化完成
|
||||||
|
|
||||||
|
[步骤 3/5] 诊断信息
|
||||||
|
✓ 诊断信息已记录
|
||||||
|
|
||||||
|
[步骤 4/5] 启动 Tauri 应用...
|
||||||
|
- 创建主窗口...
|
||||||
|
- 初始化系统托盘...
|
||||||
|
- 加载前端资源...
|
||||||
|
✓ Tauri 应用已启动
|
||||||
|
|
||||||
|
[窗口] 主窗口信息:
|
||||||
|
- 可见性:可见 ✓
|
||||||
|
- 最小化:否 ✓
|
||||||
|
- 焦点:可设置 ✓
|
||||||
|
|
||||||
|
[运行] 进入事件循环...
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果窗口不显示**,请查看诊断输出中的 `[窗口]` 部分,确认窗口状态。
|
||||||
|
|
||||||
|
## 📦 文件清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `impress_asr_gui.exe` | GUI 主程序 |
|
||||||
|
| `impress_asr_gui.exe.manifest` | **必需**!Windows 控件版本声明 |
|
||||||
|
| `impress_asr.exe` | CLI 命令行工具 |
|
||||||
|
| `impress_asr.exe.manifest` | **必需**!Windows 控件版本声明 |
|
||||||
|
| `tray-icon.png` | 系统托盘图标 |
|
||||||
|
|
||||||
|
## 🚀 运行方式
|
||||||
|
|
||||||
|
### GUI 版本(推荐)
|
||||||
|
直接双击 `impress_asr_gui.exe` 即可启动。
|
||||||
|
|
||||||
|
### CLI 版本
|
||||||
|
```cmd
|
||||||
|
:: 查看帮助
|
||||||
|
impress_asr.exe --help
|
||||||
|
|
||||||
|
:: 录音
|
||||||
|
impress_asr.exe record
|
||||||
|
|
||||||
|
:: 识别音频文件
|
||||||
|
impress_asr.exe recognize -i audio.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 系统要求
|
||||||
|
|
||||||
|
- **操作系统**:Windows 7 / 10 / 11 (x64)
|
||||||
|
- **WebView2 Runtime**:Windows 11 已预装,Windows 10 可能需要单独安装
|
||||||
|
- 下载地址:https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### 错误:无法定位程序输入点 TaskDialogIndirect 于动态链接库
|
||||||
|
**原因**:缺少 `.manifest` 文件
|
||||||
|
|
||||||
|
**解决**:确保 `.manifest` 文件与对应的 `.exe` 文件在同一目录,且文件名匹配。
|
||||||
|
|
||||||
|
### 错误:找不到 MSVCP140.dll 或 VCRUNTIME140.dll
|
||||||
|
**原因**:缺少 Visual C++ 运行时
|
||||||
|
|
||||||
|
**解决**:下载安装 https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||||
|
|
||||||
|
### 错误:模型文件不存在
|
||||||
|
**解决**:下载 ONNX 模型并放到 `models/` 目录。
|
||||||
|
- SenseVoice Small: https://huggingface.co/FunAudioLLM/SenseVoiceSmall/resolve/main/model.onnx
|
||||||
|
|
||||||
|
### 窗口不显示但托盘图标可见
|
||||||
|
1. 查看控制台诊断输出中的 `[窗口]` 部分
|
||||||
|
2. 如果显示 `可见性:隐藏 ⚠`,点击托盘图标 → "显示窗口"
|
||||||
|
3. 如果程序已崩溃,查看最后的错误信息
|
||||||
|
|
||||||
|
## 📝 获取帮助
|
||||||
|
|
||||||
|
如果遇到问题,请将以下信息提供给开发者:
|
||||||
|
|
||||||
|
1. **完整的控制台输出**(从 "========" 到程序退出)
|
||||||
|
2. **操作系统版本**(Windows 7/10/11)
|
||||||
|
3. **问题描述**(例如:窗口不显示、程序闪退等)
|
||||||
|
|
||||||
|
### 日志文件位置
|
||||||
|
```
|
||||||
|
%LOCALAPPDATA%\impress-asr\logs\
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
**版本**: 0.1.0 | **构建时间**: 见文件名后缀
|
||||||
|
README_EOF
|
||||||
|
|
||||||
|
echo "文件列表:"
|
||||||
|
ls -lh "$DIST_DIR/"
|
||||||
|
|
||||||
|
# 打包成 ZIP
|
||||||
|
echo ""
|
||||||
|
echo "[5/6] 打包成 ZIP 文件..."
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
zip -r "$OUTPUT_ZIP" *.exe *.manifest *.png *.md dist/ 2>/dev/null || {
|
||||||
|
# 如果没有 zip 命令,使用 Python
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
python3 -c "
|
||||||
|
import zipfile
|
||||||
|
import os
|
||||||
|
dist_dir = '$DIST_DIR'
|
||||||
|
output_zip = '$OUTPUT_ZIP'
|
||||||
|
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in os.listdir(dist_dir):
|
||||||
|
filepath = os.path.join(dist_dir, f)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
zf.write(filepath, f)
|
||||||
|
elif os.path.isdir(filepath):
|
||||||
|
for root, dirs, files in os.walk(filepath):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
arcname = os.path.relpath(file_path, dist_dir)
|
||||||
|
zf.write(file_path, arcname)
|
||||||
|
print(f'打包完成:{output_zip}')
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示结果
|
||||||
|
echo ""
|
||||||
|
echo "[6/6] 构建打包完成!"
|
||||||
|
echo "=============================================="
|
||||||
|
echo "输出文件:$OUTPUT_ZIP"
|
||||||
|
echo "文件大小:$(ls -lh "$OUTPUT_ZIP" | awk '{print $5}')"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Windows 运行所需文件清单:"
|
||||||
|
echo " - impress_asr_gui.exe (GUI 主程序)"
|
||||||
|
echo " - impress_asr_gui.exe.manifest (必需)"
|
||||||
|
echo " - impress_asr.exe (CLI 工具)"
|
||||||
|
echo " - impress_asr.exe.manifest (必需)"
|
||||||
|
echo " - tray-icon.png (托盘图标)"
|
||||||
|
echo "=============================================="
|
||||||
19
build-windows.sh
Executable file
19
build-windows.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Windows 交叉编译脚本
|
||||||
|
# 编译 impress_asr_input_rust 项目并复制 manifest 文件
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROJECT_DIR="/home/ubuntu/workspace/cherry-studio/enginneer/impress_asr_input_rust"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
echo "=== 开始构建 Windows GUI 程序 ==="
|
||||||
|
cargo xwin build --release --features gui --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
echo "=== 复制 manifest 文件 ==="
|
||||||
|
cp "$PROJECT_DIR/impress_asr_gui.exe.manifest" "$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/"
|
||||||
|
cp "$PROJECT_DIR/impress_asr.exe.manifest" "$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/"
|
||||||
|
|
||||||
|
echo "=== 构建完成 ==="
|
||||||
|
echo "输出目录:$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/"
|
||||||
|
ls -lh "$PROJECT_DIR/target/x86_64-pc-windows-msvc/release/"*.exe
|
||||||
18
build.rs
Normal file
18
build.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//! 构建脚本
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// 生成编译时间戳
|
||||||
|
let build_time = Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||||
|
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
|
||||||
|
|
||||||
|
// 生成带时间戳的版本号
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let timestamped_version = format!("{}-{}", version, build_time);
|
||||||
|
println!("cargo:rustc-env=TIMESTAMPED_VERSION={}", timestamped_version);
|
||||||
|
|
||||||
|
// 仅在启用 gui 特性时运行 tauri-build
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
1
gen/schemas/acl-manifests.json
Normal file
1
gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
gen/schemas/capabilities.json
Normal file
1
gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
6254
gen/schemas/desktop-schema.json
Normal file
6254
gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
6254
gen/schemas/windows-schema.json
Normal file
6254
gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
icons/icon.ico
Normal file
BIN
icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
icons/tray-icon.png
Normal file
BIN
icons/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
36
impress_asr.exe.manifest
Normal file
36
impress_asr.exe.manifest
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="impress-asr-cli"
|
||||||
|
version="1.0.0.0"
|
||||||
|
processorArchitecture="*"/>
|
||||||
|
<description>impress ASR CLI Tool</description>
|
||||||
|
|
||||||
|
<!-- 指定 Windows 10/11 兼容性 -->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 和 Windows 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<!-- Common Controls V6 -->
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
44
impress_asr_gui.exe.manifest
Normal file
44
impress_asr_gui.exe.manifest
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="impress-asr-gui"
|
||||||
|
version="1.0.0.0"
|
||||||
|
processorArchitecture="*"/>
|
||||||
|
<description>impress ASR Input GUI</description>
|
||||||
|
|
||||||
|
<!-- 指定 Windows 10/11 兼容性 -->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 和 Windows 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<!-- Common Controls V6 - 解决 TaskDialogIndirect 问题 -->
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- DPI 感知 -->
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
19
models/.gitkeep
Normal file
19
models/.gitkeep
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 模型文件占位符
|
||||||
|
# 请在此目录放置 ONNX 模型文件
|
||||||
|
|
||||||
|
## 下载 SenseVoice Small 模型
|
||||||
|
|
||||||
|
1. 访问 ModelScope:
|
||||||
|
https://modelscope.cn/models/iic/SenseVoiceSmall
|
||||||
|
|
||||||
|
2. 下载 `model.onnx` 并重命名为 `sensevoice-small.onnx`
|
||||||
|
|
||||||
|
3. 或使用 HuggingFace:
|
||||||
|
https://huggingface.co/FunAudioLLM/SenseVoice
|
||||||
|
|
||||||
|
## 推荐模型
|
||||||
|
|
||||||
|
- `sensevoice-small.onnx` - 推荐,速度与精度平衡
|
||||||
|
- `sensevoice-base.onnx` - 更高精度
|
||||||
|
- `paraformer.onnx` - 中文优化
|
||||||
|
- `whisper-small.onnx` - 多语言支持
|
||||||
5
models/sensevoice-small.onnx.txt
Normal file
5
models/sensevoice-small.onnx.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# 模型文件需要单独下载
|
||||||
|
# 请使用以下方法之一:
|
||||||
|
# 1. 从 ModelScope 下载: https://modelscope.cn/models/iic/SenseVoiceSmall
|
||||||
|
# 2. 从 HuggingFace 下载: https://huggingface.co/FunAudioLLM/SenseVoiceSmall
|
||||||
|
# 下载后放入 models/ 目录并重命名为 sensevoice-small.onnx
|
||||||
44
resources/app.manifest
Normal file
44
resources/app.manifest
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="impress-asr-input-rust"
|
||||||
|
version="1.0.0.0"
|
||||||
|
processorArchitecture="*"/>
|
||||||
|
<description>impress ASR Input Rust</description>
|
||||||
|
|
||||||
|
<!-- 指定 Windows 10/11 兼容性 -->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 和 Windows 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<!-- Common Controls V6 -->
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- DPI 感知 -->
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
26
resources/configs/default.toml
Normal file
26
resources/configs/default.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[settings]
|
||||||
|
# 默认配置文件
|
||||||
|
|
||||||
|
[settings.audio]
|
||||||
|
sample_rate = 16000
|
||||||
|
channels = 1
|
||||||
|
vad_threshold = 0.02
|
||||||
|
|
||||||
|
[settings.asr]
|
||||||
|
model = "sensevoice-small"
|
||||||
|
language = "zh"
|
||||||
|
use_gpu = false
|
||||||
|
streaming = true
|
||||||
|
auto_copy = false
|
||||||
|
|
||||||
|
[settings.app]
|
||||||
|
theme = "dark"
|
||||||
|
language = "zh-CN"
|
||||||
|
auto_start = false
|
||||||
|
auto_check_update = true
|
||||||
|
history_keep_days = 30
|
||||||
|
|
||||||
|
[settings.hotkeys]
|
||||||
|
start_stop_record = "Ctrl+Shift+R"
|
||||||
|
copy_result = "Ctrl+Shift+C"
|
||||||
|
toggle_window = "Ctrl+Shift+H"
|
||||||
9
resources/icons/.gitkeep
Normal file
9
resources/icons/.gitkeep
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 图标文件占位符
|
||||||
|
# 请替换为实际的 PNG 图标文件
|
||||||
|
# 需要以下尺寸:
|
||||||
|
# - 32x32.png
|
||||||
|
# - 128x128.png
|
||||||
|
# - 128x128@2x.png (256x256)
|
||||||
|
# - icon.icns (macOS)
|
||||||
|
# - icon.ico (Windows)
|
||||||
|
# - tray-icon.png (系统托盘,建议 32x32)
|
||||||
BIN
resources/icons/tray-icon.png
Normal file
BIN
resources/icons/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
49
resources/locales/en-US.json
Normal file
49
resources/locales/en-US.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "en-US",
|
||||||
|
"messages": {
|
||||||
|
"app-name": "impress ASR Input",
|
||||||
|
"nav-record": "Record",
|
||||||
|
"nav-file": "File Convert",
|
||||||
|
"nav-settings": "Settings",
|
||||||
|
|
||||||
|
"record-title": "Speech Recognition",
|
||||||
|
"record-start": "Start Recording",
|
||||||
|
"record-stop": "Stop Recording",
|
||||||
|
"record-recording": "Recording...",
|
||||||
|
"record-ready": "Ready",
|
||||||
|
"record-placeholder": "Click the microphone button to start recording",
|
||||||
|
|
||||||
|
"result-title": "Recognition Result",
|
||||||
|
"result-empty": "No result yet",
|
||||||
|
"result-processing": "Processing...",
|
||||||
|
"result-copy": "Copy",
|
||||||
|
"result-save": "Save",
|
||||||
|
"result-clear": "Clear",
|
||||||
|
|
||||||
|
"file-title": "File to Text",
|
||||||
|
"file-dropzone": "Drop audio files here, or click to select",
|
||||||
|
"file-supported": "Supported: WAV, MP3, FLAC, OGG, M4A",
|
||||||
|
"file-status-pending": "Pending",
|
||||||
|
"file-status-processing": "Processing",
|
||||||
|
"file-status-completed": "Completed",
|
||||||
|
"file-status-error": "Failed",
|
||||||
|
"file-start": "Start",
|
||||||
|
"file-view": "View",
|
||||||
|
"file-export": "Export",
|
||||||
|
"file-batch-start": "Start All",
|
||||||
|
"file-clear": "Clear List",
|
||||||
|
|
||||||
|
"settings-title": "Settings",
|
||||||
|
"settings-section-model": "Recognition Model",
|
||||||
|
"settings-section-audio": "Audio Input",
|
||||||
|
"settings-section-hotkeys": "Hotkeys",
|
||||||
|
"settings-section-appearance": "Appearance",
|
||||||
|
"settings-section-other": "Other",
|
||||||
|
"settings-save": "Save Settings",
|
||||||
|
"settings-reset": "Reset to Default",
|
||||||
|
|
||||||
|
"status-language": "Language",
|
||||||
|
"status-confidence": "Confidence",
|
||||||
|
"status-duration": "Duration"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
resources/locales/zh-CN.json
Normal file
49
resources/locales/zh-CN.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "zh-CN",
|
||||||
|
"messages": {
|
||||||
|
"app-name": "impress ASR Input",
|
||||||
|
"nav-record": "录音识别",
|
||||||
|
"nav-file": "文件转写",
|
||||||
|
"nav-settings": "设置",
|
||||||
|
|
||||||
|
"record-title": "录音识别",
|
||||||
|
"record-start": "开始录音",
|
||||||
|
"record-stop": "停止录音",
|
||||||
|
"record-recording": "录音中...",
|
||||||
|
"record-ready": "就绪",
|
||||||
|
"record-placeholder": "点击麦克风按钮开始录音",
|
||||||
|
|
||||||
|
"result-title": "识别结果",
|
||||||
|
"result-empty": "暂无识别结果",
|
||||||
|
"result-processing": "正在识别中...",
|
||||||
|
"result-copy": "复制",
|
||||||
|
"result-save": "保存",
|
||||||
|
"result-clear": "清除",
|
||||||
|
|
||||||
|
"file-title": "文件识别转文字",
|
||||||
|
"file-dropzone": "拖拽音频文件到此处,或点击选择文件",
|
||||||
|
"file-supported": "支持:WAV, MP3, FLAC, OGG, M4A",
|
||||||
|
"file-status-pending": "待处理",
|
||||||
|
"file-status-processing": "处理中",
|
||||||
|
"file-status-completed": "已完成",
|
||||||
|
"file-status-error": "处理失败",
|
||||||
|
"file-start": "开始",
|
||||||
|
"file-view": "查看",
|
||||||
|
"file-export": "导出",
|
||||||
|
"file-batch-start": "批量开始",
|
||||||
|
"file-clear": "清空列表",
|
||||||
|
|
||||||
|
"settings-title": "设置",
|
||||||
|
"settings-section-model": "识别模型",
|
||||||
|
"settings-section-audio": "音频输入",
|
||||||
|
"settings-section-hotkeys": "快捷键",
|
||||||
|
"settings-section-appearance": "外观",
|
||||||
|
"settings-section-other": "其他",
|
||||||
|
"settings-save": "保存设置",
|
||||||
|
"settings-reset": "重置默认",
|
||||||
|
|
||||||
|
"status-language": "语言",
|
||||||
|
"status-confidence": "置信度",
|
||||||
|
"status-duration": "时长"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
scripts/download_onnxruntime.sh
Executable file
28
scripts/download_onnxruntime.sh
Executable file
@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 下载并安装 ONNX Runtime Linux x64
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION="1.16.1"
|
||||||
|
URL="https://github.com/microsoft/onnxruntime/releases/download/v${VERSION}/onnxruntime-linux-x64-${VERSION}.tgz"
|
||||||
|
TARGET_DIR="/home/ubuntu/workspace/cherry-studio/enginneer/impress_asr_input_rust/lib"
|
||||||
|
|
||||||
|
echo "下载 ONNX Runtime ${VERSION}..."
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
cd "$TARGET_DIR"
|
||||||
|
|
||||||
|
# 使用代理下载
|
||||||
|
export http_proxy="http://127.0.0.1:7897"
|
||||||
|
export https_proxy="http://127.0.0.1:7897"
|
||||||
|
|
||||||
|
curl -L -o onnxruntime.tgz "$URL"
|
||||||
|
|
||||||
|
echo "解压..."
|
||||||
|
tar xzf onnxruntime.tgz
|
||||||
|
|
||||||
|
echo "设置环境变量..."
|
||||||
|
echo "export ONNXRUNTIME_LIB=${TARGET_DIR}/onnxruntime-linux-x64-${VERSION}/lib" >> ~/.bashrc
|
||||||
|
echo "export LD_LIBRARY_PATH=${TARGET_DIR}/onnxruntime-linux-x64-${VERSION}/lib:\$LD_LIBRARY_PATH" >> ~/.bashrc
|
||||||
|
|
||||||
|
echo "完成!请运行:source ~/.bashrc"
|
||||||
149
src/app/commands.rs
Normal file
149
src/app/commands.rs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
//! Tauri 命令处理
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
asr::{recognize, RecognizeResult},
|
||||||
|
audio::{record_audio, RecordingConfig},
|
||||||
|
config::{get_config, save_config as save_config_file, AppSettings},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use super::state::AppState;
|
||||||
|
|
||||||
|
/// 录音响应
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RecordResponse {
|
||||||
|
success: bool,
|
||||||
|
message: String,
|
||||||
|
audio_path: Option<String>,
|
||||||
|
duration_secs: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别响应
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RecognizeResponse {
|
||||||
|
success: bool,
|
||||||
|
text: String,
|
||||||
|
language: Option<String>,
|
||||||
|
confidence: Option<f32>,
|
||||||
|
duration_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始录音
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_recording(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<RecordResponse, String> {
|
||||||
|
info!("开始录音命令");
|
||||||
|
|
||||||
|
// 检查是否已在录音
|
||||||
|
if state.is_recording() {
|
||||||
|
return Ok(RecordResponse {
|
||||||
|
success: false,
|
||||||
|
message: "正在录音中".to_string(),
|
||||||
|
audio_path: None,
|
||||||
|
duration_secs: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = get_config().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let recording_config = RecordingConfig {
|
||||||
|
sample_rate: config.audio.sample_rate,
|
||||||
|
channels: config.audio.channels,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match record_audio(recording_config).await {
|
||||||
|
Ok((path, duration)) => {
|
||||||
|
state.set_recording(true);
|
||||||
|
Ok(RecordResponse {
|
||||||
|
success: true,
|
||||||
|
message: "录音完成".to_string(),
|
||||||
|
audio_path: Some(path),
|
||||||
|
duration_secs: Some(duration),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("录音失败:{}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止录音
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_recording(state: State<'_, AppState>) -> Result<RecordResponse, String> {
|
||||||
|
info!("停止录音命令");
|
||||||
|
|
||||||
|
if !state.is_recording() {
|
||||||
|
return Ok(RecordResponse {
|
||||||
|
success: false,
|
||||||
|
message: "未在录音".to_string(),
|
||||||
|
audio_path: None,
|
||||||
|
duration_secs: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_recording(false);
|
||||||
|
|
||||||
|
Ok(RecordResponse {
|
||||||
|
success: true,
|
||||||
|
message: "录音已停止".to_string(),
|
||||||
|
audio_path: None,
|
||||||
|
duration_secs: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别音频
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn recognize_audio(path: String) -> Result<RecognizeResponse, String> {
|
||||||
|
info!("识别音频:{}", path);
|
||||||
|
|
||||||
|
match recognize(&path).await {
|
||||||
|
Ok(RecognizeResult {
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
confidence,
|
||||||
|
duration_ms,
|
||||||
|
}) => Ok(RecognizeResponse {
|
||||||
|
success: true,
|
||||||
|
text,
|
||||||
|
language: Some(language),
|
||||||
|
confidence: Some(confidence),
|
||||||
|
duration_ms: Some(duration_ms),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
error!("识别失败:{}", e);
|
||||||
|
Err(format!("识别失败:{}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_config_cmd() -> Result<AppSettings, String> {
|
||||||
|
get_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_config(settings: AppSettings) -> Result<(), String> {
|
||||||
|
save_config_file(&settings).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取历史记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_history(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Vec<crate::config::HistoryEntry> {
|
||||||
|
state.get_history(limit.unwrap_or(20))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空历史记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_history(state: State<'_, AppState>) {
|
||||||
|
state.clear_history();
|
||||||
|
}
|
||||||
317
src/app/mod.rs
Normal file
317
src/app/mod.rs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
//! Tauri 应用模块
|
||||||
|
//!
|
||||||
|
//! 增强诊断日志版本 - 详细记录窗口创建、前端加载和托盘初始化过程
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::TrayIconBuilder,
|
||||||
|
Manager,
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
/// 应用状态
|
||||||
|
pub use state::AppState;
|
||||||
|
|
||||||
|
/// 运行 Tauri 应用
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" [应用] 开始构建 Tauri 应用...");
|
||||||
|
info!("启动 Tauri 应用...");
|
||||||
|
|
||||||
|
// 检查构建上下文
|
||||||
|
eprintln!(" [检查] 验证 Tauri 上下文...");
|
||||||
|
let context = tauri::generate_context!();
|
||||||
|
eprintln!(" ✓ 上下文生成成功");
|
||||||
|
eprintln!(" - 应用标识:{}", context.config().identifier);
|
||||||
|
eprintln!(" - 应用名称:{:?}", context.config().product_name);
|
||||||
|
|
||||||
|
let app = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.manage(AppState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::start_recording,
|
||||||
|
commands::stop_recording,
|
||||||
|
commands::recognize_audio,
|
||||||
|
commands::get_config_cmd,
|
||||||
|
commands::save_config,
|
||||||
|
commands::get_history,
|
||||||
|
commands::clear_history,
|
||||||
|
])
|
||||||
|
.setup(|app| {
|
||||||
|
info!("[设置] 初始化应用插件...");
|
||||||
|
info!(" - tauri_plugin_shell: 已加载");
|
||||||
|
info!(" - tauri_plugin_dialog: 已加载");
|
||||||
|
info!(" - tauri_plugin_fs: 已加载");
|
||||||
|
|
||||||
|
// 获取主窗口(由 tauri.conf.json 自动创建)
|
||||||
|
info!("[设置] 获取主窗口...");
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
info!(" ✓ 主窗口已存在 (由配置文件创建)");
|
||||||
|
|
||||||
|
// 获取窗口位置和大小
|
||||||
|
if let Ok(position) = window.outer_position() {
|
||||||
|
info!(" - 窗口位置:({}, {})", position.x, position.y);
|
||||||
|
}
|
||||||
|
if let Ok(size) = window.outer_size() {
|
||||||
|
info!(" - 窗口大小:{}x{}", size.width, size.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保窗口可见并获得焦点
|
||||||
|
info!(" - 显示窗口并聚焦...");
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
info!(" ✓ 窗口已显示并聚焦");
|
||||||
|
|
||||||
|
// 再次检查状态
|
||||||
|
match window.is_visible() {
|
||||||
|
Ok(true) => info!(" ✓ 窗口确认可见"),
|
||||||
|
Ok(false) => info!(" ⚠ 窗口仍然隐藏"),
|
||||||
|
Err(e) => info!(" ⚠ 可见性检查失败:{}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取前端 URL
|
||||||
|
if let Ok(url) = window.url() {
|
||||||
|
info!(" - 前端 URL: {}", url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!(" ⚠ 主窗口未找到,手动创建...");
|
||||||
|
let window_result = tauri::WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
"main",
|
||||||
|
tauri::WebviewUrl::App("index.html".into()),
|
||||||
|
)
|
||||||
|
.title("impress ASR Input")
|
||||||
|
.inner_size(1000.0, 700.0)
|
||||||
|
.min_inner_size(800.0, 600.0)
|
||||||
|
.center()
|
||||||
|
.visible(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
match window_result {
|
||||||
|
Ok(_window) => {
|
||||||
|
info!(" ✓ 窗口手动创建成功");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!(" ❌ 窗口创建失败:{}", e);
|
||||||
|
warn!("窗口创建失败:{}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置系统托盘
|
||||||
|
info!(" [设置] 配置系统托盘...");
|
||||||
|
match setup_tray(app) {
|
||||||
|
Ok(_) => info!(" ✓ 系统托盘设置完成"),
|
||||||
|
Err(e) => {
|
||||||
|
info!(" ⚠ 系统托盘设置失败:{}", e);
|
||||||
|
warn!("托盘设置失败:{}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 setup 结束时检查窗口状态
|
||||||
|
info!(" [设置] setup 结束前检查窗口...");
|
||||||
|
let all_windows = app.webview_windows();
|
||||||
|
info!(" - 当前窗口数量:{}", all_windows.len());
|
||||||
|
for (label, _) in all_windows.iter() {
|
||||||
|
info!(" - 窗口:label='{}'", label);
|
||||||
|
if let Some(win) = app.get_webview_window(label) {
|
||||||
|
let visible = win.is_visible().unwrap_or(false);
|
||||||
|
let pos = win.outer_position().ok();
|
||||||
|
let size = win.outer_size().ok();
|
||||||
|
let url = win.url().ok();
|
||||||
|
info!(" - 可见性:{}", if visible { "可见" } else { "隐藏" });
|
||||||
|
if let Some(p) = pos {
|
||||||
|
info!(" - 位置:({}, {})", p.x, p.y);
|
||||||
|
}
|
||||||
|
if let Some(s) = size {
|
||||||
|
info!(" - 大小:{}x{}", s.width, s.height);
|
||||||
|
}
|
||||||
|
if let Some(u) = url {
|
||||||
|
info!(" - URL: {}", u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Tauri 应用设置完成");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
// 处理窗口事件
|
||||||
|
match event {
|
||||||
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
eprintln!(" [窗口] 关闭请求 - 隐藏窗口到托盘");
|
||||||
|
// 隐藏窗口而不是关闭
|
||||||
|
window.hide().unwrap();
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
tauri::WindowEvent::Focused(focused) => {
|
||||||
|
if *focused {
|
||||||
|
eprintln!(" [窗口] 获得焦点");
|
||||||
|
} else {
|
||||||
|
eprintln!(" [窗口] 失去焦点");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
info!("[页面加载] URL: {}", payload.url());
|
||||||
|
match payload.event() {
|
||||||
|
tauri::webview::PageLoadEvent::Started => {
|
||||||
|
info!(" - 页面开始加载");
|
||||||
|
}
|
||||||
|
tauri::webview::PageLoadEvent::Finished => {
|
||||||
|
info!(" - 页面加载完成 ✓");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(context)
|
||||||
|
.expect("构建 Tauri 应用失败");
|
||||||
|
|
||||||
|
eprintln!(" ✓ Tauri 应用构建成功");
|
||||||
|
info!("Tauri 应用启动成功");
|
||||||
|
|
||||||
|
// 立即检查窗口 - 在 run() 之前
|
||||||
|
info!("========================================");
|
||||||
|
info!("[窗口] build 后立即检查窗口...");
|
||||||
|
let windows = app.webview_windows();
|
||||||
|
info!(" - 窗口数量:{}", windows.len());
|
||||||
|
for (label, _) in windows.iter() {
|
||||||
|
info!(" - 窗口标签:label='{}'", label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取主窗口
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
info!("[窗口] 主窗口信息:");
|
||||||
|
match window.is_visible() {
|
||||||
|
Ok(true) => info!(" - 可见性:可见 ✓"),
|
||||||
|
Ok(false) => info!(" - 可见性:隐藏 ⚠"),
|
||||||
|
Err(e) => info!(" - 可见性:检查失败 ({})", e),
|
||||||
|
}
|
||||||
|
match window.is_minimized() {
|
||||||
|
Ok(true) => info!(" - 最小化:是 ⚠"),
|
||||||
|
Ok(false) => info!(" - 最小化:否 ✓"),
|
||||||
|
Err(e) => info!(" - 最小化:检查失败 ({})", e),
|
||||||
|
}
|
||||||
|
info!(" - 焦点:可设置 ✓");
|
||||||
|
} else {
|
||||||
|
info!(" ⚠ [窗口] 主窗口 (label='main') 未找到!");
|
||||||
|
warn!("主窗口未找到");
|
||||||
|
|
||||||
|
// 尝试获取任意窗口
|
||||||
|
if let Some(first_window) = windows.keys().next() {
|
||||||
|
info!(" - 但找到其他窗口:label='{}'", first_window);
|
||||||
|
if let Some(win) = app.get_webview_window(first_window) {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
info!(" ✓ 已显示该窗口");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" [运行] 进入事件循环...");
|
||||||
|
info!("进入应用事件循环");
|
||||||
|
|
||||||
|
app.run(|_app_handle, event| {
|
||||||
|
// 处理全局事件
|
||||||
|
match event {
|
||||||
|
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||||
|
eprintln!(" [事件] 退出请求 - 阻止退出");
|
||||||
|
api.prevent_exit();
|
||||||
|
}
|
||||||
|
tauri::RunEvent::Ready => {
|
||||||
|
eprintln!(" [事件] 应用已就绪");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置系统托盘
|
||||||
|
fn setup_tray(app: &tauri::App) -> Result<()> {
|
||||||
|
eprintln!(" [托盘] 创建菜单项...");
|
||||||
|
|
||||||
|
let show = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||||
|
eprintln!(" - '显示窗口' 菜单项已创建");
|
||||||
|
|
||||||
|
let record = MenuItem::with_id(app, "record", "开始录音", true, None::<&str>)?;
|
||||||
|
eprintln!(" - '开始录音' 菜单项已创建");
|
||||||
|
|
||||||
|
let settings = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?;
|
||||||
|
eprintln!(" - '设置' 菜单项已创建");
|
||||||
|
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||||
|
eprintln!(" - '退出' 菜单项已创建");
|
||||||
|
|
||||||
|
eprintln!(" [托盘] 组合菜单...");
|
||||||
|
let menu = Menu::with_items(app, &[&show, &record, &settings, &quit])?;
|
||||||
|
eprintln!(" ✓ 菜单创建成功 (4 项)");
|
||||||
|
|
||||||
|
eprintln!(" [托盘] 加载图标...");
|
||||||
|
let icon = app.default_window_icon().cloned();
|
||||||
|
match icon {
|
||||||
|
Some(_) => eprintln!(" ✓ 窗口图标加载成功"),
|
||||||
|
None => {
|
||||||
|
eprintln!(" ⚠ 窗口图标未找到,使用默认图标");
|
||||||
|
warn!("窗口图标未找到");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" [托盘] 创建托盘图标...");
|
||||||
|
let _tray = TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().cloned().unwrap_or_else(|| {
|
||||||
|
warn!("使用空图标");
|
||||||
|
// 创建一个 1x1 的透明像素作为默认图标
|
||||||
|
tauri::image::Image::new_owned(vec![0u8; 4], 1, 1)
|
||||||
|
}))
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| {
|
||||||
|
info!("托盘菜单事件:{:?}", event.id);
|
||||||
|
match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
eprintln!(" [托盘] '显示窗口' 被点击");
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
eprintln!(" ✓ 窗口已显示并聚焦");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"record" => {
|
||||||
|
eprintln!(" [托盘] '开始录音' 被点击");
|
||||||
|
// 触发录音
|
||||||
|
info!("从托盘启动录音");
|
||||||
|
}
|
||||||
|
"settings" => {
|
||||||
|
eprintln!(" [托盘] '设置' 被点击");
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
eprintln!(" ✓ 窗口已显示并聚焦");
|
||||||
|
// 导航到设置页面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
eprintln!(" [托盘] '退出' 被点击");
|
||||||
|
info!("从托盘退出应用");
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
eprintln!(" ✓ 托盘图标创建成功");
|
||||||
|
info!("系统托盘设置完成");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
99
src/app/state.rs
Normal file
99
src/app/state.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
//! 应用状态管理
|
||||||
|
|
||||||
|
use crate::config::HistoryEntry;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// 应用最大历史记录数
|
||||||
|
const MAX_HISTORY: usize = 100;
|
||||||
|
|
||||||
|
/// 应用全局状态
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// 是否正在录音
|
||||||
|
is_recording: RwLock<bool>,
|
||||||
|
/// 当前录音路径
|
||||||
|
current_recording_path: RwLock<Option<String>>,
|
||||||
|
/// 识别历史记录
|
||||||
|
history: RwLock<VecDeque<HistoryEntry>>,
|
||||||
|
/// 当前使用的模型名称
|
||||||
|
current_model: RwLock<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// 创建新的应用状态
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
is_recording: RwLock::new(false),
|
||||||
|
current_recording_path: RwLock::new(None),
|
||||||
|
history: RwLock::new(VecDeque::with_capacity(MAX_HISTORY)),
|
||||||
|
current_model: RwLock::new("sensevoice-small".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置录音状态
|
||||||
|
pub fn set_recording(&self, recording: bool) {
|
||||||
|
*self.is_recording.write() = recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否在录音
|
||||||
|
pub fn is_recording(&self) -> bool {
|
||||||
|
*self.is_recording.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置当前录音路径
|
||||||
|
pub fn set_recording_path(&self, path: String) {
|
||||||
|
*self.current_recording_path.write() = Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前录音路径
|
||||||
|
pub fn get_recording_path(&self) -> Option<String> {
|
||||||
|
self.current_recording_path.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加历史记录
|
||||||
|
pub fn add_history(&self, entry: HistoryEntry) {
|
||||||
|
let mut history = self.history.write();
|
||||||
|
if history.len() >= MAX_HISTORY {
|
||||||
|
history.pop_front();
|
||||||
|
}
|
||||||
|
history.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取历史记录
|
||||||
|
pub fn get_history(&self, limit: usize) -> Vec<HistoryEntry> {
|
||||||
|
self.history
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空历史记录
|
||||||
|
pub fn clear_history(&self) {
|
||||||
|
self.history.write().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置当前模型
|
||||||
|
pub fn set_current_model(&self, model: String) {
|
||||||
|
*self.current_model.write() = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前模型
|
||||||
|
pub fn get_current_model(&self) -> String {
|
||||||
|
self.current_model.read().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for AppState {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
is_recording: RwLock::new(*self.is_recording.read()),
|
||||||
|
current_recording_path: RwLock::new(self.current_recording_path.read().clone()),
|
||||||
|
history: RwLock::new(self.history.read().clone()),
|
||||||
|
current_model: RwLock::new(self.current_model.read().clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/asr/decoder.rs
Normal file
137
src/asr/decoder.rs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
//! 识别结果解码模块
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use ndarray::{ArrayViewD, s};
|
||||||
|
|
||||||
|
/// 解码 logits 输出到文本
|
||||||
|
///
|
||||||
|
/// 根据具体模型的词表进行解码
|
||||||
|
pub fn decode_logits(logits: &ArrayViewD<f32>) -> Result<String> {
|
||||||
|
// TODO: 根据 SenseVoice 的词表解码
|
||||||
|
// 这需要根据实际模型的输出格式调整
|
||||||
|
|
||||||
|
// 简化示例:假设直接输出概率分布
|
||||||
|
let shape = logits.shape();
|
||||||
|
if shape.len() < 2 {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greedy 解码:选择每个时间步概率最高的 token
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
for i in 0..shape[1] {
|
||||||
|
let slice = logits.slice(s![0, i, ..]);
|
||||||
|
if let Some(max_idx) = slice.argmax() {
|
||||||
|
tokens.push(max_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 token IDs 转换为文本
|
||||||
|
// TODO: 加载实际词表
|
||||||
|
let text = tokens_to_text(&tokens);
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 token IDs 转换为文本
|
||||||
|
fn tokens_to_text(tokens: &[usize]) -> String {
|
||||||
|
// TODO: 使用实际的词表
|
||||||
|
// 这里仅作为示例
|
||||||
|
// SenseVoice 使用字符级或 BPE 词表
|
||||||
|
|
||||||
|
// 占位实现
|
||||||
|
format!("[识别结果:{} 个 tokens]", tokens.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CTC 解码
|
||||||
|
pub struct CtcDecoder {
|
||||||
|
/// 空白 token ID
|
||||||
|
blank_id: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CtcDecoder {
|
||||||
|
pub fn new(blank_id: usize) -> Self {
|
||||||
|
Self { blank_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CTC greedy 解码
|
||||||
|
pub fn greedy_decode(&self, logits: &ArrayViewD<f32>) -> Vec<usize> {
|
||||||
|
let shape = logits.shape();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut prev_token = self.blank_id;
|
||||||
|
|
||||||
|
for i in 0..shape[1] {
|
||||||
|
let slice = logits.slice(s![0, i, ..]);
|
||||||
|
if let Some(max_idx) = slice.argmax() {
|
||||||
|
if max_idx != self.blank_id && max_idx != prev_token {
|
||||||
|
tokens.push(max_idx);
|
||||||
|
}
|
||||||
|
prev_token = max_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CTC beam search 解码 (更高效但更复杂)
|
||||||
|
pub fn beam_search_decode(&self, _logits: &ArrayViewD<f32>, _beam_size: usize) -> Vec<(Vec<usize>, f32)> {
|
||||||
|
// TODO: 实现 beam search
|
||||||
|
todo!("Beam search 解码待实现")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whisper 风格的解码器
|
||||||
|
pub struct WhisperDecoder {
|
||||||
|
/// 词表
|
||||||
|
vocabulary: std::collections::HashMap<usize, String>,
|
||||||
|
/// 特殊 token
|
||||||
|
eos_token: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WhisperDecoder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
vocabulary: std::collections::HashMap::new(),
|
||||||
|
eos_token: 50257, // Whisper 默认 EOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载词表
|
||||||
|
pub fn load_vocabulary<P: AsRef<std::path::Path>>(&mut self, _path: P) -> Result<()> {
|
||||||
|
// TODO: 从文件加载词表
|
||||||
|
todo!("词表加载待实现")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码单个序列
|
||||||
|
pub fn decode(&self, tokens: &[usize]) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
for &token in tokens {
|
||||||
|
if token == self.eos_token {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(word) = self.vocabulary.get(&token) {
|
||||||
|
text.push_str(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WhisperDecoder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 trait 用于查找最大值索引
|
||||||
|
trait ArgMax {
|
||||||
|
fn argmax(&self) -> Option<usize>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArgMax for ndarray::ArrayView1<'_, f32> {
|
||||||
|
fn argmax(&self) -> Option<usize> {
|
||||||
|
self.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/asr/engine.rs
Normal file
119
src/asr/engine.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//! ASR 识别引擎
|
||||||
|
//!
|
||||||
|
//! 负责加载模型并执行推理
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::audio::AudioData;
|
||||||
|
|
||||||
|
use super::model::ModelConfig;
|
||||||
|
use super::types::RecognizeResult;
|
||||||
|
|
||||||
|
/// 全局 ASR 引擎
|
||||||
|
static ASR_ENGINE: OnceLock<AsrEngine> = OnceLock::new();
|
||||||
|
|
||||||
|
/// ASR 引擎
|
||||||
|
pub struct AsrEngine {
|
||||||
|
/// 模型配置
|
||||||
|
config: ModelConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsrEngine {
|
||||||
|
/// 创建新的 ASR 引擎
|
||||||
|
pub fn new(config: ModelConfig) -> Result<Self> {
|
||||||
|
info!("创建 ASR 引擎,模型路径:{:?}", config.model_path);
|
||||||
|
|
||||||
|
if !config.model_path.exists() {
|
||||||
|
error!("模型文件不存在:{:?}", config.model_path);
|
||||||
|
anyhow::bail!("模型文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("ASR 引擎初始化完成");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别音频
|
||||||
|
pub fn recognize(&self, audio: &AudioData) -> Result<RecognizeResult> {
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
info!("开始识别:时长={:.2}s", audio.duration_secs);
|
||||||
|
|
||||||
|
// TODO: 实现 ONNX 推理
|
||||||
|
// 目前返回模拟结果用于测试
|
||||||
|
|
||||||
|
let duration_ms = start_time.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let text = format!("[模拟识别结果] 音频时长:{:.2}秒,采样率:{}Hz",
|
||||||
|
audio.duration_secs, audio.sample_rate);
|
||||||
|
|
||||||
|
info!("识别完成:耗时={}ms", duration_ms);
|
||||||
|
|
||||||
|
Ok(RecognizeResult {
|
||||||
|
text,
|
||||||
|
language: "zh".to_string(),
|
||||||
|
confidence: 0.95,
|
||||||
|
duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型信息
|
||||||
|
pub fn get_model_info(&self) -> &ModelConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别音频文件
|
||||||
|
pub async fn recognize(audio_path: &str) -> Result<RecognizeResult> {
|
||||||
|
// 确保引擎已初始化
|
||||||
|
let engine = ensure_engine_initialized()?;
|
||||||
|
|
||||||
|
// 解码音频
|
||||||
|
let audio = crate::audio::decoder::decode_audio_for_asr(Path::new(audio_path))?;
|
||||||
|
|
||||||
|
// 执行识别
|
||||||
|
engine.recognize(&audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保引擎已初始化
|
||||||
|
fn ensure_engine_initialized() -> Result<&'static AsrEngine> {
|
||||||
|
// 检查是否已初始化
|
||||||
|
if let Some(engine) = ASR_ENGINE.get() {
|
||||||
|
return Ok(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试初始化默认模型
|
||||||
|
warn!("ASR 引擎未初始化,尝试初始化默认模型");
|
||||||
|
|
||||||
|
let config = ModelConfig::default();
|
||||||
|
|
||||||
|
if !config.model_exists() {
|
||||||
|
error!("模型文件不存在:{:?}", config.model_path);
|
||||||
|
anyhow::bail!("模型文件不存在,请先下载模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = AsrEngine::new(config)?;
|
||||||
|
|
||||||
|
Ok(ASR_ENGINE.get_or_init(|| engine))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 ASR 引擎
|
||||||
|
pub fn init_engine(config: ModelConfig) -> Result<()> {
|
||||||
|
let engine = AsrEngine::new(config)?;
|
||||||
|
|
||||||
|
if ASR_ENGINE.set(engine).is_err() {
|
||||||
|
warn!("ASR 引擎已被初始化");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭 ASR 引擎
|
||||||
|
pub fn close_engine() {
|
||||||
|
info!("ASR 引擎关闭请求 (实际清理在程序退出时)");
|
||||||
|
}
|
||||||
20
src/asr/mod.rs
Normal file
20
src/asr/mod.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//! ASR (自动语音识别) 核心模块
|
||||||
|
//!
|
||||||
|
//! 基于 ONNX Runtime 实现语音识别功能
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod model;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod stream;
|
||||||
|
|
||||||
|
pub use types::{RecognizeResult, Language};
|
||||||
|
pub use engine::recognize;
|
||||||
|
pub use model::{AsrModel, ModelConfig};
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 清理 ASR 资源
|
||||||
|
pub fn cleanup() {
|
||||||
|
info!("ASR 模块清理完成");
|
||||||
|
}
|
||||||
172
src/asr/model.rs
Normal file
172
src/asr/model.rs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
//! ASR 模型模块
|
||||||
|
//!
|
||||||
|
//! 定义模型配置和加载逻辑
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 模型配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelConfig {
|
||||||
|
/// 模型文件路径
|
||||||
|
pub model_path: PathBuf,
|
||||||
|
/// 模型名称
|
||||||
|
pub name: String,
|
||||||
|
/// 支持的语言
|
||||||
|
pub languages: Vec<String>,
|
||||||
|
/// 是否使用 GPU 加速
|
||||||
|
pub use_gpu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// 默认模型路径
|
||||||
|
model_path: PathBuf::from("models/sensevoice-small.onnx"),
|
||||||
|
name: "sensevoice-small".to_string(),
|
||||||
|
languages: vec!["zh".to_string(), "en".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelConfig {
|
||||||
|
/// 创建新的模型配置
|
||||||
|
pub fn new<P: AsRef<Path>>(model_path: P, name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
model_path: model_path.as_ref().to_path_buf(),
|
||||||
|
name: name.to_string(),
|
||||||
|
languages: vec!["zh".to_string(), "en".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从配置文件加载
|
||||||
|
pub fn from_config_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
|
let content = std::fs::read_to_string(path.as_ref())
|
||||||
|
.with_context(|| format!("无法读取配置文件:{:?}", path.as_ref()))?;
|
||||||
|
|
||||||
|
let config: Self = toml::from_str(&content)
|
||||||
|
.with_context(|| "无法解析模型配置")?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存到配置文件
|
||||||
|
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
|
let content = toml::to_string(self)?;
|
||||||
|
std::fs::write(path.as_ref(), content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查模型文件是否存在
|
||||||
|
pub fn model_exists(&self) -> bool {
|
||||||
|
self.model_path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型文件大小 (MB)
|
||||||
|
pub fn model_size_mb(&self) -> Option<u64> {
|
||||||
|
std::fs::metadata(&self.model_path)
|
||||||
|
.ok()
|
||||||
|
.map(|m| m.len() / 1024 / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASR 模型封装
|
||||||
|
pub struct AsrModel {
|
||||||
|
/// 模型配置
|
||||||
|
pub config: ModelConfig,
|
||||||
|
/// 是否已加载
|
||||||
|
loaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsrModel {
|
||||||
|
/// 创建新的模型实例
|
||||||
|
pub fn new(config: ModelConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
loaded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载模型
|
||||||
|
pub fn load(&mut self) -> Result<()> {
|
||||||
|
info!("加载模型:{}", self.config.name);
|
||||||
|
|
||||||
|
if !self.config.model_exists() {
|
||||||
|
anyhow::bail!("模型文件不存在:{:?}", self.config.model_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.loaded = true;
|
||||||
|
info!("模型加载完成:{} ({:?} MB)",
|
||||||
|
self.config.name,
|
||||||
|
self.config.model_size_mb());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载模型
|
||||||
|
pub fn unload(&mut self) {
|
||||||
|
self.loaded = false;
|
||||||
|
info!("模型已卸载:{}", self.config.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否已加载
|
||||||
|
pub fn is_loaded(&self) -> bool {
|
||||||
|
self.loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预定义的模型配置
|
||||||
|
pub mod presets {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// SenseVoice Small (推荐)
|
||||||
|
pub fn sensevoice_small() -> ModelConfig {
|
||||||
|
ModelConfig {
|
||||||
|
model_path: PathBuf::from("models/sensevoice-small.onnx"),
|
||||||
|
name: "sensevoice-small".to_string(),
|
||||||
|
languages: vec!["zh".to_string(), "en".to_string(), "ja".to_string(), "ko".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SenseVoice Base
|
||||||
|
pub fn sensevoice_base() -> ModelConfig {
|
||||||
|
ModelConfig {
|
||||||
|
model_path: PathBuf::from("models/sensevoice-base.onnx"),
|
||||||
|
name: "sensevoice-base".to_string(),
|
||||||
|
languages: vec!["zh".to_string(), "en".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FunASR Paraformer
|
||||||
|
pub fn paraformer() -> ModelConfig {
|
||||||
|
ModelConfig {
|
||||||
|
model_path: PathBuf::from("models/paraformer.onnx"),
|
||||||
|
name: "paraformer".to_string(),
|
||||||
|
languages: vec!["zh".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whisper Small (ONNX 版本)
|
||||||
|
pub fn whisper_small() -> ModelConfig {
|
||||||
|
ModelConfig {
|
||||||
|
model_path: PathBuf::from("models/whisper-small.onnx"),
|
||||||
|
name: "whisper-small".to_string(),
|
||||||
|
languages: vec!["zh".to_string(), "en".to_string(), "ja".to_string(), "ko".to_string()],
|
||||||
|
use_gpu: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载模型 (异步)
|
||||||
|
pub async fn download_model(_name: &str, _output_path: &Path) -> Result<()> {
|
||||||
|
// TODO: 实现模型下载
|
||||||
|
// 可以从 ModelScope、HuggingFace 等下载
|
||||||
|
todo!("模型下载功能待实现")
|
||||||
|
}
|
||||||
167
src/asr/stream.rs
Normal file
167
src/asr/stream.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
//! 流式识别模块
|
||||||
|
//!
|
||||||
|
//! 支持边录音边识别,降低延迟
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
asr::{RecognizeResult, engine::AsrEngine},
|
||||||
|
audio::AudioData,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 流式识别器
|
||||||
|
pub struct StreamRecognizer {
|
||||||
|
/// 音频片段缓冲区
|
||||||
|
buffer: Vec<f32>,
|
||||||
|
/// 采样率
|
||||||
|
sample_rate: u32,
|
||||||
|
/// 识别间隔 (毫秒)
|
||||||
|
interval_ms: u64,
|
||||||
|
/// 最小识别长度 (毫秒)
|
||||||
|
min_duration_ms: u64,
|
||||||
|
/// 是否正在识别
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamRecognizer {
|
||||||
|
/// 创建新的流式识别器
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: Vec::new(),
|
||||||
|
sample_rate,
|
||||||
|
interval_ms: 1000, // 每秒识别一次
|
||||||
|
min_duration_ms: 500, // 最小 500ms 才开始识别
|
||||||
|
is_active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加音频数据
|
||||||
|
pub fn push_audio(&mut self, samples: &[f32]) {
|
||||||
|
self.buffer.extend_from_slice(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否应该进行识别
|
||||||
|
pub fn should_recognize(&self) -> bool {
|
||||||
|
if !self.is_active {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有足够的音频数据
|
||||||
|
let duration_ms = self.buffer.len() as u64 * 1000 / self.sample_rate as u64;
|
||||||
|
duration_ms >= self.min_duration_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行识别
|
||||||
|
pub async fn recognize(&mut self, engine: &AsrEngine) -> Result<Option<RecognizeResult>> {
|
||||||
|
if !self.should_recognize() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建音频数据
|
||||||
|
let audio = AudioData::new(
|
||||||
|
self.buffer.clone(),
|
||||||
|
self.sample_rate,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行识别
|
||||||
|
match engine.recognize(&audio) {
|
||||||
|
Ok(result) => {
|
||||||
|
// 清空缓冲区 (或者保留一小部分用于上下文)
|
||||||
|
self.buffer.clear();
|
||||||
|
Ok(Some(result))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("流式识别失败:{}", e);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始流式识别
|
||||||
|
pub fn start(&mut self) {
|
||||||
|
self.is_active = true;
|
||||||
|
self.buffer.clear();
|
||||||
|
info!("流式识别已启动");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止流式识别
|
||||||
|
pub fn stop(&mut self) -> Option<Vec<f32>> {
|
||||||
|
self.is_active = false;
|
||||||
|
let remaining = std::mem::take(&mut self.buffer);
|
||||||
|
|
||||||
|
if remaining.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置识别间隔
|
||||||
|
pub fn with_interval(mut self, interval_ms: u64) -> Self {
|
||||||
|
self.interval_ms = interval_ms;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置最小识别长度
|
||||||
|
pub fn with_min_duration(mut self, min_duration_ms: u64) -> Self {
|
||||||
|
self.min_duration_ms = min_duration_ms;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式识别通道
|
||||||
|
pub struct StreamChannel {
|
||||||
|
/// 音频输入通道
|
||||||
|
audio_tx: mpsc::Sender<Vec<f32>>,
|
||||||
|
/// 结果输出通道
|
||||||
|
result_rx: mpsc::Receiver<RecognizeResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamChannel {
|
||||||
|
/// 创建新的流式通道
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (audio_tx, mut audio_rx) = mpsc::channel::<Vec<f32>>(100);
|
||||||
|
let (_result_tx, result_rx) = mpsc::channel::<RecognizeResult>(10);
|
||||||
|
|
||||||
|
// 启动后台处理任务
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// TODO: 初始化 ASR 引擎
|
||||||
|
// let engine = ...
|
||||||
|
|
||||||
|
while let Some(samples) = audio_rx.recv().await {
|
||||||
|
// 处理音频片段
|
||||||
|
debug!("收到音频片段:{} 样本", samples.len());
|
||||||
|
|
||||||
|
// TODO: 执行识别并发送结果
|
||||||
|
// if let Ok(result) = engine.recognize(...) {
|
||||||
|
// result_tx.send(result).await.ok();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
audio_tx,
|
||||||
|
result_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送音频数据
|
||||||
|
pub async fn send_audio(&self, samples: Vec<f32>) -> Result<()> {
|
||||||
|
self.audio_tx.send(samples).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接收识别结果
|
||||||
|
pub async fn recv_result(&mut self) -> Option<RecognizeResult> {
|
||||||
|
self.result_rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StreamChannel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/asr/types.rs
Normal file
49
src/asr/types.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//! ASR 识别结果类型
|
||||||
|
|
||||||
|
/// 识别结果
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecognizeResult {
|
||||||
|
/// 识别出的文字
|
||||||
|
pub text: String,
|
||||||
|
/// 语言
|
||||||
|
pub language: String,
|
||||||
|
/// 置信度 (0.0 - 1.0)
|
||||||
|
pub confidence: f32,
|
||||||
|
/// 处理耗时 (毫秒)
|
||||||
|
pub duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 支持的语言
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Language {
|
||||||
|
Chinese,
|
||||||
|
English,
|
||||||
|
Japanese,
|
||||||
|
Korean,
|
||||||
|
Cantonese,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
/// 从字符串解析语言
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"zh" | "chinese" | "中文" => Some(Language::Chinese),
|
||||||
|
"en" | "english" | "英文" => Some(Language::English),
|
||||||
|
"ja" | "japanese" | "日本語" => Some(Language::Japanese),
|
||||||
|
"ko" | "korean" | "한국어" => Some(Language::Korean),
|
||||||
|
"yue" | "cantonese" | "粤语" => Some(Language::Cantonese),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取语言代码
|
||||||
|
pub fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Language::Chinese => "zh",
|
||||||
|
Language::English => "en",
|
||||||
|
Language::Japanese => "ja",
|
||||||
|
Language::Korean => "ko",
|
||||||
|
Language::Cantonese => "yue",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/audio/capture.rs
Normal file
46
src/audio/capture.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//! 音频捕获模块
|
||||||
|
//!
|
||||||
|
//! 注意:此模块需要 cpal 库,当前已被禁用
|
||||||
|
//! 在完整版本中,用于实现实时录音功能
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 录音配置
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecordingConfig {
|
||||||
|
/// 采样率
|
||||||
|
pub sample_rate: u32,
|
||||||
|
/// 声道数
|
||||||
|
pub channels: u16,
|
||||||
|
/// 输出路径
|
||||||
|
pub output_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RecordingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
output_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制音频(占位实现)
|
||||||
|
///
|
||||||
|
/// 注意:此功能需要系统音频库支持
|
||||||
|
/// 在完整版本中实现实时录音
|
||||||
|
pub async fn record_audio(_config: RecordingConfig) -> Result<(String, f32)> {
|
||||||
|
anyhow::bail!("录音功能需要 cpal 库支持,当前构建版本已禁用。请启用 cpal 特性并安装系统音频库。")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可用的输入设备列表
|
||||||
|
pub fn list_input_devices() -> Vec<String> {
|
||||||
|
vec!["[需要 cpal 库支持]".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取默认输入设备信息
|
||||||
|
pub fn get_default_input_device_info() -> Option<String> {
|
||||||
|
Some("[需要 cpal 库支持]".to_string())
|
||||||
|
}
|
||||||
109
src/audio/decoder.rs
Normal file
109
src/audio/decoder.rs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
//! 音频解码模块
|
||||||
|
//!
|
||||||
|
//! 使用 hound 库解码 WAV 文件
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::audio::AudioData;
|
||||||
|
|
||||||
|
/// 解码 WAV 文件
|
||||||
|
///
|
||||||
|
/// 目前仅支持 WAV 格式
|
||||||
|
/// 返回解码后的音频数据
|
||||||
|
pub fn decode_audio(path: &Path) -> Result<AudioData> {
|
||||||
|
info!("解码音频文件:{:?}", path);
|
||||||
|
|
||||||
|
let reader = hound::WavReader::open(path)
|
||||||
|
.with_context(|| format!("无法打开文件:{:?}", path))?;
|
||||||
|
|
||||||
|
let spec = reader.spec();
|
||||||
|
let len = reader.len() as usize;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"音频信息:采样率={}, 声道={}, 位深={:?}, 样本数={}",
|
||||||
|
spec.sample_rate,
|
||||||
|
spec.channels,
|
||||||
|
spec.sample_format,
|
||||||
|
len
|
||||||
|
);
|
||||||
|
|
||||||
|
let samples: Vec<f32> = match spec.sample_format {
|
||||||
|
hound::SampleFormat::Int => {
|
||||||
|
let bits = spec.bits_per_sample as u32;
|
||||||
|
let max_val = (1 << (bits - 1)) as f32;
|
||||||
|
if bits <= 16 {
|
||||||
|
reader
|
||||||
|
.into_samples::<i16>()
|
||||||
|
.filter_map(|s| s.ok())
|
||||||
|
.map(|s| s as f32 / max_val)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
reader
|
||||||
|
.into_samples::<i32>()
|
||||||
|
.filter_map(|s| s.ok())
|
||||||
|
.map(|s| s as f32 / max_val)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hound::SampleFormat::Float => {
|
||||||
|
reader
|
||||||
|
.into_samples::<f32>()
|
||||||
|
.filter_map(|s| s.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AudioData::new(samples, spec.sample_rate, spec.channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码音频并转换为 ASR 所需的格式
|
||||||
|
///
|
||||||
|
/// 转换为单声道、16kHz 采样率
|
||||||
|
pub fn decode_audio_for_asr(path: &Path) -> Result<AudioData> {
|
||||||
|
let mut audio = decode_audio(path)?;
|
||||||
|
|
||||||
|
// 转换为单声道
|
||||||
|
let mono_samples = audio.to_mono();
|
||||||
|
|
||||||
|
// 重采样到 16kHz (如果需要)
|
||||||
|
if audio.sample_rate != 16000 {
|
||||||
|
audio.samples = resample_to_16k(&mono_samples, audio.sample_rate)?;
|
||||||
|
audio.sample_rate = 16000;
|
||||||
|
audio.channels = 1;
|
||||||
|
} else {
|
||||||
|
audio.samples = mono_samples;
|
||||||
|
audio.channels = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重采样到 16kHz
|
||||||
|
fn resample_to_16k(samples: &[f32], original_rate: u32) -> Result<Vec<f32>> {
|
||||||
|
if original_rate == 16000 {
|
||||||
|
return Ok(samples.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的线性插值重采样
|
||||||
|
let ratio = 16000.0 / original_rate as f32;
|
||||||
|
let new_len = (samples.len() as f32 * ratio) as usize;
|
||||||
|
let mut resampled = Vec::with_capacity(new_len);
|
||||||
|
|
||||||
|
for i in 0..new_len {
|
||||||
|
let src_idx = i as f32 / ratio;
|
||||||
|
let src_idx_floor = src_idx.floor() as usize;
|
||||||
|
let src_idx_ceil = src_idx.ceil() as usize;
|
||||||
|
|
||||||
|
if src_idx_ceil >= samples.len() {
|
||||||
|
resampled.push(*samples.last().unwrap_or(&0.0));
|
||||||
|
} else {
|
||||||
|
let frac = src_idx - src_idx_floor as f32;
|
||||||
|
let sample = samples[src_idx_floor] * (1.0 - frac) + samples[src_idx_ceil] * frac;
|
||||||
|
resampled.push(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resampled)
|
||||||
|
}
|
||||||
101
src/audio/mod.rs
Normal file
101
src/audio/mod.rs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//! 音频处理模块
|
||||||
|
//!
|
||||||
|
//! 负责音频捕获、解码、预处理等功能
|
||||||
|
|
||||||
|
pub mod capture;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod processor;
|
||||||
|
pub mod resampler;
|
||||||
|
|
||||||
|
pub use capture::{record_audio, RecordingConfig, list_input_devices, get_default_input_device_info};
|
||||||
|
pub use decoder::decode_audio;
|
||||||
|
pub use processor::AudioProcessor;
|
||||||
|
pub use resampler::resample_audio;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 清理音频资源
|
||||||
|
pub fn cleanup() {
|
||||||
|
info!("音频模块清理完成");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音频格式枚举
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AudioFormat {
|
||||||
|
Wav,
|
||||||
|
Mp3,
|
||||||
|
Flac,
|
||||||
|
Ogg,
|
||||||
|
M4a,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFormat {
|
||||||
|
/// 从文件扩展名判断音频格式
|
||||||
|
pub fn from_extension(ext: &str) -> Self {
|
||||||
|
match ext.to_lowercase().as_str() {
|
||||||
|
"wav" => AudioFormat::Wav,
|
||||||
|
"mp3" => AudioFormat::Mp3,
|
||||||
|
"flac" => AudioFormat::Flac,
|
||||||
|
"ogg" => AudioFormat::Ogg,
|
||||||
|
"m4a" | "aac" => AudioFormat::M4a,
|
||||||
|
_ => AudioFormat::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取支持的扩展名列表
|
||||||
|
pub fn supported_extensions() -> &'static [&'static str] {
|
||||||
|
&["wav", "mp3", "flac", "ogg", "m4a", "aac"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音频数据表示
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioData {
|
||||||
|
/// 采样数据 (归一化到 [-1.0, 1.0])
|
||||||
|
pub samples: Vec<f32>,
|
||||||
|
/// 采样率
|
||||||
|
pub sample_rate: u32,
|
||||||
|
/// 声道数
|
||||||
|
pub channels: u16,
|
||||||
|
/// 时长 (秒)
|
||||||
|
pub duration_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioData {
|
||||||
|
/// 创建新的音频数据
|
||||||
|
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
|
||||||
|
let duration_secs = samples.len() as f32 / (sample_rate as f32 * channels as f32);
|
||||||
|
Self {
|
||||||
|
samples,
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
duration_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为单声道
|
||||||
|
pub fn to_mono(&self) -> Vec<f32> {
|
||||||
|
if self.channels == 1 {
|
||||||
|
self.samples.clone()
|
||||||
|
} else {
|
||||||
|
let mut mono = Vec::with_capacity(self.samples.len() / self.channels as usize);
|
||||||
|
for chunk in self.samples.chunks(self.channels as usize) {
|
||||||
|
// 取所有声道的平均值
|
||||||
|
let avg = chunk.iter().sum::<f32>() / self.channels as f32;
|
||||||
|
mono.push(avg);
|
||||||
|
}
|
||||||
|
mono
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取样本总数
|
||||||
|
pub fn num_samples(&self) -> usize {
|
||||||
|
self.samples.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取总帧数 (用于模型输入)
|
||||||
|
pub fn num_frames(&self) -> usize {
|
||||||
|
self.num_samples() / self.channels as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/audio/processor.rs
Normal file
111
src/audio/processor.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! 音频预处理模块
|
||||||
|
|
||||||
|
use crate::audio::AudioData;
|
||||||
|
|
||||||
|
/// 音频处理器
|
||||||
|
pub struct AudioProcessor {
|
||||||
|
/// 是否进行音量归一化
|
||||||
|
normalize: bool,
|
||||||
|
/// 是否进行预加重
|
||||||
|
pre_emphasis: bool,
|
||||||
|
/// 预加重系数
|
||||||
|
pre_emphasis_coef: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioProcessor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
normalize: true,
|
||||||
|
pre_emphasis: true,
|
||||||
|
pre_emphasis_coef: 0.97,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioProcessor {
|
||||||
|
/// 创建新的处理器
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理音频数据
|
||||||
|
pub fn process(&self, audio: &AudioData) -> AudioData {
|
||||||
|
let mut samples = audio.samples.clone();
|
||||||
|
|
||||||
|
// 音量归一化
|
||||||
|
if self.normalize {
|
||||||
|
self.normalize_volume(&mut samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加重
|
||||||
|
if self.pre_emphasis {
|
||||||
|
samples = self.pre_emphasis(&samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioData::new(samples, audio.sample_rate, audio.channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音量归一化
|
||||||
|
fn normalize_volume(&self, samples: &mut [f32]) {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最大振幅
|
||||||
|
let max_amplitude = samples.iter().map(|s| s.abs()).fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
|
||||||
|
if max_amplitude > 0.0 && max_amplitude < 1.0 {
|
||||||
|
// 归一化到 [-1, 1] 范围
|
||||||
|
let scale = 1.0 / max_amplitude;
|
||||||
|
for sample in samples.iter_mut() {
|
||||||
|
*sample *= scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预加重滤波
|
||||||
|
/// 增强高频成分,有助于语音识别
|
||||||
|
fn pre_emphasis(&self, samples: &[f32]) -> Vec<f32> {
|
||||||
|
if samples.len() < 2 {
|
||||||
|
return samples.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut filtered = Vec::with_capacity(samples.len());
|
||||||
|
filtered.push(samples[0]);
|
||||||
|
|
||||||
|
for i in 1..samples.len() {
|
||||||
|
let value = samples[i] - self.pre_emphasis_coef * samples[i - 1];
|
||||||
|
filtered.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置是否归一化
|
||||||
|
pub fn with_normalize(mut self, normalize: bool) -> Self {
|
||||||
|
self.normalize = normalize;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置是否预加重
|
||||||
|
pub fn with_pre_emphasis(mut self, pre_emphasis: bool, coef: f32) -> Self {
|
||||||
|
self.pre_emphasis = pre_emphasis;
|
||||||
|
self.pre_emphasis_coef = coef;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_volume() {
|
||||||
|
let processor = AudioProcessor::new();
|
||||||
|
let mut samples = vec![0.1, -0.2, 0.3, -0.1, 0.05];
|
||||||
|
processor.normalize_volume(&mut samples);
|
||||||
|
|
||||||
|
// 最大值应该被归一化到 1.0
|
||||||
|
assert!((samples.iter().map(|s| s.abs()).fold(0.0, f32::max) - 1.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/audio/resampler.rs
Normal file
46
src/audio/resampler.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//! 音频重采样模块
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// 重采样音频
|
||||||
|
///
|
||||||
|
/// 使用线性插值进行重采样
|
||||||
|
pub fn resample_audio(samples: &[f32], from_rate: u32, to_rate: u32) -> Result<Vec<f32>> {
|
||||||
|
if from_rate == to_rate {
|
||||||
|
return Ok(samples.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = to_rate as f32 / from_rate as f32;
|
||||||
|
let new_len = (samples.len() as f32 * ratio) as usize;
|
||||||
|
|
||||||
|
if new_len == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resampled = Vec::with_capacity(new_len);
|
||||||
|
|
||||||
|
for i in 0..new_len {
|
||||||
|
let src_idx = i as f32 / ratio;
|
||||||
|
let src_idx_floor = src_idx.floor() as usize;
|
||||||
|
let src_idx_ceil = src_idx.ceil() as usize;
|
||||||
|
|
||||||
|
if src_idx_ceil >= samples.len() {
|
||||||
|
resampled.push(*samples.last().unwrap_or(&0.0));
|
||||||
|
} else if src_idx_floor >= samples.len() {
|
||||||
|
resampled.push(*samples.first().unwrap_or(&0.0));
|
||||||
|
} else {
|
||||||
|
let frac = src_idx - src_idx_floor as f32;
|
||||||
|
let sample = samples[src_idx_floor] * (1.0 - frac) + samples[src_idx_ceil] * frac;
|
||||||
|
resampled.push(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resampled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用更高品质的重采样算法 (可选)
|
||||||
|
pub fn resample_audio_high_quality(_samples: &[f32], _from_rate: u32, _to_rate: u32) -> Result<Vec<f32>> {
|
||||||
|
// TODO: 实现基于 sinc 插值的高品质重采样
|
||||||
|
// 可以使用 rubato crate 实现
|
||||||
|
todo!("高品质重采样待实现")
|
||||||
|
}
|
||||||
186
src/bin/cli.rs
Normal file
186
src/bin/cli.rs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
//! 命令行语音识别工具
|
||||||
|
//!
|
||||||
|
//! 用法:
|
||||||
|
//! impress_asr record -o output.wav # 录音
|
||||||
|
//! impress_asr recognize audio.wav # 识别音频文件
|
||||||
|
//! impress_asr devices # 列出音频设备
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use impress_asr_lib::audio;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "impress_asr")]
|
||||||
|
#[command(about = "高性能语音识别工具", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// 录制音频
|
||||||
|
Record {
|
||||||
|
/// 输出文件路径
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 录音时长 (秒)
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
duration: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 识别音频文件
|
||||||
|
Recognize {
|
||||||
|
/// 音频文件路径
|
||||||
|
input: PathBuf,
|
||||||
|
|
||||||
|
/// 模型路径
|
||||||
|
#[arg(short, long)]
|
||||||
|
model: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 列出音频设备
|
||||||
|
Devices,
|
||||||
|
|
||||||
|
/// 下载模型
|
||||||
|
Download {
|
||||||
|
/// 模型名称
|
||||||
|
#[arg(short, long, default_value = "sensevoice-small")]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// 输出路径
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// 初始化日志
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive("impress_asr=info".parse().unwrap())
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Record { output, duration } => {
|
||||||
|
info!("开始录音,时长={} 秒", duration);
|
||||||
|
|
||||||
|
let config = audio::RecordingConfig {
|
||||||
|
sample_rate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
output_path: output,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注意:这里需要实现定时录音功能
|
||||||
|
// 当前实现是固定 10 秒
|
||||||
|
match audio::record_audio(config).await {
|
||||||
|
Ok((path, secs)) => {
|
||||||
|
println!("录音完成:{}", path);
|
||||||
|
println!("时长:{:.2} 秒", secs);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("录音失败:{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Recognize { input, model: _model } => {
|
||||||
|
info!("识别音频:{:?}", input);
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if !input.exists() {
|
||||||
|
eprintln!("文件不存在:{:?}", input);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码音频
|
||||||
|
println!("正在加载音频...");
|
||||||
|
let audio_data = match audio::decoder::decode_audio_for_asr(&input) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("解码失败:{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("音频信息:");
|
||||||
|
println!(" 采样率:{} Hz", audio_data.sample_rate);
|
||||||
|
println!(" 声道数:{}", audio_data.channels);
|
||||||
|
println!(" 时长:{:.2} 秒", audio_data.duration_secs);
|
||||||
|
|
||||||
|
// 识别 (需要模型文件)
|
||||||
|
println!("\n正在识别...");
|
||||||
|
println!("注意:需要先下载 ONNX 模型文件");
|
||||||
|
println!("运行:impress_asr download --output models/sensevoice-small.onnx");
|
||||||
|
|
||||||
|
// TODO: 实现识别
|
||||||
|
// match asr::recognize(&input.to_string_lossy()).await {
|
||||||
|
// Ok(result) => {
|
||||||
|
// println!("识别结果:{}", result.text);
|
||||||
|
// }
|
||||||
|
// Err(e) => {
|
||||||
|
// eprintln!("识别失败:{}", e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Devices => {
|
||||||
|
println!("可用音频输入设备:");
|
||||||
|
let devices = audio::list_input_devices();
|
||||||
|
if devices.is_empty() {
|
||||||
|
println!(" 未找到设备");
|
||||||
|
} else {
|
||||||
|
for (i, device) in devices.iter().enumerate() {
|
||||||
|
println!(" {}. {}", i + 1, device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(default) = audio::get_default_input_device_info() {
|
||||||
|
println!("\n默认设备:{}", default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Download { name, output } => {
|
||||||
|
println!("下载模型:{}", name);
|
||||||
|
|
||||||
|
let output_path = output.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(format!("models/{}.onnx", name))
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = output_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("下载链接:");
|
||||||
|
match name.as_str() {
|
||||||
|
"sensevoice-small" => {
|
||||||
|
println!(" ModelScope: https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/main/model.onnx");
|
||||||
|
println!(" HuggingFace: https://huggingface.co/FunAudioLLM/SenseVoiceSmall/resolve/main/model.onnx");
|
||||||
|
}
|
||||||
|
"paraformer" => {
|
||||||
|
println!(" ModelScope: https://modelscope.cn/models/iic/paraformer-zh/resolve/main/model.onnx");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!(" 未知模型,请手动下载");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n保存到:{:?}", output_path);
|
||||||
|
println!("下载后请运行:impress_asr recognize <音频文件>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
122
src/config/manager.rs
Normal file
122
src/config/manager.rs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
//! 配置管理器
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::settings::AppSettings;
|
||||||
|
|
||||||
|
/// 全局配置
|
||||||
|
static CONFIG: OnceCell<RwLock<AppSettings>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// 配置文件夹名称
|
||||||
|
const CONFIG_DIR_NAME: &str = "impress_asr_input_rust";
|
||||||
|
/// 配置文件名
|
||||||
|
const CONFIG_FILE_NAME: &str = "config.toml";
|
||||||
|
|
||||||
|
/// 初始化配置系统
|
||||||
|
pub fn init() -> Result<()> {
|
||||||
|
let config_path = get_config_file_path()?;
|
||||||
|
|
||||||
|
let settings = if config_path.exists() {
|
||||||
|
// 加载现有配置
|
||||||
|
load_config(&config_path)?
|
||||||
|
} else {
|
||||||
|
// 创建默认配置
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
save_config_to_file(&settings, &config_path)?;
|
||||||
|
settings
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("配置已加载:{:?}", config_path);
|
||||||
|
|
||||||
|
CONFIG.get_or_init(|| RwLock::new(settings));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取配置文件路径
|
||||||
|
fn get_config_file_path() -> Result<PathBuf> {
|
||||||
|
let config_dir = dirs::config_dir()
|
||||||
|
.context("无法获取配置目录")?
|
||||||
|
.join(CONFIG_DIR_NAME);
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config_dir.join(CONFIG_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从文件加载配置
|
||||||
|
fn load_config(path: &PathBuf) -> Result<AppSettings> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("无法读取配置文件:{:?}", path))?;
|
||||||
|
|
||||||
|
let settings: AppSettings = toml::from_str(&content)
|
||||||
|
.with_context(|| "配置文件格式错误")?;
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存配置到文件
|
||||||
|
fn save_config_to_file(settings: &AppSettings, path: &PathBuf) -> Result<()> {
|
||||||
|
let content = toml::to_string_pretty(settings)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前配置
|
||||||
|
pub fn get_config() -> Result<AppSettings> {
|
||||||
|
CONFIG.get()
|
||||||
|
.map(|c| c.read().clone())
|
||||||
|
.context("配置系统未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存配置
|
||||||
|
pub fn save_config(settings: &AppSettings) -> Result<()> {
|
||||||
|
let config_path = get_config_file_path()?;
|
||||||
|
|
||||||
|
// 更新内存中的配置
|
||||||
|
if let Some(config) = CONFIG.get() {
|
||||||
|
*config.write() = settings.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到文件
|
||||||
|
save_config_to_file(settings, &config_path)?;
|
||||||
|
|
||||||
|
info!("配置已保存");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置为默认配置
|
||||||
|
pub fn reset_config() -> Result<()> {
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
save_config(&settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取配置目录路径
|
||||||
|
pub fn get_config_dir() -> Result<PathBuf> {
|
||||||
|
Ok(dirs::config_dir()
|
||||||
|
.context("无法获取配置目录")?
|
||||||
|
.join(CONFIG_DIR_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取数据目录路径
|
||||||
|
pub fn get_data_dir() -> Result<PathBuf> {
|
||||||
|
Ok(dirs::data_dir()
|
||||||
|
.context("无法获取数据目录")?
|
||||||
|
.join(CONFIG_DIR_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取缓存目录路径
|
||||||
|
pub fn get_cache_dir() -> Result<PathBuf> {
|
||||||
|
Ok(dirs::cache_dir()
|
||||||
|
.context("无法获取缓存目录")?
|
||||||
|
.join(CONFIG_DIR_NAME))
|
||||||
|
}
|
||||||
17
src/config/mod.rs
Normal file
17
src/config/mod.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//! 配置管理模块
|
||||||
|
|
||||||
|
pub mod settings;
|
||||||
|
pub mod manager;
|
||||||
|
|
||||||
|
pub use settings::{AppSettings, AudioSettings, AsrSettings, HistoryEntry};
|
||||||
|
pub use manager::{get_config, save_config};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 初始化配置系统
|
||||||
|
pub fn init() -> Result<()> {
|
||||||
|
info!("配置系统初始化");
|
||||||
|
manager::init()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
155
src/config/settings.rs
Normal file
155
src/config/settings.rs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//! 应用配置结构
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 应用配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppSettings {
|
||||||
|
/// 应用标识
|
||||||
|
pub app_id: String,
|
||||||
|
/// 主题 (dark/light/auto)
|
||||||
|
pub theme: String,
|
||||||
|
/// 界面语言
|
||||||
|
pub language: String,
|
||||||
|
/// 音频设置
|
||||||
|
pub audio: AudioSettings,
|
||||||
|
/// ASR 设置
|
||||||
|
pub asr: AsrSettings,
|
||||||
|
/// 是否开机自启
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// 是否自动检查更新
|
||||||
|
pub auto_check_update: bool,
|
||||||
|
/// 历史记录保留天数
|
||||||
|
pub history_keep_days: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
app_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
theme: "dark".to_string(),
|
||||||
|
language: "zh-CN".to_string(),
|
||||||
|
audio: AudioSettings::default(),
|
||||||
|
asr: AsrSettings::default(),
|
||||||
|
auto_start: false,
|
||||||
|
auto_check_update: true,
|
||||||
|
history_keep_days: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音频设置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AudioSettings {
|
||||||
|
/// 采样率
|
||||||
|
pub sample_rate: u32,
|
||||||
|
/// 声道数
|
||||||
|
pub channels: u16,
|
||||||
|
/// 麦克风设备名称
|
||||||
|
pub microphone: Option<String>,
|
||||||
|
/// 录音音量阈值 (用于 VAD)
|
||||||
|
pub vad_threshold: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: 16000, // ASR 标准采样率
|
||||||
|
channels: 1, // 单声道
|
||||||
|
microphone: None,
|
||||||
|
vad_threshold: 0.02,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASR 设置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AsrSettings {
|
||||||
|
/// 使用的模型名称
|
||||||
|
pub model: String,
|
||||||
|
/// 模型路径
|
||||||
|
pub model_path: Option<PathBuf>,
|
||||||
|
/// 识别语言
|
||||||
|
pub language: String,
|
||||||
|
/// 是否使用 GPU 加速
|
||||||
|
pub use_gpu: bool,
|
||||||
|
/// 是否启用流式识别
|
||||||
|
pub streaming: bool,
|
||||||
|
/// 是否自动复制结果
|
||||||
|
pub auto_copy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AsrSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model: "sensevoice-small".to_string(),
|
||||||
|
model_path: None,
|
||||||
|
language: "zh".to_string(),
|
||||||
|
use_gpu: false,
|
||||||
|
streaming: true,
|
||||||
|
auto_copy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 历史记录条目
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HistoryEntry {
|
||||||
|
/// 唯一标识
|
||||||
|
pub id: String,
|
||||||
|
/// 时间戳
|
||||||
|
pub timestamp: chrono::DateTime<chrono::Local>,
|
||||||
|
/// 音频文件路径
|
||||||
|
pub audio_path: Option<String>,
|
||||||
|
/// 识别结果
|
||||||
|
pub text: String,
|
||||||
|
/// 语言
|
||||||
|
pub language: String,
|
||||||
|
/// 置信度
|
||||||
|
pub confidence: f32,
|
||||||
|
/// 音频时长 (秒)
|
||||||
|
pub duration_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryEntry {
|
||||||
|
/// 创建新的历史记录
|
||||||
|
pub fn new(text: String, language: String, confidence: f32, duration_secs: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
timestamp: chrono::Local::now(),
|
||||||
|
audio_path: None,
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
confidence,
|
||||||
|
duration_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置音频路径
|
||||||
|
pub fn with_audio_path(mut self, path: String) -> Self {
|
||||||
|
self.audio_path = Some(path);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快捷键配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
/// 开始/停止录音
|
||||||
|
pub start_stop_record: String,
|
||||||
|
/// 快速复制结果
|
||||||
|
pub copy_result: String,
|
||||||
|
/// 显示/隐藏窗口
|
||||||
|
pub toggle_window: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HotkeyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
start_stop_record: "Ctrl+Shift+R".to_string(),
|
||||||
|
copy_result: "Ctrl+Shift+C".to_string(),
|
||||||
|
toggle_window: "Ctrl+Shift+H".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/lib.rs
Normal file
48
src/lib.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
//! impress_asr_input_rust
|
||||||
|
//!
|
||||||
|
//! 高性能跨平台桌面语音识别输入工具
|
||||||
|
//! 基于 ONNX 模型 (如 SenseVoice) 实现语音转文字功能
|
||||||
|
|
||||||
|
pub mod audio;
|
||||||
|
pub mod asr;
|
||||||
|
pub mod config;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
// GUI 相关模块 (可选)
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod app;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod tray;
|
||||||
|
|
||||||
|
// 公开 logger 模块供 main.rs 使用
|
||||||
|
pub use utils::logger;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 应用版本信息
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const NAME: &str = "impress ASR";
|
||||||
|
|
||||||
|
/// 初始化应用
|
||||||
|
pub fn init() -> Result<()> {
|
||||||
|
// 初始化日志系统
|
||||||
|
utils::logger::init_logger()?;
|
||||||
|
|
||||||
|
info!("{} v{} 启动中...", NAME, VERSION);
|
||||||
|
|
||||||
|
// 初始化配置
|
||||||
|
config::init()?;
|
||||||
|
|
||||||
|
info!("应用初始化完成");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理 ASR 资源
|
||||||
|
pub fn cleanup() {
|
||||||
|
info!("应用正在关闭...");
|
||||||
|
// 清理资源
|
||||||
|
asr::cleanup();
|
||||||
|
audio::cleanup();
|
||||||
|
}
|
||||||
89
src/main.rs
Normal file
89
src/main.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
//! 应用主入口
|
||||||
|
//!
|
||||||
|
//! 增强诊断日志版本 - 详细记录启动过程中的所有关键步骤
|
||||||
|
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use impress_asr_lib::{app, init, cleanup};
|
||||||
|
use impress_asr_lib::utils::logger;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// 步骤 1: 显示系统信息
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!(" Impress ASR - 启动诊断");
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("[步骤 1/5] 系统信息");
|
||||||
|
eprintln!(" - 操作系统:{}", std::env::consts::OS);
|
||||||
|
eprintln!(" - 架构:{}", std::env::consts::ARCH);
|
||||||
|
eprintln!(" - 工作目录:{}", std::env::current_dir().unwrap_or_default().display());
|
||||||
|
eprintln!(" - 构建模式:{}", if cfg!(debug_assertions) { "Debug" } else { "Release" });
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 步骤 2: 初始化日志
|
||||||
|
eprintln!("[步骤 2/5] 初始化日志系统...");
|
||||||
|
if let Err(e) = init() {
|
||||||
|
eprintln!("❌ 应用初始化失败:{}", e);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("详细错误:{:#?}", e);
|
||||||
|
|
||||||
|
// 即使初始化失败,也尝试输出到文件
|
||||||
|
let log_dir = logger::get_log_dir();
|
||||||
|
let error_log = log_dir.join("startup_error.log");
|
||||||
|
if let Ok(mut f) = std::fs::File::create(&error_log) {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = writeln!(f, "启动错误日志");
|
||||||
|
let _ = writeln!(f, "时间:{}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"));
|
||||||
|
let _ = writeln!(f, "错误:{}", e);
|
||||||
|
eprintln!(" - 错误日志已写入:{}", error_log.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
eprintln!(" ✓ 日志初始化完成");
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 步骤 3: 显示诊断信息
|
||||||
|
eprintln!("[步骤 3/5] 诊断信息");
|
||||||
|
info!("========================================");
|
||||||
|
info!(" Impress ASR 启动");
|
||||||
|
info!("========================================");
|
||||||
|
info!("版本:{} (构建时间:{})", env!("TIMESTAMPED_VERSION"), env!("BUILD_TIME"));
|
||||||
|
info!("工作目录:{}", std::env::current_dir().unwrap_or_default().display());
|
||||||
|
info!("========================================");
|
||||||
|
eprintln!(" ✓ 诊断信息已记录");
|
||||||
|
eprintln!(" - 日志目录:{}", logger::get_log_dir().display());
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 步骤 4: 运行 Tauri 应用
|
||||||
|
eprintln!("[步骤 4/5] 启动 Tauri 应用...");
|
||||||
|
eprintln!(" - 创建主窗口...");
|
||||||
|
eprintln!(" - 初始化系统托盘...");
|
||||||
|
eprintln!(" - 加载前端资源...");
|
||||||
|
|
||||||
|
if let Err(e) = app::run() {
|
||||||
|
eprintln!("❌ 应用运行错误:{}", e);
|
||||||
|
error!("应用运行错误:{}", e);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("详细错误:{:#?}", e);
|
||||||
|
cleanup();
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
eprintln!(" ✓ Tauri 应用已启动");
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
// 步骤 5: 清理资源
|
||||||
|
eprintln!("[步骤 5/5] 清理资源...");
|
||||||
|
cleanup();
|
||||||
|
eprintln!(" ✓ 资源清理完成");
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
info!("应用已退出");
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!(" 应用已退出");
|
||||||
|
eprintln!("========================================");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
71
src/tray/handler.rs
Normal file
71
src/tray/handler.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
//! 托盘事件处理器
|
||||||
|
|
||||||
|
use super::TrayEvent;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 托盘事件处理器
|
||||||
|
pub struct TrayHandler {
|
||||||
|
/// 是否启用
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrayHandler {
|
||||||
|
/// 创建新的处理器
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { enabled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理托盘事件
|
||||||
|
pub fn handle(&self, event: TrayEvent) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match event {
|
||||||
|
TrayEvent::Show => {
|
||||||
|
info!("托盘事件:显示窗口");
|
||||||
|
// TODO: 显示主窗口
|
||||||
|
}
|
||||||
|
TrayEvent::Record => {
|
||||||
|
info!("托盘事件:开始录音");
|
||||||
|
// TODO: 触发录音
|
||||||
|
}
|
||||||
|
TrayEvent::OpenFile => {
|
||||||
|
info!("托盘事件:打开文件");
|
||||||
|
// TODO: 打开文件选择器
|
||||||
|
}
|
||||||
|
TrayEvent::CopyLastResult => {
|
||||||
|
info!("托盘事件:复制上次结果");
|
||||||
|
// TODO: 复制到剪贴板
|
||||||
|
}
|
||||||
|
TrayEvent::History => {
|
||||||
|
info!("托盘事件:查看历史记录");
|
||||||
|
// TODO: 显示历史记录
|
||||||
|
}
|
||||||
|
TrayEvent::Settings => {
|
||||||
|
info!("托盘事件:打开设置");
|
||||||
|
// TODO: 打开设置页面
|
||||||
|
}
|
||||||
|
TrayEvent::Quit => {
|
||||||
|
info!("托盘事件:退出应用");
|
||||||
|
// TODO: 退出应用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启用处理器
|
||||||
|
pub fn enable(&mut self) {
|
||||||
|
self.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 禁用处理器
|
||||||
|
pub fn disable(&mut self) {
|
||||||
|
self.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TrayHandler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/tray/mod.rs
Normal file
33
src/tray/mod.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//! 系统托盘模块
|
||||||
|
|
||||||
|
pub mod handler;
|
||||||
|
|
||||||
|
pub use handler::TrayHandler;
|
||||||
|
|
||||||
|
/// 托盘事件
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TrayEvent {
|
||||||
|
/// 显示窗口
|
||||||
|
Show,
|
||||||
|
/// 开始录音
|
||||||
|
Record,
|
||||||
|
/// 打开文件识别
|
||||||
|
OpenFile,
|
||||||
|
/// 复制上次结果
|
||||||
|
CopyLastResult,
|
||||||
|
/// 查看历史记录
|
||||||
|
History,
|
||||||
|
/// 打开设置
|
||||||
|
Settings,
|
||||||
|
/// 退出应用
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 托盘图标资源
|
||||||
|
pub mod icons {
|
||||||
|
/// 获取托盘图标数据
|
||||||
|
pub fn get_tray_icon() -> &'static [u8] {
|
||||||
|
// TODO: 嵌入图标资源
|
||||||
|
include_bytes!("../../resources/icons/tray-icon.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/utils/diagnose.rs
Normal file
211
src/utils/diagnose.rs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
//! 增强的日志和诊断模块
|
||||||
|
//!
|
||||||
|
//! 提供详细的运行时日志输出,帮助诊断各种问题
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// 日志文件路径
|
||||||
|
static LOG_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// 诊断信息结构
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DiagnosticInfo {
|
||||||
|
pub os_info: String,
|
||||||
|
pub rust_version: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub frontend_dist: String,
|
||||||
|
pub config_file: String,
|
||||||
|
pub log_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化诊断日志
|
||||||
|
pub fn init_diagnostic_log() -> anyhow::Result<PathBuf> {
|
||||||
|
let log_dir = get_log_dir();
|
||||||
|
std::fs::create_dir_all(&log_dir)?;
|
||||||
|
|
||||||
|
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||||
|
let log_file = log_dir.join(format!("impress-asr-diagnose_{}.log", timestamp));
|
||||||
|
|
||||||
|
let mut file = File::create(&log_file)?;
|
||||||
|
|
||||||
|
// 写入诊断信息头
|
||||||
|
let diag = collect_diagnostic_info(&log_file);
|
||||||
|
|
||||||
|
writeln!(file, "========================================")?;
|
||||||
|
writeln!(file, " Impress ASR - 诊断报告")?;
|
||||||
|
writeln!(file, " 生成时间:{}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"))?;
|
||||||
|
writeln!(file, "========================================")?;
|
||||||
|
writeln!(file)?;
|
||||||
|
writeln!(file, "【系统信息】")?;
|
||||||
|
writeln!(file, " 操作系统:{}", diag.os_info)?;
|
||||||
|
writeln!(file, " Rust 版本:{}", diag.rust_version)?;
|
||||||
|
writeln!(file, " 应用版本:{}", diag.app_version)?;
|
||||||
|
writeln!(file)?;
|
||||||
|
writeln!(file, "【路径信息】")?;
|
||||||
|
writeln!(file, " 前端资源:{}", diag.frontend_dist)?;
|
||||||
|
writeln!(file, " 配置文件:{}", diag.config_file)?;
|
||||||
|
writeln!(file, " 日志文件:{}", diag.log_file)?;
|
||||||
|
writeln!(file)?;
|
||||||
|
writeln!(file, "========================================")?;
|
||||||
|
writeln!(file)?;
|
||||||
|
|
||||||
|
LOG_FILE.set(log_file.clone()).ok();
|
||||||
|
|
||||||
|
Ok(log_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志目录
|
||||||
|
fn get_log_dir() -> PathBuf {
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("impress-asr")
|
||||||
|
.join("logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收集诊断信息
|
||||||
|
fn collect_diagnostic_info(log_file: &PathBuf) -> DiagnosticInfo {
|
||||||
|
let frontend_dist = check_frontend_dist();
|
||||||
|
let config_file = check_config_file();
|
||||||
|
|
||||||
|
DiagnosticInfo {
|
||||||
|
os_info: get_os_info(),
|
||||||
|
rust_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
app_version: format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
|
||||||
|
frontend_dist,
|
||||||
|
config_file,
|
||||||
|
log_file: log_file.to_string_lossy().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查前端资源
|
||||||
|
fn check_frontend_dist() -> String {
|
||||||
|
// Tauri 嵌入的前端资源在运行时通过 tauri::Config 访问
|
||||||
|
// 这里记录配置中的前端路径
|
||||||
|
let config_path = std::env::current_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join("tauri.conf.json");
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
format!("嵌入式 (构建时打包)")
|
||||||
|
} else {
|
||||||
|
format!("未找到 tauri.conf.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查配置文件
|
||||||
|
fn check_config_file() -> String {
|
||||||
|
let config_path = dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("impress-asr")
|
||||||
|
.join("config.toml");
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
config_path.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
"使用默认配置".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取操作系统信息
|
||||||
|
fn get_os_info() -> String {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
format!("Windows")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
format!("macOS")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
format!("Linux")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
format!("Unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录应用启动信息
|
||||||
|
pub fn log_startup_info() {
|
||||||
|
info!("========================================");
|
||||||
|
info!(" Impress ASR 启动");
|
||||||
|
info!("========================================");
|
||||||
|
info!("版本:{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
info!("构建模式:Debug");
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
info!("构建模式:Release");
|
||||||
|
info!("工作目录:{}", std::env::current_dir().unwrap_or_default().display());
|
||||||
|
info!("========================================");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录窗口创建信息
|
||||||
|
pub fn log_window_creation() {
|
||||||
|
info!("[窗口] 开始创建主窗口...");
|
||||||
|
info!("[窗口] 标题:impress ASR Input");
|
||||||
|
info!("[窗口] 尺寸:1000x700");
|
||||||
|
info!("[窗口] 位置:居中");
|
||||||
|
info!("[窗口] 主题:Dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录前端加载信息
|
||||||
|
pub fn log_frontend_load(status: &str) {
|
||||||
|
match status {
|
||||||
|
"success" => {
|
||||||
|
info!("[前端] 前端资源加载成功");
|
||||||
|
}
|
||||||
|
"not_found" => {
|
||||||
|
error!("[前端] 前端资源未找到!");
|
||||||
|
error!("[前端] 请确保已运行 'npm run build'");
|
||||||
|
}
|
||||||
|
"embed" => {
|
||||||
|
info!("[前端] 使用嵌入式前端资源");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("[前端] 未知状态:{}", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录托盘初始化信息
|
||||||
|
pub fn log_tray_init(status: &str) {
|
||||||
|
match status {
|
||||||
|
"success" => {
|
||||||
|
info!("[托盘] 系统托盘初始化成功");
|
||||||
|
info!("[托盘] 图标:icons/tray-icon.png");
|
||||||
|
}
|
||||||
|
"icon_error" => {
|
||||||
|
error!("[托盘] 图标加载失败!");
|
||||||
|
error!("[托盘] 请确保 icons/tray-icon.png 存在");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("[托盘] 初始化状态:{}", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录窗口显示状态
|
||||||
|
pub fn log_window_show(visible: bool) {
|
||||||
|
if visible {
|
||||||
|
info!("[窗口] 窗口已显示");
|
||||||
|
} else {
|
||||||
|
warn!("[窗口] 窗口已隐藏(可能在系统托盘中)");
|
||||||
|
info!("[窗口] 点击托盘图标可显示窗口");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导出诊断日志内容
|
||||||
|
pub fn export_diagnostic_log() -> Option<String> {
|
||||||
|
let log_path = LOG_FILE.get()?;
|
||||||
|
std::fs::read_to_string(log_path).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志文件路径
|
||||||
|
pub fn get_log_file_path() -> Option<String> {
|
||||||
|
LOG_FILE.get().map(|p| p.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
186
src/utils/hotkey.rs
Normal file
186
src/utils/hotkey.rs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
//! 全局快捷键模块
|
||||||
|
//!
|
||||||
|
//! 注意:此模块需要 global-hotkey crate,仅在 GUI 模式下可用
|
||||||
|
|
||||||
|
#![cfg(feature = "gui")]
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager};
|
||||||
|
use global_hotkey::hotkey::HotKey;
|
||||||
|
use global_hotkey::hotkey::Modifiers;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// 快捷键管理器
|
||||||
|
pub struct HotkeyManager {
|
||||||
|
manager: GlobalHotKeyManager,
|
||||||
|
hotkeys: Vec<HotKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyManager {
|
||||||
|
/// 创建新的快捷键管理器
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let manager = GlobalHotKeyManager::new()?;
|
||||||
|
Ok(Self {
|
||||||
|
manager,
|
||||||
|
hotkeys: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册快捷键
|
||||||
|
pub fn register(&mut self, id: u32, key: char, modifiers: Modifiers) -> Result<()> {
|
||||||
|
let code = match key {
|
||||||
|
'R' => global_hotkey::hotkey::Code::KeyR,
|
||||||
|
'C' => global_hotkey::hotkey::Code::KeyC,
|
||||||
|
'H' => global_hotkey::hotkey::Code::KeyH,
|
||||||
|
'0' => global_hotkey::hotkey::Code::Digit0,
|
||||||
|
'1' => global_hotkey::hotkey::Code::Digit1,
|
||||||
|
'2' => global_hotkey::hotkey::Code::Digit2,
|
||||||
|
'3' => global_hotkey::hotkey::Code::Digit3,
|
||||||
|
'4' => global_hotkey::hotkey::Code::Digit4,
|
||||||
|
'5' => global_hotkey::hotkey::Code::Digit5,
|
||||||
|
'6' => global_hotkey::hotkey::Code::Digit6,
|
||||||
|
'7' => global_hotkey::hotkey::Code::Digit7,
|
||||||
|
'8' => global_hotkey::hotkey::Code::Digit8,
|
||||||
|
'9' => global_hotkey::hotkey::Code::Digit9,
|
||||||
|
'A' => global_hotkey::hotkey::Code::KeyA,
|
||||||
|
'B' => global_hotkey::hotkey::Code::KeyB,
|
||||||
|
'D' => global_hotkey::hotkey::Code::KeyD,
|
||||||
|
'E' => global_hotkey::hotkey::Code::KeyE,
|
||||||
|
'F' => global_hotkey::hotkey::Code::KeyF,
|
||||||
|
'G' => global_hotkey::hotkey::Code::KeyG,
|
||||||
|
'I' => global_hotkey::hotkey::Code::KeyI,
|
||||||
|
'J' => global_hotkey::hotkey::Code::KeyJ,
|
||||||
|
'K' => global_hotkey::hotkey::Code::KeyK,
|
||||||
|
'L' => global_hotkey::hotkey::Code::KeyL,
|
||||||
|
'M' => global_hotkey::hotkey::Code::KeyM,
|
||||||
|
'N' => global_hotkey::hotkey::Code::KeyN,
|
||||||
|
'O' => global_hotkey::hotkey::Code::KeyO,
|
||||||
|
'P' => global_hotkey::hotkey::Code::KeyP,
|
||||||
|
'Q' => global_hotkey::hotkey::Code::KeyQ,
|
||||||
|
'S' => global_hotkey::hotkey::Code::KeyS,
|
||||||
|
'T' => global_hotkey::hotkey::Code::KeyT,
|
||||||
|
'U' => global_hotkey::hotkey::Code::KeyU,
|
||||||
|
'V' => global_hotkey::hotkey::Code::KeyV,
|
||||||
|
'W' => global_hotkey::hotkey::Code::KeyW,
|
||||||
|
'X' => global_hotkey::hotkey::Code::KeyX,
|
||||||
|
'Y' => global_hotkey::hotkey::Code::KeyY,
|
||||||
|
'Z' => global_hotkey::hotkey::Code::KeyZ,
|
||||||
|
_ => global_hotkey::hotkey::Code::KeyR, // 默认
|
||||||
|
};
|
||||||
|
let hotkey = HotKey::new(Some(modifiers), code);
|
||||||
|
|
||||||
|
self.manager.register(hotkey)?;
|
||||||
|
self.hotkeys.push(hotkey);
|
||||||
|
|
||||||
|
info!("快捷键已注册:id={}, key={}", id, key);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销快捷键
|
||||||
|
pub fn unregister(&mut self, hotkey: HotKey) -> Result<()> {
|
||||||
|
self.manager.unregister(hotkey)?;
|
||||||
|
self.hotkeys.retain(|h| h.id != hotkey.id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销所有快捷键
|
||||||
|
pub fn unregister_all(&mut self) -> Result<()> {
|
||||||
|
for hotkey in self.hotkeys.drain(..) {
|
||||||
|
self.manager.unregister(hotkey)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("所有快捷键已注销");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 轮询快捷键事件
|
||||||
|
pub fn poll_events(&self) -> Option<GlobalHotKeyEvent> {
|
||||||
|
match GlobalHotKeyEvent::receiver().try_recv() {
|
||||||
|
Ok(event) => Some(event),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听快捷键事件 (异步)
|
||||||
|
pub async fn listen(&self) -> Option<GlobalHotKeyEvent> {
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
GlobalHotKeyEvent::receiver().recv().ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析快捷键字符串
|
||||||
|
pub fn parse_hotkey(hotkey_str: &str) -> Result<(char, Modifiers)> {
|
||||||
|
let parts: Vec<&str> = hotkey_str.split('+').collect();
|
||||||
|
|
||||||
|
let mut modifiers = Modifiers::empty();
|
||||||
|
let mut key = ' ';
|
||||||
|
|
||||||
|
for part in parts {
|
||||||
|
match part.to_lowercase().as_str() {
|
||||||
|
"ctrl" | "control" => modifiers |= Modifiers::CONTROL,
|
||||||
|
"alt" => modifiers |= Modifiers::ALT,
|
||||||
|
"shift" => modifiers |= Modifiers::SHIFT,
|
||||||
|
"cmd" | "win" | "super" => modifiers |= Modifiers::SUPER,
|
||||||
|
s if s.len() == 1 => {
|
||||||
|
key = s.chars().next().unwrap();
|
||||||
|
}
|
||||||
|
s if s.len() == 2 && s.ends_with("r") => {
|
||||||
|
// 功能键如 F1-F12
|
||||||
|
if let Some(num) = s.strip_prefix('f').and_then(|n| n.parse::<u32>().ok()) {
|
||||||
|
if num >= 1 && num <= 12 {
|
||||||
|
key = char::from_u32(0xF0 + num - 1).unwrap_or(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == ' ' {
|
||||||
|
anyhow::bail!("无效的快捷键格式:{}", hotkey_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((key, modifiers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HotkeyManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new().expect("创建快捷键管理器失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预定义的快捷键 ID
|
||||||
|
pub mod hotkey_ids {
|
||||||
|
pub const START_RECORD: u32 = 1;
|
||||||
|
pub const STOP_RECORD: u32 = 2;
|
||||||
|
pub const COPY_RESULT: u32 = 3;
|
||||||
|
pub const TOGGLE_WINDOW: u32 = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hotkey() {
|
||||||
|
let (key, modifiers) = HotkeyManager::parse_hotkey("Ctrl+Shift+R").unwrap();
|
||||||
|
assert_eq!(key, 'R');
|
||||||
|
assert!(modifiers.contains(Modifiers::CONTROL));
|
||||||
|
assert!(modifiers.contains(Modifiers::SHIFT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hotkey_alt_shift() {
|
||||||
|
let (key, modifiers) = HotkeyManager::parse_hotkey("Alt+Ctrl+S").unwrap();
|
||||||
|
assert_eq!(key, 'S');
|
||||||
|
assert!(modifiers.contains(Modifiers::ALT));
|
||||||
|
assert!(modifiers.contains(Modifiers::CONTROL));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/utils/logger.rs
Normal file
119
src/utils/logger.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//! 日志工具 - 增强版本
|
||||||
|
//!
|
||||||
|
//! 支持同时输出到控制台和文件,方便诊断问题
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
/// 获取日志目录
|
||||||
|
pub fn get_log_dir() -> PathBuf {
|
||||||
|
// Windows: %LOCALAPPDATA%\impress-asr\logs
|
||||||
|
// macOS: ~/Library/Application Support/impress-asr/logs
|
||||||
|
// Linux: ~/.local/share/impress-asr/logs
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("impress-asr")
|
||||||
|
.join("logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志文件路径
|
||||||
|
pub fn get_log_file_path() -> PathBuf {
|
||||||
|
let log_dir = get_log_dir();
|
||||||
|
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||||
|
log_dir.join(format!("impress-asr_{}.log", timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化日志系统
|
||||||
|
pub fn init_logger() -> Result<()> {
|
||||||
|
// 确保日志目录存在
|
||||||
|
let log_dir = get_log_dir();
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!("警告:无法创建日志目录 {:?}: {}", log_dir, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建日志文件
|
||||||
|
let log_file_path = get_log_file_path();
|
||||||
|
let log_file = match File::create(&log_file_path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("警告:无法创建日志文件 {:?}: {}", log_file_path, e);
|
||||||
|
// 即使创建失败也继续,只输出到控制台
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "debug".into())
|
||||||
|
.add_directive("impress_asr_input_rust=debug".parse()?)
|
||||||
|
.add_directive("tauri=warn".parse()?)
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建文件日志层
|
||||||
|
let file_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(log_file)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_target(true);
|
||||||
|
|
||||||
|
// 创建控制台日志层
|
||||||
|
let console_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_target(true);
|
||||||
|
|
||||||
|
// 初始化日志系统
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "debug".into())
|
||||||
|
.add_directive("impress_asr_input_rust=debug".parse()?)
|
||||||
|
.add_directive("tauri=warn".parse()?)
|
||||||
|
)
|
||||||
|
.with(console_layer)
|
||||||
|
.with(file_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// 输出日志文件路径
|
||||||
|
eprintln!(" - 日志文件:{}", log_file_path.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志文件路径字符串
|
||||||
|
pub fn get_log_file_path_string() -> String {
|
||||||
|
get_log_file_path().to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志宏封装
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_info {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
tracing::info!($($arg)*)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_error {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
tracing::error!($($arg)*)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_debug {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
tracing::debug!($($arg)*)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_warn {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
tracing::warn!($($arg)*)
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/utils/mod.rs
Normal file
12
src/utils/mod.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//! 工具函数模块
|
||||||
|
|
||||||
|
pub mod logger;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod hotkey;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod diagnose;
|
||||||
|
|
||||||
|
/// 初始化日志
|
||||||
|
pub fn init_logger() -> anyhow::Result<()> {
|
||||||
|
logger::init_logger()
|
||||||
|
}
|
||||||
50
tauri.conf.json
Normal file
50
tauri.conf.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/tauri-conf-schema-v2",
|
||||||
|
"productName": "impress-asr-input-rust",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.impress.asr-input-rust",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "web/dist",
|
||||||
|
"beforeDevCommand": "cd web && npm run dev",
|
||||||
|
"beforeBuildCommand": "cd web && npm run build",
|
||||||
|
"devUrl": null
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "impress ASR Input",
|
||||||
|
"width": 1000,
|
||||||
|
"height": 700,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true,
|
||||||
|
"transparent": false,
|
||||||
|
"center": true,
|
||||||
|
"visible": true,
|
||||||
|
"theme": "Dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["msi"],
|
||||||
|
"category": "Utility",
|
||||||
|
"shortDescription": "高性能语音识别输入工具",
|
||||||
|
"longDescription": "一款基于 ONNX 模型的纯 Rust 实现的高性能跨平台语音识别工具,支持 SenseVoice 等模型,1 秒内完成语音识别。",
|
||||||
|
"copyright": "MIT License",
|
||||||
|
"resources": [
|
||||||
|
"models/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"tray-icon": {
|
||||||
|
"iconPath": "icons/tray-icon.png",
|
||||||
|
"iconAsTemplate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>impress ASR Input - 语音识别输入</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1729
web/package-lock.json
generated
Normal file
1729
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "impress-asr-input-rust-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
242
web/src/App.css
Normal file
242
web/src/App.css
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent-hover: #ff6b6b;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--border: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧边栏 */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区 */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用组件 */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #1a4a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 16px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单元素 */
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 波形可视化容器 */
|
||||||
|
.waveform-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态指示器 */
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-recording {
|
||||||
|
background: rgba(233, 69, 96, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #1a4a7a;
|
||||||
|
}
|
||||||
64
web/src/App.tsx
Normal file
64
web/src/App.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import './App.css'
|
||||||
|
import RecordPage from './pages/RecordPage'
|
||||||
|
import FileConvertPage from './pages/FileConvertPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
|
||||||
|
type Page = 'record' | 'file' | 'settings'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [currentPage, setCurrentPage] = useState<Page>('record')
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'record':
|
||||||
|
return <RecordPage />
|
||||||
|
case 'file':
|
||||||
|
return <FileConvertPage />
|
||||||
|
case 'settings':
|
||||||
|
return <SettingsPage />
|
||||||
|
default:
|
||||||
|
return <RecordPage />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="logo">
|
||||||
|
<span className="logo-icon">🎙️</span>
|
||||||
|
<span className="logo-text">impress ASR</span>
|
||||||
|
</div>
|
||||||
|
<nav className="nav">
|
||||||
|
<button
|
||||||
|
className={`nav-item ${currentPage === 'record' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentPage('record')}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">🎤</span>
|
||||||
|
<span className="nav-label">录音识别</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-item ${currentPage === 'file' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentPage('file')}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">📁</span>
|
||||||
|
<span className="nav-label">文件转写</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-item ${currentPage === 'settings' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentPage('settings')}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">⚙️</span>
|
||||||
|
<span className="nav-label">设置</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="version">v0.1.0</div>
|
||||||
|
</div>
|
||||||
|
<main className="main-content">
|
||||||
|
{renderPage()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
web/src/components/.gitkeep
Normal file
1
web/src/components/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
# 组件目录占位符
|
||||||
311
web/src/index.css
Normal file
311
web/src/index.css
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
/* 录音页面样式 */
|
||||||
|
.record-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button.idle {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button.idle:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 30px rgba(233, 69, 96, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button.recording {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 4px solid var(--accent);
|
||||||
|
animation: recording-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes recording-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-timer {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 150px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件转写页面 */
|
||||||
|
.file-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(233, 69, 96, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-text {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status.processing {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status.completed {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设置页面 */
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开关 */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + .switch-slider {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + .switch-slider:before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保存按钮 */
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
204
web/src/pages/FileConvertPage.tsx
Normal file
204
web/src/pages/FileConvertPage.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'error'
|
||||||
|
progress: number
|
||||||
|
result?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileConvertPage() {
|
||||||
|
const [files, setFiles] = useState<FileItem[]>([])
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
|
addFiles(droppedFiles)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const selectedFiles = Array.from(e.target.files)
|
||||||
|
addFiles(selectedFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFiles = (newFiles: File[]) => {
|
||||||
|
const audioExtensions = ['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac']
|
||||||
|
|
||||||
|
const validFiles = newFiles.filter(file => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||||
|
return ext && audioExtensions.includes(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileItems: FileItem[] = validFiles.map(file => ({
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
setFiles(prev => [...prev, ...fileItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const startConvert = (id: string) => {
|
||||||
|
setFiles(prev => prev.map(file => {
|
||||||
|
if (file.id === id) {
|
||||||
|
return { ...file, status: 'processing' }
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 模拟转换过程
|
||||||
|
let progress = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 10
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setFiles(prev => prev.map(file => {
|
||||||
|
if (file.id === id) {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
result: '这是模拟的转换结果...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
setFiles(prev => prev.map(file => {
|
||||||
|
if (file.id === id) {
|
||||||
|
return { ...file, progress }
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAll = () => {
|
||||||
|
files.filter(f => f.status === 'pending').forEach(f => startConvert(f.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (id: string) => {
|
||||||
|
setFiles(prev => prev.filter(f => f.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-page">
|
||||||
|
<h1 className="page-title">文件识别转文字</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`drop-zone ${isDragOver ? 'drag-over' : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => document.getElementById('file-input')?.click()}
|
||||||
|
>
|
||||||
|
<div className="drop-zone-icon">📁</div>
|
||||||
|
<div className="drop-zone-text">
|
||||||
|
拖拽音频文件到此处,或点击选择文件
|
||||||
|
</div>
|
||||||
|
<div className="drop-zone-hint">
|
||||||
|
支持:WAV, MP3, FLAC, OGG, M4A
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".wav,.mp3,.flac,.ogg,.m4a,.aac"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="file-list">
|
||||||
|
{files.map(file => (
|
||||||
|
<div key={file.id} className="file-item">
|
||||||
|
<div className="file-icon">📄</div>
|
||||||
|
<div className="file-info">
|
||||||
|
<div className="file-name">{file.name}</div>
|
||||||
|
<div className="file-status">
|
||||||
|
{file.status === 'pending' && '待处理'}
|
||||||
|
{file.status === 'processing' && (
|
||||||
|
<span className="processing">
|
||||||
|
处理中 {file.progress}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.status === 'completed' && (
|
||||||
|
<span className="completed">✓ 已完成</span>
|
||||||
|
)}
|
||||||
|
{file.status === 'error' && '处理失败'}
|
||||||
|
</div>
|
||||||
|
{file.status === 'pending' && (
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="file-actions">
|
||||||
|
{file.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => startConvert(file.id)}
|
||||||
|
>
|
||||||
|
开始
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{file.status === 'completed' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary">查看</button>
|
||||||
|
<button className="btn btn-secondary">导出</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => removeFile(file.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setFiles([])}>
|
||||||
|
清空列表
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={startAll}>
|
||||||
|
批量开始
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
web/src/pages/RecordPage.tsx
Normal file
156
web/src/pages/RecordPage.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface RecordResult {
|
||||||
|
text: string
|
||||||
|
language: string
|
||||||
|
confidence: number
|
||||||
|
duration_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordPage() {
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [recordingTime, setRecordingTime] = useState(0)
|
||||||
|
const [result, setResult] = useState<RecordResult | null>(null)
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: number
|
||||||
|
if (isRecording) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setRecordingTime(prev => prev + 0.1)
|
||||||
|
}, 100) as unknown as number
|
||||||
|
} else {
|
||||||
|
setRecordingTime(0)
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isRecording])
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds % 1) * 10)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecordClick = async () => {
|
||||||
|
if (isRecording) {
|
||||||
|
// 停止录音
|
||||||
|
setIsRecording(false)
|
||||||
|
setIsProcessing(true)
|
||||||
|
|
||||||
|
// 模拟识别过程
|
||||||
|
setTimeout(() => {
|
||||||
|
setResult({
|
||||||
|
text: '这是一个模拟的语音识别结果。实际使用时会调用 ONNX 模型进行识别。',
|
||||||
|
language: 'zh',
|
||||||
|
confidence: 0.95,
|
||||||
|
duration_ms: 350
|
||||||
|
})
|
||||||
|
setIsProcessing(false)
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
// 开始录音
|
||||||
|
setIsRecording(true)
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (result?.text) {
|
||||||
|
await navigator.clipboard.writeText(result.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="record-page">
|
||||||
|
<h1 className="page-title">录音识别</h1>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="record-controls">
|
||||||
|
<div className="waveform-container">
|
||||||
|
{isRecording ? (
|
||||||
|
<div className="waveform-animation">
|
||||||
|
{[...Array(20)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="wave-bar"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 0.1}s`,
|
||||||
|
height: `${Math.random() * 100 + 20}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="waveform-placeholder">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
点击麦克风按钮开始录音
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`record-button ${isRecording ? 'recording' : 'idle'}`}
|
||||||
|
onClick={handleRecordClick}
|
||||||
|
>
|
||||||
|
{isRecording ? '⏹' : '🎤'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="record-timer">
|
||||||
|
{formatTime(recordingTime)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="record-status">
|
||||||
|
<span className={`status-indicator ${isRecording ? 'status-recording' : 'status-ready'}`}>
|
||||||
|
{isRecording ? '● 录音中' : '○ 就绪'}
|
||||||
|
</span>
|
||||||
|
{result && (
|
||||||
|
<span>
|
||||||
|
识别耗时:{result.duration_ms}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(result || isProcessing) && (
|
||||||
|
<div className="card result-section">
|
||||||
|
<div className="result-header">
|
||||||
|
<h2 className="card-title">识别结果</h2>
|
||||||
|
<div className="result-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleCopy}>
|
||||||
|
📋 复制
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary">
|
||||||
|
💾 保存
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary">
|
||||||
|
🗑️ 清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProcessing ? (
|
||||||
|
<div className="result-text empty">
|
||||||
|
正在识别中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`result-text ${!result?.text ? 'empty' : ''}`}>
|
||||||
|
{result?.text || '暂无识别结果'}
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<div className="result-meta">
|
||||||
|
<span>语言:{result.language === 'zh' ? '中文' : result.language}</span>
|
||||||
|
<span>置信度:{(result.confidence * 100).toFixed(1)}%</span>
|
||||||
|
<span>时长:{(result.duration_ms / 1000).toFixed(2)}s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
web/src/pages/SettingsPage.tsx
Normal file
275
web/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
model: string
|
||||||
|
language: string
|
||||||
|
sampleRate: number
|
||||||
|
microphone: string
|
||||||
|
theme: string
|
||||||
|
autoStart: boolean
|
||||||
|
autoCheckUpdate: boolean
|
||||||
|
autoCopy: boolean
|
||||||
|
historyKeepDays: number
|
||||||
|
hotkeyRecord: string
|
||||||
|
hotkeyCopy: string
|
||||||
|
hotkeyToggle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
model: 'sensevoice-small',
|
||||||
|
language: 'zh',
|
||||||
|
sampleRate: 16000,
|
||||||
|
microphone: '默认设备',
|
||||||
|
theme: 'dark',
|
||||||
|
autoStart: false,
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
autoCopy: false,
|
||||||
|
historyKeepDays: 30,
|
||||||
|
hotkeyRecord: 'Ctrl+Shift+R',
|
||||||
|
hotkeyCopy: 'Ctrl+Shift+C',
|
||||||
|
hotkeyToggle: 'Ctrl+Shift+H'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [modified, setModified] = useState(false)
|
||||||
|
|
||||||
|
const handleChange = (key: keyof Settings, value: Settings[typeof key]) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }))
|
||||||
|
setModified(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// 保存设置
|
||||||
|
console.log('保存设置:', settings)
|
||||||
|
setModified(false)
|
||||||
|
alert('设置已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSettings({
|
||||||
|
model: 'sensevoice-small',
|
||||||
|
language: 'zh',
|
||||||
|
sampleRate: 16000,
|
||||||
|
microphone: '默认设备',
|
||||||
|
theme: 'dark',
|
||||||
|
autoStart: false,
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
autoCopy: false,
|
||||||
|
historyKeepDays: 30,
|
||||||
|
hotkeyRecord: 'Ctrl+Shift+R',
|
||||||
|
hotkeyCopy: 'Ctrl+Shift+C',
|
||||||
|
hotkeyToggle: 'Ctrl+Shift+H'
|
||||||
|
})
|
||||||
|
setModified(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<h1 className="page-title">设置</h1>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{/* 识别模型 */}
|
||||||
|
<div className="setting-section">
|
||||||
|
<h2 className="setting-section-title">识别模型</h2>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">模型选择</div>
|
||||||
|
<div className="setting-description">选择用于语音识别的 ONNX 模型</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={settings.model}
|
||||||
|
onChange={(e) => handleChange('model', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="sensevoice-small">SenseVoice Small (推荐)</option>
|
||||||
|
<option value="sensevoice-base">SenseVoice Base</option>
|
||||||
|
<option value="paraformer">FunASR Paraformer</option>
|
||||||
|
<option value="whisper-small">Whisper Small</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">识别语言</div>
|
||||||
|
<div className="setting-description">主要识别的语言</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={settings.language}
|
||||||
|
onChange={(e) => handleChange('language', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zh">中文普通话</option>
|
||||||
|
<option value="en">英语</option>
|
||||||
|
<option value="ja">日语</option>
|
||||||
|
<option value="ko">韩语</option>
|
||||||
|
<option value="yue">粤语</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音频输入 */}
|
||||||
|
<div className="setting-section">
|
||||||
|
<h2 className="setting-section-title">音频输入</h2>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">麦克风</div>
|
||||||
|
<div className="setting-description">选择录音用的麦克风设备</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={settings.microphone}
|
||||||
|
onChange={(e) => handleChange('microphone', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="default">默认设备</option>
|
||||||
|
<option value="builtin">Built-in Microphone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">采样率</div>
|
||||||
|
<div className="setting-description">音频录制采样率</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={settings.sampleRate}
|
||||||
|
onChange={(e) => handleChange('sampleRate', Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={16000}>16000 Hz</option>
|
||||||
|
<option value={44100}>44100 Hz</option>
|
||||||
|
<option value={48000}>48000 Hz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷键 */}
|
||||||
|
<div className="setting-section">
|
||||||
|
<h2 className="setting-section-title">快捷键</h2>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">开始/停止录音</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
value={settings.hotkeyRecord}
|
||||||
|
onChange={(e) => handleChange('hotkeyRecord', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">快速复制结果</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
value={settings.hotkeyCopy}
|
||||||
|
onChange={(e) => handleChange('hotkeyCopy', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">显示/隐藏窗口</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
value={settings.hotkeyToggle}
|
||||||
|
onChange={(e) => handleChange('hotkeyToggle', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 外观 */}
|
||||||
|
<div className="setting-section">
|
||||||
|
<h2 className="setting-section-title">外观</h2>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">主题</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(e) => handleChange('theme', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="auto">跟随系统</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 其他 */}
|
||||||
|
<div className="setting-section">
|
||||||
|
<h2 className="setting-section-title">其他</h2>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">开机自启</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoStart}
|
||||||
|
onChange={(e) => handleChange('autoStart', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">自动检查更新</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCheckUpdate}
|
||||||
|
onChange={(e) => handleChange('autoCheckUpdate', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">自动复制识别结果</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCopy}
|
||||||
|
onChange={(e) => handleChange('autoCopy', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">历史记录保留天数</div>
|
||||||
|
<div className="setting-description">超过此天数的记录将被自动清除</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
value={settings.historyKeepDays}
|
||||||
|
onChange={(e) => handleChange('historyKeepDays', Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleReset}>
|
||||||
|
重置默认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!modified}
|
||||||
|
>
|
||||||
|
保存设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
web/vite.config.ts
Normal file
14
web/vite.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user