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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
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.14.0] — 2026-10-07

### Added
- **Merge recordings into one conversation.** When a recording was stopped and continued, press «Merge recordings» above the list and click the parts in order — they are numbered 1, 2, 3… (or switch to «By time» to glue them oldest first). Memiro creates a new meeting from the parts (tracks stay aligned, a short pause between parts); if every part was transcribed, the transcripts are joined too — no need to transcribe again. The original recordings stay in the list.
- **AI correction of the transcript** (when AI is connected): after transcription and background refining, the AI fixes misheard words («над дубитом» → «на дубе том»), terms and punctuation by the meaning of the conversation. Only text is sent; every phrase stays in its place with its time and speaker; rewrites, translations and changes to phrases you edited are rejected. «✨» on the transcript runs it by hand, «Undo» returns the previous text. Switch: Settings → AI → «AI transcript correction».
- **Recognition on your AI server** (optional): Settings → Recognition → «Where to recognise» → «On the AI server». Audio is sent to your server (OpenAI and compatible, /audio/transcriptions; model whisper-1 by default, «Check server» button) in up to 10-minute parts; voice separation stays on the computer. By default recognition stays on this computer.
- **Folders in the meeting list.** Create a folder with the button above the list, then drag meetings into it or use «⋯ → To folder…». Folders collapse, can be renamed and deleted (meetings from a deleted folder stay in the list). Search shows only folders with matching meetings.

## [0.13.8] — 2026-10-04

### Fixed
Expand Down
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,29 @@ Ollama), `ai::pick_model` (настроенная → ближайшая по п
- Экспорт (v0.8): одна кнопка «⬇ Экспорт» → `ExportModal`: Word (.docx — свой
генератор `docx.ts`, без зависимостей), Markdown, TXT, субтитры SRT; в документ
складываются стенограмма (опц. таймкоды) + выбранные ИИ-отчёты.
- **ИИ для качества расшифровки (v0.14.0)**: ИИ-корректура — `core/src/aifix.rs`
(`correct`: пачки по 40 реплик → JSON-массив строк той же длины; правка
принимается, если не пересказ (≤ 45 % изменённых символов), тот же язык, не
пустая; реплики `origin` не трогаются), команды `ai_correct_transcript`
(бэкап `transcript.before_ai.json`, событие `ai-fix-progress`),
`ai_correction_available`, `revert_ai_correction`; фронт —
`aiauto.ts::runAutoCorrect` (после `refine_transcript`, `aiAuto.correct`),
кнопка ✨ и плашка «Вернуть как было» в `MeetingView`. Распознавание на
сервере — `core/src/cloud_asr.rs` (`/audio/transcriptions`, multipart,
куски ≤ 10 мин в паузах, `verbose_json` → иначе `json`), `ServerAsr` в
`load_asr` при `TranscribeOptions.cloud` (`settings.whisper.engine =
"server"`, `serverModel`), проверка — `cloud_asr_check`.
- **Папки и объединение (v0.14.0)**: таблица `collections` + столбец
`meetings.collection` (миграция; имя `folder` занято каталогом встречи),
команды `list_collections`/`create_collection`/`rename_collection`/
`delete_collection` (встречи остаются)/`set_meeting_collection`; фронт —
`src/library.ts` (`groupMeetings`, `mergeOrder`, `toggleSelected`),
`MeetingList` (папки, «⋯ → В папку…», перетаскивание, свёрнутые —
localStorage `3uxo.folders.closed`), `Sidebar` (кнопки над списком, панель
объединения 1/2/3 · «По времени»). Объединение — `core/src/merge.rs`
(`merge_meetings`: две дорожки дополняются тишиной до общей длины, 1 с паузы;
есть импорт — одна дорожка `audio.wav`; расшифровки сшиваются, если есть у
всех), команда `merge_meetings`; исходные записи остаются.
- Встречи (v0.8.2): меню «⋯» в `MeetingList` (переименовать / заметки / удалить) →
`MeetingEditDialog` (порталом в body: у сайдбара `backdrop-filter`); заметки —
столбец `notes` в БД (миграция), команда `update_meeting_notes`, поле в
Expand Down
208 changes: 208 additions & 0 deletions core/src/aifix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! ИИ-корректура расшифровки (через сервер ИИ пользователя, по желанию).
//!
//! Локальное распознавание ошибается в словах («над дубитом» вместо «на дубе
//! том»), терминах и пунктуации. Чат-модель видит текст целиком и исправляет
//! такие ошибки — но только их: каждая реплика возвращается на своё место,
//! время и говорящий не меняются. Защита от «творчества» модели: правка
//! принимается, только если она близка к исходной реплике (не пересказ), не
//! меняет язык и не пустая. Реплики, которые пользователь дописал или
//! исправил сам, не трогаются. На сервер уходит только текст, звук — нет.

use crate::ai::ChatBackend;
use crate::error::AppResult;
use crate::transcript::Transcript;

/// Реплик в одном запросе (и не больше ~4000 символов).
const BATCH: usize = 40;
const BATCH_CHARS: usize = 4000;
/// Доля изменённых символов, выше которой правка считается пересказом.
const MAX_CHANGE: f32 = 0.45;

const SYSTEM: &str = "Ты — корректор автоматической расшифровки речи. Тебе дают JSON-массив реплик \
разговора по порядку. Исправь ТОЛЬКО ошибки распознавания: неверно услышанные или разорванные слова \
(по смыслу соседних реплик), термины и имена (см. словарь, если он дан), пунктуацию и заглавные буквы. \
НЕЛЬЗЯ: пересказывать, сокращать, дополнять, убирать слова-паразиты и повторы, переводить на другой язык, \
объединять или делить реплики. Если реплика верна — верни её без изменений. Ответ — строго JSON-массив \
строк той же длины и в том же порядке, без пояснений.";

/// Итог корректуры: новая расшифровка и сколько реплик исправлено.
#[derive(Debug, Clone, PartialEq)]
pub struct Corrected {
pub transcript: Transcript,
pub changed: usize,
/// Правок, отклонённых как пересказ / смена языка.
pub rejected: usize,
}

/// Расстояние Левенштейна по символам (для оценки размера правки).
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}

