From 42ac63adc6a9b3db949b29c0fac3e6e49d2a5b80 Mon Sep 17 00:00:00 2001 From: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:41:36 +0700 Subject: [PATCH] support xaiTTS (#147) --- lib/synth-audio.js | 75 +++++++++++++++++++++++++++++++++++++++++++++- test/synth.js | 35 ++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/lib/synth-audio.js b/lib/synth-audio.js index 7b4dd39..fba9dd8 100644 --- a/lib/synth-audio.js +++ b/lib/synth-audio.js @@ -80,7 +80,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc logger = logger || noopLogger; assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs', - 'whisper', 'deepgram', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf'].includes(vendor) || + 'whisper', 'deepgram', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf', 'xai'].includes(vendor) || vendor.startsWith('custom'), `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`); if ('google' === vendor) { @@ -124,6 +124,8 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc if (!credentials.deepgram_tts_uri) { assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgram is used'); } + } else if ('xai' === vendor) { + assert.ok(credentials.api_key, 'synthAudio requires api_key when xai is used'); } else if ('cartesia' === vendor) { assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used'); assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used'); @@ -228,6 +230,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc audioData = await synthDeepgram(logger, {credentials, stats, model, key, text, renderForCaching, disableTtsStreaming, disableTtsCache}); break; + case 'xai': + audioData = await synthXai(logger, { + credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, + disableTtsCache}); + break; case 'resemble': audioData = await synthResemble(logger, { credentials, stats, voice, key, text, options, renderForCaching, disableTtsStreaming, disableTtsCache}); @@ -1133,6 +1140,72 @@ const synthDeepgram = async(logger, {credentials, stats, model, key, text, rende } }; +const synthXai = async(logger, { + credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache +}) => { + const {api_key, api_uri, options: credOpts} = credentials; + const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}'); + const speed = opts.speed; + if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) { + let params = '{'; + params += `api_key=${api_key}`; + params += `,playback_id=${key}`; + params += ',vendor=xai'; + if (voice) params += `,voice=${voice}`; + if (language) params += `,language=${language}`; + if (speed !== null && speed !== undefined) params += `,speed=${speed}`; + if (opts.optimize_streaming_latency != null) { + params += `,optimize_streaming_latency=${opts.optimize_streaming_latency}`; + } + if (opts.text_normalization != null) params += `,text_normalization=${opts.text_normalization}`; + params += `,write_cache_file=${disableTtsCache ? 0 : 1}`; + if (api_uri) params += `,endpoint=${api_uri}`; + params += '}'; + + return { + filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`, + servedFromCache: false, + rtt: 0 + }; + } + try { + const post = bent(`https://${api_uri || 'api.x.ai'}`, 'POST', 'buffer', { + 'Authorization': `Bearer ${api_key}`, + 'Content-Type': 'application/json' + }); + const audioContent = await post('/v1/tts', { + text, + language: language || 'auto', + ...(voice && {voice_id: voice}), + ...(speed !== null && speed !== undefined && {speed}), + ...(opts.optimize_streaming_latency != null && {optimize_streaming_latency: opts.optimize_streaming_latency}), + ...(opts.text_normalization != null && {text_normalization: opts.text_normalization}), + output_format: { + codec: 'wav', + sample_rate: 8000 + } + }); + return { + audioContent, + extension: 'wav', + sampleRate: 8000 + }; + } catch (err) { + // xAI errors are JSON {code, error} - read the body so the surfaced message isn't 'undefined' + if (err.name === 'StatusError' && typeof err.text === 'function') { + try { + const body = await err.text(); + if (body) err.message = body; + } catch (readErr) { + logger.info({readErr}, 'synth xai: failed to read error response body'); + } + } + logger.info({err}, 'synth xai returned error'); + stats.increment('tts.count', ['vendor:xai', 'accepted:no']); + throw err; + } +}; + const synthCartesia = async(logger, { credentials, options, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache }) => { diff --git a/test/synth.js b/test/synth.js index 9b2f637..4d7b660 100644 --- a/test/synth.js +++ b/test/synth.js @@ -1118,6 +1118,41 @@ test('Deepgram speech synth tests', async(t) => { client.quit(); }) +test('xai speech synth tests', async(t) => { + const fn = require('..'); + const {synthAudio, client} = fn(opts, logger); + + if (!process.env.XAI_API_KEY) { + t.pass('skipping xai speech synth tests - no XAI_API_KEY'); + return t.end(); + } + const text = 'Hi there and welcome to jambones!'; + try { + const opts = await synthAudio(stats, { + vendor: 'xai', + credentials: { + api_key: process.env.XAI_API_KEY, + options: JSON.stringify({ + voice: process.env.XAI_VOICE || 'eve', + speed: 1.0, + optimize_streaming_latency: 1, + text_normalization: true + }) + }, + language: 'en', + voice: process.env.XAI_VOICE || 'eve', + text, + renderForCaching: true + }); + t.ok(!opts.servedFromCache && opts.filePath, `successfully synthesized xai audio to ${opts.filePath}`); + + } catch (err) { + console.error(JSON.stringify(err)); + t.end(err); + } + client.quit(); +}); + test('TTS Cache tests', async(t) => { const fn = require('..'); const {purgeTtsCache, getTtsSize, client} = fn(opts, logger);