dudu MVP:五端语音输入法初始提交
- 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user