<?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: PRABHUDAYAL VAISHNAV</title>
    <description>The latest articles on DEV Community by PRABHUDAYAL VAISHNAV (@noreplycareerscoutai).</description>
    <link>https://dev.to/noreplycareerscoutai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png</url>
      <title>DEV Community: PRABHUDAYAL VAISHNAV</title>
      <link>https://dev.to/noreplycareerscoutai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/noreplycareerscoutai"/>
    <language>en</language>
    <item>
      <title>tonesearch: find the angry minute in your call recordings, on your own machine</title>
      <dc:creator>PRABHUDAYAL VAISHNAV</dc:creator>
      <pubDate>Tue, 13 Oct 2026 18:27:23 +0000</pubDate>
      <link>https://dev.to/noreplycareerscoutai/tonesearch-find-the-angry-minute-in-your-call-recordings-on-your-own-machine-43ep</link>
      <guid>https://dev.to/noreplycareerscoutai/tonesearch-find-the-angry-minute-in-your-call-recordings-on-your-own-machine-43ep</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;tonesearch searches call recordings by &lt;strong&gt;what&lt;/strong&gt; was said and &lt;strong&gt;how&lt;/strong&gt; it was said.&lt;/p&gt;

&lt;p&gt;You type "customer wants a refund" and pick the tone "angry". You get back the 20 audio segments that match both. Click a row and you hear the segment. A second tab shows the tone over one whole call, with the top 3 spikes.&lt;/p&gt;

&lt;p&gt;It needs no transcript and runs on a laptop, so no audio leaves the machine.&lt;/p&gt;

&lt;p&gt;The people I think would pay are small call teams and small agencies that run voice agents for clients. A voice agent can make hundreds of calls a day. Somebody has to find the calls where the caller got angry, and fix the prompt. Today that person listens to random calls, or reads transcripts that lose the tone.&lt;/p&gt;

&lt;p&gt;Big contact centers buy tools like Gong or Cresta for this. Those tools are built for large teams, and a five-person agency is not their customer. Some agencies also cannot upload customer calls to another company's cloud, because their clients forbid it.&lt;/p&gt;

&lt;p&gt;What they want is small: "show me the angry moments in this week's calls, and don't send the calls anywhere."&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try it: &lt;a href="https://huggingface.co/spaces/pradachan/tonesearch" rel="noopener noreferrer"&gt;https://huggingface.co/spaces/pradachan/tonesearch&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The demo runs in your browser. The Gemma query model (about 142 MB, q4 ONNX through transformers.js) downloads once, then your query is embedded on your own machine. Tone-only search works while it loads. The query embedding in the browser matches the Python one at cosine 0.989.&lt;/p&gt;

&lt;p&gt;Search tab. The query is "customer wants a refund", tone "angry", tone weight 1.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fspaces%2Fpradachan%2Ftonesearch%2Fresolve%2Fmain%2Fimg%2Fsearch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fspaces%2Fpradachan%2Ftonesearch%2Fresolve%2Fmain%2Fimg%2Fsearch.png" alt="Search results for " width="800" height="864"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Timeline tab. The angry score over one call, with the top 3 spikes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl8u2d5i8gv99xlt4prjt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl8u2d5i8gv99xlt4prjt.png" alt="Angry score over one call, with the top 3 spikes" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo data is HaessigDB, a public set of acted call-center speech (CC-BY-4.0, by Niklas Weller, Marc Christopher Grau and Ivo Blohm). These are actors, not real customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/aipdv" rel="noopener noreferrer"&gt;
        aipdv
      &lt;/a&gt; / &lt;a href="https://github.com/aipdv/tonesearch" rel="noopener noreferrer"&gt;
        tonesearch
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/aipdv/tonesearch/assets/banner.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Faipdv%2Ftonesearch%2FHEAD%2Fassets%2Fbanner.png" alt="tonesearch: find the angry minute in your call recordings" width="100%"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;tonesearch&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Search call recordings by &lt;strong&gt;what&lt;/strong&gt; was said and &lt;strong&gt;how&lt;/strong&gt; it was said. Runs locally. No transcript needed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What: EmbeddingGemma 2 embeds the audio, so a text query finds speech by meaning.&lt;/li&gt;
&lt;li&gt;How: emotion2vec+ scores each segment for tone (angry, sad, happy, ...).&lt;/li&gt;
&lt;li&gt;Timeline: tone over time for one call, with the top 3 spikes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Validation numbers: &lt;a href="https://github.com/aipdv/tonesearch/RESULTS.md" rel="noopener noreferrer"&gt;RESULTS.md&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Where&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tonesearch.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Indexer, search, CLI, Gradio UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;make_demo.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Builds whole-call demo WAVs from HaessigDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_tonesearch.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Self-check on the demo index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;eval_tone.py&lt;/code&gt;, &lt;code&gt;probe_tone.py&lt;/code&gt;, &lt;code&gt;probe_e2v.py&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Research scripts behind RESULTS.md&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pip install -e &lt;span class="pl-c1"&gt;.&lt;/span&gt;
tonesearch index &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;audio dir&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;index dir&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;          &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; any .wav .mp3 .flac .m4a .ogg, resampled to 16 kHz mono&lt;/span&gt;
tonesearch search &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;index dir&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;refund request&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --tone angry --w 1
tonesearch search &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;index dir&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; --tone angry        &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; tone only&lt;/span&gt;
tonesearch ui &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;index dir&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;                         &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Gradio&lt;/span&gt;&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/aipdv/tonesearch" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;About 170 lines of Python in one file, plus a self-check:&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; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
tonesearch index &amp;lt;audio &lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &amp;lt;index &lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
tonesearch search &amp;lt;index &lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"refund request"&lt;/span&gt; &lt;span class="nt"&gt;--tone&lt;/span&gt; angry &lt;span class="nt"&gt;--w&lt;/span&gt; 1
tonesearch ui &amp;lt;index &lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Two open-weight models do the work.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;EmbeddingGemma 2&lt;/strong&gt; (&lt;code&gt;google/embeddinggemma-2&lt;/code&gt;) embeds each audio segment. It also embeds the text query, in the same space. So a text query finds speech by meaning, with no speech-to-text step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;emotion2vec+ base&lt;/strong&gt; (&lt;code&gt;emotion2vec/emotion2vec_plus_base&lt;/code&gt;) scores each segment for tone labels such as angry, sad, happy and neutral.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The indexer splits each call on silence into 2 to 10 second segments. It runs both models once and saves one &lt;code&gt;.npz&lt;/code&gt; file. A search is one matrix multiply:&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="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cosine&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tone&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w = 0&lt;/code&gt; searches by words only. An empty query searches by tone only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma alone did not hear the tone
&lt;/h3&gt;

