<?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: lucas white</title>
    <description>The latest articles on DEV Community by lucas white (@lucaswhite).</description>
    <link>https://dev.to/lucaswhite</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%2F4170425%2F004eb07a-ccdf-4154-811d-6b12fa409b58.png</url>
      <title>DEV Community: lucas white</title>
      <link>https://dev.to/lucaswhite</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucaswhite"/>
    <language>en</language>
    <item>
      <title>A 93-word benchmark for AI humanizers: what the top three actually preserved</title>
      <dc:creator>lucas white</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:04:42 +0000</pubDate>
      <link>https://dev.to/lucaswhite/a-93-word-benchmark-for-ai-humanizers-what-the-top-three-actually-preserved-2ema</link>
      <guid>https://dev.to/lucaswhite/a-93-word-benchmark-for-ai-humanizers-what-the-top-three-actually-preserved-2ema</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkcs1kypzub7kkqjnzhg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkcs1kypzub7kkqjnzhg.webp" alt="The Prompt Index homepage captured during the benchmark" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A rewrite can sound cleaner while quietly changing the facts. That is the failure mode I wanted to make visible when building Humanizer.best, an AI humanizer review directory.&lt;/p&gt;

&lt;p&gt;We tested tools with one fixed paragraph, saved the first complete output and compared the result against explicit factual constraints. The three highest editorial scores in this first batch were The Prompt Index, Voilà and MyDetector. All three preserved the main facts in this sample. Their differences were in how much they changed, how easy the changes were to inspect and what their free access actually covered.&lt;/p&gt;

&lt;p&gt;The ranking describes this short case study. It cannot establish universal model quality. I maintain Humanizer.best, and the testing workflow used AI assistance. This article was drafted and edited with AI. Tests and pricing were checked on October 27, 2026. The screenshots are real browser captures; the ratings are editorial judgments. We did not validate detector evasion or call paid APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the paragraph like a test fixture
&lt;/h2&gt;

&lt;p&gt;The input was a fictional, AI-generated report containing 93 words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A local library tested a four-day schedule for six weeks. It kept the same 40 weekly opening hours, closing Wednesdays and extending Monday and Thursday evenings. Visits rose from 1,200 to 1,320 a week, although Wednesday visitors had to change plans. Staff reported fewer interruptions, but productivity was not measured. Longer evenings may help people who work during the day; this does not prove every library should open fewer days. Before making the change permanent, the team will survey residents, compare costs and repeat the trial in winter, when travel habits may differ.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is enough structure here to catch several kinds of semantic drift. A model must preserve the six-week duration, the 40-hour total, the named weekdays and both attendance figures. It must also preserve the distinction between an observation and a measured outcome.&lt;/p&gt;

&lt;p&gt;Fewer interruptions do not establish higher productivity. Longer evenings may help daytime workers; that is not a demonstrated benefit for every library. Comparing costs does not imply that savings have already been found.&lt;/p&gt;

&lt;p&gt;Those qualifications belong in the test fixture alongside the numbers. A rewrite that keeps every number but changes “may” into a confident conclusion can still fail the task.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://humanizer.best/resources/" rel="noopener noreferrer"&gt;downloadable test kit&lt;/a&gt; includes the same paragraph. We stored a SHA-256 hash of the trimmed UTF-8 input so an accidental edit would be detectable before comparing outputs. The source fixture, captured outputs and scoring records are separate files, which makes an unintended change easier to trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep access failures outside the quality score
&lt;/h2&gt;

&lt;p&gt;The discovery set came from the first 30 returned Google organic results for each of three queries: &lt;code&gt;ai humanizer&lt;/code&gt;, &lt;code&gt;humanizer ai&lt;/code&gt; and &lt;code&gt;undetectable ai&lt;/code&gt;. Deduplication produced 35 product domains.&lt;/p&gt;

&lt;p&gt;We obtained 21 complete, comparable outputs. The remaining 14 encountered sign-in, verification, payment or output failures. Those records remain visible but unscored. A login screen is evidence about access, not evidence that the underlying rewrite is bad.&lt;/p&gt;

&lt;p&gt;For completed tests, we retained the first complete captured output in the stated mode. We did not rerun a disappointing result until it looked competitive. Where a technical retry was necessary, the product profile records it.&lt;/p&gt;

&lt;p&gt;If one tool gets five attempts and another gets one, the comparison has uneven generation budgets. Saving the first result makes the evaluation easier to audit, even though it does not measure output variance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the scoring dimensions
&lt;/h2&gt;

&lt;p&gt;The composite score uses six dimensions, each rated from zero to ten:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;What it measures here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Meaning and facts&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;Numbers, schedule, qualifications and unsupported additions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readability&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;Grammar, punctuation and ease of comprehension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural voice&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;Rhythm and phrasing without forced informality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editing support&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;Observed comparison and editing controls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;Completed free access and clarity of limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integrations&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;Verified official humanizer integration documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the calculation, with integer weighting before the final rounding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;overallScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Six scores required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Scores must be between 0 and 10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;overallScore&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt; &lt;span class="c1"&gt;// 8.5: The Prompt Index&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;overallScore&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt; &lt;span class="c1"&gt;// 8.0: Voilà&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;overallScore&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt; &lt;span class="c1"&gt;// 7.9: MyDetector&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decimal makes sorting convenient. It does not make the underlying judgment statistically precise. In particular, a 0.1-point gap should not be read as a proven difference across future inputs.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://humanizer.best/methodology/" rel="noopener noreferrer"&gt;full methodology&lt;/a&gt; explains the evidence threshold. An integration score of zero means that no qualifying official humanizer interface was verified in the reviewed material. It is not proof that no private or future API could exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Prompt Index: useful editing, visible changes
&lt;/h2&gt;

&lt;p&gt;The Prompt Index scored &lt;strong&gt;8.5/10&lt;/strong&gt; in the guest General / Standard configuration, with its “Remove AI fingerprints” option enabled. The full sample completed without registration.&lt;/p&gt;

&lt;p&gt;Its rewrite was restrained. The first sentence stayed unchanged. The weekly total, Wednesday closure, Monday and Thursday evenings and attendance increase all survived. The productivity caveat also remained, expressed in direct language rather than converted into a claim about improved output.&lt;/p&gt;

&lt;p&gt;The most useful interface feature was the &lt;strong&gt;Compare&lt;/strong&gt; tab. Inserted and deleted words had separate visual treatments, making it easier to locate changes before judging whether they were acceptable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba7h8ws33th23qd5ee6g.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba7h8ws33th23qd5ee6g.webp" alt="The Prompt Index input containing the unchanged library benchmark" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7hddl2ejj9521ff0tvv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7hddl2ejj9521ff0tvv.webp" alt="The Prompt Index Compare tab showing inserted and deleted words" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A word diff cannot establish semantic equivalence. It can, however, direct attention to the clauses that need review. That is valuable when the goal is controlled editing rather than maximum lexical change.&lt;/p&gt;

