<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>Local-first · Avinash Gosavi</title>
  <subtitle>Posts tagged “Local-first” by Avinash Gosavi.</subtitle>
  <id>https://www.avinashgosavi.com/feed/topic/local-first.xml</id>
  <link href="https://www.avinashgosavi.com/feed/topic/local-first.xml" rel="self" type="application/atom+xml"/>
  <link href="https://www.avinashgosavi.com/blog/#local-first" rel="alternate" type="text/html"/>
  <updated>2026-10-23T07:36:47+00:00</updated>
  <author><name>Avinash Gosavi</name><uri>https://www.avinashgosavi.com/</uri></author>
  <entry>
    <title type="html">Live Audio Transcription on a $0 Server Using Whisper</title>
    <link href="https://www.avinashgosavi.com/post/live-audio-transcription-whisper/" rel="alternate" type="text/html" title="Live Audio Transcription on a $0 Server Using Whisper"/>
    <id>https://www.avinashgosavi.com/post/live-audio-transcription-whisper/</id>
    <published>2026-04-12T00:00:00+00:00</published>
    <updated>2026-10-23T07:36:47+00:00</updated>
    <category term="Local-first"/>
    <category term="Tutorials"/>
    <summary type="html">Near real-time audio transcription on my own server with faster-whisper — no third-party APIs — and the open-source service I extracted from daylog.</summary>
    <content type="html" xml:base="https://www.avinashgosavi.com/post/live-audio-transcription-whisper/">&lt;p&gt;While building &lt;a href=&quot;https://github.com/aag1091-alt/daylog&quot;&gt;daylog&lt;/a&gt; — a private voice diary app — I needed a way to transcribe audio from a phone in near real-time, running entirely on my own server. No OpenAI API, no Google, no third-party services. Just a microphone, a chunk of audio, and text back.&lt;/p&gt;

&lt;p&gt;Here’s how I built it, what I learned, and the open-source service I extracted from it.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;/h2&gt;

&lt;p&gt;The browser’s &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder&quot;&gt;MediaRecorder API&lt;/a&gt; lets you record audio in chunks. You can set a timeslice — every N milliseconds, it fires an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ondataavailable&lt;/code&gt; event with a blob of audio. Send that blob to a server, get back a transcript, show it to the user. Simple enough in theory.&lt;/p&gt;

&lt;p&gt;The tricky parts:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;WebM chunks are not standalone files.&lt;/strong&gt; The first chunk from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt; contains the WebM file header. Every subsequent chunk is raw audio data with no header. If you send chunk #2 to ffmpeg on its own, it can’t decode it — it has no context for what codec, sample rate, or format to expect. I spent an embarrassing amount of time debugging silent transcriptions before I found this.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Whisper is slow on CPU.&lt;/strong&gt; The original &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openai-whisper&lt;/code&gt; package uses PyTorch and takes 15–30 seconds to transcribe a 5-second chunk on a shared CPU VM. That’s not a live transcription experience — that’s watching paint dry.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;First request is always slow.&lt;/strong&gt; Loading a 75MB model from disk takes 10–20 seconds. If you don’t warm it up at startup, the first user request times out.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-solutions&quot;&gt;The solutions&lt;/h2&gt;

&lt;h3 id=&quot;webm-header-problem&quot;&gt;WebM header problem&lt;/h3&gt;

