<?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: nickdelv</title>
    <description>The latest articles on DEV Community by nickdelv (@nickdelv).</description>
    <link>https://dev.to/nickdelv</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%2F3978118%2Ff7db8aa7-1f66-4aa9-957d-8020c9beb3bf.png</url>
      <title>DEV Community: nickdelv</title>
      <link>https://dev.to/nickdelv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nickdelv"/>
    <language>en</language>
    <item>
      <title>Three Models, Zero API Calls: Real-Time Meeting Intelligence on Apple Silicon</title>
      <dc:creator>nickdelv</dc:creator>
      <pubDate>Tue, 16 Jun 2026 15:12:42 +0000</pubDate>
      <link>https://dev.to/nickdelv/three-models-zero-api-calls-real-time-meeting-intelligence-on-apple-silicon-1e2i</link>
      <guid>https://dev.to/nickdelv/three-models-zero-api-calls-real-time-meeting-intelligence-on-apple-silicon-1e2i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at thunderkitty.app/learn&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thunder Kitty's Labs features run topic segmentation and agenda tracking live, entirely on-device — and getting a sentence-embedding model onto the Neural Engine took seven attempts and a fight with a silent CoreML bug.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thunder Kitty 1.9.0 adds a Labs section in Settings with two experimental features: a &lt;strong&gt;Live Topic Timeline&lt;/strong&gt; that segments a meeting into topics as you record, and &lt;strong&gt;Live Agenda Tracking&lt;/strong&gt; that marks agenda items as they get covered. Both run in real time, entirely on your Mac.&lt;/p&gt;

&lt;p&gt;Running them means running three models at once. The interesting part wasn't the idea — it was getting one of those models, a sentence-embedding model, onto the Neural Engine. That took seven attempts and a fight with a silent CoreML bug that produces plausible-looking garbage and no error.&lt;/p&gt;

&lt;p&gt;This is how the features work and what broke along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;Two ideas converged.&lt;/p&gt;

&lt;p&gt;An early user wanted a live jargon buster — not a search box (he could already ask Google or Claude), but something that would notice when a term was probably unfamiliar to him and surface the definition on its own, in real time. Separately, we'd wanted a live meeting timeline for a while: a vertical view that grows as the meeting goes, showing topic flow and recurring themes as they happen.&lt;/p&gt;

&lt;p&gt;The common thread is timing. The meeting is happening now, so the intelligence has to happen now — not as a batch job after everyone hangs up.&lt;/p&gt;

&lt;p&gt;The timeline and agenda tracking shipped in 1.9.0; the jargon buster is still ahead of us. All of it runs on-device, with no network and no per-call cost — &lt;a href="https://www.thunderkitty.app/local-first/" rel="noopener noreferrer"&gt;the same promise as the rest of the app&lt;/a&gt;. Turn on airplane mode and it still works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: three models
&lt;/h2&gt;

&lt;p&gt;Different tasks need different models. Here's what runs during and after a meeting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th&gt;What it does&lt;/th&gt;
      &lt;th&gt;Latency&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;all-mpnet-base-v2 via CoreML&lt;/td&gt;
      &lt;td&gt;Topic segmentation (which sentences belong together)&lt;/td&gt;
      &lt;td&gt;5–20ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Apple Foundation Models&lt;/td&gt;
      &lt;td&gt;Topic labeling, utterance classification&lt;/td&gt;
      &lt;td&gt;200ms–2s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Qwen 3.5 4B / 9B via MLX (downloaded once)&lt;/td&gt;
      &lt;td&gt;Post-meeting summaries&lt;/td&gt;
      &lt;td&gt;25–35 tok/s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Models 1–2 run live during the meeting; model 3 runs after. The Neural Engine handles the embedding and labeling work, the GPU handles the summary model, and they don't fight each other for resources.&lt;/p&gt;

&lt;p&gt;The hard part was model 1: getting the mpnet embedding model running on the Neural Engine via CoreML. What should have been routine turned into seven attempts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Topic segmentation: why DeepTiling
&lt;/h2&gt;