&lt;p&gt;We did not verify selective sentence generation or a separate line-level diff. Those are different capabilities from highlighting changed words, so they were not inferred from the Compare tab.&lt;/p&gt;

&lt;p&gt;The observed guest allowance was five runs per calendar month, with 5,000 characters per run. The free account allowance was listed as ten runs per 30 days and 10,000 characters per run. Paid plans used runs plus per-request character limits, not a simple monthly word bank.&lt;/p&gt;

&lt;p&gt;The first result needed little factual repair. The score advantage over Voilà came mainly from inspection support, not from a claim that heavier rewriting was inherently better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://humanizer.best/reviews/thepromptindex-com/" rel="noopener noreferrer"&gt;Full Prompt Index review&lt;/a&gt; · &lt;a href="https://humanizer.best/tools/thepromptindex-com/" rel="noopener noreferrer"&gt;Screenshots, output and parameter table&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Voilà: a light copy edit that kept the constraints
&lt;/h2&gt;

&lt;p&gt;Voilà scored &lt;strong&gt;8.0/10&lt;/strong&gt; using its free base model, English language and Neutral tone. The observed editor cap was 1,000 characters, which was enough for the unchanged sample.&lt;/p&gt;

&lt;p&gt;The result stayed close to the source. It adjusted punctuation and phrasing while retaining the figures, schedule, productivity limitation and conditional conclusion. It also kept the plan to survey residents, compare costs and repeat the trial in winter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ey2fe9n8emtzc7i7elu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ey2fe9n8emtzc7i7elu.webp" alt="The original paragraph in Voilà's humanizer" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3hzgdwug858c9b517hl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3hzgdwug858c9b517hl.webp" alt="Voilà's first complete rewrite, captured from the tested interface" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I would keep most of this rewrite. The input was already clear, and a light copy edit suited it. A more elaborate rewrite has more opportunities to introduce assumptions that were never present.&lt;/p&gt;

&lt;p&gt;The tradeoff was inspectability. We verified a complete rewrite and a Copy action, but not an inline word diff or a completed selective sentence edit. The source and result still needed to be compared manually.&lt;/p&gt;

&lt;p&gt;Voilà's broader suite pricing uses requests and model-specific conditions. Its suite Free plan listed 250 requests, while Premium listed 3,000 requests per month. Those suite allowances should not be treated as a separately verified renewal policy for the public guest humanizer.&lt;/p&gt;

&lt;p&gt;For integrations, check which direction the API key travels. A suite accepting your provider key does not necessarily expose a humanizer endpoint your application can call. We did not verify a dedicated humanizer API, MCP server or agent skill for this tested workflow.&lt;/p&gt;

&lt;p&gt;For this paragraph, Voilà was effective precisely because it did not try to add a new argument. It gave us a readable version of the same report.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://humanizer.best/reviews/getvoila-ai/" rel="noopener noreferrer"&gt;Full Voilà review&lt;/a&gt; · &lt;a href="https://humanizer.best/tools/getvoila-ai/" rel="noopener noreferrer"&gt;Screenshots, output and parameter table&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. MyDetector: more conversational, with more text to inspect
&lt;/h2&gt;

&lt;p&gt;MyDetector scored &lt;strong&gt;7.9/10&lt;/strong&gt; in Standard / Normal / General Writing, using the Base model and Natural setting. Output language was set to match the input.&lt;/p&gt;

&lt;p&gt;The rewrite broke the report into more short sentences. It retained the 40-hour total, both attendance numbers and the lack of a productivity measurement. The proposed benefit remained conditional, and the ending still called for a survey, cost review and winter repeat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz28esg4nz5snzibyhhk4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz28esg4nz5snzibyhhk4.webp" alt="The fixed input in MyDetector's editor" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9yv7aqa6xlirodcftyez.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9yv7aqa6xlirodcftyez.webp" alt="MyDetector's first Base-model output with the source/result interface" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The draft was easier to read in small pieces, but it also added conversational commentary. The phrase “Of course” before the Wednesday inconvenience did not supply evidence or clarify the schedule. It was a voice choice we would probably remove from a neutral report.&lt;/p&gt;

&lt;p&gt;The saved text contains 131 whitespace-separated words; the interface displayed 133. We keep that distinction rather than pretending every product uses the same tokenization rule. Neither number should be substituted for model tokens or billing credits.&lt;/p&gt;

&lt;p&gt;The interface provided source/result comparison, Copy and Download actions, and clickable sentence controls. Clicking a sentence did not establish a verified replacement in this session. A visible control is not the same thing as a completed editing operation.&lt;/p&gt;

&lt;p&gt;Pricing had a further implementation detail: quotas consumed characters at different rates depending on the model. The official table listed 1,000 characters per quota for the Basic humanizer model and 200 for Pro. That difference matters more than comparing subscription headlines alone.&lt;/p&gt;

&lt;p&gt;The guest sample completed, and the registered Free plan listed 20 quotas per month. Paid Basic and Pro plans showed $6.99 for 1,100 quotas and $14.99 for 3,000. We did not buy them or benchmark their paid output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://humanizer.best/reviews/mydetector-ai/" rel="noopener noreferrer"&gt;Full MyDetector review&lt;/a&gt; · &lt;a href="https://humanizer.best/tools/mydetector-ai/" rel="noopener noreferrer"&gt;Screenshots, output and parameter table&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this suggests for an evaluation harness
&lt;/h2&gt;

&lt;p&gt;For the next version of this evaluation, I would keep the same separation between generation, evidence capture and scoring. Store the original input, configuration, first output, date and screenshots before attaching a score. Otherwise, it becomes too easy to overwrite an inconvenient result while experimenting.&lt;/p&gt;

&lt;p&gt;Some checks can be automated cheaply. Verify that expected numbers and named days appear, flag a large increase in length and record whether a full output was returned. Treat those checks as alerts rather than a semantic pass: a paragraph can contain the right numbers in the wrong relationship.&lt;/p&gt;

&lt;p&gt;The next layer needs an explicit checklist for claims. Did a possibility become a certainty? Did a missing measurement become a positive result? Did a future comparison acquire an assumed outcome? Those are more useful questions than asking whether the rewrite merely “sounds human.”&lt;/p&gt;

&lt;p&gt;For a stronger follow-up study, I would add multiple genres, independently scored outputs and repeated runs with a fixed budget. That would let us estimate consistency and reviewer agreement. This first batch does not provide either, so its ranking should remain a starting point for inspection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result I would use today
&lt;/h2&gt;