&lt;p&gt;The fix: don’t send individual chunks. Send the &lt;strong&gt;full accumulated audio&lt;/strong&gt; on each event. Every transcription request becomes a valid, self-contained WebM file:&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;chunks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[];&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;recorder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ondataavailable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;size&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

  &lt;span class=&quot;c1&quot;&gt;// Full accumulated audio = always a valid WebM file&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Blob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;form&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FormData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;audio.webm&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/transcribe&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;form&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;transcript&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;nf&quot;&gt;setLiveTranscript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;transcript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// replace, don&apos;t append&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote class=&quot;diagram-text&quot;&gt;&lt;p&gt;&lt;strong&gt;Diagram: Why single WebM chunks fail and accumulated audio works&lt;/strong&gt; (&lt;a href=&quot;https://www.avinashgosavi.com/post/live-audio-transcription-whisper/&quot;&gt;see it in the post&lt;/a&gt;)&lt;/p&gt;&lt;p&gt;MediaRecorder fires one blob every N milliseconds. Only chunk 1 carries the WebM file header; chunks 2 and 3 are raw audio. Sending chunk 2 on its own fails: ffmpeg has no header, so it can&apos;t decode it and the transcript comes back silent. The fix sends everything recorded so far on each event: chunk 1, then chunks 1 and 2, then chunks 1, 2 and 3. Each request starts with the header, so each is a valid WebM file, and each new transcript replaces the last one.&lt;/p&gt;&lt;p&gt;&lt;em&gt;Only the first chunk carries the WebM header (shaded), so every request resends audio from the start.&lt;/em&gt;&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;The trade-off: each request sends more data than the last. For a 60-second recording with 5-second chunks, the last request sends all 60 seconds. Acceptable for a personal tool, worth optimising for production.&lt;/p&gt;

&lt;h3 id=&quot;whisper-performance&quot;&gt;Whisper performance&lt;/h3&gt;

&lt;p&gt;The original &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openai-whisper&lt;/code&gt; package runs on PyTorch. &lt;a href=&quot;https://github.com/SYSTRAN/faster-whisper&quot;&gt;faster-whisper&lt;/a&gt; uses CTranslate2 as its backend — it’s 2–4x faster on CPU and uses less memory. Combined with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tiny&lt;/code&gt; model and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;int8&lt;/code&gt; quantization, it transcribes a 5-second chunk in roughly 2–3 seconds on a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared-cpu-1x&lt;/code&gt; Fly.io machine.&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;faster_whisper&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WhisperModel&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WhisperModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;tiny&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;device&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;cpu&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compute_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;int8&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;segments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;transcribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;audio.webm&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beam_size&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;seg&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;segments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;beam_size=1&lt;/code&gt; switches from beam search to greedy decoding. For short conversational audio it makes no meaningful difference to accuracy, but it’s noticeably faster.&lt;/p&gt;

&lt;h3 id=&quot;cold-start&quot;&gt;Cold start&lt;/h3&gt;

&lt;p&gt;Bake the model into the Docker image. It adds ~75MB to the image size but eliminates the per-deploy download:&lt;/p&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;python3 &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;from faster_whisper import WhisperModel; WhisperModel(&apos;tiny&apos;, device=&apos;cpu&apos;, compute_type=&apos;int8&apos;)&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-service&quot;&gt;The service&lt;/h2&gt;

&lt;p&gt;I extracted this into a standalone microservice: &lt;a href=&quot;https://github.com/aag1091-alt/whisper-live-transcribe&quot;&gt;whisper-live-transcribe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It’s a single FastAPI endpoint:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;POST /transcribe
Content-Type: multipart/form-data

file: &amp;lt;audio file&amp;gt;
→ { &quot;transcript&quot;: &quot;what you said&quot; }
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s it. No auth, no database, no state. Drop it behind your existing API or deploy it standalone.&lt;/p&gt;

&lt;p&gt;The full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; is under 60 lines:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@app.post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/transcribe&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;transcribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UploadFile&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...)):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;filename&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;audio.webm&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.webm&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tempfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;NamedTemporaryFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;tmp_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_get_model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;segments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;transcribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tmp_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beam_size&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;seg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;seg&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;segments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;nc&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tmp_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;unlink&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;missing_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;asyncio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;transcript&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asyncio.to_thread&lt;/code&gt; keeps Whisper’s CPU-bound work off the event loop so the server stays responsive to other requests while transcribing.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;deploying-to-flyio&quot;&gt;Deploying to Fly.io&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;git clone https://github.com/aag1091-alt/whisper-live-transcribe
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;whisper-live-transcribe
fly launch &lt;span class=&quot;nt&quot;&gt;--name&lt;/span&gt; my-transcribe-service
fly deploy &lt;span class=&quot;nt&quot;&gt;--local-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Total cost: &lt;strong&gt;$0/month&lt;/strong&gt; on Fly’s free tier (1 shared-cpu-1x VM). The 1GB RAM config handles the model (~150MB for tiny + runtime overhead) with room to spare.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;performance-numbers&quot;&gt;Performance numbers&lt;/h2&gt;