/// Принимаем ли правку модели: не пустая, тот же язык, не пересказ.
pub fn acceptable(original: &str, fixed: &str) -> bool {
let (o, f) = (original.trim(), fixed.trim());
if f.is_empty() {
return false;
}
if o == f {
return true;
}
if let (Some(a), Some(b)) = (crate::langguard::dominant_script(o), crate::langguard::dominant_script(f)) {
if a != b {
return false;
}
}
let norm = |s: &str| s.to_lowercase();
let d = edit_distance(&norm(o), &norm(f)) as f32;
let len = o.chars().count().max(f.chars().count()).max(1) as f32;
d / len <= MAX_CHANGE
}

/// Достаёт JSON-массив строк из ответа модели (с блоком кода или без).
pub fn parse_lines(answer: &str) -> Option<Vec<String>> {
let a = answer.find('[')?;
let b = answer.rfind(']')?;
if b <= a {
return None;
}
serde_json::from_str::<Vec<String>>(&answer[a..=b]).ok()
}

/// Корректура расшифровки. `glossary` — термины словаря (подсказка модели).
/// `on_progress(done, total)` — после каждого запроса.
pub fn correct(
backend: &dyn ChatBackend,
t: &Transcript,
glossary: Option<&str>,
on_progress: &dyn Fn(usize, usize),
) -> AppResult<Corrected> {
// Пачки подряд идущих реплик (контекст для модели).
let mut batches: Vec<std::ops::Range<usize>> = Vec::new();
let mut start = 0;
let mut chars = 0;
for (i, s) in t.segments.iter().enumerate() {
chars += s.text.chars().count();
if i + 1 - start >= BATCH || chars >= BATCH_CHARS {
batches.push(start..i + 1);
start = i + 1;
chars = 0;
}
}
if start < t.segments.len() {
batches.push(start..t.segments.len());
}
let mut out = t.clone();
let (mut changed, mut rejected) = (0, 0);
let total = batches.len();
for (k, range) in batches.into_iter().enumerate() {
let lines: Vec<&str> = t.segments[range.clone()].iter().map(|s| s.text.as_str()).collect();
let mut user = String::new();
if let Some(g) = glossary.map(str::trim).filter(|g| !g.is_empty()) {
user.push_str("Словарь (так пишутся термины и имена): ");
user.push_str(&g.replace('\n', ", "));
user.push_str("\n\n");
}
user.push_str(&serde_json::to_string(&lines).unwrap_or_default());
let answer = backend.chat(SYSTEM, &user)?;
// Ответ не той длины — пачку не трогаем (иначе реплики съедут).
if let Some(fixed) = parse_lines(&answer).filter(|f| f.len() == lines.len()) {
for (i, f) in range.clone().zip(fixed) {
let seg = &mut out.segments[i];
if seg.is_user() || seg.text.trim() == f.trim() {
continue;
}
if acceptable(&seg.text, &f) {
seg.text = f.trim().to_string();
changed += 1;
} else {
rejected += 1;
}
}
}
on_progress(k + 1, total);
}
Ok(Corrected { transcript: out, changed, rejected })
}

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

