{"slug": "dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute", "title": "Dhwani: A self-hosted voice notes app built with Whistle by Cactus Compute", "summary": "Dhwani, a self-hosted voice notes app built on Cactus Compute's Whistle speech-recognition model, transcribes spoken notes locally and saves each as a plain .txt file in a user-chosen folder. The app runs on Python 3.12 with FastAPI, vanilla JavaScript and Tailwind CSS, binds to 127.0.0.1, and keeps audio in memory only, with the Tailwind stylesheet from cdn.jsdelivr.net as the sole outside request. The browser captures microphone audio at 16 kHz with a 25-second recording cap, encodes it as 16-bit mono WAV, and posts it to /api/transcriptions, where the server checks Host, Origin, size and WAV format before transcription.", "body_md": "Dhwani (ध्वनि) means sound in Hindi and Sanskrit. It is a self-hosted voice notes app. Speak a note in your browser. Dhwani turns it into text on your own machine and saves it as a `.txt` file in the folder you choose.\n\n- **Private by design:** audio and note text never leave your machine. Speech recognition runs locally with[Whistle](https://huggingface.co/Cactus-Compute/whistle) .\n- **Plain files:** each note is a`.txt` file. No notes database, no lock-in.\n- **Simple stack:** Python 3.12, FastAPI, vanilla JavaScript, Tailwind CSS.\n\n**Tags:** `voice-notes` `speech-to-text` `note-taking` `self-hosted` `privacy` `offline` `local-first` `fastapi` `python` `tailwindcss` `whistle` `transcription`\n\n| Principle | What it means here | \n|---|---|\n| Local first | The server runs on the user's machine and binds to `127.0.0.1` . | \n| Data stays local | Audio is transcribed in memory and discarded. Notes are plain files on disk. | \n| Small surface | One FastAPI app, one speech model, one file-based store. | \n| Explicit trade-offs | Each choice has an ADR with its context, consequences and a revisit trigger. | \n| Testable seams | The transcriber and the folder registry are injected. Tests use fakes. | \n\n```\nflowchart LR\n  subgraph Machine[\"Your machine\"]\n    subgraph Browser[\"Browser\"]\n      Home[\"Homepage<br/>/\"]\n      App[\"Notes app<br/>/app\"]\n      Rec[\"recorder.js<br/>16 kHz capture and WAV encode\"]\n    end\n    subgraph Server[\"Python server (FastAPI, 127.0.0.1:8000)\"]\n      Guard[\"Host and Origin guard\"]\n      Routes[\"API routes\"]\n      Trans[\"WhistleTranscriber<br/>one clip at a time\"]\n      Reg[\"FolderRegistry\"]\n      Save[\"save_note\"]\n    end\n    Model[(\"Whistle engine and model<br/>~/.cache/cactus-needle\")]\n    Registry[(\"data/folders.json\")]\n    Notes[(\"Your folders<br/>*.txt files\")]\n  end\n  CDN[\"cdn.jsdelivr.net<br/>Tailwind CSS only\"]\n\n  Home --> App\n  App --> Rec\n  App -- \"JSON\" --> Guard\n  Rec -- \"audio/wav\" --> Guard\n  Guard --> Routes\n  Routes --> Trans --> Model\n  Routes --> Reg --> Registry\n  Routes --> Save --> Notes\n  Browser -. \"styles only\" .-> CDN\n```\n\nEverything inside \"Your machine\" stays local. The only outside request is the Tailwind stylesheet. See [ADR-006](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-006-frontend-tailwind-and-routes.md).\n\n```\nsequenceDiagram\n  autonumber\n  actor User\n  participant Browser as Browser (app.js, recorder.js)\n  participant Server as Server (FastAPI)\n  participant Whistle\n  participant Folder as Chosen folder\n\n  User->>Browser: Click Record\n  Browser->>Browser: Capture mic at 16 kHz\n  User->>Browser: Click Stop (or 25 s cap)\n  Browser->>Browser: Encode 16-bit mono WAV\n  Browser->>Server: POST /api/transcriptions (audio/wav)\n  Server->>Server: Check Host, Origin, size and WAV format\n  Server->>Whistle: transcribe(samples, language en, keywords)\n  Whistle-->>Server: text\n  Server-->>Browser: 200 with text\n  Browser->>User: Transcript appended to the note\n  User->>Browser: Click Save as .txt\n  Browser->>Server: POST /api/notes\n  Server->>Folder: Create new .txt file, never overwrite\n  Server-->>Browser: 201 with file path\n```\n\nAudio lives in memory only. Debug mode is the one exception, and it is off by default.\n\n``` php\nflowchart TD\n  A[\"Microphone samples<br/>browser memory\"] --> B[\"WAV bytes<br/>request body\"]\n  B --> C[\"Float samples<br/>server memory\"]\n  C --> D[\"Transcript text\"]\n  D --> E[\"Note box<br/>edited by the user\"]\n  E --> F[\".txt file<br/>chosen folder\"]\n  C -. \"debug only<br/>VOICENOTES_DEBUG_CLIPS_DIR\" .-> G[(\"Debug folder<br/>clip and transcript\")]\nphp\nflowchart TD\n  Main[\"__main__.py<br/>entry point\"] --> App[\"app.py<br/>routes and middleware\"]\n  Main --> Config[\"config.py<br/>settings from env\"]\n  Main --> Folders[\"folders.py<br/>folder registry\"]\n  Main --> Transcription[\"transcription.py<br/>WAV read and Whistle\"]\n  App --> Config\n  App --> Folders\n  App --> Notes[\"notes.py<br/>file names and writes\"]\n  App --> Transcription\n  Warmup[\"warmup.py<br/>setup-time model load\"] --> Transcription\n```\n\n| Path | Purpose | \n|---|---|\n| `voicenotes/app.py` | Routes, request guard, error mapping | \n| `voicenotes/transcription.py` | WAV parsing, Whistle wrapper, privacy environment settings | \n| `voicenotes/folders.py` | Registered folders, stored in one JSON file | \n| `voicenotes/notes.py` | Safe file names and no-overwrite writes | \n| `voicenotes/config.py` | Environment settings | \n| `voicenotes/static/` | `home.html` ,`app.html` ,`app.js` ,`recorder.js` ,`recorder-worklet.js` | \n| `scripts/setup.sh` | First-time setup | \n| `adr/` | Architecture decision records | \n\n| Method | Path | Purpose | \n|---|---|---|\n| GET | `/` | Homepage | \n| GET | `/app` | Notes app | \n| GET | `/healthz` | Health check | \n| GET | `/api/folders` | List registered folders | \n| POST | `/api/folders` | Register a folder by absolute path | \n| DELETE | `/api/folders/{id}` | Unregister a folder. Files stay on disk. | \n| POST | `/api/transcriptions` | Send one WAV clip, get text back | \n| POST | `/api/notes` | Save a note as a `.txt` file | \n\n- **One user, one process.** Whistle runs on the CPU, one clip at a time. This is the first bottleneck. The next step would be a worker pool, then a job queue.\n- **Files, not a database.** Simple and portable. Search across folders would need an index. See[ADR-002](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-002-save-notes-as-txt-files.md) .\n- **Clips, not streams.** Text appears after each clip. Live text needs streaming. See[ADR-004](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-004-clip-limits-without-splitting.md) .\n- **Local-only access.** No login. Only local`Host` and`Origin` headers are accepted. See[ADR-008](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-008-local-request-guard.md) .\n\nAfter you clone the repo, run the setup script once from the project root:\n\n```\n./scripts/setup.sh\n```\n\nThe script:\n\n1. Installs the Python dependencies with `uv sync` .\n2. Runs the test suite.\n3. Downloads the Whistle engine and model. This needs network access once. Later runs reuse the cache.\n\nSetup needs [uv](https://docs.astral.sh/uv/). macOS and Linux work. On Windows, use WSL. You can run the script again at any time.\n\n```\nuv run python -m voicenotes\n```\n\nOpen [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. The homepage is `/`. The notes app is `/app`.\n\nThe pages load Tailwind CSS from `cdn.jsdelivr.net`, so the styling needs internet access. Notes and audio stay on your machine. See [ADR-006](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-006-frontend-tailwind-and-routes.md).\n\n| Name | Default | Description | \n|---|---|---|\n| `VOICENOTES_HOST` | `127.0.0.1` | Address to listen on. Keep the default. The app has no login. | \n| `VOICENOTES_PORT` | `8000` | Port. | \n| `VOICENOTES_FOLDERS_PATH` | `data/folders.json` | File that stores the folder list. | \n| `VOICENOTES_KEYWORDS` | empty | Comma-separated names to favor in speech, for example `Priya,Tom` . Helps some names, not all. | \n| `VOICENOTES_DEBUG_CLIPS_DIR` | empty | Debug only. Saves each clip and its transcript to this folder. Leave empty for normal use. See [ADR-005](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-005-debug-clip-retention.md) . | \n\n`./scripts/setup.sh` downloads the Whistle engine and model from Hugging Face into `~/.cache/cactus-needle/`. Setup sets `HF_HUB_OFFLINE=0` for that one step. If you skip setup, run the warm-up step by hand before the first use:\n\n```\nHF_HUB_OFFLINE=0 uv run python -m voicenotes.warmup\n```\n\nAfter that, the app runs with no network access for speech recognition. The app also turns off the Whistle usage events (`NEEDLE_TELEMETRY=0`, `DO_NOT_TRACK=1`). See [ADR-007](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-007-whistle-runtime-settings.md).\n\n1. Add a folder by its absolute path, for example `/Users/you/Documents/Notes` .\n2. Click **Record** , speak, then click**Stop** . The clip stops by itself after 25 seconds. The transcript is added to the note. You can also type.\n3. Review the text. Pick the folder and click **Save as .txt** .\n\nThe title becomes the file name. A name that already exists gets  `(2)`,  `(3)` and so on. Files are never overwritten.\n\nRemoving a folder from the list does not delete its files.\n\nAudio is not saved by default. The browser sends one clip, the server transcribes it, and the clip is discarded. Debug mode is the exception.\n\n| ADR | Decision | \n|---|---|\n| [ADR-001](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-001-voice-note-app-plan.md) | Project plan. Whistle on the server, FastAPI, self-hosted, no outbound network for speech. | \n| [ADR-002](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-002-save-notes-as-txt-files.md) | Notes are `.txt` files in user-chosen folders. No notes database. | \n| [ADR-003](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-003-capture-audio-at-16khz-in-browser.md) | Capture audio at 16 kHz in the browser. | \n| [ADR-004](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-004-clip-limits-without-splitting.md) | 25 s browser cap, 30 s server limit, no automatic splitting. | \n| [ADR-005](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-005-debug-clip-retention.md) | Audio kept only in opt-in debug mode. | \n| [ADR-006](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-006-frontend-tailwind-and-routes.md) | Tailwind CSS from CDN. Homepage at `/` , app at`/app` . | \n| [ADR-007](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-007-whistle-runtime-settings.md) | Whistle telemetry off, offline by default, English only, keyword biasing. | \n| [ADR-008](https://github.com/snehaldutta/Dhwani/blob/initial-import/adr/ADR-008-local-request-guard.md) | Only local `Host` and`Origin` headers accepted. | \n\n```\nuv run pytest\n```\n\nThe suite covers folder rules, file names and writes, the API, and WAV parsing. It uses a fake transcriber. The real Whistle path was checked by hand.\n\n- English only.\n- No login. Only `127.0.0.1` or`localhost` requests are accepted.\n- Recordings stop at 25 seconds. Longer notes need several recordings.\n- Whistle sometimes misses names, even with `VOICENOTES_KEYWORDS` . Edit those words by hand.\n- A file write is not atomic. A crash in the middle can leave a partial file.\n- No search across folders.\n- The styling needs the Tailwind CDN. Without it, pages load unstyled.\n\nMade by Snehal Dutta · [LinkedIn](https://www.linkedin.com/in/snehal-python)", "url": "https://wpnews.pro/news/dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute", "canonical_source": "https://github.com/snehaldutta/Dhwani", "published_at": "2026-10-28 07:16:06+00:00", "updated_at": "2026-10-28 07:41:52.239297+00:00", "lang": "en", "topics": ["ai-tools", "natural-language-processing", "developer-tools"], "entities": ["Dhwani", "Cactus Compute", "Whistle", "FastAPI", "Python", "Tailwind CSS"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute", "markdown": "https://wpnews.pro/news/dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute.md", "text": "https://wpnews.pro/news/dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute.txt", "jsonld": "https://wpnews.pro/news/dhwani-a-self-hosted-voice-notes-app-built-with-whistle-by-cactus-compute.jsonld"}}