<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mikhail Eshchenko]]></title><description><![CDATA[Mikhail Eshchenko]]></description><link>https://maeshchenko.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 17:58:20 GMT</lastBuildDate><atom:link href="https://maeshchenko.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Jev in Channel Finder: using typed relevance scoring for similar-channel search]]></title><description><![CDATA[How Channel Finder uses Jev as one typed semantic relevance signal, while code and manual review keep deterministic rules and final judgment separate.]]></description><link>https://maeshchenko.hashnode.dev/jev-relevance-scoring-similar-channel-search</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/jev-relevance-scoring-similar-channel-search</guid><category><![CDATA[AI]]></category><category><![CDATA[search]]></category><category><![CDATA[software development]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 12:03:51 GMT</pubDate><content:encoded><![CDATA[<p>Channel similarity sounds simple until two channels use the same vocabulary for very different kinds of content.</p>
<p>In <a href="https://channelfinder.app/">Channel Finder</a>, the <a href="https://chromewebstore.google.com/detail/channel-finder-for-youtub/dobmmfogicdchmipicnbhgelaogaahjb">desktop Chrome extension</a> I make and maintain, Jev by TypeSafe AI is one semantic relevance scoring component. Channel Finder is currently free early access with usage limits. Jev helps judge candidate public channel profiles from public titles and descriptions, focusing on recurring subject and the kind of videos a channel tends to make. It is not the retrieval system, the whole ranking system, or the final decision maker. It supplies one relevance signal that application logic can use alongside manual review.</p>
<p>That boundary is the useful part of the case study. A search product often needs both exact rules and semantic judgment, and they should not be treated as the same problem.</p>
<h2>The narrow question Jev is helping answer</h2>
<p>Suppose the seed channel repeatedly publishes home bicycle-maintenance tutorials.</p>
<p>Now consider two hypothetical candidates:</p>
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Recurring subject</th>
<th>Recurring video purpose</th>
<th>What a developer should inspect</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>Bicycle repairs, tools, maintenance</td>
<td>Step-by-step mechanical tutorials</td>
<td>Strong topical and format alignment</td>
</tr>
<tr>
<td>B</td>
<td>Bicycles, races, products</td>
<td>Race highlights and shopping hauls</td>
<td>Shared vocabulary, but a different recurring task</td>
</tr>
</tbody></table>
<p>This is not an actual Channel Finder query, and those are not Jev outputs. The example only shows the distinction the scoring component needs to represent.</p>
<p>A keyword rule can notice "bicycle", "chain", "wheel", or "brake" in both profiles. That is evidence, but it does not answer the full product question. For creator research, recurring content matters: what the channel tends to cover and what kind of videos it tends to make. A repair-tutorial channel and a race-highlights channel can share many nouns while serving different purposes.</p>
<p>Jev is relevant because its <a href="https://docs.typesafe.ai/introduction">public interface</a> is built around typed decisions rather than open-ended chat. TypeSafe describes Jev as a System One model that evaluates a typed question against supplied state and returns structured answers software can consume. Its <a href="https://docs.typesafe.ai/primitives/score">Score primitive</a> rates content against ordered descriptive levels and can return a score, distribution, and confidence.</p>
<p>That is useful at the semantic boundary, but it does not make the result ground truth.</p>
<h2>Why a typed relevance signal is a useful boundary</h2>
<p>For this task, I do not need a paragraph explaining why two channels are similar. I need a bounded judgment the application can consume.</p>
<p>A typed result fits normal software control flow. The application can accept a relevance signal as one input without pretending the model has made the complete product decision.</p>
<p>Channel Finder does not simply expose an untouched raw Jev answer as the visible percentage or final order. Jev supplies a relevance signal. Application logic remains separate, and a human still decides which channels are worth keeping in a shortlist.</p>
<p>The same separation prevents bad interpretations. A relevance score is not a percentage of shared viewers, a probability that a creator will accept a sponsorship, or evidence about private audience demographics. Public titles and descriptions can also be incomplete, stale, broad, or misleading.</p>
<p>The Score primitive needs similar care. Its meaning depends on the descriptive levels used for the task. TypeSafe's <a href="https://docs.typesafe.ai/confidence">confidence concept</a> summarizes the returned distribution; it is not measured accuracy on my product data. A concentrated answer can still be wrong. That is a general interpretation lesson, not a claim that Channel Finder currently uses confidence gating or displays confidence.</p>
<h2>Keep deterministic rules deterministic</h2>
<p>A semantic model should not absorb work ordinary code can perform exactly.</p>
<p>If a product has a subscriber-count filter, a date boundary, an exact country selection, or another explicit numeric rule, those belong in deterministic application logic. A semantic judgment about recurring content cannot certify a numeric threshold, just as arithmetic cannot decide whether two channels are meaningfully about the same kind of content.</p>
<p>This split makes failures easier to classify. If a candidate violates a hard numeric filter, investigate deterministic logic. If it passes exact rules but is semantically off-topic, investigate the relevance layer.</p>
<h2>What the archived comparison measured</h2>
<p>The archived comparison from 21 Oct 2026 used three fixed candidate pools, one per seed. Each pool contained 16 candidates, for 48 unique channels. The seeds covered 18th-century living history and cooking, visual conceptual mathematics, and popular physics and astronomy explainers. Each model family received six provider requests, split as one batch of 10 and one batch of 6 candidates for each of the three seeds. These were not six repeats of the same input. The same saved candidate evidence and scalar relevance-scoring task were supplied in matching batches, with provider-specific response wrappers. All 18 requests completed without failure. All six Jev calls ran before all six Opus calls, so order was not randomized. Haiku was added after the Jev/Opus outcomes were observed and should be read as an exploratory post-observation baseline rather than a preplanned peer. No new API call was made for this article update; these are archived results.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th align="right">nDCG@10 point score</th>
<th align="right">Useful@10 point score</th>
<th align="right">Provider-call p50 (ms, nearest rank)</th>
<th align="right">Estimated token cost (USD, 6 calls)</th>
</tr>
</thead>
<tbody><tr>
<td>Jev</td>
<td align="right">0.9512</td>
<td align="right">0.4667</td>
<td align="right">285.4</td>
<td align="right">$0.001214766</td>
</tr>
<tr>
<td>Opus</td>
<td align="right">0.9014</td>
<td align="right">0.4333</td>
<td align="right">4,875.9</td>
<td align="right">$0.206040000</td>
</tr>
<tr>
<td>Haiku</td>
<td align="right">0.8267</td>
<td align="right">0.4667</td>
<td align="right">2,968.5</td>
<td align="right">$0.006321000</td>
</tr>
</tbody></table>
<p>These are descriptive point scores, not accuracy percentages. nDCG@10 measures ranking quality within the frozen pools. Useful@10 means rubric grade 2 or higher in the first ten results, macro-averaged over the three pools. On the point scenario, Jev and Haiku each placed 14 provisionally useful items across the 30 top-ten positions, while Opus placed 13. Jev therefore had the highest provisional nDCG point here, but it did not win every metric.</p>
<p>The labels also limit what these numbers can support. They are provisional AI judgments on archived exposed development evidence, not independent human ground truth or a fresh holdout set. Under the strict labeling rule, 21 of 48 items remained unresolved. The point scenario keeps uncertain integer grades and treats a missing grade as zero only for that scenario. Deterministic lower and upper bounds for those unresolved labels overlap widely; they are not confidence intervals and do not establish a universal ranking winner.</p>
<p>Timing and cost have similarly narrow meanings. The p50 is nearest-rank over six recorded provider-request wall times, so with six values it is the lower middle observation, not the arithmetic mean of the two central values. It includes transport and provider waiting, not model compute alone, and it is not Channel Finder end-to-end search latency. The cost column is an estimate from reported token usage and historical rates checked on 21 Oct 2026, not an invoice or current price quote. It excludes tax, network, metadata retrieval, and storage; no prompt caching, Batch API, or fast mode was requested.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae677d914a83b26977463b1/7318aa6c-eb38-41a8-9036-6725f182e250.png" alt="Two bar charts comparing provider-call nearest-rank p50 and estimated six-call token cost for Jev, Opus, and Haiku: 285.4, 4,875.9, and 2,968.5 ms; $0.001215, $0.206040, and $0.006321." /></p>
<p><em>Archived 21 Oct 2026 comparison, six completed provider requests per model over the same three fixed candidate pools. Bars show nearest-rank provider-call p50 wall time and usage-based estimated token cost for six calls. Haiku was added after the Jev/Opus outcomes were observed, so it is an exploratory post-observation baseline. Timing is not end-to-end Channel Finder latency; cost is based on reported token usage and historical rates, not invoice-verified charges or current pricing.</em></p>
<p>Within this small archived comparison, Jev had the highest provisional nDCG point, the lowest recorded provider-call p50, and the lowest usage-based estimated token cost. That does not establish universal accuracy, lower whole-search latency, or production superiority.</p>
<h2>How I would evaluate this boundary</h2>
<p>The archived comparison above is exploratory. For a stronger evaluation, I would still isolate the semantic scorer from unrelated retrieval changes.</p>
<p>First, freeze a candidate pool. If retrieval changes between test runs, a better-looking result might come from different candidates rather than better relevance judgment.</p>
<p>Second, include deliberate near misses. Useful cases share vocabulary with the seed but repeatedly perform a different task, like the bicycle race channel above. Also include sparse profiles where titles and descriptions provide limited evidence.</p>
<p>Third, label for the actual product question: recurring subject and video purpose, not generic "similarity". That keeps evaluation aligned with the judgment the model is supposed to support.</p>
<p>Fourth, separate output validity from content-fit quality. A typed answer can be structurally valid and still make a poor semantic judgment. These are different failure modes.</p>
<p>Finally, use manual review on the evaluation set. It makes disagreements inspectable instead of hiding them behind one aggregate number.</p>
<p>I am not presenting the archived comparison or these steps as evidence that Channel Finder has passed a controlled production benchmark. They are a defensible way to evaluate the boundary without confusing retrieval changes, schema correctness, and semantic quality.</p>
<h2>The implementation choice I would keep</h2>
<p>The useful architectural decision is narrower than "put AI in search": give the model one semantic question ordinary rules do not answer well, return that judgment in a form code can consume, and keep exact filters, ranking logic, and human decisions outside that boundary.</p>
<p>That is where Jev fits in Channel Finder. It helps score whether public channel profiles show recurring content similar to a known creator. It does not decide what is true about an audience, whether a campaign will work, or which creator a person should choose.</p>
<p>For developers building search or recommendation tools, the actionable boundary is this: make the semantic judgment explicit, typed, and testable, then keep the rest of the system responsible for everything the model cannot honestly decide.</p>
]]></content:encoded></item><item><title><![CDATA[Make a 90-Day Creator Activity Filter Reproducible]]></title><description><![CDATA[Build a reproducible creator-activity filter in JavaScript. Separate publication and capture times, handle missing dates, and test 90-day boundaries.]]></description><link>https://maeshchenko.hashnode.dev/reproducible-90-day-creator-activity-filter</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/reproducible-90-day-creator-activity-filter</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[data-quality]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 11:27:19 GMT</pubDate><content:encoded><![CDATA[<p>Suppose a creator CSV was captured three months ago. Later, someone opens the file, subtracts its latest-known publication date from the current clock, and labels the creator inactive. The arithmetic may be correct, but the conclusion is not. The file only knows what was observed when it was captured. The creator may have published since then.</p>
<p>A reproducible activity filter needs three different times:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>publishedAt</code></td>
<td>Publication or event time for the chosen eligible item</td>
</tr>
<tr>
<td><code>capturedAt</code></td>
<td>When that observation or sample was recorded</td>
</tr>
<tr>
<td><code>evaluatedAt</code></td>
<td>When a report or decision is generated</td>
</tr>
</tbody></table>
<p>These are generic teaching fields, not a claim about any product's CSV schema or implementation. Every timestamp and example below is synthetic and unrelated to a real channel.</p>
<p>The distinction matters because publication time and observation time answer different questions. If a snapshot was captured on <code>2026-11-01T12:00:00.000Z</code>, then an activity classification for that snapshot should be evaluated relative to that capture time. Re-running the same historical data later should produce the same classification.</p>
<p>Using the current clock instead changes the question. It no longer asks, "Was the latest observed eligible publication within 90 days when this snapshot was captured?" It asks something about current activity using stale evidence. That is not a valid current-activity test.</p>
<h2>Define the event before measuring it</h2>
<p>For YouTube video metadata, the documented <a href="https://developers.google.com/youtube/data/reference/rest/v3/videos#snippet.publishedAt"><code>snippet.publishedAt</code></a> field is a publication date and time. The documentation also describes cases where publication time differs from upload time, such as content uploaded privately and made public later. So I would not rename every publication timestamp to <code>uploadedAt</code> unless the source actually defines that meaning.</p>
<p>If an importer already receives a documented absolute publication instant, the classifier can work with that value. It should not try to infer an exact instant from a display label such as <code>1 month ago</code>. A relative label is presentation text, not an exact timestamp or a documented rounding contract.</p>
<p>The caller also has to define which publications are eligible. If the observation covers a Videos-tab sample, then the result describes that sample. It is not proof that no Short or stream exists outside it.</p>
<h2>Use a fixed duration policy</h2>
<p>JavaScript <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date"><code>Date</code></a> represents an instant as milliseconds relative to the UTC epoch. Calendar months, local dates, and daylight-saving transitions are different concepts from a fixed elapsed duration.</p>
<p>For this example, the policy is exactly <code>90 * 24</code> hours. It is not "three calendar months" and it is not "90 local calendar days." Fixtures use explicit <code>Z</code> timestamps so the boundary is visible.</p>
<p>The classifier accepts <code>Date</code> objects that an importer has already constructed from documented absolute timestamps. It deliberately does not parse arbitrary strings. Its duration policy is also explicit: <code>windowDays</code> must be a finite non-negative number. Invalid types are rejected before multiplication, and a finite numeric value that overflows when converted to milliseconds is rejected too.</p>
<pre><code class="language-js">function classifyAtCapture({ publishedAt, capturedAt } = {}, windowDays = 90) {
  const msPerDay = 24 * 60 * 60 * 1000;

  if (
    typeof windowDays !== "number" ||
    !Number.isFinite(windowDays) ||
    windowDays &lt; 0
  ) {
    throw new RangeError("windowDays must be a finite non-negative number");
  }

  const windowMs = windowDays * msPerDay;
  if (!Number.isFinite(windowMs)) {
    throw new RangeError("windowDays is too large for a finite millisecond duration");
  }

  const validDate = (value) =&gt;
    value instanceof Date &amp;&amp; Number.isFinite(value.getTime());

  if (!validDate(publishedAt) || !validDate(capturedAt)) {
    return { status: "unknown", ageMs: null };
  }

  const ageMs = capturedAt.getTime() - publishedAt.getTime();
  if (ageMs &lt; 0) {
    return { status: "unknown", ageMs: null };
  }

  return {
    status: ageMs &lt;= windowMs ? "within-window" : "older-than-window",
    ageMs
  };
}
</code></pre>
<p>There is no <code>Math.floor</code> before the comparison. Exactly 90 fixed days is inside the window. One millisecond beyond the boundary is outside it.</p>
<table>
<thead>
<tr>
<th>Synthetic case</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Publication equals capture time</td>
<td><code>within-window</code></td>
</tr>
<tr>
<td>Publication exactly 90 fixed days before capture</td>
<td><code>within-window</code></td>
</tr>
<tr>
<td>Publication 90 days plus 1 ms before capture</td>
<td><code>older-than-window</code></td>
</tr>
<tr>
<td>Missing, null, invalid, or non-Date timestamp</td>
<td><code>unknown</code></td>
</tr>
<tr>
<td>Publication after capture</td>
<td><code>unknown</code></td>
</tr>
</tbody></table>
<p>Returning <code>unknown</code> is important. A missing or null date is not equivalent to an old publication. A malformed or non-Date value is not age zero. A publication timestamp after the capture instant indicates inconsistent inputs for this classifier, so silently clamping the age to zero would hide a data problem.</p>
<p>The function validates only its small teaching contract. Its <code>instanceof Date</code> check is intentionally part of this local importer contract, not a general cross-realm date validator. A valid JavaScript <code>Date</code> still does not prove that the upstream timestamp has the intended business meaning. The importer has to establish what the timestamp represents and which observations were included.</p>
<h2>Historical reproducibility is not current freshness</h2>
<p>Suppose a snapshot captured on November 1 records an eligible publication just outside the 90-day window. <code>older-than-window</code> means only that the latest publication known in that chosen snapshot was older than the policy boundary at capture time.</p>
<p>It does not prove that the creator is inactive today. To answer a current question, refresh the eligible sample, establish its coverage, record a new <code>capturedAt</code>, and classify that new snapshot. Keep <code>evaluatedAt</code> separately if the report also needs to record when the decision was generated.</p>
<p>That separation makes historical reports reproducible without converting stale observations into unsupported current claims.</p>
<p>Disclosure: I make and maintain <a href="https://channelfinder.app/">Channel Finder</a>, a Chrome extension for researching channels from public content. Its current Chrome Web Store page is <a href="https://chromewebstore.google.com/detail/channel-finder-for-youtub/dobmmfogicdchmipicnbhgelaogaahjb">here</a>. It is currently free early access with usage limits.</p>
]]></content:encoded></item><item><title><![CDATA[Fine Pitch in JavaScript: Semitones, Cents, and Playback Rate]]></title><description><![CDATA[A small JavaScript layer for pitch-ratio calculations, signed fine-tuning corrections, and the browser playback-speed trap.]]></description><link>https://maeshchenko.hashnode.dev/fine-pitch-semitones-cents-javascript</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/fine-pitch-semitones-cents-javascript</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Audio]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 06:09:02 GMT</pubDate><content:encoded><![CDATA[<p>Suppose you already know that a passage is uniformly 20 cents sharp relative to the reference you want. The correction is -20 cents. Adding +20 would move it farther away. That sign looks obvious once written down, but fine-pitch code can mix semitones, cents, frequency ratios, and playback speed in ways that make small mistakes easy.</p>
<h2>From cents to a frequency ratio</h2>
<p>In 12-tone equal temperament, one semitone is 100 cents and one octave is 1200 cents. A cent is a logarithmic pitch unit, not a fixed number of hertz. The <a href="https://cdn.korg.com/us/support/download/files/3024604ca0d3577bf5ee9db81330319d.pdf">Korg DT-1 manual</a> is a practical example of cent offsets around a reference pitch, with its calibration expressed in hertz.</p>
<p>The ratio follows directly from octave doubling. Moving up one octave multiplies frequency by 2. If a shift is <code>s</code> semitones plus <code>c</code> cents, its size in cents is:</p>
<pre><code class="language-text">totalCents = 100 * s + c
</code></pre>
<p>That is <code>totalCents / 1200</code> octaves, so the frequency ratio is:</p>
<pre><code class="language-text">ratio = 2 ** (totalCents / 1200)
</code></pre>
<p>Positive values raise frequency and negative values lower it. The formula works for combined controls too. For example, <code>s = -1</code> and <code>c = +20</code> is a net shift of -80 cents, not two unrelated operations that need separate pitch math.</p>
<p>Using 440 Hz as a base, these are calculated values from the formula, not measurements from an audio engine:</p>
<pre><code class="language-text">Shift       Calculated frequency
-100 cents  415.305 Hz
-20 cents   434.946 Hz
0 cents     440.000 Hz
+20 cents   445.113 Hz
+100 cents  466.164 Hz
</code></pre>
<p>The inverse is just as useful when you already know two positive frequencies. The correction in cents from <code>sourceHz</code> to <code>targetHz</code> is:</p>
<pre><code class="language-text">1200 * log2(targetHz / sourceHz)
</code></pre>
<p>So 440 Hz to 442 Hz is about +7.851 cents. Reversing the direction, from 442 Hz to 440 Hz, gives about -7.851 cents. This is only arithmetic on known frequencies. It is not a pitch detector, and a single global offset cannot fix a recording where different notes or instruments need different corrections.</p>
<h2>Two small JavaScript helpers</h2>
<p>For a UI control, I would keep the conversion layer boring and explicit. The functions below reject non-finite control values and reject zero or negative frequencies before taking a logarithm.</p>
<pre><code class="language-js">function pitchRatio(semitones, cents = 0) {
  if (!Number.isFinite(semitones) || !Number.isFinite(cents)) {
    throw new TypeError("semitones and cents must be finite numbers");
  }

  return 2 ** ((100 * semitones + cents) / 1200);
}