&lt;p&gt;Before the CoreML story, here's what the embedding model is actually doing.&lt;/p&gt;

&lt;p&gt;Topic segmentation — deciding where one topic ends and the next begins — is an old problem. TextTiling solved a version of it in 1997 by computing word overlap between sliding windows and marking the valleys as boundaries. DeepTiling is the same algorithm with neural embeddings in place of word overlap. Swap the similarity function; keep everything else.&lt;/p&gt;

&lt;p&gt;For each transcript line we compute a 768-dimensional embedding. For line &lt;code&gt;i&lt;/code&gt;, we take the centroid of the preceding 8 lines and compare similarity. High similarity means we're still on topic; a valley (a local minimum below a 0.12 threshold) means the topic shifted. It's simple, parallelizable, and converts cleanly to a streaming version — which is what makes the live timeline possible.&lt;/p&gt;

&lt;p&gt;We tested five embedding approaches: all-mpnet-base-v2, all-MiniLM-L6-v2, nomic-embed-text-v1.5, Apple's NLEmbedding, and Apple's NLContextualEmbedding. The algorithm was identical across all five; only the embeddings changed. mpnet won clearly — sharper valleys, better separation between on-topic and off-topic similarity, more reliable boundaries.&lt;/p&gt;

&lt;p&gt;Which is why getting mpnet onto CoreML properly was non-negotiable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CoreML conversion: seven attempts
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading closely if you convert transformers to CoreML, because the failure is silent and the warning is misleading.&lt;/p&gt;

&lt;h3&gt;
  
  
  The goal
&lt;/h3&gt;

&lt;p&gt;Convert &lt;code&gt;sentence-transformers/all-mpnet-base-v2&lt;/code&gt; to a CoreML &lt;code&gt;.mlpackage&lt;/code&gt;. Take &lt;code&gt;input_ids&lt;/code&gt; and &lt;code&gt;attention_mask&lt;/code&gt;, output &lt;code&gt;token_embeddings&lt;/code&gt;, then mean-pool and L2-normalize in Swift. Target: Neural Engine, under 20ms per sentence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1: the obvious approach
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;traced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attention_mask&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;mlmodel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;traced&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;Conversion succeeded. Cosine similarity between the CoreML output and sentence-transformers: &lt;strong&gt;0.17&lt;/strong&gt;. Essentially random.&lt;/p&gt;

&lt;p&gt;coremltools had emitted two warnings during conversion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Core ML embedding (gather) layer does not support any inputs besides
the weights and indices. Those given will be ignored.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translation: coremltools silently drops the &lt;code&gt;position_ids&lt;/code&gt; from the MPNet embedding layer. With no position information, the transformer produces meaningless output. It's a &lt;a href="https://github.com/apple/coremltools/issues/1428" rel="noopener noreferrer"&gt;known bug&lt;/a&gt; with no upstream fix as of coremltools 9.0, and the warning fires whether or not it actually affected your model — so you can't tell from the warning alone. The only way to know is to compare against a reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempts 2–6: the graveyard
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mean pooling inside the model&lt;/strong&gt; — coremltools crashes on dynamic integer ops in the pooling code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ONNX as an intermediate&lt;/strong&gt; — coremltools 8+ dropped ONNX support; &lt;code&gt;onnx-coreml&lt;/code&gt; turned out to be a separate, long-deprecated package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;coremltools 7.x with ONNX&lt;/strong&gt; — same problem, plus a Python 3.11 / numpy &amp;lt;2.0 pinning mess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;torch.export (ExportedProgram)&lt;/strong&gt; — version-format incompatibility between torch 2.7 and coremltools 8.3; 9.0 accepts it but still produces garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-computing position embeddings as constants&lt;/strong&gt; — kills one of the two gather warnings; cosine similarity still 0.17.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By attempt 6 every obvious culprit was gone and the output was still garbage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 7: the breakthrough
&lt;/h3&gt;

&lt;p&gt;The realization: MPNet doesn't only use position embeddings in the embedding layer. It also uses &lt;strong&gt;relative position bias&lt;/strong&gt; in every attention layer — another embedding lookup, computed differently from standard BERT. The whole position-handling chain was broken, not just the embedding layer.&lt;/p&gt;

