<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Baurzhan Zhetenov</title>
    <description>The latest articles on DEV Community by Baurzhan Zhetenov (@baurzhan_zhetenov_442c4cd).</description>
    <link>https://dev.to/baurzhan_zhetenov_442c4cd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3044341%2F4cbe68a9-fc09-43cc-a2ca-07e94d8a4e5f.jpg</url>
      <title>DEV Community: Baurzhan Zhetenov</title>
      <link>https://dev.to/baurzhan_zhetenov_442c4cd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/baurzhan_zhetenov_442c4cd"/>
    <language>en</language>
    <item>
      <title>5 Weird Bugs I Hit Capturing System Audio Cross-Platform (Rust + Tauri)</title>
      <dc:creator>Baurzhan Zhetenov</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:52:01 +0000</pubDate>
      <link>https://dev.to/baurzhan_zhetenov_442c4cd/5-weird-bugs-i-hit-capturing-system-audio-cross-platform-rust-tauri-5el8</link>
      <guid>https://dev.to/baurzhan_zhetenov_442c4cd/5-weird-bugs-i-hit-capturing-system-audio-cross-platform-rust-tauri-5el8</guid>
      <description>&lt;p&gt;Full disclosure up front: I'm building &lt;a href="https://syntaxcue.com" rel="noopener noreferrer"&gt;SyntaxCue&lt;/a&gt;, a desktop app that listens to a live call and helps you think through the answer in real time. None of that matters for this post — this is the engineering diary from getting system-audio capture working the same way on macOS and Windows.&lt;/p&gt;

&lt;p&gt;"System audio" sounds like it should be one API. It isn't. macOS gives you the CoreAudio Process Tap (Swift-only, no Rust bindings), and Windows gives you WASAPI opened in a mode nobody advertises as "the loopback mode." I already wrote up &lt;a href="https://syntaxcue.com/how-syntaxcue-captures-system-audio/" rel="noopener noreferrer"&gt;the full architecture&lt;/a&gt; — this post is the part that doesn't fit an architecture writeup: the five bugs that only show up once you actually run the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: a binary PCM stream deadlocked a line-buffered reader
&lt;/h2&gt;

&lt;p&gt;The Process Tap API is Swift-only, so the macOS side runs as a small Swift sidecar binary that streams raw audio to Rust over stdout — plain interleaved 32-bit float samples, nothing else. Tauri's shell plugin reads a child process's stdout as text lines by default, splitting on &lt;code&gt;\n&lt;/code&gt; (0x0A).&lt;/p&gt;

&lt;p&gt;That's fine for JSON-over-stdout. It is not fine for raw float samples, because 0x0A shows up by chance inside arbitrary audio data — and, worse, during silence (all-zero bytes), it never shows up at all. A line-buffered reader waiting for a "line" that silence will never produce just waits. Forever. The pipe backs up, the child's writes block, and the helper hangs before it ever streams a single real sample.&lt;/p&gt;

&lt;p&gt;Fix was one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
    &lt;span class="nf"&gt;.shell&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.sidecar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"syntaxcue-audiotap"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.set_raw_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;- this&lt;/span&gt;
    &lt;span class="nf"&gt;.spawn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set_raw_out(true)&lt;/code&gt; treats stdout as raw bytes instead of scanning for line breaks. Obvious once you've hit it; invisible until you have, because the failure looks exactly like "the whole thing is just hanging," not "reading stdout wrong."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a killed process left a tap that looked fine and captured nothing
&lt;/h2&gt;

&lt;p&gt;macOS's Process Tap has to be wrapped in a private aggregate device before you can pull audio through a normal IO cycle — &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt;, then &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt; wrapping it. Both are real CoreAudio objects with real lifecycles.&lt;/p&gt;

&lt;p&gt;Force-kill the sidecar mid-test (which happens constantly in development) without giving it a chance to clean up, and CoreAudio doesn't always reclaim the tap and aggregate device promptly. The next run creates a &lt;em&gt;new&lt;/em&gt; tap successfully — no error, no permission prompt, nothing — and then delivers exactly zero bytes of audio. From the outside this is indistinguishable from a real capture bug, and I spent longer than I'd like to admit debugging "capture" logic that was working fine against an orphaned device.&lt;/p&gt;

&lt;p&gt;The fix is explicit teardown on every exit path, including signals — and it has to go through &lt;code&gt;DispatchSource&lt;/code&gt;, not a raw &lt;code&gt;signal()&lt;/code&gt; handler, because tearing down CoreAudio objects allocates and talks to XPC, which isn't safe from an actual signal handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SIG_IGN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sigtermSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DispatchSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeSignalSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sigtermSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setEventHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;AudioDeviceStop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregateID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;procID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;AudioDeviceDestroyIOProcID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregateID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;procID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;AudioHardwareDestroyAggregateDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregateID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;AudioHardwareDestroyProcessTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tapID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;sigtermSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now killing the process actually tears the tap down instead of just ending the process that was holding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: WASAPI's idle endpoint doesn't send silence — it sends nothing
&lt;/h2&gt;

&lt;p&gt;Windows loopback capture works by opening the &lt;em&gt;default playback&lt;/em&gt; device in the &lt;em&gt;Capture&lt;/em&gt; direction — there's no separate "what's playing" device to enumerate. Fine so far. The part that isn't documented anywhere obvious: a render endpoint that isn't currently playing anything doesn't hand you silent buffers. It hands you no buffers at all. &lt;code&gt;get_next_packet_size()&lt;/code&gt; just returns 0, indefinitely, for as long as the call is quiet.&lt;/p&gt;

&lt;p&gt;An event-driven design assumes the event handle fires when there's something to read. On an idle endpoint it never fires — so an event-driven loop would just hang the moment the other person on the call stopped talking, which is, unhelpfully, most of a call.&lt;/p&gt;

&lt;p&gt;The fix is polling instead of waiting on the event, and manually synthesizing the elapsed silence so the voice-activity logic downstream still sees time passing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;idle_since&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="nf"&gt;.duration_since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.as_secs_f64&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TARGET_RATE&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;vad&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;BYTES_PER_FRAME&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;poll_interval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, an utterance that ended right as the line went quiet would just sit in the buffer, waiting to get merged into whatever gets said next — two separate answers transcribed as one run-on sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: the "silent" flag isn't telling you the buffer is zeroed
&lt;/h2&gt;