&lt;p&gt;For this sample, The Prompt Index offered the best combination of factual preservation and visible change inspection. Voilà was a good example of restrained editing. MyDetector preserved the evidence while producing a more conversational, longer draft.&lt;/p&gt;

&lt;p&gt;Detector bypass and plagiarism clearance were outside this test. When choosing a rewrite for an actual report, I would inspect its claims before judging its stylistic variety.&lt;/p&gt;

&lt;p&gt;The linked profiles preserve the screenshots and complete rewrites, while the separate review articles explain the judgments. Product updates will revise those existing pages rather than create a new article for the same domain. That gives future tests a stable place to show what changed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>humanizer</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building an AI rap video workflow with scene previews and recoverable jobs</title>
      <dc:creator>lucas white</dc:creator>
      <pubDate>Sat, 24 Oct 2026 10:24:25 +0000</pubDate>
      <link>https://dev.to/lucaswhite/building-an-ai-rap-video-workflow-with-scene-previews-and-recoverable-jobs-21l2</link>
      <guid>https://dev.to/lucaswhite/building-an-ai-rap-video-workflow-with-scene-previews-and-recoverable-jobs-21l2</guid>
      <description>&lt;p&gt;A ten-second rap video sounds like a small job: take a portrait, generate a performance, return an MP4.&lt;/p&gt;

&lt;p&gt;A request can time out after the provider has already accepted the job. The video might then finish after the user closes the tab. And before any of that, there is a simpler problem: the generated scene might have the wrong face.&lt;/p&gt;

&lt;p&gt;I'm building RapVideo, a web app that turns one or two portraits into short rap performances. Its main workflow has two generation steps, with a scene review between them. The application uses React and TanStack Start, runs on Cloudflare Workers, and keeps task state in D1. Generated media goes into R2.&lt;/p&gt;

&lt;p&gt;The prompts are only one part of the implementation. Much of the code deals with what happens after a request leaves the server. I've shortened the examples below to keep that logic readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the still image its own step
&lt;/h2&gt;

&lt;p&gt;The user goes through this sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;portrait(s) + scene choices
        ↓
scene image generation
        ↓
user checks the scene
        ↓
video generation with audio
        ↓
stored MP4 + task history
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want the user to inspect the cast and composition before committing to animation. A still image makes that inspection easier. Are both people present? Are their faces distinct? Is the microphone in an awkward position? Those are useful questions to answer before asking a video model to add movement.&lt;/p&gt;

&lt;p&gt;The review is also a spending decision. In the current workflow, the scene uses 10 credits and the video uses 90. Generating another scene is a separate paid attempt. I don't describe the first step as a free preview, because it isn't one.&lt;/p&gt;

&lt;p&gt;An approved scene doesn't guarantee stable faces throughout the video. It gives the user a checkpoint, and it gives the video request a specific starting frame. The app retains that scene while animation is pending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the controls small enough to validate
&lt;/h2&gt;

&lt;p&gt;For the main rap workflow, the choices are deliberately narrow: solo or duo, a studio/street/stage setting, and a friends/birthday/team theme. Solo requires one reference photo; duo requires two. This particular workflow doesn't expose arbitrary lyrics or uploaded audio.&lt;/p&gt;

&lt;p&gt;That makes the request easier to reason about. The browser sends choices and reference identifiers. A versioned server template decides the provider route, prompt, input keys, and credit charge.&lt;/p&gt;

&lt;p&gt;A simplified request looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;templateVersion&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="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scene&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;studio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;friends&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// This template doesn't accept freeform notes.&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;references&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;firstPortraitId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondPortraitId&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;The server validates each choice against the template. It also checks ownership of the referenced assets and signs them for provider access. In this path, uploaded references must be JPEG, PNG, or WebP files under 10 MB.&lt;/p&gt;

&lt;p&gt;I prefer keeping these decisions on the server. An edited browser request should never be able to choose a different model or change the number of credits charged. A disabled dropdown is useful UI, but it doesn't enforce either rule.&lt;/p&gt;

&lt;p&gt;Template versions also stay with the task. When investigating an older result, I want to know which configuration produced it, even if I've changed the default since then.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the scene prompt around visible constraints
&lt;/h2&gt;

&lt;p&gt;Here's a fragment from the duo scene prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exactly TWO adult performers. Reference image 1 stands on the LEFT
and reference image 2 on the RIGHT. Preserve their distinct faces,
hairstyles and outfits; do not blend their identities.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The studio setting adds a recording booth, dark acoustic panels, a suspended microphone, and warm amber lighting. Another part specifies the framing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Face the camera, relaxed confident posture, hands visible,
knees-up framing with comfortable space around the head.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I care more about these instructions than a long string of aesthetic adjectives. They describe things a person can check in the resulting image. Left and right are particularly useful because the video prompt can refer to those same positions later.&lt;/p&gt;

&lt;p&gt;The scene prompt also asks for no extra people, text, or logos. Those are requests to a generative model, so I still expect the output to need review. Writing “exactly two” doesn't turn a model into a cast-count validator.&lt;/p&gt;

&lt;p&gt;That first image is also where I start debugging a bad result. If the scene already has a blended face, changing the video prompt is a poor place to start. Fix the reference or scene first, then inspect the next result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate performance instructions from composition
&lt;/h2&gt;

&lt;p&gt;The second step receives the generated scene as its starting image. The current video template uses Kling 2.6 through Replicate, with a ten-second duration and generated audio enabled. Its provider input includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;start_image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signedSceneUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;generate_audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;compiledPerformancePrompt&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;The performance prompt asks for a steady portrait shot, small rhythmic gestures, and an original hip-hop beat. For a duo, it assigns the two short lines to different positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;From seconds 1 to 4 the LEFT performer raps:
"Good friends together, we bring our own flow."
From seconds 5 to 8 the RIGHT performer replies:
"Side by side shining, wherever we go."
The silent performer nods along.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also asks the model to preserve the number of people and avoid cuts. I want limited movement here because the task already includes faces, hands, vocals, and timing. There isn't much room in ten seconds for an elaborate camera sequence.&lt;/p&gt;

&lt;p&gt;Those timestamps express an intention. They don't give the application an audio timeline with frame-accurate edits. Words, pronunciation, turn-taking, and lip movement can vary. The prompt requests original audio and excludes imitation of a named artist; the workflow doesn't clone a supplied voice.&lt;/p&gt;

&lt;p&gt;For evaluation, I'd watch the faces through the entire clip and listen to the actual words. A convincing first frame is an incomplete test of a video, especially when two people are supposed to take turns.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout leaves an unanswered question
&lt;/h2&gt;

&lt;p&gt;The submission path has an awkward boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app reserves a task
app sends a provider request
provider accepts it
connection drops before the app receives the response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that point, “try again” might create another paid provider job. Treating the timeout as a definite failure could also restore credits while the original job continues running.&lt;/p&gt;