&lt;p&gt;Measured on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared-cpu-1x&lt;/code&gt; with 1GB RAM:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th&gt;Size&lt;/th&gt;
      &lt;th&gt;Latency per 5s chunk&lt;/th&gt;
      &lt;th&gt;Notes&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;tiny&lt;/td&gt;
      &lt;td&gt;75MB&lt;/td&gt;
      &lt;td&gt;~2–3s&lt;/td&gt;
      &lt;td&gt;Sweet spot for real-time UX&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;base&lt;/td&gt;
      &lt;td&gt;145MB&lt;/td&gt;
      &lt;td&gt;~5–8s&lt;/td&gt;
      &lt;td&gt;Better accuracy, noticeable lag&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;small&lt;/td&gt;
      &lt;td&gt;460MB&lt;/td&gt;
      &lt;td&gt;~15–20s&lt;/td&gt;
      &lt;td&gt;Needs 2GB RAM&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;For a live transcription experience &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tiny&lt;/code&gt; is the right call. The accuracy is good enough for clear speech — it occasionally stumbles on names or technical terms, but for conversational audio it’s reliable.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-i-ended-up-doing-instead&quot;&gt;What I ended up doing instead&lt;/h2&gt;

&lt;p&gt;After shipping this for daylog, I replaced it with the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API&quot;&gt;Web Speech API&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;webkitSpeechRecognition&lt;/code&gt; on iOS Safari uses Apple’s on-device neural engine. Results are truly instant, word-by-word as you speak, and nothing leaves the device.&lt;/p&gt;

&lt;p&gt;But the server-side approach is still the right choice when:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;You need it to work in non-Safari browsers&lt;/li&gt;
  &lt;li&gt;Privacy requires avoiding Google’s servers (Chrome’s Web Speech API sends audio to Google)&lt;/li&gt;
  &lt;li&gt;You’re building a native app without browser APIs&lt;/li&gt;
  &lt;li&gt;You want to run a different language model or fine-tune for a specific domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service is sitting open-source on GitHub if you want to use it. Swap in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;base&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;small&lt;/code&gt; for better accuracy, or point it at a GPU and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;large-v3&lt;/code&gt; for near-perfect transcription.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://github.com/aag1091-alt/whisper-live-transcribe&quot;&gt;whisper-live-transcribe on GitHub&lt;/a&gt; — PRs welcome.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where this fits in daylog: &lt;a href=&quot;/post/hobby-project-stack/&quot;&gt;the hobby project stack&lt;/a&gt; covers the rest of the setup, and &lt;a href=&quot;/post/daylog-security-architecture/&quot;&gt;daylog’s security architecture&lt;/a&gt; covers how entries are encrypted once they’re transcribed. For live audio sent to a hosted model instead, &lt;a href=&quot;/post/building-rena-ai-health-companion-gemini-live/&quot;&gt;Rena&lt;/a&gt; streams 16 kHz PCM over a WebSocket to a backend that passes it to Gemini Live.&lt;/em&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title type="html">How daylog Keeps Your Diary Private (Even from Itself)</title>
    <link href="https://www.avinashgosavi.com/post/daylog-security-architecture/" rel="alternate" type="text/html" title="How daylog Keeps Your Diary Private (Even from Itself)"/>
    <id>https://www.avinashgosavi.com/post/daylog-security-architecture/</id>
    <published>2026-04-12T00:00:00+00:00</published>
    <updated>2026-10-23T17:37:26+00:00</updated>
    <category term="Privacy"/>
    <category term="Local-first"/>
    <summary type="html">How daylog, a voice diary, keeps entries and its semantic search index encrypted, with nothing leaving the device to answer a query.</summary>
    <content type="html" xml:base="https://www.avinashgosavi.com/post/daylog-security-architecture/">&lt;p&gt;daylog is a voice diary. You open it, tap the mic, say what’s on your mind, and it saves. You can search your entries semantically — type “frustrated” and it surfaces the day you wrote &lt;em&gt;“couldn’t make progress, everything felt blocked”&lt;/em&gt; even though that exact word never appears.&lt;/p&gt;

