# Prajwal HD > AI engineer based in California building AI agents and the developer tools around them, with three years of backend and full-stack engineering at Opslyft and peer-reviewed Android malware detection research at Purdue. Open to AI engineer, backend, and full-stack roles. --- # Prajwal HD AI Engineer / Backend / Full-Stack | California, USA Email: hdprajwal01@gmail.com | GitHub: https://github.com/hdprajwal | LinkedIn: https://www.linkedin.com/in/hdprajwal Last updated: 2026-07-03 AI engineer with a backend and full-stack foundation. Three years shipping production cloud cost infrastructure at Opslyft (including ~$100K/month in recurring savings for enterprise AWS customers), plus peer-reviewed research on Android malware detection at Purdue. ## Experience ### Translation Commons (Remote) Software Engineer, Open Source, Open Source, Feb 2026 to Present - Built lang-nav-mcp, a TypeScript MCP (Model Context Protocol) server deployed on a Cloudflare Worker that exposes the language graph as MCP tools; as one use case, a PM can query a region's language distribution to plan market expansion and localization. - Set up per-PR Cloudflare preview deployments (a live URL for every PR), cutting review turnaround and letting non-technical Product and Design teammates review changes directly; paired with AI-generated variants, the team now previews multiple versions of a feature live before choosing one. - Instrumented the platform with Amplitude product analytics, giving maintainers their first visibility into feature and export usage and shifting roadmap decisions from guesswork to data on what users actually use. ### Opslyft (Bangalore, India) Cloud Engineer, Full-time, Mar 2022 to Nov 2023 - Drove ~$100K/month in recurring cost savings across enterprise AWS customers by identifying idle infrastructure, reserved-instance gaps, and data-transfer inefficiencies. - Built a multi-cloud asset management service that unified resource discovery across customer AWS, GCP, and Azure accounts for cost attribution and governance. - Partnered with customer engineering teams to redesign cloud architectures for cost and security, shortening their deployment cycles. Software Development Engineer, Full-time, Apr 2021 to Mar 2022 - Reduced SaaS platform page load times by 2x through UI component refactoring and render-path optimization. - Redesigned backend logic for the instance scheduler to cover edge cases, reaching 99% service uptime in customer environments. - Architected multi-cloud Data Transfer Visibility, solving a major pain point for teams trying to attribute cross-region and cross-AZ data transfer costs. ### Purdue University (Fort Wayne, IN) Teaching Assistant, Machine Learning, Part-time, Jan 2025 to Dec 2025 - Supported an undergraduate Machine Learning course through grading and feedback for ~40 students per semester. - Automated grading workflows in Python, improving consistency and reducing turnaround time on student evaluations. Research Assistant, Full-time, Oct 2024 to Dec 2024 - Designed and shipped an MVP mobile application and backend in React Native and TypeScript for a faculty research project. ### Gradspace (Bangalore, India) Full-stack Developer Intern, Internship, Sep 2020 to Feb 2021 - Rearchitected backend services on AWS, improving API response times and reducing deploy time. - Implemented mobile push notifications to improve user engagement on the platform. - Refactored the mobile codebase with the mobile team, cutting app load time by 60%. ## Publications ### Re-Evaluating Android Malware Detection: Tabular Features, Vision Models, and Ensembles MDPI Electronics, 2026. Hosahalli Dayananda, P.; Chen, Z. Electronics 2026, 15, 544. Compares tabular static features, vision-based byte-plot models, and ensemble strategies for Android malware classification across 1M+ samples. Evaluates where modality choice changes the detection surface and where ensembles actually pay for themselves. https://doi.org/10.3390/electronics15030544 ## Speaking ### Data Transfer Cost Optimization (Rootconf 2022) Co-presented an approach to granular visibility into cloud data transfer costs using AWS Cost and Usage Reports and VPC flow logs. https://hasgeek.com/rootconf/optimizing-costs-of-cloud-infrastructure/sub/data-transfer-cost-optimization-KGSAciSRiEjTo1bjGNWChG ### Simplifying the Complexities of Kubernetes Cost Visibility (Opslyft Webinar, Jun 2022) Spoke alongside Aayush Kumar (Founder and CEO, Opslyft) on bridging Kubernetes orchestration and underlying cloud infrastructure costs: capacity misconfigurations, siloed visibility, and building a transparency layer for effective cost management. ## Education ### Purdue University, Fort Wayne, IN Master of Science, Computer Science, Jan 2024 to Dec 2025 Thesis: Re-Evaluating Android Malware Detection: Tabular Features, Vision Models, and Ensembles (https://doi.org/10.3390/electronics15030544) Coursework: Deep Learning, Cryptography and Network Security, NLP, Algorithm Design, Database Systems ### Govt. S.K.S.J Technological Institute, Bangalore, India Bachelor of Engineering, Computer Science and Engineering, Aug 2017 to Jul 2021 --- # emberd Run untrusted AI-agent code in isolated microVMs you host yourself, inside your own network. Tags: Go, MicroVM, Firecracker, Sandboxing, AI Agents, Self-hosted Code: https://github.com/hdprajwal/emberd Live: https://emberd.hdprajwal.dev An open-source runtime for running AI-agent code in isolated microVMs that you host yourself. Hosted sandboxes like E2B and Modal run your code in their cloud. emberd runs it on your own machine, inside your own network. You create a sandbox, it boots a real Firecracker microVM, runs your code inside it, returns the output, then destroys it. The base image is read-only, each sandbox gets a throwaway writable layer, and a sandbox runs with no network device by default. Most sandbox services run your code in their cloud. E2B, Modal, Fly Machines, Cloudflare sandboxes. They all work, and they are all the same shape. You send your code to their machines, it runs over there, and the output comes back. That is fine until the code is the part you do not trust, or the data is the part you cannot send anywhere. That is the spot I kept landing in. I wanted to run code from an AI agent, but an agent can run anything, including code written to do damage. So I did not want to run it on my own machine as is. But I also did not want to ship it off to someone else's cloud just to keep my machine safe. Both options were bad for the same reason. The code had to run somewhere I either trusted too much or controlled too little. The local answer everyone reaches for is a container. A container is light and fast, and it would have been a lot less code to write. But a container shares the host kernel with everything else on the box. For normal apps that is fine. For code an agent wrote, that shared kernel is the one boundary I did not want to sit on top of. One kernel bug and the sandbox is not a sandbox anymore. So I built emberd. It runs a real Firecracker microVM for each sandbox, on your own machine, inside your own network. A microVM has its own kernel, so the agent code never touches yours. It is the same kind of boundary your cloud provider uses to keep its customers apart, except you get to spin one up per code run, and it never leaves your network. emberd is still being built, so the details keep changing. The full writeup, the API, and the setup steps live in the docs at [emberd.hdprajwal.dev](https://emberd.hdprajwal.dev). The code is on [GitHub](https://github.com/hdprajwal/emberd). --- # QuackCode Run coding agents on your real repos on Linux, with safe isolation and full history. Tags: Developer Tools, Electron, Git, SQLite Code: https://github.com/hdprajwal/quackcode Electron desktop app for running coding agents against real repositories. Supports Claude through the local Claude CLI, Gemini through API keys, and Codex through codex app-server; streams tool activity live, persists threads and event history in SQLite, and can run in disposable git worktrees or scheduled automation threads. Every new coding tool shipped for macOS and Windows first, and Linux got a terminal client or a vague roadmap. The Codex desktop app, the Claude Code desktop app, cmux, Conductor, none of them had a Linux build I could actually install. I wanted something native and local I could use on Linux every day. So I built it. QuackCode is an ongoing desktop coding agent for Linux that opens local repos, runs multi-turn threads against them, streams text and tool activity live, and keeps the whole history on disk. The current build supports Claude through the local `claude` CLI session, Gemini through API keys, and Codex through `codex app-server`. Some providers fit best as direct API integrations, others fit better as local CLI-backed sessions, and the app handles both behind one interface. Most of the current work on QuackCode now happens inside QuackCode. The UI went sluggish on long streamed replies because the renderer was re-rendering on every token, which pushed me to batch `text_delta` chunks and flush them once per animation frame. I also caught threads silently switching providers when I changed the dropdown mid-conversation, which is how the thread-level provider lock ended up in the agent service. Both of those came from using the tool to build the tool. ![QuackCode main interface with an active conversation](https://assets.hdprajwal.dev/projects/quackcode/main-interface.png) ## What shipped The core loop lives in the Electron main process. A user message gets written to SQLite, the app rebuilds the thread history from the database, hands that context to the selected provider, then records the resulting assistant text, tool calls, tool results, and turn events back into storage. Threads are intentionally locked to one provider after the first message so a conversation does not silently switch from Claude to Gemini halfway through, while model choice can still move within that provider. Gemini uses QuackCode's own tool definitions directly. Claude and Codex execute tools through their own SDK or session APIs, so I normalize those tool calls and results back into QuackCode's message model and event log so the renderer can replay the turn the same way no matter which backend handled it. On the UI side, text and tool events stream in real time, and I batch token bursts in the renderer so the app stays responsive instead of re-rendering on every tiny chunk. ## Trust and isolation The hardest product decision was how to make bad runs cheap. QuackCode can run against the local repo or against a disposable git worktree, and new worktrees are created under `.quackcode-worktrees` inside the project. That means I can hand the agent a real task without mixing its edits into whatever I already have in progress. If the run goes sideways, I remove the worktree and move on. The safety model itself is narrow. For QuackCode's built-in file tools, paths are resolved against the project root and path traversal is rejected. But this is not a sandboxed hosted product: the Claude path is configured for autonomous local execution, and the Codex path runs through a full-access local session. In practice the trust model comes from visibility, cancellation, repo isolation, and the fact that this is a local power-user tool running on my own machine. ![Local vs Worktree environment selection in QuackCode](https://assets.hdprajwal.dev/projects/quackcode/environment-selection.png) ## Local-first memory Everything important sits in a local `better-sqlite3` database. I thought about a flat JSON file first because it would have been easier to read in a terminal, but the moment I imagined the chat loop and a scheduled automation writing to the same file at the same time I stopped. SQLite with WAL mode gave me concurrent reads during a write without fighting the filesystem, and turning on `foreign_keys` meant deleting a project actually deleted its threads and events instead of leaving zombie rows behind. Replaying a thread correctly was the hard part. Timestamps are not enough once text and tool calls are streaming at the same time, so `thread_events` has a per-thread `sequence` column and an index on `(thread_id, sequence)`. The only other index I needed was `(project_id, sort_order)` on the thread list, because without it a project with a few hundred threads felt noticeably slower to open. Schema changes go through a `runMigrations()` pass at startup that uses `PRAGMA table_info` to see which columns exist and adds the missing ones inside a single transaction, so a failed upgrade rolls back cleanly instead of leaving someone's DB half-written. Once the agent becomes part of daily work, old runs stop being throwaway chat history. I can reopen a repo, search past threads, and find the exact turn where the agent figured something out last week. The scope widened once the main chat loop was solid. I wanted prompts to run on a schedule without pulling in a new runtime or a process manager for it, so the scheduler is deliberately small: a `Map` of `setTimeout` handles keyed by automation id, and a `next_run_at` column in the DB that gets updated before every run. If a timer is stale and fires late, the `next_run_at` check catches it instead of double-firing. Retries were the part I kept second-guessing. Most jobs I have built in the past retry by default, but a coding agent is not a stateless job. A failed run may have already touched files. So a failure goes into `automation_executions.error` and the `[Auto]` thread stays on disk, and the next scheduled run happens on schedule, not sooner. If I want the failed work to happen again, I open the thread and decide. The skills view is smaller in scope, but the edges still matter. It spawns the `skills` CLI through `npx --yes skills` with `DISABLE_TELEMETRY=1` and `CI=1`, which sounds minor but matters when the child process is a few menus deep and will happily hang forever on an interactive prompt nobody ever sees. Local `SKILL.md` reads are pinned to the installed-skills list before the file is opened, so the UI cannot be tricked into reading an arbitrary path if the id ever comes from the wrong place. For skills hosted on GitHub I fetch the raw markdown URL and parse the frontmatter with a small line-scanner. A full YAML dependency would have been more code and one more thing to keep up to date for the handful of fields the app actually reads. ![QuackCode automations and skills view](https://assets.hdprajwal.dev/projects/quackcode/automation.png) ## What I learned Most of QuackCode was built with AI tools. Claude Code and Codex through opencode handled a lot of the day-to-day coding, which felt like a fair test of the premise since QuackCode is itself a coding agent. They helped a lot, but a few problems needed hands-on debugging. The one I remember most clearly was the renderer. On a long streamed reply the UI would go sluggish and the fan would spin up, and no amount of prompting got the agent to diagnose it correctly. I spent a weekend in the profiler and the fix turned out to be small: merge consecutive `text_delta` chunks for the same message, buffer them in a pending array, and flush once per animation frame. Terminal chunks like `done` and `error` still flush immediately so the state machine does not stall. The patch was maybe forty lines. Finding it was not. I also learned a lot from reading other projects. Going through opencode at the start gave me the shape of what a multi-provider layer should look like, in particular the idea of a canonical parts stream that every provider emits into so the renderer can stay provider-agnostic. That is what `parts.ts` does in QuackCode today. Pi showed me a clean way to handle CLI-backed sessions, and I pulled a few pieces from t3code later on. Generating code is the easy part of a coding agent. The harder parts are state, boundaries, and recovery, and you tend to notice them only when they are missing. Worktrees started as a vague idea and became real only after an agent run touched files I was mid-edit on. The rest of the isolation and memory work showed up the same way: specific, local, reactive. --- # Gitwise Name commits and branches in seconds, without leaking secrets to the model. Tags: CLI, Git, TypeScript, Bun Code: https://github.com/hdprajwal/gitwise Git helper that reads staged diffs, proposes repo-style commit messages, and turns plain English into branch names. Interactive by default, validates model output before showing it, and blocks outbound prompts when the diff looks like it contains secrets. Finishing a change and then naming the commit and branch always felt like the least interesting part of the workflow. Small tasks, but they happen often enough that the friction adds up. The rule: the model can suggest, never act. Gitwise reads local git state, proposes a result, and waits for confirmation before it runs anything. That keeps the tool useful when the first answer is close but not right. ![Gitwise commit flow in the terminal](https://assets.hdprajwal.dev/projects/gitwise/commit_flow.png) ## What it does `gitwise commit` reads the staged diff, looks at recent history for style, and proposes a commit message that you can accept, edit, regenerate, or cancel. `gitwise branch "add github oauth login"` turns plain English into a branch like `feature/add-github-oauth-login` and creates it only after confirmation. The goal was to remove repetitive naming work without adding cleanup work when the model misses the mark. ## How it works For commit generation, Gitwise uses `git diff --staged` as the main input and recent commit history as lightweight context for style matching. The model response is validated against a schema before it is shown, so malformed output fails early instead of leaking into a git command. Configuration is layered so the tool works both as a personal CLI and as a repo-specific helper: ``` ~/.gitwise/config.json -> machine-wide defaults .gitwise.json -> repo-level overrides ~/.gitwise/.env -> global secrets .env -> repo-level secrets ``` Repo config overrides global config. Environment variables override both. That keeps the default setup simple while still making it easy to change models or endpoints per repository. ## Safety model Before any diff leaves the machine, Gitwise runs a secret scan over the outbound prompt payload. If the staged changes look like they contain an API key, token, or password, the request is blocked and the CLI tells you which lines triggered the stop. I added that after nearly sending a `.env` file to OpenAI during testing. The scanner is intentionally conservative. I would rather explain a false positive than a leaked key. ![Gitwise secret detection warning blocking an outbound prompt](https://assets.hdprajwal.dev/projects/gitwise/secret_detection.png) ## Decisions and tradeoffs I kept the tool interactive instead of making it one-shot because the first suggestion is often close but not perfect. Accept, edit, regenerate, and cancel is a better workflow than rerunning the whole command from scratch. I used Bun instead of Node because cold-start time matters for a CLI that sits in the middle of normal development, and faster startup keeps the tool out of the way. I also validated model output against a schema instead of trusting it blindly. If the response is invalid, the command should fail before git ever sees it. The secret scan sits on the outbound path for the same reason: the risky boundary is the provider call, so the guard belongs before the request, not as a warning after it. Layered config followed from how teams actually use tools like this across projects with different models and endpoints; repo overrides let the tool adapt without turning setup into a chore. ## What I would change next The current secret detector is regex-based. It catches obvious shapes like `AKIA...` and `sk-...`, but less obvious leaks still slip through. An entropy-based pass on top would cover more of the long tail. The next obvious extension is PR description generation. The diff parsing, confirmation loop, and output validation already exist. It mostly needs a different prompt and schema. --- # When prompts replace code Published: 2026-05-04 Tags: AI Engineering, LLMs, Software 3.0, Verifiability, Cursor, Evals Author: Prajwal HD Software 3.0 landing in a production codebase. Cursor deleted a feature and rewrote it as a prompt. Here's where that pattern holds and where it quietly breaks. [David Gomes from Cursor](https://www.youtube.com/watch?v=WE_Gnowy3uw) recently shipped a pull request that deleted 15,000 lines of TypeScript and replaced it with 200 lines of plain prose. Same feature, mostly. Less code than this blog post. The easy read is "markdown is the new code." It isn't. The interpreter changed. The swap only works for tasks that can verify themselves at inference time. ## The interpreter changed The format barely matters. It could be YAML, plain text, or a custom DSL. An LLM is reading the file now, not a CPU. [Andrej Karpathy](https://www.youtube.com/watch?v=LCEmiRjPEtQ) has been making a version of this point for a while. In Software 1.0 you write code and the CPU executes it. In Software 2.0 you write training data and architectures, and gradient descent produces weights. In Software 3.0 you write prompts. The LLM is the interpreter. The context window is your program. I didn't fully get this framing until I watched the Cursor PR play out. Cursor's worktree refactor is a clean instance of the 3.0 pattern landing in production. The 15k lines of TypeScript described how to create worktrees, isolate them, run setup scripts, and judge outputs. The 200 lines of prompt describe the same things. The difference is who reads them. In his [recent Sequoia talk](https://www.youtube.com/watch?v=96jN2OCOfLs), Karpathy described building a small app called MenuGen. You photograph a restaurant menu and it generates pictures of each dish. Then he realized you could hand the photo to Gemini with a one-line prompt and get the same output. "That app shouldn't exist," he said. The model swallowed the application layer. Cursor's worktree feature got partially swallowed the same way. I keep finding layers like this in code I wrote two years ago. ## Where the swap stops working If "prompts are the program" were the whole story, every codebase would be shrinking by 75x right now. They aren't. Cursor itself only deleted the worktree feature, not the entire application. There's a reason for that. The major labs train these models with reinforcement learning in environments where outputs can be automatically verified. Math problems where you can check the answer. Code where you can run the tests. Tasks with a verification signal get practiced thousands of times during training. Tasks without one do not. The result is [jagged intelligence](https://www.youtube.com/watch?v=96jN2OCOfLs). The same model refactors a 100,000-line codebase, then tells you to walk to a car wash 50 meters away because the car wash is so close. Capability is high in the domains the labs optimized for and uneven outside them. Replace code with a prompt only when the task can verify itself at inference time. Without that, you're trusting reliability the model was never trained to deliver. The parts of Cursor's refactor that work cleanly: "create a worktree at this path," "run this setup script," "open a PR from the worktree." These are short, code-shaped, and verifiable in a single step. Either the worktree exists or it does not. Either the script ran or it errored. The model is operating well inside the region the labs optimized for. The part that wobbles: "stay inside this worktree for the entire session and don't touch files outside it." Gomes is candid about this and calls the new approach "vibes-based." His exact phrasing was "knock on wood, please don't forget about this." There's no inference-time signal for whether the agent drifted outside its sandbox. It's a soft constraint stretched across hundreds of tool calls. Smaller models like Haiku deviate frequently. Composer and Grok hold the line better, but not perfectly. The old TypeScript implementation enforced isolation at the system level. The agent literally couldn't write files outside its worktree because the code wouldn't let it. The new implementation asks the agent nicely. One is a guarantee. The other is a probability distribution. That distinction matters more than the worktree story it came out of. ## The decision every team is now making The build-vs-prompt question is the most important architectural decision most teams will make in the next year. It hinges on whether the task can check itself at inference time. If it can't, the demo will still look fine. The prompt is fresh, the session is short, the path is happy. Production sessions don't get any of those things. "We shipped it as a prompt and now we're iterating on the prompt." I keep hearing some version of that sentence. Whether it describes a working product or one that's slowly breaking depends on something most of the speakers haven't checked. ## What is missing Gomes hints at the missing piece near the end of his talk. Cursor is now writing evals to test whether agents stay inside their worktrees. Two simple scorers: did the agent do work in the right place, and did it avoid the wrong place. They use these to iterate on the prompts and to add training tasks for their in-house Composer model. That's the bigger gap. When your guarantees move from deterministic to probabilistic, your testing discipline has to move with them. Unit tests, integration tests, and type systems all assume a deterministic interpreter. Prompts don't have that. We need a testing discipline built for stochastic systems. Cursor's scorers are crude, but they're already doing what most teams shipping AI features aren't: measuring. If we replace code with prose, we need a way to measure when the prose is quietly failing. --- # GuardScribe: Real-time speech-to-text with toxicity filtering Published: 2025-08-25 Tags: NLP, Speech Moderation, Realtime, Whisper, Wav2Vec2, DistilBERT Author: Prajwal HD A simple pipeline that turns live audio into text, spots toxic language, and masks it before anyone sees it. You can pick speed or accuracy depending on your use case. ## **TL;DR** I built a realtime “listen → transcribe → filter → show” pipeline. It skips silence, turns speech into text, checks if the text is toxic, and masks only the toxic words. There are two speech-to-text (ASR) engines: **wav2vec2** (fast) and **Whisper** (more accurate). With wav2vec2, average end-to-end latency is **under 400 ms**. With Whisper, it’s **\~1.1 to 1.8 s** but the text is cleaner. The sentence-level toxicity model reached **F1 0.807**, **Precision 0.835**, **Recall 0.781**, **PR-AUC 0.845**. > This started as a Natural Language Programming (NLP) course project. Not a paper - just a practical build to learn the full workflow and measure latency. ## Why I built this Live audio is everywhere: streams, games, voice chat, assistants. Most moderation happens after the fact. I wanted to see if we can catch harmful language **as it happens** without making the conversation feel slow. The goal: *fast enough*, *accurate enough*, and *not annoying*. ## What’s inside * **Silence skipping (VAD).** WebRTC VAD only lets speech frames through, so we don’t waste time on silent parts. * **Two ASR choices.** * **wav2vec2-base** → lowest latency * **Whisper-base** → higher accuracy (uses a small buffer) * **Two-step toxicity check (DistilBERT).** 1. Sentence classifier decides if the line is toxic. 2. If yes, a span tagger finds the exact toxic words to mask. * **Smart masking.** Only the toxic spans get masked. Everything else stays readable. * **Parallel pipeline.** Audio capture, ASR, and moderation run in parallel with small buffers to balance speed and context. ## How it works (under the hood) **Capture & gate:** Mic audio at 16 kHz (mono) goes through WebRTC VAD. Only frames tagged as speech enter the queue. **Transcribe:** A small buffer groups speech frames. For Whisper I use a \~2 s window with a 0.5 s step to keep context while staying responsive. Each chunk is sent to the chosen ASR backend and the ASR latency is recorded. **Moderate:** The sentence classifier scores the transcript. If the probability crosses a threshold (default **0.70**), the token‑level tagger predicts BIO spans for toxic words. I mask just those spans (or the whole sentence if token masking is disabled). The final output is the cleaned text and the total latency = ASR + moderation (+ optional span tagging). ### Pseudocode snapshot ```bash # AudioCapture → queue while running: frame = mic.read() if vad.is_speech(frame): audio_q.enqueue(frame) # SpeechToText → queue buffer = [] while running: frame = audio_q.dequeue() buffer.append(frame) if silence>THRESH or duration(buffer)>=BUFFER_MS: audio = concat(buffer) text, asr_ms = asr.transcribe(audio) transcript_q.enqueue(text, asr_ms) buffer.clear() # ModerateText while running: text, asr_ms = transcript_q.dequeue() p = sentence_clf(text) if p>=THRESH: spans = token_tagger(text) clean = mask(text, spans) else: clean = text total_ms = asr_ms + moderation_ms out_q.enqueue(clean, total_ms) ``` ## Datasets and models * **ASR checks:** LJSpeech for WER, CER, and latency * **Toxicity training:** Jigsaw Toxic Comments (sentence level) and Toxic Spans (character-level tagging) * **Backbones:** DistilBERT for moderation, wav2vec2 or Whisper for ASR ### Training & eval setup (quick facts) * Fine‑tuning: **DistilBERT** (sentence classifier) for 3 epochs on Jigsaw; **DistilBERT** (token BIO head) for 3 epochs on Toxic Spans. * Hardware for fine‑tuning: **single T4 GPU**. * ASR eval: **100 random** LJSpeech utterances. * Toxicity eval: **2,000** Toxic Spans test sentences (token tagging) + held‑out Jigsaw split for sentence classification. * Whisper streaming config: **\~2 s buffer, 0.5 s hop** (overlapping windows for context). ## Results ### ASR quality and latency | ASR model | WER % | CER % | Typical E2E latency | RTF | | ------------- | :---: | :---: | :-----------------: | :--: | | Whisper-base | 6.7 | 1.9 | \~1.2 s | 0.31 | | wav2vec2-base | 10.0 | 4.8 | \~139 ms | 0.02 | ### Toxicity detection (sentence level) | Metric | Value | | --------- | :---: | | F1 | 0.807 | | Precision | 0.835 | | Recall | 0.781 | | PR-AUC | 0.845 | | Threshold | 0.70 | ### Span tagging (word/char level) | Task | Metric | Value | | ----------- | ------ | :---: | | Toxic spans | F1 | 0.623 | ### End-to-end latency by mode | Path | Typical E2E | Best fit use case | | ------------- | :---------: | -------------------------- | | wav2vec2 path | < 400 ms | Interactive chat and games | | Whisper path | 1.1 to 1.8 s | Streams and broadcasts | ## Design choices that helped * **VAD first** to cut silence and reduce perceived lag * **Short streaming buffer** for Whisper to keep context without big delays * **Two-stage moderation** so the span tagger only runs when needed * **Threshold tuning** (default **0.7**) to lower false positives * Lower it → more recall (catches more, masks more) * Raise it → more conservative (masks less) * **Span masking** beats whole-sentence redaction for readability ## Evaluation details (what I measured) * **ASR metrics:** WER, CER, RTF, and measured per‑chunk latency. * **Moderation metrics:** sentence‑level precision/recall/F1/PR‑AUC; token‑level span F1 (BIO tagged spans → character spans). * **Throughput & latency:** total E2E time per utterance and component‑level costs (ASR dominates; token tagging is **\~5 ms**, sentence scoring is a few ms). ## Error patterns (and what to do about them) * **False positives:** harmless idioms (like “break a leg”) and **quoted slurs** can be flagged. Using **context checks** or allow‑list rules for quoted content will help reduce False Positives. * **False negatives:** implied/multi‑sentence abuse or **contextual insults** can slip through. Lowering the threshold or adding **short memory across turns** will help. * **ASR issues:** the **worst 5 to 10%** of utterances (fast speech, rare words) hurt moderation the most. Considering to **fallback** or **ask‑to‑repeat** prompts on low ASR confidence chunks will help reduce the ASR issue. ## Latency budget & runtime notes * **wav2vec2 path:** \~139 ms ASR on CPU‑friendly settings; total **< 400 ms** E2E is realistic for interactive use. * **Whisper path:** \~1.2 s ASR due to larger context; total **1.1 to 1.8 s** with span masking. * **Token tagger:** \~5 ms per utterance; negligible. * **Bench box:** Quad‑core CPU, 32 GB RAM, GTX 1050. Training/fine‑tuning done on a **single T4**. ## Where this fits * **Live streams & social audio:** tiny delay + span masking to ease manual review * **In-game voice chat:** low-latency path with a conservative threshold * **Assistants & contact centers:** edge/on-device moderation to avoid server trips ## Limitations * **ASR errors** can confuse the classifier (homophones are tricky) * **Context is hard:** sarcasm, quotes, multi-turn abuse still cause misses * **Domain shift:** typed comments ≠ spontaneous speech * **Audio bleeping** needs a small delay; text masking is instant ## What’s next * **Hybrid ASR router** Start with **wav2vec2** for short/clear utterances. If ASR confidence is low or the utterance is longer, **switch to Whisper**. Keep **chunked streaming** to cut perceived delay. * **Short memory + diarization** to handle ongoing abuse across turns * **Edge optimization:** quantization/distillation to aim for **< 250 ms** E2E on laptops * **Reviewer UI:** quick human review for tough calls, with continual learning ## Conclusion I went in thinking model choice would decide everything. It didn’t. VAD, tiny buffers, and masking strategy mattered just as much. The result isn’t perfect, but it’s useful and it runs fast enough to feel natural. Github Repo : [https://github.com/hdprajwal/Guardscribe](https://github.com/hdprajwal/Guardscribe) Paper : [https://github.com/hdprajwal/Guardscribe/blob/main/toxic\_content\_filtering\_in\_stt.pdf](https://github.com/hdprajwal/Guardscribe/blob/main/toxic_content_filtering_in_stt.pdf) --- # v0 exports images as base64 text, not binary Published: 2026-02-27 Tags: v0, debugging v0 doesn't export public/ assets. Moving images to another folder exports them, but as base64-encoded text. Decode with base64 -d to get actual binary images. I was playing around with v0 and it gave me a really good landing page. But when I downloaded the zip, the images were not included at all. Turns out v0 doesn't export anything under `public/` in the zip. So I copied the image files into a different folder inside v0 (outside of `public/`) and downloaded again. Got the files, but then none of them would open. Not in the browser, not in my image viewer, nothing. --- ## Diagnosing the Problem Ran the `file` command to check what the downloaded files actually were: ```bash file public/images/* ``` Output: ``` about.jpg: ASCII text, with very long lines (65536), with no line terminators hero-bg.jpg: ASCII text, with very long lines (65536), with no line terminators logo.png: ASCII text, with very long lines (20936), with no line terminators ... ``` The files were **ASCII text**, not binary images. v0 had saved them as raw **base64-encoded strings** instead of actual binary image data. You can confirm this by looking at the file contents: ```bash head -c 80 public/images/logo.png # iVBORw0KGgoAAAANSUhEUgAAA... ``` `iVBORw0KGgo` is the base64 encoding of the PNG magic bytes. --- ## Fix Decode all the files back to binary using `base64 -d`: ```bash for f in public/images/*.jpg public/images/*.png; do base64 -d "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" done ``` After running this, verify with `file` again: ```bash file public/images/* ``` Expected output: ``` about.jpg: JPEG image data, baseline, precision 8, 1024x1024, components 3 hero-bg.jpg: JPEG image data, baseline, precision 8, 1024x1024, components 3 logo.png: PNG image data, 432 x 126, 8-bit/color RGBA, non-interlaced ... ``` All images are now proper binary files and open correctly. --- ## Why does this happen? (my guess) Since v0 only exports code files, I think it just treats everything in the zip as a text/code file. So when an image gets pulled in this way, it gets serialized as a base64 string instead of written as a binary file, like it's treating the image as source code. Can't confirm this for sure but it's the only thing that makes sense to me. The annoying part is that copying to a non-`public/` folder is the only way to get images in the download zip right now, so you'll have to run the decode step every time. I haven't tried connecting a git repo to see if it uploads the images to GitHub. --- Canonical site: https://hdprajwal.dev