&lt;p&gt;The implementation distinguishes an explicit submission rejection from an unconfirmed outcome. It creates the local task before making the provider call. If acceptance is uncertain, it keeps the task pending for reconciliation instead of immediately submitting again.&lt;/p&gt;

&lt;p&gt;The purchase flow derives the task identity from the user, tool, flow, purchase reference, step, and attempt. Repeating the same attempt returns the existing task. Asking for a new scene is a new attempt, which is a different action from retrying an interrupted HTTP request.&lt;/p&gt;

&lt;p&gt;There is still a limit here. If the app never obtains a provider prediction ID, it may be unable to identify that particular provider job later. A local idempotency key doesn't magically make an external API exactly-once. Keeping an explicit uncertain state is more honest than pretending the network gave a definitive answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve credits and create the task together
&lt;/h2&gt;

&lt;p&gt;Before submission, the application reserves the task and debits credits in one atomic database operation. That operation also checks the user's generation concurrency limit.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;begin atomic operation
  debit credits
  insert pending task
  enforce concurrency limit
commit

submit the reserved task to the provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual implementation includes guards around credit consumption and concurrent reservations. The aim is to avoid a debit with no task to recover, or a task that starts without funding.&lt;/p&gt;

&lt;p&gt;The external provider call happens after that database operation. It can't participate in the same transaction, which is why the pending task and uncertain-submission handling matter.&lt;/p&gt;

&lt;p&gt;Failure settlement needs similar care. A refund restores the balance associated with the original consumption and records an audit event. It must remain safe when another poll or recovery run observes the same failure. A second notification should find the task settled, rather than manufacture another credit grant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let several callers use one recovery function
&lt;/h2&gt;

&lt;p&gt;The browser is useful for showing progress, but the job must survive without it. The code has a shared reconciliation function used by polling, history refresh, the webhook handler, and scheduled recovery.&lt;/p&gt;

&lt;p&gt;Cloudflare runs the scheduled recovery every five minutes. For a pending task, reconciliation examines saved outputs, queries known provider predictions, persists available media, and settles the task when it has enough information.&lt;/p&gt;

&lt;p&gt;Several callers can arrive at once. A short database lease limits concurrent reconciliation of the same task. The lease lasts two minutes, and the outbound work has a shorter, 90-second budget. Settlement checks the lease token so an older worker can't finish later and overwrite a newer recovery decision.&lt;/p&gt;

&lt;p&gt;The webhook handler verifies the signature and timestamp, checks the local prediction mapping, and queries the provider's current state. It doesn't accept an arbitrary output URL from the incoming payload and download it blindly.&lt;/p&gt;

&lt;p&gt;The recovery policy also has a one-hour deadline. When an unresolved provider job reaches that boundary, cancellation can race completion. The code checks the resulting provider state before deciding how to settle it. Otherwise, a video that finishes at the wrong instant could be treated as a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save the output before calling the job done
&lt;/h2&gt;

&lt;p&gt;A provider saying “succeeded” still leaves the application with work to do. The output has to reach storage and remain discoverable from the local task.&lt;/p&gt;

&lt;p&gt;I use stable storage keys based on the task ID, prediction ID, and output index. Each object also carries metadata for those identifiers. Recovery checks for an existing object before downloading the same output again.&lt;/p&gt;

&lt;p&gt;This handles a particularly annoying interruption: the R2 upload succeeds, then the database update fails. On a later run, recovery can find the uploaded object and attach it to the task. It doesn't need to assume that the missing database result means the media disappeared.&lt;/p&gt;

&lt;p&gt;A failed storage check is also different from an empty result. If R2 is temporarily unavailable, the recovery function leaves the operation retryable. It doesn't turn that outage into evidence that no output exists.&lt;/p&gt;

&lt;p&gt;For downloads, the code restricts accepted supplier hosts, rejects redirects, and checks media type and size. A remote URL is still untrusted input, even when it appears in a generation response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the interruptions explicitly
&lt;/h2&gt;

&lt;p&gt;The repository includes recovery tests for repeated callbacks, storage failures, and completion racing cancellation. There is also a test where a video completes after sixteen minutes with no browser present, plus a case that recovers an uploaded object after the provider output is no longer available.&lt;/p&gt;

&lt;p&gt;These controlled tests describe the behavior I expect; they don't give me a production reliability percentage. I can change a refund branch and check whether it now restores credits twice, or change storage handling and see whether an already-uploaded result gets lost.&lt;/p&gt;

&lt;p&gt;Visual quality needs a separate check. State-machine tests can't tell me whether a face drifts or whether the second performer delivers the right line. I need both kinds of review before calling the workflow useful.&lt;/p&gt;

&lt;p&gt;I still like having the scene checkpoint. It gives the person using the app a place to make a decision, and it gives the backend two understandable jobs to track. When a clip goes wrong, I want to be able to tell whether I should change the scene, adjust the performance prompt, or investigate a delivery failure.&lt;/p&gt;

&lt;p&gt;If you'd like to see how that looks in a working interface, I'm building it at &lt;a href="https://rapvideo.cc/" rel="noopener noreferrer"&gt;RapVideo&lt;/a&gt;. Start with the scene, check the faces, and listen to the whole clip before deciding whether to keep it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Rain Dance AI: turn one or two portraits into a 15-second sunset video</title>
      <dc:creator>lucas white</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:45:15 +0000</pubDate>
      <link>https://dev.to/lucaswhite/rain-dance-ai-turn-one-or-two-portraits-into-a-15-second-sunset-video-53c0</link>
      <guid>https://dev.to/lucaswhite/rain-dance-ai-turn-one-or-two-portraits-into-a-15-second-sunset-video-53c0</guid>
      <description>&lt;p&gt;I built Rain Dance AI to turn one or two portraits into a 15-second video set on a wooden pier at sunset. You choose who appears. The site supplies the scene: a singer facing the camera, a partner waiting further along the pier, and a closing moment together toward the sunset.&lt;/p&gt;

&lt;p&gt;The output is a vertical 9:16 MP4 at 768p. Solo and Duet are both 15 seconds. There is no prompt box, and new orders don't require an account.&lt;/p&gt;

&lt;p&gt;That sounds straightforward until someone has to decide whether to pay for a video they haven't seen. A sample can show the setting and motion, but it can't tell them how their own face will look. A personal preview helps with that question, though it introduces a different problem: people may reasonably assume the paid video will continue where the preview stops.&lt;/p&gt;

&lt;p&gt;The four-second preview and the paid video are separate generations. That needs to be obvious on the page, especially when someone likes their preview enough to buy a pack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing who appears in the scene
&lt;/h2&gt;

