From 4fa811d8be39ee910184a67e5405a3898aa3b98e Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:17:39 +0900 Subject: [PATCH 01/19] wip: whisper stt --- Cargo.lock | 53 ++- Cargo.toml | 1 + wayvr/Cargo.toml | 5 +- wayvr/src/subsystem/mod.rs | 3 + wayvr/src/subsystem/whisper_stt.rs | 712 +++++++++++++++++++++++++++++ wlx-capture/Cargo.toml | 5 +- 6 files changed, 766 insertions(+), 13 deletions(-) create mode 100644 wayvr/src/subsystem/whisper_stt.rs diff --git a/Cargo.lock b/Cargo.lock index 9646ed23..4ce2e84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -1997,6 +1999,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -3015,8 +3023,9 @@ dependencies = [ [[package]] name = "libspa" -version = "0.9.2" -source = "git+https://gitlab.freedesktop.org/pipewire/pipewire-rs.git?rev=670b0aabf04263ece5dc1e752e5d64aeae3056e8#670b0aabf04263ece5dc1e752e5d64aeae3056e8" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" dependencies = [ "bitflags 2.12.1", "cc", @@ -3030,8 +3039,9 @@ dependencies = [ [[package]] name = "libspa-sys" -version = "0.9.2" -source = "git+https://gitlab.freedesktop.org/pipewire/pipewire-rs.git?rev=670b0aabf04263ece5dc1e752e5d64aeae3056e8#670b0aabf04263ece5dc1e752e5d64aeae3056e8" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda" dependencies = [ "bindgen", "cc", @@ -4111,8 +4121,9 @@ dependencies = [ [[package]] name = "pipewire" -version = "0.9.2" -source = "git+https://gitlab.freedesktop.org/pipewire/pipewire-rs.git?rev=670b0aabf04263ece5dc1e752e5d64aeae3056e8#670b0aabf04263ece5dc1e752e5d64aeae3056e8" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" dependencies = [ "bitflags 2.12.1", "libc", @@ -4124,8 +4135,9 @@ dependencies = [ [[package]] name = "pipewire-sys" -version = "0.9.2" -source = "git+https://gitlab.freedesktop.org/pipewire/pipewire-rs.git?rev=670b0aabf04263ece5dc1e752e5d64aeae3056e8#670b0aabf04263ece5dc1e752e5d64aeae3056e8" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc" dependencies = [ "bindgen", "libspa-sys", @@ -6571,6 +6583,7 @@ dependencies = [ "mint", "openxr", "ovr_overlay", + "pipewire", "prost", "pure-rust-locales", "regex", @@ -6597,6 +6610,7 @@ dependencies = [ "wayland-protocols-misc", "wayvr-ipc", "wgui", + "whisper-rs", "winit", "wlx-capture", "wlx-common", @@ -6690,6 +6704,29 @@ dependencies = [ "vulkano-shaders", ] +[[package]] +name = "whisper-rs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2088172d00f936c348d6a72f488dc2660ab3f507263a195df308a3c2383229f6" +dependencies = [ + "libc", + "whisper-rs-sys", +] + +[[package]] +name = "whisper-rs-sys" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6986c0fe081241d391f09b9a071fbcbb59720c3563628c3c829057cf69f2a56f" +dependencies = [ + "bindgen", + "cfg-if", + "cmake", + "fs_extra", + "semver", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 64970436..6a4acded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ glam = { version = "0.30.9", features = ["mint", "serde"] } idmap = "0.2.2" idmap-derive = "0.2.22" log = "0.4.29" +pipewire = { version = "0.10", features = ["v0_3_33", "v0_3_65"] } regex = "1.12.2" rust-embed = "8.9.0" serde = { version = "1", features = ["derive"] } diff --git a/wayvr/Cargo.toml b/wayvr/Cargo.toml index c4f389f3..a4bdf1ac 100644 --- a/wayvr/Cargo.toml +++ b/wayvr/Cargo.toml @@ -32,6 +32,7 @@ glam = { workspace = true, features = ["mint", "serde"] } idmap = { workspace = true, features = ["serde"] } idmap-derive.workspace = true log.workspace = true +pipewire = { workspace = true, optional = true } regex.workspace = true rust-embed.workspace = true serde = { workspace = true, features = ["rc"] } @@ -91,12 +92,13 @@ xcb = { version = "1.6.0", features = [ "as-raw-xcb-connection", ], optional = true } xkbcommon = { version = "0.8.0" } # 0.9.0 breaks keymap import on some distros +whisper-rs = { version = "0.16.0", features = ["vulkan"], optional = true } [build-dependencies] regex.workspace = true [features] -default = ["openvr", "openxr", "osc", "wayland", "x11"] +default = ["openvr", "openxr", "osc", "wayland", "whisper", "x11"] as-raw-xcb-connection = [] feat-monado-metrics = [] openvr = ["dep:json", "dep:ovr_overlay"] @@ -105,5 +107,6 @@ osc = ["dep:rosc"] pipewire = ["wlx-capture/pipewire"] uidev = ["dep:winit"] wayland = ["pipewire", "wlx-capture/wlr", "xkbcommon/wayland"] +whisper = ["dep:pipewire", "dep:whisper-rs"] x11 = ["dep:xcb", "wlx-capture/xshm", "xkbcommon/x11"] xcb = ["dep:xcb"] diff --git a/wayvr/src/subsystem/mod.rs b/wayvr/src/subsystem/mod.rs index 04d997b9..1243d666 100644 --- a/wayvr/src/subsystem/mod.rs +++ b/wayvr/src/subsystem/mod.rs @@ -3,6 +3,9 @@ pub mod hid; pub mod input; pub mod notifications; +#[cfg(feature = "whisper")] +pub mod whisper_stt; + #[cfg(feature = "osc")] pub mod osc; diff --git a/wayvr/src/subsystem/whisper_stt.rs b/wayvr/src/subsystem/whisper_stt.rs new file mode 100644 index 00000000..d1b23dcb --- /dev/null +++ b/wayvr/src/subsystem/whisper_stt.rs @@ -0,0 +1,712 @@ +// whisper_stt.rs + +use std::{ + fmt, + path::{Path, PathBuf}, + sync::{Arc, mpsc}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use pipewire as pw; +use pw::{properties::properties, spa}; +use spa::{ + param::format::{MediaSubtype, MediaType}, + param::format_utils, + pod::Pod, +}; +use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; + +pub struct WhisperModel { + pub name: &'static str, + pub url: &'static str, + pub hash: &'static str, +} + +pub const WHISPER_MODELS: &[WhisperModel] = &[ + WhisperModel { + name: "Base Q8 (78MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin", + hash: "7bb89bb49ed6955013b166f1b6a6c04584a20fbe", + }, + WhisperModel { + name: "Small Q8 (252MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin", + hash: "bcad8a2083f4e53d648d586b7dbc0cd673d8afad", + }, + WhisperModel { + name: "Turbo Q5 (574MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", + hash: "e050f7970618a659205450ad97eb95a18d69c9ee", + }, + WhisperModel { + name: "Turbo Q8 (874MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", + hash: "01bf15bedffe9f39d65c1b6ff9b687ea91f59e0e", + }, + WhisperModel { + name: "Turbo (1.5GiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", + hash: "4af2b29d7ec73d781377bfd1758ca957a807e941", + }, +]; + +const WHISPER_SAMPLE_RATE: usize = 16_000; +const MAX_DURATION: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub struct WhisperSttConfig { + pub model_path: PathBuf, + pub language: Option, + + pub initial_prompt: Option, + pub n_threads: i32, + + /// lower values reduce release-time lag but cost more CPU/GPU + pub partial_decode_interval_ms: u64, + + /// ignore extremely short accidental taps + pub min_audio_ms: u64, + + /// mic object name from `pw-dump`, None for the default + pub pipewire_target_object: Option, + + pub use_gpu: bool, + pub gpu_device: i32, + pub flash_attn: bool, +} + +impl WhisperSttConfig { + pub fn new(model_path: impl AsRef) -> Self { + let n_threads = std::thread::available_parallelism() + .map(|n| n.get().min(4) as i32) + .unwrap_or(4); + + Self { + model_path: model_path.as_ref().to_path_buf(), + language: None, + initial_prompt: None, + n_threads, + partial_decode_interval_ms: 700, + min_audio_ms: 250, + pipewire_target_object: None, + use_gpu: true, + gpu_device: 0, + flash_attn: false, + } + } +} + +#[derive(Debug)] +pub enum WhisperSttError { + ModelLoad(String), + Whisper(String), + PipeWire(String), + CaptureInit(String), + ThreadSpawn(String), + CaptureThreadPanicked, + AlreadyRecording, + NotRecording, +} + +impl fmt::Display for WhisperSttError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ModelLoad(e) => write!(f, "failed to load whisper model: {e}"), + Self::Whisper(e) => write!(f, "whisper error: {e}"), + Self::PipeWire(e) => write!(f, "pipewire error: {e}"), + Self::CaptureInit(e) => write!(f, "failed to initialize capture: {e}"), + Self::ThreadSpawn(e) => write!(f, "failed to spawn thread: {e}"), + Self::CaptureThreadPanicked => write!(f, "capture thread panicked"), + Self::AlreadyRecording => write!(f, "PTT is already active"), + Self::NotRecording => write!(f, "PTT is not active"), + } + } +} + +impl std::error::Error for WhisperSttError {} + +struct StopCapture; + +struct CaptureSession { + stop_tx: pw::channel::Sender, + capture_thread: Option>, + recognizer_thread: Option>, + deadline: Instant, +} + +pub struct WhisperStt { + config: WhisperSttConfig, + ctx: Arc, + + active: Option, + finished_recognizers: Vec>, + + completed_rx: mpsc::Receiver>, + completed_tx: mpsc::Sender>, + + last_error: Option, +} + +impl WhisperStt { + pub fn new(model_path: impl AsRef) -> Result { + Self::init(WhisperSttConfig::new(model_path)) + } + + pub fn init(config: WhisperSttConfig) -> Result { + let mut ctx_params = WhisperContextParameters::default(); + ctx_params.use_gpu = config.use_gpu; + ctx_params.gpu_device = config.gpu_device; + ctx_params.flash_attn = config.flash_attn; + + let ctx = WhisperContext::new_with_params(&config.model_path, ctx_params) + .map_err(|e| WhisperSttError::ModelLoad(e.to_string()))?; + + let (completed_tx, completed_rx) = mpsc::channel(); + + Ok(Self { + config, + ctx: Arc::new(ctx), + active: None, + finished_recognizers: Vec::new(), + completed_rx, + completed_tx, + last_error: None, + }) + } + + /// starts a fresh pw capture stream and a transcription worker + pub fn ptt_start(&mut self) -> Result<(), WhisperSttError> { + self.reap_finished_recognizers(); + + if self.active.is_some() { + return Err(WhisperSttError::AlreadyRecording); + } + + let (audio_tx, audio_rx) = mpsc::channel::>(); + let (ready_tx, ready_rx) = mpsc::channel::>(); + let (stop_tx, stop_rx) = pw::channel::channel::(); + + let recognizer_thread = spawn_recognizer_thread( + Arc::clone(&self.ctx), + self.config.clone(), + audio_rx, + self.completed_tx.clone(), + )?; + + let target_object = self.config.pipewire_target_object.clone(); + + let capture_thread = thread::Builder::new() + .name("whisper-stt-pipewire-capture".to_string()) + .spawn(move || { + pipewire_capture_thread(audio_tx, stop_rx, target_object, ready_tx); + }) + .map_err(|e| WhisperSttError::ThreadSpawn(e.to_string()))?; + + match ready_rx.recv() { + Ok(Ok(())) => { + self.active = Some(CaptureSession { + stop_tx, + capture_thread: Some(capture_thread), + recognizer_thread: Some(recognizer_thread), + deadline: Instant::now() + MAX_DURATION, + }); + + Ok(()) + } + Ok(Err(e)) => { + let _ = stop_tx.send(StopCapture); + let _ = capture_thread.join(); + let _ = recognizer_thread.join(); + + Err(WhisperSttError::CaptureInit(e)) + } + Err(e) => { + let _ = stop_tx.send(StopCapture); + let _ = capture_thread.join(); + let _ = recognizer_thread.join(); + + Err(WhisperSttError::CaptureInit(e.to_string())) + } + } + } + + fn stop_active_capture(&mut self) -> Result<(), WhisperSttError> { + let Some(mut session) = self.active.take() else { + return Err(WhisperSttError::NotRecording); + }; + + let _ = session.stop_tx.send(StopCapture); + + let capture_result = if let Some(capture_thread) = session.capture_thread.take() { + capture_thread + .join() + .map_err(|_| WhisperSttError::CaptureThreadPanicked) + } else { + Ok(()) + }; + + if let Some(recognizer_thread) = session.recognizer_thread.take() { + self.finished_recognizers.push(recognizer_thread); + } + + capture_result + } + + fn drain_completed_transcriptions(&mut self) -> Option { + let mut latest = None; + + while let Ok(result) = self.completed_rx.try_recv() { + match result { + Ok(text) => { + let text = normalize_transcript(text); + if !text.is_empty() { + latest = Some(text); + } + } + Err(e) => { + self.last_error = Some(e); + } + } + } + + latest + } + + /// stops the pw stream & finalizes recognition asynchronously + /// poll `take_transcription()` from your main loop to receive transcription + pub fn ptt_end(&mut self) -> Result<(), WhisperSttError> { + self.stop_active_capture() + } + + pub fn take_transcription(&mut self) -> Option { + self.reap_finished_recognizers(); + + let latest = self.drain_completed_transcriptions(); + + if latest.is_some() { + return latest; + } + + // been recording for too long, force send a stop signal + if self + .active + .as_ref() + .is_some_and(|session| Instant::now() >= session.deadline) + { + if let Err(e) = self.stop_active_capture() { + self.last_error = Some(e.to_string()); + } + } + + return None; + } + + pub fn take_error(&mut self) -> Option { + self.last_error.take() + } + + pub fn is_recording(&self) -> bool { + self.active.is_some() + } + + fn reap_finished_recognizers(&mut self) { + let mut i = 0; + + while i < self.finished_recognizers.len() { + if self.finished_recognizers[i].is_finished() { + let handle = self.finished_recognizers.swap_remove(i); + let _ = handle.join(); + } else { + i += 1; + } + } + } +} + +impl Drop for WhisperStt { + fn drop(&mut self) { + if self.active.is_some() { + let _ = self.ptt_end(); + } + + for handle in self.finished_recognizers.drain(..) { + let _ = handle.join(); + } + } +} + +fn spawn_recognizer_thread( + ctx: Arc, + config: WhisperSttConfig, + audio_rx: mpsc::Receiver>, + completed_tx: mpsc::Sender>, +) -> Result, WhisperSttError> { + thread::Builder::new() + .name("whisper-stt-recognizer".to_string()) + .spawn(move || { + recognizer_thread(ctx, config, audio_rx, completed_tx); + }) + .map_err(|e| WhisperSttError::ThreadSpawn(e.to_string())) +} + +fn recognizer_thread( + ctx: Arc, + config: WhisperSttConfig, + audio_rx: mpsc::Receiver>, + completed_tx: mpsc::Sender>, +) { + let partial_stride_samples = + ms_to_samples(config.partial_decode_interval_ms).max(WHISPER_SAMPLE_RATE / 4); + let min_samples = ms_to_samples(config.min_audio_ms); + + let mut audio = Vec::::new(); + let mut last_decoded_len = 0usize; + let mut latest_partial = String::new(); + + while let Ok(chunk) = audio_rx.recv() { + if chunk.is_empty() { + continue; + } + + audio.extend_from_slice(&chunk); + + let enough_new_audio = + audio.len().saturating_sub(last_decoded_len) >= partial_stride_samples; + + if audio.len() >= min_samples && enough_new_audio { + match transcribe_audio(&ctx, &config, &audio) { + Ok(text) => { + latest_partial = text; + last_decoded_len = audio.len(); + } + Err(_) => { + // Do not fail the session on a speculative decode. + // The final decode after PTT end gets reported. + } + } + } + } + + if audio.len() < min_samples { + let _ = completed_tx.send(Ok(String::new())); + return; + } + + match transcribe_audio(&ctx, &config, &audio) { + Ok(text) => { + let _ = completed_tx.send(Ok(text)); + } + Err(e) if !latest_partial.trim().is_empty() => { + // Prefer a recent partial over losing the utterance completely. + let _ = completed_tx.send(Ok(latest_partial)); + let _ = completed_tx.send(Err(e.to_string())); + } + Err(e) => { + let _ = completed_tx.send(Err(e.to_string())); + } + } +} + +fn transcribe_audio( + ctx: &WhisperContext, + config: &WhisperSttConfig, + audio: &[f32], +) -> Result { + let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + + params.set_n_threads(config.n_threads); + params.set_language(config.language.as_deref()); + params.set_no_timestamps(true); + params.set_print_special(false); + params.set_print_progress(false); + params.set_print_realtime(false); + params.set_print_timestamps(false); + + if let Some(prompt) = config.initial_prompt.as_deref() { + params.set_initial_prompt(prompt); + } + + let mut state = ctx + .create_state() + .map_err(|e| WhisperSttError::Whisper(e.to_string()))?; + + state + .full(params, audio) + .map_err(|e| WhisperSttError::Whisper(e.to_string()))?; + + let text = state + .as_iter() + .map(|segment| segment.to_string()) + .collect::>() + .join(""); + + Ok(normalize_transcript(text)) +} + +fn pipewire_capture_thread( + audio_tx: mpsc::Sender>, + stop_rx: pw::channel::Receiver, + target_object: Option, + ready_tx: mpsc::Sender>, +) { + let mut ready_tx = Some(ready_tx); + + let result = run_pipewire_capture(audio_tx, stop_rx, target_object, &mut ready_tx); + + if let Err(e) = result { + if let Some(ready_tx) = ready_tx.take() { + let _ = ready_tx.send(Err(e.to_string())); + } + } +} + +fn run_pipewire_capture( + audio_tx: mpsc::Sender>, + stop_rx: pw::channel::Receiver, + target_object: Option, + ready_tx: &mut Option>>, +) -> Result<(), WhisperSttError> { + pw::init(); + + let mainloop = pw::main_loop::MainLoopRc::new(None) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + let context = pw::context::ContextRc::new(&mainloop, None) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + let core = context + .connect_rc(None) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + let _stop_receiver = stop_rx.attach(mainloop.loop_(), { + let mainloop = mainloop.clone(); + move |_| { + mainloop.quit(); + } + }); + + let mut props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Communication", + *pw::keys::APP_NAME => "WhisperStt", + }; + + if let Some(target_object) = target_object { + props.insert(*pw::keys::TARGET_OBJECT, target_object); + } + + let stream = pw::stream::StreamBox::new(&core, "WhisperStt microphone capture", props) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + let user_data = AudioCaptureUserData::default(); + let audio_tx_for_callback = audio_tx.clone(); + + let _listener = stream + .add_local_listener_with_user_data(user_data) + .param_changed(|_, user_data, id, param| { + let Some(param) = param else { + return; + }; + + if id != pw::spa::param::ParamType::Format.as_raw() { + return; + } + + let Ok((media_type, media_subtype)) = format_utils::parse_format(param) else { + return; + }; + + if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw { + return; + } + + let _ = user_data.format.parse(param); + }) + .process(move |stream, user_data| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + + let data = &mut datas[0]; + + let chunk = data.chunk(); + + let offset = chunk.offset() as usize; + let size = chunk.size() as usize; + + let Some(bytes) = data.data() else { + return; + }; + + if offset >= bytes.len() { + return; + } + + let end = offset.saturating_add(size).min(bytes.len()); + let bytes = &bytes[offset..end]; + + let channels = (user_data.format.channels() as usize).max(1); + let input_rate = { + let rate = user_data.format.rate() as usize; + if rate == 0 { 48_000 } else { rate } + }; + + let resampled = user_data + .resampler + .push_interleaved_f32le_mono_16k(bytes, channels, input_rate); + + if !resampled.is_empty() { + let _ = audio_tx_for_callback.send(resampled); + } + }) + .register() + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + let mut audio_info = spa::param::audio::AudioInfoRaw::new(); + audio_info.set_format(spa::param::audio::AudioFormat::F32LE); + + let obj = pw::spa::pod::Object { + type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: pw::spa::param::ParamType::EnumFormat.as_raw(), + properties: audio_info.into(), + }; + + let values: Vec = pw::spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &pw::spa::pod::Value::Object(obj), + ) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))? + .0 + .into_inner(); + + let pod = Pod::from_bytes(&values).ok_or_else(|| { + WhisperSttError::PipeWire("failed to parse serialized PipeWire pod".to_string()) + })?; + + let mut params = [pod]; + + stream + .connect( + spa::utils::Direction::Input, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + + if let Some(ready_tx) = ready_tx.take() { + let _ = ready_tx.send(Ok(())); + } + + mainloop.run(); + + Ok(()) +} + +#[derive(Default)] +struct AudioCaptureUserData { + format: spa::param::audio::AudioInfoRaw, + resampler: StreamingResampler, +} + +#[derive(Default)] +struct StreamingResampler { + pending: Vec, + position: f64, + input_rate: usize, +} + +impl StreamingResampler { + fn push_interleaved_f32le_mono_16k( + &mut self, + bytes: &[u8], + channels: usize, + input_rate: usize, + ) -> Vec { + if channels == 0 || input_rate == 0 { + return Vec::new(); + } + + if self.input_rate != input_rate { + self.pending.clear(); + self.position = 0.0; + self.input_rate = input_rate; + } + + let frame_bytes = channels * std::mem::size_of::(); + if frame_bytes == 0 { + return Vec::new(); + } + + let frames = bytes.len() / frame_bytes; + if frames == 0 { + return Vec::new(); + } + + let mut mono = Vec::with_capacity(frames); + + for frame in 0..frames { + let frame_start = frame * frame_bytes; + let mut sum = 0.0f32; + + for ch in 0..channels { + let sample_start = frame_start + ch * 4; + + let sample = f32::from_le_bytes([ + bytes[sample_start], + bytes[sample_start + 1], + bytes[sample_start + 2], + bytes[sample_start + 3], + ]); + + sum += sample; + } + + mono.push(sum / channels as f32); + } + + self.pending.extend_from_slice(&mono); + + let step = input_rate as f64 / WHISPER_SAMPLE_RATE as f64; + let mut out = Vec::with_capacity( + ((self.pending.len() as f64 - self.position) / step).max(0.0) as usize, + ); + + while self.position + 1.0 < self.pending.len() as f64 { + let i = self.position.floor() as usize; + let frac = (self.position - i as f64) as f32; + + let a = self.pending[i]; + let b = self.pending[i + 1]; + + out.push(a + (b - a) * frac); + + self.position += step; + } + + let drop_count = self.position.floor() as usize; + if drop_count > 0 { + self.pending.drain(..drop_count); + self.position -= drop_count as f64; + } + + out + } +} + +fn ms_to_samples(ms: u64) -> usize { + ((ms as usize) * WHISPER_SAMPLE_RATE) / 1000 +} + +fn normalize_transcript(text: String) -> String { + text.split_whitespace().collect::>().join(" ") +} diff --git a/wlx-capture/Cargo.toml b/wlx-capture/Cargo.toml index 5294556e..b338f85d 100644 --- a/wlx-capture/Cargo.toml +++ b/wlx-capture/Cargo.toml @@ -32,10 +32,7 @@ drm-fourcc = "2.2.0" idmap = { workspace = true } libc = "0.2.178" log = { workspace = true } -pipewire = { git = "https://gitlab.freedesktop.org/pipewire/pipewire-rs.git", rev = "670b0aabf04263ece5dc1e752e5d64aeae3056e8", features = [ - "v0_3_33", "v0_3_65", -], optional = true } -#pipewire = { version = "0.8.0", features = ["v0_3_33"], optional = true } +pipewire = { workspace = true, optional = true } rxscreen = { version = "0.1.7", features = [ "shm", "xrandr", From 0b2c3052f67ab669e7929451d4e8af2ed73e180e Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:57:02 +0900 Subject: [PATCH 02/19] wip: whisper stt #2 --- dash-frontend/assets/lang/en.json | 7 + dash-frontend/src/tab/settings/mod.rs | 47 ++- .../src/tab/settings/tab_features.rs | 281 +++++++++++++++++- dash-frontend/src/util/mod.rs | 1 + dash-frontend/src/util/whisper.rs | 81 +++++ dash-frontend/src/views/download_file.rs | 2 +- wayvr/src/config.rs | 2 + wayvr/src/subsystem/whisper_stt.rs | 34 --- wlx-common/src/config.rs | 3 + wlx-common/src/data_dir.rs | 20 ++ wlx-common/src/lib.rs | 1 + 11 files changed, 422 insertions(+), 57 deletions(-) create mode 100644 dash-frontend/src/util/whisper.rs create mode 100644 wlx-common/src/data_dir.rs diff --git a/dash-frontend/assets/lang/en.json b/dash-frontend/assets/lang/en.json index 568d55fc..6398977a 100644 --- a/dash-frontend/assets/lang/en.json +++ b/dash-frontend/assets/lang/en.json @@ -193,6 +193,12 @@ "USE_SKYBOX_HELP": "Show a skybox if there's no scene app or passthrough", "WATCH_VIEW_ANGLE": "Watch view angles", "WATCH_VIEW_ANGLE_HELP": "Control how the watch fades away", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "The selected model needs to be downloaded.\nDownload it right now?", + "REMOVE_UNUSED_MODELS": "You are turning off the speech-to-text feature.\nWould you like to remove downloaded Whisper models to free up space?" + }, + "WHISPER_MODEL": "Speech-to-text model", + "WHISPER_MODEL_HELP": "Whisper model to use.\nLarger models produce more accurate\nresults, but consume more VRAM.", "GRID_OPACITY": "Floor grid opacity", "GRID_OPACITY_HELP": "Opacity of the floor grid when the skybox is enabled", "XWAYLAND_BY_DEFAULT": "Run apps in Compatibility mode by default", @@ -227,6 +233,7 @@ "BUTTON_TOOLTIP": "Donate via Open Collective", "CURRENT_SUPPORTERS": "Current Supporters" }, + "DOWNLOAD": "Download", "DOWNLOADER": "Downloader", "WELCOME": { "ALL_SET": "You're all set!", diff --git a/dash-frontend/src/tab/settings/mod.rs b/dash-frontend/src/tab/settings/mod.rs index afebc9db..2f467da1 100644 --- a/dash-frontend/src/tab/settings/mod.rs +++ b/dash-frontend/src/tab/settings/mod.rs @@ -28,7 +28,7 @@ use wlx_common::{ use crate::{ frontend::{Frontend, FrontendTask, FrontendTasks}, - tab::{Tab, TabType, settings::macros::MacroParams}, + tab::{settings::macros::MacroParams, Tab, TabType}, views::ViewUpdateParams, }; @@ -107,6 +107,16 @@ trait SettingsTab { fn setting_updated(&mut self, _sup: &mut SettingUpdatedParams) -> anyhow::Result<()> { Ok(()) } + + fn context_menu_custom( + &mut self, + _action: Rc, + _config: &mut GeneralConfig, + _layout: &mut Layout, + _state: &mut ParserState, + ) -> anyhow::Result<()> { + Ok(()) + } } pub struct TabSettings { @@ -235,27 +245,32 @@ impl Tab for TabSettings { } // Dropdown handling - if let TickResult::Action(name) = self.context_menu.tick(&mut frontend.layout, &mut self.state)? - && let (Some(setting), Some(id), Some(value), Some(text), Some(translated)) = { + if let TickResult::Action(name) = self.context_menu.tick(&mut frontend.layout, &mut self.state)? { + if name.starts_with(';') && let Some(tab) = self.current_tab.as_mut() { + let config = frontend.interface.general_config(data); + tab.context_menu_custom(name, config, &mut frontend.layout, &mut self.state)?; + + } else if let (Some(setting), Some(id), Some(value), Some(text), Some(translated)) = { let mut s = name.splitn(5, ';'); (s.next(), s.next(), s.next(), s.next(), s.next()) } { - let mut common = frontend.layout.common(); - let mut label = self - .state - .fetch_widget_as::(common.state, &format!("{id}_value"))?; + let mut common = frontend.layout.common(); + let mut label = self + .state + .fetch_widget_as::(common.state, &format!("{id}_value"))?; - let translation = Translation { - text: text.into(), - translated: translated == "1", - }; + let translation = Translation { + text: text.into(), + translated: translated == "1", + }; - label.set_text(&mut common, translation); + label.set_text(&mut common, translation); - let setting = SettingType::from_str(setting).expect("Invalid Enum string"); - let config = frontend.interface.general_config(data); - setting.set_enum(config, value); - changed = Some(ConfigChangeKind::OverlayConfig); + let setting = SettingType::from_str(setting).expect("Invalid Enum string"); + let config = frontend.interface.general_config(data); + setting.set_enum(config, value); + changed = Some(ConfigChangeKind::OverlayConfig); + } } // Notify overlays of the change diff --git a/dash-frontend/src/tab/settings/tab_features.rs b/dash-frontend/src/tab/settings/tab_features.rs index 96d40fc3..4be0cc83 100644 --- a/dash-frontend/src/tab/settings/tab_features.rs +++ b/dash-frontend/src/tab/settings/tab_features.rs @@ -1,15 +1,117 @@ -use crate::tab::settings::{ - SettingType, SettingsMountParams, SettingsTab, - macros::{options_category, options_checkbox, options_range_f32}, +use std::rc::Rc; + +use wgui::{ + components::button::{ButtonClickEvent, ComponentButton}, globals::WguiGlobals, i18n::Translation, layout::WidgetID, log::LogErr, parser::{Fetchable, TemplateParams}, task::Tasks, widget::label::WidgetLabel, windowing::context_menu::{self} +}; +use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig}; + +use crate::{ + frontend::FrontendTasks, + tab::settings::{ + SettingType, SettingsMountParams, SettingsTab, TabNameEnum, Task as ParentTask, horiz_cell, macros::{MacroParams, options_category, options_checkbox, options_range_f32} + }, + util::{ + popup_manager::PopupHolder, + whisper::{WHISPER_MODELS, WhisperModel, whisper_any_models_downloaded, whisper_delete_all_models, whisper_model_from_name, whisper_model_path}, + }, + views::{self, ViewUpdateParams}, }; -pub struct State {} +#[derive(Clone)] +enum Task { + WhisperDownloadClosed, + WhisperRemoveUnused, + WhisperDownload(&'static WhisperModel), + WhisperDownloadDone, + CloseDialog, +} -impl SettingsTab for State {} +pub struct State { + popup_download: PopupHolder, + popup_dialog: PopupHolder, + frontend_tasks: FrontendTasks, + globals: WguiGlobals, + tasks: Tasks, + parent_tasks: Tasks, + pending_download: Option<&'static WhisperModel>, +} + +impl SettingsTab for State { + fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> { + self.popup_download.update(par)?; + self.popup_dialog.update(par)?; + + for task in self.tasks.drain() { + match task { + Task::WhisperDownloadClosed => { + if let Some(model) = self.pending_download.take() { + if !whisper_model_path(model.file_name).exists() { + // download failed, set to selection to none + par.general_config.whisper_model = "".into(); + // re-init the whole tab + self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); + } + } + } + Task::WhisperRemoveUnused => { + let _ = whisper_delete_all_models().log_err("could not remove whisper models"); + }, + Task::WhisperDownload(model) => { + self.pending_download = Some(model); + self.show_whisper_download_dialog(model, par.executor.clone()); + }, + Task::WhisperDownloadDone => { + // we're happy + }, + Task::CloseDialog => { + let close_dialog = self.popup_dialog.get_close_callback(par.layout); + close_dialog(); + } + } + } + + Ok(()) + } + + fn context_menu_custom( + &mut self, + action: Rc, + config: &mut GeneralConfig, + layout: &mut wgui::layout::Layout, + state: &mut wgui::parser::ParserState, + ) -> anyhow::Result<()> { + if let (Some(_), Some(id), Some(model_name)) = { + let mut s = action.splitn(3, ';'); + (s.next(), s.next(), s.next()) + } { + if !model_name.is_empty() && let Some(model) = whisper_model_from_name(model_name) + { + let mut common = layout.common(); + let mut label = state.fetch_widget_as::(common.state, &format!("{id}_value"))?; + label.set_text(&mut common, Translation::from_raw_text(model.display_name)); + + config.whisper_model = action.as_ref().into(); + + self.show_whisper_model_dialog_box_download(model)?; + } else if whisper_any_models_downloaded().unwrap_or_default() { + self.show_whisper_model_dialog_box_cleanup()?; + } + } + + Ok(()) + } +} impl State { pub fn mount(par: SettingsMountParams) -> anyhow::Result { + let tasks = Tasks::::new(); + let popup_download = PopupHolder::::default(); + let popup_dialog = PopupHolder::::default(); + let c = options_category(par.mp, par.id_parent, "APP_SETTINGS.FEATURES", "dashboard/options.svg")?; + + whisper_models_dropdown(par.mp, c)?; + options_checkbox(par.mp, c, SettingType::NotificationsEnabled)?; options_checkbox(par.mp, c, SettingType::NotificationsSoundEnabled)?; options_checkbox(par.mp, c, SettingType::KeyboardSoundEnabled)?; @@ -32,6 +134,173 @@ impl State { 1.0, 0.1, )?; - Ok(State {}) + Ok(State { + tasks, + parent_tasks: par.mp.tasks.clone(), + popup_download, + popup_dialog, + frontend_tasks: par.frontend_tasks.clone(), + globals: par.mp.doc_params.globals.clone(), + pending_download: None, + }) + } + + fn show_whisper_download_dialog(&mut self, model: &WhisperModel, executor: AsyncExecutor) { + views::download_file::mount_popup( + self.popup_download.clone(), + self.frontend_tasks.clone(), + self.tasks.make_callback_box(Task::WhisperDownloadClosed), + views::download_file::Params { + globals: self.globals.clone(), + executor, + target_path: whisper_model_path(model.file_name), + url: model.url.into(), + on_downloaded: self.tasks.make_callback_box(Task::WhisperDownloadDone), + }, + ); + } + + fn show_whisper_model_dialog_box_download(&mut self, model: &'static WhisperModel) -> anyhow::Result<()> { + const ACTION_DOWNLOAD: &str = "download"; + const ACTION_CANCEL: &str = "cancel"; + + self.pending_download = Some(model); + + let tasks = self.tasks.clone(); + views::dialog_box::mount_popup( + self.popup_dialog.clone(), + self.frontend_tasks.clone(), + views::dialog_box::Params { + globals: self.globals.clone(), + message: Translation::from_translation_key("APP_SETTINGS.WHISPER.NEED_TO_DOWNLOAD_MODEL"), + entries: vec![ + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), + icon: "dashboard/close.svg", + action: ACTION_CANCEL, + }, + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("DOWNLOAD"), + icon: "dashboard/download.svg", + action: ACTION_DOWNLOAD, + }, + ], + on_action_click: Box::new(move |action| match action { + ACTION_DOWNLOAD => { + tasks.push(Task::WhisperDownload(model)); + } + ACTION_CANCEL => { + tasks.push(Task::CloseDialog); + + // treat as failed download + tasks.push(Task::WhisperDownloadClosed); + } + _ => unreachable!(), + }), + }, + ); + + Ok(()) + } + + fn show_whisper_model_dialog_box_cleanup(&mut self) -> anyhow::Result<()> { + const ACTION_REMOVE: &str = "remove"; + const ACTION_CANCEL: &str = "cancel"; + + let tasks = self.tasks.clone(); + + views::dialog_box::mount_popup( + self.popup_dialog.clone(), + self.frontend_tasks.clone(), + views::dialog_box::Params { + globals: self.globals.clone(), + message: Translation::from_translation_key("APP_SETTINGS.WHISPER.REMOVE_UNUSED_MODELS"), + entries: vec![ + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), + icon: "dashboard/close.svg", + action: ACTION_CANCEL, + }, + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("REMOVE"), + icon: "dashboard/trash.svg", + action: ACTION_REMOVE, + }, + ], + on_action_click: Box::new(move |action| match action { + ACTION_REMOVE => { + tasks.push(Task::WhisperRemoveUnused); + } + ACTION_CANCEL => { + tasks.push(Task::CloseDialog); + } + _ => unreachable!(), + }), + }, + ); + + Ok(()) } } + +fn whisper_models_dropdown(mp: &mut MacroParams, parent: WidgetID) -> anyhow::Result<()> { + let id = mp.idx.to_string(); + mp.idx += 1; + + let parent = horiz_cell(mp.layout, parent)?; + + let current_file = mp.config.whisper_model.as_ref(); + let current_translation = whisper_model_from_name(current_file) + .map_or(Translation::from_translation_key("APP_SETTINGS.OPTION.NONE"), |x| { + Translation::from_raw_text(x.display_name) + }); + + let mut params = TemplateParams::new(); + params.insert("id", &id); + params.insert("translation", "APP_SETTINGS.WHISPER_MODEL".into()); + params.insert("tooltip", "APP_SETTINGS.WHISPER_MODEL_HELP".into()); + + mp.parser_state + .instantiate_template(mp.doc_params, "DropdownButton", mp.layout, parent, params)?; + + { + let mut label = mp + .parser_state + .fetch_widget_as::(&mp.layout.state, &format!("{id}_value"))?; + label.set_text_simple(&mut mp.layout.state.globals.get(), current_translation); + } + + let btn = mp.parser_state.fetch_component_as::(&id)?; + btn.on_click(Rc::new({ + let parent_tasks = mp.tasks.clone(); + move |_common, e: ButtonClickEvent| { + let mut cells = WHISPER_MODELS + .iter() + .map(|item| context_menu::Cell { + action_name: Some(format!(";{id};{}", item.file_name).into()), + title: Translation::from_raw_text(item.display_name), + tooltip: None, + attribs: vec![], + }) + .collect::>(); + + cells.insert( + 0, + context_menu::Cell { + action_name: Some(format!(";{id};").into()), + title: Translation::from_translation_key("APP_SETTINGS.OPTION.NONE"), + tooltip: None, + attribs: vec![], + }, + ); + + parent_tasks.push(ParentTask::OpenContextMenu( + e.mouse_pos_absolute.unwrap_or_default(), + cells, + )); + Ok(()) + } + })); + + Ok(()) +} diff --git a/dash-frontend/src/util/mod.rs b/dash-frontend/src/util/mod.rs index 2dd7db67..7eda5e07 100644 --- a/dash-frontend/src/util/mod.rs +++ b/dash-frontend/src/util/mod.rs @@ -6,3 +6,4 @@ pub mod popup_manager; pub mod steam_utils; pub mod toast_manager; pub mod wgui_simple; +pub mod whisper; diff --git a/dash-frontend/src/util/whisper.rs b/dash-frontend/src/util/whisper.rs new file mode 100644 index 00000000..823f5b07 --- /dev/null +++ b/dash-frontend/src/util/whisper.rs @@ -0,0 +1,81 @@ +use std::{fs, io, path::PathBuf}; + +use wlx_common::data_dir; + +pub struct WhisperModel { + pub file_name: &'static str, + pub display_name: &'static str, + pub url: &'static str, + pub hash: &'static str, +} + +pub const WHISPER_MODELS: &[WhisperModel] = &[ + WhisperModel { + file_name: "ggml-base-q8_0.bin", + display_name: "Base Q8 (78MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin", + hash: "7bb89bb49ed6955013b166f1b6a6c04584a20fbe", + }, + WhisperModel { + file_name: "ggml-small-q8_0.bin", + display_name: "Small Q8 (252MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin", + hash: "bcad8a2083f4e53d648d586b7dbc0cd673d8afad", + }, + WhisperModel { + file_name: "ggml-large-v3-turbo-q5_0.bin", + display_name: "Turbo Q5 (574MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", + hash: "e050f7970618a659205450ad97eb95a18d69c9ee", + }, + WhisperModel { + file_name: "ggml-large-v3-turbo-q8_0.bin", + display_name: "Turbo Q8 (874MiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", + hash: "01bf15bedffe9f39d65c1b6ff9b687ea91f59e0e", + }, + WhisperModel { + file_name: "ggml-large-v3-turbo.bin", + display_name: "Turbo (1.5GiB)", + url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", + hash: "4af2b29d7ec73d781377bfd1758ca957a807e941", + }, +]; + +pub fn whisper_model_from_name(file_name: &str) -> Option<&'static WhisperModel> { + WHISPER_MODELS.iter().find(|x| x.file_name == file_name) +} + +pub fn whisper_model_folder() -> PathBuf { + data_dir::get_path("whisper") +} + +pub fn whisper_model_path(file_name: &str) -> PathBuf { + whisper_model_folder().join(file_name) +} + +pub fn whisper_any_models_downloaded() -> io::Result { + let path = whisper_model_folder(); + if !path.is_dir() { + return Ok(false); + } + Ok(fs::read_dir(path)?.count() > 0) +} + +pub fn whisper_delete_all_models() -> io::Result<()> { + let path = whisper_model_folder(); + if !path.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + + if file_type.is_file() || file_type.is_symlink() { + fs::remove_file(entry.path())?; + } + } + + Ok(()) +} diff --git a/dash-frontend/src/views/download_file.rs b/dash-frontend/src/views/download_file.rs index c2502451..fb5d51e3 100644 --- a/dash-frontend/src/views/download_file.rs +++ b/dash-frontend/src/views/download_file.rs @@ -220,7 +220,7 @@ impl View { tasks.push(Task::SetStatusText(String::from("Writing to file..."))); - // create skymaps directory if it doesn't exist yet + // create parent directory if it doesn't exist yet if let Some(parent) = target_path.parent() { handle_async_result( "Directory creation failed", diff --git a/wayvr/src/config.rs b/wayvr/src/config.rs index 5229906b..f9255988 100644 --- a/wayvr/src/config.rs +++ b/wayvr/src/config.rs @@ -184,6 +184,7 @@ pub struct AutoSettings { pub chroma_key_params: ChromaKeyParams, pub input_emulation_method: InputEmulationMethod, pub tutorial_graduated: bool, + pub whisper_model: Arc, } fn get_settings_path() -> PathBuf { @@ -246,6 +247,7 @@ pub fn save_settings(config: &GeneralConfig) -> anyhow::Result<()> { chroma_key_params: config.chroma_key_params.clone(), input_emulation_method: config.input_emulation_method, tutorial_graduated: config.tutorial_graduated, + whisper_model: config.whisper_model.clone(), }; let json = serde_json::to_string_pretty(&conf).unwrap(); // want panic diff --git a/wayvr/src/subsystem/whisper_stt.rs b/wayvr/src/subsystem/whisper_stt.rs index d1b23dcb..b5a36931 100644 --- a/wayvr/src/subsystem/whisper_stt.rs +++ b/wayvr/src/subsystem/whisper_stt.rs @@ -17,40 +17,6 @@ use spa::{ }; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; -pub struct WhisperModel { - pub name: &'static str, - pub url: &'static str, - pub hash: &'static str, -} - -pub const WHISPER_MODELS: &[WhisperModel] = &[ - WhisperModel { - name: "Base Q8 (78MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin", - hash: "7bb89bb49ed6955013b166f1b6a6c04584a20fbe", - }, - WhisperModel { - name: "Small Q8 (252MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin", - hash: "bcad8a2083f4e53d648d586b7dbc0cd673d8afad", - }, - WhisperModel { - name: "Turbo Q5 (574MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", - hash: "e050f7970618a659205450ad97eb95a18d69c9ee", - }, - WhisperModel { - name: "Turbo Q8 (874MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", - hash: "01bf15bedffe9f39d65c1b6ff9b687ea91f59e0e", - }, - WhisperModel { - name: "Turbo (1.5GiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", - hash: "4af2b29d7ec73d781377bfd1758ca957a807e941", - }, -]; - const WHISPER_SAMPLE_RATE: usize = 16_000; const MAX_DURATION: Duration = Duration::from_secs(30); diff --git a/wlx-common/src/config.rs b/wlx-common/src/config.rs index d0745fc5..2d7ec386 100644 --- a/wlx-common/src/config.rs +++ b/wlx-common/src/config.rs @@ -430,4 +430,7 @@ pub struct GeneralConfig { #[serde(default)] pub tutorial_graduated: bool, + + #[serde(default = "def_empty")] + pub whisper_model: Arc, } diff --git a/wlx-common/src/data_dir.rs b/wlx-common/src/data_dir.rs new file mode 100644 index 00000000..c843284d --- /dev/null +++ b/wlx-common/src/data_dir.rs @@ -0,0 +1,20 @@ +use std::{path::PathBuf, sync::LazyLock}; + +const FALLBACK_DATA_PATH: &str = "/tmp/wayvr_data"; + +static DATA_ROOT_PATH: LazyLock = LazyLock::new(|| { + if let Some(mut dir) = xdg::BaseDirectories::new().get_data_home() { + dir.push("wayvr"); + return dir; + } + log::error!("Err: Failed to find data path, using {FALLBACK_DATA_PATH}"); + PathBuf::from(FALLBACK_DATA_PATH) +}); + +fn get_data_root() -> PathBuf { + DATA_ROOT_PATH.clone() +} + +pub fn get_path(data_path: &str) -> PathBuf { + get_data_root().join(data_path) +} diff --git a/wlx-common/src/lib.rs b/wlx-common/src/lib.rs index fa81cfa1..09fe1a07 100644 --- a/wlx-common/src/lib.rs +++ b/wlx-common/src/lib.rs @@ -5,6 +5,7 @@ pub mod cache_dir; pub mod common; pub mod config; pub mod config_io; +pub mod data_dir; pub mod dash_interface; pub mod dash_interface_emulated; pub mod desktop_finder; From a13e393f4c7c9a807cbdfc7bac323c959526fc04 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:11:43 +0900 Subject: [PATCH 03/19] file_downloader to handle redirects --- .../src/util/networking/http_client.rs | 115 +++++++++++++++++- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/dash-frontend/src/util/networking/http_client.rs b/dash-frontend/src/util/networking/http_client.rs index 8500dc92..1f525aaa 100644 --- a/dash-frontend/src/util/networking/http_client.rs +++ b/dash-frontend/src/util/networking/http_client.rs @@ -42,8 +42,6 @@ pub struct GetParams<'a> { } pub async fn get(params: GetParams<'_>) -> anyhow::Result { - log::info!("fetching URL \"{}\"", params.url); - let url: hyper::Uri = params.url.try_into()?; let req = Request::builder() .header( @@ -53,7 +51,74 @@ pub async fn get(params: GetParams<'_>) -> anyhow::Result { .uri(url) .body(Empty::new())?; - let resp = fetch(params.executor, req).await?; + let resp = fetch_and_follow_redirects(params.executor, req, params.on_progress, ¶ms.url).await?; + + Ok(resp) +} + +async fn fetch_and_follow_redirects( + executor: &AsyncExecutor, + req: hyper::Request>, + mut on_progress: Option, + initial_url: &str, +) -> anyhow::Result { + log::info!("fetching URL \"{}\"", initial_url); + + let resp = fetch(executor, req.clone()).await?; + let status = resp.status(); + + if status.is_redirection() { + let next_req = follow_single_redirect(&req, &resp).await?; + + let max_redirects = 10; + let mut current_req = next_req; + let mut redirects = 0; + loop { + let resp = fetch(executor, current_req.clone()).await?; + let resp_status = resp.status(); + + if resp_status.is_success() { + let (parts, body) = resp.into_parts(); + + let mut bytes_downloaded: u64 = 0; + let mut file_size: u64 = 1; + + if let Some(val) = parts.headers.get("Content-Length") + && let Ok(str) = val.to_str() + && let Ok(s) = str.parse() + { + file_size = s; + } + + let data = BodyStream::new(body) + .try_fold(Vec::new(), |mut body, chunk| { + if let Some(chunk) = chunk.data_ref() { + bytes_downloaded += chunk.len() as u64; + body.extend_from_slice(chunk); + + if let Some(on_progress) = &mut on_progress { + on_progress(ProgressFuncData { + bytes_downloaded, + file_size, + }) + } + } + Ok(body) + }) + .await?; + + return Ok(HttpClientResponse { data }); + } else if resp_status.is_redirection() { + redirects += 1; + if redirects >= max_redirects { + anyhow::bail!("too many redirects"); + } + current_req = follow_single_redirect(¤t_req, &resp).await?; + } else { + anyhow::bail!("non-200 HTTP response: {}", resp_status.as_str()); + } + } + } if !resp.status().is_success() { // non-200 HTTP response @@ -73,8 +138,6 @@ pub async fn get(params: GetParams<'_>) -> anyhow::Result { file_size = s; } - let mut on_progress = params.on_progress; - let data = BodyStream::new(body) .try_fold(Vec::new(), |mut body, chunk| { if let Some(chunk) = chunk.data_ref() { @@ -95,6 +158,48 @@ pub async fn get(params: GetParams<'_>) -> anyhow::Result { Ok(HttpClientResponse { data }) } +fn uri_try_from_str(s: &str) -> anyhow::Result { + use std::convert::TryInto; + let uri: hyper::Uri = s.try_into().context("invalid URI")?; + uri.path_and_query() + .ok_or_else(|| anyhow::anyhow!("URI has no path and query component")) + .cloned() +} + +async fn follow_single_redirect( + req: &hyper::Request>, + resp: &hyper::Response, +) -> anyhow::Result>> { + let location = resp + .headers() + .get(hyper::header::LOCATION) + .ok_or_else(|| anyhow::anyhow!("redirect response has no Location header"))?; + + let location_str = location.to_str().context("invalid redirect location header")?; + + // resolve relative urls against the original url. + let original_uri = req.uri().clone(); + let next_url: hyper::Uri = if location_str.starts_with("http://") || location_str.starts_with("https://") { + hyper::Uri::try_from(location_str).context("invalid redirect location")? + } else { + let mut parts = original_uri.into_parts(); + parts.path_and_query = uri_try_from_str(location_str).context("invalid redirect location")?.into(); + hyper::Uri::from_parts(parts).context("failed to construct redirect URI")? + }; + + log::info!("redirecting to \"{}\"", next_url); + + let next_req = Request::builder() + .header( + hyper::header::HOST, + next_url.authority().context("invalid authority")?.as_str(), + ) + .uri(next_url) + .body(Empty::new())?; + + Ok(next_req) +} + pub async fn get_simple(executor: &AsyncExecutor, url: &str) -> anyhow::Result { get(GetParams { executor, From 6f7bad9bd5bf82df6ee31384951bf3f14f3841fb Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:53:11 +0900 Subject: [PATCH 04/19] whisper ui --- dash-frontend/src/tab/settings/mod.rs | 9 +- .../src/tab/settings/tab_features.rs | 139 ++++++++++++------ .../src/util/networking/http_client.rs | 7 +- wlx-common/src/lib.rs | 2 +- 4 files changed, 102 insertions(+), 55 deletions(-) diff --git a/dash-frontend/src/tab/settings/mod.rs b/dash-frontend/src/tab/settings/mod.rs index 2f467da1..dfef32b0 100644 --- a/dash-frontend/src/tab/settings/mod.rs +++ b/dash-frontend/src/tab/settings/mod.rs @@ -28,7 +28,7 @@ use wlx_common::{ use crate::{ frontend::{Frontend, FrontendTask, FrontendTasks}, - tab::{settings::macros::MacroParams, Tab, TabType}, + tab::{Tab, TabType, settings::macros::MacroParams}, views::ViewUpdateParams, }; @@ -246,10 +246,11 @@ impl Tab for TabSettings { // Dropdown handling if let TickResult::Action(name) = self.context_menu.tick(&mut frontend.layout, &mut self.state)? { - if name.starts_with(';') && let Some(tab) = self.current_tab.as_mut() { + if name.starts_with(';') + && let Some(tab) = self.current_tab.as_mut() + { let config = frontend.interface.general_config(data); - tab.context_menu_custom(name, config, &mut frontend.layout, &mut self.state)?; - + tab.context_menu_custom(name, config, &mut frontend.layout, &mut self.state)?; } else if let (Some(setting), Some(id), Some(value), Some(text), Some(translated)) = { let mut s = name.splitn(5, ';'); (s.next(), s.next(), s.next(), s.next(), s.next()) diff --git a/dash-frontend/src/tab/settings/tab_features.rs b/dash-frontend/src/tab/settings/tab_features.rs index 4be0cc83..bac00ca4 100644 --- a/dash-frontend/src/tab/settings/tab_features.rs +++ b/dash-frontend/src/tab/settings/tab_features.rs @@ -1,18 +1,30 @@ use std::rc::Rc; use wgui::{ - components::button::{ButtonClickEvent, ComponentButton}, globals::WguiGlobals, i18n::Translation, layout::WidgetID, log::LogErr, parser::{Fetchable, TemplateParams}, task::Tasks, widget::label::WidgetLabel, windowing::context_menu::{self} + components::button::{ButtonClickEvent, ComponentButton}, + globals::WguiGlobals, + i18n::Translation, + layout::WidgetID, + log::LogErr, + parser::{Fetchable, TemplateParams}, + task::Tasks, + widget::label::WidgetLabel, + windowing::context_menu::{self}, }; use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig}; use crate::{ frontend::FrontendTasks, tab::settings::{ - SettingType, SettingsMountParams, SettingsTab, TabNameEnum, Task as ParentTask, horiz_cell, macros::{MacroParams, options_category, options_checkbox, options_range_f32} + SettingType, SettingsMountParams, SettingsTab, TabNameEnum, Task as ParentTask, horiz_cell, + macros::{MacroParams, options_category, options_checkbox, options_range_f32}, }, util::{ popup_manager::PopupHolder, - whisper::{WHISPER_MODELS, WhisperModel, whisper_any_models_downloaded, whisper_delete_all_models, whisper_model_from_name, whisper_model_path}, + whisper::{ + WHISPER_MODELS, WhisperModel, whisper_any_models_downloaded, whisper_delete_all_models, whisper_model_from_name, + whisper_model_path, + }, }, views::{self, ViewUpdateParams}, }; @@ -24,6 +36,7 @@ enum Task { WhisperDownload(&'static WhisperModel), WhisperDownloadDone, CloseDialog, + ReloadTab, } pub struct State { @@ -48,24 +61,32 @@ impl SettingsTab for State { if !whisper_model_path(model.file_name).exists() { // download failed, set to selection to none par.general_config.whisper_model = "".into(); - // re-init the whole tab + // reload the tab self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); } } } Task::WhisperRemoveUnused => { let _ = whisper_delete_all_models().log_err("could not remove whisper models"); - }, + } Task::WhisperDownload(model) => { self.pending_download = Some(model); self.show_whisper_download_dialog(model, par.executor.clone()); - }, + } Task::WhisperDownloadDone => { - // we're happy - }, + if let Some(model) = self.pending_download.take() { + par.general_config.whisper_model = model.file_name.into(); + + // reload tab so that the downloaded checkmarks get populated + self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); + } + } Task::CloseDialog => { - let close_dialog = self.popup_dialog.get_close_callback(par.layout); - close_dialog(); + let close_dialog = self.popup_dialog.get_close_callback(par.layout); + close_dialog(); + } + Task::ReloadTab => { + self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); } } } @@ -84,19 +105,27 @@ impl SettingsTab for State { let mut s = action.splitn(3, ';'); (s.next(), s.next(), s.next()) } { - if !model_name.is_empty() && let Some(model) = whisper_model_from_name(model_name) - { - let mut common = layout.common(); - let mut label = state.fetch_widget_as::(common.state, &format!("{id}_value"))?; - label.set_text(&mut common, Translation::from_raw_text(model.display_name)); - - config.whisper_model = action.as_ref().into(); - - self.show_whisper_model_dialog_box_download(model)?; - } else if whisper_any_models_downloaded().unwrap_or_default() { - self.show_whisper_model_dialog_box_cleanup()?; + if !model_name.is_empty() + && let Some(model) = whisper_model_from_name(model_name) + { + let mut common = layout.common(); + let mut label = state.fetch_widget_as::(common.state, &format!("{id}_value"))?; + label.set_text(&mut common, Translation::from_raw_text(model.display_name)); + + config.whisper_model = model.file_name.into(); + if !whisper_model_path(model.file_name).exists() { + self.show_whisper_model_dialog_box_download(model)?; + } + } else { + config.whisper_model = "".into(); + // re-init the whole tab + if whisper_any_models_downloaded().unwrap_or_default() { + self.show_whisper_model_dialog_box_cleanup()?; + } else { + self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); + } + } } - } Ok(()) } @@ -145,7 +174,7 @@ impl State { }) } - fn show_whisper_download_dialog(&mut self, model: &WhisperModel, executor: AsyncExecutor) { + fn show_whisper_download_dialog(&mut self, model: &WhisperModel, executor: AsyncExecutor) { views::download_file::mount_popup( self.popup_download.clone(), self.frontend_tasks.clone(), @@ -174,24 +203,23 @@ impl State { globals: self.globals.clone(), message: Translation::from_translation_key("APP_SETTINGS.WHISPER.NEED_TO_DOWNLOAD_MODEL"), entries: vec![ - views::dialog_box::ButtonEntry { - content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), - icon: "dashboard/close.svg", - action: ACTION_CANCEL, - }, - views::dialog_box::ButtonEntry { - content: Translation::from_translation_key("DOWNLOAD"), - icon: "dashboard/download.svg", - action: ACTION_DOWNLOAD, - }, - ], + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), + icon: "dashboard/close.svg", + action: ACTION_CANCEL, + }, + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("DOWNLOAD"), + icon: "dashboard/download.svg", + action: ACTION_DOWNLOAD, + }, + ], on_action_click: Box::new(move |action| match action { ACTION_DOWNLOAD => { tasks.push(Task::WhisperDownload(model)); } ACTION_CANCEL => { tasks.push(Task::CloseDialog); - // treat as failed download tasks.push(Task::WhisperDownloadClosed); } @@ -216,23 +244,26 @@ impl State { globals: self.globals.clone(), message: Translation::from_translation_key("APP_SETTINGS.WHISPER.REMOVE_UNUSED_MODELS"), entries: vec![ - views::dialog_box::ButtonEntry { - content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), - icon: "dashboard/close.svg", - action: ACTION_CANCEL, - }, - views::dialog_box::ButtonEntry { - content: Translation::from_translation_key("REMOVE"), - icon: "dashboard/trash.svg", - action: ACTION_REMOVE, - }, - ], + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("APP_SETTINGS.CANCEL"), + icon: "dashboard/close.svg", + action: ACTION_CANCEL, + }, + views::dialog_box::ButtonEntry { + content: Translation::from_translation_key("REMOVE"), + icon: "dashboard/trash.svg", + action: ACTION_REMOVE, + }, + ], on_action_click: Box::new(move |action| match action { ACTION_REMOVE => { tasks.push(Task::WhisperRemoveUnused); + tasks.push(Task::CloseDialog); + tasks.push(Task::ReloadTab); } ACTION_CANCEL => { tasks.push(Task::CloseDialog); + tasks.push(Task::ReloadTab); } _ => unreachable!(), }), @@ -249,6 +280,13 @@ fn whisper_models_dropdown(mp: &mut MacroParams, parent: WidgetID) -> anyhow::Re let parent = horiz_cell(mp.layout, parent)?; + let mut downloaded_models = vec![]; + for (idx, m) in WHISPER_MODELS.iter().enumerate() { + if whisper_model_path(m.file_name).exists() { + downloaded_models.push(idx); + } + } + let current_file = mp.config.whisper_model.as_ref(); let current_translation = whisper_model_from_name(current_file) .map_or(Translation::from_translation_key("APP_SETTINGS.OPTION.NONE"), |x| { @@ -276,9 +314,14 @@ fn whisper_models_dropdown(mp: &mut MacroParams, parent: WidgetID) -> anyhow::Re move |_common, e: ButtonClickEvent| { let mut cells = WHISPER_MODELS .iter() - .map(|item| context_menu::Cell { + .enumerate() + .map(|(idx, item)| context_menu::Cell { action_name: Some(format!(";{id};{}", item.file_name).into()), - title: Translation::from_raw_text(item.display_name), + title: Translation::from_raw_text_string(format!( + "{}{}", + item.display_name, + if downloaded_models.contains(&idx) { " ✅" } else { "" } + )), tooltip: None, attribs: vec![], }) diff --git a/dash-frontend/src/util/networking/http_client.rs b/dash-frontend/src/util/networking/http_client.rs index 1f525aaa..0b8bd75c 100644 --- a/dash-frontend/src/util/networking/http_client.rs +++ b/dash-frontend/src/util/networking/http_client.rs @@ -161,7 +161,8 @@ async fn fetch_and_follow_redirects( fn uri_try_from_str(s: &str) -> anyhow::Result { use std::convert::TryInto; let uri: hyper::Uri = s.try_into().context("invalid URI")?; - uri.path_and_query() + uri + .path_and_query() .ok_or_else(|| anyhow::anyhow!("URI has no path and query component")) .cloned() } @@ -183,7 +184,9 @@ async fn follow_single_redirect( hyper::Uri::try_from(location_str).context("invalid redirect location")? } else { let mut parts = original_uri.into_parts(); - parts.path_and_query = uri_try_from_str(location_str).context("invalid redirect location")?.into(); + parts.path_and_query = uri_try_from_str(location_str) + .context("invalid redirect location")? + .into(); hyper::Uri::from_parts(parts).context("failed to construct redirect URI")? }; diff --git a/wlx-common/src/lib.rs b/wlx-common/src/lib.rs index 09fe1a07..c8140e79 100644 --- a/wlx-common/src/lib.rs +++ b/wlx-common/src/lib.rs @@ -5,9 +5,9 @@ pub mod cache_dir; pub mod common; pub mod config; pub mod config_io; -pub mod data_dir; pub mod dash_interface; pub mod dash_interface_emulated; +pub mod data_dir; pub mod desktop_finder; mod handle; pub mod locale; From 5ec1866613f15c51e38c6bf75c3d6a99377f60bb Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:06:36 +0900 Subject: [PATCH 05/19] whisper to use rodio --- Cargo.lock | 123 ++++++------ wayvr/src/subsystem/whisper_stt.rs | 291 +++++++++++------------------ wlx-common/Cargo.toml | 6 +- wlx-common/src/audio.rs | 8 +- 4 files changed, 178 insertions(+), 250 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ce2e84f..641fbb61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,9 +87,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.12.1", @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -748,6 +748,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1180,11 +1189,11 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.12.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1218,9 +1227,9 @@ dependencies = [ [[package]] name = "cpal" -version = "0.16.0" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", "coreaudio-rs", @@ -1233,13 +1242,17 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", + "objc2 0.6.4", "objc2-audio-toolbox", + "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", ] [[package]] @@ -3135,9 +3148,9 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -3502,7 +3515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -3526,6 +3539,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -3533,7 +3556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3545,7 +3568,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3560,6 +3583,7 @@ dependencies = [ "objc2 0.6.4", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -3579,7 +3603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3591,7 +3615,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.12.1", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -3601,7 +3627,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", @@ -3613,7 +3639,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -3632,7 +3658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3645,6 +3671,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.12.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3665,7 +3693,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation 0.2.2", @@ -3678,7 +3706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3701,7 +3729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", @@ -3737,7 +3765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3757,7 +3785,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3769,7 +3797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.12.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -4772,15 +4800,17 @@ dependencies = [ [[package]] name = "rodio" -version = "0.21.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ "cpal", "dasp_sample", "hound", "num-rational", + "rtrb", "symphonia", + "thiserror 2.0.18", ] [[package]] @@ -4832,6 +4862,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + [[package]] name = "rust-embed" version = "8.11.0" @@ -5637,7 +5673,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.3", + "windows", ] [[package]] @@ -6764,16 +6800,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -6796,16 +6822,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -6887,15 +6903,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -7108,7 +7115,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.12.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", diff --git a/wayvr/src/subsystem/whisper_stt.rs b/wayvr/src/subsystem/whisper_stt.rs index b5a36931..f0499931 100644 --- a/wayvr/src/subsystem/whisper_stt.rs +++ b/wayvr/src/subsystem/whisper_stt.rs @@ -1,5 +1,3 @@ -// whisper_stt.rs - use std::{ fmt, path::{Path, PathBuf}, @@ -8,14 +6,11 @@ use std::{ time::{Duration, Instant}, }; -use pipewire as pw; -use pw::{properties::properties, spa}; -use spa::{ - param::format::{MediaSubtype, MediaType}, - param::format_utils, - pod::Pod, -}; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; +use wlx_common::audio::rodio::{ + Source, + microphone::{MicrophoneBuilder, available_inputs}, +}; const WHISPER_SAMPLE_RATE: usize = 16_000; const MAX_DURATION: Duration = Duration::from_secs(30); @@ -34,8 +29,8 @@ pub struct WhisperSttConfig { /// ignore extremely short accidental taps pub min_audio_ms: u64, - /// mic object name from `pw-dump`, None for the default - pub pipewire_target_object: Option, + /// force a specific recording device; see `rodio::microphone::available_inputs()` + pub rodio_input_device_name: Option, pub use_gpu: bool, pub gpu_device: i32, @@ -55,7 +50,7 @@ impl WhisperSttConfig { n_threads, partial_decode_interval_ms: 700, min_audio_ms: 250, - pipewire_target_object: None, + rodio_input_device_name: None, use_gpu: true, gpu_device: 0, flash_attn: false, @@ -67,7 +62,7 @@ impl WhisperSttConfig { pub enum WhisperSttError { ModelLoad(String), Whisper(String), - PipeWire(String), + Rodio(String), CaptureInit(String), ThreadSpawn(String), CaptureThreadPanicked, @@ -80,7 +75,7 @@ impl fmt::Display for WhisperSttError { match self { Self::ModelLoad(e) => write!(f, "failed to load whisper model: {e}"), Self::Whisper(e) => write!(f, "whisper error: {e}"), - Self::PipeWire(e) => write!(f, "pipewire error: {e}"), + Self::Rodio(e) => write!(f, "rodio error: {e}"), Self::CaptureInit(e) => write!(f, "failed to initialize capture: {e}"), Self::ThreadSpawn(e) => write!(f, "failed to spawn thread: {e}"), Self::CaptureThreadPanicked => write!(f, "capture thread panicked"), @@ -95,7 +90,7 @@ impl std::error::Error for WhisperSttError {} struct StopCapture; struct CaptureSession { - stop_tx: pw::channel::Sender, + stop_tx: mpsc::Sender, capture_thread: Option>, recognizer_thread: Option>, deadline: Instant, @@ -141,7 +136,7 @@ impl WhisperStt { }) } - /// starts a fresh pw capture stream and a transcription worker + /// starts a fresh capture stream and a transcription worker pub fn ptt_start(&mut self) -> Result<(), WhisperSttError> { self.reap_finished_recognizers(); @@ -151,7 +146,7 @@ impl WhisperStt { let (audio_tx, audio_rx) = mpsc::channel::>(); let (ready_tx, ready_rx) = mpsc::channel::>(); - let (stop_tx, stop_rx) = pw::channel::channel::(); + let (stop_tx, stop_rx) = mpsc::channel::(); let recognizer_thread = spawn_recognizer_thread( Arc::clone(&self.ctx), @@ -160,12 +155,12 @@ impl WhisperStt { self.completed_tx.clone(), )?; - let target_object = self.config.pipewire_target_object.clone(); + let input_device_name = self.config.rodio_input_device_name.clone(); let capture_thread = thread::Builder::new() - .name("whisper-stt-pipewire-capture".to_string()) + .name("whisper-stt-rodio-capture".to_string()) .spawn(move || { - pipewire_capture_thread(audio_tx, stop_rx, target_object, ready_tx); + rodio_capture_thread(audio_tx, stop_rx, input_device_name, ready_tx); }) .map_err(|e| WhisperSttError::ThreadSpawn(e.to_string()))?; @@ -265,7 +260,7 @@ impl WhisperStt { } } - return None; + None } pub fn take_error(&mut self) -> Option { @@ -347,8 +342,8 @@ fn recognizer_thread( last_decoded_len = audio.len(); } Err(_) => { - // Do not fail the session on a speculative decode. - // The final decode after PTT end gets reported. + // do not fail the session on a speculative decode + // the final decode after PTT end gets reported } } } @@ -410,15 +405,15 @@ fn transcribe_audio( Ok(normalize_transcript(text)) } -fn pipewire_capture_thread( +fn rodio_capture_thread( audio_tx: mpsc::Sender>, - stop_rx: pw::channel::Receiver, - target_object: Option, + stop_rx: mpsc::Receiver, + input_device_name: Option, ready_tx: mpsc::Sender>, ) { let mut ready_tx = Some(ready_tx); - let result = run_pipewire_capture(audio_tx, stop_rx, target_object, &mut ready_tx); + let result = run_rodio_capture(audio_tx, stop_rx, input_device_name, &mut ready_tx); if let Err(e) = result { if let Some(ready_tx) = ready_tx.take() { @@ -427,163 +422,105 @@ fn pipewire_capture_thread( } } -fn run_pipewire_capture( +fn run_rodio_capture( audio_tx: mpsc::Sender>, - stop_rx: pw::channel::Receiver, - target_object: Option, + stop_rx: mpsc::Receiver, + input_device_name: Option, ready_tx: &mut Option>>, ) -> Result<(), WhisperSttError> { - pw::init(); + let builder = MicrophoneBuilder::new(); - let mainloop = pw::main_loop::MainLoopRc::new(None) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + let builder = if let Some(input_device_name) = input_device_name { + let inputs = available_inputs().map_err(|e| WhisperSttError::Rodio(e.to_string()))?; + let input_device_name_lower = input_device_name.to_lowercase(); - let context = pw::context::ContextRc::new(&mainloop, None) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + let input = inputs + .into_iter() + .find(|input| { + input + .to_string() + .to_lowercase() + .contains(&input_device_name_lower) + }) + .ok_or_else(|| { + WhisperSttError::Rodio(format!( + "no rodio input device matched {input_device_name:?}" + )) + })?; - let core = context - .connect_rc(None) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; - - let _stop_receiver = stop_rx.attach(mainloop.loop_(), { - let mainloop = mainloop.clone(); - move |_| { - mainloop.quit(); - } - }); - - let mut props = properties! { - *pw::keys::MEDIA_TYPE => "Audio", - *pw::keys::MEDIA_CATEGORY => "Capture", - *pw::keys::MEDIA_ROLE => "Communication", - *pw::keys::APP_NAME => "WhisperStt", + builder + .device(input) + .map_err(|e| WhisperSttError::Rodio(e.to_string()))? + } else { + builder + .default_device() + .map_err(|e| WhisperSttError::Rodio(e.to_string()))? }; - if let Some(target_object) = target_object { - props.insert(*pw::keys::TARGET_OBJECT, target_object); - } + let builder = builder + .default_config() + .map_err(|e| WhisperSttError::Rodio(e.to_string()))? + .prefer_channel_counts([ + 1.try_into().expect("not zero"), + 2.try_into().expect("not zero"), + ]) + .prefer_sample_rates([ + 16_000.try_into().expect("not zero"), + 32_000.try_into().expect("not zero"), + 48_000.try_into().expect("not zero"), + ]) + .prefer_buffer_sizes(512..); - let stream = pw::stream::StreamBox::new(&core, "WhisperStt microphone capture", props) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + let mut mic = builder + .open_stream() + .map_err(|e| WhisperSttError::Rodio(e.to_string()))?; - let user_data = AudioCaptureUserData::default(); - let audio_tx_for_callback = audio_tx.clone(); - - let _listener = stream - .add_local_listener_with_user_data(user_data) - .param_changed(|_, user_data, id, param| { - let Some(param) = param else { - return; - }; - - if id != pw::spa::param::ParamType::Format.as_raw() { - return; - } - - let Ok((media_type, media_subtype)) = format_utils::parse_format(param) else { - return; - }; - - if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw { - return; - } - - let _ = user_data.format.parse(param); - }) - .process(move |stream, user_data| { - let Some(mut buffer) = stream.dequeue_buffer() else { - return; - }; - - let datas = buffer.datas_mut(); - if datas.is_empty() { - return; - } - - let data = &mut datas[0]; - - let chunk = data.chunk(); - - let offset = chunk.offset() as usize; - let size = chunk.size() as usize; - - let Some(bytes) = data.data() else { - return; - }; - - if offset >= bytes.len() { - return; - } - - let end = offset.saturating_add(size).min(bytes.len()); - let bytes = &bytes[offset..end]; - - let channels = (user_data.format.channels() as usize).max(1); - let input_rate = { - let rate = user_data.format.rate() as usize; - if rate == 0 { 48_000 } else { rate } - }; - - let resampled = user_data - .resampler - .push_interleaved_f32le_mono_16k(bytes, channels, input_rate); - - if !resampled.is_empty() { - let _ = audio_tx_for_callback.send(resampled); - } - }) - .register() - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; - - let mut audio_info = spa::param::audio::AudioInfoRaw::new(); - audio_info.set_format(spa::param::audio::AudioFormat::F32LE); - - let obj = pw::spa::pod::Object { - type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), - id: pw::spa::param::ParamType::EnumFormat.as_raw(), - properties: audio_info.into(), - }; - - let values: Vec = pw::spa::pod::serialize::PodSerializer::serialize( - std::io::Cursor::new(Vec::new()), - &pw::spa::pod::Value::Object(obj), - ) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))? - .0 - .into_inner(); - - let pod = Pod::from_bytes(&values).ok_or_else(|| { - WhisperSttError::PipeWire("failed to parse serialized PipeWire pod".to_string()) - })?; - - let mut params = [pod]; - - stream - .connect( - spa::utils::Direction::Input, - None, - pw::stream::StreamFlags::AUTOCONNECT - | pw::stream::StreamFlags::MAP_BUFFERS - | pw::stream::StreamFlags::RT_PROCESS, - &mut params, - ) - .map_err(|e| WhisperSttError::PipeWire(e.to_string()))?; + let channels = mic.channels().get() as usize; + let input_rate = mic.sample_rate().get() as usize; if let Some(ready_tx) = ready_tx.take() { let _ = ready_tx.send(Ok(())); } - mainloop.run(); + let mut resampler = StreamingResampler::default(); + let mut interleaved = Vec::new(); + + // ~20 ms of input frames; whisper still receives 16 kHz mono chunks + let chunk_input_samples = ((input_rate / 50).max(1)) * channels.max(1); + + 'capture: loop { + if stop_rx.try_recv().is_ok() { + break; + } + + interleaved.clear(); + + while interleaved.len() < chunk_input_samples { + if stop_rx.try_recv().is_ok() { + break 'capture; + } + + let Some(sample) = mic.next() else { + return Err(WhisperSttError::Rodio( + "microphone stream ended unexpectedly".to_string(), + )); + }; + + // Rodio's default sample type is f32. This cast also keeps the code + // compiling if the crate is built with rodio's `64bit` feature. + interleaved.push(sample as f32); + } + + let resampled = resampler.push_interleaved_mono_16k(&interleaved, channels, input_rate); + + if !resampled.is_empty() && audio_tx.send(resampled).is_err() { + break; + } + } Ok(()) } -#[derive(Default)] -struct AudioCaptureUserData { - format: spa::param::audio::AudioInfoRaw, - resampler: StreamingResampler, -} - #[derive(Default)] struct StreamingResampler { pending: Vec, @@ -592,9 +529,9 @@ struct StreamingResampler { } impl StreamingResampler { - fn push_interleaved_f32le_mono_16k( + fn push_interleaved_mono_16k( &mut self, - bytes: &[u8], + samples: &[f32], channels: usize, input_rate: usize, ) -> Vec { @@ -608,12 +545,7 @@ impl StreamingResampler { self.input_rate = input_rate; } - let frame_bytes = channels * std::mem::size_of::(); - if frame_bytes == 0 { - return Vec::new(); - } - - let frames = bytes.len() / frame_bytes; + let frames = samples.len() / channels; if frames == 0 { return Vec::new(); } @@ -621,20 +553,11 @@ impl StreamingResampler { let mut mono = Vec::with_capacity(frames); for frame in 0..frames { - let frame_start = frame * frame_bytes; + let frame_start = frame * channels; let mut sum = 0.0f32; for ch in 0..channels { - let sample_start = frame_start + ch * 4; - - let sample = f32::from_le_bytes([ - bytes[sample_start], - bytes[sample_start + 1], - bytes[sample_start + 2], - bytes[sample_start + 3], - ]); - - sum += sample; + sum += samples[frame_start + ch]; } mono.push(sum / channels as f32); diff --git a/wlx-common/Cargo.toml b/wlx-common/Cargo.toml index c98de46c..da4f3665 100644 --- a/wlx-common/Cargo.toml +++ b/wlx-common/Cargo.toml @@ -25,11 +25,7 @@ xdg.workspace = true chrono = "0.4.42" smol = { workspace = true } wgui = { path = "../wgui/" } -rodio = { version = "0.21.1", default-features = false, features = [ - "playback", - "mp3", - "hound", -] } +rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "hound", "recording"] } glob = "0.3.3" walkdir = "2.5.0" rust-ini = "0.21.3" diff --git a/wlx-common/src/audio.rs b/wlx-common/src/audio.rs index 946d47ab..00b0d646 100644 --- a/wlx-common/src/audio.rs +++ b/wlx-common/src/audio.rs @@ -5,8 +5,10 @@ use wgui::{assets::AssetProvider, sound::WguiSoundType}; use std::io::Read; +pub use rodio; + pub struct AudioSystem { - audio_stream: Option, + audio_stream: Option, first_try: bool, } @@ -100,10 +102,10 @@ impl AudioSystem { } } - fn get_handle(&mut self) -> Option<&rodio::OutputStream> { + fn get_handle(&mut self) -> Option<&rodio::MixerDeviceSink> { if self.audio_stream.is_none() && self.first_try { self.first_try = false; - if let Ok(stream) = rodio::OutputStreamBuilder::open_default_stream() { + if let Ok(stream) = rodio::DeviceSinkBuilder::open_default_sink() { self.audio_stream = Some(stream); } else { log::error!("Failed to open audio stream. Audio will not work."); From e032363501575f78dfa6a9f3b41cdede3bedef1f Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:21:59 +0900 Subject: [PATCH 06/19] only display whisper settings if built with feature --- dash-frontend/src/tab/settings/tab_features.rs | 4 +++- wayvr/src/overlays/dashboard.rs | 4 ++++ wlx-common/src/dash_interface.rs | 1 + wlx-common/src/dash_interface_emulated.rs | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dash-frontend/src/tab/settings/tab_features.rs b/dash-frontend/src/tab/settings/tab_features.rs index bac00ca4..c6d0d01b 100644 --- a/dash-frontend/src/tab/settings/tab_features.rs +++ b/dash-frontend/src/tab/settings/tab_features.rs @@ -139,7 +139,9 @@ impl State { let c = options_category(par.mp, par.id_parent, "APP_SETTINGS.FEATURES", "dashboard/options.svg")?; - whisper_models_dropdown(par.mp, c)?; + if par.feats.whisper { + whisper_models_dropdown(par.mp, c)?; + } options_checkbox(par.mp, c, SettingType::NotificationsEnabled)?; options_checkbox(par.mp, c, SettingType::NotificationsSoundEnabled)?; diff --git a/wayvr/src/overlays/dashboard.rs b/wayvr/src/overlays/dashboard.rs index e082eded..bcd5f316 100644 --- a/wayvr/src/overlays/dashboard.rs +++ b/wayvr/src/overlays/dashboard.rs @@ -563,6 +563,10 @@ impl DashInterface for DashInterfaceLive { monado: data.monado_state.is_some(), #[cfg(not(feature = "openxr"))] monado: false, + #[cfg(feature = "whisper")] + whisper: true, + #[cfg(not(feature = "whisper"))] + whisper: false, } } diff --git a/wlx-common/src/dash_interface.rs b/wlx-common/src/dash_interface.rs index f188b286..298b1220 100644 --- a/wlx-common/src/dash_interface.rs +++ b/wlx-common/src/dash_interface.rs @@ -47,6 +47,7 @@ pub enum RecenterMode { pub struct InterfaceFeats { pub openxr: bool, pub monado: bool, + pub whisper: bool, } pub trait DashInterface { diff --git a/wlx-common/src/dash_interface_emulated.rs b/wlx-common/src/dash_interface_emulated.rs index 919054dd..376513d4 100644 --- a/wlx-common/src/dash_interface_emulated.rs +++ b/wlx-common/src/dash_interface_emulated.rs @@ -240,6 +240,7 @@ impl DashInterface<()> for DashInterfaceEmulated { dash_interface::InterfaceFeats { openxr: true, monado: true, + whisper: true, } } From 341ee333dc255e1c68c0ef4e534dcb61a6fd94e3 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:17 +0900 Subject: [PATCH 07/19] whisper transcription working --- Cargo.lock | 12 + dash-frontend/src/tab/settings/mod.rs | 7 +- .../src/tab/settings/tab_features.rs | 8 +- dash-frontend/src/util/whisper.rs | 6 - wayvr/Cargo.toml | 1 + wayvr/src/assets/gui/whisper.xml | 36 ++ wayvr/src/assets/icons/mic.svg | 1 + wayvr/src/assets/icons/paste-go.svg | 1 + wayvr/src/assets/lang/en.json | 7 + wayvr/src/backend/wayvr/comp.rs | 12 +- wayvr/src/backend/wayvr/mod.rs | 5 + wayvr/src/gui/panel/overlay_list.rs | 2 +- wayvr/src/overlays/mod.rs | 2 + wayvr/src/overlays/whisper.rs | 256 ++++++++++++++ wayvr/src/subsystem/clipboard/mod.rs | 8 + wayvr/src/subsystem/clipboard/wl.rs | 23 ++ wayvr/src/subsystem/clipboard/x11.rs | 329 ++++++++++++++++++ wayvr/src/subsystem/mod.rs | 3 + wayvr/src/windowing/manager.rs | 7 + wayvr/src/windowing/window.rs | 3 + wlx-capture/src/wayland.rs | 1 + 21 files changed, 720 insertions(+), 10 deletions(-) create mode 100644 wayvr/src/assets/gui/whisper.xml create mode 100644 wayvr/src/assets/icons/mic.svg create mode 100644 wayvr/src/assets/icons/paste-go.svg create mode 100644 wayvr/src/overlays/whisper.rs create mode 100644 wayvr/src/subsystem/clipboard/mod.rs create mode 100644 wayvr/src/subsystem/clipboard/wl.rs create mode 100644 wayvr/src/subsystem/clipboard/x11.rs diff --git a/Cargo.lock b/Cargo.lock index 641fbb61..8db3d3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,6 +5447,17 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol" version = "2.0.2" @@ -6633,6 +6644,7 @@ dependencies = [ "slotmap", "smallvec", "smithay", + "smithay-clipboard", "smol", "strum", "sysinfo", diff --git a/dash-frontend/src/tab/settings/mod.rs b/dash-frontend/src/tab/settings/mod.rs index dfef32b0..56fcacfb 100644 --- a/dash-frontend/src/tab/settings/mod.rs +++ b/dash-frontend/src/tab/settings/mod.rs @@ -112,6 +112,7 @@ trait SettingsTab { &mut self, _action: Rc, _config: &mut GeneralConfig, + _change_kind: &mut Option, _layout: &mut Layout, _state: &mut ParserState, ) -> anyhow::Result<()> { @@ -250,7 +251,11 @@ impl Tab for TabSettings { && let Some(tab) = self.current_tab.as_mut() { let config = frontend.interface.general_config(data); - tab.context_menu_custom(name, config, &mut frontend.layout, &mut self.state)?; + let mut change_kind : Option = None; + tab.context_menu_custom(name, config, &mut change_kind, &mut frontend.layout, &mut self.state)?; + if let Some(change_kind) = change_kind { + frontend.interface.config_changed(data, change_kind); + } } else if let (Some(setting), Some(id), Some(value), Some(text), Some(translated)) = { let mut s = name.splitn(5, ';'); (s.next(), s.next(), s.next(), s.next(), s.next()) diff --git a/dash-frontend/src/tab/settings/tab_features.rs b/dash-frontend/src/tab/settings/tab_features.rs index c6d0d01b..75c8db7f 100644 --- a/dash-frontend/src/tab/settings/tab_features.rs +++ b/dash-frontend/src/tab/settings/tab_features.rs @@ -11,7 +11,7 @@ use wgui::{ widget::label::WidgetLabel, windowing::context_menu::{self}, }; -use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig}; +use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig, dash_interface::ConfigChangeKind}; use crate::{ frontend::FrontendTasks, @@ -61,6 +61,7 @@ impl SettingsTab for State { if !whisper_model_path(model.file_name).exists() { // download failed, set to selection to none par.general_config.whisper_model = "".into(); + par.config_change_kind.replace(ConfigChangeKind::Other); // reload the tab self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); } @@ -76,6 +77,7 @@ impl SettingsTab for State { Task::WhisperDownloadDone => { if let Some(model) = self.pending_download.take() { par.general_config.whisper_model = model.file_name.into(); + par.config_change_kind.replace(ConfigChangeKind::Other); // reload tab so that the downloaded checkmarks get populated self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features)); @@ -98,6 +100,7 @@ impl SettingsTab for State { &mut self, action: Rc, config: &mut GeneralConfig, + change_kind: &mut Option, layout: &mut wgui::layout::Layout, state: &mut wgui::parser::ParserState, ) -> anyhow::Result<()> { @@ -115,9 +118,12 @@ impl SettingsTab for State { config.whisper_model = model.file_name.into(); if !whisper_model_path(model.file_name).exists() { self.show_whisper_model_dialog_box_download(model)?; + } else { + change_kind.replace(ConfigChangeKind::Other); } } else { config.whisper_model = "".into(); + change_kind.replace(ConfigChangeKind::Other); // re-init the whole tab if whisper_any_models_downloaded().unwrap_or_default() { self.show_whisper_model_dialog_box_cleanup()?; diff --git a/dash-frontend/src/util/whisper.rs b/dash-frontend/src/util/whisper.rs index 823f5b07..c6920268 100644 --- a/dash-frontend/src/util/whisper.rs +++ b/dash-frontend/src/util/whisper.rs @@ -6,7 +6,6 @@ pub struct WhisperModel { pub file_name: &'static str, pub display_name: &'static str, pub url: &'static str, - pub hash: &'static str, } pub const WHISPER_MODELS: &[WhisperModel] = &[ @@ -14,31 +13,26 @@ pub const WHISPER_MODELS: &[WhisperModel] = &[ file_name: "ggml-base-q8_0.bin", display_name: "Base Q8 (78MiB)", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin", - hash: "7bb89bb49ed6955013b166f1b6a6c04584a20fbe", }, WhisperModel { file_name: "ggml-small-q8_0.bin", display_name: "Small Q8 (252MiB)", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin", - hash: "bcad8a2083f4e53d648d586b7dbc0cd673d8afad", }, WhisperModel { file_name: "ggml-large-v3-turbo-q5_0.bin", display_name: "Turbo Q5 (574MiB)", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", - hash: "e050f7970618a659205450ad97eb95a18d69c9ee", }, WhisperModel { file_name: "ggml-large-v3-turbo-q8_0.bin", display_name: "Turbo Q8 (874MiB)", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", - hash: "01bf15bedffe9f39d65c1b6ff9b687ea91f59e0e", }, WhisperModel { file_name: "ggml-large-v3-turbo.bin", display_name: "Turbo (1.5GiB)", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", - hash: "4af2b29d7ec73d781377bfd1758ca957a807e941", }, ]; diff --git a/wayvr/Cargo.toml b/wayvr/Cargo.toml index a4bdf1ac..087d9393 100644 --- a/wayvr/Cargo.toml +++ b/wayvr/Cargo.toml @@ -93,6 +93,7 @@ xcb = { version = "1.6.0", features = [ ], optional = true } xkbcommon = { version = "0.8.0" } # 0.9.0 breaks keymap import on some distros whisper-rs = { version = "0.16.0", features = ["vulkan"], optional = true } +smithay-clipboard = "0.7.3" [build-dependencies] regex.workspace = true diff --git a/wayvr/src/assets/gui/whisper.xml b/wayvr/src/assets/gui/whisper.xml new file mode 100644 index 00000000..28218b87 --- /dev/null +++ b/wayvr/src/assets/gui/whisper.xml @@ -0,0 +1,36 @@ + + + + + + +
+ +
+ + +
+
+ + +
+
+
+ +
+
+
+
+ + + diff --git a/wayvr/src/assets/icons/mic.svg b/wayvr/src/assets/icons/mic.svg new file mode 100644 index 00000000..809dfe32 --- /dev/null +++ b/wayvr/src/assets/icons/mic.svg @@ -0,0 +1 @@ + diff --git a/wayvr/src/assets/icons/paste-go.svg b/wayvr/src/assets/icons/paste-go.svg new file mode 100644 index 00000000..c9f49c44 --- /dev/null +++ b/wayvr/src/assets/icons/paste-go.svg @@ -0,0 +1 @@ + diff --git a/wayvr/src/assets/lang/en.json b/wayvr/src/assets/lang/en.json index b3a3070a..dc4b7ee0 100644 --- a/wayvr/src/assets/lang/en.json +++ b/wayvr/src/assets/lang/en.json @@ -112,5 +112,12 @@ "RECENTER": "Recenter playspace", "SWITCH_TO_SET": "Switch to set", "TOGGLE_FOR_CURRENT_SET": "Toggle for current set" + }, + "WHISPER": { + "TRANSCRIBE": "Press and hold to transcribe text", + "PASTE": "Paste transcription to focused overlay", + "UNLOAD": "Unload model from VRAM", + "MODEL_NOT_DOWNLOADED": "Speech-to-text not set up!", + "DOWNLOAD_GUIDANCE": "Set up speech-to-text under settings, features!" } } diff --git a/wayvr/src/backend/wayvr/comp.rs b/wayvr/src/backend/wayvr/comp.rs index 1a80a1ec..6b630628 100644 --- a/wayvr/src/backend/wayvr/comp.rs +++ b/wayvr/src/backend/wayvr/comp.rs @@ -42,7 +42,7 @@ use smithay::wayland::compositor::{self, BufferAssignment, SurfaceAttributes, se use smithay::wayland::selection::data_device::{ ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, - set_data_device_focus, + set_data_device_focus, set_data_device_selection, }; use smithay::wayland::selection::{self, SelectionHandler}; use smithay::wayland::shell::xdg::{ @@ -63,6 +63,7 @@ pub struct Application { pub dmabuf_state: (DmabufState, DmabufGlobal, Option), pub compositor: compositor::CompositorState, pub xdg_shell: XdgShellState, + pub seat: Seat, pub seat_state: SeatState, pub shm: ShmState, pub data_device: DataDeviceState, @@ -81,6 +82,15 @@ impl Application { self.image_importer.cleanup(); } + pub fn set_clipboard_text(&mut self, content: &str) { + let mime_types = vec![ + "text/plain;charset=utf-8".to_string(), + "text/plain".to_string(), + ]; + let user_data = Arc::from(content.as_bytes()); + set_data_device_selection(&self.display_handle, &self.seat, mime_types, user_data); + } + fn popups_commit(&mut self, surface: &WlSurface) { self.popup_manager.commit(surface); diff --git a/wayvr/src/backend/wayvr/mod.rs b/wayvr/src/backend/wayvr/mod.rs index 5d83f02b..b33f68df 100644 --- a/wayvr/src/backend/wayvr/mod.rs +++ b/wayvr/src/backend/wayvr/mod.rs @@ -227,6 +227,7 @@ impl WvrServerState { display_handle: dh, compositor, xdg_shell, + seat, seat_state, shm, data_device, @@ -626,6 +627,10 @@ impl WvrServerState { self.cur_modifiers = modifiers; } + pub fn set_clipboard(&mut self, content: &str) { + self.manager.state.set_clipboard_text(content); + } + pub fn add_external_process(&mut self, pid: u32) -> process::ProcessHandle { self.processes .add(process::Process::External(process::ExternalProcess { pid })) diff --git a/wayvr/src/gui/panel/overlay_list.rs b/wayvr/src/gui/panel/overlay_list.rs index 329165ad..3601fe54 100644 --- a/wayvr/src/gui/panel/overlay_list.rs +++ b/wayvr/src/gui/panel/overlay_list.rs @@ -59,7 +59,7 @@ impl OverlayList { ); ("Mirror", panels_root) } - OverlayCategory::Panel => { + OverlayCategory::Panel | OverlayCategory::BuiltInPanel => { let icon: Rc = if let Some(icon) = meta.icon.as_ref() { icon.to_string().into() } else { diff --git a/wayvr/src/overlays/mod.rs b/wayvr/src/overlays/mod.rs index e499de1c..b8d8a9e4 100644 --- a/wayvr/src/overlays/mod.rs +++ b/wayvr/src/overlays/mod.rs @@ -7,3 +7,5 @@ pub mod screen; pub mod toast; pub mod watch; pub mod wayvr; +#[cfg(feature = "whisper")] +pub mod whisper; diff --git a/wayvr/src/overlays/whisper.rs b/wayvr/src/overlays/whisper.rs new file mode 100644 index 00000000..b94a1466 --- /dev/null +++ b/wayvr/src/overlays/whisper.rs @@ -0,0 +1,256 @@ +use std::{path::Path, rc::Rc, time::Duration}; + +use glam::{Affine3A, Quat, Vec3, vec3}; +use wgui::{ + components::button::ComponentButton, + event::EventCallback, + i18n::Translation, + log::LogErr, + parser::Fetchable, + widget::{EventResult, label::WidgetLabel}, +}; +use wlx_common::{ + data_dir, + overlays::{BackendAttrib, BackendAttribValue, ToastTopic}, + windowing::{OverlayWindowState, Positioning}, +}; + +use crate::{ + gui::{ + panel::{ + GuiPanel, NewGuiPanelParams, OnCustomAttribFunc, + button::{BUTTON_EVENT_SUFFIX, BUTTON_EVENTS}, + }, + timer::GuiTimer, + }, + overlays::toast::Toast, + state::AppState, + subsystem::{ + clipboard::{ClipboardProvider, wl::WlClipboardProvider, x11::X11ClipboardProvider}, + hid::VirtualKey, + input::KeyboardFocus, + whisper_stt::WhisperStt, + }, + windowing::window::{OverlayCategory, OverlayWindowConfig}, +}; + +pub const WHISPER_NAME: &str = "Speech-to-Text"; + +#[derive(Default)] +struct WhisperState { + whisper_sst: Option, + clipboard_provider: Option>, + last_transcription: Option>, +} + +pub fn create_whisper( + app: &mut AppState, + headless: bool, + wayland: bool, +) -> anyhow::Result { + // let clipboard_provider: Option> = match (headless, wayland) { + // (true, _) => None, + // (false, true) => WlClipboardProvider::new() + // .log_err("Could not create Wayland clipboard provider") + // .ok() + // .map(|p| Box::new(p) as Box), + // (false, false) => X11ClipboardProvider::new() + // .log_err("Could not create X11 clipboard provider") + // .ok() + // .map(|p| Box::new(p) as Box), + // }; + + let state = WhisperState { + clipboard_provider: None, + ..Default::default() + }; + let xml = "gui/whisper.xml"; + + let on_custom_attrib: OnCustomAttribFunc = Box::new(move |layout, parser, attribs, _app| { + let Ok(button) = + parser.fetch_component_from_widget_id_as::(attribs.widget_id) + else { + return; + }; + + for (name, kind, test_button, test_duration) in &BUTTON_EVENTS { + for suffix in BUTTON_EVENT_SUFFIX { + let name = &format!("{name}{suffix}"); + let Some(action) = attribs.get_value(name) else { + break; + }; + + let mut args = action.split_whitespace(); + let Some(command) = args.next() else { + continue; + }; + + let button = button.clone(); + + let callback: EventCallback = match command { + "::WhisperTranscribeStart" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); + } + + if let Some(whisper) = state.whisper_sst.as_mut() { + let _ = whisper + .ptt_start() + .log_err("Could not start Whisper transcription"); + + return Ok(EventResult::Consumed); + } + + let model_path = data_dir::get_path("whisper") + .join(app.session.config.whisper_model.as_ref()); + if model_path.is_file() { + state.whisper_sst = WhisperStt::new(model_path) + .log_err("Could not create STT provider") + .ok(); + } else { + Toast::new( + ToastTopic::System, + "WHISPER.MODEL_NOT_DOWNLOADED".into(), + "WHISPER.DOWNLOAD_GUIDANCE".into(), + ) + .with_timeout(5.) + .with_sound(true) + .submit(app); + } + + Ok(EventResult::Consumed) + }), + "::WhisperTranscribeStop" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); + } + + if let Some(whisper) = state.whisper_sst.as_mut() { + let _ = whisper + .ptt_end() + .log_err("Could not stop Whisper transcription"); + } + Ok(EventResult::Consumed) + }), + "::WhisperPaste" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); + } + + let Some(transcription) = state.last_transcription.as_ref() else { + return Ok(EventResult::Consumed); + }; + + let mut success = false; + + match app.hid_provider.keyboard_focus { + KeyboardFocus::WayVR => { + if let Some(wvr) = app.wvr_server.as_mut() { + wvr.set_clipboard(transcription.as_ref()); + success = true; + } + } + KeyboardFocus::PhysicalScreen => { + if let Some(clip) = state.clipboard_provider.as_mut() { + clip.set_clipboard_utf8(transcription.as_ref()); + success = true; + } + } + } + + // send ctrl-v + if success { + app.hid_provider.send_key_routed( + app.wvr_server.as_mut(), + VirtualKey::RCtrl, + true, + ); + app.hid_provider.send_key_routed( + app.wvr_server.as_mut(), + VirtualKey::V, + true, + ); + app.hid_provider.send_key_routed( + app.wvr_server.as_mut(), + VirtualKey::RCtrl, + false, + ); + } + + Ok(EventResult::Consumed) + }), + "::WhisperUnload" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); + } + + state.whisper_sst = None; + + Ok(EventResult::Consumed) + }), + _ => return, + }; + + let id = layout.add_event_listener(attribs.widget_id, *kind, callback); + log::debug!("Registered {action} on {:?} as {id:?}", attribs.widget_id); + } + } + }); + + let params = NewGuiPanelParams { + on_custom_attrib: Some(on_custom_attrib), + ..Default::default() + }; + + let mut panel = GuiPanel::new_from_template(app, xml, state, params)?; + panel.extra_attribs.insert( + BackendAttrib::Icon, + BackendAttribValue::Icon("icons/mic.svg".into()), + ); + + let label = panel.parser_state.get_widget_id("transcription")?; + + panel + .timers + .push(GuiTimer::new(Duration::from_millis(100), 0)); + + let on_label_tick: EventCallback = + Box::new(move |common, data, _app, state| { + if let Some(whisper_stt) = state.whisper_sst.as_mut() + && let Some(text) = whisper_stt.take_transcription() + { + let text: Rc = text.into(); + state.last_transcription = Some(text.clone()); + + let label = data.obj.get_as_mut::().unwrap(); + label.set_text(common, Translation::from_raw_text_rc(text)); + } + + Ok(EventResult::Pass) + }); + + panel.layout.add_event_listener( + label, + wgui::event::EventListenerKind::InternalStateChange, + on_label_tick, + ); + + panel.update_layout(app)?; + + Ok(OverlayWindowConfig { + name: WHISPER_NAME.into(), + default_state: OverlayWindowState { + interactable: true, + grabbable: true, + transform: Affine3A::from_scale_rotation_translation( + Vec3::ONE * 0.5, + Quat::IDENTITY, + vec3(0.0, 0.0, -0.6), + ), + positioning: Positioning::Anchored, + ..OverlayWindowState::default() + }, + category: OverlayCategory::BuiltInPanel, + ..OverlayWindowConfig::from_backend(Box::new(panel)) + }) +} diff --git a/wayvr/src/subsystem/clipboard/mod.rs b/wayvr/src/subsystem/clipboard/mod.rs new file mode 100644 index 00000000..a19b9e22 --- /dev/null +++ b/wayvr/src/subsystem/clipboard/mod.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "wayland")] +pub mod wl; +#[cfg(feature = "x11")] +pub mod x11; + +pub trait ClipboardProvider { + fn set_clipboard_utf8(&mut self, content: &str); +} diff --git a/wayvr/src/subsystem/clipboard/wl.rs b/wayvr/src/subsystem/clipboard/wl.rs new file mode 100644 index 00000000..6be6c4e9 --- /dev/null +++ b/wayvr/src/subsystem/clipboard/wl.rs @@ -0,0 +1,23 @@ +use smithay_clipboard::Clipboard; +use wlx_capture::wayland::smithay_client_toolkit::reexports::client::{Connection, Proxy}; + +use crate::subsystem::clipboard::ClipboardProvider; + +pub struct WlClipboardProvider { + clipboard: Clipboard, +} + +impl WlClipboardProvider { + pub fn new() -> anyhow::Result { + let connection = Connection::connect_to_env()?; + let clipboard = unsafe { Clipboard::new(connection.display().id().as_ptr() as *mut _) }; + + Ok(Self { clipboard }) + } +} + +impl ClipboardProvider for WlClipboardProvider { + fn set_clipboard_utf8(&mut self, content: &str) { + self.clipboard.store(content.to_owned()); + } +} diff --git a/wayvr/src/subsystem/clipboard/x11.rs b/wayvr/src/subsystem/clipboard/x11.rs new file mode 100644 index 00000000..5cd9e783 --- /dev/null +++ b/wayvr/src/subsystem/clipboard/x11.rs @@ -0,0 +1,329 @@ +use std::{ + error::Error, + sync::{ + atomic::Ordering, + mpsc::{self, Receiver, RecvTimeoutError, Sender}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use xcb::{Xid, x}; + +use crate::{RUNNING, subsystem::clipboard::ClipboardProvider}; + +pub struct X11ClipboardProvider { + tx: Sender, + worker: Option>, +} + +impl X11ClipboardProvider { + pub fn new() -> Result> { + let (conn, screen_num) = xcb::Connection::connect(None)?; + let runtime = ClipboardRuntime::new(conn, screen_num)?; + + let (tx, rx) = mpsc::channel(); + + let worker = thread::spawn(move || { + runtime.run(rx); + }); + + Ok(Self { + tx, + worker: Some(worker), + }) + } +} + +impl ClipboardProvider for X11ClipboardProvider { + fn set_clipboard_utf8(&mut self, content: &str) { + let (ack_tx, ack_rx) = mpsc::channel(); + + if self + .tx + .send(Command::Set(content.as_bytes().to_vec(), ack_tx)) + .is_ok() + { + // make sure the X server has received the ownership request before returning + let _ = ack_rx.recv(); + } + } +} + +impl Drop for X11ClipboardProvider { + fn drop(&mut self) { + let _ = self.tx.send(Command::Shutdown); + + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +enum Command { + Set(Vec, Sender<()>), + Shutdown, +} + +#[derive(Clone, Copy)] +struct Atoms { + clipboard: x::Atom, + utf8_string: x::Atom, + targets: x::Atom, + text: x::Atom, + text_plain: x::Atom, + text_plain_utf8: x::Atom, +} + +struct ClipboardRuntime { + conn: xcb::Connection, + window: x::Window, + atoms: Atoms, + content: Vec, + owns_clipboard: bool, +} + +impl ClipboardRuntime { + fn new( + conn: xcb::Connection, + screen_num: i32, + ) -> Result> { + let atoms = Atoms { + clipboard: intern_atom(&conn, b"CLIPBOARD")?, + utf8_string: intern_atom(&conn, b"UTF8_STRING")?, + targets: intern_atom(&conn, b"TARGETS")?, + text: intern_atom(&conn, b"TEXT")?, + text_plain: intern_atom(&conn, b"text/plain")?, + text_plain_utf8: intern_atom(&conn, b"text/plain;charset=utf-8")?, + }; + + let setup = conn.get_setup(); + let screen = setup.roots().nth(screen_num as usize).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "X11 screen not found") + })?; + + let window: x::Window = conn.generate_id(); + + conn.send_and_check_request(&x::CreateWindow { + depth: x::COPY_FROM_PARENT as u8, + wid: window, + parent: screen.root(), + x: 0, + y: 0, + width: 1, + height: 1, + border_width: 0, + class: x::WindowClass::InputOutput, + visual: screen.root_visual(), + value_list: &[], + })?; + + conn.flush()?; + + Ok(Self { + conn, + window, + atoms, + content: Vec::new(), + owns_clipboard: false, + }) + } + + fn run(mut self, rx: Receiver) { + while RUNNING.load(Ordering::Relaxed) { + self.drain_x_events(); + + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(command) => { + if !self.handle_command(command) { + break; + } + + while let Ok(command) = rx.try_recv() { + if !self.handle_command(command) { + return; + } + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + } + + fn handle_command(&mut self, command: Command) -> bool { + match command { + Command::Set(content, ack) => { + self.content = content; + self.become_clipboard_owner(); + let _ = ack.send(()); + true + } + Command::Shutdown => { + self.release_clipboard_if_owned(); + false + } + } + } + + fn become_clipboard_owner(&mut self) { + self.conn.send_request(&x::SetSelectionOwner { + owner: self.window, + selection: self.atoms.clipboard, + time: x::CURRENT_TIME, + }); + + self.owns_clipboard = true; + let _ = self.conn.flush(); + } + + fn release_clipboard_if_owned(&mut self) { + if !self.owns_clipboard { + return; + } + + self.conn.send_request(&x::SetSelectionOwner { + owner: x::Window::none(), + selection: self.atoms.clipboard, + time: x::CURRENT_TIME, + }); + + self.owns_clipboard = false; + let _ = self.conn.flush(); + } + + fn drain_x_events(&mut self) { + loop { + match self.conn.poll_for_event() { + Ok(Some(event)) => self.handle_x_event(event), + Ok(None) => break, + Err(xcb::Error::Protocol(_)) => continue, + Err(xcb::Error::Connection(_)) => break, + } + } + } + + fn handle_x_event(&mut self, event: xcb::Event) { + match event { + xcb::Event::X(x::Event::SelectionRequest(req)) => { + self.handle_selection_request(&req); + } + xcb::Event::X(x::Event::SelectionClear(ev)) => { + if ev.selection() == self.atoms.clipboard { + self.owns_clipboard = false; + } + } + _ => {} + } + } + + fn handle_selection_request(&self, req: &x::SelectionRequestEvent) { + if !self.owns_clipboard || req.selection() != self.atoms.clipboard { + self.send_selection_notify(req, x::Atom::none()); + return; + } + + let property = if req.property().is_none() { + // compatibility with old clients that use XCB_NONE for property + req.target() + } else { + req.property() + }; + + let ok = if req.target() == self.atoms.targets { + self.write_targets(req.requestor(), property) + } else if let Some(type_atom) = self.type_for_text_target(req.target()) { + self.write_text(req.requestor(), property, type_atom) + } else { + false + }; + + self.send_selection_notify(req, if ok { property } else { x::Atom::none() }); + } + + fn supported_targets(&self) -> Vec { + let mut targets = vec![ + self.atoms.targets, + self.atoms.utf8_string, + self.atoms.text_plain_utf8, + self.atoms.text_plain, + ]; + + // STRING/TEXT are not UTF-8 targets. Only advertise them when the + // content is representable as plain ASCII. + if self.content.is_ascii() { + targets.push(self.atoms.text); + targets.push(x::ATOM_STRING); + } + + targets + } + + fn type_for_text_target(&self, target: x::Atom) -> Option { + if target == self.atoms.utf8_string + || target == self.atoms.text_plain_utf8 + || target == self.atoms.text_plain + { + Some(target) + } else if self.content.is_ascii() && (target == self.atoms.text || target == x::ATOM_STRING) + { + Some(target) + } else { + None + } + } + + fn write_targets(&self, requestor: x::Window, property: x::Atom) -> bool { + let targets = self.supported_targets(); + + self.conn + .send_and_check_request(&x::ChangeProperty { + mode: x::PropMode::Replace, + window: requestor, + property, + r#type: x::ATOM_ATOM, + data: targets.as_slice(), + }) + .is_ok() + } + + fn write_text(&self, requestor: x::Window, property: x::Atom, type_atom: x::Atom) -> bool { + self.conn + .send_and_check_request(&x::ChangeProperty { + mode: x::PropMode::Replace, + window: requestor, + property, + r#type: type_atom, + data: self.content.as_slice(), + }) + .is_ok() + } + + fn send_selection_notify(&self, req: &x::SelectionRequestEvent, property: x::Atom) { + let event = x::SelectionNotifyEvent::new( + req.time(), + req.requestor(), + req.selection(), + req.target(), + property, + ); + + let _ = self.conn.send_and_check_request(&x::SendEvent { + propagate: false, + destination: x::SendEventDest::Window(req.requestor()), + event_mask: x::EventMask::NO_EVENT, + event: &event, + }); + + let _ = self.conn.flush(); + } +} + +fn intern_atom(conn: &xcb::Connection, name: &[u8]) -> xcb::Result { + let cookie = conn.send_request(&x::InternAtom { + only_if_exists: false, + name, + }); + + Ok(conn.wait_for_reply(cookie)?.atom()) +} diff --git a/wayvr/src/subsystem/mod.rs b/wayvr/src/subsystem/mod.rs index 1243d666..5909c060 100644 --- a/wayvr/src/subsystem/mod.rs +++ b/wayvr/src/subsystem/mod.rs @@ -3,6 +3,9 @@ pub mod hid; pub mod input; pub mod notifications; +#[cfg(feature = "whisper")] +pub mod clipboard; + #[cfg(feature = "whisper")] pub mod whisper_stt; diff --git a/wayvr/src/windowing/manager.rs b/wayvr/src/windowing/manager.rs index 16777e76..2104b80b 100644 --- a/wayvr/src/windowing/manager.rs +++ b/wayvr/src/windowing/manager.rs @@ -136,6 +136,13 @@ where let grab_help = OverlayWindowData::from_config(create_grab_help(app)?); me.add(grab_help, app); + #[cfg(feature = "whisper")] + { + use crate::overlays::whisper::create_whisper; + let whisper = OverlayWindowData::from_config(create_whisper(app, headless, wayland)?); + me.add(whisper, app); + } + let custom_panels = app.session.config.custom_panels.clone(); for name in custom_panels { let Some(panel) = create_custom(app, name) else { diff --git a/wayvr/src/windowing/window.rs b/wayvr/src/windowing/window.rs index 6d025821..cda85cab 100644 --- a/wayvr/src/windowing/window.rs +++ b/wayvr/src/windowing/window.rs @@ -57,7 +57,10 @@ pub enum OverlayCategory { Internal, Keyboard, Dashboard, + /// User-provided custom panels Panel, + /// Same as Panel but does not support Modify and Reload + BuiltInPanel, Screen, Mirror, WayVR, diff --git a/wlx-capture/src/wayland.rs b/wlx-capture/src/wayland.rs index 4ad7c74c..4c2c8be4 100644 --- a/wlx-capture/src/wayland.rs +++ b/wlx-capture/src/wayland.rs @@ -17,6 +17,7 @@ use smithay_client_toolkit::reexports::{ }, }; +pub use smithay_client_toolkit; pub use wayland_client; use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, QueueHandle, From a54d1a02bb182b4845f97c305196a92badfb93ea Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:30:48 +0900 Subject: [PATCH 08/19] whisper ui tweaks + osc send --- wayvr/src/assets/gui/whisper.xml | 15 +-- wayvr/src/assets/icons/chat.svg | 1 + wayvr/src/assets/icons/paste.svg | 1 + wayvr/src/assets/lang/en.json | 10 +- wayvr/src/overlays/whisper.rs | 167 ++++++++++++++++++++++--------- 5 files changed, 138 insertions(+), 56 deletions(-) create mode 100644 wayvr/src/assets/icons/chat.svg create mode 100644 wayvr/src/assets/icons/paste.svg diff --git a/wayvr/src/assets/gui/whisper.xml b/wayvr/src/assets/gui/whisper.xml index 28218b87..4ec10d36 100644 --- a/wayvr/src/assets/gui/whisper.xml +++ b/wayvr/src/assets/gui/whisper.xml @@ -5,21 +5,24 @@ -
+
+ +
- -
@@ -27,7 +30,7 @@
-
diff --git a/wayvr/src/assets/icons/chat.svg b/wayvr/src/assets/icons/chat.svg new file mode 100644 index 00000000..600dee10 --- /dev/null +++ b/wayvr/src/assets/icons/chat.svg @@ -0,0 +1 @@ + diff --git a/wayvr/src/assets/icons/paste.svg b/wayvr/src/assets/icons/paste.svg new file mode 100644 index 00000000..a59ffb4f --- /dev/null +++ b/wayvr/src/assets/icons/paste.svg @@ -0,0 +1 @@ + diff --git a/wayvr/src/assets/lang/en.json b/wayvr/src/assets/lang/en.json index dc4b7ee0..8c9c989f 100644 --- a/wayvr/src/assets/lang/en.json +++ b/wayvr/src/assets/lang/en.json @@ -115,9 +115,13 @@ }, "WHISPER": { "TRANSCRIBE": "Press and hold to transcribe text", - "PASTE": "Paste transcription to focused overlay", - "UNLOAD": "Unload model from VRAM", + "PASTE": "Copy transcription to clipboard", + "PASTE_AND_GO": "Copy transcription and send Ctrl-V to focused app", + "SEND_OSC": "Send transcription to OSC chatbox", + "UNLOAD_AND_CLOSE": "Unload model and close", "MODEL_NOT_DOWNLOADED": "Speech-to-text not set up!", - "DOWNLOAD_GUIDANCE": "Set up speech-to-text under settings, features!" + "DOWNLOAD_GUIDANCE": "Set up speech-to-text under settings, features!", + "INITIAL_TEXT": "Press and hold the mic button to start transcribing...", + "INIT_ERROR": "Error while starting Whisper engine" } } diff --git a/wayvr/src/overlays/whisper.rs b/wayvr/src/overlays/whisper.rs index b94a1466..aadbf62c 100644 --- a/wayvr/src/overlays/whisper.rs +++ b/wayvr/src/overlays/whisper.rs @@ -16,6 +16,7 @@ use wlx_common::{ }; use crate::{ + backend::task::{OverlayTask, TaskType, ToggleMode}, gui::{ panel::{ GuiPanel, NewGuiPanelParams, OnCustomAttribFunc, @@ -31,10 +32,13 @@ use crate::{ input::KeyboardFocus, whisper_stt::WhisperStt, }, - windowing::window::{OverlayCategory, OverlayWindowConfig}, + windowing::{ + OverlaySelector, + window::{OverlayCategory, OverlayWindowConfig}, + }, }; -pub const WHISPER_NAME: &str = "Speech-to-Text"; +pub const WHISPER_NAME: &str = "whisper"; #[derive(Default)] struct WhisperState { @@ -43,6 +47,30 @@ struct WhisperState { last_transcription: Option>, } +impl WhisperState { + fn set_clipboard_text(&mut self, app: &mut AppState) -> bool { + let Some(text) = self.last_transcription.as_ref() else { + return false; + }; + + match app.hid_provider.keyboard_focus { + KeyboardFocus::WayVR => { + if let Some(wvr) = app.wvr_server.as_mut() { + wvr.set_clipboard(text); + return true; + } + } + KeyboardFocus::PhysicalScreen => { + if let Some(clip) = self.clipboard_provider.as_mut() { + clip.set_clipboard_utf8(text); + return true; + } + } + } + return false; + } +} + pub fn create_whisper( app: &mut AppState, headless: bool, @@ -93,30 +121,47 @@ pub fn create_whisper( return Ok(EventResult::Pass); } - if let Some(whisper) = state.whisper_sst.as_mut() { - let _ = whisper - .ptt_start() - .log_err("Could not start Whisper transcription"); + let whisper = match state.whisper_sst.as_mut() { + Some(x) => x, + None => { + let model_path = data_dir::get_path("whisper") + .join(app.session.config.whisper_model.as_ref()); + if model_path.is_file() { + state.whisper_sst = match WhisperStt::new(model_path) + .log_err("Error while starting Whisper engine") + { + Ok(x) => Some(x), + Err(e) => { + Toast::new( + ToastTopic::System, + "WHISPER.INIT_ERROR".into(), + e.to_string(), + ) + .with_timeout(5.) + .with_sound(true) + .submit(app); + return Ok(EventResult::Consumed); + } + } + } else { + Toast::new( + ToastTopic::System, + "WHISPER.MODEL_NOT_DOWNLOADED".into(), + "WHISPER.DOWNLOAD_GUIDANCE".into(), + ) + .with_timeout(5.) + .with_sound(true) + .submit(app); + return Ok(EventResult::Consumed); + } - return Ok(EventResult::Consumed); - } + state.whisper_sst.as_mut().unwrap() + } + }; - let model_path = data_dir::get_path("whisper") - .join(app.session.config.whisper_model.as_ref()); - if model_path.is_file() { - state.whisper_sst = WhisperStt::new(model_path) - .log_err("Could not create STT provider") - .ok(); - } else { - Toast::new( - ToastTopic::System, - "WHISPER.MODEL_NOT_DOWNLOADED".into(), - "WHISPER.DOWNLOAD_GUIDANCE".into(), - ) - .with_timeout(5.) - .with_sound(true) - .submit(app); - } + let _ = whisper + .ptt_start() + .log_err("Could not start Whisper transcription"); Ok(EventResult::Consumed) }), @@ -137,29 +182,17 @@ pub fn create_whisper( return Ok(EventResult::Pass); } - let Some(transcription) = state.last_transcription.as_ref() else { - return Ok(EventResult::Consumed); - }; + state.set_clipboard_text(app); - let mut success = false; - - match app.hid_provider.keyboard_focus { - KeyboardFocus::WayVR => { - if let Some(wvr) = app.wvr_server.as_mut() { - wvr.set_clipboard(transcription.as_ref()); - success = true; - } - } - KeyboardFocus::PhysicalScreen => { - if let Some(clip) = state.clipboard_provider.as_mut() { - clip.set_clipboard_utf8(transcription.as_ref()); - success = true; - } - } + Ok(EventResult::Consumed) + }), + "::WhisperPasteAndGo" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); } - // send ctrl-v - if success { + if state.set_clipboard_text(app) { + // send ctrl-v app.hid_provider.send_key_routed( app.wvr_server.as_mut(), VirtualKey::RCtrl, @@ -179,12 +212,38 @@ pub fn create_whisper( Ok(EventResult::Consumed) }), - "::WhisperUnload" => Box::new(move |_common, data, app, state| { + #[cfg(feature = "osc")] + "::WhisperSendOSC" => Box::new(move |_common, data, app, state| { + if !test_button(data) || !test_duration(&button, app) { + return Ok(EventResult::Pass); + } + + if let Some(text) = state.last_transcription.as_ref() + && let Some(osc) = app.osc_sender.as_mut() + { + use rosc::OscType; + + let _ = osc + .send_message( + "/chatbox/input".into(), + vec![OscType::String(text.to_string()), OscType::Bool(true)], + ) + .log_err("Could not send OSC message"); + } + + Ok(EventResult::Consumed) + }), + "::WhisperUnloadAndClose" => Box::new(move |_common, data, app, state| { if !test_button(data) || !test_duration(&button, app) { return Ok(EventResult::Pass); } state.whisper_sst = None; + app.tasks + .enqueue(TaskType::Overlay(OverlayTask::ToggleOverlay( + OverlaySelector::Name(WHISPER_NAME.into()), + ToggleMode::EnsureOff, + ))); Ok(EventResult::Consumed) }), @@ -208,6 +267,22 @@ pub fn create_whisper( BackendAttribValue::Icon("icons/mic.svg".into()), ); + #[cfg(not(feature = "osc"))] + { + use wgui::event::{CallbackDataCommon, StyleSetRequest}; + let osc_button = panel + .parser_state + .fetch_component_as::("btn_osc_send")?; + let common = CallbackDataCommon { + state: &panel.layout.state, + alterables: &mut panel.layout.alterables, + }; + common.alterables.set_style( + osc_button.get_rect(), + StyleSetRequest::Display(wgui::taffy::Display::None), + ); + } + let label = panel.parser_state.get_widget_id("transcription")?; panel @@ -221,11 +296,9 @@ pub fn create_whisper( { let text: Rc = text.into(); state.last_transcription = Some(text.clone()); - let label = data.obj.get_as_mut::().unwrap(); label.set_text(common, Translation::from_raw_text_rc(text)); } - Ok(EventResult::Pass) }); From 8e84e93293fad36fa7a0ca1ebb8d1d37e8606957 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:33:06 +0900 Subject: [PATCH 09/19] whisper working --- Cargo.lock | 3 + Cargo.toml | 3 +- wayvr/Cargo.toml | 7 +- wayvr/src/overlays/whisper.rs | 48 ++-- wayvr/src/subsystem/clipboard/wl.rs | 346 ++++++++++++++++++++++++++- wayvr/src/subsystem/clipboard/x11.rs | 8 +- wayvr/src/subsystem/hid/wayland.rs | 2 +- wlx-capture/src/wayland.rs | 2 - 8 files changed, 381 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8db3d3b1..1eb0b179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6608,6 +6608,8 @@ dependencies = [ "anyhow", "ash", "bytes", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", "chrono", "chrono-tz", "clap", @@ -6655,6 +6657,7 @@ dependencies = [ "vulkano", "vulkano-shaders", "wayland-client", + "wayland-protocols", "wayland-protocols-misc", "wayvr-ipc", "wgui", diff --git a/Cargo.toml b/Cargo.toml index 6a4acded..f390414f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,8 @@ vulkano = { version = "0.35.2", default-features = false, features = [ "macros", ] } vulkano-shaders = "0.35.0" -wayland-client = { version = "0.31.11" } +wayland-client = { version = "0.31" } +wayland-protocols = { version = "0.32" } wayland-protocols-misc = { version = "0.3.12" } xdg = "3.0.0" diff --git a/wayvr/Cargo.toml b/wayvr/Cargo.toml index 087d9393..d3f1ab6e 100644 --- a/wayvr/Cargo.toml +++ b/wayvr/Cargo.toml @@ -86,6 +86,7 @@ tracing = "0.1.43" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } uuid = { workspace = true } wayland-client = { workspace = true } +wayland-protocols = { workspace = true } wayland-protocols-misc = { workspace = true } winit = { version = "0.30.12", optional = true } xcb = { version = "1.6.0", features = [ @@ -95,7 +96,11 @@ xkbcommon = { version = "0.8.0" } # 0.9.0 breaks keymap import on some distros whisper-rs = { version = "0.16.0", features = ["vulkan"], optional = true } smithay-clipboard = "0.7.3" +calloop = { version = "0.14", optional = true } +calloop-wayland-source = { version = "0.4", optional = true } + [build-dependencies] + regex.workspace = true [features] @@ -108,6 +113,6 @@ osc = ["dep:rosc"] pipewire = ["wlx-capture/pipewire"] uidev = ["dep:winit"] wayland = ["pipewire", "wlx-capture/wlr", "xkbcommon/wayland"] -whisper = ["dep:pipewire", "dep:whisper-rs"] +whisper = ["dep:pipewire", "dep:whisper-rs", "dep:calloop", "dep:calloop-wayland-source"] x11 = ["dep:xcb", "wlx-capture/xshm", "xkbcommon/x11"] xcb = ["dep:xcb"] diff --git a/wayvr/src/overlays/whisper.rs b/wayvr/src/overlays/whisper.rs index aadbf62c..c3c34aa1 100644 --- a/wayvr/src/overlays/whisper.rs +++ b/wayvr/src/overlays/whisper.rs @@ -1,4 +1,4 @@ -use std::{path::Path, rc::Rc, time::Duration}; +use std::{rc::Rc, time::Duration}; use glam::{Affine3A, Quat, Vec3, vec3}; use wgui::{ @@ -27,7 +27,7 @@ use crate::{ overlays::toast::Toast, state::AppState, subsystem::{ - clipboard::{ClipboardProvider, wl::WlClipboardProvider, x11::X11ClipboardProvider}, + clipboard::{self, ClipboardProvider}, hid::VirtualKey, input::KeyboardFocus, whisper_stt::WhisperStt, @@ -76,20 +76,22 @@ pub fn create_whisper( headless: bool, wayland: bool, ) -> anyhow::Result { - // let clipboard_provider: Option> = match (headless, wayland) { - // (true, _) => None, - // (false, true) => WlClipboardProvider::new() - // .log_err("Could not create Wayland clipboard provider") - // .ok() - // .map(|p| Box::new(p) as Box), - // (false, false) => X11ClipboardProvider::new() - // .log_err("Could not create X11 clipboard provider") - // .ok() - // .map(|p| Box::new(p) as Box), - // }; + let clipboard_provider: Option> = match (headless, wayland) { + #[cfg(feature = "wayland")] + (false, true) => clipboard::wl::Provider::new() + .log_err("Could not create Wayland clipboard provider") + .ok() + .map(|p| Box::new(p) as Box), + #[cfg(feature = "x11")] + (false, false) => clipboard::x11::Provider::new() + .log_err("Could not create X11 clipboard provider") + .ok() + .map(|p| Box::new(p) as Box), + _ => None, + }; let state = WhisperState { - clipboard_provider: None, + clipboard_provider, ..Default::default() }; let xml = "gui/whisper.xml"; @@ -203,6 +205,11 @@ pub fn create_whisper( VirtualKey::V, true, ); + app.hid_provider.send_key_routed( + app.wvr_server.as_mut(), + VirtualKey::V, + false, + ); app.hid_provider.send_key_routed( app.wvr_server.as_mut(), VirtualKey::RCtrl, @@ -310,16 +317,19 @@ pub fn create_whisper( panel.update_layout(app)?; + let transform = Affine3A::from_cols_array_2d(&[ + [0.49993715, -0.00020921684, -0.008030709], + [-0.0021463279, 0.47818363, -0.14607349], + [0.007741399, 0.1460891, 0.478121], + [-0.021562248, -0.40786624, -0.3346647], + ]); + Ok(OverlayWindowConfig { name: WHISPER_NAME.into(), default_state: OverlayWindowState { interactable: true, grabbable: true, - transform: Affine3A::from_scale_rotation_translation( - Vec3::ONE * 0.5, - Quat::IDENTITY, - vec3(0.0, 0.0, -0.6), - ), + transform, positioning: Positioning::Anchored, ..OverlayWindowState::default() }, diff --git a/wayvr/src/subsystem/clipboard/wl.rs b/wayvr/src/subsystem/clipboard/wl.rs index 6be6c4e9..0c04a3bd 100644 --- a/wayvr/src/subsystem/clipboard/wl.rs +++ b/wayvr/src/subsystem/clipboard/wl.rs @@ -1,23 +1,349 @@ -use smithay_clipboard::Clipboard; -use wlx_capture::wayland::smithay_client_toolkit::reexports::client::{Connection, Proxy}; +use std::{ + fs::File, + io::Write, + sync::mpsc::{self, SyncSender}, + thread::{self, JoinHandle}, +}; + +use anyhow::Context; +use calloop::{ + EventLoop, + channel::{Channel, Event as ChannelEvent, Sender, channel}, +}; +use calloop_wayland_source::WaylandSource; +use wayland_client::{ + Connection, Dispatch, QueueHandle, + globals::{GlobalListContents, registry_queue_init}, + protocol::{ + wl_registry, + wl_seat::{self, WlSeat}, + }, +}; +use wayland_protocols::ext::data_control::v1::client::{ + ext_data_control_device_v1::{self, ExtDataControlDeviceV1}, + ext_data_control_manager_v1::{self, ExtDataControlManagerV1}, + ext_data_control_offer_v1::{self, ExtDataControlOfferV1}, + ext_data_control_source_v1::{self, ExtDataControlSourceV1}, +}; use crate::subsystem::clipboard::ClipboardProvider; -pub struct WlClipboardProvider { - clipboard: Clipboard, +const TEXT_MIME_TYPES: &[&str] = &[ + "text/plain;charset=utf-8", + "text/plain;charset=UTF-8", + "text/plain", + "UTF8_STRING", + "TEXT", + "STRING", +]; + +pub struct Provider { + tx: Sender, + _thread: JoinHandle<()>, } -impl WlClipboardProvider { +enum Command { + SetText(String), + Shutdown, +} + +struct ActiveSource { + source: ExtDataControlSourceV1, + bytes: Vec, +} + +struct State { + conn: Connection, + qh: QueueHandle, + manager: ExtDataControlManagerV1, + device: ExtDataControlDeviceV1, + active_sources: Vec, + ignored_offers: Vec, + stop: bool, +} + +impl Provider { pub fn new() -> anyhow::Result { - let connection = Connection::connect_to_env()?; - let clipboard = unsafe { Clipboard::new(connection.display().id().as_ptr() as *mut _) }; + let (tx, rx) = channel::(); + let (ready_tx, ready_rx) = mpsc::sync_channel::>(1); - Ok(Self { clipboard }) + let thread = thread::Builder::new() + .name("wayland-clipboard-ext-data-control".to_owned()) + .spawn(move || run_worker(rx, ready_tx)) + .context("failed to spawn Wayland clipboard worker thread")?; + + match ready_rx + .recv() + .context("Wayland clipboard worker exited before reporting readiness")? + { + Ok(()) => Ok(Self { + tx, + _thread: thread, + }), + Err(err) => anyhow::bail!("{}", err), + } } } -impl ClipboardProvider for WlClipboardProvider { +impl ClipboardProvider for Provider { fn set_clipboard_utf8(&mut self, content: &str) { - self.clipboard.store(content.to_owned()); + let _ = self.tx.send(Command::SetText(content.to_owned())); + } +} + +impl Drop for Provider { + fn drop(&mut self) { + let _ = self.tx.send(Command::Shutdown); + } +} + +fn run_worker(rx: Channel, ready: SyncSender>) { + macro_rules! init_or_report { + ($expr:expr, $context:literal) => { + match $expr { + Ok(value) => value, + Err(err) => { + let _ = ready.send(Err(format!("{}: {}", $context, err))); + return; + } + } + }; + } + + let conn = init_or_report!( + Connection::connect_to_env(), + "connect to Wayland compositor" + ); + + let (globals, event_queue) = + init_or_report!(registry_queue_init::(&conn), "read Wayland globals"); + + let qh = event_queue.handle(); + + let mut event_loop = + init_or_report!(EventLoop::::try_new(), "create calloop event loop"); + + init_or_report!( + WaylandSource::new(conn.clone(), event_queue).insert(event_loop.handle()), + "attach Wayland queue to event loop" + ); + + let manager: ExtDataControlManagerV1 = init_or_report!( + globals.bind(&qh, 1..=1, ()), + "bind ext_data_control_manager_v1" + ); + + let seat: WlSeat = init_or_report!(globals.bind(&qh, 1..=9, ()), "bind wl_seat"); + + let device = manager.get_data_device(&seat, &qh, ()); + + let mut state = State { + conn, + qh, + manager, + device, + active_sources: Vec::new(), + ignored_offers: Vec::new(), + stop: false, + }; + + init_or_report!( + event_loop + .handle() + .insert_source(rx, |event, _metadata, state| match event { + ChannelEvent::Msg(Command::SetText(text)) => { + state.set_clipboard_text(text); + } + ChannelEvent::Msg(Command::Shutdown) | ChannelEvent::Closed => { + state.stop = true; + } + }), + "attach clipboard command channel to event loop" + ); + + if let Err(err) = state.conn.flush() { + let _ = ready.send(Err(format!("flush initial Wayland requests: {err}"))); + return; + } + + let _ = ready.send(Ok(())); + + while !state.stop { + if let Err(err) = event_loop.dispatch(None, &mut state) { + eprintln!("Wayland clipboard event loop failed: {err}"); + break; + } + + if let Err(err) = state.conn.flush() { + eprintln!("Wayland clipboard flush failed: {err}"); + break; + } + } +} + +impl State { + fn set_clipboard_text(&mut self, content: String) { + let source = self.manager.create_data_source(&self.qh, ()); + + for mime_type in TEXT_MIME_TYPES { + source.offer((*mime_type).to_owned()); + } + + self.device.set_selection(Some(&source)); + + let previous_sources = std::mem::take(&mut self.active_sources); + + self.active_sources.push(ActiveSource { + source, + bytes: content.into_bytes(), + }); + + for old in previous_sources { + old.source.destroy(); + } + + let _ = self.conn.flush(); + } +} + +impl Dispatch for State { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + _state: &mut Self, + _seat: &WlSeat, + _event: wl_seat::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + _state: &mut Self, + _manager: &ExtDataControlManagerV1, + _event: ext_data_control_manager_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + device: &ExtDataControlDeviceV1, + event: ext_data_control_device_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + match event { + ext_data_control_device_v1::Event::DataOffer { id } => { + state.ignored_offers.push(id); + } + ext_data_control_device_v1::Event::Selection { id } + | ext_data_control_device_v1::Event::PrimarySelection { id } => { + if let Some(offer) = id { + if let Some(pos) = state + .ignored_offers + .iter() + .position(|stored| *stored == offer) + { + state.ignored_offers.swap_remove(pos); + } + + offer.destroy(); + } + } + ext_data_control_device_v1::Event::Finished => { + device.destroy(); + state.stop = true; + } + _ => {} + } + } + + fn event_created_child( + opcode: u16, + qh: &QueueHandle, + ) -> std::sync::Arc { + if opcode == ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE { + qh.make_data::(()) + } else { + unreachable!("unknown ext_data_control_device_v1 child event opcode: {opcode}") + } + } +} + +impl Dispatch for State { + fn event( + _state: &mut Self, + _offer: &ExtDataControlOfferV1, + _event: ext_data_control_offer_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + source: &ExtDataControlSourceV1, + event: ext_data_control_source_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + match event { + ext_data_control_source_v1::Event::Send { mime_type, fd } => { + if !TEXT_MIME_TYPES.contains(&mime_type.as_str()) { + return; + } + + let Some(active) = state + .active_sources + .iter() + .find(|active| active.source == *source) + else { + return; + }; + + let mut file = File::from(fd); + + if let Err(err) = file.write_all(&active.bytes) { + eprintln!("failed to write clipboard data to Wayland fd: {err}"); + return; + } + + let _ = file.flush(); + } + ext_data_control_source_v1::Event::Cancelled => { + if let Some(pos) = state + .active_sources + .iter() + .position(|active| active.source == *source) + { + let active = state.active_sources.swap_remove(pos); + active.source.destroy(); + } + } + _ => {} + } } } diff --git a/wayvr/src/subsystem/clipboard/x11.rs b/wayvr/src/subsystem/clipboard/x11.rs index 5cd9e783..3464609f 100644 --- a/wayvr/src/subsystem/clipboard/x11.rs +++ b/wayvr/src/subsystem/clipboard/x11.rs @@ -12,12 +12,12 @@ use xcb::{Xid, x}; use crate::{RUNNING, subsystem::clipboard::ClipboardProvider}; -pub struct X11ClipboardProvider { +pub struct Provider { tx: Sender, worker: Option>, } -impl X11ClipboardProvider { +impl Provider { pub fn new() -> Result> { let (conn, screen_num) = xcb::Connection::connect(None)?; let runtime = ClipboardRuntime::new(conn, screen_num)?; @@ -35,7 +35,7 @@ impl X11ClipboardProvider { } } -impl ClipboardProvider for X11ClipboardProvider { +impl ClipboardProvider for Provider { fn set_clipboard_utf8(&mut self, content: &str) { let (ack_tx, ack_rx) = mpsc::channel(); @@ -50,7 +50,7 @@ impl ClipboardProvider for X11ClipboardProvider { } } -impl Drop for X11ClipboardProvider { +impl Drop for Provider { fn drop(&mut self) { let _ = self.tx.send(Command::Shutdown); diff --git a/wayvr/src/subsystem/hid/wayland.rs b/wayvr/src/subsystem/hid/wayland.rs index 13ef1893..a8293ab2 100644 --- a/wayvr/src/subsystem/hid/wayland.rs +++ b/wayvr/src/subsystem/hid/wayland.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use wlx_capture::wayland::wayland_client::{ +use wayland_client::{ Connection, Dispatch, Proxy, QueueHandle, globals::{GlobalListContents, registry_queue_init}, protocol::{ diff --git a/wlx-capture/src/wayland.rs b/wlx-capture/src/wayland.rs index 4c2c8be4..450ceeb5 100644 --- a/wlx-capture/src/wayland.rs +++ b/wlx-capture/src/wayland.rs @@ -17,8 +17,6 @@ use smithay_client_toolkit::reexports::{ }, }; -pub use smithay_client_toolkit; -pub use wayland_client; use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, QueueHandle, backend::WaylandError, From 843240791f622ba2cbca702c67090e91e4a7a633 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:36:25 +0900 Subject: [PATCH 10/19] translator: allow specifying llama url --- scripts/translator/main.ts | 3 ++- scripts/translator/run.sh | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/translator/main.ts b/scripts/translator/main.ts index ec4203cd..dae44aba 100644 --- a/scripts/translator/main.ts +++ b/scripts/translator/main.ts @@ -3,6 +3,7 @@ import * as fsp from "fs/promises"; import path from "path"; import * as fs from "fs"; +const base_url = process.env["LLAMA_BASE_URL"] as string; const model_name = process.env["MODEL"] as string; const template_name = process.env["TEMPLATE"] as string; let lang_path = process.env["LANG_PATH"] as string; @@ -203,7 +204,7 @@ async function run() { english_translation, ); - const response = await fetch("http://localhost:8080/v1/chat/completions", { + const response = await fetch(base_url + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/scripts/translator/run.sh b/scripts/translator/run.sh index 2b371229..91e86bd3 100755 --- a/scripts/translator/run.sh +++ b/scripts/translator/run.sh @@ -21,6 +21,10 @@ if [ -z "${MODEL}" ]; then export MODEL="gemma-4-26B-A4B-it-UD-Q3_K_M" fi +if [ -z "${LLAMA_BASE_URL}" ]; then + export LLAMA_BASE_URL="http://127.0.0.1:8080" +fi + TEMPLATE="pl" bun main.ts TEMPLATE="de" bun main.ts TEMPLATE="ja" bun main.ts From 334e741d19439f681781dc7ef7fe0ecb25c25ef6 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:36:34 +0900 Subject: [PATCH 11/19] update translations --- wayvr/src/assets/gui/whisper.xml | 10 +- wayvr/src/assets/lang/de.json | 243 ++++++++++++++++--------------- wayvr/src/assets/lang/es.json | 243 ++++++++++++++++--------------- wayvr/src/assets/lang/it.json | 239 +++++++++++++++--------------- wayvr/src/assets/lang/ja.json | 239 +++++++++++++++--------------- wayvr/src/assets/lang/pl.json | 239 +++++++++++++++--------------- wayvr/src/assets/lang/zh_CN.json | 239 +++++++++++++++--------------- 7 files changed, 759 insertions(+), 693 deletions(-) diff --git a/wayvr/src/assets/gui/whisper.xml b/wayvr/src/assets/gui/whisper.xml index 4ec10d36..460fd7a1 100644 --- a/wayvr/src/assets/gui/whisper.xml +++ b/wayvr/src/assets/gui/whisper.xml @@ -8,21 +8,21 @@
- - - -
-
diff --git a/wayvr/src/assets/lang/de.json b/wayvr/src/assets/lang/de.json index f4969b8c..1532c7c3 100644 --- a/wayvr/src/assets/lang/de.json +++ b/wayvr/src/assets/lang/de.json @@ -1,118 +1,129 @@ { - "ANCHOR": { - "CENTER": "Zentrum" - }, - "BAR": { - "ADD_MIRROR": "Neuen Spiegel-Overlay hinzufügen", - "EDIT_MODE_TOGGLE": "Bearbeitungsmodus umschalten", - "ADD_NEW_SET": "Neues Set hinzufügen", - "DELETE_CURRENT_SET": "Aktuelles Set löschen", - "TOGGLE_VISIBILITY": "Sichtbarkeit umschalten", - "RESET_POSITION": "Position zurücksetzen", - "RELOAD_FROM_DISK": "XML-Datei von der Festplatte neu laden", - "CLOSE_MIRROR": "Spiegel schließen", - "CLOSE_APP": "App schließen", - "FORCE_CLOSE_APP": "App zwangsweise schließen", - "HANDSFREE": { - "TITLE": "Freihändig-Modus", - "NONE": "Aus", - "HMD": "HMD + Kneifen", - "EYE_TRACKING": "Auge + Kneifen", - "HMD_ONLY": "Nur HMD", - "EYE_ONLY": "Nur Augen" - } - }, - "WATCH": { - "RECENTER": "Spielbereich neu zentrieren", - "FIX_FLOOR": "Bodenhöhe korrigieren", - "EDIT_MODE_EXPLANATION": "Overlays können nun einzeln verschoben und angepasst werden.\nOverlays für den aktuellen Satz umschalten:", - "DASHBOARD": "Dashboard", - "EDIT_MODE": "Bearbeitungsmodus", - "ADD_NEW_SET": "Neuen Satz hinzufügen", - "SWITCH_TO_SET": "Zum Satz wechseln", - "TOGGLE_FOR_CURRENT_SET": "Sichtbarkeit im aktuellen Satz umschalten", - "LONG_PRESS_TO_DELETE_SET": "Lange drücken, um Satz zu löschen", - "CLEANUP_MIRRORS": "Spiegel entfernen, die\nderzeit nicht sichtbar sind" - }, - "EDIT_MODE": { - "ADJUST_CURVATURE": "Krümmung anpassen", - "ALPHA_BLEND_MODE": "Alpha-Mischmodus", - "BLENDING_ADDITIVE": "Additiv", - "CURVATURE": "Krümmung", - "DELETE": "Löschen", - "HINT_POINT_WINDOW": "Zeigen Sie auf ein Fenster, um seine Parameter zu ändern.\nWenn Sie fertig sind, verlassen Sie den Bearbeitungsmodus mit der Schaltfläche auf der rechten Seite.", - "LEAVE": "Bearbeitungsmodus verlassen", - "LOCK_INTERACTION": "Interaktion sperren", - "DISABLE_GRAB": "Greifen deaktivieren", - "MOVE_PRESS_AND_DRAG": "Verschieben (drücken & ziehen)", - "OPACITY": "Undurchsichtigkeit", - "POSITIONING": "Positionierung", - "RESIZE_PRESS_AND_DRAG": "Größe ändern (gedrückt halten & ziehen)", - "POS_STATIC": "Statisch: Bleibt an Ort und Stelle und wird niemals neu zentriert.", - "POS_ANCHORED": "Verankert: Bewegt sich zusammen mit dem Rest des Sets. Standard.", - "POS_FLOATING": "Schwebend: Bleibt an Ort und Stelle, wird beim Anzeigen neu zentriert.", - "POS_HMD": "Folge dem Headset.", - "POS_HAND_L": "Folge der linken Hand.", - "POS_HAND_R": "Folge der rechten Hand.", - "INTERPOLATION": "Interpolation", - "KEYBOARD": "Tastatur", - "STEREO_3D_MODE_A": {}, - "STEREO_3D_MODE": { - "TITLE": "3D Stereo-Modus", - "SPLIT_LEFT_RIGHT": "LINKS→RECHTS", - "SPLIT_RIGHT_LEFT": "RECHTS→LINKS", - "SPLIT_TOP_BOTTOM": "OBEN→UNTEN", - "SPLIT_BOTTOM_TOP": "UNTEN→OBEN", - "FULL_FRAME": "Voll 3D", - "FULL_FRAME_SBS": "Full-SBS", - "FULL_FRAME_TAB": "Full-TAB", - "FULL_FRAME_BAT": "Full-BAT", - "ADJUST_MOUSE": "Maus anpassen" - }, - "STEREO_3D_MODE_": {}, - "ALIGN_TO_HMD": "An HMD ausrichten", - "MOUSE": { - "TITLE": "Mauskorrekturen", - "WRONG_SCREEN_SELECTION_HELP": "Wenn der Cursor auf einem völlig anderen Bildschirm verschwindet,\nwurden die Bildschirme wahrscheinlich falsch ausgewählt. Siehe Lesezeichen.", - "NORMAL": "Normal", - "FLIPPED": "Gespiegelt", - "FLIP90": "90° gespiegelt", - "FLIP180": "180° gespiegelt", - "FLIP270": "270° gespiegelt", - "ROTATE90": "90° gedreht", - "ROTATE180": "180° gedreht", - "ROTATE270": "270° gedreht" - }, - "GLOBAL": "Immer sichtbar", - "ANGLE_FADE": "Winkelübergang", - "ANGLE_FADE_HELP": "Ausblenden, wenn nicht auf das Headset gerichtet", - "BLOCK_INPUT": "Blockiere Spieleingabe" - }, - "DISABLED": "Deaktiviert", - "DEFAULT": "Standard", - "GRAB": { - "ADJUST_DISTANCE": "Entfernung anpassen", - "ADJUST_SIZE": "Größe anpassen", - "UNRESTRICTED_MOVEMENT": "Unbeschränkte Bewegung", - "GRABBING_WATCH": "Um die Hände zu vertauschen, bewegen Sie die Uhr vor sich und greifen Sie sie mit der anderen Hand.", - "GRABBING_STATIC": "Dieses Overlay ist statisch und bleibt an Ort und Stelle, ignoriert die Neu-Zentrierung.", - "GRABBING_ANCHORED": "Festgelegte Overlays bewegen sich alle zusammen. Trennen Sie ein einzelnes Fenster, indem Sie es mit der anderen Hand greifen, während Sie den Anker weiterhin greifen.", - "GRABBING_ANCHORED_EDIT": "Diese Overlay-Darstellung bleibt an der Mittelmarkierung verankert.", - "GRABBING_FLOATING": "Diese Overlay-Darstellung ist frei schwebend und bleibt an Ort und Stelle, es sei denn, sie wird neu zentriert.", - "GRABBING_FOLLOW": "Dieses Overlay wird dem Gerät folgen, an das es angehängt ist." - }, - "TOAST": { - "DEFAULT_TITLE": "Benachrichtigung", - "ERROR": "Fehler", - "CANNOT_REMOVE_SET": "Satz kann nicht entfernt werden!", - "NO_SET_SELECTED": "Kein Set ausgewählt.", - "LAST_EXISTING_SET": "Dies ist das letzte vorhandene Set.", - "EMPTY_SET": "Leeres Set!", - "LETS_ADD_OVERLAYS": "Lass uns ein paar Overlays von der Uhr hinzufügen!", - "ONE_CONTROLLER_ON_FLOOR": "Lege einen Controller auf den Boden!", - "CANNOT_ADD_SET": "Satz kann nicht hinzugefügt werden!", - "MAXIMUM_SETS_REACHED": "Maximale Anzahl an Sets erreicht.", - "FIXING_FLOOR_IN_X_SECS": "Boden wird in {SECONDS} Sekunde(n) repariert..." - }, - "DONE": "Erledigt" + "ANCHOR": { + "CENTER": "Zentrum" + }, + "BAR": { + "ADD_MIRROR": "Neuen Spiegel-Overlay hinzufügen", + "EDIT_MODE_TOGGLE": "Bearbeitungsmodus umschalten", + "ADD_NEW_SET": "Neues Set hinzufügen", + "DELETE_CURRENT_SET": "Aktuelles Set löschen", + "TOGGLE_VISIBILITY": "Sichtbarkeit umschalten", + "RESET_POSITION": "Position zurücksetzen", + "RELOAD_FROM_DISK": "XML-Datei von der Festplatte neu laden", + "CLOSE_MIRROR": "Spiegel schließen", + "CLOSE_APP": "App schließen", + "FORCE_CLOSE_APP": "App zwangsweise schließen", + "HANDSFREE": { + "TITLE": "Freihändig-Modus", + "NONE": "Aus", + "HMD": "HMD + Kneifen", + "EYE_TRACKING": "Auge + Kneifen", + "HMD_ONLY": "Nur HMD", + "EYE_ONLY": "Nur Augen" + } + }, + "WATCH": { + "RECENTER": "Spielbereich neu zentrieren", + "FIX_FLOOR": "Bodenhöhe korrigieren", + "EDIT_MODE_EXPLANATION": "Overlays können nun einzeln verschoben und angepasst werden.\nOverlays für den aktuellen Satz umschalten:", + "DASHBOARD": "Dashboard", + "EDIT_MODE": "Bearbeitungsmodus", + "ADD_NEW_SET": "Neuen Satz hinzufügen", + "SWITCH_TO_SET": "Zum Satz wechseln", + "TOGGLE_FOR_CURRENT_SET": "Sichtbarkeit im aktuellen Satz umschalten", + "LONG_PRESS_TO_DELETE_SET": "Lange drücken, um Satz zu löschen", + "CLEANUP_MIRRORS": "Spiegel entfernen, die\nderzeit nicht sichtbar sind" + }, + "EDIT_MODE": { + "ADJUST_CURVATURE": "Krümmung anpassen", + "ALPHA_BLEND_MODE": "Alpha-Mischmodus", + "BLENDING_ADDITIVE": "Additiv", + "CURVATURE": "Krümmung", + "DELETE": "Löschen", + "HINT_POINT_WINDOW": "Zeigen Sie auf ein Fenster, um seine Parameter zu ändern.\nWenn Sie fertig sind, verlassen Sie den Bearbeitungsmodus mit der Schaltfläche auf der rechten Seite.", + "LEAVE": "Bearbeitungsmodus verlassen", + "LOCK_INTERACTION": "Interaktion sperren", + "DISABLE_GRAB": "Greifen deaktivieren", + "MOVE_PRESS_AND_DRAG": "Verschieben (drücken & ziehen)", + "OPACITY": "Undurchsichtigkeit", + "POSITIONING": "Positionierung", + "RESIZE_PRESS_AND_DRAG": "Größe ändern (gedrückt halten & ziehen)", + "POS_STATIC": "Statisch: Bleibt an Ort und Stelle und wird niemals neu zentriert.", + "POS_ANCHORED": "Verankert: Bewegt sich zusammen mit dem Rest des Sets. Standard.", + "POS_FLOATING": "Schwebend: Bleibt an Ort und Stelle, wird beim Anzeigen neu zentriert.", + "POS_HMD": "Folge dem Headset.", + "POS_HAND_L": "Folge der linken Hand.", + "POS_HAND_R": "Folge der rechten Hand.", + "INTERPOLATION": "Interpolation", + "KEYBOARD": "Tastatur", + "STEREO_3D_MODE_A": {}, + "STEREO_3D_MODE": { + "TITLE": "3D Stereo-Modus", + "SPLIT_LEFT_RIGHT": "LINKS→RECHTS", + "SPLIT_RIGHT_LEFT": "RECHTS→LINKS", + "SPLIT_TOP_BOTTOM": "OBEN→UNTEN", + "SPLIT_BOTTOM_TOP": "UNTEN→OBEN", + "FULL_FRAME": "Voll 3D", + "FULL_FRAME_SBS": "Full-SBS", + "FULL_FRAME_TAB": "Full-TAB", + "FULL_FRAME_BAT": "Full-BAT", + "ADJUST_MOUSE": "Maus anpassen" + }, + "STEREO_3D_MODE_": {}, + "ALIGN_TO_HMD": "An HMD ausrichten", + "MOUSE": { + "TITLE": "Mauskorrekturen", + "WRONG_SCREEN_SELECTION_HELP": "Wenn der Cursor auf einem völlig anderen Bildschirm verschwindet,\nwurden die Bildschirme wahrscheinlich falsch ausgewählt. Siehe Lesezeichen.", + "NORMAL": "Normal", + "FLIPPED": "Gespiegelt", + "FLIP90": "90° gespiegelt", + "FLIP180": "180° gespiegelt", + "FLIP270": "270° gespiegelt", + "ROTATE90": "90° gedreht", + "ROTATE180": "180° gedreht", + "ROTATE270": "270° gedreht" + }, + "GLOBAL": "Immer sichtbar", + "ANGLE_FADE": "Winkelübergang", + "ANGLE_FADE_HELP": "Ausblenden, wenn nicht auf das Headset gerichtet", + "BLOCK_INPUT": "Blockiere Spieleingabe" + }, + "DISABLED": "Deaktiviert", + "DEFAULT": "Standard", + "GRAB": { + "ADJUST_DISTANCE": "Entfernung anpassen", + "ADJUST_SIZE": "Größe anpassen", + "UNRESTRICTED_MOVEMENT": "Unbeschränkte Bewegung", + "GRABBING_WATCH": "Um die Hände zu vertauschen, bewegen Sie die Uhr vor sich und greifen Sie sie mit der anderen Hand.", + "GRABBING_STATIC": "Dieses Overlay ist statisch und bleibt an Ort und Stelle, ignoriert die Neu-Zentrierung.", + "GRABBING_ANCHORED": "Festgelegte Overlays bewegen sich alle zusammen. Trennen Sie ein einzelnes Fenster, indem Sie es mit der anderen Hand greifen, während Sie den Anker weiterhin greifen.", + "GRABBING_ANCHORED_EDIT": "Diese Overlay-Darstellung bleibt an der Mittelmarkierung verankert.", + "GRABBING_FLOATING": "Diese Overlay-Darstellung ist frei schwebend und bleibt an Ort und Stelle, es sei denn, sie wird neu zentriert.", + "GRABBING_FOLLOW": "Dieses Overlay wird dem Gerät folgen, an das es angehängt ist." + }, + "TOAST": { + "DEFAULT_TITLE": "Benachrichtigung", + "ERROR": "Fehler", + "CANNOT_REMOVE_SET": "Satz kann nicht entfernt werden!", + "NO_SET_SELECTED": "Kein Set ausgewählt.", + "LAST_EXISTING_SET": "Dies ist das letzte vorhandene Set.", + "EMPTY_SET": "Leeres Set!", + "LETS_ADD_OVERLAYS": "Lass uns ein paar Overlays von der Uhr hinzufügen!", + "ONE_CONTROLLER_ON_FLOOR": "Lege einen Controller auf den Boden!", + "CANNOT_ADD_SET": "Satz kann nicht hinzugefügt werden!", + "MAXIMUM_SETS_REACHED": "Maximale Anzahl an Sets erreicht.", + "FIXING_FLOOR_IN_X_SECS": "Boden wird in {SECONDS} Sekunde(n) repariert..." + }, + "DONE": "Erledigt", + "WHISPER": { + "TRANSCRIBE": "Zum Transkribieren von Text gedrückt halten", + "PASTE": "Transkription in die Zwischenablage kopieren", + "PASTE_AND_GO": "Transkription kopieren und Ctrl-V an die fokussierte App senden", + "SEND_OSC": "Transkription an OSC-Chatbox senden", + "UNLOAD_AND_CLOSE": "Modell entladen und schließen", + "MODEL_NOT_DOWNLOADED": "Spracherkennung nicht eingerichtet!", + "DOWNLOAD_GUIDANCE": "Richten Sie die Spracherkennung unter Einstellungen -> Features ein!", + "INITIAL_TEXT": "Halten Sie die Mikrofontaste gedrückt, um mit der Transkription zu beginnen...", + "INIT_ERROR": "Fehler beim Starten der Whisper-Engine" + } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/es.json b/wayvr/src/assets/lang/es.json index 8236a64a..b6ce7dd1 100644 --- a/wayvr/src/assets/lang/es.json +++ b/wayvr/src/assets/lang/es.json @@ -1,118 +1,129 @@ { - "ANCHOR": { - "CENTER": "Centro" - }, - "BAR": { - "ADD_MIRROR": "Agregar una nueva superposición de espejo", - "EDIT_MODE_TOGGLE": "Activar/desactivar el modo de edición", - "ADD_NEW_SET": "Añadir nuevo set", - "DELETE_CURRENT_SET": "Eliminar set actual", - "TOGGLE_VISIBILITY": "Alternar visibilidad", - "RESET_POSITION": "Restablecer posición", - "RELOAD_FROM_DISK": "Volver a cargar XML desde el disco", - "CLOSE_MIRROR": "Cerrar espejo", - "CLOSE_APP": "Cerrar aplicación", - "FORCE_CLOSE_APP": "Forzar cierre de la aplicación", - "HANDSFREE": { - "TITLE": "Modo manos libres", - "NONE": "Desactivado", - "HMD": "HMD + pellizco", - "EYE_TRACKING": "Ojo + pellizco", - "HMD_ONLY": "Solo HMD", - "EYE_ONLY": "Solo ojos" - } - }, - "WATCH": { - "RECENTER": "Recentrar el área de juego", - "FIX_FLOOR": "Ajustar la altura del piso", - "EDIT_MODE_EXPLANATION": "Ahora las superposiciones se pueden mover y ajustar individualmente.\nAlternar superposiciones para el conjunto actual:", - "DASHBOARD": "Panel de control", - "EDIT_MODE": "Modo de edición", - "ADD_NEW_SET": "Añadir un nuevo conjunto", - "SWITCH_TO_SET": "Cambiar al conjunto", - "TOGGLE_FOR_CURRENT_SET": "Alternar visibilidad en el conjunto actual", - "LONG_PRESS_TO_DELETE_SET": "Mantén presionado para eliminar el conjunto", - "CLEANUP_MIRRORS": "Eliminar los espejos que\nno son actualmente visibles" - }, - "EDIT_MODE": { - "ADJUST_CURVATURE": "Ajustar curvatura", - "ALPHA_BLEND_MODE": "Modo de mezcla alfa", - "BLENDING_ADDITIVE": "Aditivo", - "CURVATURE": "Curvatura", - "DELETE": "Eliminar", - "HINT_POINT_WINDOW": "Pulsa sobre una ventana para cambiar sus parámetros.\nUna vez terminado, sal del modo de edición usando el botón de la derecha.", - "LEAVE": "Salir del modo edición", - "LOCK_INTERACTION": "Bloquear interacción", - "DISABLE_GRAB": "Desactivar agarre", - "MOVE_PRESS_AND_DRAG": "Mover (presionar y arrastrar)", - "OPACITY": "Opacidad", - "POSITIONING": "Posicionamiento", - "RESIZE_PRESS_AND_DRAG": "Redimensionar (presionar y arrastrar)", - "POS_STATIC": "Estático: Permanece en su lugar y nunca se recentra.", - "POS_ANCHORED": "Anclado: Se mueve junto con el resto del conjunto. Predeterminado.", - "POS_FLOATING": "Flotante: Permanece en su lugar, se recentra cuando se muestra.", - "POS_HMD": "Seguir el HMD.", - "POS_HAND_L": "Seguir la mano izquierda.", - "POS_HAND_R": "Seguir la mano derecha.", - "INTERPOLATION": "Interpolación", - "KEYBOARD": "Teclado", - "STEREO_3D_MODE_A": {}, - "STEREO_3D_MODE": { - "TITLE": "Modo Estéreo 3D", - "SPLIT_LEFT_RIGHT": "IZQ→DER", - "SPLIT_RIGHT_LEFT": "DERECHA→IZQUIERDA", - "SPLIT_TOP_BOTTOM": "ARRIBA→ABAJO", - "SPLIT_BOTTOM_TOP": "ABAJO→ARRIBA", - "FULL_FRAME": "3D completo", - "FULL_FRAME_SBS": "Full-SBS", - "FULL_FRAME_TAB": "Full-TAB", - "FULL_FRAME_BAT": "Full-BAT", - "ADJUST_MOUSE": "Ajustar ratón" - }, - "STEREO_3D_MODE_": {}, - "ALIGN_TO_HMD": "Alinear al HMD", - "MOUSE": { - "TITLE": "Correcciones del ratón", - "WRONG_SCREEN_SELECTION_HELP": "Si el cursor se mueve en una pantalla completamente diferente,\nes probable que las pantallas se hayan seleccionado incorrectamente. Consulta el archivo readme.", - "NORMAL": "Normal", - "FLIPPED": "Invertido", - "FLIP90": "Invertido 90°", - "FLIP180": "Invertido 180°", - "FLIP270": "Invertido 270°", - "ROTATE90": "Rotado 90°", - "ROTATE180": "Rotado 180°", - "ROTATE270": "Rotado 270°" - }, - "GLOBAL": "Siempre visible", - "ANGLE_FADE": "Atenuación angular", - "ANGLE_FADE_HELP": "Desvanecer cuando no se mira el HMD", - "BLOCK_INPUT": "Bloquear entrada del juego" - }, - "DISABLED": "Deshabilitado", - "DEFAULT": "Por defecto", - "GRAB": { - "ADJUST_DISTANCE": "Ajustar distancia", - "ADJUST_SIZE": "Ajustar tamaño", - "UNRESTRICTED_MOVEMENT": "Movimiento sin restricciones", - "GRABBING_WATCH": "Para intercambiar las manos, mueve el reloj al frente y agrárralo con la otra mano.", - "GRABBING_STATIC": "Esta superposición es estática y permanecerá en su lugar, ignorando el recentrado.", - "GRABBING_ANCHORED": "Las superposiciones ancladas se mueven todas juntas. Separa una ventana individual agarrándola con la otra mano mientras aún sujetas el ancla.", - "GRABBING_ANCHORED_EDIT": "Esta superposición permanecerá anclada al marcador central.", - "GRABBING_FLOATING": "Esta superposición es flotante y permanecerá en su lugar a menos que se recentre.", - "GRABBING_FOLLOW": "Esta superposición seguirá el dispositivo al que está adjunta." - }, - "TOAST": { - "DEFAULT_TITLE": "Notificación", - "ERROR": "Error", - "CANNOT_REMOVE_SET": "¡No se puede eliminar el conjunto!", - "NO_SET_SELECTED": "No se ha seleccionado ningún conjunto.", - "LAST_EXISTING_SET": "Este es el último conjunto existente.", - "EMPTY_SET": "¡Conjunto vacío!", - "LETS_ADD_OVERLAYS": "¡Añadamos algunos overlays desde el reloj!", - "ONE_CONTROLLER_ON_FLOOR": "¡Coloca un mando en el suelo!", - "CANNOT_ADD_SET": "¡No se puede agregar el conjunto!", - "MAXIMUM_SETS_REACHED": "Se ha alcanzado el número máximo de sets.", - "FIXING_FLOOR_IN_X_SECS": "Corrigiendo el suelo en {SECONDS} segundo(s)..." - }, - "DONE": "Hecho" + "ANCHOR": { + "CENTER": "Centro" + }, + "BAR": { + "ADD_MIRROR": "Agregar una nueva superposición de espejo", + "EDIT_MODE_TOGGLE": "Activar/desactivar el modo de edición", + "ADD_NEW_SET": "Añadir nuevo set", + "DELETE_CURRENT_SET": "Eliminar set actual", + "TOGGLE_VISIBILITY": "Alternar visibilidad", + "RESET_POSITION": "Restablecer posición", + "RELOAD_FROM_DISK": "Volver a cargar XML desde el disco", + "CLOSE_MIRROR": "Cerrar espejo", + "CLOSE_APP": "Cerrar aplicación", + "FORCE_CLOSE_APP": "Forzar cierre de la aplicación", + "HANDSFREE": { + "TITLE": "Modo manos libres", + "NONE": "Desactivado", + "HMD": "HMD + pellizco", + "EYE_TRACKING": "Ojo + pellizco", + "HMD_ONLY": "Solo HMD", + "EYE_ONLY": "Solo ojos" + } + }, + "WATCH": { + "RECENTER": "Recentrar el área de juego", + "FIX_FLOOR": "Ajustar la altura del piso", + "EDIT_MODE_EXPLANATION": "Ahora las superposiciones se pueden mover y ajustar individualmente.\nAlternar superposiciones para el conjunto actual:", + "DASHBOARD": "Panel de control", + "EDIT_MODE": "Modo de edición", + "ADD_NEW_SET": "Añadir un nuevo conjunto", + "SWITCH_TO_SET": "Cambiar al conjunto", + "TOGGLE_FOR_CURRENT_SET": "Alternar visibilidad en el conjunto actual", + "LONG_PRESS_TO_DELETE_SET": "Mantén presionado para eliminar el conjunto", + "CLEANUP_MIRRORS": "Eliminar los espejos que\nno son actualmente visibles" + }, + "EDIT_MODE": { + "ADJUST_CURVATURE": "Ajustar curvatura", + "ALPHA_BLEND_MODE": "Modo de mezcla alfa", + "BLENDING_ADDITIVE": "Aditivo", + "CURVATURE": "Curvatura", + "DELETE": "Eliminar", + "HINT_POINT_WINDOW": "Pulsa sobre una ventana para cambiar sus parámetros.\nUna vez terminado, sal del modo de edición usando el botón de la derecha.", + "LEAVE": "Salir del modo edición", + "LOCK_INTERACTION": "Bloquear interacción", + "DISABLE_GRAB": "Desactivar agarre", + "MOVE_PRESS_AND_DRAG": "Mover (presionar y arrastrar)", + "OPACITY": "Opacidad", + "POSITIONING": "Posicionamiento", + "RESIZE_PRESS_AND_DRAG": "Redimensionar (presionar y arrastrar)", + "POS_STATIC": "Estático: Permanece en su lugar y nunca se recentra.", + "POS_ANCHORED": "Anclado: Se mueve junto con el resto del conjunto. Predeterminado.", + "POS_FLOATING": "Flotante: Permanece en su lugar, se recentra cuando se muestra.", + "POS_HMD": "Seguir el HMD.", + "POS_HAND_L": "Seguir la mano izquierda.", + "POS_HAND_R": "Seguir la mano derecha.", + "INTERPOLATION": "Interpolación", + "KEYBOARD": "Teclado", + "STEREO_3D_MODE_A": {}, + "STEREO_3D_MODE": { + "TITLE": "Modo Estéreo 3D", + "SPLIT_LEFT_RIGHT": "IZQ→DER", + "SPLIT_RIGHT_LEFT": "DERECHA→IZQUIERDA", + "SPLIT_TOP_BOTTOM": "ARRIBA→ABAJO", + "SPLIT_BOTTOM_TOP": "ABAJO→ARRIBA", + "FULL_FRAME": "3D completo", + "FULL_FRAME_SBS": "Full-SBS", + "FULL_FRAME_TAB": "Full-TAB", + "FULL_FRAME_BAT": "Full-BAT", + "ADJUST_MOUSE": "Ajustar ratón" + }, + "STEREO_3D_MODE_": {}, + "ALIGN_TO_HMD": "Alinear al HMD", + "MOUSE": { + "TITLE": "Correcciones del ratón", + "WRONG_SCREEN_SELECTION_HELP": "Si el cursor se mueve en una pantalla completamente diferente,\nes probable que las pantallas se hayan seleccionado incorrectamente. Consulta el archivo readme.", + "NORMAL": "Normal", + "FLIPPED": "Invertido", + "FLIP90": "Invertido 90°", + "FLIP180": "Invertido 180°", + "FLIP270": "Invertido 270°", + "ROTATE90": "Rotado 90°", + "ROTATE180": "Rotado 180°", + "ROTATE270": "Rotado 270°" + }, + "GLOBAL": "Siempre visible", + "ANGLE_FADE": "Atenuación angular", + "ANGLE_FADE_HELP": "Desvanecer cuando no se mira el HMD", + "BLOCK_INPUT": "Bloquear entrada del juego" + }, + "DISABLED": "Deshabilitado", + "DEFAULT": "Por defecto", + "GRAB": { + "ADJUST_DISTANCE": "Ajustar distancia", + "ADJUST_SIZE": "Ajustar tamaño", + "UNRESTRICTED_MOVEMENT": "Movimiento sin restricciones", + "GRABBING_WATCH": "Para intercambiar las manos, mueve el reloj al frente y agrárralo con la otra mano.", + "GRABBING_STATIC": "Esta superposición es estática y permanecerá en su lugar, ignorando el recentrado.", + "GRABBING_ANCHORED": "Las superposiciones ancladas se mueven todas juntas. Separa una ventana individual agarrándola con la otra mano mientras aún sujetas el ancla.", + "GRABBING_ANCHORED_EDIT": "Esta superposición permanecerá anclada al marcador central.", + "GRABBING_FLOATING": "Esta superposición es flotante y permanecerá en su lugar a menos que se recentre.", + "GRABBING_FOLLOW": "Esta superposición seguirá el dispositivo al que está adjunta." + }, + "TOAST": { + "DEFAULT_TITLE": "Notificación", + "ERROR": "Error", + "CANNOT_REMOVE_SET": "¡No se puede eliminar el conjunto!", + "NO_SET_SELECTED": "No se ha seleccionado ningún conjunto.", + "LAST_EXISTING_SET": "Este es el último conjunto existente.", + "EMPTY_SET": "¡Conjunto vacío!", + "LETS_ADD_OVERLAYS": "¡Añadamos algunos overlays desde el reloj!", + "ONE_CONTROLLER_ON_FLOOR": "¡Coloca un mando en el suelo!", + "CANNOT_ADD_SET": "¡No se puede agregar el conjunto!", + "MAXIMUM_SETS_REACHED": "Se ha alcanzado el número máximo de sets.", + "FIXING_FLOOR_IN_X_SECS": "Corrigiendo el suelo en {SECONDS} segundo(s)..." + }, + "DONE": "Hecho", + "WHISPER": { + "TRANSCRIBE": "Mantén presionado para transcribir texto", + "PASTE": "Copiar transcripción al portapapeles", + "PASTE_AND_GO": "Copiar transcripción y enviar Ctrl-V a la aplicación enfocada", + "SEND_OSC": "Enviar transcripción al chatbox de OSC", + "UNLOAD_AND_CLOSE": "Descargar modelo y cerrar", + "MODEL_NOT_DOWNLOADED": "¡El dictado por voz no está configurado!", + "DOWNLOAD_GUIDANCE": "¡Configura la conversión de voz a texto en ajustes, características!", + "INITIAL_TEXT": "Mantén presionado el botón del micrófono para comenzar a transcribir...", + "INIT_ERROR": "Error al iniciar el motor de Whisper" + } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/it.json b/wayvr/src/assets/lang/it.json index ed3015e8..d7c1403d 100644 --- a/wayvr/src/assets/lang/it.json +++ b/wayvr/src/assets/lang/it.json @@ -1,116 +1,127 @@ { - "ANCHOR": { - "CENTER": "Centro" - }, - "BAR": { - "ADD_MIRROR": "Aggiungi un nuovo overlay specchio", - "EDIT_MODE_TOGGLE": "Attiva/disattiva modalità modifica", - "ADD_NEW_SET": "Aggiungi nuovo set", - "DELETE_CURRENT_SET": "Elimina set attuale", - "TOGGLE_VISIBILITY": "Alterna visibilità", - "RESET_POSITION": "Ripristina posizione", - "RELOAD_FROM_DISK": "Ricarica XML dal disco", - "CLOSE_MIRROR": "Chiudi mirror", - "CLOSE_APP": "Chiudi app", - "FORCE_CLOSE_APP": "Chiudi forzatamente l'app", - "HANDSFREE": { - "TITLE": "Modalità a mani libere", - "NONE": "Disattivato", - "HMD": "HMD + pizzico", - "EYE_TRACKING": "Occhio + pizzico", - "HMD_ONLY": "Solo HMD", - "EYE_ONLY": "Solo occhi" - } - }, - "DEFAULT": "Predefinito", - "DISABLED": "Disabilitato", - "EDIT_MODE": { - "ADJUST_CURVATURE": "Regola curvatura", - "ALPHA_BLEND_MODE": "Modalità di fusione Alpha", - "BLENDING_ADDITIVE": "Blending additivo", - "CURVATURE": "Curvatura", - "DELETE": "Tenere premuto per rimuovere dal set corrente", - "DISABLE_GRAB": "Disabilita l'afferra", - "HINT_POINT_WINDOW": "Punta a una finestra per modificarne i parametri.\nUna volta fatto, esci dalla modalità di modifica usando il pulsante a destra.", - "INTERPOLATION": "Interpolazione", - "ALIGN_TO_HMD": "Allinea all'HMD", - "KEYBOARD": "Tastiera", - "LEAVE": "Esci dalla modalità di modifica", - "LOCK_INTERACTION": "Blocca interazione", - "MOVE_PRESS_AND_DRAG": "Muovi (premi e trascina)", - "OPACITY": "Opacità", - "POS_ANCHORED": "Ancorato: si muove con il marker centrale. Predefinito.", - "POS_FLOATING": "Galleggiante: si muove indipendentemente, si recentra quando viene visualizzato.", - "POS_HAND_L": "Segui la mano sinistra.", - "POS_HAND_R": "Segui la mano destra.", - "POS_HMD": "Segui l'HMD.", - "POS_STATIC": "Statico: non fa parte di alcun set, senza recentratura.", - "POSITIONING": "Posizionamento", - "RESIZE_PRESS_AND_DRAG": "Ridimensiona (premi e trascina)", - "STEREO_3D_MODE": { - "SPLIT_BOTTOM_TOP": "DAL BASSO→IN ALTO", - "SPLIT_LEFT_RIGHT": "SINISTRA→DESTRA", - "SPLIT_RIGHT_LEFT": "DESTRA→SINISTRA", - "SPLIT_TOP_BOTTOM": "TOP→BOTTOM", - "TITLE": "Modalità Stereo 3D", - "FULL_FRAME": "3D Completo", - "FULL_FRAME_SBS": "Full-SBS", - "FULL_FRAME_TAB": "Full-TAB", - "FULL_FRAME_BAT": "Full-BAT", - "ADJUST_MOUSE": "Regola il mouse" - }, - "MOUSE": { - "TITLE": "Correzioni del mouse", - "WRONG_SCREEN_SELECTION_HELP": "Se il cursore si sposta su uno schermo completamente diverso,\nprobabilmente gli schermi sono stati selezionati in modo errato. Consulta il file readme.", - "NORMAL": "Normale", - "ROTATE90": "Ruotato di 90°", - "ROTATE180": "Ruotato di 180°", - "ROTATE270": "Ruotato di 270°", - "FLIPPED": "Capovolto", - "FLIP90": "Ruotato di 90°", - "FLIP180": "Ruotato di 180°", - "FLIP270": "Ruotato di 270°" - }, - "GLOBAL": "Sempre visibile", - "ANGLE_FADE": "Dissolvenza angolare", - "ANGLE_FADE_HELP": "Sfumatura quando non si guarda l'HMD", - "BLOCK_INPUT": "Blocca l'input del gioco" - }, - "GRAB": { - "ADJUST_DISTANCE": "Regola la distanza", - "ADJUST_SIZE": "Regola dimensione", - "UNRESTRICTED_MOVEMENT": "Movimento libero", - "GRABBING_WATCH": "Per cambiare mano, sposta il watch davanti a te e afferralo con l'altra mano.", - "GRABBING_STATIC": "Questo overlay è Statico e rimarrà in posizione, ignorando il recenter.", - "GRABBING_ANCHORED": "Gli overlay ancorati si muovono tutti insieme. Separa una singola finestra afferrandola con l'altra mano mentre tieni ancora l'ancora.", - "GRABBING_ANCHORED_EDIT": "Questo overlay rimarrà ancorato al marcatore centrale.", - "GRABBING_FLOATING": "Questo overlay è mobile e rimarrà in posizione, a meno che non venga recentrato.", - "GRABBING_FOLLOW": "Questo overlay seguirà il dispositivo a cui è collegato." - }, - "TOAST": { - "DEFAULT_TITLE": "Notifica", - "ERROR": "Errore", - "CANNOT_ADD_SET": "Impossibile aggiungere il set!", - "MAXIMUM_SETS_REACHED": "Numero massimo di set raggiunto.", - "CANNOT_REMOVE_SET": "Impossibile rimuovere il set!", - "NO_SET_SELECTED": "Nessun set è selezionato.", - "LAST_EXISTING_SET": "Questo è l'ultimo set esistente.", - "EMPTY_SET": "Set vuoto!", - "LETS_ADD_OVERLAYS": "Aggiungiamo degli overlay dal Watch!", - "ONE_CONTROLLER_ON_FLOOR": "Posiziona un controller a terra!", - "FIXING_FLOOR_IN_X_SECS": "Correzione del pavimento in {SECONDS} secondo/i..." - }, - "WATCH": { - "ADD_NEW_SET": "Aggiungi un nuovo set", - "CLEANUP_MIRRORS": "Rimuovi gli specchi che\nnon sono attualmente visibili", - "DASHBOARD": "Pannello di controllo", - "EDIT_MODE": "Modalità modifica", - "EDIT_MODE_EXPLANATION": "Gli overlay possono ora essere spostati e regolati individualmente.\nControlla quali overlay sono visibili in questo set:", - "FIX_FLOOR": "Fissa il livello del pavimento", - "LONG_PRESS_TO_DELETE_SET": "Tenere premuto per eliminare", - "RECENTER": "Recentra l'area di gioco", - "SWITCH_TO_SET": "Passa a set", - "TOGGLE_FOR_CURRENT_SET": "Attiva/disattiva per il set corrente" - }, - "DONE": "Fatto" + "ANCHOR": { + "CENTER": "Centro" + }, + "BAR": { + "ADD_MIRROR": "Aggiungi un nuovo overlay specchio", + "EDIT_MODE_TOGGLE": "Attiva/disattiva modalità modifica", + "ADD_NEW_SET": "Aggiungi nuovo set", + "DELETE_CURRENT_SET": "Elimina set attuale", + "TOGGLE_VISIBILITY": "Alterna visibilità", + "RESET_POSITION": "Ripristina posizione", + "RELOAD_FROM_DISK": "Ricarica XML dal disco", + "CLOSE_MIRROR": "Chiudi mirror", + "CLOSE_APP": "Chiudi app", + "FORCE_CLOSE_APP": "Chiudi forzatamente l'app", + "HANDSFREE": { + "TITLE": "Modalità a mani libere", + "NONE": "Disattivato", + "HMD": "HMD + pizzico", + "EYE_TRACKING": "Occhio + pizzico", + "HMD_ONLY": "Solo HMD", + "EYE_ONLY": "Solo occhi" + } + }, + "DEFAULT": "Predefinito", + "DISABLED": "Disabilitato", + "EDIT_MODE": { + "ADJUST_CURVATURE": "Regola curvatura", + "ALPHA_BLEND_MODE": "Modalità di fusione Alpha", + "BLENDING_ADDITIVE": "Blending additivo", + "CURVATURE": "Curvatura", + "DELETE": "Tenere premuto per rimuovere dal set corrente", + "DISABLE_GRAB": "Disabilita l'afferra", + "HINT_POINT_WINDOW": "Punta a una finestra per modificarne i parametri.\nUna volta fatto, esci dalla modalità di modifica usando il pulsante a destra.", + "INTERPOLATION": "Interpolazione", + "ALIGN_TO_HMD": "Allinea all'HMD", + "KEYBOARD": "Tastiera", + "LEAVE": "Esci dalla modalità di modifica", + "LOCK_INTERACTION": "Blocca interazione", + "MOVE_PRESS_AND_DRAG": "Muovi (premi e trascina)", + "OPACITY": "Opacità", + "POS_ANCHORED": "Ancorato: si muove con il marker centrale. Predefinito.", + "POS_FLOATING": "Galleggiante: si muove indipendentemente, si recentra quando viene visualizzato.", + "POS_HAND_L": "Segui la mano sinistra.", + "POS_HAND_R": "Segui la mano destra.", + "POS_HMD": "Segui l'HMD.", + "POS_STATIC": "Statico: non fa parte di alcun set, senza recentratura.", + "POSITIONING": "Posizionamento", + "RESIZE_PRESS_AND_DRAG": "Ridimensiona (premi e trascina)", + "STEREO_3D_MODE": { + "SPLIT_BOTTOM_TOP": "DAL BASSO→IN ALTO", + "SPLIT_LEFT_RIGHT": "SINISTRA→DESTRA", + "SPLIT_RIGHT_LEFT": "DESTRA→SINISTRA", + "SPLIT_TOP_BOTTOM": "TOP→BOTTOM", + "TITLE": "Modalità Stereo 3D", + "FULL_FRAME": "3D Completo", + "FULL_FRAME_SBS": "Full-SBS", + "FULL_FRAME_TAB": "Full-TAB", + "FULL_FRAME_BAT": "Full-BAT", + "ADJUST_MOUSE": "Regola il mouse" + }, + "MOUSE": { + "TITLE": "Correzioni del mouse", + "WRONG_SCREEN_SELECTION_HELP": "Se il cursore si sposta su uno schermo completamente diverso,\nprobabilmente gli schermi sono stati selezionati in modo errato. Consulta il file readme.", + "NORMAL": "Normale", + "ROTATE90": "Ruotato di 90°", + "ROTATE180": "Ruotato di 180°", + "ROTATE270": "Ruotato di 270°", + "FLIPPED": "Capovolto", + "FLIP90": "Ruotato di 90°", + "FLIP180": "Ruotato di 180°", + "FLIP270": "Ruotato di 270°" + }, + "GLOBAL": "Sempre visibile", + "ANGLE_FADE": "Dissolvenza angolare", + "ANGLE_FADE_HELP": "Sfumatura quando non si guarda l'HMD", + "BLOCK_INPUT": "Blocca l'input del gioco" + }, + "GRAB": { + "ADJUST_DISTANCE": "Regola la distanza", + "ADJUST_SIZE": "Regola dimensione", + "UNRESTRICTED_MOVEMENT": "Movimento libero", + "GRABBING_WATCH": "Per cambiare mano, sposta il watch davanti a te e afferralo con l'altra mano.", + "GRABBING_STATIC": "Questo overlay è Statico e rimarrà in posizione, ignorando il recenter.", + "GRABBING_ANCHORED": "Gli overlay ancorati si muovono tutti insieme. Separa una singola finestra afferrandola con l'altra mano mentre tieni ancora l'ancora.", + "GRABBING_ANCHORED_EDIT": "Questo overlay rimarrà ancorato al marcatore centrale.", + "GRABBING_FLOATING": "Questo overlay è mobile e rimarrà in posizione, a meno che non venga recentrato.", + "GRABBING_FOLLOW": "Questo overlay seguirà il dispositivo a cui è collegato." + }, + "TOAST": { + "DEFAULT_TITLE": "Notifica", + "ERROR": "Errore", + "CANNOT_ADD_SET": "Impossibile aggiungere il set!", + "MAXIMUM_SETS_REACHED": "Numero massimo di set raggiunto.", + "CANNOT_REMOVE_SET": "Impossibile rimuovere il set!", + "NO_SET_SELECTED": "Nessun set è selezionato.", + "LAST_EXISTING_SET": "Questo è l'ultimo set esistente.", + "EMPTY_SET": "Set vuoto!", + "LETS_ADD_OVERLAYS": "Aggiungiamo degli overlay dal Watch!", + "ONE_CONTROLLER_ON_FLOOR": "Posiziona un controller a terra!", + "FIXING_FLOOR_IN_X_SECS": "Correzione del pavimento in {SECONDS} secondo/i..." + }, + "WATCH": { + "ADD_NEW_SET": "Aggiungi un nuovo set", + "CLEANUP_MIRRORS": "Rimuovi gli specchi che\nnon sono attualmente visibili", + "DASHBOARD": "Pannello di controllo", + "EDIT_MODE": "Modalità modifica", + "EDIT_MODE_EXPLANATION": "Gli overlay possono ora essere spostati e regolati individualmente.\nControlla quali overlay sono visibili in questo set:", + "FIX_FLOOR": "Fissa il livello del pavimento", + "LONG_PRESS_TO_DELETE_SET": "Tenere premuto per eliminare", + "RECENTER": "Recentra l'area di gioco", + "SWITCH_TO_SET": "Passa a set", + "TOGGLE_FOR_CURRENT_SET": "Attiva/disattiva per il set corrente" + }, + "DONE": "Fatto", + "WHISPER": { + "TRANSCRIBE": "Tieni premuto per trascrivere il testo", + "PASTE": "Copia trascrizione negli appunti", + "PASTE_AND_GO": "Copia trascrizione e invia Ctrl-V all'app a fuoco", + "SEND_OSC": "Invia trascrizione alla chatbox OSC", + "UNLOAD_AND_CLOSE": "Scarica modello e chiudi", + "MODEL_NOT_DOWNLOADED": "Speech-to-text non configurato!", + "DOWNLOAD_GUIDANCE": "Configura il riconoscimento vocale in impostazioni, funzionalità!", + "INITIAL_TEXT": "Tieni premuto il tasto del microfono per iniziare la trascrizione...", + "INIT_ERROR": "Errore durante l'avvio del motore Whisper" + } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/ja.json b/wayvr/src/assets/lang/ja.json index c493120a..d8e252c9 100644 --- a/wayvr/src/assets/lang/ja.json +++ b/wayvr/src/assets/lang/ja.json @@ -1,116 +1,127 @@ { - "ANCHOR": { - "CENTER": "センター" - }, - "BAR": { - "ADD_MIRROR": "新しいミラーを追加", - "EDIT_MODE_TOGGLE": "編集モードの切り替え", - "ADD_NEW_SET": "新しいセットを追加", - "DELETE_CURRENT_SET": "現在のセットを削除", - "TOGGLE_VISIBILITY": "表示/非表示の切り替え", - "RESET_POSITION": "位置をリセット", - "RELOAD_FROM_DISK": "ディスクからXMLを再読み込み", - "CLOSE_MIRROR": "ミラーを閉じる", - "CLOSE_APP": "アプリを閉じる", - "FORCE_CLOSE_APP": "アプリを強制終了", - "HANDSFREE": { - "TITLE": "ハンズフリーモード", - "NONE": "オフ", - "HMD": "HMD + ピンチ", - "EYE_TRACKING": "視線 + ピンチ", - "HMD_ONLY": "HMDのみ", - "EYE_ONLY": "目のみ" - } - }, - "WATCH": { - "RECENTER": "プレイスペースをリセンター", - "FIX_FLOOR": "床の高さを修正", - "EDIT_MODE_EXPLANATION": "オーバーレイは個別に移動および調整できます。\n現在のセットのオーバーレイを切り替え:", - "DASHBOARD": "ダッシュボード", - "EDIT_MODE": "編集モード", - "ADD_NEW_SET": "新しいセットを追加", - "SWITCH_TO_SET": "セットに切り替える", - "TOGGLE_FOR_CURRENT_SET": "現在のセットで表示を切り替え", - "LONG_PRESS_TO_DELETE_SET": "長押しでセットを削除", - "CLEANUP_MIRRORS": "現在表示されていないミラーを削除" - }, - "EDIT_MODE": { - "ADJUST_CURVATURE": "曲率の調整", - "ALPHA_BLEND_MODE": "アルファブレンド", - "BLENDING_ADDITIVE": "加算モード", - "CURVATURE": "曲率", - "DELETE": "削除", - "HINT_POINT_WINDOW": "ウィンドウをタップしてそのパラメータを変更できます。\n右側のボタンで編集モードを終了できます。", - "LEAVE": "編集モードを終了する", - "LOCK_INTERACTION": "インタラクションを無効化", - "DISABLE_GRAB": "グラブを無効化", - "MOVE_PRESS_AND_DRAG": "移動(押してドラッグ)", - "OPACITY": "不透明度", - "POSITIONING": "位置付け", - "RESIZE_PRESS_AND_DRAG": "サイズ変更(押してドラッグ)", - "POS_STATIC": "固定:置かれた場所に留まり、再センタリングされません。", - "POS_ANCHORED": "アンカー:セット内の他のウィンドウと共に移動します。デフォルト。", - "POS_FLOATING": "フローティング:置かれた場所に留まるが、表示される時に再センタリングされます。", - "POS_HMD": "HMD に追従する。", - "POS_HAND_L": "左手に追従する。", - "POS_HAND_R": "右手に追従する。", - "INTERPOLATION": "スムージング", - "KEYBOARD": "キーボード", - "STEREO_3D_MODE": { - "TITLE": "ステレオ3Dモード", - "SPLIT_LEFT_RIGHT": "左→右", - "SPLIT_RIGHT_LEFT": "右→左", - "SPLIT_TOP_BOTTOM": "上→下", - "SPLIT_BOTTOM_TOP": "下→上", - "FULL_FRAME": "フル3D", - "FULL_FRAME_SBS": "フルSBS", - "FULL_FRAME_TAB": "フルTAB", - "FULL_FRAME_BAT": "フルBAT", - "ADJUST_MOUSE": "マウス調整" - }, - "ALIGN_TO_HMD": "常にHMDの方を向く", - "MOUSE": { - "TITLE": "マウスの修正", - "WRONG_SCREEN_SELECTION_HELP": "カーソルが全く別のスクリーンで動く場合、\nスクリーンが正しく選択されていない可能性があります。\nReadmeをご参照ください。", - "NORMAL": "通常", - "FLIPPED": "ミラー", - "FLIP90": "ミラー 90°", - "FLIP180": "ミラー 180°", - "FLIP270": "ミラー 270°", - "ROTATE90": "回転 90°", - "ROTATE180": "回転 180°", - "ROTATE270": "回転 270°" - }, - "GLOBAL": "常に表示", - "ANGLE_FADE": "角度フェード", - "ANGLE_FADE_HELP": "HMDに向いていない時にフェード", - "BLOCK_INPUT": "ゲーム入力をブロック" - }, - "DISABLED": "無効", - "DEFAULT": "デフォルト", - "GRAB": { - "ADJUST_DISTANCE": "距離を調整", - "ADJUST_SIZE": "サイズ調整", - "UNRESTRICTED_MOVEMENT": "配置制限なし", - "GRABBING_WATCH": "手を入れ替えるにはウォッチを前に移動させて、もう一方の手で掴んでください。", - "GRABBING_STATIC": "このオーバーレイは固定であり、リセンターを無視してその場に留まります。", - "GRABBING_ANCHORED": "アンカーされたオーバーレイはすべて一緒に移動します。もう一方の手で掴んでアンカーを掴んだまま、単一のウィンドウを分離できます。", - "GRABBING_ANCHORED_EDIT": "このオーバーレイは、センター・マーカーに固定されます。", - "GRABBING_FLOATING": "このオーバーレイはフローティングのため、リセンターまではその場所に留まります。", - "GRABBING_FOLLOW": "このオーバーレイは、接続されているデバイスに追従します。" - }, - "TOAST": { - "DEFAULT_TITLE": "通知", - "ERROR": "エラー", - "CANNOT_REMOVE_SET": "セットを削除できません!", - "NO_SET_SELECTED": "セットが選択されていません。", - "LAST_EXISTING_SET": "これが最後の設定です。", - "EMPTY_SET": "空のセットです!", - "LETS_ADD_OVERLAYS": "ウォッチからオーバーレイを追加しましょう!", - "ONE_CONTROLLER_ON_FLOOR": "コントローラーを床に置いてください!", - "CANNOT_ADD_SET": "セットを追加できません!", - "MAXIMUM_SETS_REACHED": "最大セット数に達しました。", - "FIXING_FLOOR_IN_X_SECS": "{SECONDS}秒で床を固定中..." - }, - "DONE": "完了" + "ANCHOR": { + "CENTER": "センター" + }, + "BAR": { + "ADD_MIRROR": "新しいミラーを追加", + "EDIT_MODE_TOGGLE": "編集モードの切り替え", + "ADD_NEW_SET": "新しいセットを追加", + "DELETE_CURRENT_SET": "現在のセットを削除", + "TOGGLE_VISIBILITY": "表示/非表示の切り替え", + "RESET_POSITION": "位置をリセット", + "RELOAD_FROM_DISK": "ディスクからXMLを再読み込み", + "CLOSE_MIRROR": "ミラーを閉じる", + "CLOSE_APP": "アプリを閉じる", + "FORCE_CLOSE_APP": "アプリを強制終了", + "HANDSFREE": { + "TITLE": "ハンズフリーモード", + "NONE": "オフ", + "HMD": "HMD + ピンチ", + "EYE_TRACKING": "視線 + ピンチ", + "HMD_ONLY": "HMDのみ", + "EYE_ONLY": "目のみ" + } + }, + "WATCH": { + "RECENTER": "プレイスペースをリセンター", + "FIX_FLOOR": "床の高さを修正", + "EDIT_MODE_EXPLANATION": "オーバーレイは個別に移動および調整できます。\n現在のセットのオーバーレイを切り替え:", + "DASHBOARD": "ダッシュボード", + "EDIT_MODE": "編集モード", + "ADD_NEW_SET": "新しいセットを追加", + "SWITCH_TO_SET": "セットに切り替える", + "TOGGLE_FOR_CURRENT_SET": "現在のセットで表示を切り替え", + "LONG_PRESS_TO_DELETE_SET": "長押しでセットを削除", + "CLEANUP_MIRRORS": "現在表示されていないミラーを削除" + }, + "EDIT_MODE": { + "ADJUST_CURVATURE": "曲率の調整", + "ALPHA_BLEND_MODE": "アルファブレンド", + "BLENDING_ADDITIVE": "加算モード", + "CURVATURE": "曲率", + "DELETE": "削除", + "HINT_POINT_WINDOW": "ウィンドウをタップしてそのパラメータを変更できます。\n右側のボタンで編集モードを終了できます。", + "LEAVE": "編集モードを終了する", + "LOCK_INTERACTION": "インタラクションを無効化", + "DISABLE_GRAB": "グラブを無効化", + "MOVE_PRESS_AND_DRAG": "移動(押してドラッグ)", + "OPACITY": "不透明度", + "POSITIONING": "位置付け", + "RESIZE_PRESS_AND_DRAG": "サイズ変更(押してドラッグ)", + "POS_STATIC": "固定:置かれた場所に留まり、再センタリングされません。", + "POS_ANCHORED": "アンカー:セット内の他のウィンドウと共に移動します。デフォルト。", + "POS_FLOATING": "フローティング:置かれた場所に留まるが、表示される時に再センタリングされます。", + "POS_HMD": "HMD に追従する。", + "POS_HAND_L": "左手に追従する。", + "POS_HAND_R": "右手に追従する。", + "INTERPOLATION": "スムージング", + "KEYBOARD": "キーボード", + "STEREO_3D_MODE": { + "TITLE": "ステレオ3Dモード", + "SPLIT_LEFT_RIGHT": "左→右", + "SPLIT_RIGHT_LEFT": "右→左", + "SPLIT_TOP_BOTTOM": "上→下", + "SPLIT_BOTTOM_TOP": "下→上", + "FULL_FRAME": "フル3D", + "FULL_FRAME_SBS": "フルSBS", + "FULL_FRAME_TAB": "フルTAB", + "FULL_FRAME_BAT": "フルBAT", + "ADJUST_MOUSE": "マウス調整" + }, + "ALIGN_TO_HMD": "常にHMDの方を向く", + "MOUSE": { + "TITLE": "マウスの修正", + "WRONG_SCREEN_SELECTION_HELP": "カーソルが全く別のスクリーンで動く場合、\nスクリーンが正しく選択されていない可能性があります。\nReadmeをご参照ください。", + "NORMAL": "通常", + "FLIPPED": "ミラー", + "FLIP90": "ミラー 90°", + "FLIP180": "ミラー 180°", + "FLIP270": "ミラー 270°", + "ROTATE90": "回転 90°", + "ROTATE180": "回転 180°", + "ROTATE270": "回転 270°" + }, + "GLOBAL": "常に表示", + "ANGLE_FADE": "角度フェード", + "ANGLE_FADE_HELP": "HMDに向いていない時にフェード", + "BLOCK_INPUT": "ゲーム入力をブロック" + }, + "DISABLED": "無効", + "DEFAULT": "デフォルト", + "GRAB": { + "ADJUST_DISTANCE": "距離を調整", + "ADJUST_SIZE": "サイズ調整", + "UNRESTRICTED_MOVEMENT": "配置制限なし", + "GRABBING_WATCH": "手を入れ替えるにはウォッチを前に移動させて、もう一方の手で掴んでください。", + "GRABBING_STATIC": "このオーバーレイは固定であり、リセンターを無視してその場に留まります。", + "GRABBING_ANCHORED": "アンカーされたオーバーレイはすべて一緒に移動します。もう一方の手で掴んでアンカーを掴んだまま、単一のウィンドウを分離できます。", + "GRABBING_ANCHORED_EDIT": "このオーバーレイは、センター・マーカーに固定されます。", + "GRABBING_FLOATING": "このオーバーレイはフローティングのため、リセンターまではその場所に留まります。", + "GRABBING_FOLLOW": "このオーバーレイは、接続されているデバイスに追従します。" + }, + "TOAST": { + "DEFAULT_TITLE": "通知", + "ERROR": "エラー", + "CANNOT_REMOVE_SET": "セットを削除できません!", + "NO_SET_SELECTED": "セットが選択されていません。", + "LAST_EXISTING_SET": "これが最後の設定です。", + "EMPTY_SET": "空のセットです!", + "LETS_ADD_OVERLAYS": "ウォッチからオーバーレイを追加しましょう!", + "ONE_CONTROLLER_ON_FLOOR": "コントローラーを床に置いてください!", + "CANNOT_ADD_SET": "セットを追加できません!", + "MAXIMUM_SETS_REACHED": "最大セット数に達しました。", + "FIXING_FLOOR_IN_X_SECS": "{SECONDS}秒で床を固定中..." + }, + "DONE": "完了", + "WHISPER": { + "TRANSCRIBE": "ボタンを長押ししてテキストを文字起こしする", + "PASTE": "文字起こしをクリップボードにコピー", + "PASTE_AND_GO": "文字起こしをコピーし、フォーカスされているアプリにCtrl-Vを送信する", + "SEND_OSC": "文字起こしをOSCチャットボックスに送信する", + "UNLOAD_AND_CLOSE": "モデルをアンロードして閉じる", + "MODEL_NOT_DOWNLOADED": "音声認識が設定されていません!", + "DOWNLOAD_GUIDANCE": "設定の「機能」から音声入力(speech-to-text)をセットアップしてください!", + "INITIAL_TEXT": "マイクボタンを押し続けて文字起こしを開始してください...", + "INIT_ERROR": "Whisperエンジンの起動中にエラーが発生しました" + } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/pl.json b/wayvr/src/assets/lang/pl.json index 0d3d1fca..e8770625 100644 --- a/wayvr/src/assets/lang/pl.json +++ b/wayvr/src/assets/lang/pl.json @@ -1,116 +1,127 @@ { - "ANCHOR": { - "CENTER": "Centrum" - }, - "BAR": { - "ADD_MIRROR": "Dodaj nowy widok lustrzany", - "EDIT_MODE_TOGGLE": "Przełącz tryb edycji", - "ADD_NEW_SET": "Dodaj nowy zestaw", - "DELETE_CURRENT_SET": "Usuń aktualny zestaw", - "TOGGLE_VISIBILITY": "Przełącz widoczność", - "RESET_POSITION": "Zresetuj pozycję", - "RELOAD_FROM_DISK": "Przeładuj XML z dysku", - "CLOSE_MIRROR": "Zamknij lustro", - "CLOSE_APP": "Zamknij aplikację", - "FORCE_CLOSE_APP": "Wymuś zamknięcie aplikacji", - "HANDSFREE": { - "TITLE": "Tryb wolnych rąk", - "NONE": "Wyłączone", - "HMD": "HMD + uszczypnięcie", - "EYE_TRACKING": "Oko + uszczypnięcie", - "HMD_ONLY": "Tylko HMD", - "EYE_ONLY": "Tylko oczy" - } - }, - "WATCH": { - "RECENTER": "Wyśrodkuj przestrzeń gry", - "FIX_FLOOR": "Napraw wysokość podłogi", - "EDIT_MODE_EXPLANATION": "Nakładki można teraz przesuwać i dostosowywać indywidualnie.\nPrzełącz nakładki dla bieżącego zestawu:", - "DASHBOARD": "Pulpit", - "EDIT_MODE": "Tryb edycji", - "ADD_NEW_SET": "Dodaj nowy zestaw", - "SWITCH_TO_SET": "Przełącz na zestaw", - "TOGGLE_FOR_CURRENT_SET": "Przełącz widoczność w bieżącym zestawie", - "LONG_PRESS_TO_DELETE_SET": "Przytrzymaj, aby usunąć zestaw", - "CLEANUP_MIRRORS": "Usuń lustra, które\nnie są obecnie widoczne" - }, - "EDIT_MODE": { - "ADJUST_CURVATURE": "Dostosuj zakrzywienie", - "ALPHA_BLEND_MODE": "Tryb mieszania kolorów", - "BLENDING_ADDITIVE": "Addytywne", - "CURVATURE": "Zakrzywienie", - "DELETE": "Usuń", - "HINT_POINT_WINDOW": "Wskaż na okno, aby zmienić jego parametry.\nPo zakończeniu wyjdź z trybu edycji za pomocą przycisku po prawej stronie.", - "LEAVE": "Wyjdź z trybu edycji", - "LOCK_INTERACTION": "Zablokuj interakcję", - "DISABLE_GRAB": "Wyłącz chwytanie", - "MOVE_PRESS_AND_DRAG": "Przesuń (naciśnij i przeciągnij)", - "OPACITY": "Przezroczystość", - "POSITIONING": "Pozycjonowanie", - "BLOCK_INPUT": "Blokuj input z gry", - "RESIZE_PRESS_AND_DRAG": "Zmień rozmiar (naciśnij i przeciągnij)", - "POS_STATIC": "Statyczne: Pozostaje w miejscu i nigdy nie jest ponownie wyśrodkowywane.", - "POS_ANCHORED": "Zakotwiczone: Porusza się razem z resztą zestawu. Domyślne.", - "POS_FLOATING": "Pływające: Pozostaje w miejscu, ponownie się wyśrodkowuje po pokazaniu.", - "POS_HMD": "Podążaj za HMD.", - "POS_HAND_L": "Podążaj za lewą dłonią.", - "POS_HAND_R": "Podążaj za prawą dłonią.", - "INTERPOLATION": "Interpolacja", - "KEYBOARD": "Klawiatura", - "STEREO_3D_MODE": { - "SPLIT_BOTTOM_TOP": "DOŁ→GÓRA", - "SPLIT_LEFT_RIGHT": "LEWO→PRAWO", - "SPLIT_RIGHT_LEFT": "PRAWO→LEWO", - "SPLIT_TOP_BOTTOM": "GÓRA→DÓŁ", - "TITLE": "Tryb stereoskopowy 3D", - "FULL_FRAME": "Pełny 3D", - "FULL_FRAME_SBS": "Full-SBS", - "FULL_FRAME_TAB": "Full-TAB", - "FULL_FRAME_BAT": "Full-BAT", - "ADJUST_MOUSE": "Dostosuj mysz" - }, - "ALIGN_TO_HMD": "Dopasuj do HMD", - "MOUSE": { - "TITLE": "Naprawa myszy", - "WRONG_SCREEN_SELECTION_HELP": "Jeśli kursor porusza się na zupełnie innym ekranie,\nekrany zostały prawdopodobnie nieprawidłowo wybrane. Zobacz dokument README.", - "NORMAL": "Normalny", - "ROTATE90": "Obrócony o 90°", - "ROTATE180": "Obrócony o 180°", - "ROTATE270": "Obrócony o 270°", - "FLIPPED": "Odwrócony", - "FLIP90": "Odwrócony o 90°", - "FLIP180": "Odwrócony o 180°", - "FLIP270": "Odwrócony o 270°" - }, - "GLOBAL": "Zawsze widoczne", - "ANGLE_FADE": "Zanik kątowy", - "ANGLE_FADE_HELP": "Wyblaknij, gdy nie patrzysz na HMD" - }, - "DISABLED": "Wyłączone", - "DEFAULT": "Domyślny", - "GRAB": { - "ADJUST_DISTANCE": "Dostosuj odległość", - "ADJUST_SIZE": "Dostosuj rozmiar", - "UNRESTRICTED_MOVEMENT": "Nieograniczony ruch", - "GRABBING_WATCH": "Aby zamienić ręce, przenieś zegarek na przód i chwyć go drugą ręką.", - "GRABBING_STATIC": "Ta nakładka jest statyczna i pozostanie na swoim miejscu, ignorując centrowanie.", - "GRABBING_ANCHORED": "Przymocowane nakładki poruszają się razem. Aby oddzielić pojedyncze okno, chwyć je drugą ręką, podczas gdy nadal trzymasz kotwicę.", - "GRABBING_ANCHORED_EDIT": "Ta nakładka pozostanie zakotwiczona do znacznika centralnego.", - "GRABBING_FLOATING": "Ta nakładka jest zawieszony i pozostanie na miejscu, chyba że zostanie wycentrowany.", - "GRABBING_FOLLOW": "Ta nakładka będzie śledzić urządzenie, do którego jest dołączona." - }, - "TOAST": { - "DEFAULT_TITLE": "Powiadomienie", - "ERROR": "Błąd", - "CANNOT_REMOVE_SET": "Nie można usunąć zestawu!", - "NO_SET_SELECTED": "Nie wybrano żadnego zestawu.", - "LAST_EXISTING_SET": "To jest ostatni istniejący zestaw.", - "EMPTY_SET": "Pusty zestaw!", - "LETS_ADD_OVERLAYS": "Dodajmy kilka nakładek z zegarka!", - "ONE_CONTROLLER_ON_FLOOR": "Umieść jeden kontroler na podłodze!", - "CANNOT_ADD_SET": "Nie można dodać zestawu!", - "MAXIMUM_SETS_REACHED": "Osiągnięto maksymalną liczbę zestawów.", - "FIXING_FLOOR_IN_X_SECS": "Naprawianie podłogi za {SECONDS} sekund(y)..." - }, - "DONE": "Gotowe" + "ANCHOR": { + "CENTER": "Centrum" + }, + "BAR": { + "ADD_MIRROR": "Dodaj nowy widok lustrzany", + "EDIT_MODE_TOGGLE": "Przełącz tryb edycji", + "ADD_NEW_SET": "Dodaj nowy zestaw", + "DELETE_CURRENT_SET": "Usuń aktualny zestaw", + "TOGGLE_VISIBILITY": "Przełącz widoczność", + "RESET_POSITION": "Zresetuj pozycję", + "RELOAD_FROM_DISK": "Przeładuj XML z dysku", + "CLOSE_MIRROR": "Zamknij lustro", + "CLOSE_APP": "Zamknij aplikację", + "FORCE_CLOSE_APP": "Wymuś zamknięcie aplikacji", + "HANDSFREE": { + "TITLE": "Tryb wolnych rąk", + "NONE": "Wyłączone", + "HMD": "HMD + uszczypnięcie", + "EYE_TRACKING": "Oko + uszczypnięcie", + "HMD_ONLY": "Tylko HMD", + "EYE_ONLY": "Tylko oczy" + } + }, + "WATCH": { + "RECENTER": "Wyśrodkuj przestrzeń gry", + "FIX_FLOOR": "Napraw wysokość podłogi", + "EDIT_MODE_EXPLANATION": "Nakładki można teraz przesuwać i dostosowywać indywidualnie.\nPrzełącz nakładki dla bieżącego zestawu:", + "DASHBOARD": "Pulpit", + "EDIT_MODE": "Tryb edycji", + "ADD_NEW_SET": "Dodaj nowy zestaw", + "SWITCH_TO_SET": "Przełącz na zestaw", + "TOGGLE_FOR_CURRENT_SET": "Przełącz widoczność w bieżącym zestawie", + "LONG_PRESS_TO_DELETE_SET": "Przytrzymaj, aby usunąć zestaw", + "CLEANUP_MIRRORS": "Usuń lustra, które\nnie są obecnie widoczne" + }, + "EDIT_MODE": { + "ADJUST_CURVATURE": "Dostosuj zakrzywienie", + "ALPHA_BLEND_MODE": "Tryb mieszania kolorów", + "BLENDING_ADDITIVE": "Addytywne", + "CURVATURE": "Zakrzywienie", + "DELETE": "Usuń", + "HINT_POINT_WINDOW": "Wskaż na okno, aby zmienić jego parametry.\nPo zakończeniu wyjdź z trybu edycji za pomocą przycisku po prawej stronie.", + "LEAVE": "Wyjdź z trybu edycji", + "LOCK_INTERACTION": "Zablokuj interakcję", + "DISABLE_GRAB": "Wyłącz chwytanie", + "MOVE_PRESS_AND_DRAG": "Przesuń (naciśnij i przeciągnij)", + "OPACITY": "Przezroczystość", + "POSITIONING": "Pozycjonowanie", + "BLOCK_INPUT": "Blokuj input z gry", + "RESIZE_PRESS_AND_DRAG": "Zmień rozmiar (naciśnij i przeciągnij)", + "POS_STATIC": "Statyczne: Pozostaje w miejscu i nigdy nie jest ponownie wyśrodkowywane.", + "POS_ANCHORED": "Zakotwiczone: Porusza się razem z resztą zestawu. Domyślne.", + "POS_FLOATING": "Pływające: Pozostaje w miejscu, ponownie się wyśrodkowuje po pokazaniu.", + "POS_HMD": "Podążaj za HMD.", + "POS_HAND_L": "Podążaj za lewą dłonią.", + "POS_HAND_R": "Podążaj za prawą dłonią.", + "INTERPOLATION": "Interpolacja", + "KEYBOARD": "Klawiatura", + "STEREO_3D_MODE": { + "SPLIT_BOTTOM_TOP": "DOŁ→GÓRA", + "SPLIT_LEFT_RIGHT": "LEWO→PRAWO", + "SPLIT_RIGHT_LEFT": "PRAWO→LEWO", + "SPLIT_TOP_BOTTOM": "GÓRA→DÓŁ", + "TITLE": "Tryb stereoskopowy 3D", + "FULL_FRAME": "Pełny 3D", + "FULL_FRAME_SBS": "Full-SBS", + "FULL_FRAME_TAB": "Full-TAB", + "FULL_FRAME_BAT": "Full-BAT", + "ADJUST_MOUSE": "Dostosuj mysz" + }, + "ALIGN_TO_HMD": "Dopasuj do HMD", + "MOUSE": { + "TITLE": "Naprawa myszy", + "WRONG_SCREEN_SELECTION_HELP": "Jeśli kursor porusza się na zupełnie innym ekranie,\nekrany zostały prawdopodobnie nieprawidłowo wybrane. Zobacz dokument README.", + "NORMAL": "Normalny", + "ROTATE90": "Obrócony o 90°", + "ROTATE180": "Obrócony o 180°", + "ROTATE270": "Obrócony o 270°", + "FLIPPED": "Odwrócony", + "FLIP90": "Odwrócony o 90°", + "FLIP180": "Odwrócony o 180°", + "FLIP270": "Odwrócony o 270°" + }, + "GLOBAL": "Zawsze widoczne", + "ANGLE_FADE": "Zanik kątowy", + "ANGLE_FADE_HELP": "Wyblaknij, gdy nie patrzysz na HMD" + }, + "DISABLED": "Wyłączone", + "DEFAULT": "Domyślny", + "GRAB": { + "ADJUST_DISTANCE": "Dostosuj odległość", + "ADJUST_SIZE": "Dostosuj rozmiar", + "UNRESTRICTED_MOVEMENT": "Nieograniczony ruch", + "GRABBING_WATCH": "Aby zamienić ręce, przenieś zegarek na przód i chwyć go drugą ręką.", + "GRABBING_STATIC": "Ta nakładka jest statyczna i pozostanie na swoim miejscu, ignorując centrowanie.", + "GRABBING_ANCHORED": "Przymocowane nakładki poruszają się razem. Aby oddzielić pojedyncze okno, chwyć je drugą ręką, podczas gdy nadal trzymasz kotwicę.", + "GRABBING_ANCHORED_EDIT": "Ta nakładka pozostanie zakotwiczona do znacznika centralnego.", + "GRABBING_FLOATING": "Ta nakładka jest zawieszony i pozostanie na miejscu, chyba że zostanie wycentrowany.", + "GRABBING_FOLLOW": "Ta nakładka będzie śledzić urządzenie, do którego jest dołączona." + }, + "TOAST": { + "DEFAULT_TITLE": "Powiadomienie", + "ERROR": "Błąd", + "CANNOT_REMOVE_SET": "Nie można usunąć zestawu!", + "NO_SET_SELECTED": "Nie wybrano żadnego zestawu.", + "LAST_EXISTING_SET": "To jest ostatni istniejący zestaw.", + "EMPTY_SET": "Pusty zestaw!", + "LETS_ADD_OVERLAYS": "Dodajmy kilka nakładek z zegarka!", + "ONE_CONTROLLER_ON_FLOOR": "Umieść jeden kontroler na podłodze!", + "CANNOT_ADD_SET": "Nie można dodać zestawu!", + "MAXIMUM_SETS_REACHED": "Osiągnięto maksymalną liczbę zestawów.", + "FIXING_FLOOR_IN_X_SECS": "Naprawianie podłogi za {SECONDS} sekund(y)..." + }, + "DONE": "Gotowe", + "WHISPER": { + "TRANSCRIBE": "Naciśnij i przytrzymaj, aby transkrybować tekst", + "PASTE": "Kopiuj transkrypcję do schowka", + "PASTE_AND_GO": "Kopiuj transkrypcję i wyślij Ctrl-V do aktywnej aplikacji", + "SEND_OSC": "Wyślij transkrypcję do okna czatu OSC", + "UNLOAD_AND_CLOSE": "Odładuj model i zamknij", + "MODEL_NOT_DOWNLOADED": "Zamiana mowy na tekst nie została skonfigurowana!", + "DOWNLOAD_GUIDANCE": "Skonfiguruj funkcję zamiany mowy na tekst w ustawieniach, w sekcji funkcje!", + "INITIAL_TEXT": "Naciśnij i przytrzymaj przycisk mikrofonu, aby rozpocząć transkrypcję...", + "INIT_ERROR": "Błąd podczas uruchamiania silnika Whisper" + } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/zh_CN.json b/wayvr/src/assets/lang/zh_CN.json index eda4e52a..9f19c259 100644 --- a/wayvr/src/assets/lang/zh_CN.json +++ b/wayvr/src/assets/lang/zh_CN.json @@ -1,116 +1,127 @@ { - "ANCHOR": { - "CENTER": "中心" - }, - "BAR": { - "ADD_MIRROR": "添加新的镜像覆盖层", - "EDIT_MODE_TOGGLE": "切换编辑模式", - "ADD_NEW_SET": "添加新集合", - "DELETE_CURRENT_SET": "删除当前集合", - "TOGGLE_VISIBILITY": "切换可见性", - "RESET_POSITION": "重置位置", - "RELOAD_FROM_DISK": "从磁盘重新加载 XML", - "CLOSE_MIRROR": "关闭镜像", - "CLOSE_APP": "关闭应用", - "FORCE_CLOSE_APP": "强制关闭应用", - "HANDSFREE": { - "TITLE": "免提模式", - "NONE": "关闭", - "HMD": "HMD + 捏合", - "EYE_TRACKING": "眼动 + 捏合", - "HMD_ONLY": "仅 HMD", - "EYE_ONLY": "仅限眼睛" - } - }, - "DEFAULT": "默认", - "DISABLED": "已禁用", - "EDIT_MODE": { - "ADJUST_CURVATURE": "调整曲率", - "ALPHA_BLEND_MODE": "Alpha 混合模式", - "BLENDING_ADDITIVE": "叠加混合", - "CURVATURE": "曲率", - "DELETE": "长按以从当前集合中移除", - "DISABLE_GRAB": "禁用抓取", - "HINT_POINT_WINDOW": "指向窗口以更改其参数。\n完成后,使用右侧按钮退出编辑模式。", - "INTERPOLATION": "插值", - "ALIGN_TO_HMD": "对齐到头显 (HMD)", - "KEYBOARD": "键盘", - "LEAVE": "离开编辑模式", - "LOCK_INTERACTION": "锁定交互", - "MOVE_PRESS_AND_DRAG": "移动 (按住并拖拽)", - "OPACITY": "不透明度", - "POS_ANCHORED": "锚定:随中心标记移动。默认。", - "POS_FLOATING": "悬浮:独立移动,显示时重新居中。", - "POS_HAND_L": "跟随左手。", - "POS_HAND_R": "跟随右手。", - "POS_HMD": "跟随头显 (HMD)。", - "POS_STATIC": "静态:不属于任何集合,不重新居中。", - "POSITIONING": "定位", - "RESIZE_PRESS_AND_DRAG": "调整大小 (按住并拖拽)", - "STEREO_3D_MODE": { - "SPLIT_BOTTOM_TOP": "下→上", - "SPLIT_LEFT_RIGHT": "左→右", - "SPLIT_RIGHT_LEFT": "右→左", - "SPLIT_TOP_BOTTOM": "上→下", - "TITLE": "3D 立体模式", - "FULL_FRAME": "完整 3D", - "FULL_FRAME_SBS": "完整左右 (Full-SBS)", - "FULL_FRAME_TAB": "完整上下 (Full-TAB)", - "FULL_FRAME_BAT": "完整下上 (Full-BAT)", - "ADJUST_MOUSE": "调整鼠标" - }, - "MOUSE": { - "TITLE": "鼠标修复", - "WRONG_SCREEN_SELECTION_HELP": "如果光标在完全不同的屏幕上移动,\n可能是屏幕选择错误。请参阅 readme。", - "NORMAL": "正常", - "ROTATE90": "旋转 90°", - "ROTATE180": "旋转 180°", - "ROTATE270": "旋转 270°", - "FLIPPED": "镜像", - "FLIP90": "镜像旋转 90°", - "FLIP180": "镜像旋转 180°", - "FLIP270": "镜像旋转 270°" - }, - "GLOBAL": "始终可见", - "ANGLE_FADE": "角度淡出", - "ANGLE_FADE_HELP": "未朝向HMD时淡出", - "BLOCK_INPUT": "阻止游戏输入" - }, - "GRAB": { - "ADJUST_DISTANCE": "调整距离", - "ADJUST_SIZE": "调整大小", - "UNRESTRICTED_MOVEMENT": "无限制移动", - "GRABBING_WATCH": "若要更换手,请将手表移至前方并用另一只手抓取。", - "GRABBING_STATIC": "此覆盖层是静态的,将固定在原位,忽略重新居中。", - "GRABBING_ANCHORED": "锚定覆盖层将一起移动。通过用另一只手抓取单个窗口,同时仍抓取锚点来分离它。", - "GRABBING_ANCHORED_EDIT": "此覆盖层将保持锚定在中心标记上。", - "GRABBING_FLOATING": "此覆盖层是悬浮的,将固定在原位,除非重新居中。", - "GRABBING_FOLLOW": "此覆盖层将跟随它所连接的设备。" - }, - "TOAST": { - "DEFAULT_TITLE": "通知", - "ERROR": "错误", - "CANNOT_ADD_SET": "无法添加集合!", - "MAXIMUM_SETS_REACHED": "已达到集合数量上限。", - "CANNOT_REMOVE_SET": "无法移除集合!", - "NO_SET_SELECTED": "未选择集合。", - "LAST_EXISTING_SET": "这是最后一个现有的集合。", - "EMPTY_SET": "空集合!", - "LETS_ADD_OVERLAYS": "让我们从手表添加一些覆盖层吧!", - "ONE_CONTROLLER_ON_FLOOR": "请将一个控制器放在地面上!", - "FIXING_FLOOR_IN_X_SECS": "{SECONDS}秒内固定地面..." - }, - "WATCH": { - "ADD_NEW_SET": "添加新集合", - "CLEANUP_MIRRORS": "移除当前不可见的镜像", - "DASHBOARD": "控制面板 (Dashboard)", - "EDIT_MODE": "编辑模式", - "EDIT_MODE_EXPLANATION": "覆盖层现在可以单独移动和调整。\n控制此集合中哪些覆盖层可见:", - "FIX_FLOOR": "修复地面水平", - "LONG_PRESS_TO_DELETE_SET": "按住以删除", - "RECENTER": "重置游玩区中心 (Recenter)", - "SWITCH_TO_SET": "切换到集合", - "TOGGLE_FOR_CURRENT_SET": "切换当前集合" - }, - "DONE": "完成" + "ANCHOR": { + "CENTER": "中心" + }, + "BAR": { + "ADD_MIRROR": "添加新的镜像覆盖层", + "EDIT_MODE_TOGGLE": "切换编辑模式", + "ADD_NEW_SET": "添加新集合", + "DELETE_CURRENT_SET": "删除当前集合", + "TOGGLE_VISIBILITY": "切换可见性", + "RESET_POSITION": "重置位置", + "RELOAD_FROM_DISK": "从磁盘重新加载 XML", + "CLOSE_MIRROR": "关闭镜像", + "CLOSE_APP": "关闭应用", + "FORCE_CLOSE_APP": "强制关闭应用", + "HANDSFREE": { + "TITLE": "免提模式", + "NONE": "关闭", + "HMD": "HMD + 捏合", + "EYE_TRACKING": "眼动 + 捏合", + "HMD_ONLY": "仅 HMD", + "EYE_ONLY": "仅限眼睛" + } + }, + "DEFAULT": "默认", + "DISABLED": "已禁用", + "EDIT_MODE": { + "ADJUST_CURVATURE": "调整曲率", + "ALPHA_BLEND_MODE": "Alpha 混合模式", + "BLENDING_ADDITIVE": "叠加混合", + "CURVATURE": "曲率", + "DELETE": "长按以从当前集合中移除", + "DISABLE_GRAB": "禁用抓取", + "HINT_POINT_WINDOW": "指向窗口以更改其参数。\n完成后,使用右侧按钮退出编辑模式。", + "INTERPOLATION": "插值", + "ALIGN_TO_HMD": "对齐到头显 (HMD)", + "KEYBOARD": "键盘", + "LEAVE": "离开编辑模式", + "LOCK_INTERACTION": "锁定交互", + "MOVE_PRESS_AND_DRAG": "移动 (按住并拖拽)", + "OPACITY": "不透明度", + "POS_ANCHORED": "锚定:随中心标记移动。默认。", + "POS_FLOATING": "悬浮:独立移动,显示时重新居中。", + "POS_HAND_L": "跟随左手。", + "POS_HAND_R": "跟随右手。", + "POS_HMD": "跟随头显 (HMD)。", + "POS_STATIC": "静态:不属于任何集合,不重新居中。", + "POSITIONING": "定位", + "RESIZE_PRESS_AND_DRAG": "调整大小 (按住并拖拽)", + "STEREO_3D_MODE": { + "SPLIT_BOTTOM_TOP": "下→上", + "SPLIT_LEFT_RIGHT": "左→右", + "SPLIT_RIGHT_LEFT": "右→左", + "SPLIT_TOP_BOTTOM": "上→下", + "TITLE": "3D 立体模式", + "FULL_FRAME": "完整 3D", + "FULL_FRAME_SBS": "完整左右 (Full-SBS)", + "FULL_FRAME_TAB": "完整上下 (Full-TAB)", + "FULL_FRAME_BAT": "完整下上 (Full-BAT)", + "ADJUST_MOUSE": "调整鼠标" + }, + "MOUSE": { + "TITLE": "鼠标修复", + "WRONG_SCREEN_SELECTION_HELP": "如果光标在完全不同的屏幕上移动,\n可能是屏幕选择错误。请参阅 readme。", + "NORMAL": "正常", + "ROTATE90": "旋转 90°", + "ROTATE180": "旋转 180°", + "ROTATE270": "旋转 270°", + "FLIPPED": "镜像", + "FLIP90": "镜像旋转 90°", + "FLIP180": "镜像旋转 180°", + "FLIP270": "镜像旋转 270°" + }, + "GLOBAL": "始终可见", + "ANGLE_FADE": "角度淡出", + "ANGLE_FADE_HELP": "未朝向HMD时淡出", + "BLOCK_INPUT": "阻止游戏输入" + }, + "GRAB": { + "ADJUST_DISTANCE": "调整距离", + "ADJUST_SIZE": "调整大小", + "UNRESTRICTED_MOVEMENT": "无限制移动", + "GRABBING_WATCH": "若要更换手,请将手表移至前方并用另一只手抓取。", + "GRABBING_STATIC": "此覆盖层是静态的,将固定在原位,忽略重新居中。", + "GRABBING_ANCHORED": "锚定覆盖层将一起移动。通过用另一只手抓取单个窗口,同时仍抓取锚点来分离它。", + "GRABBING_ANCHORED_EDIT": "此覆盖层将保持锚定在中心标记上。", + "GRABBING_FLOATING": "此覆盖层是悬浮的,将固定在原位,除非重新居中。", + "GRABBING_FOLLOW": "此覆盖层将跟随它所连接的设备。" + }, + "TOAST": { + "DEFAULT_TITLE": "通知", + "ERROR": "错误", + "CANNOT_ADD_SET": "无法添加集合!", + "MAXIMUM_SETS_REACHED": "已达到集合数量上限。", + "CANNOT_REMOVE_SET": "无法移除集合!", + "NO_SET_SELECTED": "未选择集合。", + "LAST_EXISTING_SET": "这是最后一个现有的集合。", + "EMPTY_SET": "空集合!", + "LETS_ADD_OVERLAYS": "让我们从手表添加一些覆盖层吧!", + "ONE_CONTROLLER_ON_FLOOR": "请将一个控制器放在地面上!", + "FIXING_FLOOR_IN_X_SECS": "{SECONDS}秒内固定地面..." + }, + "WATCH": { + "ADD_NEW_SET": "添加新集合", + "CLEANUP_MIRRORS": "移除当前不可见的镜像", + "DASHBOARD": "控制面板 (Dashboard)", + "EDIT_MODE": "编辑模式", + "EDIT_MODE_EXPLANATION": "覆盖层现在可以单独移动和调整。\n控制此集合中哪些覆盖层可见:", + "FIX_FLOOR": "修复地面水平", + "LONG_PRESS_TO_DELETE_SET": "按住以删除", + "RECENTER": "重置游玩区中心 (Recenter)", + "SWITCH_TO_SET": "切换到集合", + "TOGGLE_FOR_CURRENT_SET": "切换当前集合" + }, + "DONE": "完成", + "WHISPER": { + "TRANSCRIBE": "按住以转录文本", + "PASTE": "将转录内容复制到剪贴板", + "PASTE_AND_GO": "复制转录文本并向当前聚焦的应用发送 Ctrl-V", + "SEND_OSC": "将转录文本发送至 OSC 聊天框", + "UNLOAD_AND_CLOSE": "卸载模型并关闭", + "MODEL_NOT_DOWNLOADED": "语音转文本未设置!", + "DOWNLOAD_GUIDANCE": "请在“设置”中的“功能”选项下配置语音转文本!", + "INITIAL_TEXT": "按住麦克风按钮开始转录...", + "INIT_ERROR": "启动 Whisper 引擎时出错" + } } \ No newline at end of file From 60e98810d97732951926facfa5489b6fce21133e Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:44:22 +0900 Subject: [PATCH 12/19] update translations (dash) --- dash-frontend/assets/lang/de.json | 11 +++++++++-- dash-frontend/assets/lang/es.json | 11 +++++++++-- dash-frontend/assets/lang/it.json | 11 +++++++++-- dash-frontend/assets/lang/ja.json | 13 ++++++++++--- dash-frontend/assets/lang/pl.json | 11 +++++++++-- dash-frontend/assets/lang/zh_CN.json | 11 +++++++++-- wayvr/src/assets/lang/ja.json | 10 +++++----- 7 files changed, 60 insertions(+), 18 deletions(-) diff --git a/dash-frontend/assets/lang/de.json b/dash-frontend/assets/lang/de.json index 9b318072..881607d0 100644 --- a/dash-frontend/assets/lang/de.json +++ b/dash-frontend/assets/lang/de.json @@ -176,7 +176,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "Bodenhöhe", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "Die Y-Position, an der sich der Boden befindet. Die Schwerkraft wird gestoppt, wenn Sie diese Höhe erreichen.", "SAVE": "Speichern", - "CANCEL": "Abbrechen" + "CANCEL": "Abbrechen", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "Das ausgewählte Modell muss heruntergeladen werden.\nJetzt herunterladen?", + "REMOVE_UNUSED_MODELS": "Sie schalten die Sprache-zu-Text-Funktion aus.\nMöchten Sie die heruntergeladenen Whisper-Modelle entfernen, um Speicherplatz freizugeben?" + }, + "WHISPER_MODEL": "Sprach-zu-Text-Modell", + "WHISPER_MODEL_HELP": "Zu verwendendes Whisper-Modell.\nGrößere Modelle liefern genauere\nErgebnisse, verbrauchen jedoch mehr VRAM." }, "HELLO": "Hallo!", "AUDIO": { @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "Vielen Dank für die Installation von WayVR!", "WATCH": "Watch", "WORKING_SET": "Arbeits-Set" - } + }, + "DOWNLOAD": "Herunterladen" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/es.json b/dash-frontend/assets/lang/es.json index 83ed9511..673c430c 100644 --- a/dash-frontend/assets/lang/es.json +++ b/dash-frontend/assets/lang/es.json @@ -176,7 +176,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "Altura del suelo", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "La posición Y donde se encuentra el suelo. La gravedad se detiene cuando alcanzas esta altura.", "SAVE": "Guardar", - "CANCEL": "Cancelar" + "CANCEL": "Cancelar", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "El modelo seleccionado debe ser descargado.\n¿Deseas descargarlo ahora?", + "REMOVE_UNUSED_MODELS": "Estás desactivando la función de voz a texto.\n¿Te gustaría eliminar los modelos de Whisper descargados para liberar espacio?" + }, + "WHISPER_MODEL": "Modelo de voz a texto", + "WHISPER_MODEL_HELP": "Modelo de Whisper a utilizar.\nLos modelos más grandes producen\nresultados más precisos, pero consumen más VRAM." }, "HELLO": "¡Hola!", "AUDIO": { @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "¡Gracias por instalar WayVR!", "WATCH": "Watch", "WORKING_SET": "Set de trabajo" - } + }, + "DOWNLOAD": "Descargar" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/it.json b/dash-frontend/assets/lang/it.json index 0bdbf563..a54f1a2a 100644 --- a/dash-frontend/assets/lang/it.json +++ b/dash-frontend/assets/lang/it.json @@ -197,7 +197,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "Altezza del pavimento", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "La posizione Y in cui si trova il pavimento. La gravità si ferma quando raggiungi questa altezza.", "SAVE": "Salva", - "CANCEL": "Annulla" + "CANCEL": "Annulla", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "Il modello selezionato deve essere scaricato.\nVuoi scaricarlo adesso?", + "REMOVE_UNUSED_MODELS": "Stai disattivando la funzione di sintesi vocale.\nDesideri rimuovere i modelli Whisper scaricati per liberare spazio?" + }, + "WHISPER_MODEL": "Modello di sintesi vocale (speech-to-text)", + "WHISPER_MODEL_HELP": "Modello Whisper da utilizzare.\nI modelli più grandi producono risultati\npiù accurati, ma consumano più VRAM." }, "APPLICATION_LAUNCHER": "Lanciatore applicazioni", "APPLICATION_STARTED": "Applicazione avviata", @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "Grazie per aver installato WayVR!", "WATCH": "Watch", "WORKING_SET": "Set di lavoro" - } + }, + "DOWNLOAD": "Scarica" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/ja.json b/dash-frontend/assets/lang/ja.json index d3d8b7b9..8c717a96 100644 --- a/dash-frontend/assets/lang/ja.json +++ b/dash-frontend/assets/lang/ja.json @@ -176,7 +176,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "床の高さ", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "床のY座標です。この高さに達すると重力が停止します。", "SAVE": "保存", - "CANCEL": "キャンセル" + "CANCEL": "キャンセル", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "選択されたモデルをダウンロードする必要があります。\n今すぐダウンロードしますか?", + "REMOVE_UNUSED_MODELS": "文字起こし機能をオフにします。\n空き容量を増やすために、ダウンロード済みのモデルファイルを削除しますか?" + }, + "WHISPER_MODEL": "文字起こしモデル", + "WHISPER_MODEL_HELP": "使用するWhisperモデル。\nモデルが大きいほど精度は向上しますが、\nVRAMの消費量が増加します。" }, "HELLO": "こんにちは!", "AUDIO": { @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "WayVRをインストールしていただきありがとうございます!", "WATCH": "Watch", "WORKING_SET": "ワーキングセット" - } -} \ No newline at end of file + }, + "DOWNLOAD": "ダウンロード" +} diff --git a/dash-frontend/assets/lang/pl.json b/dash-frontend/assets/lang/pl.json index 4bc8a415..4a30fc22 100644 --- a/dash-frontend/assets/lang/pl.json +++ b/dash-frontend/assets/lang/pl.json @@ -171,7 +171,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "Wysokość podłogi", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "Pozycja Y, w której znajduje się podłoga. Grawitacja przestaje działać po osiągnięciu tej wysokości.", "SAVE": "Zapisz", - "CANCEL": "Anuluj" + "CANCEL": "Anuluj", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "Wybrany model musi zostać pobrany.\nPobrać go teraz?", + "REMOVE_UNUSED_MODELS": "Wyłączasz funkcję zamiany mowy na tekst.\nCzy chcesz usunąć pobrane modele Whisper, aby zwolnić miejsce?" + }, + "WHISPER_MODEL": "Model mowy na tekst", + "WHISPER_MODEL_HELP": "Model Whisper do wykorzystania.\nWiększe modele dają dokładniejsze\nwyniki, ale zużywają więcej pamięci VRAM." }, "APPLICATION_LAUNCHER": "Uruchamiacz aplikacji", "APPLICATIONS": "Aplikacje", @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "Dziękujemy za zainstalowanie WayVR!", "WATCH": "Watch", "WORKING_SET": "Aktywny zestaw" - } + }, + "DOWNLOAD": "Pobierz" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/zh_CN.json b/dash-frontend/assets/lang/zh_CN.json index f6527db0..4955ce41 100644 --- a/dash-frontend/assets/lang/zh_CN.json +++ b/dash-frontend/assets/lang/zh_CN.json @@ -197,7 +197,13 @@ "SPACE_GRAVITY_FLOOR_HEIGHT": "地面高度", "SPACE_GRAVITY_FLOOR_HEIGHT_HELP": "地面所在 Y 轴位置。达到该高度时重力停止。", "SAVE": "保存", - "CANCEL": "取消" + "CANCEL": "取消", + "WHISPER": { + "NEED_TO_DOWNLOAD_MODEL": "所选模型需要下载。\n现在开始下载吗?", + "REMOVE_UNUSED_MODELS": "您正在关闭语音转文本功能。\n是否删除已下载的 Whisper 模型以释放空间?" + }, + "WHISPER_MODEL": "语音转文本模型", + "WHISPER_MODEL_HELP": "要使用的 Whisper 模型。\n模型越大,结果越准确,\n但消耗的显存 (VRAM) 越多。" }, "APPLICATION_LAUNCHER": "应用启动器", "APPLICATION_STARTED": "应用已启动", @@ -289,5 +295,6 @@ "THANKS_FOR_INSTALLING": "感谢安装 WayVR!", "WATCH": "Watch", "WORKING_SET": "工作集" - } + }, + "DOWNLOAD": "下载" } \ No newline at end of file diff --git a/wayvr/src/assets/lang/ja.json b/wayvr/src/assets/lang/ja.json index d8e252c9..9770a15c 100644 --- a/wayvr/src/assets/lang/ja.json +++ b/wayvr/src/assets/lang/ja.json @@ -116,12 +116,12 @@ "WHISPER": { "TRANSCRIBE": "ボタンを長押ししてテキストを文字起こしする", "PASTE": "文字起こしをクリップボードにコピー", - "PASTE_AND_GO": "文字起こしをコピーし、フォーカスされているアプリにCtrl-Vを送信する", - "SEND_OSC": "文字起こしをOSCチャットボックスに送信する", + "PASTE_AND_GO": "コピーしてフォーカスされているアプリにCtrl-Vを送信", + "SEND_OSC": "文字起こしをOSCチャットボックスに送信", "UNLOAD_AND_CLOSE": "モデルをアンロードして閉じる", - "MODEL_NOT_DOWNLOADED": "音声認識が設定されていません!", - "DOWNLOAD_GUIDANCE": "設定の「機能」から音声入力(speech-to-text)をセットアップしてください!", + "MODEL_NOT_DOWNLOADED": "文字起こしが設定されていません!", + "DOWNLOAD_GUIDANCE": "機能設定の中に文字起こしを設定してください!", "INITIAL_TEXT": "マイクボタンを押し続けて文字起こしを開始してください...", "INIT_ERROR": "Whisperエンジンの起動中にエラーが発生しました" } -} \ No newline at end of file +} From 3515b15a71b40b79dc312a17c85181afd418d14e Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:02:40 +0900 Subject: [PATCH 13/19] whisper enter key --- wayvr/src/assets/gui/whisper.xml | 10 ++++++++++ wayvr/src/assets/lang/de.json | 3 ++- wayvr/src/assets/lang/en.json | 3 ++- wayvr/src/assets/lang/es.json | 3 ++- wayvr/src/assets/lang/it.json | 3 ++- wayvr/src/assets/lang/ja.json | 7 ++++--- wayvr/src/assets/lang/pl.json | 3 ++- wayvr/src/assets/lang/zh_CN.json | 3 ++- 8 files changed, 26 insertions(+), 9 deletions(-) diff --git a/wayvr/src/assets/gui/whisper.xml b/wayvr/src/assets/gui/whisper.xml index 460fd7a1..07dcd4ca 100644 --- a/wayvr/src/assets/gui/whisper.xml +++ b/wayvr/src/assets/gui/whisper.xml @@ -4,6 +4,10 @@ + +
@@ -11,12 +15,18 @@ + + + + diff --git a/wayvr/src/assets/lang/de.json b/wayvr/src/assets/lang/de.json index 1532c7c3..3cb9de89 100644 --- a/wayvr/src/assets/lang/de.json +++ b/wayvr/src/assets/lang/de.json @@ -124,6 +124,7 @@ "MODEL_NOT_DOWNLOADED": "Spracherkennung nicht eingerichtet!", "DOWNLOAD_GUIDANCE": "Richten Sie die Spracherkennung unter Einstellungen -> Features ein!", "INITIAL_TEXT": "Halten Sie die Mikrofontaste gedrückt, um mit der Transkription zu beginnen...", - "INIT_ERROR": "Fehler beim Starten der Whisper-Engine" + "INIT_ERROR": "Fehler beim Starten der Whisper-Engine", + "ENTER_KEY": "Enter-Taste" } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/en.json b/wayvr/src/assets/lang/en.json index 8c9c989f..8c4a4275 100644 --- a/wayvr/src/assets/lang/en.json +++ b/wayvr/src/assets/lang/en.json @@ -122,6 +122,7 @@ "MODEL_NOT_DOWNLOADED": "Speech-to-text not set up!", "DOWNLOAD_GUIDANCE": "Set up speech-to-text under settings, features!", "INITIAL_TEXT": "Press and hold the mic button to start transcribing...", - "INIT_ERROR": "Error while starting Whisper engine" + "INIT_ERROR": "Error while starting Whisper engine", + "ENTER_KEY": "Enter key" } } diff --git a/wayvr/src/assets/lang/es.json b/wayvr/src/assets/lang/es.json index b6ce7dd1..6c534842 100644 --- a/wayvr/src/assets/lang/es.json +++ b/wayvr/src/assets/lang/es.json @@ -124,6 +124,7 @@ "MODEL_NOT_DOWNLOADED": "¡El dictado por voz no está configurado!", "DOWNLOAD_GUIDANCE": "¡Configura la conversión de voz a texto en ajustes, características!", "INITIAL_TEXT": "Mantén presionado el botón del micrófono para comenzar a transcribir...", - "INIT_ERROR": "Error al iniciar el motor de Whisper" + "INIT_ERROR": "Error al iniciar el motor de Whisper", + "ENTER_KEY": "Tecla Enter" } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/it.json b/wayvr/src/assets/lang/it.json index d7c1403d..c0c701be 100644 --- a/wayvr/src/assets/lang/it.json +++ b/wayvr/src/assets/lang/it.json @@ -122,6 +122,7 @@ "MODEL_NOT_DOWNLOADED": "Speech-to-text non configurato!", "DOWNLOAD_GUIDANCE": "Configura il riconoscimento vocale in impostazioni, funzionalità!", "INITIAL_TEXT": "Tieni premuto il tasto del microfono per iniziare la trascrizione...", - "INIT_ERROR": "Errore durante l'avvio del motore Whisper" + "INIT_ERROR": "Errore durante l'avvio del motore Whisper", + "ENTER_KEY": "Tasto Invio" } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/ja.json b/wayvr/src/assets/lang/ja.json index 9770a15c..49c80315 100644 --- a/wayvr/src/assets/lang/ja.json +++ b/wayvr/src/assets/lang/ja.json @@ -117,11 +117,12 @@ "TRANSCRIBE": "ボタンを長押ししてテキストを文字起こしする", "PASTE": "文字起こしをクリップボードにコピー", "PASTE_AND_GO": "コピーしてフォーカスされているアプリにCtrl-Vを送信", - "SEND_OSC": "文字起こしをOSCチャットボックスに送信", + "SEND_OSC": "OSCチャットボックスに送信", "UNLOAD_AND_CLOSE": "モデルをアンロードして閉じる", "MODEL_NOT_DOWNLOADED": "文字起こしが設定されていません!", "DOWNLOAD_GUIDANCE": "機能設定の中に文字起こしを設定してください!", "INITIAL_TEXT": "マイクボタンを押し続けて文字起こしを開始してください...", - "INIT_ERROR": "Whisperエンジンの起動中にエラーが発生しました" + "INIT_ERROR": "Whisperエンジンの起動中にエラーが発生しました", + "ENTER_KEY": "Enterキー" } -} +} \ No newline at end of file diff --git a/wayvr/src/assets/lang/pl.json b/wayvr/src/assets/lang/pl.json index e8770625..9ed26b87 100644 --- a/wayvr/src/assets/lang/pl.json +++ b/wayvr/src/assets/lang/pl.json @@ -122,6 +122,7 @@ "MODEL_NOT_DOWNLOADED": "Zamiana mowy na tekst nie została skonfigurowana!", "DOWNLOAD_GUIDANCE": "Skonfiguruj funkcję zamiany mowy na tekst w ustawieniach, w sekcji funkcje!", "INITIAL_TEXT": "Naciśnij i przytrzymaj przycisk mikrofonu, aby rozpocząć transkrypcję...", - "INIT_ERROR": "Błąd podczas uruchamiania silnika Whisper" + "INIT_ERROR": "Błąd podczas uruchamiania silnika Whisper", + "ENTER_KEY": "Klawisz Enter" } } \ No newline at end of file diff --git a/wayvr/src/assets/lang/zh_CN.json b/wayvr/src/assets/lang/zh_CN.json index 9f19c259..6d1e6c67 100644 --- a/wayvr/src/assets/lang/zh_CN.json +++ b/wayvr/src/assets/lang/zh_CN.json @@ -122,6 +122,7 @@ "MODEL_NOT_DOWNLOADED": "语音转文本未设置!", "DOWNLOAD_GUIDANCE": "请在“设置”中的“功能”选项下配置语音转文本!", "INITIAL_TEXT": "按住麦克风按钮开始转录...", - "INIT_ERROR": "启动 Whisper 引擎时出错" + "INIT_ERROR": "启动 Whisper 引擎时出错", + "ENTER_KEY": "回车键" } } \ No newline at end of file From 90f8ecafe5a634633a243ac00d7a126111486c7e Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 3 Jul 2026 19:44:29 +0200 Subject: [PATCH 14/19] whisper: host the models inside wayvr.org --- dash-frontend/src/util/whisper.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dash-frontend/src/util/whisper.rs b/dash-frontend/src/util/whisper.rs index c6920268..9ae5a1f3 100644 --- a/dash-frontend/src/util/whisper.rs +++ b/dash-frontend/src/util/whisper.rs @@ -12,27 +12,27 @@ pub const WHISPER_MODELS: &[WhisperModel] = &[ WhisperModel { file_name: "ggml-base-q8_0.bin", display_name: "Base Q8 (78MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin", + url: "https://wayvr.org/files/whisper/ggml-base-q8_0.bin", }, WhisperModel { file_name: "ggml-small-q8_0.bin", display_name: "Small Q8 (252MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin", + url: "https://wayvr.org/files/whisper/ggml-small-q8_0.bin", }, WhisperModel { file_name: "ggml-large-v3-turbo-q5_0.bin", display_name: "Turbo Q5 (574MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", + url: "https://wayvr.org/files/whisper/ggml-large-v3-turbo-q5_0.bin", }, WhisperModel { file_name: "ggml-large-v3-turbo-q8_0.bin", display_name: "Turbo Q8 (874MiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", + url: "https://wayvr.org/files/whisper/ggml-large-v3-turbo-q8_0.bin", }, WhisperModel { file_name: "ggml-large-v3-turbo.bin", display_name: "Turbo (1.5GiB)", - url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", + url: "https://wayvr.org/files/whisper/ggml-large-v3-turbo.bin", }, ]; From 00a9dd2b2fd18ec19e3760040ed23eff0862700e Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:20:04 +0900 Subject: [PATCH 15/19] app.tick() + whisper auto-unload --- wayvr/src/backend/input.rs | 2 +- wayvr/src/backend/openvr/mod.rs | 10 ++---- wayvr/src/backend/openxr/mod.rs | 11 ++---- wayvr/src/overlays/whisper.rs | 40 +++++++++++----------- wayvr/src/state.rs | 50 ++++++++++++++++++++++------ wayvr/src/subsystem/notifications.rs | 14 ++++---- wayvr/src/subsystem/whisper_stt.rs | 14 ++++---- wayvr/src/windowing/window.rs | 8 ----- 8 files changed, 80 insertions(+), 69 deletions(-) diff --git a/wayvr/src/backend/input.rs b/wayvr/src/backend/input.rs index 956327c9..d6c69992 100644 --- a/wayvr/src/backend/input.rs +++ b/wayvr/src/backend/input.rs @@ -20,7 +20,7 @@ use crate::subsystem::hid::WheelDelta; use crate::subsystem::input::KeyboardFocus; use crate::windowing::backend::OverlayEventData; use crate::windowing::manager::OverlayWindowManager; -use crate::windowing::window::{self, OverlayWindowData, realign, scalar_scale, window_scale}; +use crate::windowing::window::{self, OverlayWindowData, realign, scalar_scale}; use crate::windowing::{OverlayID, OverlaySelector}; use super::task::TaskType; diff --git a/wayvr/src/backend/openvr/mod.rs b/wayvr/src/backend/openvr/mod.rs index a44696b1..90d212f4 100644 --- a/wayvr/src/backend/openvr/mod.rs +++ b/wayvr/src/backend/openvr/mod.rs @@ -32,7 +32,6 @@ use crate::{ graphics::{GpuFutures, init_openvr_graphics}, overlays::toast::Toast, state::AppState, - subsystem::notifications::NotificationManager, windowing::{ backend::{RenderResources, RenderTarget, ShouldRender}, manager::OverlayWindowManager, @@ -112,12 +111,11 @@ pub fn openvr_run( log::info!("IPD: {:.0} mm", app.input_state.ipd); } + app.late_init(); + let _ = install_manifest(&mut app_mgr); let mut overlays = OverlayWindowManager::::new(&mut app, headless)?; - let mut notifications = NotificationManager::new(); - notifications.run_dbus(&mut app.dbus); - notifications.run_udp(); let mut playspace = playspace::PlayspaceMover::new(); playspace.playspace_changed(&mut compositor_mgr, &mut chaperone_mgr); @@ -215,9 +213,7 @@ pub fn openvr_run( next_device_update = Instant::now() + Duration::from_secs(30); } - app.dbus.tick(); - notifications.submit_pending(&mut app); - + app.tick(); app.tasks.retrieve_due(&mut due_tasks); while let Some(task) = due_tasks.pop_front() { diff --git a/wayvr/src/backend/openxr/mod.rs b/wayvr/src/backend/openxr/mod.rs index 535aa9cd..d0afb393 100644 --- a/wayvr/src/backend/openxr/mod.rs +++ b/wayvr/src/backend/openxr/mod.rs @@ -24,7 +24,6 @@ use crate::{ graphics::{GpuFutures, init_openxr_graphics}, overlays::{toast::Toast, watch::WATCH_NAME}, state::AppState, - subsystem::notifications::NotificationManager, windowing::{ backend::{RenderResources, RenderTarget, ShouldRender}, manager::OverlayWindowManager, @@ -86,13 +85,9 @@ pub fn openxr_run( let mut lines = LinePool::new(&app)?; let mut current_lines = Vec::with_capacity(2); - let mut notifications = NotificationManager::new(); - notifications.run_dbus(&mut app.dbus); - notifications.run_udp(); - let mut delete_queue = vec![]; - app.monado_state_init(); + app.late_init(); let mut playspace_mover = app.monado_state.as_mut().and_then(|m| { playspace::PlayspaceMover::new(&mut m.ipc) @@ -475,9 +470,7 @@ pub fn openxr_run( )?; // End layer submit - app.dbus.tick(); - notifications.submit_pending(&mut app); - + app.tick(); app.tasks.retrieve_due(&mut due_tasks); while let Some(task) = due_tasks.pop_front() { match task { diff --git a/wayvr/src/overlays/whisper.rs b/wayvr/src/overlays/whisper.rs index c3c34aa1..10e3c615 100644 --- a/wayvr/src/overlays/whisper.rs +++ b/wayvr/src/overlays/whisper.rs @@ -1,6 +1,6 @@ use std::{rc::Rc, time::Duration}; -use glam::{Affine3A, Quat, Vec3, vec3}; +use glam::Affine3A; use wgui::{ components::button::ComponentButton, event::EventCallback, @@ -38,11 +38,9 @@ use crate::{ }, }; -pub const WHISPER_NAME: &str = "whisper"; +const WHISPER_NAME: &str = "whisper"; -#[derive(Default)] struct WhisperState { - whisper_sst: Option, clipboard_provider: Option>, last_transcription: Option>, } @@ -92,7 +90,7 @@ pub fn create_whisper( let state = WhisperState { clipboard_provider, - ..Default::default() + last_transcription: None, }; let xml = "gui/whisper.xml"; @@ -118,18 +116,18 @@ pub fn create_whisper( let button = button.clone(); let callback: EventCallback = match command { - "::WhisperTranscribeStart" => Box::new(move |_common, data, app, state| { + "::WhisperTranscribeStart" => Box::new(move |_common, data, app, _state| { if !test_button(data) || !test_duration(&button, app) { return Ok(EventResult::Pass); } - let whisper = match state.whisper_sst.as_mut() { + let whisper = match app.whisper_sst.as_mut() { Some(x) => x, None => { let model_path = data_dir::get_path("whisper") .join(app.session.config.whisper_model.as_ref()); if model_path.is_file() { - state.whisper_sst = match WhisperStt::new(model_path) + app.whisper_sst = match WhisperStt::new(model_path) .log_err("Error while starting Whisper engine") { Ok(x) => Some(x), @@ -157,7 +155,7 @@ pub fn create_whisper( return Ok(EventResult::Consumed); } - state.whisper_sst.as_mut().unwrap() + app.whisper_sst.as_mut().unwrap() } }; @@ -167,12 +165,12 @@ pub fn create_whisper( Ok(EventResult::Consumed) }), - "::WhisperTranscribeStop" => Box::new(move |_common, data, app, state| { + "::WhisperTranscribeStop" => Box::new(move |_common, data, app, _state| { if !test_button(data) || !test_duration(&button, app) { return Ok(EventResult::Pass); } - if let Some(whisper) = state.whisper_sst.as_mut() { + if let Some(whisper) = app.whisper_sst.as_mut() { let _ = whisper .ptt_end() .log_err("Could not stop Whisper transcription"); @@ -240,12 +238,12 @@ pub fn create_whisper( Ok(EventResult::Consumed) }), - "::WhisperUnloadAndClose" => Box::new(move |_common, data, app, state| { + "::WhisperUnloadAndClose" => Box::new(move |_common, data, app, _state| { if !test_button(data) || !test_duration(&button, app) { return Ok(EventResult::Pass); } - state.whisper_sst = None; + app.whisper_sst = None; app.tasks .enqueue(TaskType::Overlay(OverlayTask::ToggleOverlay( OverlaySelector::Name(WHISPER_NAME.into()), @@ -297,14 +295,14 @@ pub fn create_whisper( .push(GuiTimer::new(Duration::from_millis(100), 0)); let on_label_tick: EventCallback = - Box::new(move |common, data, _app, state| { - if let Some(whisper_stt) = state.whisper_sst.as_mut() - && let Some(text) = whisper_stt.take_transcription() - { - let text: Rc = text.into(); - state.last_transcription = Some(text.clone()); - let label = data.obj.get_as_mut::().unwrap(); - label.set_text(common, Translation::from_raw_text_rc(text)); + Box::new(move |common, data, app, state| { + if let Some(whisper_stt) = app.whisper_sst.as_mut() { + if let Some(text) = whisper_stt.take_transcription() { + let text: Rc = text.into(); + state.last_transcription = Some(text.clone()); + let label = data.obj.get_as_mut::().unwrap(); + label.set_text(common, Translation::from_raw_text_rc(text)); + } } Ok(EventResult::Pass) }); diff --git a/wayvr/src/state.rs b/wayvr/src/state.rs index de36c007..95a2b71b 100644 --- a/wayvr/src/state.rs +++ b/wayvr/src/state.rs @@ -22,9 +22,12 @@ use wlx_common::{ use crate::backend; use crate::backend::wayvr::WvrServerState; +use crate::subsystem::notifications::NotificationManager; #[cfg(feature = "osc")] use crate::subsystem::osc::OscSender; +#[cfg(feature = "whisper")] +use crate::subsystem::whisper_stt::WhisperStt; use crate::{ backend::{XrBackend, input::InputState, task::TaskContainer}, config::load_general_config, @@ -45,6 +48,8 @@ pub struct AppState { pub audio_system: audio::AudioSystem, pub audio_sample_player: audio::SamplePlayer, + pub notifications: NotificationManager, + pub wgui_shared: WSharedContext, pub input_state: InputState, @@ -69,6 +74,9 @@ pub struct AppState { pub wvr_server: Option, + #[cfg(feature = "whisper")] + pub whisper_sst: Option, + #[cfg(feature = "openxr")] pub monado_state: Option, @@ -182,11 +190,14 @@ impl AppState { ipc_server, wayvr_signals: wvr_signals, desktop_finder, + notifications: NotificationManager::new(), #[cfg(feature = "osc")] osc_sender, wvr_server, + #[cfg(feature = "whisper")] + whisper_sst: None, #[cfg(feature = "openxr")] monado_state: None, @@ -200,19 +211,38 @@ impl AppState { Ok(app_state) } - #[cfg(feature = "openxr")] - pub fn monado_state_init(&mut self) { - use crate::backend::openxr::monado_state::MonadoState; + pub fn late_init(&mut self) { + self.notifications.run_dbus(&mut self.dbus); + self.notifications.run_udp(); - log::debug!("Connecting to Monado IPC"); - self.monado_state = None; // stop connection first + if matches!(self.xr_backend, XrBackend::OpenXR) { + use crate::backend::openxr::monado_state::MonadoState; - match MonadoState::new() { - Ok(m) => { - self.monado_state = Some(m); + log::debug!("Connecting to Monado IPC"); + self.monado_state = None; // stop connection first + + match MonadoState::new() { + Ok(m) => { + self.monado_state = Some(m); + } + Err(e) => { + log::error!("Will not use libmonado: {e:?}"); + } } - Err(e) => { - log::error!("Will not use libmonado: {e:?}"); + } + } + + pub fn tick(&mut self) { + self.dbus.tick(); + + for toast in self.notifications.drain_pending(&self.session) { + toast.submit(self); + } + + #[cfg(feature = "whisper")] + { + if self.whisper_sst.as_ref().is_some_and(|x| x.should_unload()) { + self.whisper_sst = None; } } } diff --git a/wayvr/src/subsystem/notifications.rs b/wayvr/src/subsystem/notifications.rs index 149444fe..4bdd61d1 100644 --- a/wayvr/src/subsystem/notifications.rs +++ b/wayvr/src/subsystem/notifications.rs @@ -11,7 +11,7 @@ use std::{ }; use wlx_common::overlays::ToastTopic; -use crate::{overlays::toast::Toast, state::AppState, subsystem::dbus::DbusConnector}; +use crate::{overlays::toast::Toast, state::AppSession, subsystem::dbus::DbusConnector}; pub struct NotificationManager { rx_toast: mpsc::Receiver, @@ -29,14 +29,14 @@ impl NotificationManager { } } - pub fn submit_pending(&self, app: &mut AppState) { - if app.session.config.notifications_enabled { - self.rx_toast.try_iter().for_each(|toast| { - toast.submit(app); - }); + pub fn drain_pending(&self, session: &AppSession) -> Vec { + if session.config.notifications_enabled { + self.rx_toast.try_iter().collect() } else { // consume without submitting - self.rx_toast.try_iter().last(); + for _ in self.rx_toast.try_iter() {} + + Vec::new() } } diff --git a/wayvr/src/subsystem/whisper_stt.rs b/wayvr/src/subsystem/whisper_stt.rs index f0499931..654de566 100644 --- a/wayvr/src/subsystem/whisper_stt.rs +++ b/wayvr/src/subsystem/whisper_stt.rs @@ -14,6 +14,7 @@ use wlx_common::audio::rodio::{ const WHISPER_SAMPLE_RATE: usize = 16_000; const MAX_DURATION: Duration = Duration::from_secs(30); +const UNLOAD_AFTER: Duration = Duration::from_mins(5); #[derive(Clone, Debug)] pub struct WhisperSttConfig { @@ -107,6 +108,7 @@ pub struct WhisperStt { completed_tx: mpsc::Sender>, last_error: Option, + unload_at: Instant, } impl WhisperStt { @@ -133,11 +135,13 @@ impl WhisperStt { completed_rx, completed_tx, last_error: None, + unload_at: Instant::now() + UNLOAD_AFTER, }) } /// starts a fresh capture stream and a transcription worker pub fn ptt_start(&mut self) -> Result<(), WhisperSttError> { + self.unload_at = Instant::now() + UNLOAD_AFTER; self.reap_finished_recognizers(); if self.active.is_some() { @@ -237,6 +241,7 @@ impl WhisperStt { /// stops the pw stream & finalizes recognition asynchronously /// poll `take_transcription()` from your main loop to receive transcription pub fn ptt_end(&mut self) -> Result<(), WhisperSttError> { + self.unload_at = Instant::now() + UNLOAD_AFTER; self.stop_active_capture() } @@ -246,6 +251,7 @@ impl WhisperStt { let latest = self.drain_completed_transcriptions(); if latest.is_some() { + self.unload_at = Instant::now() + UNLOAD_AFTER; return latest; } @@ -263,12 +269,8 @@ impl WhisperStt { None } - pub fn take_error(&mut self) -> Option { - self.last_error.take() - } - - pub fn is_recording(&self) -> bool { - self.active.is_some() + pub fn should_unload(&self) -> bool { + self.unload_at < Instant::now() } fn reap_finished_recognizers(&mut self) { diff --git a/wayvr/src/windowing/window.rs b/wayvr/src/windowing/window.rs index cda85cab..0060a5e5 100644 --- a/wayvr/src/windowing/window.rs +++ b/wayvr/src/windowing/window.rs @@ -290,14 +290,6 @@ pub fn realign(transform: &mut Affine3A, hmd: &Affine3A, scale: f32) { transform.matrix3 = Mat3A::from_cols(col_x, col_y, col_z).mul_scalar(scale) * rot; } -pub fn window_scale(state: &OverlayWindowState) -> f32 { - state - .saved_transform - .as_ref() - .map(scalar_scale) - .unwrap_or_else(|| scalar_scale(&state.transform)) -} - pub fn scalar_scale(a: &Affine3A) -> f32 { let det = a.matrix3.determinant(); (a.matrix3.x_axis.length() * det.signum() From e3ecef9a6532ea096a5ff91de71386f75764d8c5 Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:24:04 +0900 Subject: [PATCH 16/19] cargo fmt --- dash-frontend/src/tab/settings/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-frontend/src/tab/settings/mod.rs b/dash-frontend/src/tab/settings/mod.rs index 56fcacfb..ff9a4abd 100644 --- a/dash-frontend/src/tab/settings/mod.rs +++ b/dash-frontend/src/tab/settings/mod.rs @@ -251,7 +251,7 @@ impl Tab for TabSettings { && let Some(tab) = self.current_tab.as_mut() { let config = frontend.interface.general_config(data); - let mut change_kind : Option = None; + let mut change_kind: Option = None; tab.context_menu_custom(name, config, &mut change_kind, &mut frontend.layout, &mut self.state)?; if let Some(change_kind) = change_kind { frontend.interface.config_changed(data, change_kind); From 23f1f32d008ca0034334f691fed70a2f7e33b83d Mon Sep 17 00:00:00 2001 From: galister <22305755+galister@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:38:42 +0900 Subject: [PATCH 17/19] fix ci --- .github/workflows/scripts/appimage_prepare_env.sh | 2 +- wayvr/src/state.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scripts/appimage_prepare_env.sh b/.github/workflows/scripts/appimage_prepare_env.sh index fd2ab779..699e22fe 100755 --- a/.github/workflows/scripts/appimage_prepare_env.sh +++ b/.github/workflows/scripts/appimage_prepare_env.sh @@ -1,7 +1,7 @@ #!/bin/sh sudo apt-get update -sudo apt-get install -y fuse cmake pkg-config fontconfig libasound2-dev libxkbcommon-dev libxkbcommon-x11-0 libxkbcommon-x11-dev libopenxr-dev libfontconfig-dev libdbus-1-dev libpipewire-0.3-0 libpipewire-0.3-dev libspa-0.2-dev libx11-6 libxext6 libxrandr2 libx11-dev libxext-dev libxrandr-dev libopenvr-dev libopenvr-api1 libwayland-dev libegl-dev libxcb-glx0 libxcb-glx0-dev dav1d libdav1d-dev +sudo apt-get install -y fuse cmake pkg-config fontconfig libasound2-dev libxkbcommon-dev libxkbcommon-x11-0 libxkbcommon-x11-dev libopenxr-dev libfontconfig-dev libdbus-1-dev libpipewire-0.3-0 libpipewire-0.3-dev libspa-0.2-dev libx11-6 libxext6 libxrandr2 libx11-dev libxext-dev libxrandr-dev libopenvr-dev libopenvr-api1 libwayland-dev libegl-dev libxcb-glx0 libxcb-glx0-dev dav1d libdav1d-dev libvulkan-dev glslc vulkan-tools rustup update if [ "$APPDIR" != "" ]; then diff --git a/wayvr/src/state.rs b/wayvr/src/state.rs index 95a2b71b..c8aac088 100644 --- a/wayvr/src/state.rs +++ b/wayvr/src/state.rs @@ -215,6 +215,7 @@ impl AppState { self.notifications.run_dbus(&mut self.dbus); self.notifications.run_udp(); + #[cfg(feature = "openxr")] if matches!(self.xr_backend, XrBackend::OpenXR) { use crate::backend::openxr::monado_state::MonadoState; @@ -242,6 +243,7 @@ impl AppState { #[cfg(feature = "whisper")] { if self.whisper_sst.as_ref().is_some_and(|x| x.should_unload()) { + log::info!("Unloading Whisper model due to timeout"); self.whisper_sst = None; } } From 1fb2b9ccf25fd3c3a3e05c7ec191c7aef12099a7 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 3 Jul 2026 20:50:09 +0200 Subject: [PATCH 18/19] wgui: bugfix: always recompute layout before refreshing the component This allows us to get computed widget boundaries inasted of bogus values --- wgui/src/layout.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wgui/src/layout.rs b/wgui/src/layout.rs index 1eb05876..22756a03 100644 --- a/wgui/src/layout.rs +++ b/wgui/src/layout.rs @@ -187,7 +187,7 @@ pub struct Layout { pub content_root_node: taffy::NodeId, pub prev_size: Vec2, - pub content_size: Vec2, + pub content_size: Vec2, // cached value pub needs_redraw: bool, pub haptics_triggered: bool, @@ -720,9 +720,10 @@ impl Layout { pub fn tick(&mut self) -> anyhow::Result<()> { let mut alterables = EventAlterables::default(); self.animations.tick(&self.state, &mut alterables); - self.process_pending_components(); self.process_pending_widget_ticks(&mut alterables); self.process_alterables(alterables)?; + self.try_recompute_layout(self.content_size /* cached value */)?; + self.process_pending_components(); Ok(()) } From b8ae840ca4ab5f3c9ffe6ff2b70c8b935456a2f3 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 3 Jul 2026 21:52:10 +0200 Subject: [PATCH 19/19] wgui: fix label wrap code, correct position of out-of-bounds tooltips --- wgui/src/components/tooltip.rs | 98 +++++++++++++++++++++++++++------- wgui/src/widget/label.rs | 13 ++--- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/wgui/src/components/tooltip.rs b/wgui/src/components/tooltip.rs index 05648ae5..64f53a03 100644 --- a/wgui/src/components/tooltip.rs +++ b/wgui/src/components/tooltip.rs @@ -75,11 +75,15 @@ impl Default for Params { } } -struct State {} +struct State { + position_shift: Vec2, // in case if the tooltip goes out of bounds +} #[allow(clippy::struct_field_names)] struct Data { - id_root: WidgetID, // Rectangle + id_root: WidgetID, // div + id_rect: WidgetID, // rectangle + side: TooltipSide, } pub struct ComponentTooltip { @@ -99,8 +103,47 @@ impl ComponentTrait for ComponentTooltip { &self.base } - fn refresh(&self, _data: &mut RefreshData) { - // nothing to do + fn refresh(&self, data: &mut RefreshData) { + let layout_size = data.layout.content_size; + let pin_pos = data.layout.state.get_widget_boundary(self.data.id_root).pos; + let rect_size = data.layout.state.get_widget_boundary(self.data.id_rect).size; + + let rect_pos_topleft: Vec2 = match self.data.side { + TooltipSide::Left => pin_pos + Vec2::new(-rect_size.x, -rect_size.y / 2.0), + TooltipSide::Right => pin_pos + Vec2::new(0.0, -rect_size.y / 2.0), + TooltipSide::Top => pin_pos + Vec2::new(-rect_size.x / 2.0, -rect_size.y), + TooltipSide::Bottom => pin_pos + Vec2::new(-rect_size.x / 2.0, 0.0), + }; + + let mut state = self.state.borrow_mut(); + + let padding = 8.0; + let rect_right = rect_pos_topleft.x + rect_size.x + padding; + let rect_bottom = rect_pos_topleft.y + rect_size.y + padding; + + let diff_right = rect_right - layout_size.x; + let diff_bottom = rect_bottom - layout_size.y; + + // limit x < 0 or rect_right > layout_size.x + if rect_pos_topleft.x < 0.0 { + state.position_shift.x = -rect_pos_topleft.x; + } else if diff_right > 0.0 { + state.position_shift.x = -diff_right; + } + + // limit y < 0 or rect_bottom > layout_size.y + if rect_pos_topleft.y < 0.0 { + state.position_shift.y = -rect_pos_topleft.y; + } else if diff_bottom > 0.0 { + state.position_shift.y = -diff_bottom; + } + + /*log::info!( + "layout size {:?}, pin pos {:?}, rect size {:?}", + layout_size, + pin_pos, + rect_size + );*/ } } @@ -127,7 +170,7 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul let spacing = 8.0; - let transform = Mat4::from_translation(Vec3::new(-0.5, 0.0, 0.0)); + let transform = Mat4::from_translation(Vec3::new(0.0, 0.0, 0.0)); // this value needs to be bigger than rectangle padding sizes due to the // transform stack & scissoring design. Needs investigation, zero-size objects @@ -219,18 +262,25 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul }, }, ); + let (label, _) = ess.layout.add_child(rect.id, label, Default::default())?; - let data = Rc::new(Data { id_root: div.id }); + let data = Rc::new(Data { + id_root: div.id, + id_rect: rect.id, + side: params.info.side.clone(), + }); - let state = Rc::new(RefCell::new(State {})); + let state = Rc::new(RefCell::new(State { + position_shift: Vec2::default(), + })); let base = ComponentBase::default(); let tooltip = Rc::new(ComponentTooltip { base, data, - state, + state: state.clone(), tasks: ess.layout.tasks.clone(), }); @@ -246,21 +296,29 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul rect.id, (10.0 * anim_mult) as u32, AnimationEasing::OutQuad, - Box::new(move |common, data| { - let rect = data.obj.get_as_mut::().unwrap(); /* safe */ - let alpha = data.pos; - rect.params.color.a = alpha; - rect.params.border_color.a = alpha; + { + Box::new(move |common, data| { + let rect = data.obj.get_as_mut::().unwrap(); /* safe */ + let alpha = data.pos; + rect.params.color.a = alpha; + rect.params.border_color.a = alpha; - let dir_mult = (1.0 - data.pos) * 5.0; - data.data.transform = Mat4::from_translation(Vec3::new(direction.x * dir_mult, direction.y * dir_mult, 0.0)); + let position_shift = state.borrow().position_shift; - if let Some(mut label) = common.state.widgets.get_as::(label.id) { - label.set_color(common, Color::new(1.0, 1.0, 1.0, alpha), true); - } + let dir_mult = (1.0 - data.pos) * 5.0; + data.data.transform = Mat4::from_translation(Vec3::new( + direction.x * dir_mult + position_shift.x, + direction.y * dir_mult + position_shift.y, + 0.0, + )); - common.alterables.mark_redraw(); - }), + if let Some(mut label) = common.state.widgets.get_as::(label.id) { + label.set_color(common, Color::new(1.0, 1.0, 1.0, alpha), true); + } + + common.alterables.mark_redraw(); + }) + }, )); ess.layout.defer_component_refresh(Component(tooltip.clone())); diff --git a/wgui/src/widget/label.rs b/wgui/src/widget/label.rs index 8e4cf0b2..651c3cc9 100644 --- a/wgui/src/widget/label.rs +++ b/wgui/src/widget/label.rs @@ -5,7 +5,7 @@ use slotmap::Key; use taffy::AvailableSpace; use crate::{ - drawing::{self, Boundary, PrimitiveExtent}, + drawing::{self, PrimitiveExtent}, event::CallbackDataCommon, globals::Globals, i18n::Translation, @@ -28,7 +28,6 @@ pub struct WidgetLabel { params: WidgetLabelParams, buffer: Rc>, - last_boundary: Boundary, } impl WidgetLabel { @@ -64,7 +63,6 @@ impl WidgetLabel { Box::new(Self { params, buffer: Rc::new(RefCell::new(buffer)), - last_boundary: Boundary::default(), id: WidgetID::null(), }), ) @@ -122,12 +120,9 @@ impl WidgetObj for WidgetLabel { fn draw(&mut self, state: &mut super::DrawState, _params: &super::DrawParams) { let boundary = drawing::Boundary::construct_relative(state.transform_stack); - if self.last_boundary != boundary { - self.last_boundary = boundary; - let mut font_system = state.globals.font_system.system.lock(); - let mut buffer = self.buffer.borrow_mut(); - buffer.set_size(&mut font_system, Some(boundary.size.x), Some(boundary.size.y)); - } + let mut font_system = state.globals.font_system.system.lock(); + let mut buffer = self.buffer.borrow_mut(); + buffer.set_size(&mut font_system, Some(boundary.size.x), Some(boundary.size.y)); state.primitives.push(drawing::RenderPrimitive::Text( PrimitiveExtent {