function centsBetween(sourceHz, targetHz) {
  if (
    !Number.isFinite(sourceHz) ||
    !Number.isFinite(targetHz) ||
    sourceHz &lt;= 0 ||
    targetHz &lt;= 0
  ) {
    throw new RangeError("frequencies must be positive finite numbers");
  }

  return 1200 * Math.log2(targetHz / sourceHz);
}
</code></pre>
<p>The first helper returns a dimensionless ratio. The second returns the signed correction needed to move from one known frequency to another. That makes the direction visible at the call site: <code>centsBetween(observedHz, desiredHz)</code>.</p>
<p>For this article, I ran the conversion checks with plain Node. This compact check can be pasted after the two helpers and run without dependencies or network access:</p>
<pre><code class="language-js">function assertClose(actual, expected, epsilon = 1e-9) {
  if (Math.abs(actual - expected) &gt; epsilon) {
    throw new Error(`expected ${expected}, got ${actual}`);
  }
}

assertClose(pitchRatio(12), 2);
assertClose(pitchRatio(-1, 20), pitchRatio(0, -80), 1e-12);
assertClose(centsBetween(440, 442), 7.851415040126504, 1e-12);

for (const fn of [
  () =&gt; centsBetween(0, 440),
  () =&gt; pitchRatio(Number.NaN),
]) {
  let threw = false;
  try {
    fn();
  } catch {
    threw = true;
  }
  if (!threw) {
    throw new Error("expected invalid input to throw");
  }
}