&lt;p&gt;The first choice is Solo or Duet. I prefer describing the jobs of the two characters because "person one" and "person two" leave too much to guess.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What you upload&lt;/th&gt;
&lt;th&gt;What each person does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Solo&lt;/td&gt;
&lt;td&gt;One adult portrait&lt;/td&gt;
&lt;td&gt;Your photo supplies the singer; the other character is a fictional adult partner.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duet&lt;/td&gt;
&lt;td&gt;Two separate adult portraits&lt;/td&gt;
&lt;td&gt;The first photo supplies the opening singer. The second supplies the partner waiting on the pier.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are story roles, not gender labels. If you want the other person to lead the scene, change which portrait goes into the singer's slot. You don't need a photograph of both people standing together.&lt;/p&gt;

&lt;p&gt;The distinction matters before upload. Someone choosing Solo might otherwise expect a video containing only one person. Here, Solo means personalizing one character in a scene that still includes a partner. Duet gives you a portrait input for both characters.&lt;/p&gt;

&lt;p&gt;I kept the setting fixed because it makes the choice easier to explain. Someone who wants a different location or a custom sequence will need a different workflow. This page is for a particular scene, and the sample should make that scene clear before anyone pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking photos that give you a useful starting point
&lt;/h2&gt;

&lt;p&gt;Each upload accepts JPEG, PNG or WebP, up to 10 MB. Use one consenting adult per image. A sharp, front-facing portrait in even light is the simplest starting point; keep the whole head in frame and avoid sunglasses or heavy beauty filters.&lt;/p&gt;

&lt;p&gt;Clothing deserves a moment of attention too. A close crop of a face provides little information about the rest of the outfit. If the clothes matter to the result, a waist-up or full-body photograph gives you more of that information in the input. It still doesn't guarantee an exact match. A jacket can change, a sleeve can move oddly, or details can disappear as the character turns.&lt;/p&gt;

&lt;p&gt;For Duet, check each image on its own. One clear portrait doesn't compensate for a second portrait where the face is tiny or hidden. Before continuing, make sure each face is recognizable and belongs in the intended role.&lt;/p&gt;

&lt;p&gt;This is also a useful moment to decide what would make the video disappointing. If a particular shirt or an exact facial detail is essential, the possibility of variation matters more than it would for a casual experiment. I would rather spell that out on the upload screen than leave someone to discover it after payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What four seconds can tell you
&lt;/h2&gt;

&lt;p&gt;The free preview covers the opening of the scene. It gives you a moving result with your input, which is more useful for judging appearance than a still thumbnail. You can look at the face as it moves and see whether the general treatment appeals to you.&lt;/p&gt;

&lt;p&gt;Four seconds cannot show how a 15-second clip holds together. It won't demonstrate the whole approach to the partner or the closing sunset moment. Even a convincing opening leaves most of the video unexamined.&lt;/p&gt;

&lt;p&gt;There is another limit: buying a video doesn't extend the preview. The paid result is generated separately and can differ in facial appearance, clothing and movement. A preview you like is a reason to consider the format, not a promise that the next result will reproduce it.&lt;/p&gt;

&lt;p&gt;The free allowance has limits as well. A workspace saved in your browser can use one four-second preview. There is also a limit of one per network per day, plus shared daily availability. The shared daily limit resets at midnight in Asia/Shanghai; the used allowance in that browser workspace does not reset. The page shows whether a preview is currently available. Allow about 15 minutes or longer rather than expecting an immediate playback.&lt;/p&gt;

&lt;p&gt;I included the preview because somebody else's sample leaves you guessing about your own portrait. But I also want the explanation beside it to be hard to miss. A visitor shouldn't have to dig through a help page to learn that the paid clip will be a fresh attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching the full example
&lt;/h2&gt;

&lt;p&gt;The site also has a complete 15-second example made from fictional adult portraits, with the input portrait shown beside the result. It gives visitors a way to compare the starting image with what appears on screen.&lt;/p&gt;

&lt;p&gt;Watch the whole clip once at normal speed. Then replay the parts where a face turns or the camera changes its view. A face that looks convincing in one frame may drift later. Check whether the outfit remains recognizable and whether the partner is present when the scene calls for them. Hands and transitions are worth another look if either draws your attention during playback.&lt;/p&gt;

&lt;p&gt;The page describes the intended sequence in approximate five-second sections: the singer on the pier, the approach to the partner, and the ending together toward sunset. Those timings help explain the story. They aren't an exact shot schedule promised for every result.&lt;/p&gt;

&lt;p&gt;I'd watch the complete sample even after trying a personal preview. The ending is part of what you're buying, and a four-second result doesn't show it. One sample also doesn't establish how often future videos will turn out well; it gives you one full result to inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sound and the finished file
&lt;/h2&gt;

&lt;p&gt;The download is a vertical MP4, and the site doesn't add a watermark. Sound may be generated with the video. The sample's audio is generated sound, not the original Raindance recording, and the purchase doesn't include that recording or guarantee exact lip-sync.&lt;/p&gt;

&lt;p&gt;That matters if the music is the main reason you want the clip. Someone expecting a finished edit with the familiar song already attached would be buying a different thing from what this site supplies.&lt;/p&gt;

&lt;p&gt;After downloading, you can add audio from a TikTok or Instagram library where its use is permitted. Check the rules for your account and intended post. Choosing a song afterward also doesn't repair a mouth movement that fails to match it. Listen to the clip and watch the mouth before deciding how to share it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment, delivery and coming back later
&lt;/h2&gt;

&lt;p&gt;The current USD packs are $9.90 for one video, $24.90 for three, and $49.90 for ten. They are one-time purchases with no subscription or automatic renewal. Unused video allowances don't expire.&lt;/p&gt;

&lt;p&gt;The ten-video pack reduces the price per attempt to $4.99, but it doesn't make any individual attempt more predictable. If you're deciding whether the format suits you, look at the example and the available preview before comparing pack sizes.&lt;/p&gt;

&lt;p&gt;New orders use a delivery email and a private order link instead of an account. That link gives access to the order's videos and remaining uses. You can return to check progress and download completed files; you don't have to keep the original browser tab open throughout generation.&lt;/p&gt;

&lt;p&gt;The email address therefore needs the same care that a login would usually get. Check it before checkout and keep the order message. If you lose the message, the site's "Find my videos" form can email links for your five most recent paid guest orders. Support handles older orders or previous accounts.&lt;/p&gt;

&lt;p&gt;Keep the private order link out of social posts. It grants access to the order and its remaining uses. Share the downloaded video rather than copying the page address from your own order screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  A failed generation and a disappointing video need different handling
&lt;/h2&gt;

&lt;p&gt;A confirmed technical failure restores the allowance used for that attempt. It doesn't automatically send money back to your payment method. Cash refunds require review.&lt;/p&gt;