&lt;p&gt;The fix: pre-compute everything that touches position information and bypass the model's own wiring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MPNetCoreMLWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_length&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;word_embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;word_embeddings&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LayerNorm&lt;/span&gt;

        &lt;span class="c1"&gt;# Pre-compute position embeddings as a constant buffer
&lt;/span&gt;        &lt;span class="n"&gt;pos_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;padding_idx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;padding_idx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seq_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position_embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position_embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos_ids&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&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="c1"&gt;# Pre-compute relative position bias as a constant buffer
&lt;/span&gt;        &lt;span class="n"&gt;dummy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hidden_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relative_position_bias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compute_position_bias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attention_mask&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;word_emb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;word_embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# This gather works
&lt;/span&gt;        &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;word_emb&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position_embeddings&lt;/span&gt; &lt;span class="c1"&gt;# Constant add
&lt;/span&gt;        &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;layer_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... run encoder with pre-computed position bias
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CoreML vs sentence-transformers: avg=0.999985, min=0.999974
PASS — CoreML embeddings match sentence-transformers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every segmentation boundary now matched the Python baseline exactly.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to take from this
&lt;/h3&gt;

&lt;p&gt;If you're converting a transformer to CoreML and getting low cosine similarity, the &lt;code&gt;gather&lt;/code&gt; layer is probably dropping position information. The fix is architecture-specific: you have to understand how your model encodes position before you can pre-compute it. MPNet needed two gather ops handled (position embeddings plus relative attention bias). BERT would differ. DeBERTa (another transformer variant with its own position encoding scheme) is its own special hell.&lt;/p&gt;

&lt;p&gt;And validate against a known-good reference before trusting anything. The conversion warnings aren't reliable signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-time agenda tracking
&lt;/h2&gt;

&lt;p&gt;With segmentation working, the second feature matches live transcript content to your pre-meeting agenda as the conversation moves, so items shift from pending to in-progress to discussed in real time.&lt;/p&gt;

&lt;p&gt;The naive version fails immediately: when someone reads the agenda aloud at the top of the meeting, every item gets "mentioned" and a naive tracker marks them all discussed before any real discussion happens.&lt;/p&gt;

&lt;p&gt;So the tracker uses five gates, applied in order, to avoid false positives:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Similarity threshold&lt;/strong&gt; — the line must score ≥ 0.25 against the agenda item's embedding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinctiveness&lt;/strong&gt; — the best match must beat the second-best by 0.05; generic lines that match everything match nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum matches&lt;/strong&gt; — two distinctive matches before an item goes &lt;code&gt;inProgress&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal spread&lt;/strong&gt; — first and last matching lines must be ≥ 60 seconds apart before &lt;code&gt;discussed&lt;/code&gt;; reading the agenda takes ~30 seconds, real discussion spans minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speaker diversity&lt;/strong&gt; — two distinct speakers required; agenda reading is one voice, discussion is back-and-forth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a 51-minute, 721-line test transcript with six agenda items: 6/6 marked discussed, no simultaneous multi-item triggers, each item firing independently with its own relevant evidence.&lt;/p&gt;

&lt;p&gt;The live tracker is the fast, approximate pass — visual feedback while you record. The authoritative version, with full context and LLM reasoning, comes from the post-meeting pass. Keeping the live half lightweight is deliberate: the &lt;a href="https://dl.acm.org/doi/10.1145/3711030" rel="noopener noreferrer"&gt;MeetMap research&lt;/a&gt; (ACM CSCW 2025) found that real-time meeting AI works best when it lowers in-the-moment cognitive load and leaves the user in control, rather than demanding attention mid-conversation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these are in Labs
&lt;/h2&gt;

&lt;p&gt;Both features shipped in 1.9.0, and both are in Labs for a reason. They work, but they're not finished.&lt;/p&gt;

