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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,11 @@ CapsWriter

# Mimosa 安全钩子运行状态(不属版本库)
.mimosa/
/.idea/.gitignore
/.idea/git_toolbox_prj.xml
/.idea/misc.xml
/.idea/modules.xml
/.idea/openless.iml
/.idea/vcs.xml
/openless-all/app/pnpm-lock.yaml
/openless-all/app/pnpm-workspace.yaml
15 changes: 15 additions & 0 deletions openless-all/app/crates/openless-core/src/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,14 @@ pub trait LocalAsrApi: Send + Sync {
&self,
target: LocalAsrTarget,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>>;
/// Run the native smoke test for a specific ASR channel without changing
/// the globally active channel.
fn test_channel(
&self,
_channel_id: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
unsupported("local ASR channel test")
}
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -1605,6 +1613,13 @@ impl LocalAsrApi for UnsupportedDomainServices {
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
unsupported("local ASR")
}

fn test_channel(
&self,
_: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
unsupported("local ASR")
}
}

impl SelectionApi for UnsupportedDomainServices {
Expand Down
77 changes: 77 additions & 0 deletions openless-all/app/crates/openless-core/src/local_asr_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ pub trait ModelRuntimeAdapter: Send + Sync {
unsupported("model test")
}

/// Test using an explicit channel/provider backend. The default keeps
/// existing host adapters source-compatible; adapters that have multiple
/// native backends can override it to avoid consulting global preferences.
fn test_model_for_provider(
&self,
target: LocalAsrTarget,
model_dir: PathBuf,
_provider_type: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
self.test_model(target, model_dir)
}

fn invalidate_route(&self, _runtime: LocalAsrRuntime) {}
}

Expand Down Expand Up @@ -1283,4 +1295,69 @@ impl LocalAsrApi for LocalAsrService {
};
self.runtime.test_model(target, model_dir)
}

fn test_channel(
&self,
channel_id: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
let credentials = Arc::clone(&self.credentials);
let runtime = Arc::clone(&self.runtime);
let model_store = Arc::clone(&self.model_store);
let preferences = Arc::clone(&self.preferences);
Box::pin(async move {
let channel = credentials
.list_channels(ChannelKind::Asr)
.await?
.into_iter()
.find(|channel| channel.id == channel_id)
.ok_or_else(|| {
BackendError::new(
BackendErrorCode::InvalidArgument,
"local ASR channel is not configured",
)
})?;
let provider_type = channel.provider_type;
let model_id = {
let preferences = preferences.get();
match provider_type.as_str() {
"local-whisper" | "apple-whisper" => {
preferences.local_whisper_active_model.clone()
}
"local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" => {
preferences.local_asr_active_model.clone()
}
_ => {
return Err(BackendError::new(
BackendErrorCode::Unsupported,
"native local ASR channel verification is not supported",
));
}
}
};
if model_id.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::InvalidState,
"local ASR model is not configured",
));
}
let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, model_id)?;
if !model_store.is_native(&target)? && !model_store.is_installed(&target)? {
return Err(BackendError::new(
BackendErrorCode::InvalidState,
"local ASR model is not downloaded",
));
}
let model_dir = model_store.runtime_model_dir(&target)?;
let result = runtime
.test_model_for_provider(target, model_dir, provider_type)
.await?;
if result.transcribed_text.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::Provider,
"transcription provider returned an empty transcript",
));
}
Ok(result)
})
}
}
87 changes: 85 additions & 2 deletions openless-all/app/crates/openless-core/tests/local_asr_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use openless_core::{
ChannelMutation, ChannelMutationResult, ChannelSummary, CredentialKey, CredentialStore,
CredentialsStatus, FoundryRuntimeSource, InMemoryCredentialStore, LocalAsrActivationRequest,
LocalAsrMirror, LocalAsrRuntime, LocalAsrRuntimeLease, LocalAsrRuntimeStatus, LocalAsrSettings,
LocalAsrTarget, ModelRuntimeAdapter, ModelStore, ModelStoreConfig, NativeModelState,
OpenLessBackend, PreferencesStore, ProviderSlot, SecretValue,
LocalAsrTarget, LocalAsrTestResult, ModelRuntimeAdapter, ModelStore, ModelStoreConfig,
NativeModelState, OpenLessBackend, PreferencesStore, ProviderSlot, SecretValue,
};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
Expand Down Expand Up @@ -79,13 +79,40 @@ struct RecordingLocalAsrRuntime {
operations: Mutex<Vec<String>>,
during_prepare: Mutex<Option<Box<dyn FnOnce() + Send>>>,
loaded_models: Arc<Mutex<std::collections::HashMap<LocalAsrTarget, u64>>>,
test_transcript: Mutex<String>,
tested_providers: Mutex<Vec<String>>,
}