struct Fake(Vec<String>);
impl ChatBackend for Fake {
fn chat(&self, _s: &str, _u: &str) -> AppResult<String> {
Ok(format!("```json\n{}\n```", serde_json::to_string(&self.0).unwrap()))
}
}

fn seg(t: &str, origin: Option<&str>) -> TranscriptSegment {
TranscriptSegment { speaker: "me".into(), start_secs: 0.0, end_secs: 1.0, text: t.into(), origin: origin.map(Into::into) }
}

#[test]
fn fixes_misheard_words_but_not_rewrites() {
let t = Transcript {
segments: vec![
seg("Золотая цепь над дубитом", None),
seg("заведём задачу в жиру", None),
seg("ну короче я в общем думаю что надо", None),
seg("Это я поправил сам", Some("edited")),
seg("Давайте начнём", None),
],
};
let fake = Fake(vec![
"Золотая цепь на дубе том".into(),
"Заведём задачу в Jira.".into(),
// Пересказ — отклоняется.
"Предлагаю действовать".into(),
"Совсем другое".into(),
// Перевод — отклоняется.
"Let's start".into(),
]);
let r = correct(&fake, &t, Some("Jira"), &|_, _| {}).unwrap();
let texts: Vec<&str> = r.transcript.segments.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
["Золотая цепь на дубе том", "Заведём задачу в Jira.", "ну короче я в общем думаю что надо", "Это я поправил сам", "Давайте начнём"]
);
assert_eq!((r.changed, r.rejected), (2, 2));
}

#[test]
fn wrong_length_answer_changes_nothing() {
let t = Transcript { segments: vec![seg("раз", None), seg("два", None)] };
let r = correct(&Fake(vec!["один".into()]), &t, None, &|_, _| {}).unwrap();
assert_eq!(r.transcript, t);
assert_eq!(parse_lines("нет массива"), None);
}

#[test]
fn batches_long_transcripts() {
let t = Transcript { segments: (0..95).map(|i| seg(&format!("реплика {i}"), None)).collect() };
let calls = std::sync::Mutex::new(0);
struct Echo<'a>(&'a std::sync::Mutex<usize>);
impl ChatBackend for Echo<'_> {
fn chat(&self, _s: &str, u: &str) -> AppResult<String> {
*self.0.lock().unwrap() += 1;
Ok(u.to_string())
}
}
let r = correct(&Echo(&calls), &t, None, &|_, _| {}).unwrap();
assert_eq!(*calls.lock().unwrap(), 3);
assert_eq!(r.changed, 0);
}
}
14 changes: 14 additions & 0 deletions core/src/cli_transcriber.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ pub struct TranscribeOptions {
/// Языки разговора («ru», «en»): распознавание не выходит за них.
#[serde(default)]
pub languages: Option<Vec<String>>,
/// Распознавание на сервере ИИ пользователя (по желанию; звук уходит на
/// сервер). `None` — на этом компьютере.
#[serde(default)]
pub cloud: Option<CloudAsr>,
}

/// Сервер распознавания речи (OpenAI-совместимый `/audio/transcriptions`).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct CloudAsr {
pub base_url: String,
pub api_key: String,
/// Модель распознавания (например, `whisper-1`); пусто — по умолчанию.
#[serde(default)]
pub model: String,
}

/// Which whisper CLI family is being used; drives argument construction.
Expand Down
Loading
Loading