<?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: Alan Li</title>
    <description>The latest articles on DEV Community by Alan Li (@comedianhhh).</description>
    <link>https://dev.to/comedianhhh</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%2F4138312%2F012a34b8-157d-48f4-8097-5c0667c88763.jpg</url>
      <title>DEV Community: Alan Li</title>
      <link>https://dev.to/comedianhhh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/comedianhhh"/>
    <language>en</language>
    <item>
      <title>I built a calibration layer by hand. Then a model shipped with one.</title>
      <dc:creator>Alan Li</dc:creator>
      <pubDate>Tue, 22 Sep 2026 22:16:28 +0000</pubDate>
      <link>https://dev.to/comedianhhh/i-built-a-calibration-layer-by-hand-then-a-model-shipped-with-one-4pc8</link>
      <guid>https://dev.to/comedianhhh/i-built-a-calibration-layer-by-hand-then-a-model-shipped-with-one-4pc8</guid>
      <description>&lt;p&gt;A language model will tell you it is 95 % sure of almost anything. I spent a week turning that into a number you could actually route work on, and then tested it against a model built to do the same thing. The result was a split decision, and the most useful thing I learned came from neither of them.&lt;/p&gt;

&lt;p&gt;The setting is a mortgage servicing desk. Under Regulation X, when a borrower writes in, what the letter &lt;em&gt;is&lt;/em&gt; starts a clock: a notice of error gets thirty days, a request for the identity of the loan's owner gets ten, a payoff request gets seven. Misfile a letter and you miss a federal deadline. So the triage decision — which of five kinds is this — is worth automating and expensive to get wrong, which makes it a good place to ask the question everyone building on LLMs eventually has to answer: &lt;em&gt;when can I let this thing decide by itself?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: the number in the JSON is not a number
&lt;/h2&gt;

&lt;p&gt;The first version asked a model to classify the letter and return a confidence. It returned 0.95 or higher on essentially everything, including the letters it got wrong. That is not a bug in the model. It was asked to generate a plausible-looking confidence field, and it did. Nothing measured anything.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking the model to &lt;em&gt;say&lt;/em&gt; a probability and start reading one. For a fixed set of answers you can put the options in the prompt as &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt;, look at the next-token distribution at the position where the answer goes, and renormalise over just those letters. One forward pass, no decoding, and what comes back is a real distribution over the allowed answers.&lt;/p&gt;

&lt;p&gt;That helped, and then it didn't. Small models have position bias — they like &lt;code&gt;A&lt;/code&gt; — so I scored every question under five rotations of the option order and averaged. The averaged number looked reasonable and carried almost no information: AUROC 0.64, barely better than a coin flip at separating right answers from wrong ones.&lt;/p&gt;

&lt;p&gt;The reason turned out to be the averaging. Each individual rotation was nearly one-hot: 0.999 for one option, nothing for the rest. Averaging five of those gives you the &lt;em&gt;fraction of orderings that agreed&lt;/em&gt; — a vote share with six possible values — and throws away every margin. Averaging the log-probabilities instead keeps them. AUROC went from 0.64 to 0.76 on the same data, same model, same prompts. The only change was doing the mean in the right space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: ordered is not calibrated
&lt;/h2&gt;

&lt;p&gt;Now the number ranked correctly — higher meant more likely right — but it still wasn't a probability. When it said 0.9 it was right about 75 % of the time. Ranking and calibration are different properties, and the standard fix is boring and effective: divide the logits by a temperature fit on held-out data. Expected calibration error dropped from 0.15 to 0.06. When it said 0.9 it was now right about nine times in ten.&lt;/p&gt;

&lt;p&gt;That fix has a catch: it assumes the next batch of letters looks like the batch you fit the temperature on. So the last layer is &lt;strong&gt;conformal prediction&lt;/strong&gt;, which makes a promise that does not depend on the model being good. Calibrate a threshold on held-out examples, and for a new letter output the &lt;em&gt;set&lt;/em&gt; of labels that clear it. With exchangeable data the true label is in that set at least 95 % of the time, whatever the model is doing. Sets of size one are the ones you auto-route; everything else goes to a human. The coverage knob becomes a queue-length knob, with a stated error rate attached.&lt;/p&gt;

