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
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Usable end to end: record or import a recording, get a transcript with speakers
agent-drafted summary you approve, tasks on a board, questions answered from the vault with
citations, live translation of whatever is playing, dubbing, notes, calendars, comments, a roster of
agents you edit as files, and encrypted sync between machines through any folder you both can reach.
Subscribe to a calendar by URL and it stays current, prompts before a meeting starts — it never
records on its own — and drafts the follow-up email afterwards, which you send yourself.

**Not done:** mobile is scaffolded and has never been compiled, and the hosted sync relay is not
built — sync works today through any shared folder instead.
Expand All @@ -39,13 +41,13 @@ The numbers below are measured on this codebase, not estimated. Each is reproduc
command shown; see [`docs/benchmarks.md`](docs/benchmarks.md) and
[`docs/translation.md`](docs/translation.md) for the full method and caveats.

| Claim | Measured | Source |
|---|---|---|
| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` |
| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored |
| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` |
| Claim | Measured | Source |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` |
| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored |
| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` |

## Install and run

Expand Down Expand Up @@ -76,6 +78,7 @@ real-time factor, licence — and you can disagree with it. Nothing is recorded
```bash
summo serve --port 8710 # a fixed port, when something else wants to find it
summo serve --no-open # a server, when there is no browser to open
summo serve --background # run detached; `summo status` and `summo stop` from anywhere
summo import ~/Downloads/zoom-recording.mp4
summo mcp # the vault over MCP, for Claude Code or Cursor
```
Expand Down Expand Up @@ -105,7 +108,7 @@ Summo is never the distributor of a licence it cannot redistribute under.

Disconnect the machine from the network, then run `./summo serve` and record a meeting. Recognition,
voice-activity detection and speaker attribution keep working, because they never called out — there
is no cloud-ASR fallback to fail over to. What you should *not* be able to do offline is get a summary
is no cloud-ASR fallback to fail over to. What you should _not_ be able to do offline is get a summary
or a translation from a remote model you configured, since that is the one deliberate exception to
"nothing leaves the machine".

Expand Down
20 changes: 11 additions & 9 deletions README.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ cơ sở dữ liệu của ai khác. Ba nguyên tắc sau đây theo từ đó,
một bản tóm tắt do agent soạn để bạn duyệt, việc cần làm trên bảng kanban, hỏi đáp trả lời từ kho dữ
liệu kèm trích dẫn, dịch trực tiếp nội dung đang phát, lồng tiếng (dubbing), ghi chú, lịch, bình
luận, một dàn agent bạn chỉnh sửa như file, và đồng bộ mã hoá giữa các máy qua bất kỳ thư mục dùng
chung nào.
chung nào. Đăng ký lịch bằng URL thì lịch luôn cập nhật, sắp tới giờ họp app hỏi có ghi chú không —
không bao giờ tự ghi âm — và họp xong thì soạn sẵn thư gửi đi để bạn tự gửi.

**Chưa xong:** phần mobile mới ở dạng khung sườn và chưa từng biên dịch được, và relay đồng bộ trên
cloud (hosted sync relay) chưa được xây — hiện tại đồng bộ vẫn chạy qua một thư mục dùng chung.
Expand All @@ -41,13 +42,13 @@ Các con số dưới đây đều đo trên chính codebase này, không phải
bằng đúng lệnh ghi kèm; xem đầy đủ phương pháp và các lưu ý ở
[`docs/benchmarks.md`](docs/benchmarks.md) và [`docs/translation.md`](docs/translation.md).

| Nhận định | Đo được | Nguồn |
|---|---|---|
| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` |
| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER |
| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` |
| Nhận định | Đo được | Nguồn |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` |
| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER |
| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` |

## Cài đặt và chạy

Expand Down Expand Up @@ -78,6 +79,7 @@ gì được ghi lại cho tới khi bạn bấm nút ghi.