&lt;p&gt;My first plan was simpler. EmbeddingGemma 2 takes audio in, so I assumed it would hear an angry voice too. Embed the calls, search for "angry customer", done.&lt;/p&gt;

&lt;p&gt;I'm glad I tested that before building anything. HaessigDB has 888 clips, each rated 1 to 10 for irritation. I measured how well each signal tracks that rating. I also measured what is left after a transcript model explains what it can. I call that column "beyond words". For the trained probes, the test voice was never in the training set.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Spearman&lt;/th&gt;
&lt;th&gt;Beyond words&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EmbeddingGemma 2 audio, query "angry customer"&lt;/td&gt;
&lt;td&gt;+0.36&lt;/td&gt;
&lt;td&gt;+0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EmbeddingGemma 2 audio embedding, trained probe&lt;/td&gt;
&lt;td&gt;+0.45&lt;/td&gt;
&lt;td&gt;+0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EmbeddingGemma 2 transcript embedding, trained probe&lt;/td&gt;
&lt;td&gt;+0.45&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loudness&lt;/td&gt;
&lt;td&gt;+0.42&lt;/td&gt;
&lt;td&gt;+0.41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emotion2vec+ "angry" score, no training&lt;/td&gt;
&lt;td&gt;+0.54&lt;/td&gt;
&lt;td&gt;+0.54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emotion2vec+ and EmbeddingGemma 2 audio, trained probe&lt;/td&gt;
&lt;td&gt;+0.66&lt;/td&gt;
&lt;td&gt;+0.45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The audio embedding scored the same as the transcript embedding. It hears the words about as well as a transcript does, and it adds almost nothing about tone. Plain loudness, which is one line of librosa, did better on tone than Gemma did. That surprised me.&lt;/p&gt;

&lt;p&gt;emotion2vec+ caught tone with no training at all. Inside one call, its angry score follows the escalation (median Spearman +0.41 over 37 calls).&lt;/p&gt;

&lt;p&gt;So each model got one job: Gemma handles what was said, and emotion2vec handles how it was said.&lt;/p&gt;

&lt;h3&gt;
  
  
  One more fix: ties at the top
&lt;/h3&gt;

&lt;p&gt;emotion2vec returns softmax scores. On the 60-minute demo, 133 of 1176 segments scored exactly 1.0 for "angry". The top of an angry search was a tie.&lt;/p&gt;

&lt;p&gt;The fix was small. I now rank on log-odds: the log score minus the mean log score over all labels. That is the model's own logit gap, and it does not saturate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tone-only "angry" search, 1176 demo segments&lt;/th&gt;
&lt;th&gt;Spearman&lt;/th&gt;
&lt;th&gt;Mean irritation, top 10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Softmax score&lt;/td&gt;
&lt;td&gt;+0.50&lt;/td&gt;
&lt;td&gt;7.19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log-odds&lt;/td&gt;
&lt;td&gt;+0.52&lt;/td&gt;
&lt;td&gt;7.54&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Speed
&lt;/h3&gt;

&lt;p&gt;On an M3 Mac, with Gemma on the GPU, indexing runs at 18x real time. On CPU only, it runs at 3.4x real time. Gemma is the slow part on CPU. An int8 ONNX build of the Gemma audio encoder is my next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it does not do yet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The test data is acted and scripted. Real calls are noisier.&lt;/li&gt;
&lt;li&gt;HaessigDB has only 4 actors.&lt;/li&gt;
&lt;li&gt;It has no transcript and does not split speakers. You hear each segment, but you cannot read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Without open weights I could not sell this to the people above.&lt;/p&gt;

&lt;p&gt;The first reason is privacy. Call audio is full of names, account numbers and sometimes health details. Open weights let the models run where the audio already lives, so nothing gets uploaded. For a lot of small agencies that is the only way a tool like this is allowed at all.&lt;/p&gt;

&lt;p&gt;The second is cost. There is no per-minute API fee. An M3 laptop CPU indexes a 5-minute call in about 90 seconds, so the cost per call is close to zero, and a small flat monthly price could still leave a margin. I have not picked a price yet.&lt;/p&gt;

&lt;p&gt;The third is that I could look inside. I pulled the embeddings out and trained probes on them, which is how I found that Gemma audio hears words and not tone. A closed API would have given me a score and nothing to check it against.&lt;/p&gt;

&lt;p&gt;The last is that the parts are swappable. When one model could not hear tone, I added a second one. When a better tone model comes out, I change one model name, and no vendor has to sign off.&lt;/p&gt;

&lt;p&gt;A license caveat: emotion2vec+ uses the FunASR model license. I must check its commercial terms before I charge anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma: EmbeddingGemma 2 is the search engine. It embeds call audio and text queries in one space, so you can search speech by meaning with no transcript.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
