diff --git a/lib/synth-audio.js b/lib/synth-audio.js index 953600c..bd8c38a 100644 --- a/lib/synth-audio.js +++ b/lib/synth-audio.js @@ -80,7 +80,8 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc logger = logger || noopLogger; assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs', - 'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf', 'xai'] + 'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'nineninesix', 'inworld', 'resemble', + 'murf', 'xai'] .includes(vendor) || vendor.startsWith('custom'), `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`); @@ -135,6 +136,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc } 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'); + } else if ('nineninesix' === vendor) { + assert.ok(voice, 'synthAudio requires voice when nineninesix is used'); + assert.ok(credentials.api_key, 'synthAudio requires api_key when nineninesix is used'); + assert.ok(credentials.model_id, 'synthAudio requires model_id when nineninesix is used'); } else if ('murf' === vendor) { assert.ok(voice, 'synthAudio requires voice when murf is used'); assert.ok(credentials.api_key, 'synthAudio requires api_key when murf is used'); @@ -212,6 +217,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache}); break; + case 'nineninesix': + audioData = await synthNineninesix(logger, { + credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, + disableTtsCache}); + break; case 'inworld': audioData = await synthInworld(logger, { credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, @@ -1423,6 +1433,61 @@ const synthCartesia = async(logger, { }; +/* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back + (mp3 is rejected), so the cache render asks for wav rather than mp3. */ +const synthNineninesix = async(logger, { + credentials, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache +}) => { + const {api_key, model_id} = credentials; + + /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */ + if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) { + let params = '{'; + params += `api_key=${api_key}`; + params += `,playback_id=${key}`; + params += `,model_id=${model_id}`; + params += ',vendor=nineninesix'; + params += `,voice=${voice}`; + params += `,write_cache_file=${disableTtsCache ? 0 : 1}`; + if (language) params += `,language=${language}`; + params += '}'; + + return { + filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`, + servedFromCache: false, + rtt: 0 + }; + } + + try { + const sampleRate = 8000; + const post = bent('https://api.nineninesix.ai', 'POST', 'buffer', { + 'Authorization': `Bearer ${api_key}`, + 'Content-Type': 'application/json' + }); + const audioContent = await post('/tts/bytes', { + model_id, + transcript: text, + voice: {mode: 'id', id: voice}, + ...(language && {language}), + output_format: { + container: 'wav', + encoding: 'pcm_s16le', + sample_rate: sampleRate + } + }); + return { + audioContent, + extension: 'wav', + sampleRate + }; + } catch (err) { + logger.info({err}, 'synth nineninesix returned error'); + stats.increment('tts.count', ['vendor:nineninesix', 'accepted:no']); + throw err; + } +}; + const synthResemble = async(logger, { credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache }) => { diff --git a/test/synth.js b/test/synth.js index 1e86807..ecab513 100644 --- a/test/synth.js +++ b/test/synth.js @@ -1058,6 +1058,36 @@ test('Cartesia speech synth tests', async(t) => { client.quit(); }); +test('nineninesix speech synth tests', async(t) => { + const fn = require('..'); + const {synthAudio, client} = fn(opts, logger); + + if (!process.env.NINENINESIX_API_KEY) { + t.pass('skipping nineninesix speech synth tests since NINENINESIX_API_KEY is not provided'); + return t.end(); + } + const text = 'Hi there and welcome to jambones! ' + Date.now(); + try { + const opts = await synthAudio(stats, { + vendor: 'nineninesix', + credentials: { + api_key: process.env.NINENINESIX_API_KEY, + model_id: 'gepard-1.0' + }, + language: 'en', + voice: '3ad7a827-7fd1-4954-bf35-47d4cc33d9ed', + text, + renderForCaching: true + }); + t.ok(!opts.servedFromCache, `successfully synthed nineninesix audio to ${opts.filePath}`); + + } catch (err) { + console.error(JSON.stringify(err)); + t.end(err); + } + client.quit(); +}); + test('inworld speech synth', async(t) => { const fn = require('..'); const {synthAudio, client} = fn(opts, logger);