<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Nik builds TonePerfect]]></title><description><![CDATA[Nik builds TonePerfect]]></description><link>https://niktoneperfect.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 09:55:49 GMT</lastBuildDate><atom:link href="https://niktoneperfect.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Scoring a learner's Japanese with a pronunciation API, and the wrong-sentence case]]></title><description><![CDATA[Disclosure: I build the TonePerfect API used in this post. It has a free allowance (50 credits per new account) and paid plans. I'm writing this up because…]]></description><link>https://niktoneperfect.hashnode.dev/scoring-a-learner-s-japanese-with-a-pronunciation-api-and-the-wrong-sentence-case</link><guid isPermaLink="true">https://niktoneperfect.hashnode.dev/scoring-a-learner-s-japanese-with-a-pronunciation-api-and-the-wrong-sentence-case</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[api]]></category><category><![CDATA[Japanese,]]></category><dc:creator><![CDATA[Nik]]></dc:creator><pubDate>Tue, 27 Oct 2026 14:10:43 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I build the TonePerfect API used in this post. It has a free allowance (50 credits per new account) and paid plans. I'm writing this up because the most useful part turned out to be a failure case, not the happy path. The write-up was drafted with an AI assistant; every command and output below was run against the live API on 7 October 2026 (the 30 s limit in that output has since been raised to 120 s).</em></p>
<h2>What we're building</h2>
<p>A Node.js script that:</p>
<ol>
<li>checks which languages a pronunciation-scoring API supports and what its known limits are,</li>
<li>sends a learner's recording of a <strong>known</strong> Japanese sentence,</li>
<li>prints a score for the whole sentence and for each phrase, with timestamps and a "should we correct this?" decision,</li>
<li>and then shows what happens when the learner reads the wrong sentence, which is where the interesting design decision is.</li>
</ol>
<p>This is <strong>scripted speech</strong> scoring: you tell the API what the learner was supposed to say. It's not speech-to-text.</p>
<h2>Prerequisites</h2>
<ul>
<li>Node.js 18+ (uses the built-in <code>fetch</code>, <code>FormData</code> and <code>Blob</code>)</li>
<li>An API key from <a href="https://api.toneperfect.app/signup">https://api.toneperfect.app/signup</a> (new accounts get 50 free credits, no card)</li>
<li>A short recording: WAV, MP3, M4A, Ogg or WebM, 0.15 to 120 seconds, under 10 MB</li>
</ul>
<p>If you don't have a learner recording handy, macOS text-to-speech is enough to test the wiring:</p>
<pre><code class="language-bash">say -v Kyoko "日本語を勉強しています" -o ja.aiff
ffmpeg -y -i ja.aiff -ar 16000 -ac 1 ja.wav
</code></pre>
<p>A TTS voice is close to native, so expect high scores. It's only for checking that the plumbing works.</p>
<h2>Step 1: read the capabilities first (no key needed)</h2>
<pre><code class="language-js">const caps = await (await fetch('https://api.toneperfect.app/v1/capabilities')).json();
const ja = caps.languages.find((l) =&gt; l.locale === 'ja-JP');
console.log(ja.status, ja.dimensions, ja.limitations);
</code></pre>
<p>Output on 7 October 2026 (trimmed):</p>
<pre><code class="language-plaintext">experimental [ 'pronunciation', 'completeness' ]
New pack (beta): native-reference scale, not yet calibrated on graded learner recordings;
a different sentence can still score around 30, and kanji are read with a dictionary that
can miss context-dependent readings (今日は). Text is split into bunsetsu-like units;
pitch accent is not scored.
</code></pre>
<p>Worth reading before you put a number in front of a learner:</p>
<ul>
<li>Japanese is a beta pack.</li>
<li>It scores pronunciation and completeness. <strong>It does not score pitch accent.</strong></li>
<li>Kanji readings come from a dictionary, so pass context-dependent words (今日は) in kana.</li>
<li>The "words" you get back are phrase-like units (日本語を / 勉強しています), not dictionary words.</li>
</ul>
<h2>Step 2: the scoring script</h2>
<pre><code class="language-js">// assess-ja.mjs
// Node 18+. Run: TONEPERFECT_API_KEY=tp_live_... node assess-ja.mjs ja.wav "日本語を勉強しています"
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';

const API = 'https://api.toneperfect.app';
const [file = 'ja.wav', text = '日本語を勉強しています'] = process.argv.slice(2);

// 1) Is the Japanese pack available? (no key needed)
const caps = await (await fetch(`${API}/v1/capabilities`)).json();
const ja = caps.languages.find((l) =&gt; l.locale === 'ja-JP');
if (!ja?.available) throw new Error('ja-JP is not available right now');
console.log(`ja-JP: ${ja.status}, dimensions=${ja.dimensions.join(',')}, max ${ja.max_duration_seconds}s`);

