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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to Memiro AI. Versions follow [Semantic Versioning](https://semver.org/);
installers for every version are on [GitHub Releases](https://github.com/bglglzd/auris/releases).

## [0.13.4] — 2026-10-02

### Changed
- **The transcript is ready right after the first pass; difficult parts are refined in the background.** Noisy, overlapping or wrong-language parts are no longer processed while you wait (that froze progress at 45 % and loaded the computer). They are refined afterwards, one by one, and each improved part appears in the transcript as soon as it is ready — «Refining difficult parts N of M · Stop». Stopped refining can be resumed later («Continue»). Phrases you already edited are never overwritten.
- Background refining is gentler: a faster Whisper mode (greedy decoding, no extra language-detection pass when the language is set), at most half of the CPU cores, and short pauses between parts.

## [0.13.3] — 2026-10-02

### Fixed
Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,13 @@ e2e-тест `core/tests/diarize_e2e.rs` (`-- --ignored`, в CI на Windows).
Фронт — `AudioEditor`/`WaveLane` + чистая логика `audioedit.ts`.

### Качество распознавания (v0.13)
`QualityGuard` (commands.rs, Parakeet + Whisper): (1) **второй проход** —
**С v0.13.4 всё уточнение — в фоне**: `QualityGuard` делает только первый проход
и копит план (`refine.rs` → `<id>/refine.json`: окна Hard/Lang + исходные реплики);
после расшифровки фронт зовёт `refine_transcript` (по месту: denoise+Parakeet →
`WhisperTranscriber::transcribe_fast`, жадно, ½ ядер, паузы; `refine::apply_window`
меняет только реплики, совпадающие по времени с исходными — правки целы),
события `refine-progress`, `cancel_refine`, `refine_pending` (остаток/идёт).
Ниже — критерии окон (`QualityGuard`, Parakeet + Whisper): (1) **второй проход** —
окна Parakeet с низкой уверенностью (`parakeet::WindowScore`, softmax токена),
SNR < 12 дБ (`enhance::snr_db`) или «речь есть, слов нет» (перебивания —
Parakeet молчит; сильный ровный шум — всегда) → Parakeet по `enhance::denoise`
Expand Down
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod nemo_mel;
pub mod parakeet;
pub mod profanity;
pub mod recorder;
pub mod refine;
pub mod rescue;
pub mod service;
pub mod storage;
Expand Down
196 changes: 196 additions & 0 deletions core/src/refine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Фоновое уточнение расшифровки. Первый проход (Parakeet) сразу даёт готовую
//! расшифровку; трудные места (шум, перебивания, «не тот язык») сохраняются
//! планом в `<встреча>/refine.json` и уточняются потом, в фоне, не задерживая
//! пользователя. Каждое уточнённое место сразу попадает в расшифровку.
//!
//! Реплики, которые пользователь успел поправить, не трогаются: заменяются
//! только реплики, совпадающие по времени с исходными репликами окна.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{AppError, AppResult};
use crate::transcript::{Segment, Transcript, TranscriptSegment};

const FILE: &str = "refine.json";

/// Почему окно уточняется.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RefineKind {
/// Шум / неуверенность / пропущенная речь.
Hard,
/// Реплики не той письменности (латиница вместо русского и т.п.).
Lang,
}

/// Окно для уточнения и исходные реплики первого прохода в нём.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RefineWindow {
pub start_secs: f64,
pub end_secs: f64,
pub kind: RefineKind,
/// Чем больше, тем хуже (порядок обработки).
pub severity: f32,
/// Отношение сигнал/шум и доля речи — для решения о Whisper.
#[serde(default)]
pub speech_secs: f32,
#[serde(default)]
pub confidence: Option<f32>,
/// Реплики первого прохода в окне (по ним находим, что заменить).
pub originals: Vec<Segment>,
}

/// Окна одной дорожки (16 кГц моно WAV в папке встречи).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RefineJob {
pub wav: String,
pub windows: Vec<RefineWindow>,
}

/// План уточнения встречи.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RefinePlan {
pub jobs: Vec<RefineJob>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub vocabulary: Option<String>,
}

impl RefinePlan {
pub fn total(&self) -> usize {
self.jobs.iter().map(|j| j.windows.len()).sum()
}
}

pub fn save(meeting_dir: &Path, plan: &RefinePlan) -> AppResult<()> {
let path = meeting_dir.join(FILE);
if plan.total() == 0 {
let _ = std::fs::remove_file(path);
return Ok(());
}
std::fs::write(path, serde_json::to_vec(plan)?)?;
Ok(())
}