console.log("pitch checks passed");
</code></pre>
<p>The broader diagnostic I ran for the article also checked octave ratios in both directions, positive and negative semitones, an inverse round trip, all seven 440 Hz cases used for verification, the combined -80 cent inverse, and 12 invalid inputs. Those checks cover the conversion logic only, not audio processing or browser-extension runtime.</p>
<h2>Why this ratio is not playbackRate</h2>
<p>A browser implementation mistake is to calculate a pitch ratio and assign it directly to <code>audio.playbackRate</code>. Those numbers look compatible because both are ratios, but they represent different controls.</p>
<p>The current <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate">MDN documentation for <code>HTMLMediaElement.playbackRate</code></a> says that <code>1.0</code> is normal playback speed, values below 1 are slower, and values above 1 are faster. It also states that browser audio pitch is corrected by default, with <code>preservesPitch</code> available to disable that correction.</p>
<p>So a pitch ratio such as <code>2 ** (20 / 1200)</code> is not a drop-in <code>playbackRate</code> value when the requirement is "raise pitch by 20 cents while keeping tempo unchanged." Independent pitch and time control requires suitable pitch-shifting processing separate from the media element's playback-rate control.</p>
<h2>A browser product example</h2>
<p>I built my own WebAssembly pitch-shifting engine after Chrome transposers I tried made upward vocals sound cartoonish to me and downward shifts heavy or distorted. Its first released application is <a href="https://keychanger.app/">Key Changer</a>, a desktop Chrome extension that shifts already-playing browser audio on supported sites including YouTube, Spotify Web, and SoundCloud. It provides -12 to +12 semitones and -50 to +50 cents in 1-cent steps while keeping tempo unchanged; Keep vocals natural is intended to help preserve timbre. A 1-cent input step is control resolution, not a claim of measured 1-cent DSP accuracy. The <a href="https://chromewebstore.google.com/detail/key-changer/fhomhcphchfjgidmefjjbhnkdoblgcpj">Chrome Web Store listing</a> describes the released extension, while the <a href="https://misheno.com/key-changer-engine/#listen">public engine listening page</a> uses selected fixed 20-second prerecorded excerpts with an unshifted Original and processed choices. Those examples are for listening comparison, not live-input execution or latency proof.</p>
<p>The same math remains useful without that product. If a known source is uniformly off by a small amount, calculate the signed cents correction, keep the frequency conversion separate from playback speed, and test the helper at the boundaries before wiring it into an audio control.</p>
]]></content:encoded></item><item><title><![CDATA[A Repeatable Listening Test for Pitch-Shifting Engines]]></title><description><![CDATA[A manual comparison procedure for checking vocal identity and artifacts alongside a speaker-similarity metric.]]></description><link>https://maeshchenko.hashnode.dev/a-repeatable-listening-test-for-pitch-shifting-engines</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/a-repeatable-listening-test-for-pitch-shifting-engines</guid><category><![CDATA[Testing]]></category><category><![CDATA[Audio Processing]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 01:47:57 GMT</pubDate><content:encoded><![CDATA[<p>The Chrome key transposers I tried changed vocals in ways I did not like: upward shifts could sound cartoonish, while downward shifts could feel heavy or distorted. I eventually made my own pitch engine, and the Key Changer extension is its first released application.</p>
<p>For a concrete test surface, I use my public <a href="https://misheno.com/key-changer-engine/#listen">pitch-shifting comparison</a>. I own this demo and the Key Changer Engine, so it should not be read as an independent buyer guide. The useful part is the comparison setup: four 20-second musical excerpts, an unshifted Original reference, and four processed choices - Key Changer Engine, Rubber Band Library R3, Signalsmith Stretch, and soundtouchjs 0.3.0.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae677d914a83b26977463b1/43f1b4f7-1028-4454-8f30-28a2ef37d2c9.png" alt="Screenshot of the public pitch-shifting comparison. Demo track 01 is selected and paused at 0:00 of 0:20. Key Changer Engine is selected. Compare other engines is expanded, showing Rubber Band Library R3, Signalsmith Stretch, and soundtouchjs 0.3.0 (SoundTouch port). Pitch shift is +5 semitones. Red callout 1 points to the play button and callout 2 points to the selected Key Changer Engine button." /></p>
<p><em>Public comparison widget paused on demo track 01 at 0:00 of 0:20, with Key Changer Engine selected, the three other engine choices expanded, and pitch shift set to +5 semitones.</em></p>
<h2>Separate reference listening from engine comparison</h2>
<p>The Original button is not a fifth processed engine. Use it first to learn the source: the singer's vowel color, consonants, attacks, and the texture of the accompaniment. After that, fair engine-to-engine comparison means holding the track, phrase, pitch shift, and listening volume fixed.</p>
<p>That distinction matters because two questions are easy to mix up. First: did the playback move to the pitch I requested? Second: after that shift, does a familiar voice still sound recognizably like itself? A pitch shifter can satisfy the first question while making the second harder.</p>
<p>Pitch and vocal character are related but not identical. Formants are resonant features of a voice or instrument that contribute strongly to perceived timbre. Rubber Band's <a href="https://www.breakfastquay.com/rubberband/integration.html">official integration guide</a>, for example, recommends <code>OptionFormantPreserved</code> when pitch-shifting vocals or mixes with vocals to avoid a chipmunk effect. That is useful background for what to listen for, but it says nothing about how my engine is implemented internally.</p>
<h2>A repeatable manual procedure</h2>
<ol>
<li>Pick one demo track and listen to Original before judging any processed version. Note one short phrase with a clear vowel, one consonant or transient, and one accompaniment detail you can recognize.</li>
<li>Set one fixed shift. I would start with +5 semitones because it is large enough to expose changes, then make a separate pass at -5. Do not compare shifted output against Original as if that alone ranks algorithms.</li>
<li>Start playback and switch only among the processed engines during the same short passage. The widget keeps the playback position when engines are switched, so the handoff does not jump elsewhere in the excerpt.</li>
<li>Keep listening volume fixed. For each engine, record observations under three headings: voice or vowel character, consonant or transient clarity, and accompaniment texture. Describe what changed rather than writing a single winner score.</li>
<li>Repeat the same fixed shift on the other demo tracks. A result that bothers you on one singer or arrangement may not appear on another.</li>
<li>After the large-shift passes, carry the same check into normal browser playback on material you already know. The <a href="https://chromewebstore.google.com/detail/key-changer/fhomhcphchfjgidmefjjbhnkdoblgcpj">installed desktop Chrome extension</a> is the live surface for that: it provides -12 to +12 semitone control and -50 to +50 cents of fine tuning, while the fixed comparison widget above is limited to -7 to +7 and only its four excerpts. Repeat with smaller shifts and decide whether an artifact matters in your actual practice.</li>
</ol>
<p>The public widget uses pre-rendered 128 kbps, 48 kHz MP3 outputs, so this procedure evaluates audible rendered results. It does not measure live-input behavior, CPU cost, or latency.</p>
<h2>Where the metric fits</h2>
<p>My separate benchmark uses ECAPA-TDNN cosine similarity between separated source and processed vocals. The original <a href="https://arxiv.org/abs/2005.07143">ECAPA-TDNN paper</a> is about speaker verification, so using its embeddings here is my evaluation choice: the metric can help ask whether processed vocals remain similar to the source singer, but it is not a musical pleasantness score, a percentage, or a substitute for listening.</p>
<p>The primary analysis covers 13 single-lead excerpts and 14 nonzero pitch shifts. I described that aggregation in a <a href="https://maeshchenko.hashnode.dev/how-i-measured-singer-identity-after-pitch-shifting">separate article</a>, so I would not repeat it inside a listening test.</p>
<p>There are also limits to what this demo can establish. Its four public excerpts were selected after measurements from the benchmark set, the engine labels are visible, and the examples are not a blind or randomly representative listening study. Manual judgments are source-dependent.</p>
]]></content:encoded></item><item><title><![CDATA[Thai Text in JavaScript: UTF-16 Positions, Grapheme Segments, and Lesson Units]]></title><description><![CDATA[I make Learn Thai Alphabet & Tones, so Thai text is a useful place to be precise about what a JavaScript "position" means. For this article I wrote and ran…]]></description><link>https://maeshchenko.hashnode.dev/thai-text-in-javascript-utf-16-positions-grapheme-segments-and-lesson-units</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/thai-text-in-javascript-utf-16-positions-grapheme-segments-and-lesson-units</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[unicode]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 01:38:17 GMT</pubDate><content:encoded><![CDATA[<p>I make Learn Thai Alphabet &amp; Tones, so Thai text is a useful place to be precise about what a JavaScript "position" means. For this article I wrote and ran an original diagnostic to inspect two strings. It is teaching code, not source from the app.</p>
<h2>Start by naming the unit</h2>
<p>JavaScript strings expose UTF-16 code units through <code>length</code> and numeric indexing. <code>Array.from(text)</code> iterates Unicode code points. <code>Intl.Segmenter</code> can segment text into grapheme, word, or sentence units. Those are three different layers, and none of them automatically means "the unit I want to teach."</p>
<p>Thai makes that distinction visible. In the fixed <a href="https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-16/#G6649">Unicode 16 Thai section</a>, vowel characters displayed to the left of a consonant are encoded before that consonant, while combining signs and marks above or below a consonant are encoded after the consonant they modify. Normal Thai text should stay in that encoded order. Reordering it into consonant-first storage would create a different representation problem rather than solve the teaching problem.</p>
<p>The current <a href="https://www.unicode.org/reports/tr29/">Unicode text segmentation annex</a> defines default grapheme, word, and sentence boundaries and allows tailoring. It also notes that text boundaries cannot always match user perception exactly. A grapheme boundary is therefore useful information, but it is not a claim about a syllable, an onset, or a lesson item.</p>
<p>Likewise, <a href="https://tc39.es/ecma402/2024/#sec-intl.segmenter">ECMA-402 <code>Intl.Segmenter</code></a> offers <code>grapheme</code>, <code>word</code>, and <code>sentence</code> granularities. There is no syllable granularity. Requesting the <code>"th"</code> locale asks for Thai locale handling; it does not convert segmentation into a Thai phonetic parser.</p>
<h2>A small diagnostic</h2>
<p>Run this with a Node version that supports <code>Intl.Segmenter</code>. The environment logging is Node-specific; the <code>inspect</code> function itself uses standard JavaScript APIs that can also be tried in a supporting browser.</p>
<pre><code class="language-js">// Original teaching diagnostic, not Learn Thai app source or a Thai parser.
const graphemes = new Intl.Segmenter("th", { granularity: "grapheme" });