&lt;p&gt;That search working at all, while keeping your diary private, requires a few things to be true simultaneously: the content must be encrypted at rest, the search index must also be encrypted, and nothing should leave the device to answer a query. This post explains how each of those holds.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-your-words-travel&quot;&gt;How your words travel&lt;/h2&gt;

&lt;p&gt;The diagram traces a single entry from your mouth to disk, and back to your eyes.&lt;/p&gt;

&lt;blockquote class=&quot;diagram-text&quot;&gt;&lt;p&gt;&lt;strong&gt;Diagram: How a daylog entry travels to disk and back&lt;/strong&gt; (&lt;a href=&quot;https://www.avinashgosavi.com/post/daylog-security-architecture/&quot;&gt;see it in the post&lt;/a&gt;)&lt;/p&gt;&lt;p&gt;Saving goes down five layers and reading comes back up the same layers. You speak, for example &quot;went for a run today&quot;. The browser transcribes it on-device, so nothing is sent yet. HTTPS carries it encrypted in transit by TLS. The vault embeds the entry and encrypts text and vector together with AES-256-GCM using your key; when reading, it decrypts with your key only, in memory, not logged. On disk the entry is stored as ciphertext such as 7f3a a4c8 e12d, unreadable. Reading reverses the path: the vault decrypts, HTTPS carries it back, the browser shows plain text and you see it.&lt;/p&gt;&lt;p&gt;&lt;em&gt;Readable only at the top two layers. In transit and on disk it&apos;s ciphertext, and only the vault, with your key, turns it back into words.&lt;/em&gt;&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;Your words are readable on your device and readable back in your browser. Everything in between — transit, storage — is ciphertext. Notice the saving path says &lt;em&gt;“text + vector encrypted”&lt;/em&gt;: the search index is encrypted too, not just the content. More on that below.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-vaultmem-actually-does&quot;&gt;What vaultmem actually does&lt;/h2&gt;

&lt;p&gt;The vault service uses &lt;a href=&quot;/post/vaultmem-encrypted-memory-for-ai-agents/&quot;&gt;vaultmem&lt;/a&gt; — a library I built for giving AI agents a private, searchable long-term memory. In daylog it does three things every time you save an entry:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Classifies the entry automatically.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every entry is run through a fast, rule-based classifier (no LLM involved) that assigns a memory type:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;EPISODIC&lt;/strong&gt; — a time-anchored personal experience. &lt;em&gt;“Yesterday I went for a run.”&lt;/em&gt; Has a temporal marker + first-person reference.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PERSONA&lt;/strong&gt; — a habitual trait or preference. &lt;em&gt;“I usually start work before 8am.”&lt;/em&gt; Has a frequency word + first-person framing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PROCEDURAL&lt;/strong&gt; — a how-to or sequence. &lt;em&gt;“First compile, then run tests.”&lt;/em&gt; Has sequential markers.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;SEMANTIC&lt;/strong&gt; — a timeless fact. Everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most diary entries classify as EPISODIC. The type determines how the memory is weighted and how it ages — episodic memories have a natural recency decay, semantic ones don’t.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Embeds the entry as a 384-dimensional vector.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before the entry is encrypted, it is run through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/code&gt; — a 22M parameter model running locally inside the vault container. The model converts your text into a 384-float vector that captures the semantic meaning of what you wrote. (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence-transformers&lt;/code&gt; is the library that came out of the &lt;a href=&quot;/post/paper-tldr-sentence-bert/&quot;&gt;Sentence-BERT paper&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“went for a run today”&lt;/em&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[0.312, -0.087, 0.441, ..., 0.198]&lt;/code&gt; (384 values)&lt;/p&gt;

&lt;p&gt;This vector is how search finds relevant entries later. It is stored alongside the text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Encrypts text + vector together and writes to disk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The entry object — text, classification, timestamp, and embedding vector — is serialised to JSON, compressed with zlib, and encrypted with AES-256-GCM using the master encryption key (MEK). The result is appended to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.vmem&lt;/code&gt; file on disk as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IV + length + ciphertext + GCM tag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The embedding is encrypted.&lt;/strong&gt; The search index — the thing that makes semantic search work — is not sitting in a plaintext index somewhere. It is inside the same ciphertext block as your diary entry. Nobody can search your vault without your key.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-search-works-end-to-end&quot;&gt;How search works end to end&lt;/h2&gt;

&lt;p&gt;When you type “frustrated” into the search bar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The vault service embeds your query.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;“frustrated” → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[0.289, -0.041, 0.509, ...]&lt;/code&gt; (the same 384-dim model, same vector space as your stored entries).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It decrypts all stored embeddings from disk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.vmem&lt;/code&gt; entry is decrypted in RAM. The vault service now holds a matrix of shape &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(N entries) × 384&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It runs a batched dot product.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Since both vectors are unit-normalised, dot product = cosine similarity. One matrix multiply gives a relevance score for every entry simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. It filters, ranks, and returns.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Entries with score below 0.3 are dropped. The rest are sorted and the top 8 returned — with their decrypted text.&lt;/p&gt;

&lt;p&gt;The decrypted entries exist only in RAM during this step. They are never written back to disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this finds meaning, not just keywords.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two sentences with similar meaning will have similar vectors even if they share no words. “couldn’t make any progress today, everything felt blocked” scores ~0.62 against “frustrated” because the model has learned that these concepts occupy the same region of the vector space.&lt;/p&gt;

&lt;p&gt;This is the same reason “angry” might surface an entry about a deploy failing at midnight, or “energised” might surface one about a flow state — the words you actually wrote don’t have to match the words you search for.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-memory-hierarchy&quot;&gt;The memory hierarchy&lt;/h2&gt;

&lt;p&gt;vaultmem stores entries at three granularity levels:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;ATOM&lt;/strong&gt; — a single diary entry. This is what you write.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;COMPOSITE&lt;/strong&gt; — an auto-generated aggregation of related atoms. A weekly summary, or a cluster of entries about the same topic. Not yet used in daylog but the foundation is there.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AFFINITY&lt;/strong&gt; — a detected standing pattern. If the vault notices you mention sleep problems every week, it could surface that as a persistent affinity. This is where it starts to feel less like a diary and more like a memory that knows you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Search queries all three tiers in priority order — AFFINITY first, then COMPOSITE, then ATOM — so a standing pattern surfaces above any individual entry that mentions the same thing.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-one-trade-off-ai-summarise&quot;&gt;The one trade-off: AI summarise&lt;/h2&gt;

&lt;p&gt;When you tap &lt;strong&gt;Summarise this day&lt;/strong&gt;, the browser’s already-decrypted entries for that day are sent to &lt;a href=&quot;https://groq.com&quot;&gt;Groq&lt;/a&gt; (Llama 3.3 70B) via the Rails API. What Groq receives:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;09:10 AM: Went for a run today.
08:30 PM: Good session, finally fixed that bug.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Time + text for one day. No name, no metadata, nothing from other days. The summary is returned and displayed — it’s never saved back into the vault.&lt;/p&gt;

&lt;p&gt;Everything else — adding entries, browsing, searching — is vault-only. No external API. Summarise is the one feature that trades privacy for convenience, and it’s a button you tap deliberately.&lt;/p&gt;

&lt;p&gt;If that trade-off isn’t right for you, remove &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GROK_API_KEY&lt;/code&gt; from the Rails API config and everything else works unchanged.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;data-at-rest&quot;&gt;Data at rest&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Where&lt;/th&gt;
      &lt;th&gt;What&lt;/th&gt;
      &lt;th&gt;Status&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Fly.io volume (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data/vaults/&lt;/code&gt;)&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.vmem&lt;/code&gt; — text + embedding vectors, all encrypted&lt;/td&gt;
      &lt;td&gt;AES-256-GCM&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Postgres (Neon)&lt;/td&gt;
      &lt;td&gt;email, bcrypt password hash&lt;/td&gt;
      &lt;td&gt;Passwords hashed; DB encrypted at rest&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Browser sessionStorage&lt;/td&gt;
      &lt;td&gt;session_id, derived passphrase&lt;/td&gt;
      &lt;td&gt;Cleared on tab close&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Browser localStorage&lt;/td&gt;
      &lt;td&gt;auth JWT&lt;/td&gt;
      &lt;td&gt;Cleared on sign out&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Vault service RAM&lt;/td&gt;
      &lt;td&gt;MEK, decrypted atoms during active session&lt;/td&gt;
      &lt;td&gt;Never written to disk&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Voice input&lt;/strong&gt;: transcribed on-device by Apple’s neural engine. Nothing leaves your phone at this step.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Saving&lt;/strong&gt;: entry is classified, embedded, then encrypted. Text and search vector hit disk together, both as ciphertext.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Searching&lt;/strong&gt;: vault decrypts embeddings in RAM, runs cosine similarity, returns matching plaintext. No external API.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Summarising&lt;/strong&gt;: sends that day’s entries to Groq. Optional, explicit, one day at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The encrypted search index is the part most people don’t expect. Normally if you want full-text or semantic search, you need a plaintext index somewhere — Elasticsearch, pgvector, whatever. vaultmem stores the vectors encrypted alongside the content, so you get semantic search without giving the index to anyone.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h2&gt;

&lt;p&gt;The app is live. You can log in with the demo account and poke around — all entries are pre-seeded across the last two months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://daylog-ds3.pages.dev&quot;&gt;daylog-ds3.pages.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;email:    demo@daylog.app
password: demo1234
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Things worth trying:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Search&lt;/strong&gt; (bottom nav → Search tab): type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;frustrated&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flow state&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;couldn&apos;t sleep&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;good run&lt;/code&gt; — these find entries by meaning, not exact words&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Summarise&lt;/strong&gt;: navigate to any day that has entries, scroll past the list, tap &lt;strong&gt;Summarize this day&lt;/strong&gt; — it reads everything you logged that day and reflects back themes and mood&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Voice&lt;/strong&gt;: tap the mic, say something, watch the live transcription appear word by word&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The rest of the stack (hosting, transcription, costs) is in &lt;a href=&quot;/post/hobby-project-stack/&quot;&gt;my hobby project stack&lt;/a&gt;, and the open-source Whisper transcription service I extracted from daylog has &lt;a href=&quot;/post/live-audio-transcription-whisper/&quot;&gt;its own post&lt;/a&gt;. &lt;a href=&quot;/post/agent-memory-glossary/#episodic-memory&quot;&gt;Episodic&lt;/a&gt;, &lt;a href=&quot;/post/agent-memory-glossary/#semantic-memory&quot;&gt;semantic&lt;/a&gt;, &lt;a href=&quot;/post/agent-memory-glossary/#procedural-memory&quot;&gt;procedural&lt;/a&gt; and &lt;a href=&quot;/post/agent-memory-glossary/#persona-memory&quot;&gt;persona&lt;/a&gt; memory, &lt;a href=&quot;/post/agent-memory-glossary/#embedding&quot;&gt;embeddings&lt;/a&gt;, &lt;a href=&quot;/post/agent-memory-glossary/#semantic-search&quot;&gt;semantic search&lt;/a&gt; and &lt;a href=&quot;/post/agent-memory-glossary/#decay&quot;&gt;decay&lt;/a&gt; each get a short definition in the &lt;a href=&quot;/post/agent-memory-glossary/&quot;&gt;agent memory glossary&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content>
  </entry>
</feed>
