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,125 @@
|
||||
//! WS 客户端(10C):预连接 + 心跳 + 指数退避重连;
|
||||
//! 上行音频二进制帧 / 控制 JSON,下行 ServerMsg → emit("asr") + 维护听写文本状态。
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WsCmd {
|
||||
Start { session_id: String },
|
||||
Audio(Vec<u8>),
|
||||
Stop { session_id: String },
|
||||
Cancel { session_id: String },
|
||||
Reconnect, // 设置变更(token/服务器)后强制重连
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WsHandle {
|
||||
pub tx: mpsc::UnboundedSender<WsCmd>,
|
||||
}
|
||||
|
||||
/// 启动常驻连接任务(应用启动即预连接,按键时零握手)。
|
||||
pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut backoff = Duration::from_millis(500);
|
||||
loop {
|
||||
let (url, token, device_id) = {
|
||||
let store = app.state::<crate::settings::SettingsStore>();
|
||||
let s = store.get();
|
||||
let ws_url = s.server_url.replace("http://", "ws://").replace("https://", "wss://");
|
||||
(format!("{ws_url}/v1/asr/stream"), s.token, s.device_id)
|
||||
};
|
||||
if token.is_empty() {
|
||||
// 未登录:等待命令但丢弃音频,定期重查
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
while let Ok(cmd) = rx.try_recv() {
|
||||
drop(cmd);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut req = match url.clone().into_client_request() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("bad ws url: {e}");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
req.headers_mut().insert(
|
||||
"Authorization",
|
||||
format!("Bearer {token}").parse().unwrap(),
|
||||
);
|
||||
req.headers_mut()
|
||||
.insert("X-Device-ID", device_id.parse().unwrap());
|
||||
|
||||
let conn = tokio_tungstenite::connect_async(req).await;
|
||||
let (mut sink, mut stream) = match conn {
|
||||
Ok((ws, _)) => {
|
||||
log::info!("ws connected");
|
||||
backoff = Duration::from_millis(500);
|
||||
ws.split()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("ws connect failed: {e}; retry in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(30));
|
||||
'conn: loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
let Some(cmd) = cmd else { return };
|
||||
let msg = match cmd {
|
||||
WsCmd::Start { session_id } => Message::Text(
|
||||
json!({"type":"start","session_id":session_id,"sample_rate":16000,"format":"pcm16"}).to_string()),
|
||||
WsCmd::Audio(frame) => Message::Binary(frame),
|
||||
WsCmd::Stop { session_id } => Message::Text(
|
||||
json!({"type":"stop","session_id":session_id}).to_string()),
|
||||
WsCmd::Cancel { session_id } => Message::Text(
|
||||
json!({"type":"cancel","session_id":session_id}).to_string()),
|
||||
WsCmd::Reconnect => break 'conn,
|
||||
};
|
||||
if sink.send(msg).await.is_err() {
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
}
|
||||
incoming = stream.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
crate::dictation::on_server_msg(&app, &v);
|
||||
let _ = app.emit("asr", v);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("ws read error: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
None => break 'conn,
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if sink.send(Message::Ping(Vec::new())).await.is_err() { break 'conn; }
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
}
|
||||
});
|
||||
WsHandle { tx }
|
||||
}
|
||||
Reference in New Issue
Block a user