&lt;p&gt;At that point the local pipeline — a 4-billion-parameter model on one consumer GPU, no API key — was auto-routing 45 % of real borrower correspondence with an 8.8 % error rate among the routed letters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: someone shipped the whole layer
&lt;/h2&gt;

&lt;p&gt;Then TypeSafe released Jev, a model that answers typed questions and returns distributions directly. One request, one response, calibrated probabilities. Exactly the thing I had just assembled out of logprobs and arithmetic.&lt;/p&gt;

&lt;p&gt;I wired it in as a second decision layer behind the same interface, sharing the same option descriptions, so that switching between them changed the decision layer and nothing else. Then I ran both over 42 synthetic letters written to exercise the tricky cases and 300 real mortgage complaints pulled from a public mirror of the CFPB complaint database — prose nobody on the project had written, angrier and longer and vaguer than anything I would have thought to compose.&lt;/p&gt;

&lt;p&gt;The calibration claim held up completely. Jev arrived with what I had built by hand: AUROC 0.74 against my 0.64 raw, and its confidence bands were already ordered out of the box — the letters it scored under 0.70 were right about half the time, the ones it scored 0.99 were right every time. It needed a temperature of 1.7 where my local pipeline needed 6. One request at 338 ms against sixteen local forward passes at a second.&lt;/p&gt;

&lt;p&gt;And it was eleven points &lt;em&gt;less accurate&lt;/em&gt; on the real letters. 197 out of 300 against the local model's 228.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step four: the part that was actually worth finding
&lt;/h2&gt;

&lt;p&gt;The two models were not making the same mistakes. They were making opposite ones.&lt;/p&gt;

&lt;p&gt;Eighty-five of Jev's errors were letters my labels called covered that it filed as "not covered" — long complaints describing a servicing failure without ever using the word "error". The local model erred the other way, pulling twenty-nine letters into "notice of error" that belonged outside the rule.&lt;/p&gt;

&lt;p&gt;Then I checked something else. The labels for that set had been written by me and independently checked by a second labeller on a random sixty. We agreed 78 % of the time, κ = 0.61 — and of our thirteen disagreements, ten were on exactly that line. Two models and two people, all failing on the same boundary.&lt;/p&gt;

&lt;p&gt;When that happens, the boundary is the defect. Not the model.&lt;/p&gt;

&lt;p&gt;So I rewrote the definition. Not by looking at which letters anyone got wrong — that is fitting to your test set — but from the labelling policy I had written down before any of this ran. Where the old text said "a written notice asserting that the servicer made an error", the new text lists what a servicing failure actually looks like: a payment misapplied or credited late, a fee said to be unjustified, escrow items left unpaid, information lost in a transfer between servicers — and states that it counts whether or not the letter uses the word "error", and whether or not it asks for anything in return. Lending decisions — refinance applications, assumptions — are named as the thing that is &lt;em&gt;not&lt;/em&gt; servicing.&lt;/p&gt;

&lt;p&gt;One prompt change, nothing else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;on 300 real letters&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jev, accuracy&lt;/td&gt;
&lt;td&gt;197 (66 %)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;273 (91 %)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jev, AUROC&lt;/td&gt;
&lt;td&gt;0.74&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jev, auto-routed at 95 % coverage&lt;/td&gt;
&lt;td&gt;58 letters, 4 wrong&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;237 letters, 8 wrong&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;local 4B, accuracy&lt;/td&gt;
&lt;td&gt;228 (76 %)&lt;/td&gt;
&lt;td&gt;218 (73 %)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;local 4B, auto-routed at 95 % coverage&lt;/td&gt;
&lt;td&gt;136 letters, 12 wrong&lt;/td&gt;
&lt;td&gt;62 letters, &lt;strong&gt;0 wrong&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seventy-six letters, from writing the definition down properly. No amount of calibration would have found it, because nothing was miscalibrated — both models were confidently applying a rule nobody had stated clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I did not expect
&lt;/h2&gt;

