<?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: Dima Statz</title>
    <description>The latest articles on DEV Community by Dima Statz (@dimastatz).</description>
    <link>https://dev.to/dimastatz</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%2F201303%2Fc5c36ca4-1d87-47aa-88ff-d2f32dfa6918.jpeg</url>
      <title>DEV Community: Dima Statz</title>
      <link>https://dev.to/dimastatz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dimastatz"/>
    <language>en</language>
    <item>
      <title>A GitHub Action That Fails the Build When Your Voice Agent Gets Worse</title>
      <dc:creator>Dima Statz</dc:creator>
      <pubDate>Sat, 11 Jul 2026 03:39:10 +0000</pubDate>
      <link>https://dev.to/dimastatz/a-github-action-that-fails-the-build-when-your-voice-agent-gets-worse-27h4</link>
      <guid>https://dev.to/dimastatz/a-github-action-that-fails-the-build-when-your-voice-agent-gets-worse-27h4</guid>
      <description>&lt;p&gt;In the last post we put call audio in the test suite: treat a few golden&lt;br&gt;
recordings as fixtures, analyze them on every change, and assert on the report so&lt;br&gt;
a regression turns the build red. That works — but it's a pile of &lt;code&gt;pytest&lt;/code&gt; you&lt;br&gt;
have to write and maintain.&lt;/p&gt;

&lt;p&gt;This post skips the boilerplate. There's now a GitHub Action that does the whole&lt;br&gt;
thing: &lt;strong&gt;&lt;a href="https://github.com/marketplace/actions/audiotrace-regression-gate" rel="noopener noreferrer"&gt;AudioTrace regression gate&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
Point it at a folder of recordings and a committed baseline, and it fails the&lt;br&gt;
build when call quality regresses — latency, sentiment, drop-off, cost, or&lt;br&gt;
compliance. It runs entirely on open models, so there are no secrets and no&lt;br&gt;
network calls in CI.&lt;/p&gt;

&lt;p&gt;Here's the whole thing, start to finish.&lt;/p&gt;
&lt;h2&gt;
  
  
  The idea in one sentence
&lt;/h2&gt;

&lt;p&gt;Voice agents drift in ways no unit test catches — a prompt tweak makes the agent&lt;br&gt;
slower, a new TTS voice makes it colder, a refactor drops a required disclosure.&lt;br&gt;
So we &lt;strong&gt;commit a baseline&lt;/strong&gt; of what "good" sounds like, and &lt;strong&gt;gate every PR&lt;/strong&gt;&lt;br&gt;
against it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — Commit a baseline
&lt;/h2&gt;

&lt;p&gt;Grab a handful of representative call recordings — a happy path, a frustrated&lt;br&gt;
caller, a compliance-heavy call — and drop them in your repo (say &lt;code&gt;tests/calls/&lt;/code&gt;).&lt;br&gt;
Then generate a baseline locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;audiotrace          &lt;span class="c"&gt;# FFmpeg must be on your system&lt;/span&gt;
audiotrace baseline tests/calls &lt;span class="nt"&gt;-o&lt;/span&gt; baseline.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This analyzes every recording and writes &lt;code&gt;baseline.json&lt;/code&gt; — the committed snapshot&lt;br&gt;
of "good." Commit it alongside your code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add tests/calls baseline.json
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add voice-quality baseline"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's your golden master. When you &lt;em&gt;intentionally&lt;/em&gt; improve the agent, you&lt;br&gt;
regenerate and re-commit it — same workflow as snapshot testing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2 — Add the Action
&lt;/h2&gt;

&lt;p&gt;Drop this into &lt;code&gt;.github/workflows/voice-quality.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Voice quality&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;audiotrace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dimastatz/audiotrace@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;calls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests/calls&lt;/span&gt;
          &lt;span class="na"&gt;baseline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;baseline.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two required inputs — where the recordings live and where the baseline lives.&lt;br&gt;
