dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
//! 听写编排(push-to-talk 核心):
//! 快捷键按下 → 浮层弹出(光标附近,不抢焦点)→ 采集推流;
//! 松开 → stop → 等尾部 final → 注入 committed 文本 → 浮层淡出。
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
#[derive(Default)]
pub struct DictationState {
inner: Mutex<Option<Session>>,
}
struct Session {
id: String,
capture: Option<crate::audio::Capture>,
final_text: String,
partial_text: String,
started: Instant,
got_first_partial: bool,
}
pub fn start(app: &AppHandle) {
let state = app.state::<DictationState>();
if state.inner.lock().is_some() {
return; // 已在录音
}
if crate::api::is_app_paused(app) {
return; // 已暂停使用
}
let session_id = uuid::Uuid::new_v4().to_string();
set_tray_tooltip(app, "dudu — 录音中");
let _ = app.emit("hotkey", json!({"state": "down"}));
show_overlay(app);
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let _ = ws.send(crate::ws::WsCmd::Start {
session_id: session_id.clone(),
});
// 音频帧经 std channel → 转发 tokio channel
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let mic = app.state::<crate::settings::SettingsStore>().get().mic;
let t0 = Instant::now();
let capture = match crate::audio::start(&mic, tx) {
Ok(c) => {
let m = app.state::<crate::metrics::Metrics>();
m.record("audio.start_ms", json!({"ms": t0.elapsed().as_millis() as i64}));
Some(c)
}
Err(e) => {
log::error!("audio start failed: {e}");
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
None
}
};
{
let ws = ws.clone();
std::thread::spawn(move || {
while let Ok(frame) = rx.recv() {
if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() {
break;
}
}
});
}
*state.inner.lock() = Some(Session {
id: session_id,
capture,
final_text: String::new(),
partial_text: String::new(),
started: Instant::now(),
got_first_partial: false,
});
}
pub fn stop(app: &AppHandle, canceled: bool) {
let state = app.state::<DictationState>();
let Some(mut sess) = state.inner.lock().take() else {
return;
};
let _ = app.emit("hotkey", json!({"state": "up"}));
set_tray_tooltip(app, "dudu — 就绪");
sess.capture.take(); // 停止采集
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let cmd = if canceled {
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
} else {
crate::ws::WsCmd::Stop { session_id: sess.id.clone() }
};
let _ = ws.send(cmd);
let app = app.clone();
let release_at = Instant::now();
tauri::async_runtime::spawn(async move {
if !canceled {
// 等网关 flush 尾部 final(句间已实时累计,这里只兜尾部)
tokio::time::sleep(Duration::from_millis(350)).await;
let committed = {
let state = app.state::<DictationState>();
let pending = state.inner.lock();
drop(pending);
let buf = app.state::<CommitBuffer>();
buf.take()
};
if !committed.is_empty() {
if !crate::inject::accessibility_ok() {
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
let _ = app.emit(
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
tokio::time::sleep(Duration::from_millis(2400)).await; // 让用户看清引导
} else {
let t = committed.clone();
let injected =
tauri::async_runtime::spawn_blocking(move || crate::inject::inject_text(&t)).await;
match injected {
Ok(Ok(())) => {
let m = app.state::<crate::metrics::Metrics>();
m.record(
"asr.release_to_commit_ms",
json!({"ms": release_at.elapsed().as_millis() as i64}),
);
}
_ => {
log::error!("inject failed: {injected:?}");
if !crate::inject::accessibility_ok() {
let _ = app.emit(
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
tokio::time::sleep(Duration::from_millis(2400)).await;
}
}
}
}
}
} else {
app.state::<CommitBuffer>().take();
}
hide_overlay(&app);
});
}
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
#[derive(Default)]
pub struct CommitBuffer {
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
}
impl CommitBuffer {
fn take(&self) -> String {
let mut t = self.text.lock();
let committed = format!("{}{}", t.0, t.1);
*t = (String::new(), String::new());
committed
}
}
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
pub fn on_server_msg(app: &AppHandle, v: &Value) {
let buf = app.state::<CommitBuffer>();
match v.get("type").and_then(|t| t.as_str()) {
Some("partial") => {
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
buf.text.lock().1 = text.to_string();
}
let state = app.state::<DictationState>();
let mut guard = state.inner.lock();
if let Some(sess) = guard.as_mut() {
if !sess.got_first_partial {
sess.got_first_partial = true;
let m = app.state::<crate::metrics::Metrics>();
m.record(
"asr.first_partial_ms",
json!({"ms": sess.started.elapsed().as_millis() as i64}),
);
}
}
}
Some("final") => {
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
let mut t = buf.text.lock();
t.0.push_str(text);
t.1.clear();
}
}
_ => {}
}
}
/// 托盘提示按录音状态切换(11B)。
fn set_tray_tooltip(app: &AppHandle, text: &str) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_tooltip(Some(text));
}
}
fn show_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
// 跟随光标:浮层出现在指针下方 24px,水平居中
if let Ok(pos) = app.cursor_position() {
let _ = w.set_position(tauri::PhysicalPosition::new(pos.x - 180.0, pos.y + 24.0));
}
let _ = w.show();
}
}
fn hide_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
let _ = w.hide();
}
}