&lt;p&gt;Related, smaller, and sneakier: when WASAPI does deliver a buffer flagged &lt;code&gt;AUDCLNT_BUFFERFLAGS_SILENT&lt;/code&gt;, that flag means "treat this as silence" — it does not mean the buffer's memory is actually zeroed. The contents are explicitly undefined at that point.&lt;/p&gt;

&lt;p&gt;Skip that check and you're not capturing silence, you're capturing whatever happened to be sitting in that memory — which whisper.cpp will happily attempt to transcribe as if it were real audio.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="py"&gt;.flags.silent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="nf"&gt;.fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, easy to skip, and the failure mode if you do skip it is "occasional garbage phrases with no audio behind them" — which looks exactly like a transcription-model hallucination, not a buffer-handling bug, unless you already know to suspect this flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: an atexit handler that can't safely take the lock it needs
&lt;/h2&gt;

&lt;p&gt;Last one, and it's about a constraint rather than a bug that shipped: the app needs to guarantee the macOS sidecar gets a SIGTERM when the whole app quits from the menu (not just when the user clicks "stop"), so it doesn't leave an orphaned tap for Bug 2 to bite the next run. That cleanup runs from an &lt;code&gt;atexit&lt;/code&gt; handler — which has no &lt;code&gt;AppHandle&lt;/code&gt;, runs at a point where you can't assume anything about what other threads are doing, and must not block trying to acquire a mutex another thread might already be holding during shutdown.&lt;/p&gt;

&lt;p&gt;So the sidecar's PID lives in two places on purpose: the real &lt;code&gt;Option&amp;lt;CommandChild&amp;gt;&lt;/code&gt; behind a &lt;code&gt;Mutex&lt;/code&gt; for normal control flow, and a plain &lt;code&gt;AtomicI32&lt;/code&gt; mirror that the &lt;code&gt;atexit&lt;/code&gt; guard reads lock-free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SIDECAR_PID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AtomicI32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;AtomicI32&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;kill_sidecar_on_exit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SIDECAR_PID&lt;/span&gt;&lt;span class="nf"&gt;.swap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Ordering&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SeqCst&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not elegant — it's genuinely two sources of truth for one PID — but it's the boring, correct answer to "what can I safely read from a shutdown handler," which is: as little as possible, and never a lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same problem, two completely different failure shapes
&lt;/h2&gt;

&lt;p&gt;None of these are hard bugs individually. What made them worth writing down is that macOS and Windows failed in &lt;em&gt;opposite&lt;/em&gt; directions for what's nominally the same task — capture system audio, hand back the same PCM format. macOS fails by lying: the tap looks fine, permission is granted, and it just quietly hands you nothing. Windows fails by omission: it doesn't hand you anything at all, and the absence itself is the signal you have to design around. Cross-platform audio capture isn't one API with two skins on it — it's two completely different failure surfaces wearing the same output format.&lt;/p&gt;

&lt;p&gt;If you want the full pipeline this feeds into — VAD segmentation, local whisper.cpp transcription, streaming the answer back from the user's own LLM key — that's the &lt;a href="https://syntaxcue.com/how-syntaxcue-captures-system-audio/" rel="noopener noreferrer"&gt;architecture write-up&lt;/a&gt;. And if you're curious what it's actually for: &lt;a href="https://syntaxcue.com" rel="noopener noreferrer"&gt;syntaxcue.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>macos</category>
      <category>windows</category>
    </item>
    <item>
      <title>Why Copying Bank Statement PDFs Into Excel Goes So Wrong</title>
      <dc:creator>Baurzhan Zhetenov</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:32:10 +0000</pubDate>
      <link>https://dev.to/baurzhan_zhetenov_442c4cd/why-copying-bank-statement-pdfs-into-excel-goes-so-wrong-556o</link>
      <guid>https://dev.to/baurzhan_zhetenov_442c4cd/why-copying-bank-statement-pdfs-into-excel-goes-so-wrong-556o</guid>
      <description>&lt;p&gt;If you've ever copied transactions from a bank statement PDF into Excel, you've probably seen the mess: broken columns, missing rows, numbers in the wrong places.&lt;/p&gt;

&lt;p&gt;It looks like a table on your screen, so why doesn't it behave like one?&lt;/p&gt;