pub fn load(meeting_dir: &Path) -> AppResult<Option<RefinePlan>> {
let path = meeting_dir.join(FILE);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path)?;
Ok(Some(serde_json::from_slice(&bytes).map_err(|e| AppError::InvalidState(format!("refine.json: {e}")))?))
}

pub fn clear(meeting_dir: &Path) {
let _ = std::fs::remove_file(meeting_dir.join(FILE));
}

fn same_time(a: &TranscriptSegment, b: &Segment) -> bool {
(a.start_secs - b.start_secs).abs() < 1e-3 && (a.end_secs - b.end_secs).abs() < 1e-3
}

/// Заменяет в расшифровке реплики окна (совпадающие по времени с исходными)
/// новыми. Говорящий новой реплики — у исходной с наибольшим перекрытием
/// (ближайшей, если перекрытия нет). `None` — заменять нечего (реплики
/// окна правили или удалили).
pub fn apply_window(t: &Transcript, originals: &[Segment], fresh: &[Segment]) -> Option<Transcript> {
let matched: Vec<&TranscriptSegment> =
t.segments.iter().filter(|s| originals.iter().any(|o| same_time(s, o))).collect();
if matched.is_empty() || matched.len() < originals.len() {
return None;
}
let speaker_for = |a: f64, b: f64| -> String {
let overlap = |s: &TranscriptSegment| (b.min(s.end_secs) - a.max(s.start_secs)).max(0.0);
let dist = |s: &TranscriptSegment| {
let mid = (a + b) / 2.0;
((s.start_secs + s.end_secs) / 2.0 - mid).abs()
};
matched
.iter()
.max_by(|x, y| {
overlap(x)
.partial_cmp(&overlap(y))
.unwrap_or(std::cmp::Ordering::Equal)
.then(dist(y).partial_cmp(&dist(x)).unwrap_or(std::cmp::Ordering::Equal))
})
.map(|s| s.speaker.clone())
.unwrap_or_default()
};
let mut segments: Vec<TranscriptSegment> =
t.segments.iter().filter(|s| !originals.iter().any(|o| same_time(s, o))).cloned().collect();
for f in fresh.iter().filter(|f| !f.text.trim().is_empty()) {
segments.push(TranscriptSegment {
speaker: speaker_for(f.start_secs, f.end_secs),
start_secs: f.start_secs,
end_secs: f.end_secs,
text: f.text.clone(),
});
}
segments.sort_by(|a, b| a.start_secs.partial_cmp(&b.start_secs).unwrap_or(std::cmp::Ordering::Equal));
Some(Transcript { segments })
}

#[cfg(test)]
mod tests {
use super::*;

fn seg(a: f64, b: f64, t: &str) -> Segment {
Segment { start_secs: a, end_secs: b, text: t.into() }
}
fn tseg(sp: &str, a: f64, b: f64, t: &str) -> TranscriptSegment {
TranscriptSegment { speaker: sp.into(), start_secs: a, end_secs: b, text: t.into() }
}

#[test]
fn replaces_window_and_keeps_speakers() {
let t = Transcript {
segments: vec![
tseg("me", 0.0, 2.0, "Добрый день"),
tseg("spk0", 3.0, 5.0, "Then we go"),
tseg("spk1", 5.5, 8.0, "to the"),
tseg("me", 9.0, 10.0, "Спасибо"),
],
};
let originals = vec![seg(3.0, 5.0, "Then we go"), seg(5.5, 8.0, "to the")];
let fresh = vec![seg(3.1, 5.2, "Потом переходим"), seg(5.6, 8.0, "к следующему")];
let out = apply_window(&t, &originals, &fresh).unwrap();
let got: Vec<(&str, &str)> = out.segments.iter().map(|s| (s.speaker.as_str(), s.text.as_str())).collect();
assert_eq!(got, [("me", "Добрый день"), ("spk0", "Потом переходим"), ("spk1", "к следующему"), ("me", "Спасибо")]);
}

#[test]
fn skips_windows_the_user_edited() {
let t = Transcript { segments: vec![tseg("me", 0.0, 2.0, "правка"), tseg("me", 2.5, 3.0, "ещё")] };
// Пользователь удалил/сдвинул одну из исходных реплик — окно не трогаем.
let originals = vec![seg(0.0, 2.0, "х"), seg(4.0, 5.0, "у")];
assert!(apply_window(&t, &originals, &[seg(0.0, 2.0, "новое")]).is_none());
}

#[test]
fn plan_roundtrip_and_clear() {
let dir = tempfile::tempdir().unwrap();
let plan = RefinePlan {
jobs: vec![RefineJob {
wav: "mic_norm.wav".into(),
windows: vec![RefineWindow {
start_secs: 1.0,
end_secs: 15.0,
kind: RefineKind::Hard,
severity: 1.5,
speech_secs: 8.0,
confidence: Some(0.6),
originals: vec![seg(1.0, 3.0, "а")],
}],
}],
language: Some("ru".into()),
vocabulary: None,
};
save(dir.path(), &plan).unwrap();
assert_eq!(load(dir.path()).unwrap(), Some(plan));
save(dir.path(), &RefinePlan::default()).unwrap();
assert_eq!(load(dir.path()).unwrap(), None);
}
}
2 changes: 1 addition & 1 deletion core/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fn validate_id(id: &str) -> AppResult<()> {
Ok(())
}

