wip: whisper stt

This commit is contained in:
galister 2026-07-03 15:17:39 +09:00
parent 3fe47baa2e
commit 4fa811d8be
6 changed files with 766 additions and 13 deletions

53
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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"]

View File

@ -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;

View File

@ -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<String>,
pub initial_prompt: Option<String>,
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<String>,
pub use_gpu: bool,
pub gpu_device: i32,
pub flash_attn: bool,
}
impl WhisperSttConfig {
pub fn new(model_path: impl AsRef<Path>) -> 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<StopCapture>,
capture_thread: Option<JoinHandle<()>>,
recognizer_thread: Option<JoinHandle<()>>,
deadline: Instant,
}
pub struct WhisperStt {
config: WhisperSttConfig,
ctx: Arc<WhisperContext>,
active: Option<CaptureSession>,
finished_recognizers: Vec<JoinHandle<()>>,
completed_rx: mpsc::Receiver<Result<String, String>>,
completed_tx: mpsc::Sender<Result<String, String>>,
last_error: Option<String>,
}
impl WhisperStt {
pub fn new(model_path: impl AsRef<Path>) -> Result<Self, WhisperSttError> {
Self::init(WhisperSttConfig::new(model_path))
}
pub fn init(config: WhisperSttConfig) -> Result<Self, WhisperSttError> {
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::<Vec<f32>>();
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
let (stop_tx, stop_rx) = pw::channel::channel::<StopCapture>();
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<String> {
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<String> {
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<String> {
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<WhisperContext>,
config: WhisperSttConfig,
audio_rx: mpsc::Receiver<Vec<f32>>,
completed_tx: mpsc::Sender<Result<String, String>>,
) -> Result<JoinHandle<()>, 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<WhisperContext>,
config: WhisperSttConfig,
audio_rx: mpsc::Receiver<Vec<f32>>,
completed_tx: mpsc::Sender<Result<String, String>>,
) {
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::<f32>::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<String, WhisperSttError> {
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::<Vec<_>>()
.join("");
Ok(normalize_transcript(text))
}
fn pipewire_capture_thread(
audio_tx: mpsc::Sender<Vec<f32>>,
stop_rx: pw::channel::Receiver<StopCapture>,
target_object: Option<String>,
ready_tx: mpsc::Sender<Result<(), String>>,
) {
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<Vec<f32>>,
stop_rx: pw::channel::Receiver<StopCapture>,
target_object: Option<String>,
ready_tx: &mut Option<mpsc::Sender<Result<(), String>>>,
) -> 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<u8> = 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<f32>,
position: f64,
input_rate: usize,
}
impl StreamingResampler {
fn push_interleaved_f32le_mono_16k(
&mut self,
bytes: &[u8],
channels: usize,
input_rate: usize,
) -> Vec<f32> {
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::<f32>();
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::<Vec<_>>().join(" ")
}

View File

@ -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",