&lt;p&gt;The same change cost the small model ten letters.&lt;/p&gt;

&lt;p&gt;This was the second time in two rounds. Earlier, I had noticed the flag questions — "is this request overbroad?", "is it duplicative?" — were leaning toward yes, which is what binary questions do when only the affirmative is described. TypeSafe's own format has a slot for describing both outcomes, so I filled it in for both providers. Spurious flags fell from 3 to 1 for Jev and rose from 3 to 7 for the local model.&lt;/p&gt;

&lt;p&gt;Richer instructions, twice, helped the purpose-built model and hurt the 4B. It is not a coincidence: a small model scoring multiple-choice options degrades as the options grow, whether they grow by adding a negation or by adding detail. &lt;strong&gt;How much prompt a model can use is a property of the model&lt;/strong&gt;, and "write a better prompt" is advice with a size dependency nobody mentions.&lt;/p&gt;

&lt;p&gt;I kept the shared definitions anyway. Per-provider prompts would make every future comparison meaningless, the sharpened wording is what the desk's policy actually says, and the small model's calibration improved even as its accuracy fell — it now routes 62 letters at 95 % coverage and gets none of them wrong. Ten letters is the measured price of having one prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you if you are about to do this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The confidence in the JSON is decoration.&lt;/strong&gt; If you need a probability, read one — from logprobs, or from a model that returns them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Average in log space.&lt;/strong&gt; Averaging near-one-hot distributions destroys exactly the information you are trying to collect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking and calibration are different problems&lt;/strong&gt; and need different fixes. Check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conformal prediction is the only layer that survived a provider change.&lt;/strong&gt; Its guarantee never depended on the model being good, so when I swapped the decision layer it read the new distributions unchanged. If you build one thing from this list, build that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you blame the model, check whether two careful humans agree.&lt;/strong&gt; If they do not, you are measuring the rubric.&lt;/p&gt;

&lt;p&gt;One caveat belongs next to that 91 %. The new definitions encode my own labelling policy, and the labels were written by one person applying it — so part of the jump is the model finally being told the rubric it is graded against. Our two labellers agreed only 78 % of the time, which means a single labeller's policy has a ceiling and 91 % is close enough to it that the remaining nine points are as likely to be the labels as the model. Reporting the number without that sentence would be a lie of omission.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The desk, the evaluation harness, the labelled sets and every number above are in &lt;a href="https://github.com/comedianhhh/servicing-desk" rel="noopener noreferrer"&gt;comedianhhh/servicing-desk&lt;/a&gt;; the round-by-round write-up, including the failures that are not in this article, is in &lt;code&gt;backend/evals/README.md&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>Read isn't heard: detecting when streaming TTS actually finishes</title>
      <dc:creator>Alan Li</dc:creator>
      <pubDate>Tue, 22 Sep 2026 21:11:58 +0000</pubDate>
      <link>https://dev.to/comedianhhh/read-isnt-heard-detecting-when-streaming-tts-actually-finishes-3lbj</link>
      <guid>https://dev.to/comedianhhh/read-isnt-heard-detecting-when-streaming-tts-actually-finishes-3lbj</guid>
      <description>&lt;p&gt;Our voice agent kept reopening the mic before it had finished talking. The last word was still coming out of the speaker when the app started listening for the user again.&lt;/p&gt;

&lt;p&gt;I spent a good part of this year on a 3D AI companion for phones: you talk, an LLM answers, ElevenLabs streams the voice back as Ogg/Opus, and a character on screen says it with lip sync, gaze and expression. Getting the first word out fast was the easy part. This bug took three rewrites, and it came down to one question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When has the audio actually been heard?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That one moment drives everything after it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the microphone reopens, so the user can answer&lt;/li&gt;
&lt;li&gt;the subtitle comes down&lt;/li&gt;
&lt;li&gt;the mouth closes and the body goes back to idle&lt;/li&gt;
&lt;li&gt;the next turn is allowed to start&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Too early, and the mic catches the tail of the character's own voice, the classic way a voice agent ends up &lt;a href="https://www.coval.ai/blog/voice-ai-echo-cancellation/" rel="noopener noreferrer"&gt;treating its own words as a new user turn&lt;/a&gt;. Too late, and the user is talking to a character that isn't listening yet. Neither shows up in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong answer 1: estimate it from the text
&lt;/h2&gt;

