7158f1fff92dd10a4e1afa8e1ae52046ac326227
Ветки
Перейти к файлуt
A
anc
Запись всего звука, папка хранения, точнее распознавание
- Звук собеседников — весь звук компьютера, кроме самого Skillok Meet:
Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в
режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра
по приложению. Поиск Zoom и зависимость windows убраны.
- Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт
с микрофона, а интерфейс говорит почему.
- Настройки: папка хранения записей (выбрать, открыть, вернуть по
умолчанию; записи переносятся целиком), точность распознавания —
по умолчанию полная whisper-large-v3.
- «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio
сразу 16 кГц.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96a28a9
30 сентября 2026 г.
commands.rs
//! Команды для интерфейса. Каждая — одно действие человека; сама программа
//! ничего не запускает. Ошибка уходит строкой и показывается как есть, поэтому
//! ошибки модулей написаны по-русски.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use chrono::{DateTime, Local};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tokio_util::sync::CancellationToken;
use meet_audio::source::{Capture, CaptureError};
use crate::ai::{self, Command};
use crate::meetings::{AudioTrack, Meeting, MeetingDetail, Store};
use crate::openrouter::{Client, KeyInfo, SttSegment};
use crate::recorder::{self, Session, Track};
use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};
use crate::transcribe::{self, BoxFuture, Stt};
use crate::transcript::{self, Speaker};
pub struct Recording {
meeting_id: String,
started_at: DateTime<Local>,
system_error: Option<String>,
session: Session,
}
pub struct AppState {
pub settings_path: PathBuf,
/// Хранилище в папке из настроек; меняется, когда человек выбирает другую.
pub store: RwLock<Arc<Store>>,
/// Папка записей по умолчанию — в данных программы.
pub default_meetings_dir: PathBuf,
pub secrets: Arc<dyn Secrets>,
pub recording: Mutex<Option<Recording>>,
/// Работа, которая сейчас идёт над встречей (расшифровка или ответ ИИ),
/// и её отмена — по кнопке «Отменить».
pub jobs: Mutex<HashMap<String, CancellationToken>>,
}
impl AppState {
fn store(&self) -> Arc<Store> {
self.store.read().unwrap_or_else(|p| p.into_inner()).clone()
}
fn settings(&self) -> Result<Settings, String> {
Settings::load(&self.settings_path).map_err(|e| e.to_string())
}
fn key(&self) -> Result<String, String> {
self.secrets
.get(OPENROUTER_KEY)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Не подключён ИИ: добавьте ключ OpenRouter в настройках".into())
}
/// Начинает работу над встречей; вторая работа над той же встречей — отказ.
/// Работа снимается, когда `Job` уходит из области видимости.
fn start_job(&self, id: &str) -> Result<Job<'_>, String> {
let mut jobs = self.jobs.lock().unwrap_or_else(|p| p.into_inner());
if jobs.contains_key(id) {
return Err("Над этой записью уже идёт работа — дождитесь её или отмените".into());
}
let token = CancellationToken::new();
jobs.insert(id.to_string(), token.clone());
Ok(Job {
state: self,
id: id.to_string(),
token,
})
}
fn is_recording(&self, id: &str) -> bool {
self.recording
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.is_some_and(|r| r.meeting_id == id)
}
}
struct Job<'a> {
state: &'a AppState,
id: String,
token: CancellationToken,
}
impl Drop for Job<'_> {
fn drop(&mut self) {
self.state
.jobs
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(&self.id);
}
}
fn title_now(prefix: &str) -> String {
format!("{prefix} {}", Local::now().format("%d.%m, %H:%M"))
}
// ── Настройки и ключ ────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct SettingsView {
settings: Settings,
/// Ключ с закрытой серединой; `None` — ключа нет.
openrouter_key: Option<String>,
meetings_dir: String,
default_meetings_dir: String,
/// Эта машина умеет записывать звук (Windows, macOS).
capture_supported: bool,
}
#[tauri::command]
pub fn get_settings(state: State<'_, AppState>) -> Result<SettingsView, String> {
Ok(SettingsView {
settings: state.settings()?,
openrouter_key: state
.secrets
.get(OPENROUTER_KEY)
.map_err(|e| e.to_string())?
.map(|k| mask_key(&k)),
meetings_dir: state.store().root().display().to_string(),
default_meetings_dir: state.default_meetings_dir.display().to_string(),
capture_supported: meet_audio::CAPTURE_SUPPORTED,
})
}
#[tauri::command]
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Result<(), String> {
settings.save(&state.settings_path).map_err(|e| e.to_string())
}
/// Ключ сохраняется, только если OpenRouter его принял.
#[tauri::command]
pub async fn set_openrouter_key(state: State<'_, AppState>, key: String) -> Result<KeyInfo, String> {
let key = key.trim().to_string();
if key.is_empty() {
return Err("Вставьте ключ OpenRouter".into());
}
let info = Client::new(key.clone())
.key_info()
.await
.map_err(|e| e.to_string())?;
state
.secrets
.set(OPENROUTER_KEY, &key)
.map_err(|e| e.to_string())?;
Ok(info)
}
#[tauri::command]
pub async fn check_openrouter_key(state: State<'_, AppState>) -> Result<KeyInfo, String> {
Client::new(state.key()?)
.key_info()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_openrouter_key(state: State<'_, AppState>) -> Result<(), String> {
state.secrets.delete(OPENROUTER_KEY).map_err(|e| e.to_string())
}
// ── Встречи и заметки ───────────────────────────────────────────────────────
#[tauri::command]
pub fn list_meetings(state: State<'_, AppState>) -> Result<Vec<Meeting>, String> {
state.store().list().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_meeting(state: State<'_, AppState>, id: String) -> Result<MeetingDetail, String> {
state.store().get(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> {
state.store().save_note(&id, &text).map_err(|e| e.to_string())
}
/// Убрать результат — раздел заметки с этим заголовком.
#[tauri::command]
pub fn remove_section(state: State<'_, AppState>, id: String, heading: String) -> Result<String, String> {
state
.store()
.remove_section(&id, &heading)
.map_err(|e| e.to_string())
}
/// Новая папка для записей: все записи переносятся туда, дальше пишутся туда
/// же. `None` — вернуть папку по умолчанию.
#[tauri::command]
pub fn set_storage_dir(state: State<'_, AppState>, path: Option<String>) -> Result<String, String> {
if state
.recording
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_some()
{
return Err("Сначала остановите запись".into());
}
if !state.jobs.lock().unwrap_or_else(|p| p.into_inner()).is_empty() {
return Err("Дождитесь, пока закончится расшифровка или ответ ИИ".into());
}
let target = match path.as_deref().map(str::trim) {
Some(p) if !p.is_empty() => PathBuf::from(p),
_ => state.default_meetings_dir.clone(),
};
let mut settings = state.settings()?;
let mut slot = state.store.write().unwrap_or_else(|p| p.into_inner());
slot.move_all_to(&target).map_err(|e| e.to_string())?;
settings.storage_dir = if target == state.default_meetings_dir {
String::new()
} else {
target.display().to_string()
};
settings.save(&state.settings_path).map_err(|e| e.to_string())?;
*slot = Arc::new(Store::new(&target));
Ok(target.display().to_string())
}
/// Открыть папку с записями или с одной записью в Finder / Проводнике.
#[tauri::command]
pub fn open_folder(state: State<'_, AppState>, id: Option<String>) -> Result<(), String> {
let store = state.store();
let path = match id {
Some(id) => store.folder(&id).map_err(|e| e.to_string())?,
None => store.root().to_path_buf(),
};
std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
tauri_plugin_opener::open_path(&path, None::<&str>).map_err(|e| e.to_string())
}
/// «Отменить» — расшифровку или ответ ИИ, что сейчас идёт над встречей.
#[tauri::command]
pub fn cancel_job(state: State<'_, AppState>, id: String) {
if let Some(token) = state.jobs.lock().unwrap_or_else(|p| p.into_inner()).get(&id) {
token.cancel();
}
}
#[tauri::command]
pub fn rename_meeting(state: State<'_, AppState>, id: String, title: String) -> Result<Meeting, String> {
state.store().rename(&id, &title).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_meeting(state: State<'_, AppState>, id: String) -> Result<(), String> {
if state.is_recording(&id) {
return Err("Сначала остановите запись".into());
}
let _job = state.start_job(&id)?;
state.store().delete(&id).map_err(|e| e.to_string())
}
/// Новая встреча из аудиофайла. Файл сразу читается целиком: так сразу видно,
/// что формат подходит, и известна длительность.
#[tauri::command]
pub async fn add_audio(state: State<'_, AppState>, path: String) -> Result<Meeting, String> {
let source = PathBuf::from(&path);
let decoded = {
let source = source.clone();
tokio::task::spawn_blocking(move || meet_audio::decode::decode_file(&source))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?
};
let duration_ms = meet_audio::pcm::samples_to_ms(decoded.len());
let title = source
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Аудио")
.to_string();
let meeting = state
.store()
.create(&title, Local::now())
.map_err(|e| e.to_string())?;
state
.store()
.attach_audio_file(&meeting.id, &source, duration_ms)
.map_err(|e| e.to_string())
}
// ── Расшифровка ─────────────────────────────────────────────────────────────
struct OpenRouterStt {
client: Client,
model: String,
language: Option<String>,
}
impl Stt for OpenRouterStt {
fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
Box::pin(async move {
self.client
.transcribe(&self.model, &wav, self.language.as_deref())
.await
.map(|t| t.segments)
.map_err(|e| e.to_string())
})
}
}
#[derive(Serialize)]
pub struct TranscribeResult {
note: String,
failed: usize,
last_error: Option<String>,
}
#[derive(Serialize, Clone)]
struct Progress<'a> {
meeting_id: &'a str,
done: usize,
total: usize,
}
fn read_track(path: &Path) -> Result<Vec<i16>, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
// Свои дорожки записи читаются напрямую, чужие файлы — декодером.
match meet_audio::pcm::decode_wav(&bytes) {
Ok(samples) => Ok(samples),
Err(_) => meet_audio::decode::decode_file(path).map_err(|e| e.to_string()),
}
}
#[tauri::command]
pub async fn transcribe_meeting(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<TranscribeResult, String> {
if state.is_recording(&id) {
return Err("Сначала остановите запись".into());
}
let key = state.key()?;
let job = state.start_job(&id)?;
let settings = state.settings()?;
let meeting = state.store().get(&id).map_err(|e| e.to_string())?.meeting;
if meeting.audio.is_empty() {
return Err("В этой встрече нет звука — запишите встречу или добавьте аудиофайл".into());
}
let mut tracks: Vec<(Speaker, PathBuf)> = Vec::new();
for AudioTrack { file, speaker, .. } in &meeting.audio {
tracks.push((
speaker.clone(),
state.store().audio_path(&id, file).map_err(|e| e.to_string())?,
));
}
let tracks = tokio::task::spawn_blocking(move || {
tracks
.into_iter()
.map(|(s, p)| read_track(&p).map(|a| (s, a)))
.collect::<Result<Vec<_>, _>>()
})
.await
.map_err(|e| e.to_string())??;
let stt = Arc::new(OpenRouterStt {
client: Client::new(key),
model: settings.stt_model.clone(),
language: settings.stt_language().map(str::to_string),
});
let outcome = transcribe::run(
tracks,
stt,
transcribe::Config::default(),
&job.token,
|done, total| {
let _ = app.emit(
"meet://progress",
Progress {
meeting_id: &id,
done,
total,
},
);
},
)
.await
.map_err(|e| e.to_string())?;
if outcome.failed > 0 && outcome.failed == outcome.segments.len() {
// Не распознался ни один кусок — в заметку нечего класть, причина важнее.
return Err(outcome
.last_error
.unwrap_or_else(|| "распознавание не удалось".into()));
}
let note = state
.store()
.upsert_section(
&id,
transcript::HEADING,
&transcript::to_markdown(&outcome.segments),
)
.map_err(|e| e.to_string())?;
state
.store()
.mark_transcribed(&id, Local::now())
.map_err(|e| e.to_string())?;
Ok(TranscribeResult {
note,
failed: outcome.failed,
last_error: outcome.last_error,
})
}
// ── Команды ИИ ──────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CommandResult {
note: String,
/// Заголовок раздела с ответом — его вкладку интерфейс откроет.
heading: String,
}
/// Получить результат: ответ ИИ ложится разделом на место прежнего с тем же
/// заголовком. Прежний ответ модель не видит — иначе она его пересказывает.
#[tauri::command]
pub async fn run_command(
state: State<'_, AppState>,
id: String,
command: Command,
) -> Result<CommandResult, String> {
let key = state.key()?;
let job = state.start_job(&id)?;
let settings = state.settings()?;
let note = state.store().get(&id).map_err(|e| e.to_string())?.note;
let heading = ai::heading(&command).map_err(|e| e.to_string())?;
let prepared =
ai::prepare(&command, &crate::notes::remove(¬e, &heading)).map_err(|e| e.to_string())?;
let client = Client::new(key);
let answer = tokio::select! {
answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user) => answer.map_err(|e| e.to_string())?.0,
_ = job.token.cancelled() => return Err("Отменено".into()),
};
let note = state
.store()
.upsert_section(&id, &prepared.heading, &ai::to_block(&prepared.heading, &answer))
.map_err(|e| e.to_string())?;
Ok(CommandResult {
note,
heading: prepared.heading,
})
}
// ── Запись по кнопке ────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct RecordingView {
meeting_id: String,
started_at: DateTime<Local>,
paused: bool,
/// Пишется звук компьютера (собеседники), а не только микрофон.
system: bool,
/// Почему звук компьютера не пишется — чтобы подсказать, где разрешить.
system_error: Option<String>,
}
#[tauri::command]
pub fn recorder_status(state: State<'_, AppState>) -> Option<RecordingView> {
state
.recording
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.map(|r| RecordingView {
meeting_id: r.meeting_id.clone(),
started_at: r.started_at,
paused: r.session.is_paused(),
system: r.system_error.is_none(),
system_error: r.system_error.clone(),
})
}
/// Начать запись: микрофон и весь звук компьютера — голоса собеседников в
/// любом сервисе встреч.
#[tauri::command]
pub fn recorder_start(app: AppHandle, state: State<'_, AppState>) -> Result<Meeting, String> {
if !meet_audio::CAPTURE_SUPPORTED {
return Err("На этой системе запись не поддерживается — можно добавить готовый аудиофайл.".into());
}
let mut slot = state.recording.lock().unwrap_or_else(|p| p.into_inner());
if slot.is_some() {
return Err("Запись уже идёт".into());
}
let meeting = state
.store()
.create(&title_now("Встреча"), Local::now())
.map_err(|e| e.to_string())?;
let id = meeting.id.clone();
let mut tracks = Vec::new();
let mut errors = Vec::new();
let mut add = |speaker: Speaker, capture: Result<Box<dyn Capture>, CaptureError>| {
let path = state
.store()
.audio_path(&id, speaker.track_file())
.map_err(|e| e.to_string());
match (capture, path) {
(Ok(capture), Ok(path)) => tracks.push(Track {
speaker,
capture,
path,
}),
(Err(e), _) => errors.push(e.to_string()),
(_, Err(e)) => errors.push(e),
}
};
add(Speaker::Me, meet_audio::open_microphone());
add(Speaker::Others, meet_audio::open_system_audio());
if tracks.is_empty() {
let _ = state.store().delete(&id);
return Err(errors.join("; "));
}
let system_error = (!tracks.iter().any(|t| t.speaker == Speaker::Others)).then(|| {
errors
.last()
.cloned()
.unwrap_or_else(|| "звук компьютера недоступен".into())
});
let audio: Vec<AudioTrack> = tracks
.iter()
.map(|t| AudioTrack {
file: t.speaker.track_file().into(),
speaker: t.speaker.clone(),
duration_ms: 0,
})
.collect();
let emit_id = id.clone();
let session = Session::start(
tracks,
Arc::new(move |e: recorder::Event| {
let _ = app.emit(
"meet://recorder",
serde_json::json!({ "meeting_id": emit_id, "event": e }),
);
}),
)?;
let meeting = state
.store()
.set_recording(&id, true, audio)
.map_err(|e| e.to_string())?;
*slot = Some(Recording {
meeting_id: id,
started_at: Local::now(),
system_error,
session,
});
Ok(meeting)
}
#[tauri::command]
pub fn recorder_pause(state: State<'_, AppState>, paused: bool) -> Result<(), String> {
match state.recording.lock().unwrap_or_else(|p| p.into_inner()).as_ref() {
Some(r) => {
r.session.set_paused(paused);
Ok(())
}
None => Err("Запись не идёт".into()),
}
}
#[tauri::command]
pub async fn recorder_stop(state: State<'_, AppState>) -> Result<Meeting, String> {
let recording = state
.recording
.lock()
.unwrap_or_else(|p| p.into_inner())
.take()
.ok_or("Запись не идёт")?;
let written = tokio::task::spawn_blocking(move || recording.session.stop())
.await
.map_err(|e| e.to_string())?;
let audio: Vec<AudioTrack> = written
.into_iter()
.map(|w| AudioTrack {
file: w.speaker.track_file().into(),
speaker: w.speaker,
duration_ms: w.duration_ms,
})
.collect();
state
.store()
.set_recording(&recording.meeting_id, false, audio)
.map_err(|e| e.to_string())
}