<?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: Kenji</title>
    <description>The latest articles on DEV Community by Kenji (@kenjiginjo).</description>
    <link>https://dev.to/kenjiginjo</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%2F1069530%2F2c786d50-bf19-4487-be88-7bbe5fbc5802.png</url>
      <title>DEV Community: Kenji</title>
      <link>https://dev.to/kenjiginjo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenjiginjo"/>
    <language>en</language>
    <item>
      <title>A passing pronunciation score still hid the broken phoneme</title>
      <dc:creator>Kenji</dc:creator>
      <pubDate>Mon, 19 Oct 2026 06:27:55 +0000</pubDate>
      <link>https://dev.to/kenjiginjo/a-passing-pronunciation-score-still-hid-the-broken-phoneme-2e3g</link>
      <guid>https://dev.to/kenjiginjo/a-passing-pronunciation-score-still-hid-the-broken-phoneme-2e3g</guid>
      <description>&lt;p&gt;Azure Speech will give a spoken word one number, PronScore. For a Chinese speaker learning American English, that number is the wrong thing to teach with. Mandarin does not have /θ/, /ð/, /æ/, or the American flap. The word can score in the 80s while one of those phonemes is the actual failure.&lt;/p&gt;

&lt;p&gt;I built Vocalace around that. The screen shows one word. The pass/fail decision and the red mark are two different scores, on purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.vocalace.com" rel="noopener noreferrer"&gt;https://www.vocalace.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the assessment payload actually contains
&lt;/h2&gt;

&lt;p&gt;A successful response has RecognitionStatus: "Success" and an NBest array. I only read NBest[0]. The word score is PronScore, and I floor it. If the field is missing, the attempt scores 0. I do not average the alternatives.&lt;/p&gt;

&lt;p&gt;Inside that first hypothesis, each word has a Phonemes array. Each phoneme has its own AccuracyScore. That list is what I can mark. PronScore is what I can use to decide whether the attempt counts.&lt;/p&gt;

&lt;p&gt;The JSON is treated as unknown and parsed field by field. A missing Words array, a phoneme with no AccuracyScore, or a non-numeric score is skipped. One bad node does not throw away the rest of the attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two thresholds
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpyl3klk3oq3b2g60fwcl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpyl3klk3oq3b2g60fwcl.png" alt="A failed take. The word scores 10, under the pass line of 90. The phonemes stay on the card." width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A phoneme is marked when its AccuracyScore is under 60. The attempt passes only when the floored PronScore is at least 90.&lt;/p&gt;

&lt;p&gt;These are not two views of the same cutoff. A word can pass 90 and still contain a phoneme under 60, because PronScore is a whole-word score. A word can also fail 90 with no marked phoneme, when several phonemes are merely mediocre. Marking every phoneme below 90 would light up the whole transcription. Using 90 as the mark threshold and the pass threshold would make those two failures look the same.&lt;/p&gt;

&lt;p&gt;So the UI shows the sounds that were clearly off. The scheduler, not the red marks, decides whether the word is learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attempt does not master a word
&lt;/h2&gt;

&lt;p&gt;Passing once is not mastery. The states are unseen, confirming, and mastered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unseen and a score of at least 90 moves the word to confirming.&lt;/li&gt;
&lt;li&gt;confirming and another score of at least 90 moves it to mastered.&lt;/li&gt;
&lt;li&gt;a miss stays where it is and comes back tomorrow. The word is not downgraded.&lt;/li&gt;
&lt;li&gt;a miss after mastered steps back to confirming and also comes back tomorrow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next review is never later the same day. The earliest nextReviewAt is tomorrow, so two passes cannot be recorded from two takes in one sitting. After mastered, the gaps are 21 days, then 90, then 365. A further pass stays at 365.&lt;/p&gt;

&lt;p&gt;Due reviews are selected before any new word. New words come from the top 8,000 in BNC/COCA, more frequent bands first. If nothing is due and no new word is left, the client is told how many days until the next review. It does not invent a practice item.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recording is bounded before it reaches Azure
&lt;/h2&gt;

&lt;p&gt;Assessment audio is 16 kHz, 16-bit, mono. The cap is 15 seconds, which is also the upload cap: 44 bytes of WAV header plus one second of samples times 15. Recognition failures are mapped to a short message. NoMatch, initial silence, and babble each say what to change. They do not become a score of 0 mixed in with a real attempt.&lt;/p&gt;

&lt;p&gt;The model recording is separate. It is Azure neural TTS with the target IPA written into SSML as a phoneme element, American English only. The voice the learner imitates and the score of their own take do not share a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would not do again
&lt;/h2&gt;

&lt;p&gt;I would not store the raw assessment document on the client and score it there. The pass line, the mark line, and the next review date are the product. They stay on the server.&lt;/p&gt;

&lt;p&gt;I would also not show AccuracyScore next to every phoneme. The learner needs the sound that failed, then another try. The number is for the scheduler.&lt;/p&gt;