&lt;p&gt;The first version guessed. Count the words, weight the punctuation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.33f&lt;/span&gt;       &lt;span class="c1"&gt;// seconds per word&lt;/span&gt;
               &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;longPauses&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.5f&lt;/span&gt;   &lt;span class="c1"&gt;// . ! ?&lt;/span&gt;
               &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortPauses&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.2f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// , ; :&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start a timer, end the turn at &lt;code&gt;duration + 1s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That holds in a demo and drifts everywhere else. Voices read at different speeds. We sent Chinese at 1.2× speed, so it needed its own correction. And LLM text is full of numbers and abbreviations the voice model reads however it likes. Padding just moves the failure: too little clips long replies, too much leaves a dead gap after short ones.&lt;/p&gt;

&lt;p&gt;The estimate still earns its place as a progress bar before any audio arrives. It can't decide when the turn ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong answer 2: done when the stream is done
&lt;/h2&gt;

&lt;p&gt;Next: stop guessing, count samples.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main thread (DownloadHandlerScript.ReceiveData): each decoded chunk&lt;/span&gt;
&lt;span class="n"&gt;totalDecoded&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// PCMReaderCallback: each block Unity pulls from the clip&lt;/span&gt;
&lt;span class="n"&gt;totalRead&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;samplesRead&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;finished&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;downloadComplete&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;totalRead&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;totalDecoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kills a trap on its own: &lt;strong&gt;the HTTP stream closing means nothing.&lt;/strong&gt; TTS servers generate faster than real time, so a ten-second reply can be fully downloaded while the character is on its second sentence. OpenAI's Realtime API documents the same gap. &lt;a href="https://developers.openai.com/api/reference/resources/realtime/client-events" rel="noopener noreferrer"&gt;&lt;code&gt;conversation.item.truncate&lt;/code&gt;&lt;/a&gt; exists for audio "sent to the client but not yet played". And two weeks ago Pipecat, one of the most widely used voice-agent frameworks, had &lt;a href="https://github.com/pipecat-ai/pipecat/issues/5662" rel="noopener noreferrer"&gt;an issue filed&lt;/a&gt; for logging "Bot stopped speaking" about four seconds before playback ended.&lt;/p&gt;

&lt;p&gt;Counting read against decoded is much better, and progress becomes honest too. But on some phones the last syllable was still getting cut. A 0.15-second grace period helped. A magic number that works on some phones and not others means the model is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning point: stop reading logs, record the audio
&lt;/h2&gt;

&lt;p&gt;Every log line said "done." So I built a capture you can arm for &lt;strong&gt;one&lt;/strong&gt; session, in editor and development builds only. It writes three files:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the raw Ogg bytes, as downloaded&lt;/li&gt;
&lt;li&gt;the decoded PCM, as WAV&lt;/li&gt;
&lt;li&gt;what came out of the AudioSource, recorded in &lt;code&gt;OnAudioFilterRead&lt;/code&gt;, as WAV&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Line them up in any audio editor. If a word is in (1) but not (2), it's the decoder. If it's in (2) but not (3), it's timing. Ours was in (2) and missing from (3).&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong answer 3: done when Unity has read every sample
&lt;/h2&gt;

&lt;p&gt;A streaming &lt;code&gt;AudioClip&lt;/code&gt; is fed by a &lt;a href="https://docs.unity3d.com/ScriptReference/AudioClip.PCMReaderCallback.html" rel="noopener noreferrer"&gt;&lt;code&gt;PCMReaderCallback&lt;/code&gt;&lt;/a&gt;. When that callback takes your last sample, the sample has not been played. It has entered a pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your buffer → PCMReaderCallback → AudioSource (+ filters) → mixer → DSP buffers → OS / device → speaker
                    ↑                                                                      ↑
            "all samples read"                                                    "user hears it"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unity reads streamed clips ahead, in blocks. &lt;a href="https://discussions.unity.com/t/audioclip-pcmreadercallback-has-insane-latency/820545" rel="noopener noreferrer"&gt;People have measured&lt;/a&gt; how far ahead. How much audio is in flight behind the reader depends on the platform, which is exactly why a fixed grace period worked on one phone and not another.&lt;/p&gt;