&lt;p&gt;A completed video can still disappoint. You might dislike the expression or notice that the clothing changed. Another generation uses another allowance, and choosing a different photo doesn't guarantee an improvement.&lt;/p&gt;

&lt;p&gt;For a serious quality problem, such as the wrong identity, a missing Duet partner or severe visual corruption, contact support with the receipt reference, the affected timestamp and a screenshot. Those details make the problem easier to locate than a general description of a bad result. Quality complaints receive manual review; a retry or refund isn't automatic.&lt;/p&gt;

&lt;p&gt;If a request is still processing or under review, keep that request and check its status. Starting another one while the first is unresolved can leave you with two separate attempts to track.&lt;/p&gt;

&lt;p&gt;Uploaded photos are retained in our storage for seven days, with scheduled cleanup removing expired files. An interruption can delay cleanup. External processing has its own retention terms, so our seven-day rule isn't a promise about every copy outside our storage. Completed private videos have no automatic expiry, and you can request deletion through support. Download the files you want to keep instead of treating an online order page as your only copy.&lt;/p&gt;

&lt;p&gt;Rain Dance AI is an independent project, unaffiliated with the artists behind the song. I want the page to give someone enough information to decide against buying, too. If the preview leaves them needing a guarantee of exact likeness, adding a larger pack won't resolve that concern.&lt;/p&gt;

&lt;p&gt;What would you need beside a four-second preview to judge whether a separately generated 15-second video is worth buying?&lt;/p&gt;

&lt;p&gt;You can see the current flow and playable example at &lt;a href="https://raindanceai.me/" rel="noopener noreferrer"&gt;Rain Dance AI&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ux</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ozil Goal AI: turn one portrait into a 15-second football video</title>
      <dc:creator>lucas white</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:41:39 +0000</pubDate>
      <link>https://dev.to/lucaswhite/ozil-goal-ai-turn-one-portrait-into-a-15-second-football-video-3ed</link>
      <guid>https://dev.to/lucaswhite/ozil-goal-ai-turn-one-portrait-into-a-15-second-football-video-3ed</guid>
      <description>&lt;p&gt;I built Ozil Goal AI to turn a single adult portrait into a short football goal video. You supply the person and their outfit. The app places them in a football scene and produces a 15-second vertical MP4 with generated sound.&lt;/p&gt;

&lt;p&gt;The idea is easy to describe. Explaining what someone is buying takes more care. A still image can look convincing while the next second contains a strange foot movement or a ball that disappears. A download button can exist somewhere on a page and still be hard to find. Removing registration saves a step, but then the delivery email has to carry much of the job an account normally does.&lt;/p&gt;

&lt;p&gt;The current flow uses one photo, a fixed scene and a private order link. There is no prompt editor. I wanted someone who likes the example to be able to understand the purchase without learning how to direct an AI video.&lt;/p&gt;

&lt;h2&gt;
  
  
  The photo does more than supply a face
&lt;/h2&gt;

&lt;p&gt;The input is one clear photo of a consenting adult. The app accepts JPEG, PNG and WebP, with a 10 MB limit. The person should be easy to distinguish from the background, and their face should be visible. If a particular outfit is part of the idea, it needs to appear in the photo too.&lt;/p&gt;

&lt;p&gt;An office outfit on a football pitch is part of the joke. Keeping the clothes from the photo gives the scene some of its personality. The page includes complete examples made with fictional adults in different clothes, so visitors can compare a source photo with the resulting video. Those examples are product demonstrations, not customer testimonials.&lt;/p&gt;

&lt;p&gt;There is no separate shirt selector, second-person goalkeeper upload or resolution menu in this flow. That matters when choosing a photo: the photo is the main creative input. Someone who needs a specific team kit or a friend cast as the goalkeeper should know before paying that this version does not provide those controls.&lt;/p&gt;

&lt;p&gt;Selecting a photo first shows a local preview. Continuing uploads it for processing and opens the package and email step. The button now says "Continue — choose your pack." Calling that button "Generate my video" made the next screen less predictable, because generation does not start at that point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching the whole example should be part of buying
&lt;/h2&gt;

&lt;p&gt;The output is 15 seconds, 720p, vertical 9:16 and MP4, with generated sound. Those specifications are useful, but they do not tell you whether the action holds together.&lt;/p&gt;

&lt;p&gt;The public example is free to play without signing in. It uses a fictional adult portrait and shows a complete generated result. I also put five moments from the video beside a replay checklist. A visitor can use those moments to check the action instead of stopping at the first attractive frame.&lt;/p&gt;

&lt;p&gt;Start with the person. Does the face remain recognizable when the head turns? Does the outfit stay reasonably consistent? Then follow the ball through the sequence. It should be possible to understand where it came from and what contact changed its direction. Watch the goalkeeper and defenders too. A good-looking main character does not make a collision or an unexplained change of position less distracting.&lt;/p&gt;

&lt;p&gt;The finish deserves particular attention. In the current sample, the ball briefly leaves the lower edge of the frame as the camera follows the shot toward the goal. The full crossing of the goal line is not visible. The page says this. I cannot use a frame of the ball near the net as proof that the entire entry was shown correctly.&lt;/p&gt;

&lt;p&gt;Sound also needs a full playthrough. A clip can have an audio track while a kick lands at the wrong moment or the crowd response feels disconnected from the action. The presence of sound and the quality of its timing are separate things to judge.&lt;/p&gt;

&lt;p&gt;Another photo can change the result, including facial detail and clothing. Watching the sample helps you decide which imperfections would bother you. It cannot tell you how often they will occur across other orders, and I do not present it as a measured success rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I kept new orders out of an account dashboard
&lt;/h2&gt;

&lt;p&gt;New orders do not require an account. After reviewing the photo, the buyer chooses a pack and enters a delivery email twice. Payment confirmation starts the first video. Emails provide access to the order and its result, including any unused videos in that order.&lt;/p&gt;

&lt;p&gt;For someone making a short clip, creating an account felt like an extra task before they had received anything. Email delivery keeps the purchase closer to the way people expect a small digital order to work. They can leave the page while the video is processing and return through the email later.&lt;/p&gt;

&lt;p&gt;A mistyped email address matters more when it is the route back to the purchase. The confirmation field catches mismatches, although it cannot tell whether someone typed the same wrong address twice. The buyer still needs to check it before continuing to payment.&lt;/p&gt;

&lt;p&gt;The order link also needs a clear explanation. It opens a private order and can provide access to remaining videos. It should be kept out of a group chat or a public post. When someone wants to show friends the finished clip, sharing the downloaded MP4 is the straightforward option.&lt;/p&gt;

&lt;p&gt;If the email is lost, the site has a "Find my order" entry with recovery instructions. Recovery may require contacting support with a payment receipt or order reference. I do not want a missing email to make someone assume they need to buy the same pack again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the file should feel like the job is finished
&lt;/h2&gt;

