169ce64e1705fd64e48ee357a816bbdb90bab01d
Ветки
Перейти к файлуt
A
anc
Этап 1: запись встречи в Zoom, живая расшифровка и разбор
- crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process
loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом
тишины, распознавание начала и конца встречи по звуковой сессии Zoom.
- Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка,
живой конспект раз в три минуты, по окончании — разбор сам; звук
удаляется после разбора, если не включено хранение.
- Контроллер: слежу за Zoom → прошу предупредить участников → пишу;
запись вручную, пауза, стоп; прерванная запись помечается при запуске.
- Трей, уведомления, крестик прячет окно, запуск вместе с Windows.
- Интерфейс: главный экран с карточкой состояния (задача #1), живая
расшифровка и конспект на экране встречи, новые настройки.
- reqwest на системном TLS: под Windows без C-кода ring.
- 67 тестов; код под Windows проверен компиляцией и clippy с Mac,
на Windows не запускался.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
30 сентября 2026 г.
controller.rs
//! Что программа делает сама: следит за Zoom, спрашивает отметку о
//! предупреждении участников, пишет встречу, по окончании дописывает
//! расшифровку и запускает разбор.
//!
//! ```text
//! встреча началась «Предупредил, записывать»
//! Watching ─────────────────► AwaitingConsent ─────────────────► Recording
//! ▲ │ (или сразу, если «предупреждаю всегда сам») │
//! │ └──────────── «Записать вручную» ───────────────────────────►│
//! │ встреча кончилась / «Стоп» ▼
//! └──────────────────────────────── Finishing (расшифровка → разбор)
//! ```
//!
//! Контроллер не знает про Tauri: окно, уведомления и события интерфейса —
//! через [`Outlet`], источники звука — через [`Sources`]. Поэтому переходы
//! проверяются тестами на любой машине.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use meet_audio::detector::{self, Detector};
use serde::Serialize;
use crate::meetings::{Meeting, Store};
use crate::openrouter::{Client, SttSegment};
use crate::recorder::{self, BoxFuture, Session, Track, Transcriber};
use crate::settings::{Secrets, Settings, OPENROUTER_KEY};
use crate::transcript::Transcript;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishStep {
/// Дописываем расшифровку последних кусков.
Transcribing,
/// Модель пишет разбор.
Analyzing,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Status {
/// На этой машине звук Zoom не записать (не Windows).
Unsupported,
/// Жду встречу в Zoom.
Watching,
/// Встреча началась, ждём отметку «участники предупреждены».
AwaitingConsent,
Recording {
meeting_id: String,
started_at: DateTime<Local>,
paused: bool,
/// Пишется звук Zoom (а не только микрофон).
zoom: bool,
},
Finishing {
meeting_id: String,
step: FinishStep,
},
}
/// Всё, что видит пользователь.
pub trait Outlet: Send + Sync + 'static {
fn status(&self, status: &Status);
fn recorder_event(&self, meeting_id: &str, event: &recorder::Event);
/// Встреча изменилась на диске (дописана, разобрана).
fn meeting_changed(&self, meeting_id: &str);
fn notify(&self, title: &str, body: &str);
fn show_window(&self);
}
/// Источники звука этой машины.
pub trait Sources: Send + Sync + 'static {
fn supported(&self) -> bool;
/// Процесс Zoom с активным звуком, если он есть.
fn active_zoom(&self) -> Option<u32>;
/// Дорожки записи: микрофон и, если есть `zoom_pid`, звук Zoom.
fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String>;
}
/// Распознавание и конспект по ключу и настройкам.
pub type MakeAi = Arc<dyn Fn(&str, &Settings) -> Arc<dyn Transcriber> + Send + Sync>;
/// Итоговый разбор встречи: (id, настройки, ключ).
pub type Analyze =
Arc<dyn Fn(String, Settings, String) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;
pub struct Deps {
pub store: Arc<Store>,
pub settings_path: PathBuf,
pub secrets: Arc<dyn Secrets>,
pub outlet: Arc<dyn Outlet>,
pub sources: Arc<dyn Sources>,
pub rt: tokio::runtime::Handle,
/// Распознавание и конспект по ключу; в тестах — подделка.
pub make_ai: MakeAi,
/// Итоговый разбор; в тестах — подделка.
pub analyze: Analyze,
pub recorder: recorder::Config,
}
struct Inner {
status: Status,
session: Option<Session>,
detector: Detector,
/// Процесс Zoom текущей встречи.
zoom_pid: Option<u32>,
/// Предупредили, что без ключа записи не будет, — один раз за встречу.
warned_no_key: bool,
}
pub struct Controller {
deps: Deps,
clock: Instant,
inner: Mutex<Inner>,
}
impl Controller {
pub fn new(deps: Deps) -> Arc<Self> {
let status = if deps.sources.supported() {
Status::Watching
} else {
Status::Unsupported
};
Arc::new(Self {
deps,
clock: Instant::now(),
inner: Mutex::new(Inner {
status,
session: None,
detector: Detector::new(detector::Config::default()),
zoom_pid: None,
warned_no_key: false,
}),
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn status(&self) -> Status {
self.lock().status.clone()
}
fn set_status(&self, inner: &mut Inner, status: Status) {
inner.status = status;
self.deps.outlet.status(&inner.status);
}
fn settings(&self) -> Settings {
Settings::load(&self.deps.settings_path).unwrap_or_default()
}
fn key(&self) -> Option<String> {
self.deps.secrets.get(OPENROUTER_KEY).ok().flatten()
}
/// Опрос Zoom раз в секунду в своём потоке.
pub fn spawn_watch(self: &Arc<Self>) {
if !self.deps.sources.supported() {
return;
}
let this = self.clone();
std::thread::Builder::new()
.name("meet-watch".into())
.spawn(move || loop {
this.tick();
std::thread::sleep(Duration::from_secs(1));
})
.expect("поток наблюдения");
}
/// Один опрос: что с Zoom сейчас.
pub fn tick(self: &Arc<Self>) {
self.tick_at(self.clock.elapsed().as_millis() as u64);
}
/// Опрос в момент `now_ms` — тесты подают время сами, не ожидая секунд.
pub fn tick_at(self: &Arc<Self>, now_ms: u64) {
let active = self.deps.sources.active_zoom();
let event = self.lock().detector.poll(now_ms, active);
match event {
Some(detector::Event::Started { pid }) => self.meeting_started(pid),
Some(detector::Event::Ended) => self.meeting_ended(),
None => {}
}
}
fn meeting_started(self: &Arc<Self>, pid: u32) {
let mut inner = self.lock();
inner.zoom_pid = Some(pid);
if inner.status != Status::Watching {
return;
}
if self.key().is_none() {
if !inner.warned_no_key {
inner.warned_no_key = true;
self.deps.outlet.notify(
"Началась встреча в Zoom",
"Записать не получится: не подключён ИИ. Добавьте ключ OpenRouter в настройках.",
);
}
return;
}
if self.settings().always_warn_myself {
drop(inner);
if let Err(e) = self.start(true) {
self.deps.outlet.notify("Запись не началась", &e);
}
return;
}
self.set_status(&mut inner, Status::AwaitingConsent);
self.deps.outlet.notify(
"Началась встреча в Zoom",
"Предупредите участников о записи и нажмите «Записывать».",
);
self.deps.outlet.show_window();
}
fn meeting_ended(self: &Arc<Self>) {
let mut inner = self.lock();
inner.zoom_pid = None;
inner.warned_no_key = false;
match inner.status {
Status::AwaitingConsent => self.set_status(&mut inner, Status::Watching),
Status::Recording { zoom: true, .. } => {
drop(inner);
let this = self.clone();
self.deps.rt.spawn(async move {
let _ = this.stop().await;
});
}
_ => {}
}
}
/// Ответ на «Предупредите участников».
pub fn consent(self: &Arc<Self>, record: bool) -> Result<(), String> {
let mut inner = self.lock();
if inner.status != Status::AwaitingConsent {
return Err("Сейчас нечего подтверждать".into());
}
if !record {
self.set_status(&mut inner, Status::Watching);
return Ok(());
}
drop(inner);
self.start(true).map(|_| ())
}
/// «Записать вручную»: встреча не в Zoom или Zoom не распознался.
pub fn start_manual(self: &Arc<Self>) -> Result<Meeting, String> {
self.start(false)
}
fn start(self: &Arc<Self>, auto: bool) -> Result<Meeting, String> {
let mut inner = self.lock();
if !matches!(inner.status, Status::Watching | Status::AwaitingConsent) {
return Err("Запись уже идёт".into());
}
if !self.deps.sources.supported() {
return Err("Запись встреч работает только в Windows".into());
}
let key = self
.key()
.ok_or("Сначала подключите ИИ: добавьте ключ OpenRouter в настройках")?;
let settings = self.settings();
let zoom_pid = inner.zoom_pid.or_else(|| self.deps.sources.active_zoom());
let tracks = self.deps.sources.open(zoom_pid)?;
let zoom = zoom_pid.is_some();
let now = Local::now();
let title = format!(
"{} {}",
if zoom {
"Встреча в Zoom,"
} else {
"Запись,"
},
now.format("%d.%m, %H:%M")
);
let meeting = self
.deps
.store
.create_recording(&title, now)
.map_err(|e| e.to_string())?;
let ai = (self.deps.make_ai)(&key, &settings);
let outlet = self.deps.outlet.clone();
let id = meeting.id.clone();
let session = Session::start(
meeting.id.clone(),
self.deps.store.clone(),
tracks,
ai,
Arc::new(move |e| outlet.recorder_event(&id, &e)),
self.deps.recorder,
&self.deps.rt,
);
inner.session = Some(session);
self.set_status(
&mut inner,
Status::Recording {
meeting_id: meeting.id.clone(),
started_at: now,
paused: false,
zoom,
},
);
if auto {
self.deps.outlet.notify(
"Записываю встречу",
"Расшифровка появится в Skillok Meet по ходу встречи.",
);
}
Ok(meeting)
}
pub fn pause(&self, paused: bool) -> Result<(), String> {
let mut inner = self.lock();
let Status::Recording {
meeting_id,
started_at,
zoom,
..
} = inner.status.clone()
else {
return Err("Запись не идёт".into());
};
if let Some(s) = inner.session.as_ref() {
s.set_paused(paused);
}
self.set_status(
&mut inner,
Status::Recording {
meeting_id,
started_at,
paused,
zoom,
},
);
Ok(())
}
/// Остановить запись, дописать расшифровку и запустить разбор.
pub async fn stop(self: &Arc<Self>) -> Result<(), String> {
let (session, meeting_id) = {
let mut inner = self.lock();
let Status::Recording { meeting_id, .. } = inner.status.clone() else {
return Err("Запись не идёт".into());
};
let session = inner.session.take();
self.set_status(
&mut inner,
Status::Finishing {
meeting_id: meeting_id.clone(),
step: FinishStep::Transcribing,
},
);
(session, meeting_id)
};
let duration = match session {
Some(s) => s.stop().await,
None => 0,
};
let store = &self.deps.store;
let outlet = &self.deps.outlet;
let finished = store.finish_recording(&meeting_id, duration);
outlet.meeting_changed(&meeting_id);
let title = finished.map(|m| m.title).unwrap_or_default();
let has_speech = store
.get(&meeting_id)
.map(|d| !d.transcript.segments.is_empty())
.unwrap_or(false);
let settings = self.settings();
match (has_speech, self.key()) {
(false, _) => outlet.notify("Запись остановлена", "Речи не было — разбирать нечего."),
(true, None) => outlet.notify("Запись остановлена", "Разбор не запущен: не подключён ИИ."),
(true, Some(key)) => {
{
let mut inner = self.lock();
self.set_status(
&mut inner,
Status::Finishing {
meeting_id: meeting_id.clone(),
step: FinishStep::Analyzing,
},
);
}
match (self.deps.analyze)(meeting_id.clone(), settings.clone(), key).await {
Ok(()) => {
if !settings.keep_audio {
let _ = store.delete_audio(&meeting_id);
}
outlet.meeting_changed(&meeting_id);
outlet.notify("Разбор встречи готов", &title);
}
Err(e) => outlet.notify(
"Разбор не получился",
&format!("{e}. Звук сохранён, можно разобрать заново."),
),
}
}
}
let mut inner = self.lock();
self.set_status(&mut inner, Status::Watching);
Ok(())
}
}
// ── Настоящие зависимости ───────────────────────────────────────────────────
/// Распознавание и живой конспект через OpenRouter.
pub struct OpenRouterAi {
client: Client,
stt_model: String,
live_model: String,
language: Option<String>,
}
impl OpenRouterAi {
pub fn new(key: &str, settings: &Settings) -> Self {
Self {
client: Client::new(key),
stt_model: settings.stt_model.clone(),
live_model: settings.live_model.clone(),
language: settings.stt_language().map(str::to_string),
}
}
}
impl Transcriber for OpenRouterAi {
fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
Box::pin(async move {
self.client
.transcribe(&self.stt_model, &wav, self.language.as_deref())
.await
.map(|t| t.segments)
.map_err(|e| e.to_string())
})
}
fn live_summary(&self, transcript: Transcript) -> BoxFuture<'_, Result<String, String>> {
Box::pin(async move {
self.client
.live_summary(&self.live_model, &transcript)
.await
.map(|(t, _)| t)
.map_err(|e| e.to_string())
})
}
}
/// Звук этой машины: WASAPI в Windows. Вне Windows — только проба цепочки из
/// файла: `MEET_DEV_REPLAY=путь.wav` (16 кГц, моно) играет его как «собеседников».
pub struct SystemSources {
dev_replay: Option<PathBuf>,
}
impl SystemSources {
pub fn from_env() -> Self {
Self {
dev_replay: std::env::var_os("MEET_DEV_REPLAY").map(PathBuf::from),
}
}
}
impl Sources for SystemSources {
fn supported(&self) -> bool {
meet_audio::CAPTURE_SUPPORTED || self.dev_replay.is_some()
}
fn active_zoom(&self) -> Option<u32> {
meet_audio::active_zoom_process()
}
fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String> {
use crate::transcript::Speaker;
if let Some(path) = &self.dev_replay {
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let samples = meet_audio::pcm::decode_wav(&bytes).map_err(|e| e.to_string())?;
return Ok(vec![Track {
speaker: Speaker::Others,
capture: Box::new(meet_audio::source::Replay::new(samples, 1.0)),
}]);
}
let mut tracks = Vec::new();
let mut errors = Vec::new();
match meet_audio::open_microphone() {
Ok(c) => tracks.push(Track {
speaker: Speaker::Me,
capture: c,
}),
Err(e) => errors.push(e.to_string()),
}
if let Some(pid) = zoom_pid {
match meet_audio::open_process_loopback(pid) {
Ok(c) => tracks.push(Track {
speaker: Speaker::Others,
capture: c,
}),
Err(e) => errors.push(e.to_string()),
}
}
if tracks.is_empty() {
return Err(errors.join("; "));
}
Ok(tracks)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Memory;
use crate::transcript::Speaker;
use meet_audio::pcm::{ms_to_samples, SAMPLE_RATE};
use meet_audio::source::Replay;
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Default)]
struct Log(Mutex<Vec<String>>);
impl Log {
fn has(&self, needle: &str) -> bool {
self.0.lock().unwrap().iter().any(|l| l.contains(needle))
}
}
impl Outlet for Log {
fn status(&self, s: &Status) {
self.0
.lock()
.unwrap()
.push(format!("status {}", serde_json::to_string(s).unwrap()));
}
fn recorder_event(&self, _: &str, e: &recorder::Event) {
if let recorder::Event::Segments(s) = e {
self.0.lock().unwrap().push(format!("segments {}", s.len()));
}
}
fn meeting_changed(&self, id: &str) {
self.0.lock().unwrap().push(format!("changed {id}"));
}
fn notify(&self, title: &str, body: &str) {
self.0.lock().unwrap().push(format!("notify {title}: {body}"));
}
fn show_window(&self) {
self.0.lock().unwrap().push("show".into());
}
}
/// Zoom «активен», пока `zoom` не 0.
struct FakeSources {
zoom: AtomicU32,
}
impl Sources for FakeSources {
fn supported(&self) -> bool {
true
}
fn active_zoom(&self) -> Option<u32> {
Some(self.zoom.load(Ordering::Relaxed)).filter(|p| *p != 0)
}
fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String> {
let speech: Vec<i16> = (0..ms_to_samples(3_000))
.map(|i| {
((i as f32 / SAMPLE_RATE as f32 * std::f32::consts::TAU * 250.0).sin() * 9000.0) as i16
})
.collect();
let speaker = if zoom_pid.is_some() {
Speaker::Others
} else {
Speaker::Me
};
Ok(vec![Track {
speaker,
capture: Box::new(Replay::new(speech, 50.0)),
}])
}
}
struct EchoAi;
impl Transcriber for EchoAi {
fn transcribe(&self, _: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
Box::pin(async {
Ok(vec![SttSegment {
start_ms: 0,
text: "реплика".into(),
}])
})
}
fn live_summary(&self, _: Transcript) -> BoxFuture<'_, Result<String, String>> {
Box::pin(async { Ok("конспект".into()) })
}
}
struct Setup {
clock: std::sync::atomic::AtomicU64,
ctl: Arc<Controller>,
log: Arc<Log>,
sources: Arc<FakeSources>,
store: Arc<Store>,
_dir: tempfile::TempDir,
}
fn setup(with_key: bool, always_warn: bool) -> Setup {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
Settings {
always_warn_myself: always_warn,
..Settings::default()
}
.save(&settings_path)
.unwrap();
let secrets = Arc::new(Memory::default());
if with_key {
secrets.set(OPENROUTER_KEY, "sk-test").unwrap();
}
let log = Arc::new(Log::default());
let sources = Arc::new(FakeSources {
zoom: AtomicU32::new(0),
});
let store = Arc::new(Store::new(dir.path().join("meetings")));
let analyzed_store = store.clone();
let ctl = Controller::new(Deps {
store: store.clone(),
settings_path,
secrets,
outlet: log.clone(),
sources: sources.clone(),
rt: tokio::runtime::Handle::current(),
make_ai: Arc::new(|_, _| Arc::new(EchoAi)),
analyze: Arc::new(move |id, _, _| {
let store = analyzed_store.clone();
Box::pin(async move {
store
.save_live_summary(&id, "разобрано")
.map_err(|e| e.to_string())
})
}),
recorder: recorder::Config {
live_every: Duration::from_secs(3600),
..recorder::Config::default()
},
});
Setup {
clock: std::sync::atomic::AtomicU64::new(0),
ctl,
log,
sources,
store,
_dir: dir,
}
}
/// Прогоняет опрос Zoom `seconds` раз по секунде условного времени.
fn ticks(s: &Setup, seconds: u64) {
for _ in 0..seconds {
let now = s.clock.fetch_add(1000, Ordering::Relaxed) + 1000;
s.ctl.tick_at(now);
}
}
async fn wait_for(s: &Setup, pred: impl Fn(&Status) -> bool) {
for _ in 0..200 {
if pred(&s.ctl.status()) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("не дождались состояния, сейчас {:?}", s.ctl.status());
}
#[tokio::test(flavor = "multi_thread")]
async fn meeting_asks_for_consent_records_and_analyzes() {
let s = setup(true, false);
s.sources.zoom.store(42, Ordering::Relaxed);
ticks(&s, 6);
assert_eq!(s.ctl.status(), Status::AwaitingConsent);
assert!(s.log.has("notify Началась встреча в Zoom: Предупредите"));
assert!(s.log.has("show"));
s.ctl.consent(true).unwrap();
let Status::Recording {
meeting_id,
zoom: true,
..
} = s.ctl.status()
else {
panic!("{:?}", s.ctl.status())
};
tokio::time::sleep(Duration::from_millis(400)).await;
// Zoom закрыли — запись кончается сама, разбор запускается.
s.sources.zoom.store(0, Ordering::Relaxed);
ticks(&s, 25);
wait_for(&s, |st| *st == Status::Watching).await;
let d = s.store.get(&meeting_id).unwrap();
assert_eq!(d.meeting.state, crate::meetings::MeetingState::Done);
assert_eq!(d.transcript.segments[0].speaker, Speaker::Others);
assert_eq!(d.live_summary.as_deref(), Some("разобрано"), "разбор запущен");
assert!(s.log.has("notify Разбор встречи готов"));
assert!(
!s.store.root().join(&meeting_id).join("audio").exists(),
"звук удалён после разбора"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn declined_meeting_is_not_recorded() {
let s = setup(true, false);
s.sources.zoom.store(42, Ordering::Relaxed);
ticks(&s, 6);
s.ctl.consent(false).unwrap();
assert_eq!(s.ctl.status(), Status::Watching);
ticks(&s, 30);
assert_eq!(
s.ctl.status(),
Status::Watching,
"та же встреча второй раз не спрашивается"
);
assert!(s.store.list().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn always_warn_starts_recording_at_once() {
let s = setup(true, true);
s.sources.zoom.store(42, Ordering::Relaxed);
ticks(&s, 6);
assert!(
matches!(s.ctl.status(), Status::Recording { .. }),
"{:?}",
s.ctl.status()
);
assert!(s.log.has("notify Записываю встречу"));
s.ctl.stop().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn without_key_nothing_is_recorded_and_user_is_told_once() {
let s = setup(false, true);
s.sources.zoom.store(42, Ordering::Relaxed);
ticks(&s, 20);
assert_eq!(s.ctl.status(), Status::Watching);
assert_eq!(
s.log
.0
.lock()
.unwrap()
.iter()
.filter(|l| l.contains("не подключён ИИ"))
.count(),
1
);
assert!(s.ctl.start_manual().unwrap_err().contains("ключ OpenRouter"));
}
#[tokio::test(flavor = "multi_thread")]
async fn manual_recording_with_pause_and_stop() {
let s = setup(true, false);
let m = s.ctl.start_manual().unwrap();
assert!(matches!(s.ctl.status(), Status::Recording { zoom: false, .. }));
assert!(s.ctl.start_manual().is_err(), "вторая запись не начинается");
s.ctl.pause(true).unwrap();
assert!(matches!(s.ctl.status(), Status::Recording { paused: true, .. }));
s.ctl.pause(false).unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
s.ctl.stop().await.unwrap();
assert_eq!(s.ctl.status(), Status::Watching);
assert_eq!(
s.store.get(&m.id).unwrap().transcript.segments[0].speaker,
Speaker::Me
);
assert!(
s.ctl.stop().await.is_err(),
"стоп без записи — ошибка, а не паника"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn status_serializes_for_the_interface() {
let json = serde_json::to_value(Status::Finishing {
meeting_id: "1".into(),
step: FinishStep::Analyzing,
})
.unwrap();
assert_eq!(
json,
serde_json::json!({ "kind": "finishing", "meeting_id": "1", "step": "analyzing" })
);
}
}