&lt;p&gt;The same gap exists outside Unity. In the browser, an AudioWorklet consuming your last sample isn't playback either (see &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/outputLatency" rel="noopener noreferrer"&gt;&lt;code&gt;AudioContext.outputLatency&lt;/code&gt;&lt;/a&gt;). On native iOS and Android, the output buffer sits after your render callback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What held up: follow the last frame to the output
&lt;/h2&gt;

&lt;p&gt;Each utterance is a session with explicit states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Buffering&lt;/td&gt;
&lt;td&gt;clip is playing, no data yet (silence)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playing&lt;/td&gt;
&lt;td&gt;real samples are flowing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draining&lt;/td&gt;
&lt;td&gt;every decoded sample has been read; waiting for it to be heard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed / Cancelled / Failed&lt;/td&gt;
&lt;td&gt;terminal, and whichever comes first wins&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The session exposes a &lt;code&gt;PlaybackCompleted&lt;/code&gt; task that the turn logic awaits. Cancel and fail resolve it too, so nothing waits forever on audio that will never play. &lt;a href="https://github.com/livekit/agents/issues/5359" rel="noopener noreferrer"&gt;LiveKit Agents hit that exact deadlock&lt;/a&gt; this year, when &lt;code&gt;wait_for_playout()&lt;/code&gt; ignored interruption.&lt;/p&gt;

&lt;p&gt;Ending the turn takes three steps. The counters cross threads, so they use &lt;code&gt;Interlocked&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Mark the last frame at the reader.&lt;/strong&gt; When the download is complete and the reader has consumed every decoded sample, record the frame index of the final sample and move to &lt;code&gt;Draining&lt;/code&gt;. Short replies can get here straight from &lt;code&gt;Buffering&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PCMReaderCallback (a Unity audio thread)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;frameStart&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;readerFrames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;totalRead&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;downloadComplete&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;totalRead&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;totalDecoded&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Buffering&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;Playing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;finalFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frameStart&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Draining&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;&lt;strong&gt;2. Confirm it after the AudioSource.&lt;/strong&gt; &lt;a href="https://docs.unity3d.com/ScriptReference/MonoBehaviour.OnAudioFilterRead.html" rel="noopener noreferrer"&gt;&lt;code&gt;OnAudioFilterRead&lt;/code&gt;&lt;/a&gt; inserts a filter into this AudioSource's own chain. It sees the source's output, already resampled to the output rate and channel layout, before the mixer sums it. Count frames there too. When that count passes &lt;code&gt;finalFrame&lt;/code&gt;, the last sample has made it through the source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnAudioFilterRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;outFrames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;outputFrames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;finalFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;outFrames&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;tailConfirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;tailConfirmedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AudioSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dspTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;tailConfirmed&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="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 caveat: the reader counts frames at the clip's rate, and the filter counts at &lt;a href="https://docs.unity3d.com/ScriptReference/AudioSettings-outputSampleRate.html" rel="noopener noreferrer"&gt;&lt;code&gt;AudioSettings.outputSampleRate&lt;/code&gt;&lt;/a&gt;. Our clip is 48 kHz Opus. If your output rate differs, or you change pitch, scale first: &lt;code&gt;finalFrame * outputRate / clipRate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Wait out Unity's mixer buffer.&lt;/strong&gt; Unity will &lt;a href="https://docs.unity3d.com/ScriptReference/AudioSettings.GetDSPBufferSize.html" rel="noopener noreferrer"&gt;tell you its size&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AudioSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDSPBufferSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;drain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;AudioSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outputSampleRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;endAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tailConfirmedAt&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// e.g. 1024 × 4 / 48000 ≈ 85 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;a href="https://docs.unity3d.com/ScriptReference/AudioSettings-dspTime.html" rel="noopener noreferrer"&gt;&lt;code&gt;AudioSettings.dspTime&lt;/code&gt;&lt;/a&gt; passes &lt;code&gt;endAt&lt;/code&gt;, stop the source, settle the session, reopen the mic.&lt;/p&gt;