&lt;p&gt;I kept running into this problem while building Bank Parser (&lt;a href="https://bank-parser.com" rel="noopener noreferrer"&gt;https://bank-parser.com&lt;/a&gt;). I ended up writing a more detailed technical explanation on the site here: Why Bank Statement PDFs Break in Excel (&lt;a href="https://bank-parser.com/blog/why-bank-statement-pdfs-break-excel" rel="noopener noreferrer"&gt;https://bank-parser.com/blog/why-bank-statement-pdfs-break-excel&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The short version is that a PDF and a spreadsheet think about data in completely different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PDF looks structured. It often isn't.
&lt;/h2&gt;

&lt;p&gt;When you open a bank statement, you might see something like a row: date 05/02, description STARBUCKS STORE #1821, debit -6.45.&lt;/p&gt;

&lt;p&gt;Visually, that's obviously a row of transaction data lined up in columns.&lt;/p&gt;

&lt;p&gt;But internally, a PDF usually doesn't contain a real table with rows and columns. It stores text and numbers as individual elements positioned at specific X/Y coordinates on a page.&lt;/p&gt;

&lt;p&gt;The table is essentially an illusion created by layout.&lt;/p&gt;

&lt;p&gt;That works perfectly for displaying and printing the statement. It becomes a problem when another program tries to reconstruct the underlying data.&lt;/p&gt;

&lt;p&gt;Excel has to guess where the columns and rows are. And bank statements give it plenty of opportunities to guess wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that commonly break
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Columns stop lining up
&lt;/h3&gt;

&lt;p&gt;PDFs position text rather than storing consistent spreadsheet-style column widths.&lt;/p&gt;

&lt;p&gt;So when Excel interprets spacing as column boundaries, a small difference can move a value into the wrong place.&lt;/p&gt;

&lt;p&gt;A date and description might end up together. An amount can shift into the description column. Debit and credit values can stop lining up with their transactions.&lt;/p&gt;

&lt;p&gt;The result may still look like data, but it's no longer reliable data.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Transactions disappear or split apart
&lt;/h3&gt;

&lt;p&gt;Bank statements frequently use multi-line descriptions.&lt;/p&gt;

&lt;p&gt;One transaction might occupy two or three visual lines. When you paste it into Excel, those lines can become separate rows, merge incorrectly, or otherwise confuse the structure.&lt;/p&gt;

&lt;p&gt;That creates a particularly nasty problem for bookkeeping: a transaction can appear to have disappeared, or you can accidentally create duplicates.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Numbers move into the wrong column
&lt;/h3&gt;

&lt;p&gt;Financial statements often right-align numerical values.&lt;/p&gt;

&lt;p&gt;When the PDF layout is interpreted incorrectly, something like 245.50 might end up attached to the description instead of appearing under Debit or Credit.&lt;/p&gt;

&lt;p&gt;This is more than a formatting annoyance. If you're importing or reconciling financial data, the transaction direction matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Formatting gets mistaken for data
&lt;/h3&gt;

&lt;p&gt;Statements can contain merged cells, headers, horizontal separators, subtotal rows, and other visual elements.&lt;/p&gt;

&lt;p&gt;Those are useful to a human reader.&lt;/p&gt;

&lt;p&gt;During copy-paste, however, they can create blank rows, merged columns, or other artifacts that have nothing to do with the actual transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important distinction: OCR vs. structured parsing
&lt;/h2&gt;

&lt;p&gt;There's another misconception I ran into while working on this: PDF extraction and OCR aren't necessarily the same thing.&lt;/p&gt;

&lt;p&gt;OCR (Optical Character Recognition) is useful when the document is essentially an image. It reads characters from that image and tries to turn them into text. That's exactly what you need for a scanned or photographed statement.&lt;/p&gt;

&lt;p&gt;But OCR can introduce its own problems: broken decimals, merged rows, and incorrect column assignments. It recognizes a number, but it doesn't automatically know whether that number represents a debit or a credit.&lt;/p&gt;

&lt;p&gt;A native PDF downloaded directly from online banking is different. It already contains a text layer. For those files, you can extract the underlying text directly and use the document's structure and transaction patterns to reconstruct the rows.&lt;/p&gt;

&lt;p&gt;That's the approach Bank Parser uses for Chase, Bank of America, Wells Fargo, and Capital One. Its specialized parsers use structured parsing rather than OCR for native PDFs, with OCR used only when the input is a scanned image.&lt;/p&gt;

&lt;p&gt;The parser looks at things like transaction positioning, multi-line descriptions, bank-specific patterns, and balance information rather than simply trying to reproduce what the page looks like.&lt;/p&gt;

&lt;p&gt;For those native PDFs, accuracy reaches 95-100% with balance verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what actually works?
&lt;/h2&gt;

&lt;p&gt;If you only have one statement with a handful of transactions, manually cleaning up a pasted table may be perfectly reasonable.&lt;/p&gt;

&lt;p&gt;But the economics change quickly when you're dealing with bookkeeping.&lt;/p&gt;

&lt;p&gt;Twelve monthly statements for one client is already a different problem. Add multiple accounts or multiple clients, and manually repairing broken rows and columns becomes a recurring job.&lt;/p&gt;

&lt;p&gt;The better approach is to extract the transactions as structured financial data in the first place.&lt;/p&gt;

&lt;p&gt;For the four banks with specialized parsers (Chase, Bank of America, Wells Fargo, and Capital One), Bank Parser can produce a 17-field QuickBooks-ready output.&lt;/p&gt;

&lt;p&gt;For other banks, the universal converter extracts a simpler three-column structure.&lt;/p&gt;

&lt;p&gt;The goal isn't to make the PDF look like Excel. It's to reconstruct the transactions so Excel actually contains usable financial data.&lt;/p&gt;

&lt;p&gt;Try Bank Parser (&lt;a href="https://bank-parser.com" rel="noopener noreferrer"&gt;https://bank-parser.com&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I capture system audio and transcribe it locally, with no server in the loop (Tauri + Rust + whisper.cpp)</title>
      <dc:creator>Baurzhan Zhetenov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:28:03 +0000</pubDate>
      <link>https://dev.to/baurzhan_zhetenov_442c4cd/how-i-capture-system-audio-and-transcribe-it-locally-with-no-server-in-the-loop-tauri-rust--4a0n</link>
      <guid>https://dev.to/baurzhan_zhetenov_442c4cd/how-i-capture-system-audio-and-transcribe-it-locally-with-no-server-in-the-loop-tauri-rust--4a0n</guid>
      <description>&lt;p&gt;Disclosure: I'm the developer of the product this pipeline belongs to (SyntaxCue, a live-interview assistant), so treat this as an engineering writeup with an obvious source, not a neutral third-party post. Cross-posted from the original with a canonical link back.&lt;/p&gt;

&lt;p&gt;I built a tool that listens to a live technical interview and streams an answer suggestion in a few hundred milliseconds, on-device, without ever recording the call or sending audio anywhere. This is a walkthrough of the actual pipeline — the OS audio API, voice-activity detection, local transcription, and the streamed model response — with the real APIs and the real measured numbers, not the marketing version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, end to end
&lt;/h2&gt;

&lt;p&gt;Four stages: capture system audio → cut the continuous stream into utterances → transcribe each utterance locally → send the text to the user's own LLM and stream the answer back. Capture is the only stage that's platform-specific. Everything after it is shared code that never learns which OS produced the bytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Capturing system audio, not the microphone
&lt;/h3&gt;

&lt;p&gt;The first design decision drives everything downstream: it listens to &lt;strong&gt;system audio&lt;/strong&gt; — the mixed output of whatever's playing, i.e. the call — not the microphone. That's why it never has to solve "is this the candidate's voice or the interviewer's?" It captures the same mixed output a screen recorder's audio track would, not two separate input channels. There's nothing to diarize; there's one stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On macOS&lt;/strong&gt; (14.2+, Sonoma or later), this uses the CoreAudio &lt;strong&gt;Process Tap API&lt;/strong&gt;: &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; with a &lt;code&gt;CATapDescription&lt;/code&gt; configured as &lt;code&gt;monoGlobalTapButExcludeProcesses&lt;/code&gt; — a system-wide tap, not scoped to one application — with &lt;code&gt;isPrivate = true&lt;/code&gt; and, importantly, &lt;code&gt;muteBehavior = .unmuted&lt;/code&gt;. That last flag matters: the tap reads a &lt;em&gt;copy&lt;/em&gt; of the stream while the user keeps hearing their call completely normally. Nothing is intercepted, muted, or rerouted.&lt;/p&gt;

&lt;p&gt;A raw process tap isn't independently readable. To pull audio through a normal CoreAudio IO cycle, it has to be wrapped in a &lt;strong&gt;private aggregate device&lt;/strong&gt; via &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt;, which then delivers buffers through a standard IO callback. This part runs as a small &lt;strong&gt;Swift sidecar binary&lt;/strong&gt;, compiled separately and shipped alongside the main Tauri binary, because the Process Tap API has no Rust binding — it's Swift/Objective-C only. So on macOS there are two processes: the Rust/Tauri app and a thin Swift audio helper feeding it PCM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Windows&lt;/strong&gt;, capture uses &lt;strong&gt;WASAPI loopback&lt;/strong&gt;, and it runs entirely &lt;strong&gt;in-process&lt;/strong&gt; — no separate sidecar, unlike macOS. WASAPI is plain COM, reachable directly from the Rust process, so there's no second binary to build, bundle, or code-sign on this platform. Two details shape how this actually works, and they're less obvious than "loopback capture" makes it sound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loopback is a render endpoint opened for capture.&lt;/strong&gt; There's no separate "what's playing" device to enumerate. You take the &lt;em&gt;default playback&lt;/em&gt; device and initialize its audio client in the &lt;em&gt;Capture&lt;/em&gt; direction — that's specifically what sets &lt;code&gt;AUDCLNT_STREAMFLAGS_LOOPBACK&lt;/code&gt;. Nothing here ever asks for a microphone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It polls instead of being event-driven.&lt;/strong&gt; WASAPI's event handle only signals while the render endpoint is actively pulling buffers — on a silent or idle endpoint, it never fires, so an event-driven loop would simply hang the moment the call goes quiet. Polling avoids that, and gives the idle branch a place to synthesize the silence the endpoint stops delivering once nothing is playing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both platforms converge on exactly the same wire format before anything else touches the audio: &lt;strong&gt;raw interleaved PCM, 32-bit float, 16 kHz, mono.&lt;/strong&gt; From here on, voice-activity detection and transcription are identical shared code that doesn't know or care whether a CoreAudio aggregate device or a WASAPI loopback client produced the samples.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cutting the stream into utterances
&lt;/h3&gt;

&lt;p&gt;A continuous PCM stream isn't transcribable as-is — something has to decide where one spoken chunk ends. This uses &lt;strong&gt;energy-based voice-activity detection&lt;/strong&gt;: plain RMS amplitude thresholding, no ML model for this step.&lt;/p&gt;

&lt;p&gt;The rules, exactly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silence&lt;/strong&gt; is any window with RMS amplitude below &lt;strong&gt;0.006&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A segment &lt;strong&gt;closes and is sent to transcription&lt;/strong&gt; once the stream has been &lt;em&gt;voiced&lt;/em&gt; (had signal above threshold) and either: (a) silence has run for &lt;strong&gt;≥0.7 seconds&lt;/strong&gt; and the segment so far is &lt;strong&gt;≥1.0 second&lt;/strong&gt; long, or (b) the segment hits a hard cap of &lt;strong&gt;20 seconds&lt;/strong&gt;, so an unbroken monologue doesn't grow unbounded before transcription even starts.&lt;/li&gt;
&lt;li&gt;Independently of segmentation, a live audio-level meter is emitted to the UI roughly every &lt;strong&gt;100 ms&lt;/strong&gt;, so there's continuous visual confirmation that audio is being captured, whether or not a segment has closed yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part is &lt;em&gt;why&lt;/em&gt; this is deliberately unsophisticated. A neural VAD would be more precise about exact speech boundaries, but precision isn't the bottleneck here — latency and CPU cost are, on a stage that runs continuously against a live stream. More importantly, a wrong split is &lt;strong&gt;cheap&lt;/strong&gt;: if the RMS heuristic closes a segment slightly early or late, the transcription step just receives a marginally short or long chunk of the &lt;em&gt;same speech&lt;/em&gt;, not a wrong one. When the cost of an error is that low, a neural model in the hot path is the wrong trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Transcription, fully local
&lt;/h3&gt;

&lt;p&gt;Each closed segment goes to &lt;strong&gt;whisper.cpp, running on-device.&lt;/strong&gt; No audio and no transcription text leave the machine at this stage, or any other. The GPU backend is platform-specific: &lt;strong&gt;Metal on macOS, Vulkan on Windows.&lt;/strong&gt; The bundled default model is whisper.cpp's &lt;strong&gt;Small&lt;/strong&gt; model, shipped with the app; larger models are optional downloads. No word-error-rate figure has been benchmarked for it, so none is quoted.&lt;/p&gt;

&lt;p&gt;The measured cost that matters is model load — a one-time cost paid once, not per question: &lt;strong&gt;~271 ms on macOS with Metal&lt;/strong&gt;, versus &lt;strong&gt;~650–870 ms on a Windows machine without GPU acceleration&lt;/strong&gt; (CPU-only). Per-utterance transcription latency for Metal hasn't been separately isolated the way it was for Windows, so rather than invent a number to make the comparison symmetric, that side just isn't quoted — the load figure is the one that's actually been measured.&lt;/p&gt;

&lt;p&gt;The Windows number, though, is a story worth telling in full, because it changed how I think about "cross-platform."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I measured instead of assuming
&lt;/h2&gt;

&lt;p&gt;The initial assumption about Windows was the boring, reasonable one: it'd be "a bit slower than the Mac." Same model, same code above the capture layer — expect a tuning-level difference.&lt;/p&gt;

&lt;p&gt;The reality wasn't a tuning difference. Before the Vulkan backend was wired up, whisper.cpp on Windows ran &lt;strong&gt;CPU-only&lt;/strong&gt;, and it paid a &lt;strong&gt;fixed ~25-second cost per transcription pass — regardless of utterance length.&lt;/strong&gt; A one-second "yes, exactly" and a fifteen-second answer cost the same 25 seconds. For a tool whose entire value is answering within the few seconds a candidate has to respond, that isn't slow — it's non-functional. Twenty-five seconds per utterance means the answer to the &lt;em&gt;first&lt;/em&gt; question arrives around the time the interviewer is asking the third.&lt;/p&gt;

&lt;p&gt;The instinct in that situation is to start tuning: a smaller model, shorter segments, thread-count knobs. All of that would have treated a symptom. The actual cause was that there was &lt;strong&gt;no GPU backend at all&lt;/strong&gt; on Windows — whisper.cpp was doing the entire inference on CPU because nothing had told &lt;code&gt;whisper-rs&lt;/code&gt; to use the GPU. It wasn't a slow path; it was the wrong path entirely.&lt;/p&gt;

&lt;p&gt;Wiring up the &lt;strong&gt;Vulkan backend for &lt;code&gt;whisper-rs&lt;/code&gt;&lt;/strong&gt; on Windows changed the numbers by two orders of magnitude. Warm transcription of a ~6-second utterance dropped to &lt;strong&gt;~330 ms.&lt;/strong&gt; There's a one-time &lt;strong&gt;~8-second GPU "warm-up"&lt;/strong&gt; on the very first transcription of a session — paid once, not per question — and after that, warm passes are in the range the product actually needs.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past this one bug: assuming rough parity between platforms and tuning from there would have meant chasing a 20% improvement on a path that was 100× off for a categorical reason. The gap between 25 seconds and 330 milliseconds wasn't hiding in a config value; it was a missing backend. You only find that by measuring the real number on the real machine instead of reasoning about what &lt;em&gt;should&lt;/em&gt; be comparable. Cross-platform parity is a hypothesis, not a default.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The answer streams from the user's own model
&lt;/h3&gt;

&lt;p&gt;Once an utterance is transcribed, the text (optionally with a screenshot image) goes out over direct HTTPS to the user's &lt;strong&gt;own&lt;/strong&gt; LLM provider — either &lt;code&gt;api.anthropic.com/v1/messages&lt;/code&gt; or &lt;code&gt;api.openai.com/v1/chat/completions&lt;/code&gt;, whichever they configured, authenticated with &lt;strong&gt;their&lt;/strong&gt; API key. There is no server of mine anywhere in this path; the request goes from the user's machine straight to Anthropic or OpenAI and nowhere else.&lt;/p&gt;

&lt;p&gt;There's no official Anthropic or OpenAI SDK for Rust, so these are &lt;strong&gt;direct HTTP calls against the documented wire formats&lt;/strong&gt; — the response body is consumed as a byte stream, each provider's own delta format (e.g. Anthropic's &lt;code&gt;content_block_delta&lt;/code&gt; events) is parsed as it arrives, and each extracted text fragment is forwarded to the UI immediately as its own event, rather than waiting to assemble a complete response first.&lt;/p&gt;

&lt;p&gt;That streaming behavior is the product, not an implementation footnote. During a live call, perceived latency &lt;em&gt;is&lt;/em&gt; what the user experiences — they're reading or paraphrasing the suggestion out loud within seconds, so time-to-first-token is the metric that matters. A blocking call that returned a perfect, complete answer two seconds later would be a worse product than a streamed one whose first line lands in a few hundred milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a native desktop app, not a browser tab
&lt;/h2&gt;

&lt;p&gt;The pipeline above couldn't run in a browser tab, for specific reasons, not aesthetic ones.&lt;/p&gt;

&lt;p&gt;A single window transforms in place between a full setup view and a compact hint panel — no second window, no second tab. That panel needs a &lt;strong&gt;global hotkey&lt;/strong&gt; that works while the user's focus is inside the call app (Zoom, Meet, Teams). A browser tab can't register a truly global hotkey or react while unfocused; a native app can, and for a tool used &lt;em&gt;while your attention is on the interviewer&lt;/em&gt;, that's not optional.&lt;/p&gt;

&lt;p&gt;There's also an optional setting to keep the hint panel &lt;strong&gt;out of the user's own screen-capture or recording stream&lt;/strong&gt;, using native window-level APIs on each platform. It's off by default and framed entirely around the user's own screen and their own choice to share it — a browser tab has no comparable control over whether it appears in the user's own capture stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;That's the whole path: a system-audio tap (CoreAudio Process Tap on macOS, WASAPI loopback on Windows) → RMS-based segmentation → local whisper.cpp transcription (Metal / Vulkan) → a streamed response from the user's own LLM, with no server of mine anywhere in it. The specifics — the aggregate-device wrapper, the polling loopback loop, the 0.006 silence threshold, the 25-seconds-to-330ms Vulkan fix — are the honest shape of building something that has to answer within the few seconds a live interview gives you.&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any part of this in the comments — the WASAPI polling-vs-event-driven tradeoff and the macOS aggregate-device wrapper are the two I'd most enjoy discussing.&lt;/p&gt;

&lt;p&gt;Original piece, with a network-traffic verification page for the "no backend" claim: &lt;a href="https://syntaxcue.com/how-syntaxcue-captures-system-audio/" rel="noopener noreferrer"&gt;https://syntaxcue.com/how-syntaxcue-captures-system-audio/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Built a Chase Bank PDF Parser with 99% Accuracy</title>
      <dc:creator>Baurzhan Zhetenov</dc:creator>
      <pubDate>Thu, 20 Nov 2025 03:36:48 +0000</pubDate>
      <link>https://dev.to/baurzhan_zhetenov_442c4cd/how-i-built-a-chase-bank-pdf-parser-with-99-accuracy-4j6c</link>
      <guid>https://dev.to/baurzhan_zhetenov_442c4cd/how-i-built-a-chase-bank-pdf-parser-with-99-accuracy-4j6c</guid>
      <description>&lt;p&gt;Parsing PDFs sounds easy until you try parsing bank statements.&lt;/p&gt;

&lt;p&gt;I learned this the hard way.&lt;/p&gt;

&lt;p&gt;I spent nearly 2 months building a Chase Bank PDF parser that reaches 99% accuracy across 23 real statements (1,123 transactions total). Meanwhile, generic converters like Tabula or PDFTables only hit ~70% on the same documents.&lt;/p&gt;

&lt;p&gt;Here’s why Chase PDFs are much harder than you think—and how I solved the problems using TypeScript and pdfjs-dist, with real code you can copy.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Introduction&lt;/p&gt;

&lt;p&gt;If you’ve ever worked with U.S. banking data, you know that Chase Bank does something strange:&lt;br&gt;
They only let you download the last 18 months of transactions as CSV.&lt;/p&gt;

&lt;p&gt;CPAs, bookkeepers, and backend engineers quickly hit a wall when they need 5+ years of historical data. Chase provides those older statements only as PDFs—and the PDFs are absolutely not designed for machine parsing.&lt;/p&gt;

&lt;p&gt;Most accountants spend 45–60 minutes manually retyping each statement into QuickBooks or Excel.&lt;/p&gt;

&lt;p&gt;Most developers try using generic PDF converters… and then discover that bank statements are in the top 1% of “PDFs that look structured but absolutely aren’t.”&lt;/p&gt;

&lt;p&gt;I wanted to solve this in code.&lt;/p&gt;

&lt;p&gt;In this article, you’ll learn:&lt;br&gt;
    • Why Chase PDFs are so uniquely hard to parse&lt;br&gt;
    • How structure-based format detection beats year-based detection&lt;br&gt;
    • How to infer column positions when the PDF has no headers&lt;br&gt;
    • How to merge split dates from fragmented PDF text items&lt;br&gt;
    • Real TypeScript code using pdfjs-dist&lt;br&gt;
    • Accuracy results from 23 real PDFs (2015–2025)&lt;/p&gt;

&lt;p&gt;This is the article I wish existed before I started.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 1: Why Generic PDF Converters Fail on Bank Statements&lt;/p&gt;

&lt;p&gt;After testing every major converter (PDFTables, Tabula, SmallPDF), I discovered four structural issues that make Chase PDFs uniquely problematic.&lt;/p&gt;

&lt;p&gt;Let’s break them down.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Challenge 1: Multiple formats inside the SAME year&lt;/p&gt;

&lt;p&gt;Chase used two formats simultaneously in 2024:&lt;br&gt;
    • v2 (2018–2024)&lt;br&gt;
    • v3 (2024–2025)&lt;/p&gt;

&lt;p&gt;That means this detection method:&lt;/p&gt;

&lt;p&gt;// ❌ WRONG: Year-based detection (breaks in 2024!)&lt;br&gt;
function detectFormatWrong(year: number): 'v1' | 'v2' | 'v3' {&lt;br&gt;
  if (year &amp;lt; 2018) return 'v1';&lt;br&gt;
  if (year &amp;lt; 2024) return 'v2';&lt;br&gt;
  return 'v3';&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;…works fine until you get a February 2024 statement in v2 format and a May 2024 statement in v3 format.&lt;/p&gt;

&lt;p&gt;Generic converters assume document consistency.&lt;br&gt;
Chase does not.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Challenge 2: Missing column headers&lt;/p&gt;

&lt;p&gt;Some Chase PDFs—especially early-2022 Business Checking—contain no column labels at all.&lt;/p&gt;

&lt;p&gt;Just raw rows:&lt;/p&gt;

&lt;p&gt;02/01    AMAZON PAYMENT     $1,250.00     $15,840.32&lt;/p&gt;

&lt;p&gt;No:&lt;br&gt;
    • DATE&lt;br&gt;
    • DESCRIPTION&lt;br&gt;
    • AMOUNT&lt;br&gt;
    • BALANCE&lt;/p&gt;

&lt;p&gt;Generic table extractors rely on headers. Without them, they completely collapse.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Challenge 3: Variable column positions&lt;/p&gt;

&lt;p&gt;Typical fixed-width parsers assume:&lt;/p&gt;

&lt;p&gt;DATE      DESC      AMOUNT      BALANCE&lt;/p&gt;

&lt;p&gt;But Chase PDFs vary:&lt;br&gt;
    • DATE X position: anywhere from 30 to 70 pixels&lt;br&gt;
    • AMOUNT column: sometimes 2nd from right, sometimes 3rd&lt;br&gt;
    • BALANCE column: right-aligned but with different indentation per statement&lt;br&gt;
    • DESCRIPTION: can shift 40–80 pixels depending on layout&lt;/p&gt;

&lt;p&gt;You cannot rely on static pixel positions. You must infer structure dynamically.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Challenge 4: Split dates across text items&lt;/p&gt;

&lt;p&gt;PDF.js may return:&lt;/p&gt;

&lt;p&gt;"0"&lt;br&gt;
"2"&lt;br&gt;
"/01"&lt;/p&gt;

&lt;p&gt;instead of one "02/01".&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because Chase stores each glyph separately in the PDF.&lt;br&gt;
Generic converters treat these as separate columns and produce output like:&lt;/p&gt;

&lt;p&gt;0, 2, /01, AMAZON, PAYMENT, $1250.00&lt;/p&gt;

&lt;p&gt;When fixed:&lt;/p&gt;

&lt;p&gt;02/01, AMAZON PAYMENT, $1250.00&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Real example:&lt;/p&gt;

&lt;p&gt;❌ Generic PDF converter:&lt;br&gt;
Row 1: 0, 2, /01, AMAZON PAYMENT, $1,250.00, ???&lt;/p&gt;

&lt;p&gt;✅ After merging + heuristics:&lt;br&gt;
Row 1: 02/01, AMAZON PAYMENT, $1,250.00, $15,840.32&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Accuracy comparison (23 real PDFs):&lt;/p&gt;

&lt;p&gt;Tool    Accuracy    Correct Wrong&lt;br&gt;
Generic converters  ~70%    802 321&lt;br&gt;
Custom parser (pdfjs + TS)  99% 1,112   11&lt;/p&gt;

&lt;p&gt;That’s 310 fewer errors—per 23 statements.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 2: Solution — Structure-Based Format Detection&lt;/p&gt;

&lt;p&gt;The key insight:&lt;/p&gt;

&lt;p&gt;Don’t detect PDF format by year. Detect it by TEXT SIGNATURES.&lt;/p&gt;

&lt;p&gt;Chase formats have unique structural markers.&lt;br&gt;
Once you read the full extracted text, you can reliably detect formats.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;The 3 Chase formats&lt;/p&gt;

&lt;p&gt;Format  Years   Columns Structure   Unique Signature&lt;br&gt;
v1  2015–2017 3 cols  Simple list No section headers&lt;br&gt;
v2  2018–2024 4 cols  Transaction table   "TRANSACTION DETAIL"&lt;br&gt;
v3  2024–2025 3 cols  Grouped by category "DEPOSITS AND ADDITIONS" + "TOTAL DEPOSITS"&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Year-based detection (WRONG)&lt;/p&gt;

&lt;p&gt;// ❌ breaks immediately in 2024&lt;br&gt;
function detectFormatWrong(year: number): 'v1' | 'v2' | 'v3' {&lt;br&gt;
  if (year &amp;lt; 2018) return 'v1';&lt;br&gt;
  if (year &amp;lt; 2024) return 'v2';&lt;br&gt;
  return 'v3';&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Structure-based detection (CORRECT)&lt;/p&gt;

&lt;p&gt;// ✅ CORRECT: Structure-based detection&lt;br&gt;
function detectChaseFormat(fullText: string): 'v1' | 'v2' | 'v3' {&lt;br&gt;
  // Priority 1: Check for v2 signature&lt;br&gt;
  if (fullText.includes('TRANSACTION DETAIL')) {&lt;br&gt;
    return 'v2';&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Priority 2: Check for v3 signature&lt;br&gt;
  if (fullText.includes('DEPOSITS AND ADDITIONS') &amp;amp;&amp;amp;&lt;br&gt;
      fullText.includes('TOTAL DEPOSITS')) {&lt;br&gt;
    return 'v3';&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Priority 3: Year-based fallback for old v1 format&lt;br&gt;
  const year = extractStatementYear(fullText);&lt;br&gt;
  if (year &amp;amp;&amp;amp; year &amp;lt; 2018) {&lt;br&gt;
    return 'v1';&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Default: assume v2&lt;br&gt;
  return 'v2';&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Why this works&lt;br&gt;
    • v2 always includes "TRANSACTION DETAIL"&lt;br&gt;
    • v3 always includes "DEPOSITS AND ADDITIONS" and "TOTAL DEPOSITS"&lt;br&gt;
    • v1 has none of these markers, so year fallback is safe&lt;br&gt;
    • Adding future formats becomes trivial: just add new signatures at top of the list&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Real-world validation&lt;/p&gt;

&lt;p&gt;I tested all 23 PDFs:&lt;br&gt;
    • v1: 1 file&lt;br&gt;
    • v2: 15 files&lt;br&gt;
    • v3: 7 files&lt;/p&gt;

&lt;p&gt;Detection accuracy: 23/23 (100%).&lt;/p&gt;

&lt;p&gt;This approach also works for:&lt;br&gt;
    • Business Checking&lt;br&gt;
    • Personal Banking&lt;br&gt;
    • PDFs during format transition periods (e.g., April–July 2024)&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 3: Heuristic Column Detection for PDFs with NO Headers&lt;/p&gt;

&lt;p&gt;Some Chase PDFs simply omit headers altogether.&lt;br&gt;
You must infer columns dynamically.&lt;/p&gt;

&lt;p&gt;The solution:&lt;/p&gt;

&lt;p&gt;Infer column positions from the first transaction row using date heuristics.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Step-by-step algorithm&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Identify first transaction using date pattern&lt;br&gt;
• Look for MM/DD (02/01)&lt;br&gt;
• In X range 30–70 (Chase always puts dates on left)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Extract all text items on the same horizontal row&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use Y coordinate tolerance of ±5px.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Sort items left-to-right by X&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Infer column meaning:&lt;br&gt;
• leftmost → date&lt;br&gt;
• center → description&lt;br&gt;
• 2nd from right → amount&lt;br&gt;
• rightmost → balance&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These rules held across every tested statement.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Code: Column detection&lt;/p&gt;

&lt;p&gt;interface ColumnPositions {&lt;br&gt;
  dateX: number;&lt;br&gt;
  descX: number;&lt;br&gt;
  amountX: number;&lt;br&gt;
  balanceX: number;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function inferColumnPositions(&lt;br&gt;
  textItems: PDFTextItem[]&lt;br&gt;
): ColumnPositions {&lt;br&gt;
  // Step 1: Find the first transaction row&lt;br&gt;
  const firstDateItem = textItems.find(item =&amp;gt;&lt;br&gt;
    /^\d{2}\/\d{2}$/.test(item.str) &amp;amp;&amp;amp;&lt;br&gt;&lt;br&gt;
    item.transform[4] &amp;gt;= 30 &amp;amp;&amp;amp;&lt;br&gt;&lt;br&gt;
    item.transform[4] &amp;lt;= 70&lt;br&gt;&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;if (!firstDateItem) {&lt;br&gt;
    throw new Error('Cannot find first transaction (no date pattern found)');&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Step 2: Extract row by Y position&lt;br&gt;
  const dateY = firstDateItem.transform[5];&lt;br&gt;
  const rowItems = textItems.filter(item =&amp;gt;&lt;br&gt;
    Math.abs(item.transform[5] - dateY) &amp;lt; 5&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;// Step 3: Sort left-to-right&lt;br&gt;
  const sortedByX = rowItems.sort((a, b) =&amp;gt;&lt;br&gt;
    a.transform[4] - b.transform[4]&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;// Step 4: Infer from positions&lt;br&gt;
  return {&lt;br&gt;
    dateX: sortedByX[0].transform[4],&lt;br&gt;
    descX: (sortedByX[0].transform[4] +&lt;br&gt;
            sortedByX[sortedByX.length - 1].transform[4]) / 2,&lt;br&gt;
    balanceX: sortedByX[sortedByX.length - 1].transform[4],&lt;br&gt;
    amountX: sortedByX[sortedByX.length - 2].transform[4]&lt;br&gt;
  };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Why this works&lt;br&gt;
    • Chase PDFs ALWAYS have date on the far left&lt;br&gt;
    • Balance is ALWAYS right-aligned&lt;br&gt;
    • Description always occupies the middle&lt;br&gt;
    • Amount is consistently next to balance&lt;/p&gt;

&lt;p&gt;This works even with:&lt;br&gt;
    • v1 (3 columns)&lt;br&gt;
    • v2 (4 columns)&lt;br&gt;
    • v3 (3 columns + grouped sections)&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 4: Handling Split Dates&lt;/p&gt;

&lt;p&gt;pdfjs-dist often splits glyphs into separate items.&lt;/p&gt;

&lt;p&gt;Example raw output:&lt;/p&gt;

&lt;p&gt;"0"&lt;br&gt;
"2"&lt;br&gt;
"/"&lt;br&gt;
"0"&lt;br&gt;
"1"&lt;/p&gt;

&lt;p&gt;You must merge items by proximity.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Core idea:&lt;/p&gt;

&lt;p&gt;If two items’ X positions differ &amp;lt; 15px, they’re part of the same text value.&lt;/p&gt;

&lt;p&gt;This was empirically tested across 23 PDFs.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Code: Merging split date fragments&lt;/p&gt;

&lt;p&gt;function mergeSplitDates(items: PDFTextItem[]): PDFTextItem[] {&lt;br&gt;
  const merged: PDFTextItem[] = [];&lt;br&gt;
  let buffer = '';&lt;br&gt;
  let bufferX = 0;&lt;/p&gt;

&lt;p&gt;for (let i = 0; i &amp;lt; items.length; i++) {&lt;br&gt;
    const item = items[i];&lt;br&gt;
    const nextItem = items[i + 1];&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Merge if close enough
if (nextItem &amp;amp;&amp;amp;
    Math.abs(nextItem.transform[4] - item.transform[4]) &amp;lt; 15) {
  buffer += item.str;
  if (!bufferX) bufferX = item.transform[4];
} else {
  merged.push({
    str: buffer + item.str,
    transform: [0, 0, 0, 0, bufferX || item.transform[4], item.transform[5]]
  });
  buffer = '';
  bufferX = 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;return merged;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Why 15px?&lt;br&gt;
    • &amp;lt; 10px missed some merges&lt;br&gt;
    • 20px caused accidental merges&lt;br&gt;
    • 15px was perfect across all documents&lt;/p&gt;

&lt;p&gt;Result&lt;/p&gt;

&lt;p&gt;❌ Before: ["0", "2", "/01", "AMAZON", "PAY", "MENT"]&lt;br&gt;
✅ After:  ["02/01", "AMAZON PAYMENT"]&lt;/p&gt;

&lt;p&gt;You absolutely cannot build a reliable parser without this.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 5: Tech Stack &amp;amp; Architecture&lt;/p&gt;

&lt;p&gt;Here’s the stack that worked reliably.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Core technologies&lt;/p&gt;

&lt;p&gt;pdfjs-dist&lt;br&gt;
    • Same engine Firefox uses&lt;br&gt;
    • Extracts precise text positions (X/Y)&lt;br&gt;
    • Supports PDF 1.4–2.0&lt;br&gt;
    • Lightweight compared to OCR (no 200MB Tesseract install)&lt;/p&gt;

&lt;p&gt;TypeScript&lt;br&gt;
    • Needed for complex PDF item types&lt;br&gt;
    • Prevents 90% of runtime errors&lt;br&gt;
    • Great autocomplete for pdfjs API&lt;/p&gt;

&lt;p&gt;Node.js&lt;br&gt;
    • Fast enough for server-side parsing&lt;br&gt;
    • Can run heavy parsing without blocking UI&lt;/p&gt;

&lt;p&gt;Bull + Redis&lt;br&gt;
    • Parallel PDF processing&lt;br&gt;
    • Retry logic&lt;br&gt;
    • Failure handling that generic HTTP handlers lack&lt;/p&gt;

&lt;p&gt;ExcelJS&lt;br&gt;
    • Generates QuickBooks-ready Excel output&lt;br&gt;
    • Supports proper data validation + number formats&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;System Architecture Flow&lt;/p&gt;

&lt;p&gt;User uploads PDF&lt;br&gt;
        ↓&lt;br&gt;
Backend creates Bull job&lt;br&gt;
        ↓&lt;br&gt;
Worker parses PDF with pdfjs-dist&lt;br&gt;
        ↓&lt;br&gt;
Detect format (v1/v2/v3)&lt;br&gt;
        ↓&lt;br&gt;
Merge split dates&lt;br&gt;
        ↓&lt;br&gt;
Infer column positions&lt;br&gt;
        ↓&lt;br&gt;
Extract rows into normalized structure&lt;br&gt;
        ↓&lt;br&gt;
Generate final Excel file (ExcelJS)&lt;br&gt;
        ↓&lt;br&gt;
Return download URL&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Performance&lt;br&gt;
    • Average PDF: 5 seconds&lt;br&gt;
    • Largest tested PDF (273 transactions): 2 seconds&lt;br&gt;
    • Bottleneck: Excel generation, not PDF parsing&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Part 6: Results &amp;amp; Lessons Learned&lt;/p&gt;

&lt;p&gt;I tested the parser on a dataset of 23 real Chase PDF statements:&lt;br&gt;
    • Business + Personal&lt;br&gt;
    • 2015–2025 (10 years)&lt;br&gt;
    • Formats: v1, v2, v3&lt;br&gt;
    • Total rows: 1,123 transactions&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Accuracy&lt;/p&gt;

&lt;p&gt;Metric  Generic Tools   Custom Parser&lt;br&gt;
Correct Transactions    802 1,112&lt;br&gt;
Format Detection    33% 100%&lt;br&gt;
Headerless PDFs Fail    Pass&lt;br&gt;
Split Date Handling Fail    Pass&lt;br&gt;
Total Accuracy  ~71%    99%&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;What Worked&lt;/p&gt;

&lt;p&gt;✔ Structure-based detection&lt;br&gt;
✔ Heuristic column inference&lt;br&gt;
✔ Split date merging&lt;br&gt;
✔ Real-world testing (not synthetic PDFs)&lt;br&gt;
✔ Using pdfjs-dist instead of OCR or regex-heavy hacks&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;What Didn’t Work&lt;/p&gt;

&lt;p&gt;❌ Regex-only parsing&lt;br&gt;
❌ Assuming headers always exist&lt;br&gt;
❌ Fixed column positions&lt;br&gt;
❌ Year-based format detection&lt;br&gt;
❌ OCR — slow, inaccurate, unnecessary&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Key Lessons Learned&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Test with real documents&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Not all PDFs behave the same.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Structure &amp;gt; content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Detect formats by text signatures, not by year.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use tolerance ranges, not precise numbers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Between PDFs, text shifts significantly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Merge text items aggressively&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;PDF.js fragments everything.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Don’t try to “regex your way out”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Positional parsing beats text scrubbing every time.&lt;/p&gt;

&lt;p&gt;⸻&lt;/p&gt;

&lt;p&gt;Conclusion&lt;/p&gt;

&lt;p&gt;Building a Chase Bank PDF parser taught me something unexpected:&lt;/p&gt;

&lt;p&gt;PDFs are simple to read as humans and extremely complex to parse as machines.&lt;/p&gt;

&lt;p&gt;Chase statements, in particular, combine:&lt;br&gt;
    • Multiple formats in the same year&lt;br&gt;
    • Missing headers&lt;br&gt;
    • Variable column alignment&lt;br&gt;
    • Fragmented text items&lt;/p&gt;

&lt;p&gt;Generic converters assume too much structure.&lt;br&gt;
To reach production-grade accuracy, you must infer structure dynamically.&lt;/p&gt;

&lt;p&gt;The winning combination was:&lt;br&gt;
    • Structure-based format detection&lt;br&gt;
    • Heuristic column detection&lt;br&gt;
    • Split date merging&lt;br&gt;
    • pdfjs-dist + TypeScript&lt;br&gt;
    • Extensive testing on real PDFs&lt;/p&gt;

&lt;p&gt;If you’re working with Chase PDFs and want to try a ready-made implementation, you can use &lt;a href="https://bank-parser.com" rel="noopener noreferrer"&gt;https://bank-parser.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tutorial_pdf&lt;/a&gt; (free trial, no card required).&lt;/p&gt;

&lt;p&gt;Have you built PDF parsers before?&lt;br&gt;
What challenges did you face? I’d love to hear what approaches worked (or failed!) for you — share in the comments!&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>backend</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