```bash
summo serve --port 8710 # cố định một port, khi có thứ khác cần tìm tới nó
summo serve --background # chạy nền; `summo status` để xem, `summo stop` để dừng
summo serve --no-open # chạy server, khi không có trình duyệt nào để mở
summo import ~/Downloads/zoom-recording.mp4
summo mcp # đưa kho dữ liệu ra qua MCP, cho Claude Code hay Cursor
Expand Down Expand Up @@ -109,7 +111,7 @@ không có quyền phân phối lại.

Ngắt kết nối mạng của máy, rồi chạy `./summo serve` và ghi một cuộc họp. Nhận dạng giọng nói, VAD và
tách người nói vẫn chạy bình thường, vì chúng chưa bao giờ gọi ra ngoài — không có đường lùi nào sang
ASR trên cloud để mà thất bại. Điều bạn *sẽ không* làm được khi offline là lấy một bản tóm tắt hay
ASR trên cloud để mà thất bại. Điều bạn _sẽ không_ làm được khi offline là lấy một bản tóm tắt hay
bản dịch từ model từ xa mà bạn đã cấu hình — đó là ngoại lệ duy nhất, có chủ đích, của lời hứa "không
gì rời khỏi máy".

Expand Down
109 changes: 109 additions & 0 deletions apps/web/e2e/calendar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Subscribing to a calendar, in the browser, against a real calendar server.
*
* The unit tests cover which URLs are refused and the daemon test covers the fetch. What neither
* can cover is the part that was actually missing for a year: the only way to add a calendar was to
* type the *path of a file*, which a browser cannot produce and which stops being true the day
* after it is exported. This drives the form a person uses.
*
* The calendar is served by a two-line HTTP server in this process, so the suite depends on no
* network and no account. That is also what makes it able to assert the failure path — the server
* is told to answer 404 and the row has to say so, because a subscription that quietly stopped
* working looks exactly like a week with no meetings.
*/
import { createServer } from "node:http";
import { chromium } from "playwright";

import { daemon } from "./daemon.mjs";

const problems = [];

// ---- a calendar server ----------------------------------------------------
const stamp = (epoch) => new Date(epoch * 1000).toISOString().replace(/[-:]|\.\d{3}/g, "");
const soon = Math.floor(Date.now() / 1000) + 3600;
const ICS =
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" +
`BEGIN:VEVENT\r\nUID:e2e-1\r\nSUMMARY:Họp chốt giá\r\nDTSTART:${stamp(soon)}\r\n` +
`DTEND:${stamp(soon + 1800)}\r\n` +
"ATTENDEE:mailto:ngoc@acme.vn\r\nATTENDEE:mailto:binh@acme.vn\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";

let serving = true;
const calendars = createServer((request, response) => {
if (!serving) {
response.writeHead(404).end("gone");
return;
}
response.writeHead(200, { "content-type": "text/calendar" }).end(ICS);
});
await new Promise((resolve) => calendars.listen(0, "127.0.0.1", resolve));
const address = `http://127.0.0.1:${calendars.address().port}/work.ics`;

const engine = await daemon(process.argv, { name: "calendar" });
const browser = await chromium.launch();
const context = await browser.newContext({
locale: "vi-VN",
viewport: { width: 1280, height: 950 },
});
const page = await context.newPage();
page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));