&lt;p&gt;Treat this as a lower bound. It covers Unity's buffering, not the OS mixer or a Bluetooth headset, which can add a few hundred milliseconds. For our purpose that was fine: with headphones on, the mic can't hear the tail anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mobile catch.&lt;/strong&gt; On some phones, &lt;code&gt;OnAudioFilterRead&lt;/code&gt; was late, and in some cases never fired, once the stream went quiet. If step 2 waits forever, the subtitle stays up forever. So step 2 has a 2-second timeout. By then every decoded sample has been handed to Unity, so the timeout moment becomes the confirmation point and the drain is added on top. The worst case is a couple of seconds of silence, not a stuck turn. It logs a warning, so you can see which devices take that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more that bit us
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A fixed ring buffer drops audio without telling you.&lt;/strong&gt; The first buffer was a 20-second ring. The stream outruns playback, so a long reply could fill it. &lt;code&gt;Write&lt;/code&gt; returned how many samples it accepted, and nothing checked. The replacement starts at 10 seconds, doubles as needed, and is capped at 120 seconds of &lt;em&gt;unplayed&lt;/em&gt; audio (5,760,000 samples at 48 kHz mono). Past the cap it reports overflow and fails the session. A reply with a missing middle is worse than an error you can see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Callbacks from a dead session.&lt;/strong&gt; The user interrupts, a new reply starts, and a chunk from the &lt;em&gt;old&lt;/em&gt; reply lands a moment later. If it writes into shared state, you get two sentences glued together. Every callback carries its session and checks it's still current. And callers cancel only the session they own, instead of a global "stop everything" that used to kill audio from other features.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;ReferenceEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsTerminal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// decode, write&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buffer and the session state machine are unit-tested without a device. The tail detection is verified with the three-file capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  How others handle it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LiveKit Agents&lt;/strong&gt; makes the audio sink report the end itself: the sink calls &lt;a href="https://github.com/livekit/agents/blob/main/livekit-agents/livekit/agents/voice/io.py" rel="noopener noreferrer"&gt;&lt;code&gt;on_playback_finished()&lt;/code&gt;&lt;/a&gt; with a playback position. That's the same "confirm at the output" idea.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI's realtime console&lt;/strong&gt; counts samples inside an &lt;a href="https://github.com/openai/openai-realtime-console/blob/websockets/src/lib/wavtools/lib/wav_stream_player.js" rel="noopener noreferrer"&gt;AudioWorklet&lt;/a&gt;. That's the browser version of step 2, without a drain step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipecat&lt;/strong&gt; decides the bot stopped speaking when its output queue goes idle for a short window. That is why the issue above sees the event arrive early.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you're building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't end a turn on a timer or on the stream closing.&lt;/li&gt;
&lt;li&gt;"Read by the audio callback" is not "heard." Confirm after the source, then add the buffer.&lt;/li&gt;
&lt;li&gt;Build the capture before you need it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;References: Unity &lt;a href="https://docs.unity3d.com/ScriptReference/AudioClip.Create.html" rel="noopener noreferrer"&gt;AudioClip.Create&lt;/a&gt; · ElevenLabs &lt;a href="https://elevenlabs.io/docs/api-reference/text-to-speech/stream" rel="noopener noreferrer"&gt;stream speech&lt;/a&gt; · &lt;a href="https://www.rfc-editor.org/rfc/rfc7845" rel="noopener noreferrer"&gt;RFC 7845&lt;/a&gt; (Ogg Opus) · &lt;a href="https://www.rfc-editor.org/rfc/rfc3533" rel="noopener noreferrer"&gt;RFC 3533&lt;/a&gt; (Ogg) · &lt;a href="https://voiceaiandvoiceagents.com/" rel="noopener noreferrer"&gt;Voice AI &amp;amp; Voice Agents primer&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>unity3d</category>
      <category>audio</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