function inspect(text) {
  return {
    text,
    utf16Units: text.length,
    codePoints: Array.from(text, (char) =&gt;
      `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`
    ),
    graphemes: Array.from(graphemes.segment(text), ({ segment }) =&gt; segment),
    nfcChangesText: text.normalize("NFC") !== text,
  };
}

console.log(JSON.stringify({
  environment: {
    node: process.version,
    icu: process.versions.icu,
    unicode: process.versions.unicode,
    segmenter: graphemes.resolvedOptions(),
  },
  examples: ["มา", "เก้า"].map(inspect),
}, null, 2));
</code></pre>
<p>I ran that diagnostic on Node v26.9.0 with ICU 78.3 and Unicode 17.0. <code>resolvedOptions()</code> reported locale <code>th</code> and grapheme granularity. It does not report a Unicode version; the Unicode and ICU fields above come from Node's <code>process.versions</code>.</p>
<table>
<thead>
<tr>
<th>Text</th>
<th align="right">UTF-16 units</th>
<th>Code points</th>
<th>Grapheme segments</th>
<th>NFC changed?</th>
</tr>
</thead>
<tbody><tr>
<td><code>มา</code></td>
<td align="right">2</td>
<td><code>U+0E21 U+0E32</code></td>
<td><code>[ม, า]</code></td>
<td>false</td>
</tr>
<tr>
<td><code>เก้า</code></td>
<td align="right">4</td>
<td><code>U+0E40 U+0E01 U+0E49 U+0E32</code></td>
<td><code>[เ, ก้, า]</code></td>
<td>false</td>
</tr>
</tbody></table>
<p>These are observed outputs in that environment, not a guarantee for every engine and Unicode data version.</p>
<h2>What the output actually tells us</h2>
<p>Both examples contain only BMP code points, so the UTF-16 unit count equals the code-point count here.</p>
<p>The second string is more useful for the Thai-specific boundary. Its encoded order is <code>เ</code> (<code>U+0E40</code>), <code>ก</code> (<code>U+0E01</code>), <code>้</code> (<code>U+0E49</code>), then <code>า</code> (<code>U+0E32</code>). The leading segment <code>เ</code> is not the initial consonant. At the same time, the grapheme result <code>[เ, ก้, า]</code> is still not a parsed lesson syllable. It is a grapheme segmentation result.</p>
<p>NFC leaves both strings unchanged in this run. For these two strings, that observed result still does not supply pedagogical grouping: normalization and lesson-unit boundaries answer different questions.</p>
<h2>Design the teaching boundary explicitly</h2>
<p>For a reading exercise, I would treat the learning unit as application data rather than infer it from string indexes at render time. A practical boundary checklist is:</p>
<ul>
<li>Decide the intended unit before slicing: code unit, code point, grapheme, word boundary, or an authored teaching unit.</li>
<li>Preserve the original Thai spelling and encoded order for display and transport.</li>
<li>Keep author-reviewed pedagogical units separate from the representation used to render the text.</li>
<li>When a boundary looks wrong, inspect both code points and <code>Intl.Segmenter</code> output instead of guessing from glyph positions.</li>
<li>Test the actual target runtime with representative Thai examples, because segmentation behavior depends on implementation data and locale handling.</li>
</ul>
<p>That separation keeps <code>Intl.Segmenter</code> useful for what it provides without promoting it into a syllable parser it is not specified to be.</p>
<p>I make <a href="https://sabai.misheno.com/">Learn Thai Alphabet &amp; Tones</a>, a script-reading course on <a href="https://play.google.com/store/apps/details?id=com.mikhail_eshchenko.thai_fun">Android</a> and <a href="https://apps.apple.com/app/id6759532371">iPhone</a>. It has five modules and 90 lessons. The first seven beginner lessons are free; the remaining 83 require a monthly or yearly subscription or a one-time lifetime purchase. The course covers 34 consonants, 18 vowel signs, and 4 tone marks, not all 44 consonants. It is a Thai script-reading course, not a complete conversation course.</p>
]]></content:encoded></item><item><title><![CDATA[How to deduplicate a YouTube channel list]]></title><description><![CDATA[Deduplicate YouTube channel CSVs by resolving aliases to channel IDs, grouping confirmed matches, and preserving every source row for review.]]></description><link>https://maeshchenko.hashnode.dev/deduplicate-youtube-channel-list-by-id</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/deduplicate-youtube-channel-list-by-id</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[youtube]]></category><category><![CDATA[csv]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 01:31:09 GMT</pubDate><content:encoded><![CDATA[<p>A channel list can contain several rows for the same creator. One file may use a handle URL, another an ID-based URL, and a third a different display name. Removing duplicate names can also combine two unrelated channels.</p>
<p>For an importer, the useful distinction is between a channel's identity and the labels observed when a row was collected. Keep both.</p>
<h2>Separate the identifier from the label</h2>
<p>YouTube documents several URL forms that can lead to a channel: ID-based URLs, handle URLs, custom URLs and legacy username URLs. A <code>/channel/...</code> URL contains the channel's unique ID. Different URL strings are therefore insufficient evidence that two rows describe different channels. <a href="https://support.google.com/youtube/answer/6180214?hl=en">YouTube's URL guide</a> explains these forms.</p>
<p>A handle starts with <code>@</code> and is distinct from a channel name. Handles can also change or be reclaimed. Preserve the handle you observed, together with a date; do not treat an old handle-to-channel mapping as permanent. <a href="https://support.google.com/youtube/answer/11585688">YouTube's handle documentation</a> covers these rules.</p>
<p>For a research import, a small record can keep these concerns separate:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>channelId</code></td>
<td>Confirmed YouTube channel ID used for grouping.</td>
</tr>
<tr>
<td><code>observedHandle</code></td>
<td>Handle captured with this source row.</td>
</tr>
<tr>
<td><code>displayName</code></td>
<td>Human-readable label, retained for review.</td>
</tr>
<tr>
<td><code>sourceUrl</code></td>
<td>Original link, before normalization.</td>
</tr>
<tr>
<td><code>observedAt</code></td>
<td>When the observation was recorded.</td>
</tr>
</tbody></table>
<p>This is a proposed import record, not a requirement that every CSV already have these columns. A row without a confirmed ID remains useful evidence.</p>
<h2>Resolve before merging</h2>
<p>When your application already uses the YouTube Data API, <code>channels.list</code> provides a <code>forHandle</code> filter for a handle and <code>forUsername</code> for a legacy username. Its <code>id</code> filter retrieves channels by channel ID. The method requires exactly one filter parameter per request. Use the returned channel resource's ID as the identity key; YouTube documents the channel ID as uniquely identifying the channel. The <a href="https://developers.google.com/youtube/data/reference/rest/v3/channels/list">official reference</a> describes the filters, and the <a href="https://developers.google.com/youtube/data/guides/implementation/channels">channel implementation guide</a> describes the ID-based lookup.</p>
<p>Do not send the last segment of every channel URL as a legacy username. <code>/@handle</code>, <code>/user/name</code> and <code>/c/name</code> have different meanings. Resolving an alias needs evidence from the relevant official response or channel page, not a string replacement.</p>
<p>Keep unresolved rows in a review queue. A failed lookup is a resolution failure; it does not establish that a channel no longer exists.</p>
<h2>Group confirmed IDs and retain the original rows</h2>
<p>The following JavaScript groups rows whose IDs your importer has already confirmed. It does not fetch YouTube data or validate whether an arbitrary string is a real channel ID.</p>
<pre><code class="language-javascript">function groupKnownChannels(rows) {
  const byId = new Map();
  const unresolved = [];

  for (const row of rows) {
    const id = typeof row.channelId === "string"
      ? row.channelId.trim()
      : "";

    if (!id) {
      unresolved.push(row);
      continue;
    }

    const observations = byId.get(id) ?? [];
    observations.push(row);
    byId.set(id, observations);
  }

  return { groups: [...byId.values()], unresolved };
}
</code></pre>
<p>Consider four synthetic rows. <code>CHANNEL_A</code> and <code>CHANNEL_B</code> are placeholders, not real channel IDs:</p>
<pre><code class="language-javascript">const rows = [
  { channelId: "CHANNEL_A", displayName: "Field Notes", source: "a.csv" },
  { channelId: "CHANNEL_B", displayName: "Field Notes", source: "b.csv" },
  { channelId: "CHANNEL_A", displayName: "Field Notes Studio", source: "c.csv" },
  { channelId: null, observedHandle: "@fieldnotes", source: "d.csv" }
];