&lt;p&gt;Vocalace is the app this runs in: one American English word, the missed phoneme marked, and the review rules above. A new account can practice for 7 days.&lt;/p&gt;

</description>
      <category>api</category>
      <category>azure</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>I needed audio in Bun, so I skipped fluent-ffmpeg</title>
      <dc:creator>Kenji</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:34:18 +0000</pubDate>
      <link>https://dev.to/kenjiginjo/i-needed-audio-in-bun-so-i-skipped-fluent-ffmpeg-5g83</link>
      <guid>https://dev.to/kenjiginjo/i-needed-audio-in-bun-so-i-skipped-fluent-ffmpeg-5g83</guid>
      <description>&lt;p&gt;Bun's Node compatibility layer will run &lt;code&gt;fluent-ffmpeg&lt;/code&gt;. That is the honest answer, and for a lot of apps it is good enough.&lt;/p&gt;

&lt;p&gt;I still ended up writing a tiny wrapper around &lt;code&gt;Bun.spawn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The jobs I actually had were boring: transcode a file, pipe a stream, turn whatever the user uploaded into 16 kHz mono PCM for Whisper. &lt;code&gt;fluent-ffmpeg&lt;/code&gt; can do all of that, plus video, filters, and a fluent builder I was not going to use. In a Bun-only service I also did not want "it works through the Node compat layer" as the whole story.&lt;/p&gt;

&lt;p&gt;So &lt;a href="https://github.com/KenjiGinjo/bun-ffmpeg" rel="noopener noreferrer"&gt;bun-ffmpeg&lt;/a&gt; is intentionally small. Audio only. System &lt;code&gt;ffmpeg&lt;/code&gt; / &lt;code&gt;ffprobe&lt;/code&gt;. Typed helpers. No bundled binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;Install ffmpeg yourself (&lt;code&gt;brew install ffmpeg&lt;/code&gt;, &lt;code&gt;apt install ffmpeg&lt;/code&gt;, or set &lt;code&gt;FFMPEG_PATH&lt;/code&gt; in Docker). Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun add bun-ffmpeg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;audio&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="s2"&gt;bun-ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input.mp3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;output.aac&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="na"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aac&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bitrate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;192k&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44100&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 Whisper path is one function. It always emits 16 kHz, mono, &lt;code&gt;pcm_s16le&lt;/code&gt; WAV:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;audioWav&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="s2"&gt;bun-ffmpeg&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;wav&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;audioWav&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;Uint8Array&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;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input.mp3&lt;/span&gt;&lt;span class="dl"&gt;"&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;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input.16k.wav&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wav&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streams work the same way: &lt;code&gt;ReadableStream&lt;/code&gt; in, file or chunk callbacks out. If the binary is missing you get an error that tells you to install ffmpeg instead of a raw &lt;code&gt;ENOENT&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not fluent-ffmpeg
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;fluent-ffmpeg&lt;/th&gt;
&lt;th&gt;bun-ffmpeg&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Node, works on Bun via compat&lt;/td&gt;
&lt;td&gt;Bun only (&lt;code&gt;Bun.spawn&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surface&lt;/td&gt;
&lt;td&gt;Video, filters, graphs&lt;/td&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary&lt;/td&gt;
&lt;td&gt;Still needs ffmpeg on the machine&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shape&lt;/td&gt;
&lt;td&gt;Fluent builder&lt;/td&gt;
&lt;td&gt;A handful of functions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are muxing video or building filter graphs, keep fluent-ffmpeg. This package is for the case where the runtime is Bun and the media is audio.&lt;/p&gt;

&lt;p&gt;Under the hood there is no magic: spawn &lt;code&gt;ffmpeg&lt;/code&gt;, pass &lt;code&gt;-i&lt;/code&gt;, codec, bitrate, channels, sample rate, and either a path or &lt;code&gt;pipe:0&lt;/code&gt; / &lt;code&gt;pipe:1&lt;/code&gt;. The wrapper exists so you do not re-derive those flags in every script.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js&lt;/li&gt;
&lt;li&gt;Video&lt;/li&gt;
&lt;li&gt;A fluent API&lt;/li&gt;
&lt;li&gt;Shipping ffmpeg inside the npm tarball&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v0.3.0 added &lt;code&gt;FFMPEG_PATH&lt;/code&gt; / &lt;code&gt;FFPROBE_PATH&lt;/code&gt; for containers, and &lt;code&gt;FfmpegNotFoundError&lt;/code&gt; when the binary is not there.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/KenjiGinjo/bun-ffmpeg" rel="noopener noreferrer"&gt;github.com/KenjiGinjo/bun-ffmpeg&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;npm: &lt;code&gt;bun add bun-ffmpeg&lt;/code&gt;&lt;/p&gt;

</description>
      <category>bunjs</category>
      <category>ffmpeg</category>
      <category>typescript</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