// 2) Send the recording plus the text the learner was asked to read
const form = new FormData();
form.append('audio', new Blob([await readFile(file)], { type: 'audio/wav' }), file);
form.append('text', text);
form.append('language', 'ja-JP');

const res = await fetch(`${API}/v1/assess`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TONEPERFECT_API_KEY}`,
    // One key per recording attempt; reuse it only to retry the same attempt
    'Idempotency-Key': `ja-${randomUUID()}`,
  },
  body: form,
});
const r = await res.json();
if (!res.ok) throw new Error(`${res.status} ${r.error?.code}: ${r.error?.message}`);

// 3) Silence etc. comes back as 200 with assessable=false (and is not charged)
if (!r.assessable) {
  console.log(`not assessable: ${r.reason}`);
  process.exit(0);
}

console.log(`overall ${r.scores.overall}  pronunciation ${r.scores.pronunciation}  completeness ${r.scores.integrity}`);
for (const w of r.words) {
  // decision is review / uncertain / no_correction
  const flag = w.decision === 'review' ? '  &lt;- review' : '';
  console.log(`${w.word}\t${w.score}\t${w.start_ms}-${w.end_ms}ms\t${w.decision}${flag}`);
}
console.log('credits charged:', r.billing.credits, 'free left:', r.billing.free_credits);
</code></pre>
<p>Three things that tripped me up the first time:</p>
<ul>
<li>Don't set <code>Content-Type</code> yourself. Pass the <code>FormData</code> and let <code>fetch</code> add the multipart boundary.</li>
<li><code>Idempotency-Key</code> is required. Reuse the same key only when retrying the <em>same</em> recording after a network error, so a retry can't charge twice.</li>
<li>Keep the key on your server. A browser or mobile app should call your backend, which calls the API.</li>
</ul>
<h2>Step 3: the happy path</h2>
<p>Reading the right sentence:</p>
<pre><code class="language-plaintext">ja-JP: experimental, dimensions=pronunciation,completeness, max 30s
overall 83.1  pronunciation 90.6  completeness 91.6
日本語を	95.5	0-676ms	no_correction
勉強しています	86.6	676-2016ms	no_correction
credits charged: 1 free left: 49
</code></pre>
<p>Each phrase comes with <code>start_ms</code> / <code>end_ms</code>, so "replay just this part" or highlighting the phrase is easy to build.</p>
<p>The 0–100 <code>score</code> is a display grade, not a "% native" figure. Whether to tell a learner to fix something is meant to come from <code>decision</code> (for example, only interrupt on <code>review</code>), not from a score threshold you pick.</p>
<h2>Step 4: the wrong sentence (the useful part)</h2>
<p>Same <code>text</code> ("日本語を勉強しています"), but the recording says "ちょっと待ってください" (a completely different sentence):</p>
<pre><code class="language-plaintext">overall 34.4  pronunciation 54.1  completeness 66.2
日本語を	85.1	0-445ms	no_correction
勉強しています	64.9	445-1435ms	no_correction
</code></pre>
<p>The overall score drops to 34.4, but <strong>the first phrase still scores 85.1</strong> even though it was never said. That's exactly the "a different sentence can still score around 30" caveat from the capabilities response. If your UI only shows per-phrase scores, a learner who read the wrong line could see a green "日本語を".</p>
<p>So the order of checks I'd use:</p>
<ol>
<li><code>assessable</code> first.</li>
<li>Then <code>scores.overall</code> and <code>scores.integrity</code> (completeness) to decide whether they read the right sentence at all. Pick the threshold from your own recordings.</li>
<li>Only then show per-phrase scores and <code>decision</code>s.</li>
</ol>
<h2>Japanese-specific notes</h2>
<ul>
<li>Pitch accent (高低) is not scored; consonants, vowels and length are.</li>
<li>Write numbers as words.</li>
<li>Up to 120 s and 10 MB per recording. One credit per started 15 s of audio.</li>
<li>Unassessable requests (<code>assessable: false</code>) are not charged.</li>
<li>Audio is deleted after scoring and not used for training.</li>
</ul>
<h2>Wrapping up</h2>
<p>The Japanese pack is still beta and not yet calibrated on graded learner recordings. Its limits show up in <code>/v1/capabilities</code> and in the scores themselves, so most of the work on the integration side is choosing the order of checks, as in step 4.</p>
<hr />
<p>TonePerfect API (I'm the maker): <a href="https://api.toneperfect.app">https://api.toneperfect.app</a> · docs: <a href="https://api.toneperfect.app/docs">https://api.toneperfect.app/docs</a> · 50 free credits per new account. If you get a score that looks wrong, I'd like to see it in the comments.</p>
]]></content:encoded></item></channel></rss>