Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
592 changes: 592 additions & 0 deletions openless-all/app/src-tauri/src/asr/bailian.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/asr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
//! `frame.rs` (binary frame codec) and the session lifecycle in
//! `volcengine.rs`.

pub mod bailian;
mod frame;
pub mod local;
pub mod volcengine;
pub mod wav;
pub mod whisper;

pub use bailian::{BailianCredentials, BailianRealtimeASR};
pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR};
pub use whisper::WhisperBatchASR;

Expand Down
59 changes: 59 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,9 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
// 本地 ASR 不依赖云端凭据。
return true;
}
if provider == crate::asr::bailian::PROVIDER_ID {
return configured(&snap.asr_api_key);
}
configured(&snap.asr_endpoint) && configured(&snap.asr_model)
}

Expand Down Expand Up @@ -539,6 +542,11 @@ pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheck

#[tauri::command]
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
});
}
let config = read_openai_provider_config(&kind)?;
fetch_provider_models(&config)
.await
Expand Down Expand Up @@ -619,6 +627,10 @@ async fn validate_asr_provider() -> Result<(), String> {
return Ok(());
}

if active_asr == crate::asr::bailian::PROVIDER_ID {
return validate_bailian_asr_provider().await;
}

let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
Expand All @@ -627,6 +639,44 @@ async fn validate_asr_provider() -> Result<(), String> {
validate_asr_transcription(&config, model.trim()).await
}

async fn validate_bailian_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
}
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty());
let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new(
crate::asr::BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
},
));
asr.open_session().await.map_err(|e| e.to_string())?;
crate::asr::AudioConsumer::consume_pcm_chunk(
&*asr,
&vec![0u8; crate::asr::bailian::TARGET_AUDIO_CHUNK_BYTES],
);
asr.send_last_frame().await.map_err(|e| e.to_string())?;
asr.await_final_result()
.await
.map(|_| ())
.map_err(|e| e.to_string())
}

fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider)
}
Expand Down Expand Up @@ -821,6 +871,7 @@ fn parse_account(s: &str) -> Result<CredentialAccount, String> {
"asr.api_key" => Ok(CredentialAccount::AsrApiKey),
"asr.endpoint" => Ok(CredentialAccount::AsrEndpoint),
"asr.model" => Ok(CredentialAccount::AsrModel),
"asr.vocabulary_id" => Ok(CredentialAccount::AsrVocabularyId),
_ => Err(format!("unknown account: {s}")),
}
}
Expand Down Expand Up @@ -1784,6 +1835,10 @@ mod tests {
..snapshot()
};
assert!(!asr_configured_for_provider("whisper", &whisper_key_only));
assert!(asr_configured_for_provider(
crate::asr::bailian::PROVIDER_ID,
&whisper_key_only
));

let whisper_keyless_ready = CredentialsSnapshot {
asr_endpoint: Some("https://api.openai.com/v1".into()),
Expand All @@ -1794,6 +1849,10 @@ mod tests {
"whisper",
&whisper_keyless_ready
));
assert!(!asr_configured_for_provider(
crate::asr::bailian::PROVIDER_ID,
&whisper_keyless_ready
));

assert!(asr_configured_for_provider(
crate::asr::local::PROVIDER_ID,
Expand Down
38 changes: 35 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ use uuid::Uuid;
#[cfg(target_os = "windows")]
use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr};
use crate::asr::{
DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR,
WhisperBatchASR,
BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript,
VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
};
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor};
use crate::coordinator_state::{
Expand Down Expand Up @@ -69,6 +69,7 @@ use resources::{
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
Bailian(Arc<BailianRealtimeASR>),
#[cfg(target_os = "windows")]
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
Expand Down Expand Up @@ -1785,7 +1786,7 @@ fn ensure_asr_credentials() -> Result<(), String> {
return Ok(());
}

if is_whisper_compatible_provider(&active_asr) {
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
Expand Down Expand Up @@ -1918,6 +1919,10 @@ fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
}

fn is_bailian_provider(id: &str) -> bool {
id == crate::asr::bailian::PROVIDER_ID
}

fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
Expand Down Expand Up @@ -2100,6 +2105,33 @@ fn read_whisper_credentials() -> (String, String, String) {
(api_key, base_url, model)
}

fn read_bailian_credentials() -> BailianCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty());
BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
}
}

fn read_volc_credentials() -> VolcengineCredentials {
let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey)
.ok()
Expand Down
118 changes: 117 additions & 1 deletion openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,79 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
return Ok(());
}

if is_whisper_compatible_provider(&active_asr) {
if is_bailian_provider(&active_asr) {
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Bailian(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?;

if let Err(e) = asr.open_session().await {
log::error!("[coord] open Bailian ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {
asr.cancel();
}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
} else if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
Expand Down Expand Up @@ -627,6 +699,50 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] Bailian send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] Bailian await final failed: {e}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some("识别超时".to_string()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("bailian global timeout".to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/coordinator/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
ActiveAsr::Bailian(b) => b.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
#[cfg(target_os = "macos")]
Expand Down
Loading
Loading