&lt;p&gt;An earlier completion email had a link that took the recipient back to the website. That technically gave them a way toward the result, but the download action was not obvious enough once they arrived. The feedback was direct: the user had received an email saying the video was ready and still could not find where to save it.&lt;/p&gt;

&lt;p&gt;The completion email now separates watching the result from downloading the MP4. The result page has a visible download action, and it explains how to save the file before editing or sharing it. Someone clicking "Download" should get a file, without having to search through a creation form.&lt;/p&gt;

&lt;p&gt;I also keep photo retention separate from video retention in the email. Saying "your file is kept" without naming the file is too vague when the same order contains both a personal photo and a generated clip.&lt;/p&gt;

&lt;p&gt;Once the MP4 is on the device, the user can open it in an editor, add a caption or upload it to a social app. The exact sharing steps depend on the browser and destination app. I would rather give a clear download path than imply that every social platform accepts a video through the same button.&lt;/p&gt;

&lt;p&gt;Before posting, watch the saved file. Check any face changes or awkward movements at the size people will see on their phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the payment buys, including the awkward cases
&lt;/h2&gt;

&lt;p&gt;The current USD packs are $9.90 for one video, $18.90 for two and $45.90 for five. These are one-time purchases without a subscription or automatic renewal. Unused videos in new orders have no automatic expiry. The amount and currency shown at checkout are the ones to review before paying.&lt;/p&gt;

&lt;p&gt;Watching the examples is free. Generating a video with your own photo is paid; this flow does not include a free generation allowance.&lt;/p&gt;

&lt;p&gt;Each confirmed technical failure restores the count used by that attempt to its original order. If a later attempt also fails technically, its count is restored too. The wording needs to make that clear rather than leave "restored once" open to interpretation as a limit of one restoration per order.&lt;/p&gt;

&lt;p&gt;A completed video with visual imperfections is a different case. It still uses a count, even if the buyer decides not to share it. Another attempt uses another count. Returning a generation count is also separate from returning money; billing and cash-refund requests go through support and the refund policy.&lt;/p&gt;

&lt;p&gt;If a task enters review, the buyer should check the existing order instead of paying or submitting again. The published rule is that a task still unresolved 24 hours after entering review is closed and its used count restored. Service interruptions can delay that handling. If the count is still missing, support can investigate using the receipt. This is a deadline for resolving an uncertain task, not a promise that every video finishes within a particular number of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A photo and a finished video have different lifetimes
&lt;/h2&gt;

&lt;p&gt;Uploaded photos are automatically deleted after seven days. Finished videos have no automatic expiry unless deletion is requested. The site provides a way to contact support about deletion, and I still recommend saving a local copy of a video someone wants to keep.&lt;/p&gt;

&lt;p&gt;These two rules appear together because "no automatic expiry" can otherwise sound as though it applies to the uploaded face photo as well. It does not. That distinction belongs in the buying flow and delivery email, where someone is likely to read it, rather than only in a policy page.&lt;/p&gt;

&lt;p&gt;Use photos you have permission to use, and keep the project within the adult-photo requirement. Ozil Goal AI is an independent project, unaffiliated with any player, club or competition. The generated clips are fictional scenes, not footage of someone taking part in a real match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I still want feedback on
&lt;/h2&gt;

&lt;p&gt;The changes I keep coming back to are small: renaming the button before checkout, asking for the email twice, putting an actual download link in the completion email. They address specific places where someone could misunderstand the next step. I do not have conversion data that proves which change matters most.&lt;/p&gt;

&lt;p&gt;I am less certain about how much sample analysis belongs before checkout. A long checklist can make a lighthearted product feel like homework. A looping clip without any explanation makes it too easy to overlook the part that breaks the illusion. For now, the page pairs the full video with a few moments to inspect.&lt;/p&gt;

&lt;p&gt;For a paid creative tool, would that combination give you enough information to decide, or would you want a different kind of preview before buying?&lt;/p&gt;

&lt;p&gt;You can see the current flow at &lt;a href="https://ozilgoalai.app/" rel="noopener noreferrer"&gt;Ozil Goal AI&lt;/a&gt;, or go directly to the &lt;a href="https://ozilgoalai.app/watch/ozil-goal-ai-example" rel="noopener noreferrer"&gt;complete football video example&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>buildinpublic</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Humming to MIDI in the Browser with Basic Pitch and Web Workers</title>
      <dc:creator>lucas white</dc:creator>
      <pubDate>Thu, 08 Oct 2026 06:54:20 +0000</pubDate>
      <link>https://dev.to/lucaswhite/humming-to-midi-in-the-browser-with-basic-pitch-and-web-workers-eji</link>
      <guid>https://dev.to/lucaswhite/humming-to-midi-in-the-browser-with-basic-pitch-and-web-workers-eji</guid>
      <description>&lt;p&gt;You can turn a short hummed recording into MIDI in the browser with Spotify's Basic Pitch. Decode the audio, resample it to mono at 22,050 Hz, run the model, then encode its detected notes. The result needs a listen and usually some editing.&lt;/p&gt;

&lt;p&gt;I build SoundToMIDI around that workflow. One design decision turned out to matter as much as the model: moving inference into a Web Worker so the interface could respond while a conversion was running.&lt;/p&gt;

&lt;p&gt;The use case is familiar. In &lt;a href="https://www.reddit.com/r/hardstyle/comments/1jsvpvx/how_do_you_translate_a_humming_melody_into_midi/" rel="noopener noreferrer"&gt;this r/hardstyle thread&lt;/a&gt;, a producer describes recording a melody, converting it, then spending too long correcting the notes. That is the job here: get an idea into a DAW without entering every note by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  From recording to note events
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Local file
  → Web Audio decoding
  → mono PCM at 22,050 Hz
  → Basic Pitch inference
  → onset/frame post-processing
  → note events in seconds
  → MIDI encoding
  → piano-roll inspection and download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/spotify/basic-pitch" rel="noopener noreferrer"&gt;Basic Pitch&lt;/a&gt; supports polyphonic notes, though Spotify recommends one instrument at a time. Start with a solo hum. MIDI records note events; it won't preserve the sound of your voice.&lt;/p&gt;

&lt;p&gt;Let &lt;code&gt;AudioContext&lt;/code&gt; decode at the device's supported rate, then use a mono &lt;code&gt;OfflineAudioContext&lt;/code&gt; to prepare the model input. Forcing the decoding context to 22,050 Hz can cause problems on some devices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeAndResample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeAudioData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22050&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OfflineAudioContext&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBufferSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;offline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startRendering&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChannelData&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This applies Web Audio's channel-mixing rules. Stereo phase cancellation can affect the mono result. A mono recording avoids that surprise. File extensions can be misleading too: a supported container may hold a codec the browser cannot decode.&lt;/p&gt;