&lt;p&gt;The timeline's data layer is solid and the segmentation is accurate. The UI is still rough, and topic labels are only as good as the on-device labeling model on a given day. Agenda tracking clears the five gates well on clean transcripts, but messy audio, heavy cross-talk, or an agenda full of near-identical items will still trip it. They're opt-in because we'd rather you turn them on knowing that than have them surprise you with a sub-par experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Three models on Apple Silicon — an mpnet embedder on the Neural Engine, Apple Foundation Models for live labeling, and a Qwen model on the GPU for post-meeting summaries — with nothing leaving the Mac and no per-call cost. The embedder fought us for seven attempts. The rest was getting the timing right.&lt;/p&gt;

&lt;p&gt;It's in Labs because it's early. But it runs, it's local, and it works in airplane mode like everything else.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>swift</category>
      <category>ai</category>
    </item>
    <item>
      <title>2,000 Buffers of Nothing</title>
      <dc:creator>nickdelv</dc:creator>
      <pubDate>Wed, 10 Jun 2026 17:47:34 +0000</pubDate>
      <link>https://dev.to/nickdelv/2000-buffers-of-nothing-3i8</link>
      <guid>https://dev.to/nickdelv/2000-buffers-of-nothing-3i8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at thunderkitty.app/learn&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned about macOS audio capture that I couldn't find written down anywhere — Core Audio taps, silent TCC denials, and an Info.plist key Xcode ignores.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two days after launching Thunder Kitty, I tested a scenario I should have tested earlier: picking up a phone call on my MacBook via iPhone Continuity. My voice came through fine. The other person's audio? Completely missing from the transcript. Silent.&lt;/p&gt;

&lt;p&gt;The fix took me down a rabbit hole through three Apple frameworks, four wrong hypotheses, and one undocumented Info.plist key. This is what I learned about macOS audio capture that I couldn't find written down anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  ScreenCaptureKit can't see phone calls
&lt;/h2&gt;

&lt;p&gt;When I built system audio capture for Thunder Kitty, I reached for ScreenCaptureKit. It's Apple's modern API for capturing screen and audio. You create an SCStream, set &lt;code&gt;capturesAudio = true&lt;/code&gt;, and audio buffers show up in a delegate callback. Clean, well-documented, works great.&lt;/p&gt;

&lt;p&gt;For Zoom, Google Meet, Teams, anything running in a browser or windowed app — ScreenCaptureKit captures the audio perfectly. It operates at the application/compositor layer.&lt;/p&gt;

&lt;p&gt;The problem is that word: &lt;em&gt;applications&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;When you pick up an iPhone call on your Mac via Continuity, the call audio doesn't come from an application with a window. It comes from &lt;code&gt;callservicesd&lt;/code&gt; — a background system daemon. FaceTime audio routes through &lt;code&gt;avconferenced&lt;/code&gt;. Neither has any window presence. They're invisible to ScreenCaptureKit.&lt;/p&gt;

&lt;p&gt;This isn't a permissions issue. It's not a configuration issue. The API operates at the wrong layer of the stack. ScreenCaptureKit is a net that only works on the surface; these daemons are swimming underneath.&lt;/p&gt;

&lt;p&gt;The fix is to drop down a layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Audio taps: the right layer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CATapDescription&lt;/code&gt; is a Core Audio API that captures audio at the HAL — the Hardware Abstraction Layer. The HAL sits between your audio hardware and everything above it. Every sound that hits your output device passes through it, regardless of which process produced it. Browser, Zoom, &lt;code&gt;callservicesd&lt;/code&gt;, whatever. If the bytes are flowing, the tap can see them.&lt;/p&gt;

&lt;p&gt;The setup is more involved than ScreenCaptureKit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a process tap with &lt;code&gt;CATapDescription(monoGlobalTapButExcludeProcesses:)&lt;/code&gt;, excluding your own process so you don't create a feedback loop.&lt;/li&gt;
&lt;li&gt;Wrap it in a private aggregate device via &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attach an IOProc callback with &lt;code&gt;AudioDeviceCreateIOProcIDWithBlock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start the device with &lt;code&gt;AudioDeviceStart&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The aggregate device is the non-obvious piece. A process tap alone doesn't deliver audio buffers — it has to live inside an aggregate device that combines the tap with an output device, and you read from the aggregate's input stream. Most of the documentation around this lives in WWDC sessions and a few sample projects.&lt;/p&gt;

