e581846c7ddcb785b10d499fa25b4c0cde98f88f
Ветки
Перейти к файлуt
A
anc
Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст
Запись:
- Время — по записанному звуку, паузы не в счёт (позиция сессии записи,
ms в событиях уровня, position_ms в recorder_status).
- Живая «волна» последней минуты, «Микрофон / Звук компьютера —
слышно / тишина», объём записи; «Остановить и расшифровать».
- «★ Отметить момент»: отметка с подписью (Meeting.markers,
add_marker / set_markers); видна в расшифровке и на проигрывателе.
Проигрыватель (player.ts):
- Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна»
всей встречи (клик и протяжка — перемотка, отметки, текущее место),
«★» — отметить место. Две дорожки записи играют вместе и
подравниваются. Живёт дольше перерисовок экрана — звук не прерывается.
- Звук читается с диска протоколом asset: только из папки записей
(protocol-asset, scope на папку, media-src в CSP); «волна» считается
один раз и кэшируется в peaks.json.
- Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика
подсвечивается; пробел — играть/пауза, ←/→ — 5 с.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b82e259
2 октября 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, Manager, State};
use tokio_util::sync::CancellationToken;
use meet_audio::pcm::{WavFile, WavWriter};
use meet_audio::source::{Capture, CaptureError};
use crate::ai::{self, Command};
use crate::meetings::{AudioTrack, Marker, 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::speakers;
use crate::transcribe::{self, BoxFuture, Stt};
use crate::transcript::{self, Speaker};
use meet_voices::{Count, DEFAULT_THRESHOLD};
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>>,
/// Модель голосов (WeSpeaker, ONNX) из ресурсов программы. Нет файла —
/// расшифровка идёт без разбора по голосам.
pub voices_model: PathBuf,
/// Проверка звука на экране «Новая запись»: дорожки без файла, только уровни.
pub check: Mutex<Option<Session>>,
}
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(
app: AppHandle,
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));
allow_playback(&app, &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>,
/// Сколько говорящих нашлось по голосам (0 — разбора не было).
voices: usize,
/// Почему разбора по голосам не было, если он должен был быть.
voices_error: Option<String>,
}
#[derive(Serialize, Clone)]
struct Progress<'a> {
meeting_id: &'a str,
/// Что идёт: `speech` — распознавание, `voices` — разбор по голосам.
stage: &'static str,
done: usize,
total: usize,
/// Новые реплики по порядку времени, абзацами как в заметке.
lines: Vec<String>,
}
/// Дорожка для расшифровки, читаемая с диска по кускам. Свои дорожки записи —
/// WAV нашего формата; чужой файл (mp3, m4a…) один раз перегоняется в такой
/// WAV рядом, чтобы и двухчасовой файл не держать в памяти всю расшифровку.
/// Второй путь — файл-времянка, его надо удалить после.
fn open_track(path: &Path) -> Result<(WavFile, Option<PathBuf>), String> {
if let Ok(f) = WavFile::open(path) {
return Ok((f, None));
}
let samples = meet_audio::decode::decode_file(path).map_err(|e| e.to_string())?;
let temp = path.with_extension("transcribe.wav");
let mut w = WavWriter::create(&temp).map_err(|e| format!("{}: {e}", temp.display()))?;
w.write(&samples).map_err(|e| e.to_string())?;
w.flush().map_err(|e| e.to_string())?;
drop(samples);
let f = WavFile::open(&temp).map_err(|e| e.to_string())?;
Ok((f, Some(temp)))
}
#[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 opened = tokio::task::spawn_blocking(move || {
tracks
.into_iter()
.map(|(s, p)| open_track(&p).map(|a| (s, a)))
.collect::<Result<Vec<_>, _>>()
})
.await
.map_err(|e| e.to_string())??;
let mut temps = Vec::new();
let tracks: Vec<(Speaker, Arc<dyn transcribe::Samples>)> = opened
.into_iter()
.map(|(speaker, (file, temp))| {
temps.extend(temp);
(speaker, Arc::new(file) as Arc<dyn transcribe::Samples>)
})
.collect();
// Времянки удаляются при любом исходе, в том числе при отмене.
let _cleanup = RemoveOnDrop(temps);
let stt = Arc::new(OpenRouterStt {
client: Client::new(key),
model: settings.stt_model.clone(),
language: settings.stt_language().map(str::to_string),
});
let sources = tracks.clone();
let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), &job.token, |u| {
let _ = app.emit(
"meet://progress",
Progress {
meeting_id: &id,
stage: "speech",
done: u.done,
total: u.total,
lines: u.fresh.iter().map(transcript::live_line).collect(),
},
);
})
.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()));
}
// Разбор по голосам: «Собеседники» и файл — на «Спикер 1», «Спикер 2»…
// Не вышло (нет модели, не читается звук) — расшифровка всё равно
// сохраняется, со старыми подписями, а причина уходит человеку.
let mut segments = outcome.segments;
let mut voices_error = None;
if segments.iter().any(|s| speakers::diarized(&s.speaker)) {
let model = state.voices_model.clone();
let token = job.token.clone();
let emit = app.clone();
let emit_id = id.clone();
let input = segments.clone();
let result = tokio::task::spawn_blocking(move || {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
let mut extractor = meet_voices::Extractor::open(&model, threads).map_err(|e| e.to_string())?;
speakers::embed(&input, &sources, &mut extractor, &token, |done, total| {
let _ = emit.emit(
"meet://progress",
Progress {
meeting_id: &emit_id,
stage: "voices",
done,
total,
lines: Vec::new(),
},
);
})
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?;
match result {
Ok(voices) => {
segments = speakers::label(
&voices,
Count::Auto {
threshold: DEFAULT_THRESHOLD,
},
);
if let Err(e) = state.store().save_voices(&id, &voices) {
voices_error = Some(e.to_string());
}
}
Err(e) if e == "Расшифровка отменена" => return Err(e),
Err(e) => voices_error = Some(e),
}
}
let note = state
.store()
.upsert_section(&id, transcript::HEADING, &transcript::to_markdown(&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,
voices: speakers::voice_count(&segments),
voices_error,
})
}
#[derive(Serialize)]
pub struct RegroupResult {
note: String,
voices: usize,
}
/// Пересобрать разбиение по говорящим: `count` — сколько их (пусто — сколько
/// получится). Расшифровка раздела пересобирается из реплик: правки и
/// переименования в ней заменяются — интерфейс предупреждает.
#[tauri::command]
pub async fn regroup_speakers(
state: State<'_, AppState>,
id: String,
count: Option<usize>,
) -> Result<RegroupResult, String> {
let voices = state
.store()
.load_voices(&id)
.map_err(|_| "Для этой записи нет данных о голосах — расшифруйте её заново".to_string())?;
let count = match count {
Some(n) if n >= 1 => Count::Fixed(n),
_ => Count::Auto {
threshold: DEFAULT_THRESHOLD,
},
};
let segments = tokio::task::spawn_blocking(move || speakers::label(&voices, count))
.await
.map_err(|e| e.to_string())?;
let note = state
.store()
.upsert_section(&id, transcript::HEADING, &transcript::to_markdown(&segments))
.map_err(|e| e.to_string())?;
Ok(RegroupResult {
note,
voices: speakers::voice_count(&segments),
})
}
struct RemoveOnDrop(Vec<PathBuf>);
impl Drop for RemoveOnDrop {
fn drop(&mut self) {
for p in &self.0 {
let _ = std::fs::remove_file(p);
}
}
}
// ── Команды ИИ ──────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CommandResult {
note: String,
/// Заголовок раздела с ответом — его вкладку интерфейс откроет.
heading: String,
}
/// Получить результат: ответ ИИ ложится разделом на место прежнего с тем же
/// заголовком. Прежний ответ модель не видит — иначе она его пересказывает.
#[derive(Serialize, Clone)]
struct AnswerPiece<'a> {
meeting_id: &'a str,
heading: &'a str,
/// Новый кусок ответа; интерфейс дописывает его к уже показанному.
text: &'a str,
}
#[tauri::command]
pub async fn run_command(
app: AppHandle,
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, |text| {
let _ = app.emit("meet://answer", AnswerPiece { meeting_id: &id, heading: &prepared.heading, text });
}) => 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>,
/// Сколько звука записано, мс (паузы не в счёт).
position_ms: u64,
}
#[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(),
position_ms: r.session.position_ms(),
})
}
// ── Отметки и проигрыватель ─────────────────────────────────────────────────
/// Отметить момент: во время записи — там, где запись сейчас (`ms` пусто),
/// в проигрывателе — там, где звук (`ms` задан).
#[tauri::command]
pub fn add_marker(
state: State<'_, AppState>,
id: String,
ms: Option<u64>,
text: Option<String>,
) -> Result<Meeting, String> {
let ms = match ms {
Some(ms) => ms,
None => {
let slot = state.recording.lock().unwrap_or_else(|p| p.into_inner());
match slot.as_ref() {
Some(r) if r.meeting_id == id => r.session.position_ms(),
_ => return Err("Запись не идёт — отметьте место в проигрывателе".into()),
}
}
};
state
.store()
.add_marker(
&id,
Marker {
ms,
text: text.unwrap_or_default().trim().to_string(),
},
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_markers(state: State<'_, AppState>, id: String, markers: Vec<Marker>) -> Result<Meeting, String> {
state
.store()
.set_markers(&id, &markers)
.map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct PlayTrack {
/// Полный путь к файлу; интерфейс превращает его в адрес `asset:`.
path: String,
speaker: Speaker,
}
#[derive(Serialize)]
pub struct PlaybackView {
tracks: Vec<PlayTrack>,
duration_ms: u64,
peaks: Vec<f32>,
}
/// Что играть и «волна» записи. Во время записи — нельзя: дорожки ещё пишутся.
#[tauri::command]
pub async fn playback(state: State<'_, AppState>, id: String) -> Result<PlaybackView, String> {
if state.is_recording(&id) {
return Err("Запись ещё идёт".into());
}
let store = state.store();
let meeting = store.get(&id).map_err(|e| e.to_string())?.meeting;
let dir = store.meeting_dir(&id).map_err(|e| e.to_string())?;
let mut paths = Vec::new();
let mut tracks = Vec::new();
for t in &meeting.audio {
let path = store.audio_path(&id, &t.file).map_err(|e| e.to_string())?;
if path.is_file() {
tracks.push(PlayTrack {
path: path.display().to_string(),
speaker: t.speaker.clone(),
});
paths.push(path);
}
}
if tracks.is_empty() {
return Err("Файлов звука этой записи нет на диске".into());
}
let duration_ms = meeting.duration_ms();
let peaks = tokio::task::spawn_blocking(move || crate::playback::peaks(&dir, &paths, duration_ms))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(PlaybackView {
tracks,
duration_ms,
peaks: peaks.peaks,
})
}
/// Интерфейсу можно читать файлы звука только из папки записей.
pub fn allow_playback(app: &AppHandle, dir: &Path) {
let _ = app.asset_protocol_scope().allow_directory(dir, true);
}
/// Начать запись: микрофон и весь звук компьютера — голоса собеседников в
/// любом сервисе встреч.
#[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());
}
// Проверка звука держит те же устройства — гасим её до записи.
stop_check(&state);
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: Some(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)
}
// ── Проверка звука до записи ────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CheckView {
/// Почему не открылся микрофон.
mic_error: Option<String>,
/// Почему не открылся звук компьютера — подсказать, где разрешить.
system_error: Option<String>,
}
/// Открыть микрофон и звук компьютера без записи: интерфейс показывает две
/// живые полоски, и человек видит, что всё слышно, до начала встречи. Уровни
/// приходят тем же событием `meet://recorder` с пустым `meeting_id`.
#[tauri::command]
pub fn audio_check_start(app: AppHandle, state: State<'_, AppState>) -> Result<CheckView, String> {
if !meet_audio::CAPTURE_SUPPORTED {
return Err("На этой системе запись не поддерживается".into());
}
if state
.recording
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_some()
{
return Err("Запись уже идёт".into());
}
stop_check(&state);
let mut tracks = Vec::new();
let mut view = CheckView {
mic_error: None,
system_error: None,
};
match meet_audio::open_microphone() {
Ok(capture) => tracks.push(Track {
speaker: Speaker::Me,
capture,
path: None,
}),
Err(e) => view.mic_error = Some(e.to_string()),
}
match meet_audio::open_system_audio() {
Ok(capture) => tracks.push(Track {
speaker: Speaker::Others,
capture,
path: None,
}),
Err(e) => view.system_error = Some(e.to_string()),
}
if !tracks.is_empty() {
let session = Session::start(
tracks,
Arc::new(move |e: recorder::Event| {
let _ = app.emit(
"meet://recorder",
serde_json::json!({ "meeting_id": "", "event": e }),
);
}),
)?;
*state.check.lock().unwrap_or_else(|p| p.into_inner()) = Some(session);
}
Ok(view)
}
#[tauri::command]
pub fn audio_check_stop(state: State<'_, AppState>) {
stop_check(&state);
}
fn stop_check(state: &AppState) {
let check = state.check.lock().unwrap_or_else(|p| p.into_inner()).take();
if let Some(session) = check {
// Потоки дорожек выходят за ~50 мс: ждать можно и здесь.
session.stop();
}
}
#[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())
}