const { groups, unresolved } = groupKnownChannels(rows);
// groups.length === 2; unresolved.length === 1
// The CHANNEL_A group retains both source rows.
</code></pre>
<p>With these synthetic rows, the example produces two groups, one unresolved row, and two retained source rows for <code>CHANNEL_A</code>. Local Node assertions confirmed this result; no network requests or real channel data were used.</p>
<p>The shared display name does not merge A with B. The changed label does not split A into two identities. The handle-only row stays unresolved until there is evidence tying it to an ID.</p>
<h2>Choose a representative after grouping</h2>
<p>For a clean shortlist, choose one row to display per confirmed ID, but retain the observations behind it. If labels disagree, show the most recently verified label and keep the older source. If identity evidence conflicts, pause that merge for review.</p>
<p>This also makes corrections practical: you can see which file introduced a row and revise a mistaken match without reconstructing the import. Deduplication becomes an explicit identity decision instead of a destructive operation on names.</p>
<p>I make <a href="https://channelfinder.app/">Channel Finder</a>, a <a href="https://chromewebstore.google.com/detail/channel-finder-for-youtub/dobmmfogicdchmipicnbhgelaogaahjb">desktop Chrome extension</a> for finding channels with similar YouTube content and exporting CSV shortlists. The import pattern here is general; it does not describe the extension's export schema.</p>
]]></content:encoded></item><item><title><![CDATA[Modeling One Thai Tone Rule in TypeScript: Unmarked Live Syllables]]></title><description><![CDATA[มา and ขา are a useful pair for a very small Thai tone-rule exercise. Both use the long vowel า, both are open live syllables, and neither has a written…]]></description><link>https://maeshchenko.hashnode.dev/modeling-one-thai-tone-rule-in-typescript-unmarked-live-syllables</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/modeling-one-thai-tone-rule-in-typescript-unmarked-live-syllables</guid><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 00:20:57 GMT</pubDate><content:encoded><![CDATA[<p>มา and ขา are a useful pair for a very small Thai tone-rule exercise. Both use the long vowel า, both are open live syllables, and neither has a written tone mark. The relevant difference is the initial consonant class: ม is low-class, so มา has a mid tone; ข is high-class, so ขา has a rising tone.</p>
<p>I built Learn Thai Alphabet &amp; Tones, a Thai-script reading course, and this is the kind of rule I want software to represent without implying that one helper can decode every Thai syllable. Consonant class, written tone mark, syllable type, and spoken tone are separate pieces of state. A high-class consonant does not mean the spoken tone itself is high.</p>
<p>For this article, the supported case is only unmarked live syllables. NIU's <a href="https://seasite.niu.edu/thai/tones/tonechart.htm">Thai tone chart</a> gives the mapping used here: with no written tone mark and a live syllable, a high-class initial produces a rising tone, while a mid- or low-class initial produces a mid tone. The chart also distinguishes four written tone marks from five spoken tones in Standard Thai and lists ข as high-class and ม as low-class.</p>
<table>
<thead>
<tr>
<th>Initial class</th>
<th>Tone mark</th>
<th>Syllable type</th>
<th>Spoken tone</th>
</tr>
</thead>
<tbody><tr>
<td>high</td>
<td>none</td>
<td>live</td>
<td>rising</td>
</tr>
<tr>
<td>mid</td>
<td>none</td>
<td>live</td>
<td>mid</td>
</tr>
<tr>
<td>low</td>
<td>none</td>
<td>live</td>
<td>mid</td>
</tr>
</tbody></table>
<p>NIU's <a href="https://seasite.niu.edu/thai/maanii1/lesson12/structureUni.htm">Maanii Lesson 12 structure page</a> places ขา in the high-class, live, unmarked case and identifies า as a long vowel. That makes มา and ขา a useful engineering pair because the long vowel, open live structure, and absence of a tone mark stay the same while the initial class changes.</p>
<p>Here is an original illustrative TypeScript helper. It is not code from the app.</p>
<pre><code class="language-ts">type UnmarkedLiveInput = {
  initialClass: 'high' | 'mid' | 'low';
  toneMark: 'none';
  syllableType: 'live';
};

