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
46 changes: 44 additions & 2 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8458,6 +8458,8 @@ mod tests {
)
.await
.unwrap();
// Valid quiet PCM reaches ASR; an empty transcript is distinct from a missing microphone input.
silent.feed_pcm(&[1, 0]).unwrap();
assert_eq!(
silent.finish().await.unwrap(),
LessComputerVoiceFinish::Dictated {
Expand Down Expand Up @@ -8510,6 +8512,40 @@ mod tests {
assert_eq!(backend.less_computer_active_session(), None);
}

#[tokio::test]
async fn less_computer_missing_audio_fails_without_chat_errors_and_releases_capture() {
use crate::events::{LessComputerEventKind, LessComputerVoiceOutcome};
let (_data_dir, backend, _transcription, _runtime) =
dictation_backend("less-computer-dictation-missing-audio", "must not commit");
let mut events = backend.subscribe();
for pcm in [Vec::new(), vec![0; 640]] {
let capture = backend
.start_less_computer_voice_with(
SessionId::new(),
Arc::new(FakeRecordingControl::default()),
DICTATE,
)
.await
.unwrap();
if !pcm.is_empty() {
capture.feed_pcm(&pcm).unwrap();
}
let error = capture.finish().await.unwrap_err();
assert_eq!(error.code, BackendErrorCode::InvalidArgument);
assert!(!error.retryable);
let kinds = less_computer_event_kinds(&mut events);
assert!(!kinds
.iter()
.any(|kind| matches!(kind, LessComputerEventKind::Error { .. })));
assert!(
matches!(last_voice_state(&kinds), LessComputerEventKind::VoiceState {
outcome: Some(LessComputerVoiceOutcome::Failed), transcript, ..
} if transcript.is_empty())
);
assert_eq!(backend.less_computer_active_session(), None);
}
}

#[tokio::test]
async fn less_computer_panel_start_errors_are_returned_not_published() {
let (_data_dir, backend, transcription, _runtime) =
Expand Down Expand Up @@ -12267,7 +12303,10 @@ mod tests {
async fn raw_style_runs_through_the_real_pipeline_without_a_polishing_stage() {
let data_dir = TestDataDir::new("raw-real-pipeline");
let polisher = crate::testing::FixtureTextPolisher::successful("must not run");
let recorder = Arc::new(crate::testing::FixtureAudioRecorder::default());
let recorder = Arc::new(crate::testing::FixtureAudioRecorder::new(
vec![vec![1, 0]],
Vec::new(),
));
let engine = Arc::new(crate::PipelineDictationEngine::new(
recorder.clone(),
Arc::new(crate::testing::FixtureTranscriptionEngine::successful(
Expand Down Expand Up @@ -12325,7 +12364,10 @@ mod tests {
let backend = backend_with_dictation_engine(
data_dir.path().to_path_buf(),
Arc::new(crate::PipelineDictationEngine::new(
Arc::new(crate::testing::FixtureAudioRecorder::default()),
Arc::new(crate::testing::FixtureAudioRecorder::new(
vec![vec![1, 0]],
Vec::new(),
)),
Arc::new(crate::testing::FixtureTranscriptionEngine::successful(
"raw words",
80,
Expand Down
96 changes: 96 additions & 0 deletions openless-all/app/crates/openless-core/src/dictation_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ struct BufferedTranscriptionInner {
partials: Arc<dyn TextStreamSink>,
progress: Arc<dyn RecordingProgressSink>,
limit_notified: AtomicBool,
has_nonzero_pcm: AtomicBool,
limit_threshold_bytes: usize,
state: Mutex<BufferedTranscriptionState>,
}
Expand Down Expand Up @@ -1164,6 +1165,7 @@ impl BufferedTranscriptionSession {
partials,
progress,
limit_notified: AtomicBool::new(false),
has_nonzero_pcm: AtomicBool::new(false),
limit_threshold_bytes,
state: Mutex::new(BufferedTranscriptionState::Buffering(Vec::new())),
}),
Expand Down Expand Up @@ -1353,6 +1355,10 @@ impl Drop for NotifyOnDrop {

impl AudioConsumer for BufferedTranscriptionSession {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
if !self.inner.has_nonzero_pcm.load(Ordering::Relaxed) && pcm.iter().any(|byte| *byte != 0)
{
self.inner.has_nonzero_pcm.store(true, Ordering::Release);
}
let (downstream, buffer_limit_reached) = {
let mut state = self
.inner
Expand Down Expand Up @@ -1407,6 +1413,24 @@ impl TranscriptionSession for BufferedTranscriptionSession {
}

fn finish(&self) -> BoxFuture<'static, Result<crate::ports::TranscriptOutput, BackendError>> {
// Inspect after the caller stops capture. Cancellation remains independent
// of audio validity, and quiet non-zero samples do not trip a speech threshold.
let terminal = matches!(
&*self
.inner
.state
.lock()
.expect("buffered transcription lock poisoned"),
BufferedTranscriptionState::Failed(_) | BufferedTranscriptionState::Cancelled
);
if !terminal && !self.inner.has_nonzero_pcm.load(Ordering::Acquire) {
return Box::pin(async {
Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"未收到有效音频,请检查麦克风输入设备后重试",
))
});
}
let attaching = self.attach();
Box::pin(async move {
let downstream = attaching.await?;
Expand Down Expand Up @@ -2197,6 +2221,78 @@ mod tests {
assert_eq!(&*fixture.pcm.lock().unwrap(), &[1, 0, 2, 0]);
}

#[tokio::test]
async fn empty_or_zero_pcm_never_finalizes_asr_and_can_still_be_cancelled() {
for (chunk, attached) in [
(Vec::new(), false),
(vec![0; 640], false),
(Vec::new(), true),
(vec![0; 640], true),
] {
let pcm = Arc::new(Mutex::new(Vec::new()));
let starts = Arc::new(AtomicUsize::new(0));
let cancels = Arc::new(AtomicUsize::new(0));
let transcriber = Arc::new(FixtureTranscriber {
session: Arc::new(FixtureTranscriptionSession {
pcm: pcm.clone(),
cancels: cancels.clone(),
finish_entered: None,
finish_release: None,
}),
starts: starts.clone(),
});
let prepared = transcriber
.prepare(SessionId::new(), raw_dictation_context())
.await
.unwrap();
let buffered = BufferedTranscriptionSession::new(
prepared,
Arc::new(DiscardTextStream),
Arc::new(LimitRecordingProgress::default()),
);
if attached {
buffered.attach().await.unwrap();
}
buffered.consume_pcm_chunk(&chunk);
let error = buffered.finish().await.unwrap_err();
assert_eq!(error.code, BackendErrorCode::InvalidArgument);
assert_eq!(starts.load(Ordering::Acquire), usize::from(attached));
buffered.cancel().await.unwrap();
assert_eq!(cancels.load(Ordering::Acquire), usize::from(attached));
assert_eq!(
buffered.finish().await.unwrap_err().code,
BackendErrorCode::Cancelled
);
}
}

#[tokio::test]
async fn quiet_nonzero_pcm_is_not_rejected_as_silence() {
let pcm = Arc::new(Mutex::new(Vec::new()));
let starts = Arc::new(AtomicUsize::new(0));
let transcriber = Arc::new(FixtureTranscriber {
session: Arc::new(FixtureTranscriptionSession {
pcm: pcm.clone(),
cancels: Arc::new(AtomicUsize::new(0)),
finish_entered: None,
finish_release: None,
}),
starts: starts.clone(),
});
let prepared = transcriber
.prepare(SessionId::new(), raw_dictation_context())
.await
.unwrap();
let buffered = BufferedTranscriptionSession::new(
prepared,
Arc::new(DiscardTextStream),
Arc::new(LimitRecordingProgress::default()),
);
buffered.consume_pcm_chunk(&[1, 0]);
assert_eq!(buffered.finish().await.unwrap().text, "raw text");
assert_eq!(starts.load(Ordering::Acquire), 1);
}

#[tokio::test]
async fn buffered_pcm_limit_requests_one_stop_and_preserves_the_tail() {
let pcm = Arc::new(Mutex::new(Vec::new()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ impl AudioRecorder for Recorder {
&self,
_: SessionId,
_: Arc<DictationContext>,
_: Arc<dyn AudioConsumer>,
consumer: Arc<dyn AudioConsumer>,
_: Arc<dyn RecordingProgressSink>,
) -> BoxFuture<'static, Result<Box<dyn ActiveRecording>, BackendError>> {
self.starts.fetch_add(1, Ordering::SeqCst);
consumer.consume_pcm_chunk(&[1, 0]);
let recording = Recording {
stopped: self.stopped.clone(),
gate: self.stop_gate.clone(),
Expand Down Expand Up @@ -351,7 +352,7 @@ async fn qa_and_selection_voice_never_request_disk_archives() {
"less",
] {
let plans = Arc::new(Mutex::new(Vec::new()));
let recorder = testing::FixtureAudioRecorder::new(vec![vec![0; 320]], Vec::new());
let recorder = testing::FixtureAudioRecorder::new(vec![vec![1; 320]], Vec::new());
let (backend, path) = backend(
Arc::new(ArchivePolicyRecorder {
inner: recorder.clone(),
Expand Down Expand Up @@ -491,7 +492,7 @@ async fn stable_mode_is_shared_by_dictation_qa_selection_and_less_computer() {
let starts = Arc::new(AtomicUsize::new(0));
let (backend, path) = backend(
Arc::new(testing::FixtureAudioRecorder::new(
vec![vec![0; 320]],
vec![vec![1; 320]],
Vec::new(),
)),
Arc::new(CountingAsr {
Expand Down Expand Up @@ -1536,7 +1537,10 @@ async fn less_voice_finish_errors_publish_one_safe_error_but_cancellation_does_n
let recorder: Arc<dyn AudioRecorder> = if stop_fails {
Arc::new(StopFailure)
} else {
Arc::new(testing::FixtureAudioRecorder::default())
Arc::new(testing::FixtureAudioRecorder::new(
vec![vec![1, 0]],
Vec::new(),
))
};
let (backend, path) = backend(recorder, Arc::new(asr), Arc::new(QaRuntime::default()));
let mut events = backend.subscribe();
Expand Down
33 changes: 33 additions & 0 deletions openless-all/app/scripts/test-recorder-module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Run the native recorder tests without building Tauri/MLX or fetching ASR submodules.

The bridge trait below matches the only core interface used by recorder.rs.
This checks the production recorder module; it does not replace a desktop cargo check.
"""
import json
from pathlib import Path
import subprocess
import sys
import tempfile

recorder = Path(__file__).resolve().parents[1] / "src-tauri/src/recorder.rs"
with tempfile.TemporaryDirectory(prefix="openless-recorder-tests-") as directory:
root = Path(directory)
(root / "src").mkdir()
(root / "Cargo.toml").write_text("""[package]
name = "openless-recorder-tests"
version = "0.1.0"
edition = "2021"
[dependencies]
cpal = "=0.15.3"
parking_lot = "0.12"
serde = { version = "1", features = ["derive"] }
thiserror = "1"
log = "0.4"
""")
(root / "src/lib.rs").write_text(
"extern crate self as openless_core;\n"
"pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n"
+ "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n"
)
subprocess.run(["cargo", "test", "--manifest-path", str(root / "Cargo.toml"), *sys.argv[1:]], check=True)
6 changes: 5 additions & 1 deletion openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2423,7 +2423,11 @@ pub(crate) mod less_computer_test_support {
};
let data_dir =
std::env::temp_dir().join(format!("openless-less-host-{}", uuid::Uuid::new_v4()));
let recorder = Arc::new(FixtureAudioRecorder::default());
// Successful capture fixtures must provide real PCM, including at very low volume.
let recorder = Arc::new(FixtureAudioRecorder::new(
vec![1i16.to_le_bytes().repeat(1920)],
Vec::new(),
));
let backend = Arc::new(
openless_core::OpenLessBackend::new(
openless_core::BackendConfig {
Expand Down
Loading
Loading