That's it. The Action installs AudioTrace + FFmpeg, re-analyzes the calls,&lt;br&gt;
compares each against the baseline, and &lt;strong&gt;exits non-zero on any out-of-tolerance&lt;br&gt;
regression&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 — Watch it catch a regression
&lt;/h2&gt;

&lt;p&gt;Open a PR that changes a prompt or swaps the model. On the next run the gate&lt;br&gt;
re-scores your golden calls and, if the agent got measurably worse, the check&lt;br&gt;
fails with a summary like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL  frustrated_customer.wav
  Response latency (p95)  1,820ms → 2,540ms   (+39%, allowed +15%)
  Sentiment               0.12 → -0.20        (Δ0.32, allowed 0.10)

1 of 3 calls regressed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A green build means the change was safe to ship. A red build means you made the&lt;br&gt;
agent slower or colder &lt;em&gt;before&lt;/em&gt; a customer felt it.&lt;/p&gt;

&lt;p&gt;Every run also uploads a per-call &lt;strong&gt;HTML + JSON report&lt;/strong&gt; as a build artifact —&lt;br&gt;
even on failure — so you can open the report and see exactly what moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inputs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Directory of golden call recordings to gate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;baseline.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The committed baseline to compare against.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;report-dir&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;code&gt;audiotrace-report&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where per-call HTML + JSON reports are written.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;code&gt;audiotrace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pip spec to pin AudioTrace (e.g. &lt;code&gt;audiotrace==1.2.2&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;python-version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3.12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Python version the gate runs on.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Tuning the tolerances
&lt;/h2&gt;

&lt;p&gt;Conversational signals wiggle run to run, so the gate ships with sane per-metric&lt;br&gt;
tolerances — a band the number can move within before it counts as a regression:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Tolerance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quality score&lt;/td&gt;
&lt;td&gt;±0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentiment&lt;/td&gt;
&lt;td&gt;±0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response p95&lt;/td&gt;
&lt;td&gt;+15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;+20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interruptions&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frustration / drop-off / compliance&lt;/td&gt;
&lt;td&gt;zero — any regression fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;New recordings that aren't in the baseline yet are &lt;em&gt;skipped&lt;/em&gt;, not failed, so&lt;br&gt;
adding fixtures never breaks the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this runs in CI at all
&lt;/h2&gt;

&lt;p&gt;The trick that makes this practical: &lt;code&gt;audiotrace analyze()&lt;/code&gt; runs locally on open&lt;br&gt;
models (Whisper, pyannote, Librosa) — no hosted API, no key, no per-call bill. So&lt;br&gt;
the gate needs nothing but your recordings and a runner. That's what lets it live&lt;br&gt;
in &lt;code&gt;pull_request&lt;/code&gt; CI instead of a nightly job behind a secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; &lt;a href="https://github.com/marketplace/actions/audiotrace-regression-gate" rel="noopener noreferrer"&gt;AudioTrace regression gate on the Marketplace&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Library:&lt;/strong&gt; &lt;code&gt;pip install audiotrace&lt;/code&gt; · &lt;a href="https://github.com/dimastatz/audiotrace" rel="noopener noreferrer"&gt;github.com/dimastatz/audiotrace&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point it at three recordings and a baseline, open a PR, and watch a bad prompt&lt;br&gt;
change go red. That's regression testing for the part of your product that used&lt;br&gt;
to be a black box.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Fail the Build When Your Voice Agent Gets Worse</title>
      <dc:creator>Dima Statz</dc:creator>
      <pubDate>Sat, 11 Jul 2026 03:38:05 +0000</pubDate>
      <link>https://dev.to/dimastatz/fail-the-build-when-your-voice-agent-gets-worse-2pee</link>
      <guid>https://dev.to/dimastatz/fail-the-build-when-your-voice-agent-gets-worse-2pee</guid>
      <description>&lt;p&gt;In this series we've turned a raw call recording into a structured &lt;code&gt;CallReport&lt;/code&gt;&lt;br&gt;
(post 1) and looked at how to extract signals cheaply enough to run on every&lt;br&gt;
call (post 2). Now the payoff: &lt;strong&gt;using those signals to stop regressions&lt;br&gt;
before they ship.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A voice agent's behavior drifts. You change a prompt, swap a model, pick a new TTS&lt;br&gt;
voice — and the agent gets subtly slower to respond, colder in tone, or starts&lt;br&gt;
skipping a required disclosure. None of that shows up in a normal test suite,&lt;br&gt;
because the regression lives in the audio. So let's put the audio in the test&lt;br&gt;
suite.&lt;/p&gt;
&lt;h2&gt;
  
  
  The idea: golden recordings as test fixtures
&lt;/h2&gt;

&lt;p&gt;Treat a small set of representative call recordings as fixtures. On every change,&lt;br&gt;
analyze them and assert on the report. If a prompt change pushes a number past a&lt;br&gt;
threshold, the build goes red — same as any other failing test.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="c1"&gt;# A few representative calls checked into the repo (or pulled from storage).
&lt;/span&gt;&lt;span class="n"&gt;GOLDEN_CALLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/happy_path.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/frustrated_customer.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/compliance_heavy.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GOLDEN_CALLS&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;test_call_quality_does_not_regress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_speakers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Latency: the agent must stay responsive.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent got too slow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Quality: overall score must stay healthy.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;

    &lt;span class="c1"&gt;# The agent shouldn't be talking over the caller.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interruptions&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_required_disclosure_present&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/compliance_heavy.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Compliance flags surface missing/again-required disclosures.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing_disclosure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compliance_flags&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_agent_does_not_frustrate_callers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/happy_path.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;caller_frustration&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;  &lt;span class="c1"&gt;# net-neutral-or-better tone
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;analyze()&lt;/code&gt; runs locally with no API calls, this works in CI with no&lt;br&gt;
secrets and no network — the recordings and the open models are all you need.&lt;/p&gt;
&lt;h2&gt;
  
  
  Catching drift, not just hard failures
&lt;/h2&gt;

&lt;p&gt;Absolute thresholds catch cliffs. To catch &lt;em&gt;drift&lt;/em&gt;, compare against a baseline you&lt;br&gt;
commit alongside the code:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_speakers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pace_wpm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaking_pace_wpm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;,&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;test_no_drift_from_baseline&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/baseline.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/calls/happy_path.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Latency may not grow more than 15% vs. the committed baseline.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.15&lt;/span&gt;
    &lt;span class="c1"&gt;# Tone may not drop more than 0.1 absolute.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you intentionally improve the agent, you regenerate &lt;code&gt;baseline.json&lt;/code&gt; and&lt;br&gt;
commit it — the same workflow as snapshot testing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Emit it as OpenTelemetry spans
&lt;/h2&gt;

&lt;p&gt;CI catches regressions before they ship; observability catches what happens in&lt;br&gt;
production. The &lt;code&gt;CallReport&lt;/code&gt; maps cleanly onto OpenTelemetry, so voice-call&lt;br&gt;
signals sit right next to the rest of your traces:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;

&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audiotrace&lt;/span&gt;&lt;span class="sh"&gt;"&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;trace_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice_call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.duration_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.quality_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.caller_frustrated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;caller_frustration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.outcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# The latency waterfall becomes child spans (STT, LLM, TTS, ...).
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waterfall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_ms&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="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hang it off your LangChain / LangSmith traces
&lt;/h2&gt;

&lt;p&gt;If you already trace your agent's reasoning in LangSmith, AudioTrace fills in the&lt;br&gt;
half it can't see — what actually reached the caller's ear. Attach the report to&lt;br&gt;
the run as metadata so the audio signals live next to the token-level trace:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langsmith&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&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;attach_audio_signals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quality_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caller_frustration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;caller_frustration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;speaking_pace_wpm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaking_pace_wpm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drop_off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_usd&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;Now a single LangSmith run shows both what the model &lt;em&gt;thought&lt;/em&gt; and how the call&lt;br&gt;
&lt;em&gt;sounded&lt;/em&gt; — and the same signals that flag a bad call in production are the&lt;br&gt;
examples you feed back in to fine-tune the next, better agent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wrapping the series
&lt;/h2&gt;

&lt;p&gt;Three ideas, one thread:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A voice call is a rich artifact your token-level tooling can't read — so turn
it into a typed &lt;code&gt;CallReport&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Split the work by &lt;strong&gt;measure vs. estimate&lt;/strong&gt;, and don't reach for a big model
when a cheap measurement will do.&lt;/li&gt;
&lt;li&gt;Put those signals where they pay off: &lt;strong&gt;red builds&lt;/strong&gt; on regressions and
&lt;strong&gt;spans/traces&lt;/strong&gt; in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A lot of progress in AI isn't a new model — it's packaging hard-won engineering&lt;br&gt;
into something others can &lt;code&gt;pip install&lt;/code&gt;. That's what AudioTrace is trying to be&lt;br&gt;
for voice agents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;audiotrace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⭐ Repo: &lt;a href="https://github.com/dimastatz/audiotrace" rel="noopener noreferrer"&gt;github.com/dimastatz/audiotrace&lt;/a&gt; —&lt;br&gt;
it's early, and provider integrations + richer compliance checks are exactly where&lt;br&gt;
contributions help most.&lt;/p&gt;

&lt;p&gt;Keep building!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Measure, Don't Estimate: Labeling Speakers Without a Gated Model</title>
      <dc:creator>Dima Statz</dc:creator>
      <pubDate>Sat, 11 Jul 2026 03:06:02 +0000</pubDate>
      <link>https://dev.to/dimastatz/measure-dont-estimate-labeling-speakers-without-a-gated-model-3pgm</link>
      <guid>https://dev.to/dimastatz/measure-dont-estimate-labeling-speakers-without-a-gated-model-3pgm</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/dimastatz/your-ai-voice-agent-is-a-black-box-heres-how-to-open-it-41kc"&gt;the first post&lt;/a&gt; I argued there are two ways to pull meaning out of audio:&lt;br&gt;
&lt;strong&gt;measure&lt;/strong&gt; it with signal processing, or &lt;strong&gt;estimate&lt;/strong&gt; it with a model. This post&lt;br&gt;
is the story of a problem where the obvious move was to estimate — and where&lt;br&gt;
measuring turned out to be better.&lt;/p&gt;

&lt;p&gt;The problem: &lt;strong&gt;labeling who is speaking.&lt;/strong&gt; A transcript that says "Agent: …" and&lt;br&gt;
"Customer: …" is far more useful than an undifferentiated wall of text. Splitting&lt;br&gt;
a conversation by speaker is called &lt;em&gt;diarization&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The obvious tool, and why it hurt
&lt;/h2&gt;

&lt;p&gt;The strong, well-known tool for diarization is&lt;br&gt;
&lt;a href="https://github.com/pyannote/pyannote-audio" rel="noopener noreferrer"&gt;pyannote&lt;/a&gt;. It's genuinely good. It&lt;br&gt;
is also &lt;strong&gt;gated&lt;/strong&gt;: to run it you need a Hugging Face account, an access token, and&lt;br&gt;
to accept a license agreement before the weights will download.&lt;/p&gt;

&lt;p&gt;That's fine for a production deployment. It's a terrible first impression for&lt;br&gt;
someone who just &lt;code&gt;pip install&lt;/code&gt;-ed your library and wants to see it work. Without a&lt;br&gt;
token, every single turn comes back labeled &lt;code&gt;"unknown"&lt;/code&gt;. The newcomer's first run&lt;br&gt;
is a wall of &lt;code&gt;unknown: …&lt;/code&gt; and they bounce.&lt;/p&gt;

&lt;p&gt;So I wanted a &lt;em&gt;default&lt;/em&gt; path that works with zero setup, and lets you opt into&lt;br&gt;
pyannote when you have a token and want the best quality.&lt;/p&gt;
&lt;h2&gt;
  
  
  Shortcut #1: just alternate speakers (this fails)
&lt;/h2&gt;

&lt;p&gt;My first instinct was the dumbest possible heuristic: in a two-party call, the&lt;br&gt;
speakers take turns, so just alternate &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Customer&lt;/code&gt;, &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Customer&lt;/code&gt;…&lt;/p&gt;

&lt;p&gt;It fell apart immediately. Speech recognizers like Whisper segment on&lt;br&gt;
&lt;strong&gt;sentences&lt;/strong&gt;, not &lt;strong&gt;speakers&lt;/strong&gt;. So the agent's multi-sentence greeting —&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hi there! Thanks for calling. How can I help you today?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;— gets split into three segments, and the naive alternator flip-flops the label&lt;br&gt;
mid-utterance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent:    Hi there!
Customer: Thanks for calling.
Agent:    How can I help you today?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Garbage. The structure I assumed (one segment per speaker turn) simply isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shortcut #2: ask what signal is actually present
&lt;/h2&gt;

&lt;p&gt;Instead of forcing a model-shaped solution, I asked: &lt;em&gt;what's physically in the&lt;br&gt;
audio that distinguishes these two speakers?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a typical support call, the agent and the customer have &lt;strong&gt;noticeably&lt;br&gt;
different voice pitch&lt;/strong&gt;. That's a physical property of the waveform — exactly the&lt;br&gt;
kind of thing signal processing measures cheaply and exactly.&lt;/p&gt;

&lt;p&gt;So the approach becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For each transcribed segment, &lt;strong&gt;measure its average pitch&lt;/strong&gt; (fundamental
frequency) using an audio library I already had as a dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster&lt;/strong&gt; the segments into two groups by pitch.&lt;/li&gt;
&lt;li&gt;The low-pitch cluster is one speaker, the high-pitch cluster is the other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core of it is just a measurement plus a 2-way split:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;librosa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;segment_pitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Mean fundamental frequency (Hz) of one transcript segment.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;f0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;voiced_flag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;librosa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pyin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fmin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;librosa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;note_to_hz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;C2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;fmax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;librosa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;note_to_hz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;C7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;voiced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f0&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;voiced_flag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanmean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voiced&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;voiced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assign_speakers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pitches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Split segments into two speakers by a pitch threshold.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pitches&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;valid&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pitches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# Lower-pitched cluster -&amp;gt; first label, higher -&amp;gt; second.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;labels&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="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="n"&gt;labels&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&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="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pitches&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few dozen lines. No new dependency. No token. And the labels come out right for&lt;br&gt;
the common case — a plain &lt;em&gt;measurement&lt;/em&gt; standing in for a &lt;em&gt;model&lt;/em&gt; I couldn't&lt;br&gt;
assume the user had.&lt;/p&gt;
&lt;h2&gt;
  
  
  It isn't magic — and that's the point
&lt;/h2&gt;

&lt;p&gt;Two similar voices (two men, two women, a deep-voiced customer) can fool the pitch&lt;br&gt;
split. With a token, pyannote still does better, and it handles three-plus&lt;br&gt;
speakers, overlapping speech, and edge cases this never will. So AudioTrace keeps&lt;br&gt;
both paths:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;

&lt;span class="c1"&gt;# Default: zero-setup, infer speakers by pitch.
&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diarize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_speakers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Best quality: opt into pyannote with a token.
&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hf_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hf_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson I keep relearning: &lt;strong&gt;we grab the biggest model out of habit.&lt;/strong&gt; A&lt;br&gt;
careful look at the data often points to something lighter, cheaper, and easier&lt;br&gt;
to reason about. "What signal is actually there?" is a more useful question than&lt;br&gt;
"which model should I download?"&lt;/p&gt;

&lt;p&gt;That's also a practical observability principle. The cheap, deterministic&lt;br&gt;
measurement runs in milliseconds with no GPU, which means you can run it on&lt;br&gt;
&lt;em&gt;every&lt;/em&gt; call — and the things you can afford to run on every call are the things&lt;br&gt;
that actually catch regressions.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;We now have a structured &lt;code&gt;CallReport&lt;/code&gt; with speakers, quality, sentiment, latency,&lt;br&gt;
and cost. In the final post I'll wire it into CI: &lt;strong&gt;fail the build when a prompt&lt;br&gt;
change makes the agent slower, colder, or less compliant&lt;/strong&gt;, and emit the signals&lt;br&gt;
as OpenTelemetry spans alongside your LangChain / LangSmith traces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;audiotrace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⭐ Repo: &lt;a href="https://github.com/dimastatz/audiotrace" rel="noopener noreferrer"&gt;github.com/dimastatz/audiotrace&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep building!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your AI Voice Agent Is a Black Box. Here's How to Open It.</title>
      <dc:creator>Dima Statz</dc:creator>
      <pubDate>Sat, 27 Jun 2026 04:39:16 +0000</pubDate>
      <link>https://dev.to/dimastatz/your-ai-voice-agent-is-a-black-box-heres-how-to-open-it-41kc</link>
      <guid>https://dev.to/dimastatz/your-ai-voice-agent-is-a-black-box-heres-how-to-open-it-41kc</guid>
      <description>&lt;p&gt;When your AI agent types, you can see everything it does. LangChain traces every&lt;br&gt;
step, LangSmith replays every run, OpenTelemetry hangs spans off each call. You&lt;br&gt;
know what the model saw, what it said, how long it took, and what it cost.&lt;/p&gt;

&lt;p&gt;The moment that same agent picks up a phone, the lights go out.&lt;/p&gt;

&lt;p&gt;A voice agent's entire interaction lives inside an &lt;code&gt;.mp3&lt;/code&gt;. The transcript, the&lt;br&gt;
customer's mood, the awkward four-second silence, the moment it talked over the&lt;br&gt;
caller, the point where the conversation went sideways — all of it is in there.&lt;br&gt;
But to your existing observability stack, that file is opaque. LangSmith sees the&lt;br&gt;
tokens you fed the LLM; it does not see the audio that reached a human ear.&lt;/p&gt;

&lt;p&gt;So most teams do the only thing they can: they listen to a handful of calls by&lt;br&gt;
hand and hope the sample is representative. That doesn't scale, and it misses the&lt;br&gt;
thing that makes voice agents hard — &lt;strong&gt;their behavior drifts.&lt;/strong&gt; You tweak a&lt;br&gt;
prompt, swap a model, change a TTS voice, and the agent gets subtly slower,&lt;br&gt;
colder, or starts missing intents. No unit test catches it, because the&lt;br&gt;
regression lives in the audio.&lt;/p&gt;

&lt;p&gt;This series is about closing that gap. In this first post I'll lay out the mental&lt;br&gt;
model; the next two get hands-on with a tricky signal-extraction problem and with&lt;br&gt;
wiring voice signals into CI.&lt;/p&gt;
&lt;h2&gt;
  
  
  The artifact is richer than you think
&lt;/h2&gt;

&lt;p&gt;Here's what's actually recoverable from a single call recording:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transcript&lt;/strong&gt; — what was said, by whom, with timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality&lt;/strong&gt; — silence gaps, interruptions, speaking pace, pitch variance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment&lt;/strong&gt; — the caller's mood, and where it shifted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; — how long each stage (STT, LLM, TTS) took to respond.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt; — what the call cost, attributed per stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events&lt;/strong&gt; — the detected intent, whether the caller dropped off, compliance flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a lot of signal locked inside one file. The reason teams rebuild this from&lt;br&gt;
scratch at every company is that prying it loose means bolting together speech&lt;br&gt;
recognition, speaker separation, audio analysis, a sentiment model, and a pricing&lt;br&gt;
sheet — and then maintaining all of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ways to pull meaning out of audio
&lt;/h2&gt;

&lt;p&gt;The key insight that makes this tractable: there are really &lt;strong&gt;two different&lt;br&gt;
kinds of question&lt;/strong&gt; you can ask of audio, and they want two different tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Measure it — classical signal processing.&lt;/strong&gt; Deterministic math run straight&lt;br&gt;
on the waveform: energy, pitch, the length of a silence. Cheap, exact, no&lt;br&gt;
training data. It shines for physical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long was the pause?&lt;/li&gt;
&lt;li&gt;How fast did someone speak?&lt;/li&gt;
&lt;li&gt;Is this voice high-pitched or low?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You &lt;em&gt;measure&lt;/em&gt; the answer instead of guessing at it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Estimate it — learned models.&lt;/strong&gt; Statistical systems like Whisper or a&lt;br&gt;
sentiment classifier that have ingested enormous amounts of data and &lt;em&gt;estimate&lt;/em&gt;&lt;br&gt;
an answer. They own everything that turns on meaning rather than physics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What words were said?&lt;/li&gt;
&lt;li&gt;Who is speaking?&lt;/li&gt;
&lt;li&gt;Is the caller upset?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No hand-written rule survives real speech here — you need a model.&lt;/p&gt;

&lt;p&gt;Most of the craft is knowing which question belongs to which bucket: reach for a&lt;br&gt;
model to &lt;strong&gt;estimate meaning&lt;/strong&gt;, for signal processing to &lt;strong&gt;measure physics&lt;/strong&gt;. (In&lt;br&gt;
the next post you'll see that when a model isn't available, a measurement can&lt;br&gt;
sometimes stand in for it — that turns out to be a surprisingly useful trick.)&lt;/p&gt;
&lt;h2&gt;
  
  
  One report, split along that line
&lt;/h2&gt;

&lt;p&gt;I packaged this into a small open-source library called&lt;br&gt;
&lt;a href="https://github.com/dimastatz/audiotrace" rel="noopener noreferrer"&gt;AudioTrace&lt;/a&gt;. You hand it a recording;&lt;br&gt;
it hands back one structured, typed report — split along exactly that&lt;br&gt;
measure-vs-estimate line. The acoustic layer (silence, pace, pitch) is signal&lt;br&gt;
processing; the semantic layer (transcript, sentiment, intent) is models.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;audiotrace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;

&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audiotrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_recording.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v2.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vapi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overall_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# 0.87
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaking_pace_wpm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 168.0
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;caller_frustration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# False
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# 4200
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# False
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# 0.063
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return value is a Pydantic &lt;code&gt;CallReport&lt;/code&gt;, so it's typed, validated, and trivial&lt;br&gt;
to serialize. You can emit it as OpenTelemetry spans, hang it off your LangChain&lt;br&gt;
and LangSmith traces, or assert on it in a CI check — which is exactly where this&lt;br&gt;
series is headed.&lt;/p&gt;
&lt;h2&gt;
  
  
  One decision shaped everything: it runs locally
&lt;/h2&gt;

&lt;p&gt;Call recordings are about as sensitive as data gets. So AudioTrace runs entirely&lt;br&gt;
on your machine — no audio leaves the box, and the open models download once.&lt;br&gt;
Privacy here shouldn't be an upgrade you pay for; it should be the default.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The two-layer model sounds tidy, but the interesting part is what happens when&lt;br&gt;
the "right" tool isn't available. In the next post I'll walk through a concrete&lt;br&gt;
example: labeling &lt;strong&gt;who is speaking&lt;/strong&gt; without the gated model everyone reaches&lt;br&gt;
for — and why a few dozen lines of pitch measurement beat it for the common case.&lt;/p&gt;

&lt;p&gt;If you want to poke at it now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;audiotrace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⭐ The repo is at &lt;a href="https://github.com/dimastatz/audiotrace" rel="noopener noreferrer"&gt;github.com/dimastatz/audiotrace&lt;/a&gt;.&lt;br&gt;
Issues and PRs welcome — it's early, and provider integrations are exactly the&lt;br&gt;
kind of contribution that helps most.&lt;/p&gt;

&lt;p&gt;Keep building!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