impl ModelRuntimeAdapter for RecordingLocalAsrRuntime {
fn engine_available(&self, _: LocalAsrRuntime) -> bool {
true
}

fn test_model_for_provider(
&self,
target: LocalAsrTarget,
_model_dir: PathBuf,
provider_type: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
let transcribed_text = self.test_transcript.lock().unwrap().clone();
self.tested_providers
.lock()
.unwrap()
.push(provider_type.clone());
Box::pin(async move {
Ok(LocalAsrTestResult {
target,
backend: provider_type,
expected_text: "Hello. This is a test of the Voxtrail speech-to-text system."
.into(),
transcribed_text,
audio_ms: 3_000,
load_ms: 10,
transcribe_ms: 20,
})
})
}

fn runtime_status(
&self,
settings: LocalAsrSettings,
Expand Down Expand Up @@ -453,6 +480,62 @@ fn local_asr_backend_with_credentials(
(data_dir, runtime, backend)
}

#[tokio::test]
async fn channel_test_requires_a_non_blank_transcript() {
let (data_dir, runtime, backend) = local_asr_backend();
let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap();
let model_dir = data_dir.join("models").join(target.model_id());
std::fs::create_dir_all(&model_dir).unwrap();
std::fs::write(
model_dir.join(openless_core::MODEL_READY_SENTINEL),
b"ready",
)
.unwrap();
backend
.services()
.local_asr
.set_active_model(target)
.await
.unwrap();
let channel_id = backend
.create_channel(
ChannelKind::Asr,
"local-qwen3-c".into(),
"Local Qwen".into(),
)
.await
.unwrap();

for transcript in ["", " \n\t"] {
*runtime.test_transcript.lock().unwrap() = transcript.into();
let error = backend
.services()
.local_asr
.test_channel(channel_id.clone())
.await
.expect_err("blank channel-test transcripts must fail");
assert_eq!(error.code, BackendErrorCode::Provider);
assert_eq!(
error.message,
"transcription provider returned an empty transcript"
);
}

*runtime.test_transcript.lock().unwrap() = "Hello from the local model".into();
let result = backend
.services()
.local_asr
.test_channel(channel_id)
.await
.unwrap();
assert_eq!(result.transcribed_text, "Hello from the local model");
assert_eq!(
runtime.tested_providers.lock().unwrap().as_slice(),
["local-qwen3-c", "local-qwen3-c", "local-qwen3-c"]
);
let _ = std::fs::remove_dir_all(data_dir);
}

#[tokio::test]
async fn local_asr_activation_owns_channel_creation_and_enabling() {
for (runtime_kind, model_id, provider_id) in [
Expand Down
47 changes: 46 additions & 1 deletion openless-all/app/src-tauri/src/asr/local/mlx_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,16 @@ fn accept_worker(
) -> Result<UnixStream> {
loop {
match listener.accept() {
Ok((stream, _)) => return Ok(stream),
Ok((stream, _)) => {
// `listener` is nonblocking for the startup poll loop. macOS may
// propagate that flag to accepted sockets, which would make the
// handshake read return `WouldBlock` immediately despite the
// read timeout configured by the client.
stream
.set_nonblocking(false)
.context("set MLX worker socket blocking")?;
return Ok(stream);
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {}
Err(error) => {
log::error!(
Expand Down Expand Up @@ -1878,6 +1887,42 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}

#[test]
fn accepted_worker_socket_is_blocking_after_nonblocking_accept_loop() {
let dir = test_dir();
let socket_path = dir.join("worker.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
listener.set_nonblocking(true).unwrap();
let client_path = socket_path.clone();
let client = thread::spawn(move || {
thread::sleep(Duration::from_millis(20));
UnixStream::connect(client_path).unwrap()
});
let mut child = sleeping_child();
let mut stream = accept_worker(
&listener,
&mut child,
Instant::now(),
&Diagnostics::default(),
)
.unwrap();
stream
.set_read_timeout(Some(Duration::from_millis(50)))
.unwrap();
let mut byte = [0_u8; 1];
let read_started_at = Instant::now();
let error = stream.read(&mut byte).unwrap_err();
assert!(read_started_at.elapsed() >= Duration::from_millis(25));
assert!(matches!(
error.kind(),
ErrorKind::TimedOut | ErrorKind::WouldBlock
));
drop(stream);
drop(client.join().unwrap());
terminate_unmanaged_child(&mut child);
fs::remove_dir_all(dir).unwrap();
}

#[test]
fn capture_threads_drain_large_stdout_and_stderr_without_blocking() {
let mut child = Command::new("/bin/sh")
Expand Down
29 changes: 29 additions & 0 deletions openless-all/app/src-tauri/src/commands/local_asr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,35 @@ pub async fn local_asr_test_model(
.map_err(core_error)
}

/// 验证设置页上的本地渠道。与通用云端 provider 验证不同,这里必须真正
/// 加载该渠道对应的本地模型并跑一次内置音频,且不能偷偷切换全局 active 渠道。
#[tauri::command]
pub async fn local_asr_test_channel(
backend: CoreState<'_>,
channel_id: String,
) -> Result<LocalAsrTestResult, String> {
log::info!("[local-asr verify] start channel={channel_id}");
let result = backend
.services()
.local_asr
.test_channel(channel_id.clone())
.await
.map(LocalAsrTestResult::from)
.map_err(|error| {
let message = core_error(error);
log::warn!("[local-asr verify] failed channel={channel_id}: {message}");
message
})?;
log::info!(
"[local-asr verify] success channel={channel_id} model={} backend={} load_ms={} transcribe_ms={}",
result.model_id,
result.backend,
result.load_ms,
result.transcribe_ms
);
Ok(result)
}

#[tauri::command]
pub async fn local_asr_engine_status(
backend: CoreState<'_>,
Expand Down
Loading
Loading