&lt;p&gt;I wired it all up, built the app, started a recording, played a YouTube video.&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  2,000 buffers of nothing
&lt;/h2&gt;

&lt;p&gt;The IOProc was running. My logs showed the callback firing hundreds of times per second, delivering audio buffers on schedule. But every buffer was zeros. Two thousand buffers of zeros.&lt;/p&gt;

&lt;p&gt;My first hypothesis was Bluetooth. When AirPods connect, macOS negotiates between two profiles: A2DP (high-quality stereo, output only) and HFP (phone-call quality, bidirectional). Opening the microphone forces a switch from A2DP to HFP, which can change the default output device mid-setup. Maybe the aggregate device was being created with a stale device reference.&lt;/p&gt;

&lt;p&gt;I rewrote the setup to host the aggregate on the built-in MacBook speakers — always present, always stable, doesn't change when headphones connect.&lt;/p&gt;

&lt;p&gt;Still silence.&lt;/p&gt;

&lt;p&gt;I tested with wired headphones. No Bluetooth in the chain. Same result. The Bluetooth hypothesis was dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The muted speaker test
&lt;/h2&gt;

&lt;p&gt;Here's where I got lucky.&lt;/p&gt;

&lt;p&gt;Thunder Kitty supports two recording modes: "unified" (one stream where the mic picks up speakers and your voice together) and "dual stream" (separate channels for mic and system audio, used with headphones). Unified mode had been "working" — transcripts showed both sides of conversations. Dual stream was always silent.&lt;/p&gt;

&lt;p&gt;On a hunch, I ran unified mode with the speakers muted. If the Core Audio tap was actually delivering system audio, transcription should still work — the tap captures the audio stream before it reaches the speakers, so muting the output shouldn't matter.&lt;/p&gt;

&lt;p&gt;Two transcript lines. Both my voice. No system audio at all.&lt;/p&gt;

&lt;p&gt;Unified mode had never actually worked. The microphone had been picking up YouTube playing through the speakers — acoustic bleed dressed up as success. The Core Audio tap had been delivering silence the whole time, in every mode, and I'd been fooling myself for days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Info.plist key Xcode silently ignores
&lt;/h2&gt;

&lt;p&gt;macOS gates sensitive APIs through TCC (Transparency, Consent, and Control). For system audio capture, the relevant service is &lt;code&gt;kTCCServiceAudioCapture&lt;/code&gt;, and it requires &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; in your Info.plist — the string shown in the permission dialog.&lt;/p&gt;

&lt;p&gt;I had added this key. Or so I thought.&lt;/p&gt;

&lt;p&gt;Xcode normally lets you set &lt;code&gt;INFOPLIST_KEY_*&lt;/code&gt; build settings, and it injects the corresponding key into your compiled Info.plist at build time. This works for &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt;, &lt;code&gt;NSSpeechRecognitionUsageDescription&lt;/code&gt;, and most other privacy keys. So I'd set &lt;code&gt;INFOPLIST_KEY_NSAudioCaptureUsageDescription&lt;/code&gt; in my build settings and moved on.&lt;/p&gt;

&lt;p&gt;It doesn't work for &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt;. Xcode silently ignores it. The key never makes it into the compiled plist.&lt;/p&gt;

&lt;p&gt;I verified by running &lt;code&gt;plutil -p&lt;/code&gt; on the app bundle. Microphone description: present. Speech recognition: present. Audio capture: gone.&lt;/p&gt;

&lt;p&gt;The fix was adding it directly to the Info.plist file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSAudioCaptureUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Thunder Kitty captures system audio to transcribe the other side of your calls.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The silent denial
&lt;/h2&gt;

&lt;p&gt;Here's the truly nasty part: when &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; is missing, TCC denies access silently.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioDeviceCreateIOProcIDWithBlock&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioDeviceStart&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. Your IOProc callback fires on schedule. Everything looks perfect.&lt;/p&gt;

