diff --git a/.gitignore b/.gitignore index 92dbe4d..9ba89d8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ __pycache__/ # Seeded design canvases: build products of design/*.dc.html. design/build/ + +# `scripts/social_card.py` output, the repo's social preview. It is uploaded by hand +# (Settings ▸ General ▸ Social preview), GitHub keeps its own copy, and the script +# rebuilds it from docs/flow.gif in about half a minute. Ignored here, it also stays out +# of the sdist, which it alone would put over its size guard. +docs/social-preview.gif diff --git a/README.md b/README.md index 9382d35..095b318 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,188 @@ -# Flow +

+ +

+ +

Flow

+ +

+ Local voice dictation for Windows, made for developers who speak English with an accent. +

+ +

+ Latest release + Windows 10 and 11 + macOS and Linux: Lite, in progress + Tests + MIT license +

+ +

+ Install · + Guide · + Report a mishearing +

-Hold a key, talk, let go: the words paste into the window you were working in. Or tap the -pill to hand them to the agent CLI you already use — shaped into a prompt for your -project, or asked as a question and answered above the pill. - -Speech recognition runs on your machine. No API key. +![Flow Home page by page: Home, History, Voice, then a question dictated with the pill into Conversations and answered by the agent, then Models and Settings](docs/flow.gif) -**English only · three dependencies · Windows in full, macOS and Linux in -[Lite](#flow-lite-macos-and-linux)** +Hold Ctrl+Win, say what you want to write, and let go. Flow pastes the +words wherever you were typing: your editor, a terminal, Slack, a browser tab. If it heard +something wrong, hold the keys again and say *"change Tuesday to Thursday"* or *"scratch +that"*, and it fixes the text it just pasted. -![Flow Home page by page: Home, History, Voice, then a question dictated with the pill into Conversations and answered by the agent, then Models and Settings](docs/flow.gif) +Flow sits in a small pill at the bottom of the screen, and the pill has two more modes. In +Refine, what you say is rewritten into a proper prompt for your project. In Ask, your question goes to the agent +CLI you already use (`codex` or `claude`) and the answer is read back to you. -*Flow Home and the real pill against a scripted session, recorded by [`scripts/home_reel.py`](scripts/home_reel.py).* +I made Flow for people whose English has an accent that speech recognition keeps tripping +over, often enough that they give up and type. Spanish, Indian, Russian and Japanese +speakers are who it's designed and benchmarked for. Recognition runs on your own machine, +there's no account or API key, and your audio never leaves your PC. ## Install -```bash -uv tool install git+https://github.com/samartomar/flow -flow -``` +### Windows + +You need Windows 10 or 11 and a microphone. Download +[`flow-windows-x64.zip`](https://github.com/samartomar/flow/releases/latest/download/flow-windows-x64.zip), +unzip it anywhere and run `flow.exe`. No Python required. -You need [`uv`](https://docs.astral.sh/uv/), which fetches Python and the three -dependencies itself, and a microphone. No Python? On Windows, [Scoop](https://scoop.sh) -installs the release build: +> [!NOTE] +> The zip isn't code-signed, so the first launch shows a SmartScreen warning. Click +> **More info**, then **Run anyway**. + +Or get the same build through [Scoop](https://scoop.sh), which makes updating one command: ```powershell scoop bucket add flow https://github.com/samartomar/scoop-flow scoop install flow/flow # flow/ matters: Scoop's main bucket has a different "flow" ``` -Or download -[`flow-windows-x64.zip`](https://github.com/samartomar/flow/releases/latest/download/flow-windows-x64.zip), -unzip it and run `flow.exe`. It is unsigned, so the first launch shows SmartScreen: -**More info → Run anyway**. +If you already use [uv](https://docs.astral.sh/uv/), you can install from source instead. +It fetches Python and Flow's three dependencies (faster-whisper, sounddevice and numpy): -An agent CLI — `codex` or `claude` on PATH, signed in — is optional. It adds Refine, Ask -and rewrites; dictation and spoken corrections work without it. -[The guide](docs/guide.md#install) has the rest. +```bash +uv tool install git+https://github.com/samartomar/flow +flow +``` + +The speech models aren't part of any of these. Flow downloads them on first run and shows +you the progress. + +### macOS and Linux -## Flow Lite (macOS and Linux) +Flow Lite, the version for macOS and Linux, is still in progress. Install it with uv, as +above. It has the same recognition, corrections and agent features, but it can't paste into +other apps or listen for global hotkeys. You hold the pill to talk, and Send copies the +text for you to paste. The microphone is the only permission it needs. There's no Mac or +Linux download yet. [More about Lite](docs/product.md#flow-lite--the-portable-body). -Off Windows, Flow runs as **Lite**: the same recognition, corrections and agent loop, but -Send copies the draft and you paste it yourself. No injection and no global hotkeys, so -there is nothing to grant but the microphone — hold the pill to talk. There is no Mac or -Linux download; install with uv. [What Lite is, and is not](docs/product.md#flow-lite--the-portable-body). +### The agent CLI (optional) -## The loop +Refine and Ask need `codex` or `claude` on your PATH, already signed in. Dictation and +spoken corrections work without either. [The guide](docs/guide.md#install) has the details. -1. **Hold `ctrl+win`** (or the pill) and talk. -2. **Let go.** The words paste where you were typing. -3. **Said it wrong?** Hold again and say *"change Tuesday to Thursday"* or *"scratch - that"*. Flow fixes its last paste until you type or click there. -4. **Wrong window?** Click the right one and press **Paste last** (`alt+shift+Z`). -5. **Want an answer?** Hold **`ctrl+alt+win`** and ask. +## Using it -On first run, Flow Home walks you through the microphone, the model download, a -45-second tuning to your voice, and whether to keep a history. Every step can be skipped. +1. Hold Ctrl+Win (or hold the pill) and talk. +2. Let go, and the text is pasted where your cursor was. +3. Heard you wrong? Hold again and say *"change Tuesday to Thursday"* or *"scratch that"*. + Flow can fix its last paste until you type or click there. +4. Pasted into the wrong window? Click the right one and press + Alt+Shift+Z. +5. Want an answer rather than text? Hold Ctrl+Alt+Win and + ask. -## Three modes, one tap +Those last two are Wispr Flow's keys on Windows as well, so if you've used it, your hands +already know them. -- **Type** (white): speak, let go, it pastes. -- **Refine** (gold): `codex` or `claude` turns what you said into a prompt for your - project. Nothing pastes until you press Send. -- **Ask** (violet): the question goes to your agent CLI, and the answer appears above the - pill and is read aloud. **Continue in Flow** opens the conversation in a window. +The first run walks you through the microphone, the model download, a 45-second tuning to +your voice, and whether to keep a history. You can skip any step. -Point Refine and Ask at a project (Flow Home ▸ Settings ▸ Workspaces, or `--cwd`) and the -answers are about your code. The Classic pill — a draft you correct by voice before -sending — is one switch away in Settings. +## Type, Refine, Ask + +Tap the pill to cycle through its three modes. Its colour tells you which one it's in. + +| Mode | Pill | When you let go | +|:---|:---|:---| +| Type | white | The words are pasted. | +| Refine | gold | `codex` or `claude` turns them into a prompt for your project. Nothing is pasted until you press Send. | +| Ask | violet | The question goes to your agent CLI. The answer appears above the pill and is read aloud, and **Continue in Flow** opens the conversation in a window. | + +Point Refine and Ask at a project folder (Flow Home ▸ Settings ▸ Workspaces, or `--cwd`) +and the answers are about your code. If you'd rather correct a draft by voice before +anything is sent, switch to the Classic pill in Settings. ## Flow Home -Right-click the pill ▸ **Open Flow**, or run `flow --home`: one window for everything that -isn't talking. Speech models download with progress and swap without a restart; the -microphone, shortcuts, workspaces, agent CLI and reading voice are all set there. -**Voice** tunes Flow to you, **History** shows what you dictated (if you keep one), and -**Conversations** is Ask in a window. The pill never grows a setting. +Everything that isn't talking happens in one window, the one in the GIF above. Right-click +the pill and choose **Open Flow**, or run `flow --home`. Speech models download there with +a progress bar and swap in without a restart, and it's where you set the microphone, +shortcuts, workspaces, agent CLI and the voice that reads answers. Voice tunes Flow to how +you speak, History shows what you've dictated if you chose to keep it, and Conversations +is Ask in a full window. -## Who it is for +## Privacy -Developers who speak English as a second language, with a strong accent. Spanish, Indian, -Russian and Japanese L1 speakers anchor the design and the benchmarks -([why](docs/product.md)). +By default nothing leaves your PC. Audio, recognition, corrections, your word list and your +voice profile all stay local, and no API key is read, stored or passed anywhere in the code. -## What leaves your machine +A few things do go out, and only when you use them: -- **Nothing, by default.** Audio, recognition, corrections, your lexicon and your profile - stay local. No API key is read, stored or passed anywhere in this codebase. -- **Whatever you hand an agent CLI** goes to its cloud: the draft or the question, plus - your workspace path if you set one. -- **History** is kept only if you choose to, in `~/.flow/history.jsonl`. -- **Pasting** goes through the Windows clipboard for a moment, where clipboard managers +- A draft you refine or a question you ask goes to that agent CLI's cloud, along with your + workspace path if you set one. +- Pasting puts the text on the Windows clipboard for a moment, where clipboard managers and cloud sync can see it. -- **A Microsoft voice**, if you choose one, sends each spoken answer's text to Microsoft. - Every other voice is local. +- If you choose one of Microsoft's natural voices to read answers, the text of each answer + is sent to Microsoft. Every other voice runs locally. -Flow Home is served on `127.0.0.1`, only to the window Flow opens, with a new token each -launch. [The exact boundary](docs/architecture.md#what-leaves-the-machine). +History is kept only if you say so, in `~/.flow/history.jsonl`. Flow Home is served on +`127.0.0.1` to the window Flow opens and nothing else, with a new token every launch. +[The exact boundary](docs/architecture.md#what-leaves-the-machine) is in the architecture +doc. ## Known limits -- **Corrections must be phrased as commands.** *"delete the bit about the standup"* works; - *"I feel it should not contain the summary"* is typed as words. -- **A personal lexicon cuts both ways.** It recovers 27–34% of the rare words the model - missed, and makes WER 14–38% relatively worse on speech without them (EdAcc, - `small.en`). Add the words you say often. -- **Live text refreshes about once a second**, is shown dimmed, and is always replaced by - the final text. -- **Accuracy depends on the voice.** Flow Home ▸ Voice ▸ **How well Flow hears you** - measures yours in five sentences. +- Flow does English only. +- Corrections have to be phrased as commands. *"delete the bit about the standup"* works; + *"I feel it should not contain the summary"* gets typed as words. +- Your personal word list cuts both ways. It recovers 27–34% of the rare words the model + missed, but raises the error rate by 14–38% on speech that doesn't contain them + (measured on EdAcc with `small.en`). Add the words you say often, not every word you know. +- The live preview refreshes about once a second and is shown dimmed. The final text + always replaces it. +- How accurate it is depends on your voice. Flow Home ▸ Voice ▸ **How well Flow hears you** + measures yours with five sentences. -All of them are in [the guide](docs/guide.md#known-limitations). +The rest are in [the guide](docs/guide.md#known-limitations). ## Docs -| | | -|---|---| -| [guide](docs/guide.md) | the manual: every flag, spoken command, mode and setting | -| [product](docs/product.md) | who Flow is for, the P1–P9 requirements, the non-goals | -| [architecture](docs/architecture.md) | data flow, threads, events, and the tuned constants with their measurements | -| [roadmap](docs/roadmap.md) | what is left, and the accent benchmark that measures it | -| [development](docs/development.md) | layout, tests, harnesses, and building a release | -| [decisions](docs/decisions.md) | why things are the way they are | +[The guide](docs/guide.md) is the manual: every flag, spoken command, mode and setting. +[product.md](docs/product.md) says who Flow is for and what it deliberately won't do, +[architecture.md](docs/architecture.md) how it fits together and the measurements behind +its tuned numbers, [roadmap.md](docs/roadmap.md) what's left and the accent benchmark that +tracks it, [development.md](docs/development.md) how to work on it and cut a release, and +[decisions.md](docs/decisions.md) why things are the way they are. ## Contributing -Especially welcome: anyone who speaks accented English and can tell me where Flow -mishears them. That is the one thing I cannot measure alone. +The most useful thing you can send me is a mishearing. If you speak English with an +accent, [open an issue](https://github.com/samartomar/flow/issues/new) with what you said +and what Flow typed. That's the one thing I can't measure on my own. + +Pull requests are welcome for anything else. To run it from a clone: ```bash git clone https://github.com/samartomar/flow && cd flow uv sync && uv run flow # run it -uv run python -m unittest discover -s tests # ~3,200 tests, ~90 s, no mic needed +uv run python -m unittest discover -s tests # ~3,200 tests, about 90 s, no mic needed ``` -Open an [issue](https://github.com/samartomar/flow/issues) for a mishearing (what you -said, and what appeared) or a pull request for anything else. +The GIF at the top is recorded by [`scripts/home_reel.py`](scripts/home_reel.py) from a +scripted session, so it can be re-shot whenever the app changes. + +## License -MIT licensed. +MIT. Flow is built on [faster-whisper](https://github.com/SYSTRAN/faster-whisper) and +OpenAI's [Whisper](https://github.com/openai/whisper) models, and reads answers aloud with +[Piper](https://github.com/OHF-Voice/piper1-gpl) if you add it. diff --git a/docs/flow.gif b/docs/flow.gif index 1bbb5b9..62ab649 100644 Binary files a/docs/flow.gif and b/docs/flow.gif differ diff --git a/docs/guide.md b/docs/guide.md index 7749d78..70cedd4 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -49,7 +49,7 @@ asking and Mac hardware to measure on. **Off Windows, Flow starts in Lite** rather than refusing. Everything that does not need hands is there — both decoder tiers, the correction loop, the lexicon, the profile, the polish, the thread, converse mode — and Send copies the draft for you to paste. See -[README](../README.md#flow-lite-macos-and-linux) for the short version and +[README](../README.md#macos-and-linux) for the short version and [product.md](product.md#flow-lite--the-portable-body) for the definition and the fence. `--lite` runs the same code on Windows. diff --git a/flow/home/static/app.js b/flow/home/static/app.js index 9a1a070..a46e8be 100644 --- a/flow/home/static/app.js +++ b/flow/home/static/app.js @@ -360,17 +360,16 @@
${icon("chip", onGpu ? C.green : C.muted, 22)}
-
${esc(gpu ? gpu.name : "No NVIDIA GPU found")}${gpu && gpu.memory_mb ? `${Math.round(gpu.memory_mb / 1024)} GB` : ""} +
${esc(gpu ? gpu.name : "No NVIDIA GPU found")}${gpu && gpu.memory_mb ? `${Math.round(gpu.memory_mb / 1024)} GB video memory` : ""} ${onGpu ? 'speech runs on the GPU' : "speech runs on the CPU"} ${sp.compute_types.includes("int8") && onGpu && !sp.compute_types.includes("float16") ? 'int8' : ""}

${onGpu ? (sp.compute_types.includes("float16") ? "This card has fast half-precision; Flow runs int8 on it." : "This card has no fast half-precision, so int8 is its fast path. Flow picks it for you.") : esc(sp.why_cpu || "The CPU runs the smaller models in time; the large ones need a GPU.")}

-
${esc(sp.cache.text)}of speech models on disk
-
-

Speech recognition

${sp.loading ? 'loading a model' : ""}
+

Speech recognition

${sp.loading ? 'loading a model' : ""} + ${esc(sp.cache.text)} on this PC

Now: ${esc(finalNow)} for the words that get pasted, ${esc(partialNow)} for the live preview.

${sp.swappable ? `
diff --git a/scripts/home_reel.py b/scripts/home_reel.py index 7893aa0..9e32f10 100644 --- a/scripts/home_reel.py +++ b/scripts/home_reel.py @@ -28,7 +28,8 @@ uv run --no-sync --with pillow --with playwright python scripts/home_reel.py --frames out/ Playwright drives the Edge that Windows ships (`channel="msedge"`), so it downloads no -browser of its own. +browser of its own. After a new take, `scripts/social_card.py` rebuilds the repo's social +preview from it. """ from __future__ import annotations @@ -335,7 +336,11 @@ def tour(self) -> None: self.dictate(QUESTION, 2.8) # hold ctrl+win, talk, let go self.wait(1.2) self.press("Enter") # and Enter asks it - self.wait(ANSWER_SEC + 2.8) + # The composer drops below the answer once the question posts, so a hand left + # where it clicked would sit on the answer. It rests under the conversation list. + box = self.page.locator(".convo-list").bounding_box() + self.move((box["x"] + box["width"] / 2, box["y"] + box["height"] + 110), 0.6) + self.wait(ANSWER_SEC + 2.2) self.session._recent += [(RECENT_ASKED, QUESTION), (RECENT_ANSWERED, reel.ANSWER)] self.to(NAV.format("models"), 0.7) diff --git a/scripts/social_card.py b/scripts/social_card.py new file mode 100644 index 0000000..7cefd2b --- /dev/null +++ b/scripts/social_card.py @@ -0,0 +1,177 @@ +"""Build the repository's social preview: a 1280x640 card with the README's reel playing +in its right half. + +GitHub shows this image wherever a link to the repo is shared. Settings ▸ General ▸ Social +preview takes a PNG, JPG or GIF under 1 MB, and has no API, so the upload is by hand. X, +Slack and LinkedIn show only a GIF's first frame, so the card opens on the reel's answered +question, with the pointer resting clear of it, and plays the tour on from there wherever +GIFs move. + +The left half is HTML in Flow Home's own fonts and colours, photographed once by a +headless Edge. The right half is `docs/flow.gif`, frame by frame, so re-record that with +`scripts/home_reel.py` first and this follows it: + + uv run --no-sync --with pillow --with playwright python scripts/social_card.py + +It writes `docs/social-preview.gif`, which git ignores (GitHub keeps the uploaded copy), +and says so if the result is over GitHub's limit. +""" + +from __future__ import annotations + +import argparse +import io +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw + +REPO = Path(__file__).resolve().parent.parent +FONTS = REPO / "flow" / "assets" / "fonts" +ICON = REPO / "flow" / "assets" / "flow.svg" + +#: GitHub's numbers: 1280x640 displays best, and the file has to be under 1 MB. +CARD = (1280, 640) +LIMIT = 1_000_000 + +#: The reel at 80 %, so the window and the desk strip with the pill fit the card's +#: height. Its right edge runs off the card. +SCALE = 0.8 +AT = (572, 37) +RADIUS = 10 + +#: Seconds into the reel where the card opens: the question answered, the hand resting +#: under the conversation list. Most places that show the card show only this frame. +OPEN_AT = 22.6 +#: How long that frame holds, where the GIF does play. +OPEN_MS = 2400 + +#: Flow Home's rail, flat: a gradient would band in a GIF's 256 colours. +BG = (18, 20, 24) +EDGE = (46, 50, 59) +COLOURS = 256 + +LEFT = """ +
+ +

Flow

+

Local voice dictation for Windows, made for developers who speak English with an accent.

+
Ctrl+Win hold, talk, let go
+
Windows 10 · 11macOS · Linux: Lite, in progress
+
+
github.com/samartomar/flow
+""" + + +def left_half() -> Image.Image: + """The card without the reel: the words, photographed once.""" + from playwright.sync_api import sync_playwright + + html = LEFT.format(fonts=FONTS.as_uri(), icon=ICON.as_uri(), w=CARD[0], h=CARD[1], bg=BG) + # From a file rather than `set_content`: a page with no origin may not read the fonts + # and the icon off the disk. + with tempfile.TemporaryDirectory() as tmp, sync_playwright() as p: + page_file = Path(tmp) / "card.html" + page_file.write_text(html, encoding="utf-8") + browser = p.chromium.launch(channel="msedge", headless=True) + try: + page = browser.new_page(viewport={"width": CARD[0], "height": CARD[1]}, + device_scale_factor=1) + page.goto(page_file.as_uri()) + page.evaluate("document.fonts.ready.then(() => true)") + png = page.screenshot(type="png") + finally: + browser.close() + return Image.open(io.BytesIO(png)).convert("RGB") + + +def timeline(reel: Image.Image) -> tuple[list[int], int]: + """Every frame's duration, and the index of the one showing at `OPEN_AT`.""" + durations, start, t = [], 0, 0 + for i in range(reel.n_frames): + reel.seek(i) + d = reel.info["duration"] + if t <= OPEN_AT * 1000 < t + d: + start = i + durations.append(d) + t += d + return durations, start + + +def framer(left: Image.Image, size: tuple[int, int]): + """A function from one frame of the reel to one frame of the card.""" + w, h = round(size[0] * SCALE), round(size[1] * SCALE) + mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, w - 1, h - 1), RADIUS, fill=255) + box = (AT[0] - 1, AT[1] - 1, AT[0] + w, AT[1] + h) + + def card(frame: Image.Image) -> Image.Image: + out = left.copy() + out.paste(frame.convert("RGB").resize((w, h), Image.LANCZOS), AT, mask) + ImageDraw.Draw(out).rounded_rectangle(box, RADIUS + 1, outline=EDGE, width=1) + return out + + return card + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--reel", type=Path, default=REPO / "docs" / "flow.gif") + ap.add_argument("--out", type=Path, default=REPO / "docs" / "social-preview.gif") + args = ap.parse_args() + + reel = Image.open(args.reel) + durations, start = timeline(reel) + order = list(range(start, reel.n_frames)) + list(range(start)) + card = framer(left_half(), reel.size) + + def frame(i: int) -> Image.Image: + reel.seek(i) + return card(reel) + + # One palette for all of it, from frames across the whole tour, as the reel's is. + sample = [frame(i) for i in order[::max(1, len(order) // 40)]] + strip = Image.new("RGB", (CARD[0], CARD[1] * len(sample))) + for n, img in enumerate(sample): + strip.paste(img, (0, n * CARD[1])) + pal = strip.quantize(colors=COLOURS, method=Image.Quantize.MEDIANCUT) + + flat = [frame(i).quantize(palette=pal, dither=Image.Dither.NONE) for i in order] + shown = [durations[i] for i in order] + shown[0] = max(shown[0], OPEN_MS) + args.out.parent.mkdir(parents=True, exist_ok=True) + flat[0].save(args.out, save_all=True, append_images=flat[1:], duration=shown, + loop=0, optimize=True, disposal=1) + size = args.out.stat().st_size + print(f"{len(flat)} frames, {sum(shown) / 1000:.1f} s, {CARD[0]}x{CARD[1]}, " + f"{size / 1024:.0f} KB -> {args.out}") + if size >= LIMIT: + sys.exit(f"GitHub takes a social preview under 1 MB; this is {size:,} bytes. " + "Lower SCALE, or re-record a shorter reel.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 8277070..9b5dcaa 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -167,7 +167,8 @@ def test_which_platforms_get_what_is_stated_before_the_feature_tour(self): # So the region checked is install through to the feature tour — the part read by # someone deciding whether this runs at all — and both halves of the answer have # to be in it. - deciding = self.readme.split("## Install", 1)[1].split("## The loop", 1)[0] + deciding = self.readme.split("## Install", 1)[1].split("## Using it", 1)[0] + self.assertIn("## Using it", self.readme, "the tour's heading moved; move this too") self.assertIn("Windows", deciding) self.assertIn("macOS", deciding) self.assertIn("Lite", deciding)