'use strict'; /** * TonePerfect pronunciation assessment API client (Node 18+, no dependencies). * * Wraps the public endpoints of https://api.toneperfect.app: * GET /v1/capabilities -> languages served, limits, model version * POST /v1/assess -> score one scripted recording * POST /v1/lint -> check a reference text before recording (free, no key) * GET /v1/status -> synthetic engine probe summary (free, no key) * * Accounts, API keys and billing are managed in the web dashboard * (session-based routes, deliberately not wrapped). Docs: https://api.toneperfect.app/docs */ const { randomUUID } = require('node:crypto'); const { readFile } = require('node:fs/promises'); const VERSION = '0.2.0'; const DEFAULT_BASE_URL = 'https://api.toneperfect.app'; const TASKS = Object.freeze(['general', 'connected_speech', 'isolated_word']); /** Values of `decision` on words and phones. review / uncertain / no_correction are the feedback * decisions; unassessable, partial and legacy_correction also occur. Treat unknown values as "no decision". */ const DECISIONS = Object.freeze(['review', 'uncertain', 'no_correction', 'unassessable', 'partial', 'legacy_correction']); /** Error codes after which the SAME Idempotency-Key must be retried: nothing was finalized. */ const RETRY_SAME_KEY_CODES = new Set(['rate_limit', 'account_busy', 'gateway_busy', 'decoder_busy', 'audio_decoder_unavailable', 'upload_timeout', 'request_pending', 'timeout', 'network_error']); /** Error codes that mean the key is spent: start a new attempt with a new key. */ const NEW_KEY_CODES = new Set(['idempotency_expired', 'idempotency_conflict']); const IDEMPOTENCY_RE = /^[A-Za-z0-9_.:-]{8,128}$/; /** * A fresh Idempotency-Key for one recording attempt. Keep the same key while the result is * unknown or pending (error.retryable); use a new key for a different attempt or after a * definite failed response (error.needsNewKey). Never generate one inside an automatic retry loop. */ function newIdempotencyKey(prefix = 'tp') { const key = `${prefix}-${randomUUID()}`; if (!IDEMPOTENCY_RE.test(key)) throw new Error('prefix must be letters, digits, dots, dashes, underscores or colons'); return key; } /** * Any non-2xx response, or a transport failure (status 0). `code`/`detail` come from the * API's `error` object. What to do next is decided by the BODY, not the status: * - finalized: the failure was stored under your Idempotency-Key (body has a `req_…` `id` * and `billing` with credits 0, e.g. engine_unavailable / request_interrupted after the * request was accepted). The same key replays this failure for 24 h → needsNewKey. * - retryable: nothing was finalized; retry later with the SAME key (rate_limit, account_busy, * gateway_busy, decoder_busy, audio_decoder_unavailable, upload_timeout, request_pending, * transport timeout / network_error, any non-finalized 5xx such as an HTML or empty 502/504). * - everything else (invalid_api_key, insufficient_credits, invalid_audio, …) needs a fix on your side. */ class TonePerfectError extends Error { constructor(status, code, message, requestId = null, retryAfter = null, body = null) { super(`${status} ${code}: ${message}`); this.name = 'TonePerfectError'; this.status = status; this.code = code; this.detail = message; this.requestId = requestId; this.retryAfter = retryAfter; this.body = body && typeof body === 'object' ? body : {}; /** `req_…` result id when the failure was finalized under your Idempotency-Key. */ this.id = typeof this.body.id === 'string' ? this.body.id : null; this.billing = this.body.billing && typeof this.body.billing === 'object' ? this.body.billing : null; } /** The failure is a stored result for this key (id + billing present). */ get finalized() { return this.id !== null && this.billing !== null; } /** Start a new attempt with a new Idempotency-Key; the old key replays this response. */ get needsNewKey() { return this.finalized || NEW_KEY_CODES.has(this.code); } /** Retry later with the SAME Idempotency-Key: nothing was finalized. */ get retryable() { if (this.finalized || NEW_KEY_CODES.has(this.code)) return false; return RETRY_SAME_KEY_CODES.has(this.code) || [408, 429, 502, 503, 504].includes(this.status); } } /** One POST /v1/assess result: `data` is the untouched JSON body; the getters mirror its documented fields. */ class Assessment { constructor(data, status, headers) { this.data = data; this.status = status; this.headers = headers; this.requestId = headers.get('x-request-id') || null; this.replayed = (headers.get('idempotent-replayed') || '').toLowerCase() === 'true'; } /** false when the audio could not be meaningfully scored; never show that as a zero score. See `reason`. */ get assessable() { return Boolean(this.data.assessable); } get reason() { return this.data.reason ?? null; } /** 0-100 display grades per dimension; null = not supported by this pack. */ get scores() { return this.data.scores || {}; } get overall() { return this.scores.overall ?? null; } /** Per word/character: score, start_ms, end_ms, phones[] {expected, heard, score, decision} (see DECISIONS). */ get words() { return this.data.words || []; } /** {credits: charged, balance_credits: remaining}; failed or unassessable requests charge 0. */ get billing() { return this.data.billing || {}; } get modelVersion() { return this.data.model_version ?? null; } get id() { return this.data.id || this.requestId; } /** true for a fixed example response from a tp_test_ key (no audio scored, nothing charged). */ get test() { return this.data.test === true; } } async function toBlob(audio, contentType) { if (audio instanceof Blob) return audio; if (typeof audio === 'string' || (audio && typeof audio === 'object' && 'href' in audio && audio.protocol === 'file:')) { return new Blob([await readFile(audio)], { type: contentType }); } if (audio instanceof ArrayBuffer) return new Blob([audio], { type: contentType }); if (ArrayBuffer.isView(audio)) return new Blob([audio], { type: contentType }); throw new TypeError('audio must be a file path, Buffer/Uint8Array, ArrayBuffer or Blob'); } class TonePerfect { /** * @param {object} [options] * @param {string} [options.apiKey] secret key (tp_live_...). Defaults to process.env.TONEPERFECT_API_KEY. * @param {string} [options.baseUrl] API origin. api.toneperfect.app sits behind a proxy with a 26 s request * timeout; if your assessments run longer, ask nik@toneperfect.app for the direct gateway address. * @param {number} [options.timeoutMs] per-request timeout (default 90 000). * @param {typeof fetch} [options.fetch] custom fetch (tests, proxies). */ constructor(options = {}) { this.apiKey = options.apiKey ?? process.env.TONEPERFECT_API_KEY ?? ''; this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); this.timeoutMs = options.timeoutMs ?? 90_000; this.fetch = options.fetch || globalThis.fetch; this.userAgent = options.userAgent || `toneperfect-node/${VERSION}`; if (typeof this.fetch !== 'function') throw new Error('Node 18+ (global fetch) is required, or pass options.fetch.'); } /** GET /v1/capabilities — no key needed. languages[] (locale, status, dimensions, limitations), limits, model_version. */ async capabilities() { const { status, headers, text } = await this._send('GET', '/v1/capabilities', null, {}); return parseJson(status, headers, text); } /** * POST /v1/lint — check a reference text against the engine's rules before anyone records it. * Free, no key. Returns {ok, language, issues: [{code, message, field, severity}], units}. * A text that passes can still be refused by a newer engine; the assess response stays authoritative. */ async lint(text, { language = 'zh-CN', pinyin = '', task = 'general' } = {}) { const { status, headers, text: body } = await this._send('POST', '/v1/lint', JSON.stringify({ text: text || '', language, pinyin: pinyin || '', task }), { 'Content-Type': 'application/json' }); return parseJson(status, headers, body); } /** GET /v1/status — outcome of the gateway's synthetic engine probe (status, last_check, last_24h, last_7d). */ async status() { const { status, headers, text } = await this._send('GET', '/v1/status', null, {}); return parseJson(status, headers, text); } /** * POST /v1/assess — score one scripted recording. * @param {string|URL|Buffer|Uint8Array|ArrayBuffer|Blob} audio up to 30 s / 10 MB (WAV, MP3, M4A, OGG, WebM...) * @param {object} fields * @param {string} [fields.text] reference text the learner read (required unless pinyin is given) * @param {string} [fields.language] locale such as zh-CN, en-US, fr-FR (default zh-CN) * @param {string} [fields.pinyin] explicit Mandarin reading with numbered tones, e.g. "ni3 hao3" * @param {'general'|'connected_speech'|'isolated_word'} [fields.task] default general * @param {string} [fields.idempotencyKey] unique per attempt; generated when omitted. If a network error * prevents you from receiving a result, retry with the same key and identical inputs: a completed request * is returned without charging again, including a completed failure. Use a new key for a different attempt * or after a definite failed response (error.needsNewKey); keep it while the outcome is unknown (error.retryable). * @param {string} [fields.filename] default recording.wav * @param {string} [fields.contentType] default application/octet-stream * @param {boolean} [fields.compact] ask for the stable compact schema (toneperfect.compact.v1) * @param {string} [fields.testOutcome] with a tp_test_ key only: success, unassessable, invalid_assessment, * engine_unavailable, subscription_required, insufficient_credits, account_busy or rate_limit * @returns {Promise} */ async assess(audio, fields = {}) { const { text, language = 'zh-CN', pinyin, task = 'general', filename = 'recording.wav', contentType = 'application/octet-stream' } = fields; if (!this.apiKey) throw new TonePerfectError(401, 'invalid_api_key', 'Set apiKey or the TONEPERFECT_API_KEY environment variable.'); if (!TASKS.includes(task)) throw new RangeError(`task must be one of ${TASKS.join(', ')}`); if (!text && !pinyin) throw new RangeError('Provide text (or pinyin for Mandarin).'); const key = fields.idempotencyKey || newIdempotencyKey(); if (!IDEMPOTENCY_RE.test(key)) throw new RangeError('idempotencyKey must be 8-128 letters, digits, dots, dashes, underscores or colons'); const form = new FormData(); form.append('audio', await toBlob(audio, contentType), filename); if (text) form.append('text', text); if (pinyin) form.append('pinyin', pinyin); form.append('language', language); form.append('task', task); const extra = {}; if (fields.compact) extra['TonePerfect-Response'] = 'compact'; if (fields.testOutcome) extra['TonePerfect-Test-Outcome'] = String(fields.testOutcome); const { status, headers, text: body } = await this._send('POST', '/v1/assess', form, { Authorization: `Bearer ${this.apiKey}`, 'Idempotency-Key': key, ...extra, }); return new Assessment(parseJson(status, headers, body), status, headers); } async _send(method, path, body, headers) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { const response = await this.fetch(this.baseUrl + path, { method, body, signal: controller.signal, headers: { Accept: 'application/json', 'User-Agent': this.userAgent, ...headers }, }); return { status: response.status, headers: response.headers, text: await response.text() }; } catch (error) { if (error && error.name === 'AbortError') throw new TonePerfectError(0, 'timeout', `No response within ${this.timeoutMs} ms; the outcome is unknown, retry with the same Idempotency-Key.`); throw new TonePerfectError(0, 'network_error', `Could not reach ${this.baseUrl}: ${error && error.message}`); } finally { clearTimeout(timer); } } } function parseJson(status, headers, text) { let data = null; try { data = text ? JSON.parse(text) : {}; } catch { data = null; } if (status >= 200 && status < 300 && data && typeof data === 'object') return data; const retryAfter = headers.get('retry-after') ? Number(headers.get('retry-after')) || null : null; if (data && typeof data === 'object' && data.error && typeof data.error === 'object') { throw new TonePerfectError(status, String(data.error.code || 'request_error'), String(data.error.message || ''), data.request_id || headers.get('x-request-id') || null, retryAfter, data); } throw new TonePerfectError(status, 'http_error', (text || '').slice(0, 200).trim() || `HTTP ${status}`, headers.get('x-request-id') || null, retryAfter); } module.exports = { TonePerfect, TonePerfectError, Assessment, newIdempotencyKey, DEFAULT_BASE_URL, TASKS, DECISIONS, RETRY_SAME_KEY_CODES, VERSION };