&lt;p&gt;But every buffer is zeros.&lt;/p&gt;

&lt;p&gt;There's no error code. No log message. No indication anything is wrong. The system hands you silence and lets you figure it out. If you're not specifically checking whether audio data is non-zero, you'll never know.&lt;/p&gt;

&lt;p&gt;A single error from &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; saying "TCC denied" would have saved me hours. I understand the security rationale — you don't want to make it easy for malware to detect denial — but it makes legitimate development genuinely painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one more gotcha: triggering the prompt
&lt;/h2&gt;

&lt;p&gt;Even with the Info.plist key in place, I had one more problem. I wanted Thunder Kitty's onboarding to trigger the permission prompt before the first recording, so users could grant access without confusion.&lt;/p&gt;

&lt;p&gt;My first attempt: create a process tap and immediately destroy it. The permission gate is on tap creation, right? Surely calling &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; will trigger the system prompt.&lt;/p&gt;

&lt;p&gt;Nope. The tap creates "successfully" (returns &lt;code&gt;noErr&lt;/code&gt;, as we've established it does regardless). No prompt appears.&lt;/p&gt;

&lt;p&gt;It turns out &lt;code&gt;AudioDeviceStart&lt;/code&gt; is the call that triggers the TCC prompt. Not creating the tap. Not creating the aggregate device. Not creating the IOProc. You have to actually start IO on a tap-backed aggregate device before macOS asks the user.&lt;/p&gt;

&lt;p&gt;There's no &lt;code&gt;requestAuthorization&lt;/code&gt;-style API for audio capture, the way there is for the microphone or speech recognition. You have to spin up the entire pipeline — tap, aggregate device, IOProc, start IO — wait for the system prompt, then tear it all down. Thunder Kitty's onboarding does exactly this: builds a throwaway audio pipeline, holds it for a beat, destroys it. It's the only way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working
&lt;/h2&gt;

&lt;p&gt;After fixing the Info.plist key and rebuilding, I started a test recording and picked up a phone call on my Mac. My voice. Then the other person's voice. Then both of us, transcribed line by line in a single conversation.&lt;/p&gt;

&lt;p&gt;The permission prompt now reads "System Audio Recording" instead of "Screen &amp;amp; System Audio Recording." It's a smaller ask, a less alarming privacy indicator, and it actually describes what the app does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six things I'd tell other Mac developers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use Core Audio taps, not ScreenCaptureKit, if you need to hear everything.&lt;/strong&gt; SCK is great for capturing specific applications. But if your users might be on phone calls, FaceTime, or anything that runs through a background daemon, SCK will miss it. Don't ship a transcription product that misses phone calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; directly to your Info.plist file.&lt;/strong&gt; Do not rely on &lt;code&gt;INFOPLIST_KEY_*&lt;/code&gt; build settings for this key. Xcode ignores them silently. Verify with &lt;code&gt;plutil -p&lt;/code&gt; on your compiled app bundle to confirm the key is actually there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCC enforcement is silent.&lt;/strong&gt; Every Core Audio API will return &lt;code&gt;noErr&lt;/code&gt; even when permission is denied. The only way to know is that your buffers contain zeros. Build a non-zero check into your audio pipeline early, before you spend a day debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AudioDeviceStart&lt;/code&gt; is what triggers the permission prompt.&lt;/strong&gt; Not creating the tap, not creating the aggregate device. If you want to prompt during onboarding, you need to build and start the full pipeline, then tear it down once macOS has shown the dialog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start your mic engine before creating the aggregate device.&lt;/strong&gt; If your users have AirPods, opening the mic first forces the HFP profile negotiation. If the aggregate device starts first, AirPods stay in A2DP, and your mic channel fails silently. (Another silent failure. They're a theme.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Host the aggregate device on the built-in output device, not the default output device.&lt;/strong&gt; The default device can change when headphones connect or Bluetooth profiles switch. Built-in speakers are always there, always stable. Pin to them and your aggregate survives device changes mid-recording.&lt;/p&gt;

&lt;p&gt;None of this is documented in one place. I hope this post saves someone the days I spent on it.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