fn meeting_dir(data_root: &Path, id: &str) -> PathBuf {
pub fn meeting_dir(data_root: &Path, id: &str) -> PathBuf {
data_root.join("meetings").join(id)
}

Expand Down
46 changes: 37 additions & 9 deletions core/src/whisper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ pub struct WhisperTranscriber {
prompt: Option<String>,
}

/// Режим быстрой расшифровки (уточнение трудных мест).
struct FastMode {
force: Option<String>,
prefer: Option<String>,
}

/// Выбор языка окна по вероятностям Whisper: приоритетный — если он не
/// исключён явно; другой — только когда модель в нём уверена (≥ 0.7), а у
/// приоритетного шансов почти нет (< 0.1).
Expand Down Expand Up @@ -148,20 +154,35 @@ impl WhisperTranscriber {
on_progress: &dyn Fn(usize, usize),
) -> AppResult<Vec<Segment>> {
let audio = Self::read_wav_as_f32(wav_path)?;
Ok(self.transcribe_audio(&audio, window_secs, on_progress)?.0)
Ok(self.transcribe_audio(&audio, window_secs, on_progress, None)?.0)
}

/// Расшифровка отрывка (16 кГц моно f32) с оценкой: средняя вероятность
/// токенов текста (`None` — текста нет).
pub fn transcribe_samples(&self, samples: &[f32]) -> AppResult<(Vec<Segment>, Option<f32>)> {
self.transcribe_audio(samples, DEFAULT_WINDOW_SECS, &|_, _| {})
self.transcribe_audio(samples, DEFAULT_WINDOW_SECS, &|_, _| {}, None)
}

/// Быстрая расшифровка отрывка для уточнения трудных мест: жадное
/// декодирование (в разы быстрее beam search) и без лишнего прохода
/// определения языка, если язык известен (`force`). `prefer` — язык
/// определяется по звуку с этим приоритетом (для реплик «не того языка»).
pub fn transcribe_fast(
&self,
samples: &[f32],
force: Option<&str>,
prefer: Option<&str>,
) -> AppResult<(Vec<Segment>, Option<f32>)> {
let fast = FastMode { force: force.map(str::to_string), prefer: prefer.map(str::to_string) };
self.transcribe_audio(samples, DEFAULT_WINDOW_SECS, &|_, _| {}, Some(&fast))
}

fn transcribe_audio(
&self,
audio: &[f32],
window_secs: usize,
on_progress: &dyn Fn(usize, usize),
fast: Option<&FastMode>,
) -> AppResult<(Vec<Segment>, Option<f32>)> {
let (mut prob_sum, mut prob_n) = (0.0f32, 0usize);
if audio.is_empty() {
Expand Down Expand Up @@ -189,15 +210,22 @@ impl WhisperTranscriber {

// Beam search заметно точнее жадного декодирования (меньше пропусков
// и искажённых слов); у turbo-моделей декодер лёгкий, цена небольшая.
let mut params = FullParams::new(SamplingStrategy::BeamSearch {
beam_size: 5,
patience: -1.0,
});
params.set_n_threads(n_threads);
let mut params = if fast.is_some() {
FullParams::new(SamplingStrategy::Greedy { best_of: 1 })
} else {
FullParams::new(SamplingStrategy::BeamSearch { beam_size: 5, patience: -1.0 })
};
// Фоновое уточнение — не больше половины ядер: компьютер остаётся
// отзывчивым и не перегревается.
params.set_n_threads(if fast.is_some() { (n_threads / 2).max(2) } else { n_threads });
let mut window_lang: Option<String> = None;
match self.language.as_deref() {
let (language, prefer) = match fast {
Some(f) => (f.force.as_deref().or(Some("auto")), f.prefer.as_deref()),
None => (self.language.as_deref(), self.prefer.as_deref()),
};
match language {
Some("auto") | None => {
if let Some(prefer) = self.prefer.as_deref() {
if let Some(prefer) = prefer {
window_lang = Some(self.detect_preferring(&mut state, chunk, prefer, n_threads as usize));
}
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "memiro",
"private": true,
"version": "0.13.3",
"version": "0.13.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading
Loading