&lt;p&gt;For a prototype in a browser project with a bundler such as Vite, install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @spotify/basic-pitch@1.0.1 @tonejs/midi@2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the model assets from &lt;a href="https://github.com/spotify/basic-pitch-ts" rel="noopener noreferrer"&gt;basic-pitch-ts&lt;/a&gt; into &lt;code&gt;public/model/&lt;/code&gt;, preserving the paths between &lt;code&gt;model.json&lt;/code&gt; and its weight shards. Add this to the same module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;BasicPitch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;outputToNotesPoly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;noteFramesToTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@spotify/basic-pitch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Midi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tonejs/midi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BasicPitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/model/model.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fileToMidi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt; &lt;span class="o"&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="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pcm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;decodeAndResample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onsets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;onsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minFrames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.130&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;22050&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;noteFramesToTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;outputToNotesPoly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onsets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minFrames&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No pitched notes detected.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;midi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Midi&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTempo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Transcribed melody&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNote&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pitchMidi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTimeSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amplitude&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;events&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;Pass a &lt;code&gt;File&lt;/code&gt; from an &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt; to &lt;code&gt;fileToMidi&lt;/code&gt;. Turn its bytes into an &lt;code&gt;audio/midi&lt;/code&gt; Blob for download, and revoke the object URL when finished.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;0.5&lt;/code&gt; onset threshold controls note starts; &lt;code&gt;0.3&lt;/code&gt; controls sustained pitch activity. The minimum duration is in frames, so 130 ms becomes roughly 11 at &lt;code&gt;22050 / 256&lt;/code&gt; frames per second. Raising these controls may remove breath-triggered blips, but can also lose quiet or short intended notes. Change one setting and compare the same phrase.&lt;/p&gt;

&lt;p&gt;This prototype runs on the main thread. It is enough to inspect a short clip, but the application needs a different execution boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an async conversion can still freeze Cancel
&lt;/h2&gt;

&lt;p&gt;Awaiting a Promise doesn't move synchronous inference kernels off the main thread. SoundToMIDI keeps decoding and native resampling outside the Worker, and puts TensorFlow, inference, note extraction, and MIDI encoding inside it.&lt;/p&gt;

&lt;p&gt;The boundary passes PCM and settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./transcription-worker.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After a separate model-load request has completed:&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transcribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mono&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onsetThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;frameThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&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="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&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;Transferring &lt;code&gt;pcm.buffer&lt;/code&gt; avoids a copy and detaches it from the sender. Keep the decoded audio if the user may retry.&lt;/p&gt;

&lt;p&gt;Cancel terminates the Worker and rejects pending requests. Retry creates a new one, which must load the model again. A cancellation flag alone can't interrupt a kernel that hasn't returned.&lt;/p&gt;

&lt;p&gt;There is a race to test here: cancel, retry immediately, then receive a late response from the old Worker. Check the Worker instance as well as the request ID before accepting a response. An obsolete result must not complete the new request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory bill follows the recording
&lt;/h2&gt;

&lt;p&gt;Basic Pitch 1.0.1 uses overlapping windows. The application's inference loop processes one window at a time and releases its temporary tensors after reading the values. Leading padding and overlap trimming must match the reference implementation, or window boundaries can alter the output.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;tf.tidy&lt;/code&gt; for synchronous tensor work, then dispose returned tensors in &lt;code&gt;finally&lt;/code&gt; after awaiting their arrays. An async callback inside &lt;code&gt;tf.tidy&lt;/code&gt; won't manage tensor lifetime across an await.&lt;/p&gt;

&lt;p&gt;The decoded audio and accumulated frame outputs still grow with clip length. Windowing doesn't make this a constant-memory streaming system. I prefer short selections for this workflow.&lt;/p&gt;

&lt;p&gt;The model runs locally, but the browser downloads code and weights. Local audio processing doesn't make the page network-free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight intended notes can become nine
&lt;/h2&gt;

&lt;p&gt;On October 8, 2026, we reran the published synthetic fixtures in desktop Chrome using the live converter. These are eight-second WAVs at 22,050 Hz. Settings were onset 0.5, frame 0.3, minimum length 130 ms, tempo 120 BPM, and pitch bends off:&lt;/p&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;Intended pitched events&lt;/th&gt;
&lt;th&gt;Raw output events&lt;/th&gt;
&lt;th&gt;Extra events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Isolated melody&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chords with noise percussion&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unpitched noise percussion&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every pitched source event had a same-pitch onset match within 150 ms, used once. We didn't score duration or expression. These synthetic fixtures cannot establish accuracy on humming. The noise-only clip produced no downloadable MIDI.&lt;/p&gt;

&lt;p&gt;The melody's extra D5 was an octave above a source D4. Harmonic confusion is a possible explanation; the octave relationship doesn't prove the cause. The &lt;a href="https://soundtomidi.com/blog/audio-to-midi-conversion-examples/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=browser_humming_midi&amp;amp;utm_content=test_fixtures" rel="noopener noreferrer"&gt;source audio and original September 28 results&lt;/a&gt; are public. That earlier melody run produced ten events, which is another reason to record the test date and settings.&lt;/p&gt;

&lt;p&gt;Check repeated notes and phrase endings when you try your own recording. Also remember that writing 120 BPM into a MIDI header doesn't detect the recording's beat grid. This code writes notes in seconds against a chosen tempo map.&lt;/p&gt;

&lt;p&gt;I leave pitch bends out of the prototype. Ordinary MIDI bends affect a channel, so overlapping notes can't all bend independently on a single channel. Cleanup rules can shorten overlaps or remove blips, but they don't know the intended melody. Keep the raw events available.&lt;/p&gt;

&lt;p&gt;Record 10–20 seconds without backing music, leave gaps between repeated notes, and &lt;a href="https://soundtomidi.com/humming-to-midi/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=browser_humming_midi&amp;amp;utm_content=try_humming#tool" rel="noopener noreferrer"&gt;try the humming-to-MIDI workflow&lt;/a&gt;. Raw MIDI downloads are free; optional paid exports are separate. Listen beside the piano roll before importing it into a DAW. That last check catches errors that a successful download won't.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/spotify/basic-pitch" rel="noopener noreferrer"&gt;Spotify Basic Pitch: model, scope, and limitations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/spotify/basic-pitch-ts" rel="noopener noreferrer"&gt;Basic Pitch TypeScript implementation and model assets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects" rel="noopener noreferrer"&gt;Web Workers and transferable objects&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://js.tensorflow.org/api/latest/#tidy" rel="noopener noreferrer"&gt;TensorFlow.js: tensor management&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>machinelearning</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