function toneForUnmarkedLive(
  input: UnmarkedLiveInput
): 'mid' | 'rising' {
  return input.initialClass === 'high' ? 'rising' : 'mid';
}

toneForUnmarkedLive({
  initialClass: 'low',
  toneMark: 'none',
  syllableType: 'live',
}); // มา -&gt; mid

toneForUnmarkedLive({
  initialClass: 'high',
  toneMark: 'none',
  syllableType: 'live',
}); // ขา -&gt; rising
</code></pre>
<p>The conditional is small because the caller has already established the prerequisites. It must already know the consonant class, that there is no written tone mark, and that the syllable is live. This helper does not derive those facts from Thai text.</p>
<p>The string-literal types also describe a compile-time contract, not runtime validation. If arbitrary data reaches this function from JavaScript, JSON, or another unchecked source, TypeScript alone does not prove that the values are valid. A separate boundary would need to validate or construct the input before calling this helper.</p>
<p>The exclusions are therefore narrow but important. This function does not tokenize Thai, recognize consonant classes from characters, handle marked or dead syllables, resolve clusters or leading-consonant patterns, account for loanword exceptions, or assess pronunciation. Those cases require additional rules rather than extra branches hidden inside this example.</p>
<p><a href="https://sabai.misheno.com/">Learn Thai Alphabet &amp; Tones</a> uses a staged reading sequence rather than introducing tone rules at the start. The course has five modules and 90 lessons. It begins with letters and syllable building such as ม + า = มา, then later covers live and dead syllables, consonant classes, and tone rules. It covers 34 consonants, 18 vowel signs, and four tone marks.</p>
<p>The first seven beginner lessons are free. The remaining 83 lessons, including the later advanced tone-rule material, require a monthly or yearly subscription or a one-time lifetime purchase. There are no ads and no account is required. Lessons, audio, and flashcards work offline. The course is available on <a href="https://play.google.com/store/apps/details?id=com.mikhail_eshchenko.thai_fun">Android</a> and <a href="https://apps.apple.com/app/id6759532371">iPhone</a>. It is a reading course, not a complete conversation, grammar, vocabulary, or dictionary course.</p>
]]></content:encoded></item><item><title><![CDATA[Recent median views: what the metric actually says]]></title><description><![CDATA[How recent median video views are calculated, which observations enter the metric, and what the result cannot tell you.]]></description><link>https://maeshchenko.hashnode.dev/recent-median-views-what-the-metric-actually-says</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/recent-median-views-what-the-metric-actually-says</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[youtube]]></category><category><![CDATA[statistics]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sun, 01 Nov 2026 00:00:32 GMT</pubDate><content:encoded><![CDATA[<p>Consider ten hypothetical video view counts:</p>
<p><code>1,000, 1,100, 1,200, 1,300, 1,400, 1,600, 1,700, 1,800, 1,900, 100,000</code></p>
<p>Their mean is 11,300. Their median is 1,500. Both calculations are correct, but they answer different questions. One high-view upload raises the mean far above nine of the ten observations. The median is the middle of this set and is less affected by this one large value than the mean.</p>
<p>That can make a median useful when inspecting a channel. It does not make the result an audience estimate, a forecast, a confidence measure, or evidence that a collaboration will work.</p>
<h2>Start with the observation window</h2>
<p>A metric card should tell you what observations entered the number. In <a href="https://channelfinder.app/">Channel Finder</a>, the Chrome extension I make, recent median views uses up to ten available uploads from a channel's Videos tab, and the sample size is displayed with the metric.</p>
<p>The sample size matters. A result based on two uploads and one based on ten uploads can both have a median of 1,500. Showing only 1.5K would hide that difference. The count is not a confidence score, and ten defines the product's maximum window for this metric.</p>
<p>The window is also defined by uploads, not by a fixed amount of time. In a hypothetical comparison, ten uploads could span a week for one channel and a year for another. A one-day-old upload and a six-month-old upload have had different time to accumulate views. The latest uploads are also not a random sample from some larger population of a channel's videos, so the median should not be interpreted as an estimate with an implied sampling confidence.</p>
<p>Before comparing two cards, inspect the sample sizes, upload dates, and recent titles. The statistic is most useful when you can see whether the underlying sets are reasonably comparable for the task at hand.</p>
<h2>Compute the statistic without inventing observations</h2>
<p>For a sorted sample, the median is the middle observation when the sample size is odd. When the sample size is even, it is the arithmetic mean of the two middle observations. That is the <a href="https://www.itl.nist.gov/div898/handbook/eda/section3/eda351.htm">definition given in NIST's statistics handbook</a>.</p>
<p>Here is a standalone JavaScript teaching example. It summarizes an already selected set of observed counts and does not fetch YouTube data or choose the upload window. It is not a quote of product code.</p>
<pre><code class="language-javascript">function summarizeViews(observedViews) {
  const views = observedViews
    .filter(v =&gt; typeof v === "number" &amp;&amp; Number.isFinite(v) &amp;&amp; v &gt;= 0)
    .sort((a, b) =&gt; a - b);

  const sampleSize = views.length;
  if (sampleSize === 0) return { median: null, sampleSize: 0 };

  const middle = Math.floor(sampleSize / 2);
  const median = sampleSize % 2
    ? views[middle]
    : (views[middle - 1] + views[middle]) / 2;

  return { median, sampleSize };
}
</code></pre>
<p>The example deliberately keeps a genuine zero while excluding missing, nonnumeric, nonfinite, and negative values. That makes the distinction between "observed zero" and "no usable observation" visible:</p>
<table>
<thead>
<tr>
<th>Input</th>
<th align="right">Median</th>
<th align="right">Sample size</th>
</tr>
</thead>
<tbody><tr>
<td><code>[1000, 1100, 1200, 1300, 1400, 1600, 1700, 1800, 1900, 100000]</code></td>
<td align="right">1500</td>
<td align="right">10</td>
</tr>
<tr>
<td><code>[1400, 1600]</code></td>
<td align="right">1500</td>
<td align="right">2</td>
</tr>
<tr>
<td><code>[null, 0, 200]</code></td>
<td align="right">100</td>
<td align="right">2</td>
</tr>
<tr>
<td><code>[null, undefined]</code></td>
<td align="right">missing</td>
<td align="right">0</td>
</tr>
</tbody></table>
<p>Replacing the missing value with zero in the third row would change the median to zero. Dropping the genuine zero would change it to 200. Those are not display differences; they change the observations being summarized. A metric card should preserve that distinction instead of silently converting one case into the other.</p>
<p>The table uses invented inputs for arithmetic illustration, not observations from real channels.</p>
<h2>Keep the interpretation narrower than the number</h2>
<p>Public video views are not a count of distinct people. YouTube defines <a href="https://support.google.com/youtube/answer/9314416?hl=en">unique viewers as an estimated number of viewers in a selected date range</a>, which is a separate metric. A median of public video view counts does not calculate unique viewers and does not reveal private viewer demographics.</p>
<p>The same boundary applies to nearby metadata. A creator-stated country is not the geography of the audience. Recent titles can help you inspect what the channel publishes, but neither a median nor a filter can establish sponsorship fit or predict a partnership outcome.</p>
<p>Use the metric as a screening signal: identify channels worth opening, then inspect the actual videos, dates, subjects, and other relevant public context. The card can reduce a list. It cannot replace the judgment that comes after it.</p>
<p>Channel Finder combines similar-content discovery, public channel stats, filters, local saved shortlists, and CSV export in Chrome's side panel. It is <a href="https://chromewebstore.google.com/detail/channel-finder-for-youtub/dobmmfogicdchmipicnbhgelaogaahjb">available in the Chrome Web Store</a>, currently free during early access with usage limits. No Channel Finder account or YouTube sign-in is required; searches need internet.</p>
]]></content:encoded></item><item><title><![CDATA[How I measured singer identity after pitch shifting]]></title><description><![CDATA[I built the Key Changer Engine because I wanted a desktop Chrome transposer that sounded acceptable to my own ears in both directions. Upward vocals could…]]></description><link>https://maeshchenko.hashnode.dev/how-i-measured-singer-identity-after-pitch-shifting</link><guid isPermaLink="true">https://maeshchenko.hashnode.dev/how-i-measured-singer-identity-after-pitch-shifting</guid><category><![CDATA[Audio Processing]]></category><category><![CDATA[WebAssembly]]></category><dc:creator><![CDATA[Mikhail Eshchenko]]></dc:creator><pubDate>Sat, 31 Oct 2026 23:33:55 GMT</pubDate><content:encoded><![CDATA[<p>I built the Key Changer Engine because I wanted a desktop Chrome transposer that sounded acceptable to my own ears in both directions. Upward vocals could sound too cartoonish to me, while downward shifts could feel heavy or distorted. I did not find an option that satisfied my ears, so I wrote my own proprietary engine.</p>
<p>That motivation is subjective. It is not evidence that other engines fail. To evaluate my own result more carefully, I used a narrower question: after pitch shifting, how similar is the processed vocal to the original vocal according to a speaker-embedding model?</p>
<p>The number that comes out is a proxy, not a listening score.</p>
<h2>Dataset and signal path</h2>
<p>The published test is dated October 1, 2026. I started from 57 Pixabay source tracks and preselected 16 excerpts, each 20 seconds long. Thirteen had a single lead vocal and were used for the speaker metric. Three choral or multi-voice excerpts were excluded from that metric, although all 16 were used for a separate spectral-envelope analysis.</p>
<p>Each engine rendered shifts from -7 through +7 semitones. The 0-shift render remained as a displayed reference but was excluded from the summary, leaving 14 nonzero shifts.</p>
<p>Before speaker analysis, I used Demucs 4.1.0 with htdemucs to separate vocals from both the original audio and every processed output. This matters because the comparison is not performed on pristine isolated stems. It includes a source-separation stage applied to both sides. The <a href="https://github.com/facebookresearch/demucs">original Demucs repository</a> describes the hybrid source-separation model; that repository was archived in 2025.</p>
<p>For speaker embeddings, I used SpeechBrain 1.1.1 with spkrec-ecapa-voxceleb. Its <a href="https://huggingface.co/speechbrain/spkrec-ecapa-voxceleb">model card</a> describes it as a speaker-verification model trained on VoxCeleb1 and VoxCeleb2 and documents cosine comparison between embeddings.</p>
<h2>Averaging windows, excerpts, and shifts</h2>
<p>I computed embeddings on voiced 3-second segments with a 1.5-second step. The windows overlap, so they must not be treated as independent observations that create a large sample size.</p>
<p>For each excerpt and shift, I first averaged cosine similarity across its voiced windows. I then averaged those excerpt-level values across the 13 single-lead excerpts for that shift. Finally, I averaged the 14 nonzero shift means.</p>
<p>That order matters. Overlapping windows stay inside their excerpt instead of being counted as if each were a separate song or singer. The final mean is an aggregate over excerpts and shifts, not a probability, confidence level, p-value, or percentage of "voice identity preserved."</p>
<p>A cosine similarity of .681 does not mean 68.1% of a singer survived the transformation. It is a geometric relationship between embeddings, interpreted within this model, material, and processing chain.</p>
<h2>What the aggregate showed</h2>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Mean cosine similarity</th>
</tr>
</thead>
<tbody><tr>
<td>Key Changer</td>
<td>.681</td>
</tr>
<tr>
<td>Rubber Band R3</td>
<td>.662</td>
</tr>
<tr>
<td>Signalsmith Stretch</td>
<td>.601</td>
</tr>
<tr>
<td>soundtouchjs</td>
<td>.403</td>
</tr>
</tbody></table>
<p>The complete methodology and per-shift table are on the <a href="https://misheno.com/key-changer-engine/">Key Changer Engine page</a>.</p>
<p>These numbers support a limited statement: under this specific test, with this dataset, separation stage, speaker model, shift range, and aggregation, Key Changer had the highest overall mean of the four tested configurations.</p>
<p>They do not mean "best at every shift." Key Changer did not lead every row. At -1 semitone, its mean was .834 versus .847 for Rubber Band R3. At +1, it was .826 versus .836. Those reversals are useful because they stop the aggregate from turning into a universal per-case ranking.</p>
<h2>Configuration is part of the result</h2>
<p>The comparison used Rubber Band R3 v4.0.0 WASM in realtime mode with formant preservation on and stereo together; Signalsmith Stretch 1.3.2 with default formant compensation on and an 8 kHz tonality limit; and soundtouchjs 0.3.0 with its standard 16 ms overlap and no dedicated vocal mode.</p>
<p>Offline renders were produced with Node 24.20.0 on macOS 26.5 at 48 kHz stereo with 128-sample blocks. Outputs were latency-aligned before analysis. These details matter because a benchmark is not only an algorithm name. Version, mode, formant handling, block size, alignment, source material, and analysis all constrain the result.</p>
<p>The shipping Key Changer Engine has a fixed 94 ms engine latency at 48 kHz. That excludes browser, OS, and output latency, and it is separate from the pre-rendered listening demos. The four public 20-second demos were selected after measurement, encoded as 128 kbps MP3 at 48 kHz, and do not represent the complete benchmark dataset. They are for listening, not a latency demonstration or a replacement for the benchmark dataset.</p>
<h2>What I would and would not conclude</h2>
<p>This measurement gives me one structured view of singer identity after pitch shifting. It does not establish perceptual equivalence to the original singer, overall audio quality, naturalness, artifact severity, or listener preference. ECAPA-TDNN was built for speaker verification, not for grading music effects. Demucs can also influence the signals that reach the embedding model.</p>
<p>That is why I keep the numerical claim narrow and the listening question separate. The published page includes the methodology, table, and <a href="https://misheno.com/key-changer-engine/#listen">listening examples</a>, so the embedding result can be inspected alongside actual audio instead of used as a stand-in for it.</p>
<p>Key Changer is the first released application of the engine. It runs in desktop Chrome on audio from websites such as YouTube, Spotify Web, and SoundCloud, with manual shifts from -12 to +12 semitones and -50 to +50 cents, unchanged tempo, and a Keep vocals natural option. It is currently free in early access. The site is <a href="https://keychanger.app/">keychanger.app</a>, and the <a href="https://chromewebstore.google.com/detail/key-changer/fhomhcphchfjgidmefjjbhnkdoblgcpj">Chrome Web Store listing</a> is the installation page.</p>
]]></content:encoded></item></channel></rss>