await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/agenda`, {
waitUntil: "networkidle",
});

// ---- subscribing ----------------------------------------------------------
{
await page.getByLabel("Địa chỉ lịch (URL)").fill(address);
await page.getByLabel("Tên lịch").fill("Lịch công ty");
await page.getByRole("button", { name: "Đăng ký", exact: true }).click();

const row = page.getByTestId("calendar-list").getByText("Lịch công ty");
await row.waitFor({ timeout: 15000 }).catch(() => problems.push("the calendar was never listed"));

// The meeting from that calendar, on the agenda, without a reload.
await page
.getByText("Họp chốt giá")
.first()
.waitFor({ timeout: 15000 })
.catch(() => problems.push("the subscribed calendar's meeting never reached the agenda"));

const state = await page.getByTestId("calendar-list").innerText();
if (!/1 sự kiện/.test(state)) problems.push(`the row does not say what it holds: "${state}"`);
}

// ---- a subscription that stops working ------------------------------------
{
serving = false;
await page.getByRole("button", { name: /Đồng bộ lại/ }).click();

const failed = page.getByText(/không tìm thấy lịch/);
await failed
.waitFor({ timeout: 15000 })
.catch(() => problems.push("a broken subscription reports nothing"));

// And the meetings it already fetched are still there: a laptop that woke up without WiFi should
// still show this morning's meetings.
if ((await page.getByText("Họp chốt giá").count()) === 0) {
problems.push("a failed refresh threw away the calendar it already had");
}
}

// ---- a URL that is not a calendar -----------------------------------------
{
await page.getByLabel("Địa chỉ lịch (URL)").fill("file:///etc/passwd");
await page.getByRole("button", { name: "Đăng ký", exact: true }).click();
await page
.getByText(/phải bắt đầu bằng https/)
.waitFor({ timeout: 10000 })
.catch(() => problems.push("a file:// URL was not refused in the interface"));
}

await browser.close();
await engine.stop();
calendars.close();

if (problems.length > 0) {
console.error(problems.map((p) => ` - ${p}`).join("\n"));
process.exit(1);
}
console.log("calendar ok");
79 changes: 79 additions & 0 deletions apps/web/e2e/notes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Starting a note that is already the right shape.
*
* A blank page is the right default and a poor only option: people were typing the same four sets
* of headings by hand — an idea, a decision, a list of things to do, a day's journal — and a note
* app that watched them do it and offered nothing is the one they stop opening.
*
* What this asserts is the part that would break silently: that choosing a kind puts its headings
* *in the file*, rather than into some hidden template state that a later edit or an export would
* lose.
*/
import { chromium } from "playwright";

import { daemon } from "./daemon.mjs";

const problems = [];
const engine = await daemon(process.argv, { name: "notes" });
const browser = await chromium.launch();
const context = await browser.newContext({
locale: "vi-VN",
viewport: { width: 1280, height: 950 },
});
const page = await context.newPage();
page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));

await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/notes`, {
waitUntil: "networkidle",
});

// ---- the kinds on offer ---------------------------------------------------
await page.getByRole("button", { name: "Mới", exact: true }).click();
const menu = page.getByTestId("note-kinds");
await menu.waitFor({ timeout: 10000 }).catch(() => problems.push("no kinds were offered"));

const offered = (await menu.innerText()).split("\n").filter(Boolean);
if (offered.length < 5)
problems.push(`only ${offered.length} kinds offered: ${offered.join(", ")}`);
if (!/Trống/.test(offered[0] ?? "")) {
problems.push(`a blank note should be first, got "${offered[0]}"`);
}

// ---- a decision note starts with a decision's headings --------------------
await menu.getByRole("button", { name: "Quyết định" }).click();

const body = page.getByLabel("Nội dung ghi chú");
await body.waitFor({ timeout: 10000 });
// The seed has to be in the editor, which is what proves it is in the file rather than a label.
await page
.waitForFunction(
() => document.querySelector("textarea")?.value.includes("## Bối cảnh") ?? false,
{ timeout: 10000 },
)
.catch(() => problems.push("the decision note did not start with a decision's headings"));

// ---- and it survives a reload, because it was saved -----------------------
{
const before = await body.inputValue();
// Typing is what triggers the save; a note created and never touched is allowed to be empty.
await body.click();
await body.press("End");
await body.type(" Ngọc chốt.");
await page.waitForTimeout(3000);
await page.reload({ waitUntil: "networkidle" });

const notes = await page.getByText("Quyết định").count();
if (notes === 0) problems.push("the note is not in the list after a reload");
if (!before.includes("## Quyết định")) {
problems.push(`the seed is missing its own heading: ${JSON.stringify(before.slice(0, 40))}`);
}
}

await browser.close();
await engine.stop();

if (problems.length > 0) {
console.error(problems.map((p) => ` - ${p}`).join("\n"));
process.exit(1);
}
console.log("notes ok");
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"preview": "vite preview",
"test": "vitest run",
"tauri": "tauri",
"e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/density.mjs",
"e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/calendar.mjs && node e2e/notes.mjs && node e2e/density.mjs",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
Expand Down
Loading
Loading